From 950b6e3dc321006345ae427e30eee6c561dcab5b Mon Sep 17 00:00:00 2001 From: Steve Sewell Date: Sat, 8 Aug 2026 13:53:01 -0700 Subject: [PATCH 01/26] feat: add opt-in chat-first workbench --- .agents/skills/chat-first-workbench/SKILL.md | 54 + .changeset/chat-first-workbench.md | 7 + docs/environment-variables.md | 1 + package.json | 1 + packages/code-agents-ui/src/CodeAgentsApp.tsx | 130 ++ .../code-agents-ui/src/SessionWatchPanel.tsx | 193 +++ packages/code-agents-ui/src/index.ts | 1 + packages/code-agents-ui/src/styles.css | 156 +++ packages/code-agents-ui/src/types.ts | 3 + packages/core/src/client/agent-chat/index.ts | 68 + .../src/client/chat-first-agent-activity.tsx | 209 +++ .../chat-first-surface-panel-toggle.tsx | 23 + packages/core/src/client/chat-first.spec.ts | 294 +++++ packages/core/src/client/chat-first.ts | 1125 +++++++++++++++++ packages/core/src/client/chat/runtime.spec.ts | 112 ++ packages/core/src/client/chat/runtime.ts | 85 ++ packages/desktop-app/DESIGN.md | 32 + packages/desktop-app/src/main/app-store.ts | 1 + packages/desktop-app/src/main/index.ts | 11 +- packages/desktop-app/src/renderer/App.tsx | 62 +- .../src/renderer/components/AppSettings.tsx | 38 + .../src/renderer/components/AppWebview.tsx | 29 +- .../src/renderer/components/CodeAgentsHub.tsx | 546 +++++++- .../components/CodeProviderSettings.tsx | 213 ++++ .../components/DesktopChatFirstAppPane.tsx | 60 + .../DesktopChatFirstBrowserPane.tsx | 101 ++ .../components/DesktopChatFirstRail.tsx | 312 +++++ .../DesktopChatFirstSurfaceTabs.tsx | 191 +++ .../src/renderer/components/Sidebar.tsx | 50 +- packages/desktop-app/src/renderer/global.d.ts | 3 + packages/desktop-app/src/renderer/shell.css | 1023 ++++++++++++++- packages/dispatch/DESIGN.md | 43 + .../src/components/layout/Layout.spec.tsx | 1 + .../dispatch/src/components/layout/Layout.tsx | 884 ++++++++++++- .../layout/chat-first-agents-pane.tsx | 74 ++ .../components/layout/chat-first-app-pane.tsx | 184 +++ .../layout/chat-first-browser-pane.tsx | 180 +++ .../components/layout/chat-first-context.tsx | 40 + .../layout/chat-first-session-watch-pane.tsx | 110 ++ .../layout/chat-first-surface-tabs.tsx | 270 ++++ .../layout/chat-first-workspace-apps-rail.tsx | 375 ++++++ .../dispatch/src/routes/pages/settings.tsx | 72 ++ packages/dispatch/src/styles/dispatch.css | 423 +++++++ packages/mobile-app/DESIGN.md | 24 + packages/mobile-app/app/(tabs)/chat.tsx | 13 + packages/mobile-app/app/(tabs)/settings.tsx | 73 ++ packages/mobile-app/components/AppCard.tsx | 3 +- .../components/chat/ChatFirstMobileRail.tsx | 121 ++ packages/mobile-app/lib/chat-first-mode.ts | 88 ++ packages/shared-app-config/index.ts | 2 + .../src/chat-history/ChatHistoryList.tsx | 5 + scripts/qa-chat-first-workbench-smoke.ts | 442 +++++++ templates/dispatch/app/chat-first-pages.ts | 747 +++++++++++ templates/dispatch/app/i18n-data.ts | 181 +++ templates/dispatch/app/i18n/zh-TW.ts | 13 + templates/dispatch/app/routes/settings.tsx | 63 +- ...-keeps-conversations-central-while-open.md | 6 + 57 files changed, 9422 insertions(+), 149 deletions(-) create mode 100644 .agents/skills/chat-first-workbench/SKILL.md create mode 100644 .changeset/chat-first-workbench.md create mode 100644 packages/code-agents-ui/src/SessionWatchPanel.tsx create mode 100644 packages/core/src/client/chat-first-agent-activity.tsx create mode 100644 packages/core/src/client/chat-first-surface-panel-toggle.tsx create mode 100644 packages/core/src/client/chat-first.spec.ts create mode 100644 packages/core/src/client/chat-first.ts create mode 100644 packages/desktop-app/DESIGN.md create mode 100644 packages/desktop-app/src/renderer/components/DesktopChatFirstAppPane.tsx create mode 100644 packages/desktop-app/src/renderer/components/DesktopChatFirstBrowserPane.tsx create mode 100644 packages/desktop-app/src/renderer/components/DesktopChatFirstRail.tsx create mode 100644 packages/desktop-app/src/renderer/components/DesktopChatFirstSurfaceTabs.tsx create mode 100644 packages/dispatch/DESIGN.md create mode 100644 packages/dispatch/src/components/layout/chat-first-agents-pane.tsx create mode 100644 packages/dispatch/src/components/layout/chat-first-app-pane.tsx create mode 100644 packages/dispatch/src/components/layout/chat-first-browser-pane.tsx create mode 100644 packages/dispatch/src/components/layout/chat-first-context.tsx create mode 100644 packages/dispatch/src/components/layout/chat-first-session-watch-pane.tsx create mode 100644 packages/dispatch/src/components/layout/chat-first-surface-tabs.tsx create mode 100644 packages/dispatch/src/components/layout/chat-first-workspace-apps-rail.tsx create mode 100644 packages/mobile-app/DESIGN.md create mode 100644 packages/mobile-app/components/chat/ChatFirstMobileRail.tsx create mode 100644 packages/mobile-app/lib/chat-first-mode.ts create mode 100644 scripts/qa-chat-first-workbench-smoke.ts create mode 100644 templates/dispatch/app/chat-first-pages.ts create mode 100644 templates/dispatch/changelog/2026-08-08-chat-first-navigation-keeps-conversations-central-while-open.md diff --git a/.agents/skills/chat-first-workbench/SKILL.md b/.agents/skills/chat-first-workbench/SKILL.md new file mode 100644 index 0000000000..1a3aa54bfe --- /dev/null +++ b/.agents/skills/chat-first-workbench/SKILL.md @@ -0,0 +1,54 @@ +--- +name: chat-first-workbench +description: >- + Implement or extend the opt-in Codex/T3-like chat-first shell. Use when + changing chat rails, contextual app panes, app opening, or local/mobile shell + preferences across Electron, Dispatch, or mobile. +scope: dev +--- + +# Chat-first workbench + +## Contract + +The chat remains the primary surface. Workspace apps are a bounded discovery +rail, not a second navigation system. An app pane is opened from an explicit +app selection or a completed open_app action and carries only an app id plus a +validated app-relative path or view. + +## Client behavior + +- The preference is opt-in and local to the client. Keep the existing app-first + shell unchanged when it is disabled. +- Electron uses the shared Code Agents rail and AppWebview. +- Dispatch uses list-workspace-apps and create_embed_session; never iframe a + raw workspace URL. Resolve an absolute open_app URL against the registered + app first, then pass the resulting app-relative path. +- Mobile uses the existing app registry and secure AppWebView routes instead of + accepting arbitrary URLs or adding a second navigator. +- Show no nested app sidebar or browser chrome in a contextual pane. +- The app shelf is capped at six visible entries. Pinning and drag-reordering + are device-local presentation preferences; the agent-visible target remains + the registered app id and route. + +## Agent and state boundaries + +- Keep app discovery, embed-session minting, and thread operations on the + existing action surfaces. +- Emit the shared chat-first open-app event only for the completed first-party + `open_app` action with a real app, path, or view. A missing or unreadable + result is not success, and namespaced third-party tools cannot steer the + pane. +- Both web and desktop resolve the event through the same core origin/base-path + allow-list. An unresolved target produces a visible notice and no pane. +- If the agent needs to know which app is visible, expose a compact + application-state selection instead of scraping the iframe DOM. +- Cloud handoff is a future execution-residence seam. Do not claim that a local + run has moved to a worker until a durable worker snapshot and readback exist. + +## Verification + +Test the disabled path and the enabled path separately. For the enabled path, +verify rail ordering, chat history, app selection, embed-session errors, pane +close, and completed open_app behavior in a running client. Treat configuration +alone as insufficient proof. diff --git a/.changeset/chat-first-workbench.md b/.changeset/chat-first-workbench.md new file mode 100644 index 0000000000..a2c607c127 --- /dev/null +++ b/.changeset/chat-first-workbench.md @@ -0,0 +1,7 @@ +--- +"@agent-native/core": minor +"@agent-native/code-agents-ui": minor +"@agent-native/dispatch": minor +--- + +Add an opt-in chat-first workbench with contextual app surfaces for desktop, Dispatch, and mobile clients. diff --git a/docs/environment-variables.md b/docs/environment-variables.md index 4dcc13eb4f..b5b37b0ed0 100644 --- a/docs/environment-variables.md +++ b/docs/environment-variables.md @@ -223,6 +223,7 @@ production deployment: | Pattern | Scope | | ------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------- | | `E2E_*` / `PLAYWRIGHT_*` / `*_SMOKE_*` / `SIGN_IN_MATRIX_*` | Browser, end-to-end, and smoke-test configuration. | +| `CHAT_FIRST_SCREENSHOT_DIR` / `CHAT_FIRST_ELECTRON` / `CHAT_FIRST_ELECTRON_EXECUTABLE` | Chat-first workbench QA output, optional Electron lane toggle, and an optional Electron binary override. | | `HEADLESS_ONRAMP_*` / `STANDALONE_CHAT_DEV_*` | CLI and standalone-chat QA harnesses. | | `DEV_*` / `DEBUG_*` / `*_DEBUG` / `CHOKIDAR_*` | Local development and diagnostics. | | `AUTH_SKIP_EMAIL_VERIFICATION` | Local/test auth convenience; never use to weaken production auth. | diff --git a/package.json b/package.json index ab866181be..e4be24c52b 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "qa:composer-geometry": "tsx scripts/qa-composer-geometry-smoke.ts", "qa:dispatch-workspace-resources": "tsx scripts/qa-dispatch-workspace-resources-smoke.ts", "qa:dispatch-automations": "tsx scripts/qa-dispatch-automations-smoke.ts", + "qa:chat-first-workbench": "tsx scripts/qa-chat-first-workbench-smoke.ts", "qa:template-routes": "tsx scripts/qa-template-route-matrix.ts", "qa:sign-in": "tsx scripts/qa-sign-in-matrix-smoke.ts", "actions:audit": "node scripts/audit-template-actions.mjs", diff --git a/packages/code-agents-ui/src/CodeAgentsApp.tsx b/packages/code-agents-ui/src/CodeAgentsApp.tsx index ef843c227a..06a64ca3de 100644 --- a/packages/code-agents-ui/src/CodeAgentsApp.tsx +++ b/packages/code-agents-ui/src/CodeAgentsApp.tsx @@ -3,13 +3,18 @@ import { ChatHistoryList, buildRepositoryFromCodeAgentTranscript, codeAgentTranscriptHasPendingApproval, + closeChatFirstSessionWatch, createCodeAgentChatAdapter, + emitChatFirstSessionWatch, isCodeAgentRunActive, isCredentialGapCodeAgentEvent, mergeCodeAgentTranscriptEvents, + useChatFirstSessionWatch, + type ChatFirstSurfaceKind, type ChatHistoryItem, type CodeAgentChatController, } from "@agent-native/core/client/agent-chat"; +import { writeClipboardText } from "@agent-native/core/client/clipboard"; import { PromptComposer, readAgentPromptAttachment, @@ -30,6 +35,7 @@ import { IconCopy, IconDeviceMobile, IconDeviceDesktop, + IconEye, IconFolder, IconFolderPlus, IconLink, @@ -54,6 +60,7 @@ import { useRef, useState, type CSSProperties, + type ReactNode, } from "react"; import { toast } from "sonner"; @@ -68,6 +75,7 @@ import { type CodeAgentGoalId, type CodeAgentPermissionMode, } from "./code-agents.js"; +import { SessionWatchPanel } from "./SessionWatchPanel.js"; import type { CodeAgentCodePack, CodeAgentCodePackResult, @@ -243,9 +251,26 @@ export interface CodeAgentsAppProps { refreshKey?: number; brandIconUrl?: string; onOpenSettings?: () => void; + /** Extra first-party navigation items rendered below New chat. */ + railNavigationSlot?: ReactNode; + /** App shortcuts rendered between navigation and the chat history. */ + railWorkspaceSlot?: ReactNode; + /** Optional actions pinned to the bottom of the rail. */ + railFooterSlot?: ReactNode; renderAppSurface?: CodeAgentsRenderAppSurface; newSessionExtension?: CodeAgentsNewSessionExtension; openDetailRequest?: { detailId: string; nonce: number }; + /** Active chat-first side surface; watch is rendered only when selected. */ + activeChatFirstSurfaceKind?: ChatFirstSurfaceKind; + /** Keep session-watch affordances opt-in with the chat-first shell. */ + chatFirstMode?: boolean; + /** Lets a host place the shared watch renderer in its side-surface slot. */ + onWatchedRunChange?: ( + run: CodeAgentRun | null, + sourceRunId?: string | null, + ) => void; + /** Exposes the already-loaded run list to a host-owned side surface. */ + onRunsChange?: (runs: CodeAgentRun[]) => void; } type RunListStatus = CodeAgentRunListResult["status"]; @@ -404,15 +429,23 @@ export default function CodeAgentsApp({ refreshKey = 0, brandIconUrl, onOpenSettings, + railNavigationSlot, + railWorkspaceSlot, + railFooterSlot, renderAppSurface, newSessionExtension, openDetailRequest, + activeChatFirstSurfaceKind, + chatFirstMode = false, + onWatchedRunChange, + onRunsChange, }: CodeAgentsAppProps) { const [selectedGoalId, setSelectedGoalId] = useState("task"); const selectedGoal = getCodeAgentGoal(selectedGoalId) ?? getDefaultCodeAgentGoal(); const [runs, setRuns] = useState([]); const [selectedRunId, setSelectedRunId] = useState(null); + const watchedSession = useChatFirstSessionWatch(); const [selectedExtensionDetailId, setSelectedExtensionDetailId] = useState< string | null >(null); @@ -425,12 +458,37 @@ export default function CodeAgentsApp({ () => runs.find((run) => run.id === selectedRunId) ?? null, [runs, selectedRunId], ); + const watchedRun = useMemo( + () => + runs.find((run) => run.id === watchedSession.target?.sessionId) ?? null, + [runs, watchedSession.target?.sessionId], + ); useEffect(() => { if (activeNewSessionExtension) return; setSelectedExtensionDetailId(null); }, [activeNewSessionExtension]); + useEffect(() => { + if (watchedSession.target && !watchedRun) closeChatFirstSessionWatch(); + }, [watchedRun, watchedSession.target]); + + useEffect(() => { + onWatchedRunChange?.( + watchedRun, + watchedSession.target?.sourceSessionId ?? selectedRunId, + ); + }, [ + onWatchedRunChange, + selectedRunId, + watchedRun, + watchedSession.target?.sourceSessionId, + ]); + + useEffect(() => { + onRunsChange?.(runs); + }, [onRunsChange, runs]); + useEffect(() => { if (!openDetailRequest || !activeNewSessionExtension?.renderDetail) return; setSelectedRunId(null); @@ -1701,6 +1759,7 @@ export default function CodeAgentsApp({ New chat + {railNavigationSlot} + {chatFirstMode ? ( + + ) : null} + + )} variant="rail" /> )} + {railFooterSlot ? ( +
{railFooterSlot}
+ ) : null}
+ {chatFirstMode && + !onWatchedRunChange && + watchedRun && + (!activeChatFirstSurfaceKind || + activeChatFirstSurfaceKind === "side-chat") ? ( + + ) : null} {workbenchOpen ? (
@@ -3961,6 +4090,7 @@ function createHostCodeAgentChatController( model: input.model, effort: input.reasoningEffort as CodeAgentReasoningEffort | undefined, attachments: normalizePromptAttachmentsForHost(input.metadata), + metadata: input.metadata, }); return { ok: result.ok, diff --git a/packages/code-agents-ui/src/SessionWatchPanel.tsx b/packages/code-agents-ui/src/SessionWatchPanel.tsx new file mode 100644 index 0000000000..16e0c1a74d --- /dev/null +++ b/packages/code-agents-ui/src/SessionWatchPanel.tsx @@ -0,0 +1,193 @@ +import { mergeCodeAgentTranscriptEvents } from "@agent-native/core/client/agent-chat"; +import { PromptComposer } from "@agent-native/core/client/composer"; +import { + IconEye, + IconLoader2, + IconMessageCircle, + IconX, +} from "@tabler/icons-react"; +import { useCallback, useEffect, useState } from "react"; +import { toast } from "sonner"; + +import type { CodeAgentsHost } from "./CodeAgentsApp.js"; +import type { CodeAgentRun, CodeAgentTranscriptEvent } from "./types.js"; + +/** + * A deliberately small second-session surface. It watches a run through the + * same transcript subscription as the main chat and sends follow-ups through + * the host's existing run-manager boundary, so watching never grants direct + * access to another session's files or process. + */ +export function SessionWatchPanel({ + host, + run, + sourceRunId, + onClose, +}: { + host: CodeAgentsHost; + run: CodeAgentRun; + sourceRunId?: string | null; + onClose: () => void; +}) { + const [events, setEvents] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [sending, setSending] = useState(false); + const [prompt, setPrompt] = useState(""); + + const loadTranscript = useCallback(async () => { + try { + const result = await host.readTranscript({ + goalId: run.goalId, + runId: run.id, + }); + if (result.status !== "ok") { + setError(result.error ?? "This session is not reporting a transcript."); + return; + } + setError(null); + setEvents(result.events); + } catch (caught) { + setError(caught instanceof Error ? caught.message : String(caught)); + } finally { + setLoading(false); + } + }, [host, run.goalId, run.id]); + + useEffect(() => { + setEvents([]); + setError(null); + setLoading(true); + void loadTranscript(); + + const unsubscribe = host.subscribeTranscript?.( + { goalId: run.goalId, runId: run.id }, + (batch) => { + if (batch.status !== "ok") { + setError(batch.error ?? "The watched session could not be updated."); + return; + } + setError(null); + if (batch.events.length > 0) { + setEvents((current) => + mergeCodeAgentTranscriptEvents(current, batch.events), + ); + } + }, + ); + const interval = window.setInterval(() => void loadTranscript(), 10_000); + return () => { + unsubscribe?.(); + window.clearInterval(interval); + }; + }, [host, loadTranscript, run.goalId, run.id]); + + async function sendFollowUp( + text: string, + _files: File[], + _references: unknown[], + options: { intent?: "immediate" | "queued" }, + ) { + const trimmed = text.trim(); + if (!trimmed || sending) return; + setSending(true); + try { + const result = await host.appendFollowUp({ + goalId: run.goalId, + runId: run.id, + prompt: trimmed, + followUpMode: options.intent === "queued" ? "queued" : "immediate", + metadata: { + sessionBridge: "watch-panel", + ...(sourceRunId ? { sourceRunId } : {}), + }, + }); + if (!result.ok) { + toast("Could not message this session", { + description: result.error ?? result.message, + duration: 3200, + }); + return; + } + setPrompt(""); + toast("Message sent to session", { duration: 1600 }); + await loadTranscript(); + } catch (caught) { + toast("Could not message this session", { + description: caught instanceof Error ? caught.message : String(caught), + duration: 3200, + }); + } finally { + setSending(false); + } + } + + return ( +
+
+
+
+ +
+ +
+
+ {loading ? ( +
+ + Reading session… +
+ ) : error ? ( +
+ {error} +
+ ) : events.length === 0 ? ( +
+ + No transcript events yet. +
+ ) : ( + events.slice(-8).map((event) => ( +
+ {event.type} +

{event.text}

+
+ )) + )} +
+ +
+
+ ); +} diff --git a/packages/code-agents-ui/src/index.ts b/packages/code-agents-ui/src/index.ts index 46bed38ec5..52ef8437c8 100644 --- a/packages/code-agents-ui/src/index.ts +++ b/packages/code-agents-ui/src/index.ts @@ -11,6 +11,7 @@ export type { CodeAgentsRenderAppSurface, } from "./CodeAgentsApp.js"; export { resolveNewSessionExtensionComposerState } from "./CodeAgentsApp.js"; +export { SessionWatchPanel } from "./SessionWatchPanel.js"; export * from "./composer-primitives.js"; export * from "./code-agents.js"; export * from "./types.js"; diff --git a/packages/code-agents-ui/src/styles.css b/packages/code-agents-ui/src/styles.css index d37e5d46a3..e144d06f3d 100644 --- a/packages/code-agents-ui/src/styles.css +++ b/packages/code-agents-ui/src/styles.css @@ -447,6 +447,162 @@ background: hsl(var(--background)); } +.code-agents-main { + display: flex; + flex-direction: column; + overflow: hidden; +} + +.code-agents-session-watch { + flex: 0 0 auto; + display: flex; + flex-direction: column; + gap: 8px; + max-height: min(340px, 38vh); + padding: 12px 16px 10px; + border-bottom: 1px solid hsl(var(--border)); + background: hsl(var(--card) / 0.72); +} + +.code-agents-session-watch__header, +.code-agents-session-watch__title { + display: flex; + align-items: center; + min-width: 0; +} + +.code-agents-session-watch__header { + justify-content: space-between; + gap: 12px; +} + +.code-agents-session-watch__title { + gap: 8px; + color: hsl(var(--foreground)); +} + +.code-agents-session-watch__title > svg { + flex: 0 0 auto; + color: hsl(var(--primary)); +} + +.code-agents-session-watch__title div { + display: grid; + min-width: 0; + gap: 2px; +} + +.code-agents-session-watch__title strong, +.code-agents-session-watch__title span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.code-agents-session-watch__title strong { + font-size: 12px; + font-weight: 600; +} + +.code-agents-session-watch__title span { + color: hsl(var(--muted-foreground)); + font-family: ui-monospace, SFMono-Regular, Consolas, monospace; + font-size: 10px; +} + +.code-agents-session-watch__close { + display: inline-grid; + flex: 0 0 auto; + place-items: center; + width: 26px; + height: 26px; + border: 1px solid hsl(var(--border)); + border-radius: calc(var(--radius) - 2px); + color: hsl(var(--muted-foreground)); + cursor: pointer; +} + +.code-agents-session-watch__close:hover { + background: hsl(var(--accent)); + color: hsl(var(--foreground)); +} + +.code-agents-session-watch__body { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(260px, 360px); + gap: 12px; + min-height: 0; +} + +.code-agents-session-watch__events { + min-width: 0; + max-height: 226px; + overflow-y: auto; + padding: 2px; +} + +.code-agents-session-watch__event { + display: grid; + gap: 2px; + padding: 5px 7px; + border-bottom: 1px solid hsl(var(--border) / 0.7); +} + +.code-agents-session-watch__event span { + color: hsl(var(--muted-foreground)); + font-size: 10px; + text-transform: uppercase; +} + +.code-agents-session-watch__event p { + margin: 0; + overflow: hidden; + color: hsl(var(--foreground) / 0.82); + font-size: 11px; + line-height: 1.4; + text-overflow: ellipsis; + white-space: nowrap; +} + +.code-agents-session-watch__state { + display: flex; + align-items: center; + gap: 7px; + min-height: 60px; + padding: 10px; + color: hsl(var(--muted-foreground)); + font-size: 11px; +} + +.code-agents-session-watch__state--error { + color: hsl(var(--destructive)); +} + +.code-agents-session-watch__composer { + align-self: end; + width: 100% !important; +} + +.code-agents-main > .code-agents-overview, +.code-agents-main > .code-agents-workbench { + flex: 1 1 auto; + height: auto; +} + +@media (max-width: 760px) { + .code-agents-session-watch__body { + grid-template-columns: minmax(0, 1fr); + } + + .code-agents-session-watch { + max-height: min(480px, 58vh); + } + + .code-agents-session-watch__events { + max-height: 150px; + } +} + .code-agents-overview { height: 100%; overflow-y: auto; diff --git a/packages/code-agents-ui/src/types.ts b/packages/code-agents-ui/src/types.ts index f4535c6f28..453cb607f8 100644 --- a/packages/code-agents-ui/src/types.ts +++ b/packages/code-agents-ui/src/types.ts @@ -232,6 +232,8 @@ export interface CodeAgentFollowUpRequest { model?: string; effort?: CodeAgentReasoningEffort; attachments?: CodeAgentPromptAttachment[]; + /** Bounded provenance for host-side coordination such as session watch. */ + metadata?: Record; } export interface CodeAgentFollowUpResult { @@ -349,6 +351,7 @@ export interface CodeAgentRetryRunRequest { engine?: string; model?: string; effort?: CodeAgentReasoningEffort; + metadata?: Record; } export interface CodeAgentRetryRunResult { diff --git a/packages/core/src/client/agent-chat/index.ts b/packages/core/src/client/agent-chat/index.ts index 793ba7d6f5..7adc38f5b7 100644 --- a/packages/core/src/client/agent-chat/index.ts +++ b/packages/core/src/client/agent-chat/index.ts @@ -88,6 +88,11 @@ export { CodeRequiredDialog, type CodeRequiredDialogProps, } from "../components/CodeRequiredDialog.js"; +export { + ChatFirstAgentActivityPanel, + type ChatFirstAgentActivityPanelProps, +} from "../chat-first-agent-activity.js"; +export { ChatFirstSurfacePanelToggle } from "../chat-first-surface-panel-toggle.js"; export { useAgentEngineConfigured, type AgentEngineConfiguredState, @@ -283,3 +288,66 @@ export { } from "../chat/tool-render-registry.js"; export * from "../chat/connectors.js"; export * from "../chat/runtime.js"; +export { + CHAT_FIRST_APP_LAYOUT_STORAGE_KEY, + CHAT_FIRST_SURFACE_TABS_STORAGE_KEY, + CHAT_FIRST_SURFACE_PANEL_STORAGE_KEY, + CHAT_FIRST_SURFACE_WIDTH_DEFAULT, + CHAT_FIRST_SURFACE_WIDTH_MIN, + clampChatFirstSurfaceWidth, + CHAT_FIRST_MODE_STORAGE_KEY, + CHAT_FIRST_MODE_CHANGED_EVENT, + CHAT_FIRST_OPEN_APP_EVENT, + CHAT_FIRST_OPEN_BROWSER_EVENT, + CHAT_FIRST_WATCH_SESSION_EVENT, + CHAT_FIRST_SURFACE_CATALOG, + chatFirstSurfaceTabId, + closeChatFirstSessionWatch, + emitChatFirstOpenApp, + emitChatFirstOpenBrowser, + emitChatFirstSessionWatch, + getChatFirstSessionWatchStore, + getChatFirstSurfaceTabsStore, + getChatFirstSurfacePanelStore, + orderChatFirstAppIds, + readChatFirstAppLayout, + resolveChatFirstAppTarget, + resolveChatFirstBrowserTarget, + resolveChatFirstSessionId, + normalizeChatFirstSessionReference, + readChatFirstMode, + readChatFirstModeState, + readChatFirstSurfaceWidth, + subscribeChatFirstOpenApp, + subscribeChatFirstOpenBrowser, + subscribeChatFirstSessionWatch, + useChatFirstSessionWatch, + useChatFirstSurfaceResize, + useChatFirstSurfacePanel, + useChatFirstSurfaceTabs, + writeChatFirstAppLayout, + writeChatFirstMode, + writeChatFirstSurfaceWidth, + type ChatFirstAppLayoutPreference, + type ChatFirstModeReadResult, + type ChatFirstAppRegistration, + type ChatFirstAppResolution, + type ChatFirstAppTarget, + type ChatFirstAgentActivity, + type ChatFirstAgentActivityStatus, + type ChatFirstOpenAppDelivery, + type ChatFirstOpenAppDetail, + type ChatFirstOpenBrowserDetail, + type ChatFirstBrowserResolution, + type ChatFirstSessionKind, + type ChatFirstSessionReference, + type ChatFirstSessionWatchDelivery, + type ChatFirstSessionWatchState, + type ChatFirstSessionWatchStore, + type ChatFirstSurfaceKind, + type ChatFirstSurfaceTab, + type ChatFirstSurfacePanelState, + type ChatFirstSurfacePanelStore, + type ChatFirstSurfaceTabsState, + type ChatFirstSurfaceTabsStore, +} from "../chat-first.js"; diff --git a/packages/core/src/client/chat-first-agent-activity.tsx b/packages/core/src/client/chat-first-agent-activity.tsx new file mode 100644 index 0000000000..a40598f175 --- /dev/null +++ b/packages/core/src/client/chat-first-agent-activity.tsx @@ -0,0 +1,209 @@ +import { + IconAlertCircle, + IconCheck, + IconCircleDot, + IconClock, + IconCopy, + IconEye, + IconLoader2, + IconPlayerPause, + IconRefresh, + IconShieldExclamation, +} from "@tabler/icons-react"; +import { useState } from "react"; + +import type { + ChatFirstAgentActivity, + ChatFirstAgentActivityStatus, +} from "./chat-first.js"; +import { writeClipboardText } from "./clipboard.js"; + +export interface ChatFirstAgentActivityPanelProps { + activities: readonly ChatFirstAgentActivity[]; + loading?: boolean; + error?: string | null; + onRefresh?: () => void; + onWatch?: (activity: ChatFirstAgentActivity) => void; +} + +const STATUS_LABELS: Record = { + queued: "Queued", + running: "Running", + paused: "Paused", + "needs-approval": "Needs approval", + completed: "Completed", + errored: "Needs attention", + recent: "Recent", + unknown: "Unknown", +}; + +function statusIcon(status: ChatFirstAgentActivityStatus) { + if (status === "running" || status === "queued") return IconLoader2; + if (status === "paused") return IconPlayerPause; + if (status === "needs-approval") return IconShieldExclamation; + if (status === "completed" || status === "recent") return IconCheck; + if (status === "errored") return IconAlertCircle; + return IconCircleDot; +} + +function relativeTime(value: string | number | undefined): string { + if (value === undefined) return ""; + const timestamp = typeof value === "number" ? value : Date.parse(value); + if (!Number.isFinite(timestamp)) return ""; + const seconds = Math.max(0, Math.round((Date.now() - timestamp) / 1000)); + if (seconds < 60) return "just now"; + const minutes = Math.round(seconds / 60); + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.round(minutes / 60); + if (hours < 24) return `${hours}h ago`; + return `${Math.round(hours / 24)}d ago`; +} + +function clampProgress(value: number | undefined): number | null { + if (value === undefined || !Number.isFinite(value)) return null; + return Math.min(100, Math.max(0, Math.round(value))); +} + +export function ChatFirstAgentActivityPanel({ + activities, + loading = false, + error = null, + onRefresh, + onWatch, +}: ChatFirstAgentActivityPanelProps) { + const [copiedSessionId, setCopiedSessionId] = useState(null); + + async function copySessionId(sessionId: string) { + const copied = await writeClipboardText(sessionId); + if (!copied) return; + setCopiedSessionId(sessionId); + window.setTimeout(() => { + setCopiedSessionId((current) => (current === sessionId ? null : current)); + }, 1600); + } + + return ( +
+
+
+

Workspace

+

Agent activity

+

Follow runs and open a second session beside this chat.

+
+ {onRefresh ? ( + + ) : null} +
+ + {error ? ( +
+
+ ) : null} + + {loading && activities.length === 0 ? ( +
+ {Array.from({ length: 4 }, (_, index) => ( +
+ ))} +
+ ) : activities.length === 0 ? ( +
+
+ ) : ( +
+ {activities.map((activity) => { + const StatusIcon = statusIcon(activity.status); + const progress = clampProgress(activity.progressPercent); + const copied = copiedSessionId === activity.sessionId; + return ( +
+
+
+
+ {onWatch ? ( + + ) : null} + +
+
+ ); + })} +
+ )} +
+ ); +} diff --git a/packages/core/src/client/chat-first-surface-panel-toggle.tsx b/packages/core/src/client/chat-first-surface-panel-toggle.tsx new file mode 100644 index 0000000000..fdcf87d2c2 --- /dev/null +++ b/packages/core/src/client/chat-first-surface-panel-toggle.tsx @@ -0,0 +1,23 @@ +import { IconLayoutSidebarRightCollapse } from "@tabler/icons-react"; + +export function ChatFirstSurfacePanelToggle({ + open, + onToggle, +}: { + open: boolean; + onToggle: () => void; +}) { + return ( + + ); +} diff --git a/packages/core/src/client/chat-first.spec.ts b/packages/core/src/client/chat-first.spec.ts new file mode 100644 index 0000000000..256410472a --- /dev/null +++ b/packages/core/src/client/chat-first.spec.ts @@ -0,0 +1,294 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + orderChatFirstAppIds, + readChatFirstAppLayout, + writeChatFirstAppLayout, + CHAT_FIRST_MODE_STORAGE_KEY, + getChatFirstSessionWatchStore, + getChatFirstSurfaceTabsStore, + getChatFirstSurfacePanelStore, + clampChatFirstSurfaceWidth, + normalizeChatFirstSessionReference, + readChatFirstMode, + readChatFirstModeState, + resolveChatFirstAppTarget, + resolveChatFirstBrowserTarget, + resolveChatFirstSessionId, + chatFirstSurfaceTabId, + readChatFirstSurfaceWidth, + writeChatFirstSurfaceWidth, + writeChatFirstMode, +} from "./chat-first.js"; + +function createStorage(): Storage { + const values = new Map(); + return { + getItem: (key) => values.get(key) ?? null, + setItem: (key, value) => { + values.set(key, value); + }, + removeItem: (key) => { + values.delete(key); + }, + clear: () => values.clear(), + key: (index) => [...values.keys()][index] ?? null, + get length() { + return values.size; + }, + }; +} + +describe("chat-first preference", () => { + it("defaults to disabled and persists the opt-in value", () => { + const storage = createStorage(); + + expect(storage.getItem(CHAT_FIRST_MODE_STORAGE_KEY)).toBeNull(); + expect(readChatFirstMode(storage)).toBe(false); + expect(readChatFirstModeState(storage)).toEqual({ + enabled: false, + availability: "available", + }); + + writeChatFirstMode(true, storage); + + expect(readChatFirstMode(storage)).toBe(true); + }); + + it("reports storage failures instead of claiming the preference persisted", () => { + const storage = { + getItem: () => { + throw new Error("blocked"); + }, + setItem: () => { + throw new Error("blocked"); + }, + }; + + expect(readChatFirstMode(storage)).toBe(false); + expect(readChatFirstModeState(storage)).toEqual({ + enabled: false, + availability: "unavailable", + }); + expect(writeChatFirstMode(true, storage)).toEqual({ + ok: false, + reason: "write-failed", + }); + }); + + it("persists a bounded app shelf layout and keeps pinned apps first", () => { + const storage = createStorage(); + expect( + writeChatFirstAppLayout( + { + pinnedIds: ["calendar", "calendar"], + orderedIds: ["mail", "calendar"], + }, + storage, + ), + ).toEqual({ ok: true }); + const layout = readChatFirstAppLayout(storage); + + expect(layout).toEqual({ + pinnedIds: ["calendar"], + orderedIds: ["mail", "calendar"], + }); + expect( + orderChatFirstAppIds(["mail", "calendar", "design"], layout), + ).toEqual(["calendar", "mail", "design"]); + expect( + orderChatFirstAppIds(["mail", "calendar", "design"], { + pinnedIds: ["calendar"], + orderedIds: ["design", "mail", "calendar"], + }), + ).toEqual(["calendar", "design", "mail"]); + }); +}); + +describe("chat-first app target resolution", () => { + const apps = [ + { + id: "mail", + name: "Mail", + url: "https://mail.example.com", + path: "/mail", + }, + { + id: "calendar", + name: "Calendar", + url: "https://workspace.example.com/calendar", + path: "/calendar", + }, + ]; + + it("turns a registered absolute target into an app-relative path", () => { + expect( + resolveChatFirstAppTarget( + { + app: "mail", + url: "https://mail.example.com/inbox?filter=unread", + }, + apps, + ), + ).toEqual({ + status: "ready", + target: { + appId: "mail", + path: "/inbox?filter=unread", + }, + }); + }); + + it("rejects a URL that does not belong to the named app", () => { + expect( + resolveChatFirstAppTarget( + { app: "mail", url: "https://evil.example.com/login" }, + apps, + ), + ).toEqual({ status: "unresolved", reason: "invalid-url" }); + }); + + it("rejects an app-named target that is not registered", () => { + expect( + resolveChatFirstAppTarget({ app: "unknown", path: "/inbox" }, apps), + ).toEqual({ status: "unresolved", reason: "unknown-app" }); + }); + + it("builds a deep-link path for view-only targets", () => { + expect( + resolveChatFirstAppTarget({ app: "calendar", view: "month" }, apps), + ).toEqual({ + status: "ready", + target: { + appId: "calendar", + path: "/_agent-native/open?app=calendar&view=month", + view: "month", + }, + }); + }); + + it("keeps non-agent-native browser targets in a visible browser surface", () => { + expect( + resolveChatFirstBrowserTarget({ + url: "https://example.com/docs?q=chat-first", + title: "Docs", + }), + ).toEqual({ + status: "ready", + target: { + url: "https://example.com/docs?q=chat-first", + title: "Docs", + }, + }); + expect( + resolveChatFirstBrowserTarget({ + url: "javascript:alert(1)", + }), + ).toEqual({ status: "unresolved", reason: "invalid-url" }); + }); +}); + +describe("chat-first surface panel preference", () => { + it("starts closed and supports an explicit toggle", () => { + const store = getChatFirstSurfacePanelStore( + `panel-test-${Math.random().toString(36).slice(2)}`, + ); + expect(store.getSnapshot().open).toBe(false); + store.setOpen(true); + expect(store.getSnapshot().open).toBe(true); + store.toggle(); + expect(store.getSnapshot().open).toBe(false); + }); +}); + +describe("chat-first session watch contract", () => { + it("resolves stable ids from run and thread-shaped payloads", () => { + expect(resolveChatFirstSessionId({ runId: " run-42 " })).toBe("run-42"); + expect(resolveChatFirstSessionId({ threadId: "thread-7" })).toBe( + "thread-7", + ); + expect(resolveChatFirstSessionId({ id: "" })).toBeNull(); + expect( + normalizeChatFirstSessionReference({ + id: "thread-7", + title: " Review ", + kind: "agent-chat", + sourceSessionId: "source-1", + }), + ).toEqual({ + sessionId: "thread-7", + title: "Review", + kind: "agent-chat", + sourceSessionId: "source-1", + }); + }); + + it("keeps watch state and subscriptions shared by both renderers", () => { + const store = getChatFirstSessionWatchStore(); + store.close(); + const listener = vi.fn(); + const unsubscribe = store.subscribe(listener); + + store.open({ sessionId: "session-1", title: "Build" }); + expect(store.getSnapshot()).toEqual({ + target: { sessionId: "session-1", title: "Build" }, + }); + expect(listener).toHaveBeenCalledTimes(1); + + store.close(); + expect(store.getSnapshot()).toEqual({ target: null }); + expect(listener).toHaveBeenCalledTimes(2); + unsubscribe(); + }); + + it("provides tab lifecycle operations for shared side surfaces", () => { + const store = getChatFirstSurfaceTabsStore(); + store.closeAll(); + store.open({ + id: chatFirstSurfaceTabId("browser", "docs"), + kind: "browser", + title: "Docs", + url: "https://example.com/docs", + }); + store.open({ + id: chatFirstSurfaceTabId("app", "mail"), + kind: "app", + title: "Mail", + appId: "mail", + }); + store.open({ + id: chatFirstSurfaceTabId("files", "workspace"), + kind: "files", + title: "Files", + }); + + expect(store.getSnapshot().activeTabId).toBe("files:workspace"); + store.closeToRight("browser:docs"); + expect(store.getSnapshot().tabs.map((tab) => tab.id)).toEqual([ + "browser:docs", + ]); + store.open({ + id: "app:mail", + kind: "app", + title: "Mail", + appId: "mail", + }); + store.open({ id: "diff", kind: "diff", title: "Diff" }); + store.closeOthers("app:mail"); + expect(store.getSnapshot().tabs.map((tab) => tab.id)).toEqual(["app:mail"]); + store.closeAll(); + }); + + it("clamps and persists side-surface width without accepting invalid values", () => { + const storage = createStorage(); + expect(clampChatFirstSurfaceWidth(100, 1200)).toBe(360); + expect(clampChatFirstSurfaceWidth(2000, 1200)).toBe(840); + expect(writeChatFirstSurfaceWidth(620, "thread-1", storage)).toEqual({ + ok: true, + }); + expect(readChatFirstSurfaceWidth("thread-1", storage)).toBe(620); + expect(readChatFirstSurfaceWidth("missing", storage)).toBe( + clampChatFirstSurfaceWidth(540), + ); + }); +}); diff --git a/packages/core/src/client/chat-first.ts b/packages/core/src/client/chat-first.ts new file mode 100644 index 0000000000..cb8f637db4 --- /dev/null +++ b/packages/core/src/client/chat-first.ts @@ -0,0 +1,1125 @@ +import { + useCallback, + useEffect, + useRef, + useState, + useSyncExternalStore, +} from "react"; + +export const CHAT_FIRST_MODE_STORAGE_KEY = "agent-native:chat-first-mode:v1"; +export const CHAT_FIRST_APP_LAYOUT_STORAGE_KEY = + "agent-native:chat-first-app-layout:v1"; +export const CHAT_FIRST_SURFACE_TABS_STORAGE_KEY = + "agent-native:chat-first-surface-tabs:v1"; +export const CHAT_FIRST_SURFACE_WIDTH_STORAGE_KEY = + "agent-native:chat-first-surface-width:v1"; +export const CHAT_FIRST_SURFACE_PANEL_STORAGE_KEY = + "agent-native:chat-first-surface-panel:v1"; +export const CHAT_FIRST_MODE_CHANGED_EVENT = "agentNative:chatFirstModeChanged"; +export const CHAT_FIRST_OPEN_APP_EVENT = "agentNative:openApp"; +export const CHAT_FIRST_OPEN_BROWSER_EVENT = "agentNative:openBrowser"; +export const CHAT_FIRST_WATCH_SESSION_EVENT = "agentNative:watchSession"; + +export type ChatFirstOpenAppDetail = { + app?: string; + path?: string; + url?: string; + view?: string; +}; + +export type ChatFirstOpenBrowserDetail = { + url?: string; + title?: string; +}; + +export type ChatFirstSessionKind = "code-agent" | "agent-chat" | "external"; + +export interface ChatFirstSessionReference { + sessionId: string; + title?: string; + kind?: ChatFirstSessionKind; + goalId?: string; + sourceSessionId?: string; +} + +export type ChatFirstSessionWatchState = { + target: ChatFirstSessionReference | null; +}; + +export interface ChatFirstSessionWatchStore { + getSnapshot: () => ChatFirstSessionWatchState; + subscribe: (listener: () => void) => () => void; + hasSubscribers: () => boolean; + open: (target: ChatFirstSessionReference) => void; + close: () => void; +} + +export type ChatFirstSurfaceKind = + | "app" + | "browser" + | "terminal" + | "diff" + | "files" + | "side-chat" + | "agents"; + +export interface ChatFirstSurfaceTab { + id: string; + kind: ChatFirstSurfaceKind; + title: string; + appId?: string; + path?: string; + view?: string; + url?: string; + session?: ChatFirstSessionReference; + disabledReason?: string; +} + +export type ChatFirstAgentActivityStatus = + | "queued" + | "running" + | "paused" + | "needs-approval" + | "completed" + | "errored" + | "recent" + | "unknown"; + +/** The renderer-neutral subset used by the shared agent activity surface. */ +export interface ChatFirstAgentActivity { + sessionId: string; + title: string; + subtitle?: string; + status: ChatFirstAgentActivityStatus; + updatedAt?: string | number; + progressPercent?: number; + goalId?: string; +} + +export interface ChatFirstSurfaceTabsState { + tabs: ChatFirstSurfaceTab[]; + activeTabId: string | null; +} + +export interface ChatFirstSurfaceTabsStore { + getSnapshot: () => ChatFirstSurfaceTabsState; + subscribe: (listener: () => void) => () => void; + open: (tab: ChatFirstSurfaceTab) => void; + activate: (tabId: string) => void; + close: (tabId: string) => void; + closeOthers: (tabId: string) => void; + closeToRight: (tabId: string) => void; + closeAll: () => void; +} + +export interface ChatFirstSurfacePanelState { + open: boolean; +} + +export interface ChatFirstSurfacePanelStore { + getSnapshot: () => ChatFirstSurfacePanelState; + subscribe: (listener: () => void) => () => void; + setOpen: (open: boolean) => void; + toggle: () => void; +} + +export const CHAT_FIRST_SURFACE_CATALOG: readonly { + kind: ChatFirstSurfaceKind; + label: string; + description: string; + availability: "both" | "desktop" | "deferred"; + disabledReason?: string; +}[] = [ + { + kind: "browser", + label: "Browser", + description: "Open a regular web page with address-bar controls.", + availability: "both", + }, + { + kind: "terminal", + label: "Terminal", + description: "Run commands beside the conversation.", + availability: "deferred", + disabledReason: + "Deferred in this release: the existing terminal is a standalone host surface; a shared PTY lifecycle is needed for an embedded pane.", + }, + { + kind: "files", + label: "Files", + description: "Browse the active workspace tree.", + availability: "deferred", + disabledReason: + "Deferred in this release: neither client exposes a bounded workspace tree to this shared pane contract yet.", + }, + { + kind: "diff", + label: "Diff", + description: "Review the current changeset.", + availability: "deferred", + disabledReason: + "Deferred in this release: diff blocks remain available in chat while a shared changed-file source is defined.", + }, + { + kind: "side-chat", + label: "Side chat", + description: "Watch and message another session beside the main chat.", + availability: "both", + disabledReason: "Choose Watch and message from any chat row.", + }, + { + kind: "agents", + label: "Agents", + description: "Review agent sessions and watch one beside the conversation.", + availability: "both", + disabledReason: "Review recent runs or watch one beside this conversation.", + }, +]; + +const CHAT_FIRST_SURFACE_KINDS: readonly ChatFirstSurfaceKind[] = [ + "app", + "browser", + "terminal", + "diff", + "files", + "side-chat", + "agents", +]; + +export function chatFirstSurfaceTabId( + kind: ChatFirstSurfaceKind, + value: string, +): string { + return `${kind}:${value}`; +} + +export const CHAT_FIRST_SURFACE_WIDTH_DEFAULT = 540; +export const CHAT_FIRST_SURFACE_WIDTH_MIN = 360; + +export function clampChatFirstSurfaceWidth( + value: number, + viewportWidth = typeof window === "undefined" + ? Number.POSITIVE_INFINITY + : window.innerWidth, +): number { + const max = Math.max( + CHAT_FIRST_SURFACE_WIDTH_MIN, + Math.floor(viewportWidth * 0.7), + ); + return Math.min( + max, + Math.max(CHAT_FIRST_SURFACE_WIDTH_MIN, Math.round(value)), + ); +} + +function surfaceWidthStorageKey(scope: string): string { + return `${CHAT_FIRST_SURFACE_WIDTH_STORAGE_KEY}:${normalizedSurfaceScope(scope)}`; +} + +export function readChatFirstSurfaceWidth( + scope = "default", + storage: StringStorage | null = browserStorage(), +): number { + if (!storage) return CHAT_FIRST_SURFACE_WIDTH_DEFAULT; + try { + const raw = storage.getItem(surfaceWidthStorageKey(scope)); + if (!raw) return CHAT_FIRST_SURFACE_WIDTH_DEFAULT; + const value = Number(raw); + return Number.isFinite(value) + ? clampChatFirstSurfaceWidth(value) + : CHAT_FIRST_SURFACE_WIDTH_DEFAULT; + } catch { + return CHAT_FIRST_SURFACE_WIDTH_DEFAULT; + } +} + +export function writeChatFirstSurfaceWidth( + width: number, + scope = "default", + storage: StringStorage | null = browserStorage(), +): { ok: true } | { ok: false; reason: "unavailable" | "write-failed" } { + if (!storage) return { ok: false, reason: "unavailable" }; + try { + storage.setItem( + surfaceWidthStorageKey(scope), + String(clampChatFirstSurfaceWidth(width)), + ); + return { ok: true }; + } catch { + return { ok: false, reason: "write-failed" }; + } +} + +export function useChatFirstSurfaceResize(scope = "default") { + const [width, setWidth] = useState(() => readChatFirstSurfaceWidth(scope)); + const [isResizing, setIsResizing] = useState(false); + const widthRef = useRef(width); + const cleanupRef = useRef<(() => void) | null>(null); + widthRef.current = width; + + useEffect(() => { + cleanupRef.current?.(); + cleanupRef.current = null; + const next = readChatFirstSurfaceWidth(scope); + widthRef.current = next; + setWidth(next); + setIsResizing(false); + return () => cleanupRef.current?.(); + }, [scope]); + + const onPointerDown = useCallback( + (event: { clientX: number; preventDefault: () => void }) => { + if (typeof window === "undefined") return; + event.preventDefault(); + cleanupRef.current?.(); + const startX = event.clientX; + const startWidth = widthRef.current; + setIsResizing(true); + const handleMove = (moveEvent: PointerEvent) => { + const next = clampChatFirstSurfaceWidth( + startWidth - (moveEvent.clientX - startX), + ); + widthRef.current = next; + setWidth(next); + }; + const handleUp = () => { + window.removeEventListener("pointermove", handleMove); + window.removeEventListener("pointerup", handleUp); + cleanupRef.current = null; + setIsResizing(false); + writeChatFirstSurfaceWidth(widthRef.current, scope); + }; + cleanupRef.current = () => { + window.removeEventListener("pointermove", handleMove); + window.removeEventListener("pointerup", handleUp); + }; + window.addEventListener("pointermove", handleMove); + window.addEventListener("pointerup", handleUp, { once: true }); + }, + [scope], + ); + + return { width, isResizing, onPointerDown }; +} + +function surfacePanelStorageKey(scope: string): string { + return `${CHAT_FIRST_SURFACE_PANEL_STORAGE_KEY}:${normalizedSurfaceScope(scope)}`; +} + +function createChatFirstSurfacePanelStore( + scope: string, +): ChatFirstSurfacePanelStore { + let state: ChatFirstSurfacePanelState = { open: false }; + const listeners = new Set<() => void>(); + try { + state = { + open: browserStorage()?.getItem(surfacePanelStorageKey(scope)) === "true", + }; + } catch { + // The panel is an optional device preference; the live default is closed. + } + const publish = (open: boolean) => { + if (state.open === open) return; + state = { open }; + try { + browserStorage()?.setItem(surfacePanelStorageKey(scope), String(open)); + } catch { + // Keep the in-memory toggle usable when device storage is unavailable. + } + listeners.forEach((listener) => listener()); + }; + return { + getSnapshot: () => state, + subscribe: (listener) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + setOpen: publish, + toggle: () => publish(!state.open), + }; +} + +const chatFirstSurfacePanelStores = new Map< + string, + ChatFirstSurfacePanelStore +>(); + +export function getChatFirstSurfacePanelStore( + scope = "default", +): ChatFirstSurfacePanelStore { + const key = normalizedSurfaceScope(scope); + const existing = chatFirstSurfacePanelStores.get(key); + if (existing) return existing; + const store = createChatFirstSurfacePanelStore(key); + chatFirstSurfacePanelStores.set(key, store); + return store; +} + +export function useChatFirstSurfacePanel(scope = "default") { + const store = getChatFirstSurfacePanelStore(scope); + const state = useSyncExternalStore( + store.subscribe, + store.getSnapshot, + store.getSnapshot, + ); + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if ( + (event.metaKey || event.ctrlKey) && + event.altKey && + event.key.toLowerCase() === "b" + ) { + event.preventDefault(); + store.toggle(); + } + }; + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [store]); + + return { ...state, setOpen: store.setOpen, toggle: store.toggle }; +} + +export interface ChatFirstSessionWatchDelivery { + delivered: boolean; + reason?: "empty-detail" | "no-listener"; +} + +export type ChatFirstBrowserResolution = + | { status: "ready"; target: { url: string; title?: string } } + | { status: "unresolved"; reason: "empty-detail" | "invalid-url" }; + +export interface ChatFirstAppRegistration { + id: string; + name?: string; + enabled?: boolean; + /** The app's current production or mounted URL. */ + url?: string | null; + /** Optional local development URL used by the desktop shell. */ + devUrl?: string | null; + /** Mounted path used when an app has no absolute URL. */ + path?: string | null; +} + +export interface ChatFirstAppTarget { + appId: string; + path?: string; + view?: string; +} + +export type ChatFirstAppResolution = + | { status: "ready"; target: ChatFirstAppTarget } + | { + status: "unresolved"; + reason: "empty-detail" | "unknown-app" | "invalid-url"; + }; + +export interface ChatFirstOpenAppDelivery { + delivered: boolean; + reason?: "empty-detail" | "no-listener"; +} + +export interface ChatFirstAppLayoutPreference { + pinnedIds: string[]; + orderedIds: string[]; +} + +export interface ChatFirstModeReadResult { + enabled: boolean; + availability: "available" | "unavailable"; +} + +type StringStorage = Pick; + +function browserStorage(): StringStorage | null { + if (typeof window === "undefined") return null; + try { + return window.localStorage; + } catch { + return null; + } +} + +export function readChatFirstModeState( + storage: StringStorage | null = browserStorage(), +): ChatFirstModeReadResult { + if (!storage) { + return { enabled: false, availability: "unavailable" }; + } + try { + return { + enabled: storage.getItem(CHAT_FIRST_MODE_STORAGE_KEY) === "true", + availability: "available", + }; + } catch { + return { enabled: false, availability: "unavailable" }; + } +} + +export function readChatFirstMode( + storage: StringStorage | null = browserStorage(), +): boolean { + return readChatFirstModeState(storage).enabled; +} + +export function writeChatFirstMode( + enabled: boolean, + storage: StringStorage | null = browserStorage(), +): { ok: true } | { ok: false; reason: "unavailable" | "write-failed" } { + if (!storage) return { ok: false, reason: "unavailable" }; + try { + storage.setItem(CHAT_FIRST_MODE_STORAGE_KEY, String(enabled)); + return { ok: true }; + } catch { + return { ok: false, reason: "write-failed" }; + } +} + +const EMPTY_CHAT_FIRST_APP_LAYOUT: ChatFirstAppLayoutPreference = { + pinnedIds: [], + orderedIds: [], +}; + +export function readChatFirstAppLayout( + storage: StringStorage | null = browserStorage(), +): ChatFirstAppLayoutPreference { + if (!storage) return { ...EMPTY_CHAT_FIRST_APP_LAYOUT }; + try { + const raw = storage.getItem(CHAT_FIRST_APP_LAYOUT_STORAGE_KEY); + if (!raw) return { ...EMPTY_CHAT_FIRST_APP_LAYOUT }; + const parsed: unknown = JSON.parse(raw); + if (!parsed || typeof parsed !== "object") { + return { ...EMPTY_CHAT_FIRST_APP_LAYOUT }; + } + const value = parsed as Record; + const ids = (candidate: unknown): string[] => + Array.isArray(candidate) + ? candidate.filter( + (item): item is string => + typeof item === "string" && item.trim().length > 0, + ) + : []; + return { + pinnedIds: [...new Set(ids(value.pinnedIds))], + orderedIds: [...new Set(ids(value.orderedIds))], + }; + } catch { + return { ...EMPTY_CHAT_FIRST_APP_LAYOUT }; + } +} + +export function writeChatFirstAppLayout( + layout: ChatFirstAppLayoutPreference, + storage: StringStorage | null = browserStorage(), +): { ok: true } | { ok: false; reason: "unavailable" | "write-failed" } { + if (!storage) return { ok: false, reason: "unavailable" }; + try { + storage.setItem( + CHAT_FIRST_APP_LAYOUT_STORAGE_KEY, + JSON.stringify({ + pinnedIds: [...new Set(layout.pinnedIds.filter(Boolean))], + orderedIds: [...new Set(layout.orderedIds.filter(Boolean))], + }), + ); + return { ok: true }; + } catch { + return { ok: false, reason: "write-failed" }; + } +} + +export function orderChatFirstAppIds( + appIds: readonly string[], + layout: ChatFirstAppLayoutPreference, +): string[] { + const available = new Set(appIds); + const ordered = [ + ...layout.orderedIds.filter((id) => available.has(id)), + ...appIds.filter((id) => !layout.orderedIds.includes(id)), + ]; + const pinned = new Set(layout.pinnedIds.filter((id) => available.has(id))); + // `orderedIds` is the single source of positional truth. Pinning changes + // presentation only; it must not rewrite the user's drag order or make an + // unpinned app jump to the fallback order. + return [ + ...ordered.filter((id) => pinned.has(id)), + ...ordered.filter((id) => !pinned.has(id)), + ]; +} + +export function resolveChatFirstBrowserTarget( + detail: ChatFirstOpenBrowserDetail, +): ChatFirstBrowserResolution { + const rawUrl = detail.url?.trim(); + if (!rawUrl) return { status: "unresolved", reason: "empty-detail" }; + const url = parseAbsoluteHttpUrl(rawUrl); + if (!url || url.username || url.password) { + return { status: "unresolved", reason: "invalid-url" }; + } + return { + status: "ready", + target: { + url: url.href, + ...(detail.title?.trim() ? { title: detail.title.trim() } : {}), + }, + }; +} + +type ChatFirstOpenAppListener = (detail: ChatFirstOpenAppDetail) => void; +const chatFirstOpenAppListeners = new Set(); +type ChatFirstOpenBrowserListener = ( + detail: ChatFirstOpenBrowserDetail, +) => void; +const chatFirstOpenBrowserListeners = new Set(); + +function normalizeSessionId(value: unknown): string | null { + if (typeof value !== "string") return null; + const id = value.trim(); + return id || null; +} + +/** Resolve ids from run/thread payloads without treating an absent id as a session. */ +export function resolveChatFirstSessionId(value: unknown): string | null { + if (typeof value === "string") return normalizeSessionId(value); + if (!value || typeof value !== "object") return null; + const record = value as Record; + for (const key of ["sessionId", "runId", "threadId", "id"]) { + const id = normalizeSessionId(record[key]); + if (id) return id; + } + return null; +} + +export function normalizeChatFirstSessionReference( + value: unknown, +): ChatFirstSessionReference | null { + const sessionId = resolveChatFirstSessionId(value); + if (!sessionId) return null; + if (!value || typeof value !== "object") return { sessionId }; + const record = value as Record; + const title = normalizeSessionId(record.title); + const goalId = normalizeSessionId(record.goalId); + const sourceSessionId = normalizeSessionId(record.sourceSessionId); + const kind = + record.kind === "code-agent" || + record.kind === "agent-chat" || + record.kind === "external" + ? record.kind + : undefined; + return { + sessionId, + ...(title ? { title } : {}), + ...(kind ? { kind } : {}), + ...(goalId ? { goalId } : {}), + ...(sourceSessionId ? { sourceSessionId } : {}), + }; +} + +function createChatFirstSessionWatchStore(): ChatFirstSessionWatchStore { + let state: ChatFirstSessionWatchState = { target: null }; + const listeners = new Set<() => void>(); + return { + getSnapshot: () => state, + subscribe: (listener) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + hasSubscribers: () => listeners.size > 0, + open: (target) => { + state = { target }; + listeners.forEach((listener) => listener()); + }, + close: () => { + if (!state.target) return; + state = { target: null }; + listeners.forEach((listener) => listener()); + }, + }; +} + +const chatFirstSessionWatchStore = createChatFirstSessionWatchStore(); + +export function getChatFirstSessionWatchStore(): ChatFirstSessionWatchStore { + return chatFirstSessionWatchStore; +} + +export function useChatFirstSessionWatch(): ChatFirstSessionWatchState { + return useSyncExternalStore( + chatFirstSessionWatchStore.subscribe, + chatFirstSessionWatchStore.getSnapshot, + chatFirstSessionWatchStore.getSnapshot, + ); +} + +export function subscribeChatFirstSessionWatch( + listener: (target: ChatFirstSessionReference) => void, +): () => void { + if (typeof window === "undefined") return () => undefined; + listenersForWatch += 1; + const handleEvent = (event: Event) => { + const target = normalizeChatFirstSessionReference( + (event as CustomEvent).detail, + ); + if (target) listener(target); + }; + window.addEventListener(CHAT_FIRST_WATCH_SESSION_EVENT, handleEvent); + return () => { + listenersForWatch = Math.max(0, listenersForWatch - 1); + window.removeEventListener(CHAT_FIRST_WATCH_SESSION_EVENT, handleEvent); + }; +} + +export function emitChatFirstSessionWatch( + detail: unknown, +): ChatFirstSessionWatchDelivery { + const target = normalizeChatFirstSessionReference(detail); + if (!target) return { delivered: false, reason: "empty-detail" }; + const delivered = + chatFirstSessionWatchStore.hasSubscribers() || listenersForWatch > 0; + chatFirstSessionWatchStore.open(target); + if (typeof window !== "undefined") { + window.dispatchEvent( + new CustomEvent( + CHAT_FIRST_WATCH_SESSION_EVENT, + { detail: target }, + ), + ); + } + return delivered + ? { delivered: true } + : { delivered: false, reason: "no-listener" }; +} + +let listenersForWatch = 0; + +export function closeChatFirstSessionWatch(): void { + chatFirstSessionWatchStore.close(); +} + +function readChatFirstSurfaceTabsState( + scope: string, +): ChatFirstSurfaceTabsState { + const storage = browserStorage(); + if (!storage) return { tabs: [], activeTabId: null }; + try { + const raw = storage.getItem(CHAT_FIRST_SURFACE_TABS_STORAGE_KEY); + if (!raw) return { tabs: [], activeTabId: null }; + const parsed: unknown = JSON.parse(raw); + if (!parsed || typeof parsed !== "object") { + return { tabs: [], activeTabId: null }; + } + const scoped = (parsed as Record)[scope]; + if (!scoped || typeof scoped !== "object") { + return { tabs: [], activeTabId: null }; + } + const value = scoped as Record; + const tabs = Array.isArray(value.tabs) + ? value.tabs.flatMap((candidate) => { + const tab = normalizePersistedChatFirstSurfaceTab(candidate); + return tab ? [tab] : []; + }) + : []; + const activeTabId = + typeof value.activeTabId === "string" && + tabs.some((tab) => tab.id === value.activeTabId) + ? value.activeTabId + : (tabs[0]?.id ?? null); + return { tabs, activeTabId }; + } catch { + return { tabs: [], activeTabId: null }; + } +} + +function normalizePersistedChatFirstSurfaceTab( + candidate: unknown, +): ChatFirstSurfaceTab | null { + if (!candidate || typeof candidate !== "object") return null; + const value = candidate as Record; + const id = typeof value.id === "string" ? value.id.trim() : ""; + const title = typeof value.title === "string" ? value.title.trim() : ""; + const kind = value.kind; + if ( + !id || + !title || + typeof kind !== "string" || + !CHAT_FIRST_SURFACE_KINDS.includes(kind as ChatFirstSurfaceKind) + ) { + return null; + } + + const tab = value as unknown as ChatFirstSurfaceTab; + if (kind === "app") { + const appId = typeof value.appId === "string" ? value.appId.trim() : ""; + if (!appId) return null; + if ( + value.path !== undefined && + (typeof value.path !== "string" || normalizedPath(value.path) === null) + ) { + return null; + } + return { ...tab, id, title, appId }; + } + if (kind === "browser") { + const url = typeof value.url === "string" ? value.url.trim() : ""; + const parsed = parseAbsoluteHttpUrl(url); + if (!parsed || parsed.username || parsed.password) return null; + return { ...tab, id, title, url: parsed.href }; + } + if (kind === "side-chat") { + const session = normalizeChatFirstSessionReference(value.session); + return session ? { ...tab, id, title, session } : null; + } + return { ...tab, id, title, kind: kind as ChatFirstSurfaceKind }; +} + +function writeChatFirstSurfaceTabsState( + scope: string, + state: ChatFirstSurfaceTabsState, +): void { + const storage = browserStorage(); + if (!storage) return; + try { + const raw = storage.getItem(CHAT_FIRST_SURFACE_TABS_STORAGE_KEY); + const parsed: unknown = raw ? JSON.parse(raw) : {}; + const all = + parsed && typeof parsed === "object" + ? (parsed as Record) + : {}; + storage.setItem( + CHAT_FIRST_SURFACE_TABS_STORAGE_KEY, + JSON.stringify({ ...all, [scope]: state }), + ); + } catch { + // Device-local tab restoration is optional; the live store remains usable. + } +} + +function createChatFirstSurfaceTabsStore( + scope: string, +): ChatFirstSurfaceTabsStore { + let state = readChatFirstSurfaceTabsState(scope); + const listeners = new Set<() => void>(); + const publish = (next: ChatFirstSurfaceTabsState) => { + state = next; + writeChatFirstSurfaceTabsState(scope, state); + listeners.forEach((listener) => listener()); + }; + const activeIndex = (tabId: string) => + state.tabs.findIndex((tab) => tab.id === tabId); + + return { + getSnapshot: () => state, + subscribe: (listener) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + open: (tab) => { + const existingIndex = activeIndex(tab.id); + if (existingIndex >= 0) { + const tabs = state.tabs.map((current, index) => + index === existingIndex ? tab : current, + ); + publish({ tabs, activeTabId: tab.id }); + return; + } + publish({ + tabs: [...state.tabs, tab], + activeTabId: tab.id, + }); + }, + activate: (tabId) => { + if (activeIndex(tabId) < 0 || state.activeTabId === tabId) return; + publish({ ...state, activeTabId: tabId }); + }, + close: (tabId) => { + const index = activeIndex(tabId); + if (index < 0) return; + const tabs = state.tabs.filter((tab) => tab.id !== tabId); + const activeTabId = + state.activeTabId !== tabId + ? state.activeTabId + : (tabs[index - 1]?.id ?? tabs[index]?.id ?? null); + publish({ tabs, activeTabId }); + }, + closeOthers: (tabId) => { + if (activeIndex(tabId) < 0) return; + const tab = state.tabs.find((candidate) => candidate.id === tabId); + if (!tab) return; + publish({ tabs: [tab], activeTabId: tab.id }); + }, + closeToRight: (tabId) => { + const index = activeIndex(tabId); + if (index < 0) return; + const tabs = state.tabs.slice(0, index + 1); + const activeTabId = tabs.some((tab) => tab.id === state.activeTabId) + ? state.activeTabId + : (tabs[index]?.id ?? null); + publish({ tabs, activeTabId }); + }, + closeAll: () => publish({ tabs: [], activeTabId: null }), + }; +} + +const chatFirstSurfaceTabsStores = new Map(); + +function normalizedSurfaceScope(scope: string): string { + const value = scope.trim(); + return value || "default"; +} + +export function getChatFirstSurfaceTabsStore( + scope = "default", +): ChatFirstSurfaceTabsStore { + const key = normalizedSurfaceScope(scope); + const existing = chatFirstSurfaceTabsStores.get(key); + if (existing) return existing; + const store = createChatFirstSurfaceTabsStore(key); + chatFirstSurfaceTabsStores.set(key, store); + return store; +} + +export function useChatFirstSurfaceTabs( + scope = "default", +): ChatFirstSurfaceTabsState { + const store = getChatFirstSurfaceTabsStore(scope); + return useSyncExternalStore( + store.subscribe, + store.getSnapshot, + store.getSnapshot, + ); +} + +export function subscribeChatFirstOpenApp( + listener: ChatFirstOpenAppListener, +): () => void { + if (typeof window === "undefined") return () => undefined; + const subscription = Symbol("chat-first-open-app"); + const handleEvent = (event: Event) => { + const detail = (event as CustomEvent).detail; + if (detail && typeof detail === "object") listener(detail); + }; + chatFirstOpenAppListeners.add(subscription); + window.addEventListener(CHAT_FIRST_OPEN_APP_EVENT, handleEvent); + return () => { + chatFirstOpenAppListeners.delete(subscription); + window.removeEventListener(CHAT_FIRST_OPEN_APP_EVENT, handleEvent); + }; +} + +export function emitChatFirstOpenApp( + detail: ChatFirstOpenAppDetail, +): ChatFirstOpenAppDelivery { + if (!detail.app && !detail.url && !detail.path && !detail.view) { + return { delivered: false, reason: "empty-detail" }; + } + if (typeof window === "undefined") { + return { delivered: false, reason: "no-listener" }; + } + const delivered = chatFirstOpenAppListeners.size > 0; + window.dispatchEvent( + new CustomEvent(CHAT_FIRST_OPEN_APP_EVENT, { + detail, + }), + ); + return delivered + ? { delivered: true } + : { delivered: false, reason: "no-listener" }; +} + +export function subscribeChatFirstOpenBrowser( + listener: ChatFirstOpenBrowserListener, +): () => void { + if (typeof window === "undefined") return () => undefined; + const subscription = Symbol("chat-first-open-browser"); + const handleEvent = (event: Event) => { + const detail = (event as CustomEvent).detail; + if (detail && typeof detail === "object") listener(detail); + }; + chatFirstOpenBrowserListeners.add(subscription); + window.addEventListener(CHAT_FIRST_OPEN_BROWSER_EVENT, handleEvent); + return () => { + chatFirstOpenBrowserListeners.delete(subscription); + window.removeEventListener(CHAT_FIRST_OPEN_BROWSER_EVENT, handleEvent); + }; +} + +export function emitChatFirstOpenBrowser( + detail: ChatFirstOpenBrowserDetail, +): ChatFirstOpenAppDelivery { + if (!detail.url?.trim()) { + return { delivered: false, reason: "empty-detail" }; + } + if (typeof window === "undefined") { + return { delivered: false, reason: "no-listener" }; + } + const delivered = chatFirstOpenBrowserListeners.size > 0; + window.dispatchEvent( + new CustomEvent(CHAT_FIRST_OPEN_BROWSER_EVENT, { + detail, + }), + ); + return delivered + ? { delivered: true } + : { delivered: false, reason: "no-listener" }; +} + +function normalizedPath(value: string | null | undefined): string | null { + const path = value?.trim(); + if (!path || !path.startsWith("/") || path.startsWith("//")) return null; + if (path.startsWith("/\\") || /[\u0000-\u001f\u007f]/.test(path)) { + return null; + } + if (/^\/[a-z][a-z0-9+.-]*:/i.test(path)) return null; + if (path.split(/[?#]/, 1)[0]?.split("/").includes("..")) return null; + return path; +} + +function parseAbsoluteHttpUrl(value: string): URL | null { + try { + const url = new URL(value); + return url.protocol === "http:" || url.protocol === "https:" ? url : null; + } catch { + return null; + } +} + +function registrationUrls( + app: ChatFirstAppRegistration, + currentOrigin?: string, +): URL[] { + const values = [app.url, app.devUrl]; + if (app.path && currentOrigin) values.push(`${currentOrigin}${app.path}`); + return values.flatMap((value) => { + if (!value) return []; + const absolute = parseAbsoluteHttpUrl(value); + if (absolute) return [absolute]; + if (value.startsWith("/") && currentOrigin) { + const relative = parseAbsoluteHttpUrl(`${currentOrigin}${value}`); + return relative ? [relative] : []; + } + return []; + }); +} + +function appMatchesUrl( + app: ChatFirstAppRegistration, + targetUrl: URL, + currentOrigin?: string, +): URL | null { + for (const registeredUrl of registrationUrls(app, currentOrigin)) { + if (registeredUrl.origin !== targetUrl.origin) continue; + const basePath = registeredUrl.pathname.replace(/\/+$/, "") || "/"; + if ( + basePath !== "/" && + targetUrl.pathname !== basePath && + !targetUrl.pathname.startsWith(`${basePath}/`) + ) { + continue; + } + return registeredUrl; + } + return null; +} + +function appRelativeUrlPath(targetUrl: URL, registeredUrl: URL): string { + const basePath = registeredUrl.pathname.replace(/\/+$/, "") || "/"; + const pathname = + basePath === "/" + ? targetUrl.pathname + : targetUrl.pathname === basePath + ? "/" + : targetUrl.pathname.slice(basePath.length) || "/"; + return `${pathname}${targetUrl.search}${targetUrl.hash}`; +} + +function viewPath(appId: string, view: string): string { + const params = new URLSearchParams({ app: appId, view }); + return `/_agent-native/open?${params.toString()}`; +} + +export function resolveChatFirstAppTarget( + detail: ChatFirstOpenAppDetail, + apps: readonly ChatFirstAppRegistration[], + options: { currentOrigin?: string } = {}, +): ChatFirstAppResolution { + if (!detail.app && !detail.url && !detail.path && !detail.view) { + return { status: "unresolved", reason: "empty-detail" }; + } + + const availableApps = apps.filter((app) => app.enabled !== false); + const requestedId = detail.app?.trim().toLowerCase(); + let app = requestedId + ? availableApps.find( + (candidate) => + candidate.id.toLowerCase() === requestedId || + candidate.name?.trim().toLowerCase() === requestedId, + ) + : undefined; + + const rawUrl = detail.url?.trim(); + let targetUrl: URL | null = null; + if (rawUrl) { + targetUrl = parseAbsoluteHttpUrl(rawUrl); + if (!targetUrl) { + const relativePath = normalizedPath(rawUrl); + if (!app || !relativePath) { + return { status: "unresolved", reason: "invalid-url" }; + } + } + } + + if (targetUrl) { + if (app) { + const registeredUrl = appMatchesUrl( + app, + targetUrl, + options.currentOrigin, + ); + if (!registeredUrl) { + return { status: "unresolved", reason: "invalid-url" }; + } + } else { + const matchingApp = availableApps + .map((candidate) => ({ + app: candidate, + registeredUrl: appMatchesUrl( + candidate, + targetUrl as URL, + options.currentOrigin, + ), + })) + .find((candidate) => candidate.registeredUrl); + if (!matchingApp) { + return { status: "unresolved", reason: "unknown-app" }; + } + app = matchingApp.app; + } + } + + if (!app) return { status: "unresolved", reason: "unknown-app" }; + + let path: string | undefined; + if (targetUrl) { + const registeredUrl = appMatchesUrl(app, targetUrl, options.currentOrigin); + if (!registeredUrl) { + return { status: "unresolved", reason: "invalid-url" }; + } + path = appRelativeUrlPath(targetUrl, registeredUrl); + } else if (rawUrl) { + path = normalizedPath(rawUrl) ?? undefined; + } else if (detail.path) { + path = normalizedPath(detail.path) ?? undefined; + if (!path) return { status: "unresolved", reason: "invalid-url" }; + } else if (detail.view?.trim()) { + path = viewPath(app.id, detail.view.trim()); + } + + return { + status: "ready", + target: { + appId: app.id, + ...(path ? { path } : {}), + ...(detail.view?.trim() ? { view: detail.view.trim() } : {}), + }, + }; +} diff --git a/packages/core/src/client/chat/runtime.spec.ts b/packages/core/src/client/chat/runtime.spec.ts index 8ee4ad9f85..72e926f4b8 100644 --- a/packages/core/src/client/chat/runtime.spec.ts +++ b/packages/core/src/client/chat/runtime.spec.ts @@ -1,5 +1,9 @@ import { describe, expect, expectTypeOf, it, vi } from "vitest"; +import { + subscribeChatFirstOpenApp, + subscribeChatFirstOpenBrowser, +} from "../chat-first.js"; import type { AgentChatRuntime as AgentChatRuntimeFromClientBarrel } from "../index.js"; import type { AgentChatRuntime as AgentChatRuntimeFromChatBarrel } from "./index.js"; import { @@ -360,6 +364,114 @@ describe("createAgentNativeChatRuntime", () => { }); describe("createAgentChatRuntimeAdapter", () => { + it("emits exact first-party app/browser tools after completed tool calls", async () => { + const listeners = new Map void>>(); + const fakeWindow = { + addEventListener(type: string, listener: (event: unknown) => void) { + const current = listeners.get(type) ?? new Set(); + current.add(listener); + listeners.set(type, current); + }, + removeEventListener(type: string, listener: (event: unknown) => void) { + listeners.get(type)?.delete(listener); + }, + dispatchEvent(event: { type: string }) { + for (const listener of listeners.get(event.type) ?? []) listener(event); + return true; + }, + }; + class FakeCustomEvent { + readonly type: string; + readonly detail: unknown; + + constructor(type: string, init: { detail: unknown }) { + this.type = type; + this.detail = init.detail; + } + } + vi.stubGlobal("window", fakeWindow); + vi.stubGlobal("CustomEvent", FakeCustomEvent); + + const appDetails: unknown[] = []; + const browserDetails: unknown[] = []; + const unsubscribeApp = subscribeChatFirstOpenApp((detail) => + appDetails.push(detail), + ); + const unsubscribeBrowser = subscribeChatFirstOpenBrowser((detail) => + browserDetails.push(detail), + ); + const runtime: AgentChatRuntime = { + id: "external:test", + kind: "external-agent", + label: "Test runtime", + capabilities: { messages: { streaming: true } }, + async createSession() { + return { + id: "session-1", + runtimeId: "external:test", + async startTurn() { + async function* events(): AsyncIterable { + yield { + type: "tool-done", + toolCallId: "app-1", + toolName: "open_app", + status: "completed", + resultText: JSON.stringify({ + app: "mail", + path: "/inbox", + }), + }; + yield { + type: "tool-done", + toolCallId: "evil-1", + toolName: "evil___open_app", + status: "completed", + resultText: JSON.stringify({ + app: "mail", + path: "/phishing", + }), + }; + yield { + type: "tool-done", + toolCallId: "browser-1", + toolName: "open_browser", + status: "completed", + resultText: JSON.stringify({ + url: "https://example.com/docs", + title: "Docs", + }), + }; + yield { type: "done", reason: "complete" }; + } + return { + id: "turn-1", + sessionId: "session-1", + events: events(), + }; + }, + }; + }, + }; + + try { + await drain( + createAgentChatRuntimeAdapter(runtime).run({ + messages: [], + abortSignal: new AbortController().signal, + runConfig: {}, + } as any), + ); + expect(appDetails).toEqual([{ app: "mail", path: "/inbox" }]); + expect(browserDetails).toEqual([ + { url: "https://example.com/docs", title: "Docs" }, + ]); + } finally { + unsubscribeApp(); + unsubscribeBrowser(); + vi.unstubAllGlobals(); + } + }); + it("attaches approval to the call matching toolCallId, not the latest same-named call", async () => { const runtime: AgentChatRuntime = { id: "external:test", diff --git a/packages/core/src/client/chat/runtime.ts b/packages/core/src/client/chat/runtime.ts index 6bade6afda..e701dbceff 100644 --- a/packages/core/src/client/chat/runtime.ts +++ b/packages/core/src/client/chat/runtime.ts @@ -4,6 +4,10 @@ import type { ActionChatUIConfig } from "../../action-ui.js"; import type { AgentMcpAppPayload } from "../../mcp-client/app-result.js"; import type { ReasoningEffort } from "../../shared/reasoning-effort.js"; import { agentNativePath } from "../api-path.js"; +import { + emitChatFirstOpenApp, + emitChatFirstOpenBrowser, +} from "../chat-first.js"; import { formatChatErrorText, normalizeChatError } from "../error-format.js"; import { settleInterruptedToolCalls, @@ -1796,6 +1800,7 @@ export function createAgentChatRuntimeAdapter( }; try { for await (const event of turn.events) { + emitChatFirstOpenAppFromRuntimeEvent(event); const result = applyRuntimeEventToContent(event, projection); if (result) { const metadata = (result.metadata ?? {}) as Record; @@ -1826,3 +1831,83 @@ export function createAgentChatRuntimeAdapter( }, }; } + +function emitChatFirstOpenAppFromRuntimeEvent( + event: AgentChatRuntimeEventBase, +): void { + if (event.type !== "tool-done") return; + const toolEvent = event as AgentChatRuntimeToolDoneEvent; + if ( + toolEvent.status !== "completed" || + (!isOpenAppTool(toolEvent.toolName) && + !isOpenBrowserTool(toolEvent.toolName)) + ) { + return; + } + + const result = + asRecord(toolEvent.result) ?? parseResultRecord(toolEvent.resultText); + if (!result) { + console.warn( + `[chat-first] ${toolEvent.toolName} completed without a readable result`, + ); + return; + } + if (isOpenBrowserTool(toolEvent.toolName)) { + const delivery = emitChatFirstOpenBrowser({ + url: readString(result.url ?? result.href), + title: readString(result.title ?? result.name), + }); + warnWhenChatFirstDeliveryIsNotObserved(toolEvent.toolName, delivery); + return; + } + + const detail = { + app: readString(result.app ?? result.appId ?? result.application), + path: readString(result.path ?? result.targetPath), + url: readString(result.url ?? result.href), + view: readString(result.view), + }; + const delivery = emitChatFirstOpenApp(detail); + warnWhenChatFirstDeliveryIsNotObserved(toolEvent.toolName, delivery); +} + +function warnWhenChatFirstDeliveryIsNotObserved( + toolName: string, + delivery: { delivered: boolean; reason?: string }, +): void { + if (delivery.delivered) return; + console.warn( + `[chat-first] ${toolName} was completed but not delivered (${delivery.reason ?? "unknown"})`, + ); +} + +function isOpenAppTool(toolName: string): boolean { + return toolName === "open_app"; +} + +function isOpenBrowserTool(toolName: string): boolean { + return toolName === "open_browser"; +} + +function asRecord(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null; +} + +function readString(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value : undefined; +} + +function parseResultRecord( + value: string | undefined, +): Record | null { + if (!value?.trim()) return null; + try { + return asRecord(JSON.parse(value)); + } catch { + // coercion-ok: malformed tool output is an unreadable absence; the caller logs it. + return null; + } +} diff --git a/packages/desktop-app/DESIGN.md b/packages/desktop-app/DESIGN.md new file mode 100644 index 0000000000..d136367046 --- /dev/null +++ b/packages/desktop-app/DESIGN.md @@ -0,0 +1,32 @@ +# Desktop chat-first workbench + +## Direction + +The opt-in desktop mode is a quiet command center: a narrow, persistent +conversation rail, a generous chat canvas, and a contextual app pane that +appears only when the user or agent asks for one. It borrows the information +architecture of Codex and T3 Code while keeping Agent-Native's existing +surface, typography, iconography, and app URLs. + +## Layout contract + +- The shared Code Agents rail owns New chat, search, subscriptions, computer + access, pinned/recent chat history, and the composer. +- Desktop adds Integrations and Scheduled as first-party rail actions and + shows at most six workspace apps above Chats. +- App panes use the existing Electron AppWebview and have a small title row, + no address bar, and no nested app/sidebar chrome. +- Chat rows expose a right-click menu for copying the stable session ID and + opening a live watch/message panel backed by the existing run-manager host. +- The tabbed side-surface shell currently ships app, browser, and watched-session + surfaces. Terminal, files, diff, and live-agent panes stay explicit deferred + cards until their platform-specific data boundaries are connected. +- The mode is opt-in and persisted in frame settings; the existing app-first + shell remains the default. + +## Future boundary + +Cloud handoff should move the same thread/run descriptor to a worker +residence, not create a second conversation. This mode only establishes the +local contextual-pane seam; computer use remains local until that worker +contract is implemented. diff --git a/packages/desktop-app/src/main/app-store.ts b/packages/desktop-app/src/main/app-store.ts index e155378fcd..5cd0387bc6 100644 --- a/packages/desktop-app/src/main/app-store.ts +++ b/packages/desktop-app/src/main/app-store.ts @@ -101,6 +101,7 @@ function defaultFrameSettings(): FrameSettings { return { enabled: true, showCodeTab: true, + chatFirstMode: false, mode: app.isPackaged ? "prod" : "dev", }; } diff --git a/packages/desktop-app/src/main/index.ts b/packages/desktop-app/src/main/index.ts index de7b5e3320..84bdaaf5e9 100644 --- a/packages/desktop-app/src/main/index.ts +++ b/packages/desktop-app/src/main/index.ts @@ -234,7 +234,16 @@ if (IS_DEV) { // Keep local electron-vite runs out of the packaged app's Chromium profile. // Sharing the same userData directory lets dev and prod processes fight over // persisted webview storage (notably IndexedDB LevelDB LOCK files). - const devUserDataPath = path.join(app.getPath("appData"), "Agent Native Dev"); + // An explicit Electron --user-data-dir remains authoritative so scripted + // desktop smoke tests can isolate state without touching a developer's + // normal profile. + const requestedUserDataPath = process.argv + .find((argument) => argument.startsWith("--user-data-dir=")) + ?.slice("--user-data-dir=".length) + .trim(); + const devUserDataPath = requestedUserDataPath + ? path.resolve(requestedUserDataPath) + : path.join(app.getPath("appData"), "Agent Native Dev"); try { fs.mkdirSync(devUserDataPath, { recursive: true }); app.setPath("userData", devUserDataPath); diff --git a/packages/desktop-app/src/renderer/App.tsx b/packages/desktop-app/src/renderer/App.tsx index 5cb6308511..7a1d1520c4 100644 --- a/packages/desktop-app/src/renderer/App.tsx +++ b/packages/desktop-app/src/renderer/App.tsx @@ -19,7 +19,7 @@ import AppSettings, { } from "./components/AppSettings.js"; import AppWebview, { type AppWebviewHandle } from "./components/AppWebview.js"; import CodeAgentsHub from "./components/CodeAgentsHub.js"; -import Sidebar from "./components/Sidebar.js"; +import Sidebar, { WindowControls } from "./components/Sidebar.js"; import TabBar from "./components/TabBar.js"; import UpdatePrompt from "./components/UpdatePrompt.js"; import { shouldReserveMacOSWindowControlsSpace } from "./lib/platform.js"; @@ -135,6 +135,7 @@ export default function App() { null, ); const [showCodeAgentsTab, setShowCodeAgentsTab] = useState(true); + const [chatFirstMode, setChatFirstMode] = useState(false); const [hasMountedCodeAgents, setHasMountedCodeAgents] = useState(false); const [codeAgentsOpenRequest, setCodeAgentsOpenRequest] = useState<{ goalId?: string; @@ -167,8 +168,14 @@ export default function App() { if (!window.electronAPI?.frame) return; window.electronAPI.frame .load() - .then((settings) => setShowCodeAgentsTab(settings.showCodeTab)) - .catch(() => setShowCodeAgentsTab(true)); + .then((settings) => { + setShowCodeAgentsTab(settings.showCodeTab); + setChatFirstMode(settings.chatFirstMode === true); + }) + .catch(() => { + setShowCodeAgentsTab(true); + setChatFirstMode(false); + }); }, []); const enabledApps = apps.filter((a) => a.enabled); @@ -244,6 +251,7 @@ export default function App() { const handleFrameSettingsChanged = useCallback((settings: FrameSettings) => { setShowCodeAgentsTab(settings.showCodeTab); + setChatFirstMode(settings.chatFirstMode === true); }, []); const activateApp = useCallback( @@ -837,6 +845,7 @@ export default function App() { const isCodeAgentsActive = showCodeAgentsTab && activeSidebarAppId === CODE_AGENTS_SURFACE_ID; + const isChatFirstActive = isCodeAgentsActive && chatFirstMode; const shouldRenderCodeAgents = showCodeAgentsTab && (isCodeAgentsActive || hasMountedCodeAgents); @@ -869,6 +878,9 @@ export default function App() { return (
+ {isChatFirstActive ? ( + + ) : null} {findOpen && (
)} - {isCodeAgentsActive ? ( + {isCodeAgentsActive && !isChatFirstActive ? (
{reserveMacOSWindowControlsSpace && (
- ) : ( + ) : isChatFirstActive ? null : ( )}
- void handleSidebarAppContextMenu(appId)} - onAddAppClick={() => setShowAddApp(true)} - isCodeAgentsActive={isCodeAgentsActive} - onCodeAgentsClick={ - showCodeAgentsTab ? handleCodeAgentsClick : undefined - } - onSettingsClick={() => setShowSettings(true)} - /> + {!isChatFirstActive && ( + + void handleSidebarAppContextMenu(appId) + } + onAddAppClick={() => setShowAddApp(true)} + isCodeAgentsActive={isCodeAgentsActive} + onCodeAgentsClick={ + showCodeAgentsTab ? handleCodeAgentsClick : undefined + } + onSettingsClick={() => setShowSettings(true)} + /> + )}
{shouldRenderCodeAgents && (
setShowSettings(true)} + onCreateApp={() => setShowAddApp(true)} + chatFirstMode={chatFirstMode} />
)} diff --git a/packages/desktop-app/src/renderer/components/AppSettings.tsx b/packages/desktop-app/src/renderer/components/AppSettings.tsx index 4c00f2c322..38a665e820 100644 --- a/packages/desktop-app/src/renderer/components/AppSettings.tsx +++ b/packages/desktop-app/src/renderer/components/AppSettings.tsx @@ -1,3 +1,4 @@ +import { Switch } from "@agent-native/toolkit/ui/switch"; import type { AppConfig, FrameSettings } from "@shared/app-registry"; import { generateAppId, @@ -666,6 +667,18 @@ export default function AppSettings({ [onFrameSettingsChanged], ); + const handleChatFirstToggle = useCallback( + async (chatFirstMode: boolean) => { + if (!window.electronAPI?.frame) return; + const updated = await window.electronAPI.frame.update({ + chatFirstMode, + }); + setFrameSettings(updated); + onFrameSettingsChanged?.(updated); + }, + [onFrameSettingsChanged], + ); + const handleRemoteToggle = useCallback(async (enabled: boolean) => { const api = window.electronAPI?.codeAgents; if (!api?.setRemoteConnectorEnabled) return; @@ -918,6 +931,31 @@ export default function AppSettings({
)} + {frameSettings && ( +
+
+ + Chat-first workbench + + + Codex-style chats with contextual apps beside the agent. + Existing app tabs stay available when this is off. + +
+ + void handleChatFirstToggle(checked) + } + aria-label={ + frameSettings.chatFirstMode + ? "Use the standard desktop shell" + : "Use the chat-first desktop shell" + } + /> +
+ )} +
diff --git a/packages/desktop-app/src/renderer/components/AppWebview.tsx b/packages/desktop-app/src/renderer/components/AppWebview.tsx index 389ca27712..17ac80c207 100644 --- a/packages/desktop-app/src/renderer/components/AppWebview.tsx +++ b/packages/desktop-app/src/renderer/components/AppWebview.tsx @@ -43,6 +43,8 @@ interface AppWebviewProps { /** Full app config with URL overrides (optional for backward compat) */ appConfig?: AppConfig; isActive: boolean; + /** Explicit browser target for the chat-first with-chrome surface. */ + sourceUrl?: string; /** Changes when the same URL should be opened again. */ urlOpenNonce?: number; /** Safe app-relative path to load inside this app's origin. */ @@ -239,6 +241,7 @@ const AppWebview = forwardRef( app, appConfig, isActive, + sourceUrl, urlOpenNonce, urlPath, urlOpenSoft, @@ -254,16 +257,15 @@ const AppWebview = forwardRef( const [isLoading, setIsLoading] = useState(true); const [slowLoad, setSlowLoad] = useState(false); const [isFullscreen, setIsFullscreen] = useState(false); - const url = withUrlParams( - withUrlPath(resolveUrl(app, appConfig), urlPath), - { - ...(appConfig?.mode === "dev" && appConfig.localPath - ? { _agentNativeDesktopCode: "1" } - : {}), - ...urlParams, - }, - ); - const isDevMode = appConfig?.mode === "dev"; + const url = sourceUrl?.trim() + ? withUrlParams(sourceUrl.trim(), urlParams) + : withUrlParams(withUrlPath(resolveUrl(app, appConfig), urlPath), { + ...(appConfig?.mode === "dev" && appConfig.localPath + ? { _agentNativeDesktopCode: "1" } + : {}), + ...urlParams, + }); + const isDevMode = !sourceUrl && appConfig?.mode === "dev"; const optimizeDepRecoveryRef = useRef(false); const prevUrlRef = useRef(url); const prevUrlOpenNonceRef = useRef(urlOpenNonce); @@ -702,7 +704,12 @@ const AppWebview = forwardRef( "webpreferences", "contextIsolation=true,nodeIntegration=false,sandbox=true,backgroundThrottling=false", ); - wv.setAttribute("partition", `persist:app-${app.id}`); + wv.setAttribute( + "partition", + sourceUrl + ? "persist:chat-first-browser" + : `persist:app-${app.id}`, + ); wv.setAttribute("src", url); container.appendChild(wv); webviewRef.current = wv; diff --git a/packages/desktop-app/src/renderer/components/CodeAgentsHub.tsx b/packages/desktop-app/src/renderer/components/CodeAgentsHub.tsx index 291aa37c0e..62ab55b4eb 100644 --- a/packages/desktop-app/src/renderer/components/CodeAgentsHub.tsx +++ b/packages/desktop-app/src/renderer/components/CodeAgentsHub.tsx @@ -1,13 +1,38 @@ import { CodeAgentsApp, + SessionWatchPanel, type CodeAgentComputerSetupAction, type CodeAgentModelListResult, type CodeAgentPermissionMode, type CodeAgentTranscriptEvent, type CodeAgentTranscriptRequest, + type CodeAgentRun, type CodeAgentsHost, type CodeAgentsNewSessionExtension, } from "@agent-native/code-agents-ui"; +import { + ChatFirstAgentActivityPanel, + ChatFirstSurfacePanelToggle, + chatFirstSurfaceTabId, + closeChatFirstSessionWatch, + emitChatFirstSessionWatch, + getChatFirstSurfaceTabsStore, + resolveChatFirstAppTarget, + resolveChatFirstBrowserTarget, + subscribeChatFirstOpenBrowser, + subscribeChatFirstOpenApp, + useChatFirstSessionWatch, + useChatFirstSurfaceResize, + useChatFirstSurfacePanel, + useChatFirstSurfaceTabs, + type ChatFirstAppRegistration, + type ChatFirstAppResolution, + type ChatFirstOpenAppDetail, + type ChatFirstOpenBrowserDetail, + type ChatFirstAgentActivity, + type ChatFirstSurfaceKind, + type ChatFirstSurfaceTab, +} from "@agent-native/core/client/agent-chat"; import { createAgentNativeQueryClient } from "@agent-native/core/client/hooks"; import { Select, @@ -16,8 +41,16 @@ import { SelectTrigger, } from "@agent-native/toolkit/ui/select"; import { toAppDefinition, type AppConfig } from "@shared/app-registry"; +import { IconSettings } from "@tabler/icons-react"; import { QueryClientProvider } from "@tanstack/react-query"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type CSSProperties, +} from "react"; import type { MultiFrontierIpcEvent, @@ -26,6 +59,12 @@ import type { } from "../../../shared/multi-frontier-ipc.js"; import type { SubscriptionStatus } from "../../../shared/subscription-status.js"; import AppWebview from "./AppWebview.js"; +import DesktopChatFirstAppPane from "./DesktopChatFirstAppPane.js"; +import DesktopChatFirstBrowserPane from "./DesktopChatFirstBrowserPane.js"; +import DesktopChatFirstRail, { + DesktopChatFirstNavigation, +} from "./DesktopChatFirstRail.js"; +import DesktopChatFirstSurfaceTabs from "./DesktopChatFirstSurfaceTabs.js"; import { initialMultiFrontierRunAutoContinue, locksMultiFrontierMode, @@ -70,12 +109,36 @@ const MULTI_FRONTIER_RUN_MODES = [ }, ] as const; +function chatFirstResolutionMessage( + reason: Exclude["reason"], +): string { + switch (reason) { + case "empty-detail": + return "The agent did not provide an app target to open."; + case "invalid-url": + return "The requested app route is not registered for this app."; + case "unknown-app": + return "That app is not enabled in the desktop workspace."; + } + return "The requested app could not be opened."; +} + +function chatFirstBrowserResolutionMessage( + reason: "empty-detail" | "invalid-url", +): string { + return reason === "empty-detail" + ? "The agent did not provide a browser URL to open." + : "The requested browser URL is not a safe HTTP(S) address."; +} + interface CodeAgentsHubProps { apps: AppConfig[]; isActive?: boolean; openRequest?: { goalId?: string; runId?: string; nonce: number }; refreshKey?: number; onOpenSettings?: () => void; + onCreateApp?: () => void; + chatFirstMode?: boolean; } type CodeAgentTranscriptSubscriptionBatch = { @@ -101,7 +164,40 @@ export default function CodeAgentsHub({ openRequest, refreshKey = 0, onOpenSettings, + onCreateApp, + chatFirstMode = false, }: CodeAgentsHubProps) { + const chatFirstSurfaceTabs = useChatFirstSurfaceTabs("desktop"); + const chatFirstSurfaceTabsStore = getChatFirstSurfaceTabsStore("desktop"); + const chatFirstSurfaceResize = useChatFirstSurfaceResize("desktop"); + const chatFirstSurfacePanel = useChatFirstSurfacePanel("desktop"); + const { setOpen: setChatFirstSurfacePanelOpen } = chatFirstSurfacePanel; + const chatFirstSessionWatch = useChatFirstSessionWatch(); + const [chatFirstWatchedRun, setChatFirstWatchedRun] = + useState(null); + const [chatFirstWatchedSourceRunId, setChatFirstWatchedSourceRunId] = + useState(null); + const [chatFirstAgentActivities, setChatFirstAgentActivities] = useState< + ChatFirstAgentActivity[] + >([]); + const previousChatFirstSurfaceTabCountRef = useRef(null); + const activeChatFirstSurfaceTab = useMemo( + () => + chatFirstSurfaceTabs.tabs.find( + (tab) => tab.id === chatFirstSurfaceTabs.activeTabId, + ) ?? null, + [chatFirstSurfaceTabs], + ); + const [chatFirstAppSelection, setChatFirstAppSelection] = useState<{ + appId: string; + path?: string; + view?: string; + } | null>(null); + const [chatFirstBrowserSelection, setChatFirstBrowserSelection] = useState<{ + url: string; + title?: string; + } | null>(null); + const [chatFirstNotice, setChatFirstNotice] = useState(null); const [multiFrontierMode, setMultiFrontierMode] = useState(false); const [multiFrontierState, setMultiFrontierState] = useState(); @@ -131,6 +227,253 @@ export default function CodeAgentsHub({ multiFrontierState?.collaborationId; const multiFrontierModeLocked = locksMultiFrontierMode(multiFrontierState); + const openChatFirstApp = useCallback( + (appId: string, path?: string, view?: string) => { + const app = apps.find( + (candidate) => candidate.id === appId && candidate.enabled, + ); + if (!app) { + setChatFirstNotice("That app is not enabled in the desktop workspace."); + return; + } + setChatFirstNotice(null); + setChatFirstBrowserSelection(null); + closeChatFirstSessionWatch(); + chatFirstSurfaceTabsStore.open({ + id: chatFirstSurfaceTabId("app", `${app.id}:${path ?? view ?? "/"}`), + kind: "app", + title: app.name, + appId: app.id, + ...(path ? { path } : {}), + ...(view ? { view } : {}), + }); + setChatFirstAppSelection({ appId: app.id, path, view }); + }, + [apps, chatFirstSurfaceTabsStore], + ); + + const chatFirstAppRegistrations = useMemo( + () => + apps.map((app) => ({ + id: app.id, + name: app.name, + url: app.url, + devUrl: app.devUrl, + enabled: app.enabled, + })), + [apps], + ); + + const resolveChatFirstOpenApp = useCallback( + (detail: ChatFirstOpenAppDetail) => { + const resolution = resolveChatFirstAppTarget( + detail, + chatFirstAppRegistrations, + ); + if (resolution.status === "unresolved") { + setChatFirstNotice(chatFirstResolutionMessage(resolution.reason)); + return; + } + openChatFirstApp( + resolution.target.appId, + resolution.target.path, + resolution.target.view, + ); + }, + [chatFirstAppRegistrations, openChatFirstApp], + ); + + const resolveChatFirstOpenBrowser = useCallback( + (detail: ChatFirstOpenBrowserDetail) => { + const resolution = resolveChatFirstBrowserTarget(detail); + if (resolution.status === "unresolved") { + setChatFirstNotice( + chatFirstBrowserResolutionMessage(resolution.reason), + ); + return; + } + setChatFirstAppSelection(null); + setChatFirstNotice(null); + closeChatFirstSessionWatch(); + chatFirstSurfaceTabsStore.open({ + id: chatFirstSurfaceTabId("browser", resolution.target.url), + kind: "browser", + title: resolution.target.title ?? "Browser", + url: resolution.target.url, + }); + setChatFirstBrowserSelection(resolution.target); + }, + [chatFirstSurfaceTabsStore], + ); + + useEffect(() => { + if (!chatFirstMode) { + setChatFirstAppSelection(null); + setChatFirstBrowserSelection(null); + setChatFirstNotice(null); + chatFirstSurfaceTabsStore.closeAll(); + return; + } + const unsubscribeApp = subscribeChatFirstOpenApp(resolveChatFirstOpenApp); + const unsubscribeBrowser = subscribeChatFirstOpenBrowser( + resolveChatFirstOpenBrowser, + ); + return () => { + unsubscribeApp(); + unsubscribeBrowser(); + }; + }, [ + chatFirstMode, + chatFirstSurfaceTabsStore, + resolveChatFirstOpenApp, + resolveChatFirstOpenBrowser, + ]); + + useEffect(() => { + const target = chatFirstSessionWatch.target; + if (!chatFirstMode || !target) return; + setChatFirstAppSelection(null); + setChatFirstBrowserSelection(null); + chatFirstSurfaceTabsStore.open({ + id: chatFirstSurfaceTabId("side-chat", target.sessionId), + kind: "side-chat", + title: target.title ? `Watch · ${target.title}` : "Watched session", + session: target, + }); + }, [chatFirstMode, chatFirstSessionWatch.target, chatFirstSurfaceTabsStore]); + + useEffect(() => { + const tabCount = chatFirstSurfaceTabs.tabs.length; + const previousTabCount = previousChatFirstSurfaceTabCountRef.current; + if (!chatFirstMode) { + setChatFirstSurfacePanelOpen(false); + } else if ( + tabCount > 0 && + (previousTabCount === null || previousTabCount === 0) + ) { + setChatFirstSurfacePanelOpen(true); + } else if ( + tabCount === 0 && + previousTabCount !== null && + previousTabCount > 0 + ) { + setChatFirstSurfacePanelOpen(false); + } + previousChatFirstSurfaceTabCountRef.current = tabCount; + }, [ + chatFirstMode, + setChatFirstSurfacePanelOpen, + chatFirstSurfaceTabs.tabs.length, + ]); + + useEffect(() => { + const activeTab = activeChatFirstSurfaceTab; + if ( + !chatFirstMode || + activeTab?.kind !== "side-chat" || + !activeTab.session || + chatFirstSessionWatch.target + ) { + return; + } + emitChatFirstSessionWatch(activeTab.session); + }, [activeChatFirstSurfaceTab, chatFirstMode, chatFirstSessionWatch.target]); + + const activateChatFirstSurfaceTab = useCallback( + (tab: ChatFirstSurfaceTab) => { + chatFirstSurfaceTabsStore.activate(tab.id); + if (tab.kind === "app" && tab.appId) { + closeChatFirstSessionWatch(); + setChatFirstBrowserSelection(null); + setChatFirstAppSelection({ + appId: tab.appId, + ...(tab.path ? { path: tab.path } : {}), + ...(tab.view ? { view: tab.view } : {}), + }); + return; + } + if (tab.kind === "browser" && tab.url) { + closeChatFirstSessionWatch(); + setChatFirstAppSelection(null); + setChatFirstBrowserSelection({ url: tab.url, title: tab.title }); + return; + } + if (tab.kind === "side-chat" && tab.session) { + setChatFirstAppSelection(null); + setChatFirstBrowserSelection(null); + emitChatFirstSessionWatch(tab.session); + } + }, + [chatFirstSurfaceTabsStore], + ); + + const closeChatFirstSurfaceTab = useCallback( + (tab: ChatFirstSurfaceTab) => { + const isActive = chatFirstSurfaceTabs.activeTabId === tab.id; + if (tab.kind === "app") setChatFirstAppSelection(null); + if (tab.kind === "browser") setChatFirstBrowserSelection(null); + if (tab.kind === "side-chat" && isActive) { + closeChatFirstSessionWatch(); + } + chatFirstSurfaceTabsStore.close(tab.id); + }, + [chatFirstSurfaceTabs, chatFirstSurfaceTabsStore], + ); + + const closeAllChatFirstSurfaceTabs = useCallback(() => { + setChatFirstAppSelection(null); + setChatFirstBrowserSelection(null); + closeChatFirstSessionWatch(); + chatFirstSurfaceTabsStore.closeAll(); + }, [chatFirstSurfaceTabsStore]); + + const openChatFirstSurface = useCallback( + (kind: ChatFirstSurfaceKind) => { + if (kind !== "agents") return; + closeChatFirstSessionWatch(); + chatFirstSurfaceTabsStore.open({ + id: chatFirstSurfaceTabId(kind, "activity"), + kind, + title: "Agents", + }); + }, + [chatFirstSurfaceTabsStore], + ); + + const watchChatFirstAgent = useCallback( + (activity: ChatFirstAgentActivity) => { + emitChatFirstSessionWatch({ + sessionId: activity.sessionId, + title: activity.title, + kind: "code-agent", + ...(activity.goalId ? { goalId: activity.goalId } : {}), + }); + }, + [], + ); + + const handleChatFirstRunsChange = useCallback((runs: CodeAgentRun[]) => { + setChatFirstAgentActivities( + runs.map((run) => ({ + sessionId: run.id, + title: run.title || "Untitled agent session", + subtitle: run.subtitle || run.phase, + status: run.status, + updatedAt: run.updatedAt, + progressPercent: run.progress?.percent, + goalId: run.goalId, + })), + ); + }, []); + + const handleChatFirstWatchedRunChange = useCallback( + (run: CodeAgentRun | null, sourceRunId?: string | null) => { + setChatFirstWatchedRun(run); + setChatFirstWatchedSourceRunId(sourceRunId ?? null); + }, + [], + ); + const appendMultiFrontierNotice = useCallback( (notice: MultiFrontierNotice) => { setMultiFrontierNotices((current) => @@ -908,30 +1251,191 @@ export default function CodeAgentsHub({ [], ); + const selectedChatFirstApp = + activeChatFirstSurfaceTab?.kind === "app" && activeChatFirstSurfaceTab.appId + ? apps.find((app) => app.id === activeChatFirstSurfaceTab.appId) + : undefined; + return ( - ( -
- + + ) : undefined + } + railWorkspaceSlot={ + chatFirstMode ? ( + + ) : undefined + } + railFooterSlot={ + chatFirstMode && onOpenSettings ? ( + + ) : undefined + } + newSessionExtension={multiFrontierExtension} + openDetailRequest={multiFrontierOpenDetailRequest} + renderAppSurface={({ app, urlParams, refreshKey: appRefreshKey }) => ( +
+ +
+ )} + /> + {chatFirstMode ? ( + + ) : null} + {chatFirstMode && + chatFirstSurfacePanel.open && + chatFirstSurfaceTabs.tabs.length === 0 ? ( +
+ undefined} + onClose={() => undefined} + onCloseOthers={() => undefined} + onCloseToRight={() => undefined} + onCloseAll={() => undefined} + onOpenSurface={openChatFirstSurface} />
- )} - /> + ) : null} + {chatFirstMode && + chatFirstSurfacePanel.open && + chatFirstSurfaceTabs.tabs.length > 0 ? ( + + ) : null} +
); } diff --git a/packages/desktop-app/src/renderer/components/CodeProviderSettings.tsx b/packages/desktop-app/src/renderer/components/CodeProviderSettings.tsx index ad002f2ddd..b849749029 100644 --- a/packages/desktop-app/src/renderer/components/CodeProviderSettings.tsx +++ b/packages/desktop-app/src/renderer/components/CodeProviderSettings.tsx @@ -8,6 +8,11 @@ import { } from "@tabler/icons-react"; import { useCallback, useEffect, useState } from "react"; +import type { + SubscriptionProviderId, + SubscriptionStatus, +} from "../../../shared/subscription-status.js"; + type ProviderStatusTone = "ok" | "offline"; const PENDING_BUILDER_CONNECT_RELOAD_KEY = "agent-native:pending-builder-connect-after-reload"; @@ -499,6 +504,8 @@ export function CodeProviderSettings({
+ + + ) : null} +
+ + {copy.command} + +
+ ); + })} + + {subscriptionError ? ( +

+ {subscriptionError} +

+ ) : null} + + ); +} diff --git a/packages/desktop-app/src/renderer/components/DesktopChatFirstAppPane.tsx b/packages/desktop-app/src/renderer/components/DesktopChatFirstAppPane.tsx new file mode 100644 index 0000000000..eb92f48bd5 --- /dev/null +++ b/packages/desktop-app/src/renderer/components/DesktopChatFirstAppPane.tsx @@ -0,0 +1,60 @@ +import { toAppDefinition, type AppConfig } from "@shared/app-registry"; +import { IconX } from "@tabler/icons-react"; + +import AppWebview from "./AppWebview.js"; +import { DesktopChatFirstAppIcon } from "./DesktopChatFirstRail.js"; + +export default function DesktopChatFirstAppPane({ + app, + path, + view, + onClose, +}: { + app: AppConfig; + path?: string; + view?: string; + onClose: () => void; +}) { + return ( + + ); +} diff --git a/packages/desktop-app/src/renderer/components/DesktopChatFirstBrowserPane.tsx b/packages/desktop-app/src/renderer/components/DesktopChatFirstBrowserPane.tsx new file mode 100644 index 0000000000..200b497c00 --- /dev/null +++ b/packages/desktop-app/src/renderer/components/DesktopChatFirstBrowserPane.tsx @@ -0,0 +1,101 @@ +import type { AppDefinition } from "@shared/app-registry"; +import { + IconArrowLeft, + IconArrowRight, + IconExternalLink, + IconRefresh, + IconWorld, + IconX, +} from "@tabler/icons-react"; +import { useRef } from "react"; + +import AppWebview, { type AppWebviewHandle } from "./AppWebview.js"; + +const BROWSER_APP: AppDefinition = { + id: "chat-first-browser", + name: "Browser", + icon: "Globe", + description: "A browser surface opened by an agent.", + devPort: 0, +}; + +export default function DesktopChatFirstBrowserPane({ + url, + title, + isActive, + onClose, +}: { + url: string; + title?: string; + isActive: boolean; + onClose: () => void; +}) { + const webviewRef = useRef(null); + + return ( + + ); +} diff --git a/packages/desktop-app/src/renderer/components/DesktopChatFirstRail.tsx b/packages/desktop-app/src/renderer/components/DesktopChatFirstRail.tsx new file mode 100644 index 0000000000..540651c6f7 --- /dev/null +++ b/packages/desktop-app/src/renderer/components/DesktopChatFirstRail.tsx @@ -0,0 +1,312 @@ +import { + CHAT_FIRST_OPEN_APP_EVENT, + orderChatFirstAppIds, + readChatFirstAppLayout, + writeChatFirstAppLayout, + type ChatFirstAppLayoutPreference, +} from "@agent-native/core/client/agent-chat"; +import type { AppConfig } from "@shared/app-registry"; +import { + IconApps, + IconBrandChrome, + IconBrandJira, + IconBrain, + IconCalendar, + IconChartBar, + IconClock, + IconCode, + IconFileText, + IconFolder, + IconGripVertical, + IconLayoutBoard, + IconListCheck, + IconMail, + IconMessageCircle, + IconPhoto, + IconPin, + IconPlus, + IconPlugConnected, + IconPresentation, + IconRoute, + IconSettings, + IconStack2, + IconUsers, +} from "@tabler/icons-react"; +import { useMemo, useState, type ReactNode } from "react"; + +const APP_ICON_MAP: Record = { + Mail: IconMail, + CalendarDays: IconCalendar, + FileText: IconFileText, + LayoutBoard: IconLayoutBoard, + BarChart2: IconChartBar, + GalleryHorizontal: IconPresentation, + BrandJira: IconBrandJira, + Users: IconUsers, + Code: IconCode, + MessageCircle: IconMessageCircle, + Route: IconRoute, + Brain: IconBrain, + Globe: IconBrandChrome, + Photo: IconPhoto, + ListCheck: IconListCheck, + Folder: IconFolder, + Settings: IconSettings, +}; + +function AppIcon({ app, size = 15 }: { app: AppConfig; size?: number }) { + const Icon = APP_ICON_MAP[app.icon] ?? IconStack2; + return