From 8402ea70768b403e892c8e6cd9a6cbb0464f83cd Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 20 May 2026 21:48:08 -0400 Subject: [PATCH 1/6] WIP: deeplinks feature snapshot before merging main Snapshot of in-progress deeplinks work before pulling Tui Perf and Mac VM updates from main. Safe to soft-reset after the merge if you want these changes back in the working tree. - ade:// protocol handler + parser (apps/desktop/src/main/services/deeplinks) - iOS DeepLinkRouter extended for lane / repo / pr / linear-issue forms plus the cross-machine "Send to your Mac" path - syncRemoteCommandService.deeplinks.open for iOS -> desktop bounce - buildDeeplink + ensureAdeDeeplinkFooter shared utilities - LinearLaneCardService now publishes the cross-machine ADE deeplink - New renderer surfaces: ClipboardDeeplinkBanner, CrossRepoPrBanner, InboundDeeplinkModal - ade-deeplinks agent skill, OpenPage web handler, docs Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/ade-cli/README.md | 7 + apps/ade-cli/src/adeRpcServer.test.ts | 102 ++++ apps/ade-cli/src/adeRpcServer.ts | 77 ++- apps/ade-cli/src/bootstrap.test.ts | 4 +- apps/ade-cli/src/bootstrap.ts | 16 +- apps/ade-cli/src/cli.ts | 73 ++- apps/ade-cli/src/commands/deeplinks.test.ts | 178 +++++++ apps/ade-cli/src/commands/deeplinks.ts | 416 +++++++++++++++++ apps/ade-cli/src/eventBuffer.ts | 2 +- apps/ade-cli/src/lib/clipboard.ts | 63 +++ apps/ade-cli/src/multiProjectRpcServer.ts | 3 +- .../src/services/sync/syncHostService.ts | 7 + .../services/sync/syncRemoteCommandService.ts | 28 ++ apps/ade-cli/src/services/sync/syncService.ts | 8 + .../src/tuiClient/__tests__/ChatView.test.tsx | 27 +- .../src/tuiClient/__tests__/adeApi.test.ts | 41 +- .../src/tuiClient/__tests__/aggregate.test.ts | 44 +- .../src/tuiClient/__tests__/appInput.test.ts | 7 + .../__tests__/deeplinkKeybind.test.ts | 207 +++++++++ .../src/tuiClient/__tests__/format.test.ts | 6 + apps/ade-cli/src/tuiClient/adeApi.ts | 5 +- apps/ade-cli/src/tuiClient/aggregate.ts | 79 +++- apps/ade-cli/src/tuiClient/app.tsx | 121 +++-- apps/ade-cli/src/tuiClient/deeplinkRow.ts | 78 ++++ apps/ade-cli/src/tuiClient/format.ts | 9 +- .../src/tuiClient/keybindings/index.ts | 1 + .../agent-skills/ade-deeplinks/SKILL.md | 150 ++++++ apps/desktop/src/main/main.ts | 103 +++- .../src/main/services/adeActions/registry.ts | 2 +- .../services/chat/agentChatService.test.ts | 2 +- .../main/services/chat/agentChatService.ts | 45 +- .../src/main/services/cto/linearClient.ts | 15 +- .../cto/linearLaneCardService.test.ts | 55 ++- .../services/cto/linearLaneCardService.ts | 96 +++- .../deeplinks/protocolHandler.test.ts | 105 +++++ .../services/deeplinks/protocolHandler.ts | 189 ++++++++ .../src/main/services/ipc/registerIpc.ts | 6 + .../src/main/services/ipc/runtimeBridge.ts | 4 +- .../localRuntimeConnectionPool.test.ts | 8 +- .../localRuntimeConnectionPool.ts | 2 +- .../src/main/services/prs/prService.test.ts | 11 +- .../src/main/services/prs/prService.ts | 101 ++-- .../src/main/services/pty/ptyService.test.ts | 90 ++++ .../src/main/services/pty/ptyService.ts | 31 +- .../remoteConnectionPool.test.ts | 8 +- .../remoteRuntime/remoteConnectionPool.ts | 3 +- .../runtime/processRegistryService.test.ts | 146 ++++++ .../runtime/processRegistryService.ts | 212 +++++++++ .../services/sessions/sessionService.test.ts | 185 ++++++++ .../main/services/sessions/sessionService.ts | 88 +++- apps/desktop/src/main/services/state/kvDb.ts | 22 + apps/desktop/src/preload/global.d.ts | 1 + apps/desktop/src/preload/preload.test.ts | 67 +++ apps/desktop/src/preload/preload.ts | 3 +- .../src/renderer/components/app/App.tsx | 67 ++- .../app/ClipboardDeeplinkBanner.tsx | 124 +++++ .../components/app/CrossRepoPrBanner.tsx | 217 +++++++++ .../components/app/InboundDeeplinkModal.tsx | 312 +++++++++++++ .../components/app/LinearIssueBrowser.tsx | 2 +- .../chat/chatTranscriptRows.test.ts | 20 +- .../components/chat/chatTranscriptRows.ts | 16 +- .../components/lanes/LaneContextMenu.tsx | 75 +++ .../renderer/components/lanes/LanesPage.tsx | 27 ++ .../components/terminals/TerminalsPage.tsx | 9 + .../components/terminals/useWorkSessions.ts | 9 + apps/desktop/src/shared/adeCliGuidance.ts | 2 +- .../src/shared/adeDeeplinkFooter.test.ts | 101 ++++ apps/desktop/src/shared/adeDeeplinkFooter.ts | 118 +++++ apps/desktop/src/shared/deeplinks.test.ts | 262 +++++++++++ apps/desktop/src/shared/deeplinks.ts | Bin 0 -> 15386 bytes apps/desktop/src/shared/ipc.ts | 1 + apps/desktop/src/shared/types/core.ts | 24 + .../desktop/src/shared/types/remoteRuntime.ts | 3 +- apps/desktop/src/shared/types/sessions.ts | 1 + apps/desktop/src/shared/types/sync.ts | 3 +- apps/ios/ADE.xcodeproj/project.pbxproj | 12 + apps/ios/ADE/App/ADEApp.swift | 25 + apps/ios/ADE/App/DeepLinkRouter.swift | 70 ++- apps/ios/ADE/Services/SyncService.swift | 12 + .../ADE/Views/Deeplinks/SendToMacCard.swift | 317 +++++++++++++ apps/web/api/open.ts | 245 ++++++++++ apps/web/api/tsconfig.json | 17 + apps/web/src/app/SiteRoutes.tsx | 2 + apps/web/src/app/pages/OpenPage.tsx | 177 +++++++ apps/web/vercel.json | 4 + docs/ARCHITECTURE.md | 32 +- docs/features/ade-code/README.md | 16 +- docs/features/deeplinks/README.md | 238 ++++++++++ .../sync-and-multi-device/ios-companion.md | 16 +- .../features/terminals-and-sessions/README.md | 48 +- goal.md | 438 +++++++++++++++--- 91 files changed, 6171 insertions(+), 250 deletions(-) create mode 100644 apps/ade-cli/src/commands/deeplinks.test.ts create mode 100644 apps/ade-cli/src/commands/deeplinks.ts create mode 100644 apps/ade-cli/src/lib/clipboard.ts create mode 100644 apps/ade-cli/src/tuiClient/__tests__/deeplinkKeybind.test.ts create mode 100644 apps/ade-cli/src/tuiClient/deeplinkRow.ts create mode 100644 apps/desktop/resources/agent-skills/ade-deeplinks/SKILL.md create mode 100644 apps/desktop/src/main/services/deeplinks/protocolHandler.test.ts create mode 100644 apps/desktop/src/main/services/deeplinks/protocolHandler.ts create mode 100644 apps/desktop/src/main/services/runtime/processRegistryService.test.ts create mode 100644 apps/desktop/src/main/services/runtime/processRegistryService.ts create mode 100644 apps/desktop/src/renderer/components/app/ClipboardDeeplinkBanner.tsx create mode 100644 apps/desktop/src/renderer/components/app/CrossRepoPrBanner.tsx create mode 100644 apps/desktop/src/renderer/components/app/InboundDeeplinkModal.tsx create mode 100644 apps/desktop/src/shared/adeDeeplinkFooter.test.ts create mode 100644 apps/desktop/src/shared/adeDeeplinkFooter.ts create mode 100644 apps/desktop/src/shared/deeplinks.test.ts create mode 100644 apps/desktop/src/shared/deeplinks.ts create mode 100644 apps/ios/ADE/Views/Deeplinks/SendToMacCard.swift create mode 100644 apps/web/api/open.ts create mode 100644 apps/web/api/tsconfig.json create mode 100644 apps/web/src/app/pages/OpenPage.tsx create mode 100644 docs/features/deeplinks/README.md diff --git a/apps/ade-cli/README.md b/apps/ade-cli/README.md index 663548b9e..a87842411 100644 --- a/apps/ade-cli/README.md +++ b/apps/ade-cli/README.md @@ -277,6 +277,13 @@ ade actions list ade actions run git.stageFile --arg laneId=lane-id --arg path=src/index.ts ade cursor cloud agents list --text ade cursor cloud agents create --repo https://github.com/owner/repo --prompt "fix flaky test" --auto-pr +ade open ade://lane/ +ade open --linear-issue ADE-123 --branch arul/ade-123-fix +ade link lane +ade link branch owner/repo my-branch --pr 42 +ade link pr owner/repo 42 --ade +ade link linear-issue ADE-123 --branch arul/ade-123-fix +ade linear install ``` Use typed commands first. They validate common arguments and provide stable JSON fields or readable text summaries. Use `ade help ` for exact flags, `ade actions list --text` to discover the full service-backed action catalog, and `ade actions run ` only when there is no typed command for the workflow yet. diff --git a/apps/ade-cli/src/adeRpcServer.test.ts b/apps/ade-cli/src/adeRpcServer.test.ts index c48bcb819..2cdb1c4fc 100644 --- a/apps/ade-cli/src/adeRpcServer.test.ts +++ b/apps/ade-cli/src/adeRpcServer.test.ts @@ -338,7 +338,10 @@ function createRuntime() { void data; return true; }), + write: vi.fn(), + resize: vi.fn(), readTranscriptTail: vi.fn(async () => ""), + list: vi.fn(() => []), enrichSessions: vi.fn((sessions: unknown[]) => sessions), }, testService: { @@ -1209,6 +1212,79 @@ function createFakePathExecutable(dir: string, name: string): string { } describe("adeRpcServer", () => { + it("exposes direct PTY RPC methods with enriched create/list responses", async () => { + const { runtime } = createRuntime(); + const handler = createAdeRpcRequestHandler({ runtime, serverVersion: "test" }); + const session = { + id: "session-1", + laneId: "lane-1", + status: "running", + ownerPid: 12_345, + }; + runtime.sessionService.get.mockReturnValue(session); + runtime.ptyService.list.mockReturnValue([session]); + await initialize(handler, { role: "external" }); + + const created = await handler({ + jsonrpc: "2.0", + id: 2, + method: "pty.create", + params: { args: { laneId: "lane-1", title: "Claude", cols: 120, rows: 40 } }, + }); + expect(created).toEqual({ + ptyId: "pty-1", + sessionId: "session-1", + session, + }); + expect(runtime.ptyService.create).toHaveBeenCalledWith({ + laneId: "lane-1", + title: "Claude", + cols: 120, + rows: 40, + }); + + await expect(handler({ + jsonrpc: "2.0", + id: 3, + method: "pty.sendToSession", + params: { args: { sessionId: "session-1", text: "continue" } }, + })).resolves.toMatchObject({ sessionId: "session-1", reusedExistingRuntime: true }); + expect(runtime.ptyService.sendToSession).toHaveBeenCalledWith({ sessionId: "session-1", text: "continue" }); + + await expect(handler({ + jsonrpc: "2.0", + id: 4, + method: "pty.write", + params: { args: { ptyId: "pty-1", data: "x" } }, + })).resolves.toBeNull(); + expect(runtime.ptyService.write).toHaveBeenCalledWith({ ptyId: "pty-1", data: "x" }); + + await expect(handler({ + jsonrpc: "2.0", + id: 5, + method: "pty.resize", + params: { args: { ptyId: "pty-1", cols: 100, rows: 30 } }, + })).resolves.toBeNull(); + expect(runtime.ptyService.resize).toHaveBeenCalledWith({ ptyId: "pty-1", cols: 100, rows: 30 }); + + await expect(handler({ + jsonrpc: "2.0", + id: 6, + method: "pty.dispose", + params: { args: { ptyId: "pty-1", sessionId: "session-1" } }, + })).resolves.toBeNull(); + expect(runtime.ptyService.dispose).toHaveBeenCalledWith({ ptyId: "pty-1", sessionId: "session-1" }); + + const listed = await handler({ + jsonrpc: "2.0", + id: 7, + method: "pty.list", + params: { args: { laneId: "lane-1", limit: 20 } }, + }); + expect(listed).toEqual({ sessions: [session] }); + expect(runtime.ptyService.list).toHaveBeenCalledWith({ laneId: "lane-1", limit: 20 }); + }); + it("routes app/navigate through the runtime navigation service", async () => { const { runtime } = createRuntime(); const navigate = vi.fn(async () => ({ ok: true, mode: "desktop", windowId: 7 })); @@ -5779,6 +5855,32 @@ describe("adeRpcServer", () => { expect(response.structuredContent.events.every((e: any) => e.category === "orchestrator")).toBe(true); }); + it("stream_events supports the PTY category", async () => { + const fixture = createRuntime(); + fixture.runtime.eventBuffer.drain = vi.fn((cursor: number) => ({ + events: [ + { id: cursor + 1, timestamp: new Date().toISOString(), category: "runtime", payload: { type: "terminal_session_changed" } }, + { id: cursor + 2, timestamp: new Date().toISOString(), category: "pty", payload: { type: "pty_data", event: { sessionId: "session-1", data: "hi" } } }, + { id: cursor + 3, timestamp: new Date().toISOString(), category: "pty", payload: { type: "pty_exit", event: { sessionId: "session-1", exitCode: 0 } } }, + ], + nextCursor: cursor + 3, + hasMore: false, + })); + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + + await initialize(handler, { role: "external" }); + const response = await callTool(handler, "stream_events", { + cursor: 0, + limit: 100, + category: "pty", + }); + + expect(response?.isError).toBeUndefined(); + expect(response.structuredContent.events).toHaveLength(2); + expect(response.structuredContent.events.every((event: any) => event.category === "pty")).toBe(true); + expect(response.structuredContent.nextCursor).toBe(3); + }); + it("stream_events returns runtime validation contract events when requested", async () => { const fixture = createRuntime(); fixture.runtime.eventBuffer.drain = vi.fn((cursor: number) => ({ diff --git a/apps/ade-cli/src/adeRpcServer.ts b/apps/ade-cli/src/adeRpcServer.ts index 4ba2c3888..09dbaa31d 100644 --- a/apps/ade-cli/src/adeRpcServer.ts +++ b/apps/ade-cli/src/adeRpcServer.ts @@ -1521,7 +1521,7 @@ const TOOL_SPECS: ToolSpec[] = [ properties: { cursor: { type: "number", minimum: 0 }, limit: { type: "number", minimum: 1, maximum: 1000 }, - category: { type: "string", enum: ["orchestrator", "dag_mutation", "runtime", "mission"] } + category: { type: "string", enum: ["orchestrator", "dag_mutation", "runtime", "mission", "pty"] } } } }, @@ -7823,6 +7823,38 @@ export function createAdeRpcRequestHandler(args: { } } + if (method.startsWith("pty.")) { + const ptyArgs = safeObject(params.args ?? params.arg ?? params); + if (method === "pty.create") { + const result = await runtime.ptyService.create(ptyArgs as Parameters[0]); + return { + ...result, + session: runtime.sessionService.get(result.sessionId), + }; + } + if (method === "pty.sendToSession") { + return await runtime.ptyService.sendToSession(ptyArgs as Parameters[0]); + } + if (method === "pty.write") { + runtime.ptyService.write(ptyArgs as Parameters[0]); + return null; + } + if (method === "pty.resize") { + runtime.ptyService.resize(ptyArgs as Parameters[0]); + return null; + } + if (method === "pty.dispose") { + runtime.ptyService.dispose(ptyArgs as Parameters[0]); + return null; + } + if (method === "pty.list") { + return { + sessions: runtime.ptyService.list(ptyArgs as Parameters[0]), + }; + } + throw new JsonRpcError(JsonRpcErrorCode.methodNotFound, `Unsupported PTY method: ${method}`); + } + if (method.startsWith("modelPicker.")) { const store = getSharedModelPickerStore(); if (method === "modelPicker.getFavorites") { @@ -7891,7 +7923,15 @@ export function createAdeRpcRequestHandler(args: { if (!kind) { throw new JsonRpcError(JsonRpcErrorCode.invalidParams, "app/navigate requires target.kind."); } - if (kind !== "work" && kind !== "chat" && kind !== "lane" && kind !== "pr" && kind !== "route") { + if ( + kind !== "work" && + kind !== "chat" && + kind !== "lane" && + kind !== "pr" && + kind !== "route" && + kind !== "branch" && + kind !== "linear-issue" + ) { throw new JsonRpcError(JsonRpcErrorCode.invalidParams, `Unsupported app navigation target kind: ${kind}.`); } if (kind === "lane" && !asOptionalTrimmedString(target.laneId)) { @@ -7900,6 +7940,24 @@ export function createAdeRpcRequestHandler(args: { if (kind === "route" && !asOptionalTrimmedString(target.route)) { throw new JsonRpcError(JsonRpcErrorCode.invalidParams, "app/navigate target 'route' requires route."); } + if (kind === "branch") { + if ( + !asOptionalTrimmedString(target.repoOwner) || + !asOptionalTrimmedString(target.repoName) || + !asOptionalTrimmedString(target.branch) + ) { + throw new JsonRpcError( + JsonRpcErrorCode.invalidParams, + "app/navigate target 'branch' requires repoOwner, repoName, and branch.", + ); + } + } + if (kind === "linear-issue" && !asOptionalTrimmedString(target.issueIdentifier)) { + throw new JsonRpcError( + JsonRpcErrorCode.invalidParams, + "app/navigate target 'linear-issue' requires issueIdentifier.", + ); + } const normalizedTarget: Record = { kind }; const sessionId = asOptionalTrimmedString(target.sessionId); const laneId = asOptionalTrimmedString(target.laneId); @@ -7909,6 +7967,21 @@ export function createAdeRpcRequestHandler(args: { const prId = asOptionalTrimmedString(target.prId); if (prId) normalizedTarget.prId = prId; if (typeof target.prNumber === "number") normalizedTarget.prNumber = target.prNumber; + const repoOwner = asOptionalTrimmedString(target.repoOwner); + const repoName = asOptionalTrimmedString(target.repoName); + if (repoOwner) normalizedTarget.repoOwner = repoOwner; + if (repoName) normalizedTarget.repoName = repoName; + } + if (kind === "branch") { + normalizedTarget.repoOwner = asOptionalTrimmedString(target.repoOwner); + normalizedTarget.repoName = asOptionalTrimmedString(target.repoName); + normalizedTarget.branch = asOptionalTrimmedString(target.branch); + if (typeof target.prNumber === "number") normalizedTarget.prNumber = target.prNumber; + } + if (kind === "linear-issue") { + normalizedTarget.issueIdentifier = asOptionalTrimmedString(target.issueIdentifier); + const branch = asOptionalTrimmedString(target.branch); + if (branch) normalizedTarget.branch = branch; } if (kind === "route") { normalizedTarget.route = asOptionalTrimmedString(target.route); diff --git a/apps/ade-cli/src/bootstrap.test.ts b/apps/ade-cli/src/bootstrap.test.ts index 0354607c5..4ff63b15d 100644 --- a/apps/ade-cli/src/bootstrap.test.ts +++ b/apps/ade-cli/src/bootstrap.test.ts @@ -131,14 +131,14 @@ describe("createEventBuffer", () => { it("preserves event category and payload through push and drain", () => { const buffer = createEventBuffer(); - const categories: BufferedEvent["category"][] = ["orchestrator", "dag_mutation", "runtime", "mission"]; + const categories: BufferedEvent["category"][] = ["orchestrator", "dag_mutation", "runtime", "mission", "pty"]; for (const category of categories) { buffer.push({ timestamp: "t", category, payload: { kind: category } }); } const result = buffer.drain(0); - expect(result.events).toHaveLength(4); + expect(result.events).toHaveLength(5); for (let i = 0; i < categories.length; i++) { expect(result.events[i]!.category).toBe(categories[i]); expect(result.events[i]!.payload).toEqual({ kind: categories[i] }); diff --git a/apps/ade-cli/src/bootstrap.ts b/apps/ade-cli/src/bootstrap.ts index 84ce72b50..de64d6e2e 100644 --- a/apps/ade-cli/src/bootstrap.ts +++ b/apps/ade-cli/src/bootstrap.ts @@ -74,6 +74,7 @@ import { createUsageTrackingService } from "../../desktop/src/main/services/usag import { createBudgetCapService } from "../../desktop/src/main/services/usage/budgetCapService"; import { createSessionDeltaService } from "../../desktop/src/main/services/sessions/sessionDeltaService"; import { createReviewService } from "../../desktop/src/main/services/review/reviewService"; +import { createProcessRegistryService } from "../../desktop/src/main/services/runtime/processRegistryService"; import type { createAutoUpdateService } from "../../desktop/src/main/services/updates/autoUpdateService"; import { createComputerUseArtifactBrokerService, @@ -447,9 +448,17 @@ export async function createAdeRuntime(args: { sessionService.onChanged((event) => { pushEvent("runtime", { type: "terminal_session_changed", event }); }); + const processRegistry = createProcessRegistryService({ + db, + logger, + role: chatOnlyRuntime ? "tui-runtime" : "ade-serve-daemon", + projectRoot, + }); + processRegistry.start(); sessionService.reconcileStaleRunningSessions({ status: "disposed", excludeToolTypes: ["claude-chat", "codex-chat", "opencode-chat", "cursor", "droid-chat"], + liveOwnerPids: processRegistry.listLivePids(), }); const sessionDeltaService = createSessionDeltaService({ db, @@ -608,9 +617,10 @@ export async function createAdeRuntime(args: { transcriptsDir: paths.transcriptsDir, laneService, sessionService, + processRegistry, logger, - broadcastData: (event) => pushEvent("runtime", { type: "pty_data", event }), - broadcastExit: (event) => pushEvent("runtime", { type: "pty_exit", event }), + broadcastData: (event) => pushEvent("pty", { type: "pty_data", event }), + broadcastExit: (event) => pushEvent("pty", { type: "pty_exit", event }), onSessionEnded: () => {}, getAdeCliAgentEnv: createHeadlessAdeCliAgentEnv, loadPty: () => nodePty @@ -922,6 +932,7 @@ export async function createAdeRuntime(args: { computerUseArtifactBrokerService, laneService, sessionService, + processRegistry, projectConfigService, aiIntegrationService, ctoStateService, @@ -1212,6 +1223,7 @@ export async function createAdeRuntime(args: { swallow(() => agentChatService?.forceDisposeAll?.()); swallow(() => testService.disposeAll()); swallow(() => ptyService.disposeAll()); + swallow(() => processRegistry.stop()); swallow(() => db.flushNow()); swallow(() => db.close()); } diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index 0024981f1..7b4f73bc6 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -12,6 +12,10 @@ import { CursorCloudUsageError, runCursorCloud, } from "./cursorCloud"; +import { + CliDeeplinkUsageError, + runDeeplinkCommand, +} from "./commands/deeplinks"; import { resolveMachineAdeLayout } from "./services/projects/machineLayout"; import { findAdeManagedWorktreeRoot, @@ -130,7 +134,8 @@ type CliPlan = | { kind: "serve"; rest: string[] } | { kind: "rpc-stdio"; rest: string[] } | { kind: "init"; targetPath: string | null } - | { kind: "cursor-cloud"; rest: string[] }; + | { kind: "cursor-cloud"; rest: string[] } + | { kind: "deeplink"; rest: string[] }; type CliConnection = { mode: "desktop-socket" | "runtime-socket" | "headless"; @@ -358,6 +363,9 @@ const TOP_LEVEL_HELP = `${ADE_BANNER} $ ade auth status Check local ADE CLI readiness $ ade code Open ADE Work chat in the terminal $ ade desktop Launch the installed desktop app + $ ade open Open an ade:// or ade.app deeplink via the OS + $ ade link lane | branch | pr | linear-issue Build a shareable deeplink (copies to clipboard) + $ ade linear install Register ADE as Linear's "Open in coding tool" target $ ade runtime start | stop | status Manage the machine runtime daemon $ ade serve Run the ADE runtime daemon in foreground $ ade rpc --stdio Speak ADE JSON-RPC over stdin/stdout @@ -786,6 +794,40 @@ const HELP_BY_COMMAND: Record = { Flags: --app-name macOS app name to open. Defaults to ADE, ADE Beta, or ADE Alpha based on the installed CLI wrapper. +`, + open: `${ADE_BANNER} + ADE Open + + Hand an "ade://" or "https://ade.app/open?..." deeplink to the OS so the + installed ADE desktop receives it (single-instance lock focuses the existing + window). Also accepts the Linear coding-tool hand-off form. + + $ ade open ade://lane/ + $ ade open ade://repo///branch/?pr=42 + $ ade open ade://pr/// + $ ade open ade://linear-issue/ADE-123?branch=arul/ade-123-fix + $ ade open https://ade.app/open?type=lane&id= + $ ade open --linear-issue ADE-123 --branch arul/ade-123-fix + + Flags: + --linear-issue Linear issue identifier; routes to the matching lane. + --branch Linear-generated branch hint passed alongside --linear-issue. +`, + link: `${ADE_BANNER} + ADE Link + + Build a shareable deeplink URL for a lane, branch, PR, or Linear issue. + The URL is printed and (unless --no-clipboard) copied to the clipboard. + + $ ade link lane + $ ade link branch [--pr ] + $ ade link pr + $ ade link linear-issue [--branch ] + $ ade link Round-trip parse + re-emit a deeplink + + Flags: + --ade Emit the custom "ade://" form. Defaults to the https mirror. + --no-clipboard Print the URL but do not copy it to the system clipboard. `, runtime: `${ADE_BANNER} ADE Runtime @@ -1331,6 +1373,8 @@ const HELP_BY_COMMAND: Record = { $ ade linear sync queue --text List sync queue items $ ade linear sync resolve --queue-item --action approve $ ade linear route worker --input-json '{"issueId":"LIN-123","workerId":"worker-1"}' + $ ade linear install Register ADE as the "Open in coding tool" target + $ ade linear install --dry-run Preview the ~/.linear/coding-tools.json write `, flow: `${ADE_BANNER} Flow policy @@ -8722,6 +8766,22 @@ function buildCliPlan(command: string[]): CliPlan { if (primary === "desktop") { return { kind: "desktop", rest: args }; } + if (primary === "open" || primary === "link") { + // Deeplink-related subcommands. We need the verb back so the inner + // dispatcher can branch on it; reconstruct rest accordingly. + return { kind: "deeplink", rest: [primary, ...args] }; + } + if (primary === "linear") { + // `ade linear install` is the deeplink installer; every other `ade linear` + // subcommand (workflows, sync, quick-view, route, picker-data, ...) belongs + // to buildLinearPlan below. Only route to the deeplink handler when the + // first positional looks like "install". Use a non-mutating peek so + // buildLinearPlan still sees the original args. + const linearSubPeek = args.find((arg) => arg !== "--" && !arg.startsWith("-")); + if (linearSubPeek === "install") { + return { kind: "deeplink", rest: [primary, ...args] }; + } + } if (primary === "runtime") { return { kind: "runtime", rest: args }; } @@ -13098,6 +13158,17 @@ async function runCli( exitCode: isRecord(result) && result.ok === false ? 1 : 0, }; } + if (plan.kind === "deeplink") { + try { + const result = runDeeplinkCommand(plan.rest); + return { output: result.output, exitCode: result.exitCode }; + } catch (error) { + if (error instanceof CliDeeplinkUsageError) { + throw new CliUsageError(error.message); + } + throw error; + } + } if (plan.kind === "runtime") { const result = await runRuntimeCommand(plan.rest, parsed.options); return { diff --git a/apps/ade-cli/src/commands/deeplinks.test.ts b/apps/ade-cli/src/commands/deeplinks.test.ts new file mode 100644 index 000000000..7fe0e4916 --- /dev/null +++ b/apps/ade-cli/src/commands/deeplinks.test.ts @@ -0,0 +1,178 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { + CliDeeplinkUsageError, + runDeeplinkCommand, + runLinearInstall, + runLinkCommand, + runOpenCommand, +} from "./deeplinks"; + +const UUID = "550e8400-e29b-41d4-a716-446655440000"; + +describe("ade link", () => { + it("emits an https lane link by default", () => { + const r = runLinkCommand(["lane", UUID, "--no-clipboard"]); + expect(r.exitCode).toBe(0); + expect(r.output).toContain(`https://ade.app/open?type=lane&id=${UUID}`); + }); + + it("emits the ade:// form when --ade is set", () => { + const r = runLinkCommand(["lane", UUID, "--ade", "--no-clipboard"]); + expect(r.output).toContain(`ade://lane/${UUID}`); + }); + + it("emits a branch link", () => { + const r = runLinkCommand(["branch", "a/b", "feat", "--no-clipboard"]); + expect(r.output).toContain("https://ade.app/open?type=branch"); + expect(r.output).toContain("repo=a%2Fb"); + expect(r.output).toContain("branch=feat"); + }); + + it("emits a branch link with --pr", () => { + const r = runLinkCommand(["branch", "a/b", "feat", "--pr", "42", "--no-clipboard"]); + expect(r.output).toContain("pr=42"); + }); + + it("emits a pr link", () => { + const r = runLinkCommand(["pr", "a/b", "1234", "--no-clipboard"]); + expect(r.output).toContain("https://ade.app/open?type=pr"); + expect(r.output).toContain("number=1234"); + }); + + it("rejects malformed repo", () => { + expect(() => runLinkCommand(["branch", "no-slash", "feat", "--no-clipboard"])).toThrow( + CliDeeplinkUsageError, + ); + }); + + it("rejects bad --pr value", () => { + expect(() => runLinkCommand(["branch", "a/b", "f", "--pr", "abc", "--no-clipboard"])).toThrow( + CliDeeplinkUsageError, + ); + }); + + it("round-trips an existing URL", () => { + const r = runLinkCommand([`ade://lane/${UUID}`, "--no-clipboard"]); + expect(r.output).toContain(`https://ade.app/open?type=lane&id=${UUID}`); + }); + + it("emits a linear-issue link", () => { + const r = runLinkCommand(["linear-issue", "ADE-123", "--no-clipboard"]); + expect(r.output).toContain("https://ade.app/open?type=linear-issue"); + expect(r.output).toContain("issue=ADE-123"); + }); + + it("emits a linear-issue link with branch hint", () => { + const r = runLinkCommand([ + "linear-issue", + "ADE-123", + "--branch", + "arul/ade-123-feat", + "--no-clipboard", + ]); + expect(r.output).toContain("issue=ADE-123"); + expect(r.output).toContain("branch=arul%2Fade-123-feat"); + }); + + it("emits ade:// form for linear-issue when --ade is set", () => { + const r = runLinkCommand(["linear-issue", "ADE-123", "--ade", "--no-clipboard"]); + expect(r.output).toContain("ade://linear-issue/ADE-123"); + }); + + it("rejects empty linear-issue identifier", () => { + expect(() => runLinkCommand(["linear-issue", "--no-clipboard"])).toThrow( + CliDeeplinkUsageError, + ); + }); +}); + +describe("ade linear install", () => { + let tmpHome: string; + + beforeEach(() => { + tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-test-")); + }); + + afterEach(() => { + fs.rmSync(tmpHome, { recursive: true, force: true }); + }); + + it("writes coding-tools.json when none exists", () => { + const r = runLinearInstall([], { home: tmpHome, argv0: "/usr/local/bin/ade" }); + expect(r.exitCode).toBe(0); + const cfg = JSON.parse( + fs.readFileSync(path.join(tmpHome, ".linear", "coding-tools.json"), "utf8"), + ); + expect(cfg).toHaveProperty("openIssue"); + expect(cfg.openIssue).toHaveProperty("path", "/usr/local/bin/ade"); + expect(cfg.openIssue.args[0]).toBe("open"); + // Linear placeholders used in args must be ones Linear actually substitutes. + // {{issue.identifier}} and {{issue.branchName}} are documented; we must + // NOT reference made-up placeholders like {{issue.workspaceKey}} (which + // Linear ignores and would render literally). + const argString = cfg.openIssue.args.join(" "); + expect(argString).toContain("{{issue.identifier}}"); + expect(argString).toContain("{{issue.branchName}}"); + expect(argString).not.toMatch(/\{\{issue\.workspaceKey\}\}/); + }); + + it("backs up an existing config", () => { + fs.mkdirSync(path.join(tmpHome, ".linear"), { recursive: true }); + fs.writeFileSync( + path.join(tmpHome, ".linear", "coding-tools.json"), + JSON.stringify({ otherTool: { path: "/x" } }, null, 2), + ); + const r = runLinearInstall([], { home: tmpHome, argv0: "/usr/local/bin/ade" }); + expect(r.exitCode).toBe(0); + const entries = fs.readdirSync(path.join(tmpHome, ".linear")); + const backup = entries.find((entry) => entry.startsWith("coding-tools.json.bak-")); + expect(backup).toBeDefined(); + const cfg = JSON.parse( + fs.readFileSync(path.join(tmpHome, ".linear", "coding-tools.json"), "utf8"), + ); + expect(cfg).toHaveProperty("openIssue"); + expect(cfg).toHaveProperty("otherTool"); + }); + + it("dry-run prints without writing", () => { + const r = runLinearInstall(["--dry-run"], { home: tmpHome, argv0: "/usr/local/bin/ade" }); + expect(r.exitCode).toBe(0); + expect(r.output).toContain("Would write"); + expect(fs.existsSync(path.join(tmpHome, ".linear", "coding-tools.json"))).toBe(false); + }); +}); + +describe("runDeeplinkCommand", () => { + it("returns help when no args", () => { + const r = runDeeplinkCommand([]); + expect(r.exitCode).toBe(0); + expect(r.output).toContain("ade open"); + expect(r.output).toContain("ade link"); + expect(r.output).toContain("ade linear"); + }); + + it("rejects unknown verbs", () => { + expect(() => runDeeplinkCommand(["frobnicate"])).toThrow(CliDeeplinkUsageError); + }); +}); + +describe("ade open --help", () => { + it("returns help including --linear-issue form", () => { + const r = runOpenCommand(["--help"]); + expect(r.exitCode).toBe(0); + expect(r.output).toContain("--linear-issue"); + expect(r.output).toContain("--branch"); + }); + + it("rejects invalid URL", () => { + expect(() => runOpenCommand(["not a url"])).toThrow(CliDeeplinkUsageError); + }); + + it("rejects unknown ade-host URLs (non-https)", () => { + expect(() => runOpenCommand(["ade://surprise/anything"])).toThrow(CliDeeplinkUsageError); + }); +}); diff --git a/apps/ade-cli/src/commands/deeplinks.ts b/apps/ade-cli/src/commands/deeplinks.ts new file mode 100644 index 000000000..88e58b1a7 --- /dev/null +++ b/apps/ade-cli/src/commands/deeplinks.ts @@ -0,0 +1,416 @@ +// --------------------------------------------------------------------------- +// CLI subcommands for ADE deeplinks: `ade open`, `ade link`, `ade linear`. +// +// The three commands cover the inbound (open), outbound (link), and Linear- +// integration (linear install) surfaces described in the deeplinks plan. +// --------------------------------------------------------------------------- + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; + +import { + buildDeeplink, + parseDeeplink, + type DeeplinkTarget, +} from "../../../desktop/src/shared/deeplinks"; +import { copyToClipboard } from "../lib/clipboard"; + +export class CliDeeplinkUsageError extends Error {} + +export type DeeplinkCliResult = { + output: string; + exitCode: number; +}; + +const HELP_OPEN = [ + "Usage:", + " ade open ", + " ade open --linear-issue --branch ", + "", + " Opens an `ade://` or `https://ade.app/open?...` URL via the OS, which", + " routes it back to the running ADE desktop app (or launches it cold).", + "", + " The --linear-issue / --branch form is what Linear's 'Open in coding", + " tool' menu passes us (Linear doesn't surface the GitHub repo, so the", + " desktop renderer resolves it from the active project).", +].join("\n"); + +const HELP_LINK = [ + "Usage:", + " ade link lane ", + " ade link branch [--pr ]", + " ade link pr ", + " ade link linear-issue [--branch ]", + " ade link # round-trip — parse + re-print canonical form", + "", + "Options:", + " --ade Emit the custom `ade://` form (default: https)", + " --no-clipboard Print the URL but don't copy to clipboard", +].join("\n"); + +const HELP_LINEAR = [ + "Usage: ade linear install", + "", + " Writes ~/.linear/coding-tools.json so Linear's 'Open issue in coding", + " tool' dropdown can launch ADE. Backs up any existing file alongside.", +].join("\n"); + +export function runDeeplinkCommand(rest: string[]): DeeplinkCliResult { + if (rest.length === 0) { + return { output: `${HELP_OPEN}\n${HELP_LINK}\n${HELP_LINEAR}\n`, exitCode: 0 }; + } + const [verb, ...verbArgs] = rest; + switch (verb) { + case "open": + return runOpenCommand(verbArgs); + case "link": + return runLinkCommand(verbArgs); + case "linear": + return runLinearCommand(verbArgs); + default: + throw new CliDeeplinkUsageError( + `Unknown deeplink subcommand: ${verb}. Try 'ade open ', 'ade link ...', or 'ade linear install'.`, + ); + } +} + +// --------------------------------------------------------------------------- +// ade open +// --------------------------------------------------------------------------- + +export function runOpenCommand(args: string[]): DeeplinkCliResult { + if (args.length === 0 || args[0] === "--help" || args[0] === "-h") { + return { output: `${HELP_OPEN}\n`, exitCode: 0 }; + } + + // Flag form: --linear-issue --branch (Linear coding-tool entry) + const flags = extractFlags(args, { + booleans: [], + valued: ["linear-issue", "branch"], + }); + const linearIssue = flags.valued.get("linear-issue"); + const branch = flags.valued.get("branch"); + if (linearIssue || branch) { + // Build an https://ade.app/open URL with the hints Linear gave us. The + // landing page (and the renderer-side handler) interpret the linear-issue + // hint by looking up the lane/project that owns it. + const params = new URLSearchParams(); + params.set("type", "linear-issue"); + if (linearIssue) params.set("issue", linearIssue); + if (branch) params.set("branch", branch); + const url = `https://ade.app/open?${params.toString()}`; + const result = openUrlViaOs(url); + if (result.failed) { + return { + output: `Could not invoke OS opener for ${url}: ${result.message}\n`, + exitCode: 1, + }; + } + return { output: `Opened ${url}\n`, exitCode: 0 }; + } + + // URL form. + const url = flags.positional[0]; + if (!url) { + throw new CliDeeplinkUsageError("ade open (or --linear-issue / --branch)"); + } + const parsed = parseDeeplink(url); + if (!parsed.ok) { + // Allow unknown_type URLs (like the linear-issue form above) to still pass + // through to the OS opener — the landing page / desktop can decide what + // to do. + if (parsed.error.kind === "unknown_type" && /^https?:\/\/ade\.app\/open\b/i.test(url)) { + const result = openUrlViaOs(url); + if (result.failed) { + return { + output: `Could not invoke OS opener for ${url}: ${result.message}\n`, + exitCode: 1, + }; + } + return { output: `Opened ${url}\n`, exitCode: 0 }; + } + throw new CliDeeplinkUsageError( + `Invalid deeplink: ${(parsed.error as { kind: string; reason?: string }).reason ?? parsed.error.kind}`, + ); + } + const result = openUrlViaOs(url); + if (result.failed) { + return { + output: `Could not invoke OS opener for ${url}: ${result.message}\n`, + exitCode: 1, + }; + } + return { output: `Opened ${url}\n`, exitCode: 0 }; +} + +function openUrlViaOs(url: string): { failed: boolean; message: string } { + const platform = process.platform; + let cmd: string; + let args: string[]; + if (platform === "darwin") { + cmd = "open"; + args = [url]; + } else if (platform === "win32") { + cmd = "cmd"; + args = ["/c", "start", "", url]; + } else { + cmd = "xdg-open"; + args = [url]; + } + try { + const r = spawnSync(cmd, args, { stdio: "ignore" }); + if (r.error) return { failed: true, message: r.error.message }; + if (typeof r.status === "number" && r.status !== 0) { + return { failed: true, message: `${cmd} exited with ${r.status}` }; + } + return { failed: false, message: "" }; + } catch (err) { + return { + failed: true, + message: err instanceof Error ? err.message : String(err), + }; + } +} + +// --------------------------------------------------------------------------- +// ade link ... +// --------------------------------------------------------------------------- + +export function runLinkCommand(args: string[]): DeeplinkCliResult { + if (args.length === 0 || args[0] === "--help" || args[0] === "-h") { + return { output: `${HELP_LINK}\n`, exitCode: 0 }; + } + const flags = extractFlags(args, { + booleans: ["ade", "no-clipboard"], + valued: ["pr", "branch"], + }); + const positional = flags.positional; + const useAdeForm = flags.booleans.has("ade"); + const skipClipboard = flags.booleans.has("no-clipboard"); + + // `ade link ` — accept a deeplink and re-emit it in the chosen form. + if (positional.length === 1) { + const parsed = parseDeeplink(positional[0]); + if (parsed.ok) { + const out = buildDeeplink(parsed.target, { form: useAdeForm ? "ade" : "https" }); + return finishLink(out, skipClipboard); + } + } + + const verb = positional[0]; + if (verb === "lane") { + const laneId = positional[1]; + if (!laneId) { + throw new CliDeeplinkUsageError("ade link lane "); + } + const target: DeeplinkTarget = { kind: "lane", laneId }; + const url = buildDeeplink(target, { form: useAdeForm ? "ade" : "https" }); + return finishLink(url, skipClipboard); + } + if (verb === "branch") { + const repo = positional[1]; + const branch = positional[2]; + if (!repo || !branch) { + throw new CliDeeplinkUsageError( + "ade link branch [--pr ]", + ); + } + const [repoOwner, repoName] = repo.split("/"); + if (!repoOwner || !repoName) { + throw new CliDeeplinkUsageError("Repo must be in 'owner/repo' form"); + } + const prNumberRaw = flags.valued.get("pr"); + const prNumber = prNumberRaw ? Number(prNumberRaw) : undefined; + if (prNumberRaw != null && (!Number.isInteger(prNumber) || prNumber == null || prNumber < 1)) { + throw new CliDeeplinkUsageError("--pr must be a positive integer"); + } + const target: DeeplinkTarget = prNumber != null + ? { kind: "branch", repoOwner, repoName, branch, prNumber } + : { kind: "branch", repoOwner, repoName, branch }; + const url = buildDeeplink(target, { form: useAdeForm ? "ade" : "https" }); + return finishLink(url, skipClipboard); + } + if (verb === "pr") { + const repo = positional[1]; + const numberRaw = positional[2]; + if (!repo || !numberRaw) { + throw new CliDeeplinkUsageError("ade link pr "); + } + const [repoOwner, repoName] = repo.split("/"); + if (!repoOwner || !repoName) { + throw new CliDeeplinkUsageError("Repo must be in 'owner/repo' form"); + } + const prNumber = Number(numberRaw); + if (!Number.isInteger(prNumber) || prNumber < 1) { + throw new CliDeeplinkUsageError("PR number must be a positive integer"); + } + const target: DeeplinkTarget = { kind: "pr", repoOwner, repoName, prNumber }; + const url = buildDeeplink(target, { form: useAdeForm ? "ade" : "https" }); + return finishLink(url, skipClipboard); + } + if (verb === "linear-issue") { + const issueIdentifier = positional[1]; + if (!issueIdentifier) { + throw new CliDeeplinkUsageError("ade link linear-issue [--branch ]"); + } + const branchHint = flags.valued.get("branch"); + const target: DeeplinkTarget = branchHint + ? { kind: "linear-issue", issueIdentifier, branch: branchHint } + : { kind: "linear-issue", issueIdentifier }; + const url = buildDeeplink(target, { form: useAdeForm ? "ade" : "https" }); + return finishLink(url, skipClipboard); + } + + throw new CliDeeplinkUsageError(HELP_LINK); +} + +function finishLink(url: string, skipClipboard: boolean): DeeplinkCliResult { + let clipboardNote = ""; + if (!skipClipboard) { + if (copyToClipboard(url)) { + clipboardNote = "\n(copied to clipboard)"; + } + } + return { output: `${url}${clipboardNote}\n`, exitCode: 0 }; +} + +// --------------------------------------------------------------------------- +// ade linear install +// --------------------------------------------------------------------------- + +export function runLinearCommand(args: string[]): DeeplinkCliResult { + const verb = args[0]; + if (!verb || verb === "--help" || verb === "-h") { + return { output: `${HELP_LINEAR}\n`, exitCode: 0 }; + } + if (verb === "install") { + return runLinearInstall(args.slice(1)); + } + throw new CliDeeplinkUsageError(`Unknown linear subcommand: ${verb}`); +} + +export function runLinearInstall(args: string[], opts: { home?: string; argv0?: string } = {}): DeeplinkCliResult { + const home = opts.home ?? os.homedir(); + const cfgDir = path.join(home, ".linear"); + const cfgPath = path.join(cfgDir, "coding-tools.json"); + const bin = opts.argv0 ?? resolveAdeBinaryPath(); + const dryRun = args.includes("--dry-run"); + + // Linear's coding-tool placeholder set only includes the Linear issue + // identifier, branch name, project name, prompt, and PR-comment id. It does + // NOT include the GitHub repo owner/name — tools are expected to resolve + // that locally from the issue identifier + active workspace. + // + // For v1 we hand the CLI just the branch name and Linear issue identifier; + // the receiving `ade open` invocation builds an `ade://` URL based on the + // active desktop project's GitHub remote (resolved via RPC) and opens the + // inbound-deeplink flow from there. If the desktop can't resolve the repo, + // it falls back to opening ADE on the lanes page so the user can pick. + const desiredEntry = { + openIssue: { + path: bin, + args: [ + "open", + "--linear-issue", + "{{issue.identifier}}", + "--branch", + "{{issue.branchName}}", + ], + env: ["LINEAR_PROMPT", "LINEAR_ISSUE_IDENTIFIER", "LINEAR_ISSUE_BRANCH_NAME"], + }, + }; + + let existing: Record | null = null; + if (fs.existsSync(cfgPath)) { + try { + existing = JSON.parse(fs.readFileSync(cfgPath, "utf8")) as Record; + } catch { + existing = null; + } + } + + const merged = existing ? { ...existing, ...desiredEntry } : desiredEntry; + const serialized = JSON.stringify(merged, null, 2) + "\n"; + + if (dryRun) { + return { + output: `Would write ${cfgPath}\n\n${serialized}`, + exitCode: 0, + }; + } + + fs.mkdirSync(cfgDir, { recursive: true }); + + if (existing) { + const backupPath = `${cfgPath}.bak-${new Date() + .toISOString() + .replace(/[:.]/g, "") + .slice(0, 15)}`; + fs.copyFileSync(cfgPath, backupPath); + fs.writeFileSync(cfgPath, serialized); + return { + output: `Wrote ${cfgPath} (backup at ${backupPath})\n`, + exitCode: 0, + }; + } + fs.writeFileSync(cfgPath, serialized); + return { output: `Wrote ${cfgPath}\n`, exitCode: 0 }; +} + +function resolveAdeBinaryPath(): string { + // When invoked via the bundled binary, argv[0] is the absolute path. When + // run via node, argv[0] is node — fall back to a `which`-like resolution. + const argv0 = process.argv[0] ?? ""; + if (argv0 && /\b(ade|node)\b/.test(argv0)) { + if (/\bade\b/.test(argv0)) return argv0; + } + // Last-resort: hope `ade` is on PATH. + return "ade"; +} + +// --------------------------------------------------------------------------- +// Argument helpers +// --------------------------------------------------------------------------- + +type FlagSpec = { + booleans: string[]; + valued: string[]; +}; + +type FlagResult = { + booleans: Set; + valued: Map; + positional: string[]; +}; + +function extractFlags(args: string[], spec: FlagSpec): FlagResult { + const booleans = new Set(); + const valued = new Map(); + const positional: string[] = []; + const booleanSet = new Set(spec.booleans); + const valuedSet = new Set(spec.valued); + for (let i = 0; i < args.length; i += 1) { + const arg = args[i]; + if (arg.startsWith("--")) { + const name = arg.slice(2); + if (booleanSet.has(name)) { + booleans.add(name); + continue; + } + if (valuedSet.has(name)) { + const next = args[i + 1]; + if (next == null || next.startsWith("--")) { + throw new CliDeeplinkUsageError(`--${name} requires a value`); + } + valued.set(name, next); + i += 1; + continue; + } + throw new CliDeeplinkUsageError(`Unknown flag: ${arg}`); + } + positional.push(arg); + } + return { booleans, valued, positional }; +} diff --git a/apps/ade-cli/src/eventBuffer.ts b/apps/ade-cli/src/eventBuffer.ts index be0475117..0429f3bc3 100644 --- a/apps/ade-cli/src/eventBuffer.ts +++ b/apps/ade-cli/src/eventBuffer.ts @@ -1,7 +1,7 @@ export type BufferedEvent = { id: number; timestamp: string; - category: "orchestrator" | "dag_mutation" | "runtime" | "mission"; + category: "orchestrator" | "dag_mutation" | "runtime" | "mission" | "pty"; payload: Record; }; diff --git a/apps/ade-cli/src/lib/clipboard.ts b/apps/ade-cli/src/lib/clipboard.ts new file mode 100644 index 000000000..dd6efa1dd --- /dev/null +++ b/apps/ade-cli/src/lib/clipboard.ts @@ -0,0 +1,63 @@ +// --------------------------------------------------------------------------- +// Cross-platform clipboard helper used by CLI commands and the TUI. +// +// Picks the right system clipboard binary for darwin (pbcopy), win32 (clip), +// or Linux (wl-copy / xclip). Returns `false` when no usable binary is found +// instead of throwing — callers decide how to surface the failure. +// --------------------------------------------------------------------------- + +import { spawnSync } from "node:child_process"; + +export type CopyToClipboardOptions = { + /** + * Test seam: override the spawn function. The override must return the + * same shape as `spawnSync` (status + error). Defaults to `spawnSync`. + */ + spawn?: (cmd: string, args: string[], options: { input: string }) => { + error?: Error; + status?: number | null; + }; + /** + * Test seam: override the `which`/`where` lookup used to detect Linux + * clipboard tools. Defaults to a real `spawnSync` check. + */ + commandExists?: (cmd: string) => boolean; + /** Test seam: override `process.platform`. */ + platform?: NodeJS.Platform; +}; + +export function copyToClipboard(text: string, options: CopyToClipboardOptions = {}): boolean { + const platform = options.platform ?? process.platform; + const spawn = options.spawn ?? ((cmd, args, opts) => spawnSync(cmd, args, opts)); + const commandExists = options.commandExists ?? defaultCommandExists; + + let cmd: string; + let args: string[]; + if (platform === "darwin") { + cmd = "pbcopy"; + args = []; + } else if (platform === "win32") { + cmd = "clip"; + args = []; + } else { + if (commandExists("wl-copy")) { + cmd = "wl-copy"; + args = []; + } else if (commandExists("xclip")) { + cmd = "xclip"; + args = ["-selection", "clipboard"]; + } else { + return false; + } + } + const r = spawn(cmd, args, { input: text }); + if (r.error || (typeof r.status === "number" && r.status !== 0)) return false; + return true; +} + +function defaultCommandExists(cmd: string): boolean { + const r = spawnSync(process.platform === "win32" ? "where" : "which", [cmd], { + stdio: "ignore", + }); + return !r.error && r.status === 0; +} diff --git a/apps/ade-cli/src/multiProjectRpcServer.ts b/apps/ade-cli/src/multiProjectRpcServer.ts index c96ac49bc..e844170b7 100644 --- a/apps/ade-cli/src/multiProjectRpcServer.ts +++ b/apps/ade-cli/src/multiProjectRpcServer.ts @@ -208,7 +208,8 @@ function readEventCategory(value: unknown): RuntimeEventCategory | null { return value === "orchestrator" || value === "dag_mutation" || value === "runtime" || - value === "mission" + value === "mission" || + value === "pty" ? value : null; } diff --git a/apps/ade-cli/src/services/sync/syncHostService.ts b/apps/ade-cli/src/services/sync/syncHostService.ts index 888f1830c..5fe71a656 100644 --- a/apps/ade-cli/src/services/sync/syncHostService.ts +++ b/apps/ade-cli/src/services/sync/syncHostService.ts @@ -321,6 +321,12 @@ type SyncHostServiceArgs = { laneTemplateService?: ReturnType; rebaseSuggestionService?: ReturnType; autoRebaseService?: ReturnType; + /** + * Optional handler for the `deeplinks.open` sync command (iOS Send-to-Mac). + * Desktop main.ts passes a wrapper that parses the URL + dispatches via the + * renderer's navigation service. + */ + dispatchDeeplinkUrl?: (url: string) => Promise<{ ok: boolean; message?: string }>; computerUseArtifactBrokerService: ReturnType; pinStore: SyncPinStore; bootstrapTokenPath?: string; @@ -684,6 +690,7 @@ export function createSyncHostService(args: SyncHostServiceArgs) { laneTemplateService: args.laneTemplateService, rebaseSuggestionService: args.rebaseSuggestionService, autoRebaseService: args.autoRebaseService, + dispatchDeeplinkUrl: args.dispatchDeeplinkUrl, logger: args.logger, }); const heartbeatIntervalMs = Math.max(5_000, Math.floor(args.heartbeatIntervalMs ?? DEFAULT_SYNC_HEARTBEAT_INTERVAL_MS)); diff --git a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts index e04cf40a5..d3d66a84a 100644 --- a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts +++ b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts @@ -209,6 +209,15 @@ type SyncRemoteCommandServiceArgs = { * compiling — handlers reject with a clear error when missing. */ getModelPickerStore?: () => ModelPickerStore | null; + /** + * Optional handler for the `deeplinks.open` sync command. iOS uses this to + * bounce a cross-machine `ade://...` URL to the paired desktop ("Send to + * your Mac"). Desktop main.ts wires this up to parseDeeplink + + * appNavigationService; in the ade-cli/runtime context (no desktop windows + * present) the handler is intentionally unset and the command returns a + * clear "not available" error. + */ + dispatchDeeplinkUrl?: (url: string) => Promise<{ ok: boolean; message?: string }>; logger: Logger; }; @@ -2537,6 +2546,25 @@ export function createSyncRemoteCommandService(args: SyncRemoteCommandServiceArg snapshots: args.prService.listSnapshots(), }; }); + // iOS "Send to your Mac" deeplink bounce. Mobile cannot natively open a + // lane / repo-branch / cross-repo PR deeplink, so it forwards the URL to + // the paired desktop via this command. Desktop main.ts wires up + // `dispatchDeeplinkUrl` to parse the URL and route through the renderer's + // navigation service. In the ade-cli runtime (no desktop windows) the + // handler returns a clear "not available" so the iOS caller can fall back. + register("deeplinks.open", { viewerAllowed: true, queueable: false }, async (payload) => { + const url = asTrimmedString(payload.url); + if (!url) { + throw new Error("deeplinks.open requires a url."); + } + if (!args.dispatchDeeplinkUrl) { + return { + ok: false, + message: "Desktop navigation is unavailable in this runtime.", + }; + } + return await args.dispatchDeeplinkUrl(url); + }); register("prs.getDetail", { viewerAllowed: true }, async (payload) => args.prService.getDetail(requirePrId(payload, "prs.getDetail"))); register("prs.getStatus", { viewerAllowed: true }, async (payload) => args.prService.getStatus(requirePrId(payload, "prs.getStatus"))); register("prs.getChecks", { viewerAllowed: true }, async (payload) => args.prService.getChecks(requirePrId(payload, "prs.getChecks"))); diff --git a/apps/ade-cli/src/services/sync/syncService.ts b/apps/ade-cli/src/services/sync/syncService.ts index e3d7520c4..92418e141 100644 --- a/apps/ade-cli/src/services/sync/syncService.ts +++ b/apps/ade-cli/src/services/sync/syncService.ts @@ -149,6 +149,12 @@ type SyncServiceArgs = { * same in-memory state and persist to `~/.ade/modelPicker.json`. */ getModelPickerStore?: () => ModelPickerStore | null; + /** + * Optional handler for the iOS Send-to-Mac deeplink bounce. Wired up by + * desktop main.ts; the ade-cli runtime context leaves it unset and the + * `deeplinks.open` sync command reports unavailable. + */ + dispatchDeeplinkUrl?: (url: string) => Promise<{ ok: boolean; message?: string }>; }; const DRAFT_FILE = "sync-peer-draft.json"; @@ -567,6 +573,7 @@ export function createSyncService(args: SyncServiceArgs) { rebaseSuggestionService: args.rebaseSuggestionService ?? undefined, autoRebaseService: args.autoRebaseService ?? undefined, getModelPickerStore: args.getModelPickerStore, + dispatchDeeplinkUrl: args.dispatchDeeplinkUrl, logger: args.logger, }); @@ -635,6 +642,7 @@ export function createSyncService(args: SyncServiceArgs) { laneTemplateService: args.laneTemplateService, rebaseSuggestionService: args.rebaseSuggestionService ?? undefined, autoRebaseService: args.autoRebaseService ?? undefined, + dispatchDeeplinkUrl: args.dispatchDeeplinkUrl, computerUseArtifactBrokerService: args.computerUseArtifactBrokerService, pinStore, bootstrapTokenPath: tokenPath, diff --git a/apps/ade-cli/src/tuiClient/__tests__/ChatView.test.tsx b/apps/ade-cli/src/tuiClient/__tests__/ChatView.test.tsx index ec2614b25..1cee610e7 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/ChatView.test.tsx +++ b/apps/ade-cli/src/tuiClient/__tests__/ChatView.test.tsx @@ -355,13 +355,37 @@ describe("ChatView", () => { expect(frame).not.toContain("[done]"); }); + it("keeps tool activity status out of the visible transcript", () => { + const turnId = "tool-status-turn"; + const frame = renderChatTranscriptPlainText({ + events: [ + { sessionId: "s1", timestamp: "2026-01-01T12:00:00.000Z", sequence: 1, event: { type: "activity", activity: "tool_calling", detail: "Processing tool input", turnId } }, + { sessionId: "s1", timestamp: "2026-01-01T12:00:01.000Z", sequence: 2, event: { type: "tool_call", tool: "Grep", args: {}, itemId: "tool-1", turnId } }, + { sessionId: "s1", timestamp: "2026-01-01T12:00:02.000Z", sequence: 3, event: { type: "activity", activity: "reading", detail: "Read", turnId } }, + { sessionId: "s1", timestamp: "2026-01-01T12:00:03.000Z", sequence: 4, event: { type: "tool_call", tool: "Read", args: {}, itemId: "tool-2", turnId } }, + { sessionId: "s1", timestamp: "2026-01-01T12:00:04.000Z", sequence: 5, event: { type: "text", text: "Let me look at the sendMessage flow more carefully and what ", itemId: "msg-1", turnId } }, + { sessionId: "s1", timestamp: "2026-01-01T12:00:05.000Z", sequence: 6, event: { type: "activity", activity: "searching", detail: "Grep", turnId } }, + { sessionId: "s1", timestamp: "2026-01-01T12:00:06.000Z", sequence: 7, event: { type: "text", text: "events are emitted when a session is resumed.", itemId: "msg-1", turnId } }, + ], + notices: [], + activeSession: session, + width: 120, + }); + + expect(frame).not.toContain("Runtime"); + expect(frame.match(/Tool calls/g)).toHaveLength(1); + expect(frame).toContain("Tool calls (2)"); + expect(frame).toContain("Let me look at the sendMessage flow more carefully and what events are emitted when a session is resumed."); + }); + it("keeps startup/auth notices out of the transcript header spam path", () => { const result = render( { expect(frame).toContain("Claude auth completed. Refreshing provider status."); expect(frame).not.toContain("Session ready"); expect(frame).not.toContain("Hook: SessionStart"); + expect(frame).not.toContain("Trimmed large tool output"); expect(frame).not.toContain("Claude ·"); expect(frame).not.toContain("ADE Code ·"); expect(frame).not.toContain("12:00"); diff --git a/apps/ade-cli/src/tuiClient/__tests__/adeApi.test.ts b/apps/ade-cli/src/tuiClient/__tests__/adeApi.test.ts index 168309532..4419412dc 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/adeApi.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/adeApi.test.ts @@ -3,7 +3,7 @@ import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { AgentChatEventEnvelope } from "../../../../desktop/src/shared/types/chat"; -import { cancelSteerMessage, createChatSession, DEFAULT_CODEX_REASONING_EFFORT, dispatchSteerMessage, discoverProjectSlashCommands, editSteerMessage, latestGoal, latestTokenStats, listLaneDiffStats, listPrsByLane, sendChatMessage, signalTerminal, steerChatMessage } from "../adeApi"; +import { cancelSteerMessage, createChatSession, DEFAULT_CODEX_REASONING_EFFORT, dispatchSteerMessage, discoverProjectSlashCommands, editSteerMessage, latestGoal, latestTokenStats, listLaneDiffStats, listPrsByLane, listTerminalSessions, sendChatMessage, signalTerminal, steerChatMessage } from "../adeApi"; import type { AdeCodeConnection } from "../types"; const tmpPaths: string[] = []; @@ -366,6 +366,45 @@ describe("signalTerminal", () => { }); }); +describe("listTerminalSessions", () => { + it("only exposes Claude Code CLI sessions in the ADE code TUI", async () => { + const calls: Array<{ domain: string; action: string; args?: Record }> = []; + const sessions = [ + { terminalId: "claude-1", toolType: "claude" }, + { terminalId: "claude-orch-1", toolType: "claude-orchestrated" }, + { terminalId: "legacy-claude-1", toolType: "shell", resumeMetadata: { provider: "claude" } }, + { terminalId: "codex-1", toolType: "codex" }, + { terminalId: "codex-orch-1", toolType: "codex-orchestrated" }, + { terminalId: "legacy-codex-1", toolType: "shell", resumeMetadata: { provider: "codex" } }, + { terminalId: "chat-claude-1", toolType: "claude-chat" }, + { terminalId: "chat-codex-1", toolType: "codex-chat" }, + { terminalId: "cursor-1", toolType: "cursor" }, + { terminalId: "shell-1", toolType: "shell" }, + ]; + const connection = { + action: async (domain: string, action: string, args?: Record) => { + calls.push({ domain, action, args }); + return sessions; + }, + } as unknown as AdeCodeConnection; + + const result = await listTerminalSessions(connection, "lane-1"); + + expect(calls).toEqual([ + { + domain: "terminal", + action: "list", + args: { laneId: "lane-1", limit: 200 }, + }, + ]); + expect(result.map((session) => session.terminalId)).toEqual([ + "claude-1", + "claude-orch-1", + "legacy-claude-1", + ]); + }); +}); + describe("sendChatMessage", () => { it("forwards chat text unchanged and waits until the shared runtime has accepted the turn", async () => { const calls: Array<{ domain: string; action: string; argsList: unknown[] }> = []; diff --git a/apps/ade-cli/src/tuiClient/__tests__/aggregate.test.ts b/apps/ade-cli/src/tuiClient/__tests__/aggregate.test.ts index 192eb4f92..c48d83290 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/aggregate.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/aggregate.test.ts @@ -149,20 +149,54 @@ describe("aggregateChatBlocks typed groups", () => { expect(toolGroup!.entries.map((e) => e.itemId)).toEqual(["kept-1"]); }); - it("groups meaningful runtime activity while suppressing generic thinking heartbeats", () => { + it("suppresses tool-derived runtime activity so tool groups match desktop transcript grouping", () => { const events: AgentChatEventEnvelope[] = [ env("2026-01-01T12:00:00.000Z", { type: "activity", activity: "thinking", detail: "Thinking through the answer", turnId: "turn-1" }), env("2026-01-01T12:00:01.000Z", { type: "activity", activity: "reading", detail: "apps/ade-cli/src/tuiClient/app.tsx", turnId: "turn-1" }), - env("2026-01-01T12:00:02.000Z", { type: "subagent_started", taskId: "agent-1", parentToolUseId: "spawn-1", description: "child launch spam", turnId: "turn-1" }), + env("2026-01-01T12:00:02.000Z", { type: "activity", activity: "searching", detail: "Grep", turnId: "turn-1" }), + env("2026-01-01T12:00:03.000Z", { type: "activity", activity: "tool_calling", detail: "Processing tool input", turnId: "turn-1" }), + env("2026-01-01T12:00:04.000Z", { type: "subagent_started", taskId: "agent-1", parentToolUseId: "spawn-1", description: "child launch spam", turnId: "turn-1" }), ]; const blocks = aggregate(events); const activity = blocks.find((b) => b.kind === "runtime-activity") as Extract | undefined; expect(activity).toBeDefined(); - expect(activity!.entries[0]).toMatchObject({ label: "reading", detail: "apps/ade-cli/src/tuiClient/app.tsx" }); - expect(activity!.entries[1]).toMatchObject({ label: "subagent started" }); - expect(activity!.entries[1]).not.toHaveProperty("detail"); + expect(activity!.entries).toHaveLength(1); + expect(activity!.entries[0]).toMatchObject({ label: "subagent started" }); + expect(activity!.entries[0]).not.toHaveProperty("detail"); + }); + + it("keeps one tool-calls-group when activity status events are interleaved", () => { + const events: AgentChatEventEnvelope[] = [ + env("2026-01-01T12:00:00.000Z", { type: "activity", activity: "tool_calling", detail: "Processing tool input", turnId: "turn-1" }), + env("2026-01-01T12:00:01.000Z", { type: "tool_call", tool: "grep", args: {}, itemId: "t1", turnId: "turn-1" }), + env("2026-01-01T12:00:02.000Z", { type: "activity", activity: "reading", detail: "Read", turnId: "turn-1" }), + env("2026-01-01T12:00:03.000Z", { type: "tool_call", tool: "read", args: {}, itemId: "t2", turnId: "turn-1" }), + env("2026-01-01T12:00:04.000Z", { type: "activity", activity: "searching", detail: "Grep", turnId: "turn-1" }), + env("2026-01-01T12:00:05.000Z", { type: "tool_call", tool: "grep", args: {}, itemId: "t3", turnId: "turn-1" }), + ]; + + const blocks = aggregate(events); + const toolGroups = blocks.filter((b) => b.kind === "tool-calls-group") as Array>; + + expect(blocks.some((b) => b.kind === "runtime-activity")).toBe(false); + expect(toolGroups).toHaveLength(1); + expect(toolGroups[0]!.entries.map((entry) => entry.tool)).toEqual(["grep", "read", "grep"]); + }); + + it("merges assistant text chunks after suppressing interleaved activity", () => { + const events: AgentChatEventEnvelope[] = [ + env("2026-01-01T12:00:00.000Z", { type: "text", text: "Let me look at the sendMessage flow more carefully and what ", turnId: "turn-1", itemId: "msg-1" }), + env("2026-01-01T12:00:01.000Z", { type: "activity", activity: "reading", detail: "Read", turnId: "turn-1" }), + env("2026-01-01T12:00:02.000Z", { type: "text", text: "events are emitted when a session is resumed.", turnId: "turn-1", itemId: "msg-1" }), + ]; + + const blocks = aggregate(events); + const assistantBlocks = blocks.filter((b) => b.kind === "assistant-text") as Array>; + + expect(assistantBlocks).toHaveLength(1); + expect(assistantBlocks[0]!.line.body).toBe("Let me look at the sendMessage flow more carefully and what events are emitted when a session is resumed."); }); it("marks tool-calls-group and files-changed-group as not-live without stamping turn duration", () => { diff --git a/apps/ade-cli/src/tuiClient/__tests__/appInput.test.ts b/apps/ade-cli/src/tuiClient/__tests__/appInput.test.ts index 72bc12e3c..9b15219ce 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/appInput.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/appInput.test.ts @@ -1,8 +1,10 @@ import { describe, expect, it } from "vitest"; import { + CLAUDE_TERMINAL_SUBMIT_CONFIRM_DELAY_MS, clampChatScrollOffsetRows, deletePreviousPromptLine, deletePreviousPromptWord, + encodeTerminalPromptSubmitConfirm, encodeTerminalPromptSubmit, footerControlsForAvailability, isPromptLineBackspace, @@ -509,4 +511,9 @@ describe("encodeTerminalPromptSubmit", () => { it("uses bracketed paste for multiline prompts", () => { expect(encodeTerminalPromptSubmit("one\r\ntwo")).toBe("\x1b[200~one\ntwo\x1b[201~\r"); }); + + it("uses a delayed confirm enter for live Claude terminal submissions", () => { + expect(encodeTerminalPromptSubmitConfirm()).toBe("\r"); + expect(CLAUDE_TERMINAL_SUBMIT_CONFIRM_DELAY_MS).toBeGreaterThanOrEqual(1000); + }); }); diff --git a/apps/ade-cli/src/tuiClient/__tests__/deeplinkKeybind.test.ts b/apps/ade-cli/src/tuiClient/__tests__/deeplinkKeybind.test.ts new file mode 100644 index 000000000..f362c61a2 --- /dev/null +++ b/apps/ade-cli/src/tuiClient/__tests__/deeplinkKeybind.test.ts @@ -0,0 +1,207 @@ +import { describe, expect, it } from "vitest"; + +import { copyToClipboard } from "../../lib/clipboard"; +import { + buildDeeplinkForRow, + parseGitHubPrUrl, + type DeeplinkRow, +} from "../deeplinkRow"; +import { + dispatchKeybinding, + validateClaudeKeybindingsConfig, +} from "../keybindings"; + +const LANE_UUID = "550e8400-e29b-41d4-a716-446655440000"; + +describe("copy ADE deeplink keybinding", () => { + it("registers as a known TUI action and dispatches under Tabs context", () => { + const diagnostics = validateClaudeKeybindingsConfig({ + bindings: [ + { context: "Tabs", bindings: { "ctrl+y": "app:copyAdeDeeplink" } }, + ], + }); + expect(diagnostics.bindingCount).toBe(1); + expect(diagnostics.warnings).toEqual([]); + expect( + dispatchKeybinding(diagnostics.bindings, "Tabs", "y", { ctrl: true }), + ).toBe("app:copyAdeDeeplink"); + }); + + it("dispatches under the Select (lane-details) context too", () => { + const diagnostics = validateClaudeKeybindingsConfig({ + bindings: [ + { context: "Select", bindings: { "ctrl+y": "app:copyAdeDeeplink" } }, + ], + }); + expect(diagnostics.bindingCount).toBe(1); + expect( + dispatchKeybinding(diagnostics.bindings, "Select", "y", { ctrl: true }), + ).toBe("app:copyAdeDeeplink"); + }); + + it("does not dispatch in unrelated contexts (Chat)", () => { + const diagnostics = validateClaudeKeybindingsConfig({ + bindings: [ + { context: "Tabs", bindings: { "ctrl+y": "app:copyAdeDeeplink" } }, + ], + }); + expect( + dispatchKeybinding(diagnostics.bindings, "Chat", "y", { ctrl: true }), + ).toBeUndefined(); + }); + + it("builds the ade:// deeplink for a lane row", () => { + const row: DeeplinkRow = { kind: "lane", lane: { id: LANE_UUID } }; + expect(buildDeeplinkForRow(row)).toBe(`ade://lane/${LANE_UUID}`); + }); + + it("builds the ade:// deeplink for a PR row from explicit fields", () => { + const row: DeeplinkRow = { + kind: "pr", + pr: { repoOwner: "anthropics", repoName: "ade", prNumber: 42 }, + }; + expect(buildDeeplinkForRow(row)).toBe("ade://pr/anthropics/ade/42"); + }); + + it("builds the ade:// deeplink for a PR row by parsing the GitHub URL", () => { + const row: DeeplinkRow = { + kind: "pr", + pr: { url: "https://github.com/anthropics/ade/pull/322", prNumber: 322 }, + }; + expect(buildDeeplinkForRow(row)).toBe("ade://pr/anthropics/ade/322"); + }); + + it("returns null when the PR row has no URL and no owner/repo", () => { + const row: DeeplinkRow = { + kind: "pr", + pr: { url: "not-a-url" }, + }; + expect(buildDeeplinkForRow(row)).toBeNull(); + }); + + it("parseGitHubPrUrl rejects non-pull paths", () => { + expect( + parseGitHubPrUrl("https://github.com/anthropics/ade/issues/7"), + ).toBeNull(); + expect(parseGitHubPrUrl("")).toBeNull(); + }); +}); + +describe("clipboard helper dispatches the right OS command", () => { + it("uses pbcopy on darwin with the deeplink as stdin", () => { + const calls: Array<{ cmd: string; args: string[]; input: string }> = []; + const ok = copyToClipboard("ade://lane/abc", { + platform: "darwin", + spawn: (cmd, args, opts) => { + calls.push({ cmd, args, input: opts.input }); + return { status: 0 }; + }, + }); + expect(ok).toBe(true); + expect(calls).toEqual([{ cmd: "pbcopy", args: [], input: "ade://lane/abc" }]); + }); + + it("uses clip on win32", () => { + const calls: Array<{ cmd: string; args: string[]; input: string }> = []; + const ok = copyToClipboard("ade://pr/o/r/1", { + platform: "win32", + spawn: (cmd, args, opts) => { + calls.push({ cmd, args, input: opts.input }); + return { status: 0 }; + }, + }); + expect(ok).toBe(true); + expect(calls[0]?.cmd).toBe("clip"); + }); + + it("prefers wl-copy on linux, falling back to xclip", () => { + const wlCalls: Array = []; + const okWl = copyToClipboard("ade://lane/x", { + platform: "linux", + commandExists: (cmd) => cmd === "wl-copy", + spawn: (cmd, _args, _opts) => { + wlCalls.push(cmd); + return { status: 0 }; + }, + }); + expect(okWl).toBe(true); + expect(wlCalls).toEqual(["wl-copy"]); + + const xclipCalls: Array<{ cmd: string; args: string[] }> = []; + const okX = copyToClipboard("ade://lane/x", { + platform: "linux", + commandExists: (cmd) => cmd === "xclip", + spawn: (cmd, args, _opts) => { + xclipCalls.push({ cmd, args }); + return { status: 0 }; + }, + }); + expect(okX).toBe(true); + expect(xclipCalls).toEqual([{ cmd: "xclip", args: ["-selection", "clipboard"] }]); + }); + + it("returns false when no linux clipboard tool is available", () => { + const ok = copyToClipboard("ade://lane/x", { + platform: "linux", + commandExists: () => false, + spawn: () => ({ status: 0 }), + }); + expect(ok).toBe(false); + }); + + it("returns false when the spawn errors out", () => { + const ok = copyToClipboard("ade://lane/x", { + platform: "darwin", + spawn: () => ({ error: new Error("ENOENT") }), + }); + expect(ok).toBe(false); + }); +}); + +describe("end-to-end: keybinding action invokes clipboard with the right URL", () => { + // This emulates how runKeybindingAction wires the dispatch path: resolve the + // focused row to a DeeplinkRow, build the URL, and hand it to copyToClipboard. + // We do not render the Ink app — this is the contract the TUI relies on. + function runCopyAdeDeeplinkAction(row: DeeplinkRow | null, recorded: { url: string | null }): "copied" | "no-row" | "no-url" | "no-clipboard" { + if (!row) return "no-row"; + const url = buildDeeplinkForRow(row); + if (!url) return "no-url"; + const ok = copyToClipboard(url, { + platform: "darwin", + spawn: (_cmd, _args, opts) => { + recorded.url = opts.input; + return { status: 0 }; + }, + }); + return ok ? "copied" : "no-clipboard"; + } + + it("copies the canonical lane URL when a lane row is focused", () => { + const recorded = { url: null as string | null }; + const result = runCopyAdeDeeplinkAction( + { kind: "lane", lane: { id: LANE_UUID } }, + recorded, + ); + expect(result).toBe("copied"); + expect(recorded.url).toBe(`ade://lane/${LANE_UUID}`); + }); + + it("copies the canonical PR URL when a PR row is focused", () => { + const recorded = { url: null as string | null }; + const result = runCopyAdeDeeplinkAction( + { + kind: "pr", + pr: { url: "https://github.com/anthropics/ade/pull/7", prNumber: 7 }, + }, + recorded, + ); + expect(result).toBe("copied"); + expect(recorded.url).toBe("ade://pr/anthropics/ade/7"); + }); + + it("reports no-row when nothing is focused", () => { + const recorded = { url: null as string | null }; + expect(runCopyAdeDeeplinkAction(null, recorded)).toBe("no-row"); + expect(recorded.url).toBeNull(); + }); +}); diff --git a/apps/ade-cli/src/tuiClient/__tests__/format.test.ts b/apps/ade-cli/src/tuiClient/__tests__/format.test.ts index 15385b036..012473af3 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/format.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/format.test.ts @@ -608,6 +608,12 @@ describe("renderChatLines", () => { sessionId: "s1", timestamp: "2026-01-01T12:00:02.000Z", sequence: 3, + event: { type: "system_notice", noticeKind: "hook", message: "Trimmed large tool output before sending it back to Claude." } as never, + }, + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:03.000Z", + sequence: 4, event: { type: "system_notice", noticeKind: "auth", message: "Failed to authenticate. API Error: 401 Invalid authentication credentials" } as never, }, ], diff --git a/apps/ade-cli/src/tuiClient/adeApi.ts b/apps/ade-cli/src/tuiClient/adeApi.ts index b3e43dedd..ffdf747d2 100644 --- a/apps/ade-cli/src/tuiClient/adeApi.ts +++ b/apps/ade-cli/src/tuiClient/adeApi.ts @@ -85,8 +85,6 @@ const CHAT_BACKED_TERMINAL_TOOL_TYPES = new Set([ const RESUMABLE_TERMINAL_TOOL_TYPES = new Set([ "claude", "claude-orchestrated", - "codex", - "codex-orchestrated", ]); export async function listTerminalSessions( @@ -101,8 +99,7 @@ export async function listTerminalSessions( const toolType = session.toolType ?? ""; if (CHAT_BACKED_TERMINAL_TOOL_TYPES.has(toolType)) return false; return RESUMABLE_TERMINAL_TOOL_TYPES.has(toolType) - || session.resumeMetadata?.provider === "claude" - || session.resumeMetadata?.provider === "codex"; + || session.resumeMetadata?.provider === "claude"; }); } diff --git a/apps/ade-cli/src/tuiClient/aggregate.ts b/apps/ade-cli/src/tuiClient/aggregate.ts index 190606375..127a3ba9a 100644 --- a/apps/ade-cli/src/tuiClient/aggregate.ts +++ b/apps/ade-cli/src/tuiClient/aggregate.ts @@ -6,6 +6,7 @@ import type { import type { LocalNotice } from "./types"; import { chatEventLineId, + parseAssistantMarkdown, renderChatLines, type RenderedChatLine, } from "./format"; @@ -66,6 +67,41 @@ function turnIdOf(event: AgentChatEvent): string | null { return (event as { turnId?: string }).turnId ?? null; } +type AssistantTextEvent = Extract; + +function textIdentity(event: AssistantTextEvent): string | null { + const messageId = event.messageId?.trim(); + return messageId?.length ? messageId : null; +} + +function turnAndItemMatch( + a: { turnId?: string; itemId?: string }, + b: { turnId?: string; itemId?: string }, +): boolean { + const aTurnId = a.turnId ?? null; + const bTurnId = b.turnId ?? null; + if (!aTurnId || !bTurnId || aTurnId !== bTurnId) return false; + const aItemId = a.itemId ?? null; + const bItemId = b.itemId ?? null; + return !aItemId || !bItemId || aItemId === bItemId; +} + +function shouldMergeAssistantTextEvents(previous: AssistantTextEvent, next: AssistantTextEvent): boolean { + const previousIdentity = textIdentity(previous); + const nextIdentity = textIdentity(next); + + if (previousIdentity || nextIdentity) { + if (previousIdentity && nextIdentity) { + return previousIdentity === nextIdentity; + } + return turnAndItemMatch(previous, next); + } + + if (turnAndItemMatch(previous, next)) return true; + + return !previous.turnId && !next.turnId && !previous.itemId && !next.itemId; +} + function safeMs(value: string): number { const parsed = Date.parse(value); return Number.isNaN(parsed) ? 0 : parsed; @@ -300,7 +336,21 @@ function runtimeActivityFromEvent(id: string, event: AgentChatEvent): RuntimeAct if (event.type === "activity") { const activity = event.activity; const detail = compactActivityDetail(event.detail); - if (activity === "thinking" || activity === "working") return null; + // The desktop transcript intentionally hides activity events because they are + // a live status shadow of the real tool/command/file events. Showing them in + // the TUI makes the same transcript fragment into Runtime + Tool calls pairs. + if ( + activity === "thinking" + || activity === "working" + || activity === "tool_calling" + || activity === "searching" + || activity === "reading" + || activity === "running_command" + || activity === "editing_file" + || activity === "web_searching" + ) { + return null; + } return { id, label: activity.replace(/_/g, " "), @@ -474,6 +524,7 @@ export function aggregateChatBlocks(args: { blocks.push({ kind, id, line } as AggregatedBlock); }; const workItemStartedAt = new Map(); + const assistantTextEventsByBlockId = new Map(); for (const entry of timeline) { if (entry.kind === "notice") { @@ -514,7 +565,31 @@ export function aggregateChatBlocks(args: { continue; } if (event.type === "text") { - passthrough(id, "assistant-text"); + if (!event.text.trim().length) continue; + const previous = blocks[blocks.length - 1]; + if (previous?.kind === "assistant-text") { + const previousTextEvent = assistantTextEventsByBlockId.get(previous.id); + if (previousTextEvent && shouldMergeAssistantTextEvents(previousTextEvent, event)) { + const mergedText = `${previous.line.body}${event.text}`; + previous.line = { + ...previous.line, + body: mergedText, + blocks: parseAssistantMarkdown(mergedText), + }; + assistantTextEventsByBlockId.set(previous.id, { + ...previousTextEvent, + text: mergedText, + turnId: previousTextEvent.turnId ?? event.turnId, + itemId: previousTextEvent.itemId ?? event.itemId, + messageId: previousTextEvent.messageId ?? event.messageId, + }); + continue; + } + } + const line = linesById.get(id); + if (!line) continue; + blocks.push({ kind: "assistant-text", id, line }); + assistantTextEventsByBlockId.set(id, event); continue; } if (event.type === "reasoning") { diff --git a/apps/ade-cli/src/tuiClient/app.tsx b/apps/ade-cli/src/tuiClient/app.tsx index bc1ffdf67..f626da890 100644 --- a/apps/ade-cli/src/tuiClient/app.tsx +++ b/apps/ade-cli/src/tuiClient/app.tsx @@ -105,7 +105,7 @@ import { } from "./components/ChatView"; import { TerminalPane, clampTerminalPaneCols } from "./components/TerminalPane"; import { Header } from "./components/Header"; -import { computeLaneChatCounts, LANE_DETAIL_ACTIONS, RightPane } from "./components/RightPane"; +import { computeLaneChatCounts, LANE_DETAIL_ACTIONS, LANE_DETAIL_PR_ACTION_INDEX, RightPane } from "./components/RightPane"; import { buildModelPickerLayout, defaultSelectionFor } from "./components/ModelPicker/modelPickerLayout"; import { SlashPalette, SLASH_PALETTE_ROWS } from "./components/SlashPalette"; import { MentionPalette, MENTION_PALETTE_ROWS } from "./components/MentionPalette"; @@ -132,6 +132,8 @@ import { } from "./feedback"; import { buildPendingInputAnswers, latestPendingApproval } from "./pendingInput"; import { claudeHomePath, defaultKeybindingsPath, dispatchKeybinding, openKeybindingsFile, readClaudeKeybindingsFile, type KeybindingDispatchState, type TuiKeybindingAction } from "./keybindings"; +import { buildDeeplinkForRow, type DeeplinkRow } from "./deeplinkRow"; +import { copyToClipboard } from "../lib/clipboard"; import { buildSubagentPaneRows, buildSubagentTranscriptEvents, @@ -1510,6 +1512,8 @@ const MIN_CENTER_PANE_WIDTH = 24; const MIN_RIGHT_PANE_WIDTH = 30; const RIGHT_PANE_MAX_WIDTH = 42; const CLAUDE_TERMINAL_HIDDEN_INPUT_ROWS = 3; +export const CLAUDE_TERMINAL_SUBMIT_CONFIRM_DELAY_MS = 1200; +const CLAUDE_TERMINAL_SUBMIT_REFRESH_DELAY_MS = 150; function finiteFloor(value: number, fallback: number): number { return Number.isFinite(value) ? Math.floor(value) : fallback; @@ -1533,6 +1537,10 @@ export function encodeTerminalPromptSubmit(value: string): string { return `${normalized}\r`; } +export function encodeTerminalPromptSubmitConfirm(): string { + return "\r"; +} + export function isTerminalControlToggle(input: string, key: { ctrl?: boolean }): boolean { return input === "\x14" || (key.ctrl === true && input.toLowerCase() === "t"); } @@ -1569,29 +1577,6 @@ function promptTextForTerminal(text: string, attachments: AgentChatFileRef[]): s return text ? `${text}\n\n${attachmentBlock}` : attachmentBlock; } -function terminalPromptNeedle(value: string): string | null { - const lines = value - .replace(/\r\n/g, "\n") - .replace(/\r/g, "\n") - .split("\n") - .map((line) => line.trim()) - .filter(Boolean); - const last = lines[lines.length - 1] ?? ""; - return last.length ? last.slice(0, 96) : null; -} - -function terminalHiddenRowsContainPrompt(preview: ChatTerminalPreviewResult | null, value: string): boolean { - const needle = terminalPromptNeedle(value); - const rows = preview?.snapshot?.visibleRows ?? []; - if (!needle || !rows.length) return false; - const hiddenText = rows - .slice(-CLAUDE_TERMINAL_HIDDEN_INPUT_ROWS) - .map((row) => row.text) - .join("\n") - .trim(); - return hiddenText.includes(needle); -} - function signalTerminalWithCliSync(args: { projectRoot: string; socketPath?: string | null; @@ -4150,7 +4135,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } if (!connection) return; let disposed = false; let unsubscribe: (() => void) | null = null; - void connection.subscribeRuntimeEvents({ category: "runtime", cursor: 0, limit: 50, replay: false }, (event) => { + void connection.subscribeRuntimeEvents({ category: "pty", cursor: 0, limit: 50, replay: false }, (event) => { const payload = event.payload as { type?: unknown; event?: unknown }; const terminalEvent = payload.event as { sessionId?: unknown; data?: unknown } | undefined; const sessionId = typeof terminalEvent?.sessionId === "string" ? terminalEvent.sessionId : null; @@ -4434,13 +4419,15 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } const terminalRows = claudeTerminalRowsForPane(chatRowBudget); if (terminal.status === "running") { await writeTerminal(conn, terminal.terminalId, encodeTerminalPromptSubmit(text)); - await delay(250); - let preview = await refreshTerminalPreview(conn, terminal.terminalId); - if (terminalHiddenRowsContainPrompt(preview, text)) { - await writeTerminal(conn, terminal.terminalId, "\r"); - await delay(150); - preview = await refreshTerminalPreview(conn, terminal.terminalId); - } + // Claude Code occasionally leaves a programmatic `text + Enter` sitting + // in its prompt editor. New/resumed launches already send a delayed + // confirm Enter; do the same for live embedded sessions so submitting + // from ADE behaves like manually focusing Claude with Ctrl+T and + // pressing Enter. + await delay(CLAUDE_TERMINAL_SUBMIT_CONFIRM_DELAY_MS); + await writeTerminal(conn, terminal.terminalId, encodeTerminalPromptSubmitConfirm()); + await delay(CLAUDE_TERMINAL_SUBMIT_REFRESH_DELAY_MS); + await refreshTerminalPreview(conn, terminal.terminalId); return true; } const created = await sendToTerminalSession({ @@ -5199,7 +5186,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } setRightPane({ kind: "details", title: "ADE action", - body: "Usage: /ade [json-object|json-array|json-scalar]", + body: "Usage: /ade [json-object|json-array|json-scalar]", }); return; } @@ -6177,6 +6164,42 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } return true; }, [addNotice, focusChat, project.workspaceRoot]); + // Resolve the deeplink target for the row/pane currently focused in the + // lanes-picker or PR-picker contexts. Returns `null` when the focus is on + // something the deeplink scheme does not cover (chat preview, slash-prompt + // pane, etc.) — keep this conservative so we never copy a misleading URL. + const resolveFocusedDeeplinkRow = useCallback((): DeeplinkRow | null => { + const pane = activePaneRef.current; + + // PR-picker context: the lane-details right pane is showing and the focus + // ring is on its PR row. We prefer this over the lane row when both are + // available so Ctrl+Y on a highlighted PR copies the PR deeplink. + if ( + pane === "details" + && rightPane.kind === "lane-details" + && rightPane.pr + && rightPane.selectedActionIndex === LANE_DETAIL_PR_ACTION_INDEX + ) { + const prNumber = rightPane.pr.number; + const url = rightPane.pr.url; + return { kind: "pr", pr: { url, prNumber } }; + } + + // Lanes-picker context: the drawer is open on lanes (or chats — fall back + // to the lane that owns the focused chat) and a row is highlighted. + if (pane === "drawer" && drawerOpen) { + const lane = highlightedDrawerLane ?? drawerLane ?? activeLane; + if (lane) return { kind: "lane", lane: { id: lane.id } }; + } + + // Lane-details pane with focus on a non-PR action row: still useful to + // copy the lane deeplink so the user can hand it off to a teammate. + if (pane === "details" && rightPane.kind === "lane-details") { + return { kind: "lane", lane: { id: rightPane.lane.id } }; + } + return null; + }, [activeLane, drawerLane, drawerOpen, highlightedDrawerLane, rightPane]); + const runKeybindingAction = useCallback((action: TuiKeybindingAction): boolean => { const reportUnavailable = (label = action): true => { addNotice(`${label} is recognized, but there is no active ADE Code control for it right now.`, "info"); @@ -6473,11 +6496,29 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } copyChatSelection(); return true; } + if (action === "app:copyAdeDeeplink") { + const row = resolveFocusedDeeplinkRow(); + if (!row) { + addNotice("No lane or PR row is focused to copy a deeplink for.", "info"); + return true; + } + const url = buildDeeplinkForRow(row); + if (!url) { + addNotice("Cannot build an ADE deeplink for the focused row.", "error"); + return true; + } + if (copyToClipboard(url)) { + addNotice("ADE deeplink copied", "success"); + } else { + addNotice(`ADE deeplink: ${url}`, "info"); + } + return true; + } if (action.startsWith("selection:")) { return reportUnavailable(); } return reportUnavailable(); - }, [addNotice, applyModelState, attachClipboardImage, chatRowBudget, copyChatSelection, cycleFooterControl, cyclePaneFocus, cyclePermission, cycleReasoning, drawerOpen, focusAfterDetails, focusChat, focusDetails, footerControls, launchPromptInBackground, modelState.provider, openHistorySearch, openModelRow, prompt, recallPromptHistory, refreshState, requestAppExit, rightOpen, selectFooterControl, setChatScrollOffset, submitPrompt, toggleDetailsPane, toggleSubagentsPane]); + }, [addNotice, applyModelState, attachClipboardImage, chatRowBudget, copyChatSelection, cycleFooterControl, cyclePaneFocus, cyclePermission, cycleReasoning, drawerOpen, focusAfterDetails, focusChat, focusDetails, footerControls, launchPromptInBackground, modelState.provider, openHistorySearch, openModelRow, prompt, recallPromptHistory, refreshState, requestAppExit, resolveFocusedDeeplinkRow, rightOpen, selectFooterControl, setChatScrollOffset, submitPrompt, toggleDetailsPane, toggleSubagentsPane]); const chatPointFromMouse = useCallback(( x: number | null, @@ -6816,6 +6857,18 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } return; } + // Ctrl+Y: copy the canonical ade:// deeplink for the focused lane or PR. + // Scoped to the lanes drawer ("Tabs") and the lane-details/select pane + // ("Select") so it doesn't shadow other contexts. Users can additionally + // bind this to any chord via "app:copyAdeDeeplink" in keybindings.json. + if ( + key.ctrl + && input === "y" + && (keybindingContext === "Tabs" || keybindingContext === "Select") + ) { + if (runKeybindingAction("app:copyAdeDeeplink")) return; + } + if (footerActive) { if (key.leftArrow || key.rightArrow) { cycleFooterControl(key.rightArrow ? 1 : -1); diff --git a/apps/ade-cli/src/tuiClient/deeplinkRow.ts b/apps/ade-cli/src/tuiClient/deeplinkRow.ts new file mode 100644 index 000000000..288ba7d41 --- /dev/null +++ b/apps/ade-cli/src/tuiClient/deeplinkRow.ts @@ -0,0 +1,78 @@ +// --------------------------------------------------------------------------- +// Helper for the "copy ADE deeplink" keybinding in the TUI. +// +// Resolves the focused row in the lanes-picker / PR-picker contexts to a +// canonical `ade://` URL. Keeping this isolated from the Ink component lets us +// unit-test the dispatch path without rendering the whole app. +// --------------------------------------------------------------------------- + +import { buildDeeplink, type DeeplinkTarget } from "../../../desktop/src/shared/deeplinks"; + +/** + * Minimal lane shape needed to build a lane deeplink. Subset of `LaneSummary` + * so tests can construct fixtures without pulling the full type. + */ +export type DeeplinkLaneRow = { id: string }; + +/** + * Minimal PR shape needed to build a PR deeplink. We accept either an explicit + * `repoOwner`/`repoName`/`prNumber` triple or a GitHub `url` we can parse — + * the lane-details right pane only carries the URL, so we lift owner/repo + * from there at the call site. + */ +export type DeeplinkPrRow = + | { repoOwner: string; repoName: string; prNumber: number } + | { url: string; prNumber?: number }; + +export type DeeplinkRow = + | { kind: "lane"; lane: DeeplinkLaneRow } + | { kind: "pr"; pr: DeeplinkPrRow }; + +/** + * Pull `{owner, name, number}` out of a GitHub PR URL like + * `https://github.com///pull/`. Returns `null` for anything + * that doesn't look like a PR URL we recognize. + */ +export function parseGitHubPrUrl(url: string): { repoOwner: string; repoName: string; prNumber: number } | null { + if (!url || typeof url !== "string") return null; + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return null; + } + if (parsed.hostname.toLowerCase() !== "github.com") return null; + const segments = parsed.pathname.split("/").filter(Boolean); + // ///pull/ + if (segments.length < 4) return null; + const [repoOwner, repoName, kind, numberRaw] = segments; + if (kind !== "pull") return null; + const prNumber = Number(numberRaw); + if (!Number.isInteger(prNumber) || prNumber < 1) return null; + return { repoOwner, repoName, prNumber }; +} + +/** Build the `ade://` deeplink for a focused lane or PR row. Returns `null` + * when the row doesn't have enough data (e.g. a PR row with a malformed + * URL and no explicit owner/repo). */ +export function buildDeeplinkForRow(row: DeeplinkRow): string | null { + if (row.kind === "lane") { + if (!row.lane.id) return null; + const target: DeeplinkTarget = { kind: "lane", laneId: row.lane.id }; + return buildDeeplink(target, { form: "ade" }); + } + const pr = row.pr; + if ("repoOwner" in pr) { + if (!pr.repoOwner || !pr.repoName || !pr.prNumber) return null; + return buildDeeplink( + { kind: "pr", repoOwner: pr.repoOwner, repoName: pr.repoName, prNumber: pr.prNumber }, + { form: "ade" }, + ); + } + const parsed = parseGitHubPrUrl(pr.url); + if (!parsed) return null; + return buildDeeplink( + { kind: "pr", repoOwner: parsed.repoOwner, repoName: parsed.repoName, prNumber: pr.prNumber ?? parsed.prNumber }, + { form: "ade" }, + ); +} diff --git a/apps/ade-cli/src/tuiClient/format.ts b/apps/ade-cli/src/tuiClient/format.ts index f70091c79..ed7f5cda1 100644 --- a/apps/ade-cli/src/tuiClient/format.ts +++ b/apps/ade-cli/src/tuiClient/format.ts @@ -197,6 +197,13 @@ function statusGlyph(status: string | undefined): string { return "✓"; } +function isLowValueHookNotice(noticeKind: string | undefined, message: string): boolean { + if (noticeKind !== "hook") return false; + const trimmed = message.trim(); + return /^hook:\s+.+\s+started$/i.test(trimmed) + || trimmed.toLowerCase() === "trimmed large tool output before sending it back to claude."; +} + function multiLine(value: unknown, maxLines = 18): string { if (typeof value === "string") return value.split(/\r?\n/).slice(0, maxLines).join("\n"); return renderObject(value, maxLines); @@ -719,7 +726,7 @@ export function renderChatLines(args: { const normalizedMessage = message.trim().toLowerCase(); if (!normalizedMessage) continue; if (noticeKind === "info" && normalizedMessage === "session ready") continue; - if (noticeKind === "hook" && /^hook:\s+.+\s+started$/i.test(message)) continue; + if (isLowValueHookNotice(noticeKind, message)) continue; // Surface the severity-bearing noticeKinds with an error tone so the TUI // colorizes them distinctively. Guardian warnings, rate limits, thread diff --git a/apps/ade-cli/src/tuiClient/keybindings/index.ts b/apps/ade-cli/src/tuiClient/keybindings/index.ts index 44ad36a17..4d2c4124b 100644 --- a/apps/ade-cli/src/tuiClient/keybindings/index.ts +++ b/apps/ade-cli/src/tuiClient/keybindings/index.ts @@ -37,6 +37,7 @@ const SUPPORTED_ACTION_VALUES = [ "app:help", "app:clear", "app:quit", + "app:copyAdeDeeplink", "history:search", "history:previous", "history:next", diff --git a/apps/desktop/resources/agent-skills/ade-deeplinks/SKILL.md b/apps/desktop/resources/agent-skills/ade-deeplinks/SKILL.md new file mode 100644 index 000000000..f47361836 --- /dev/null +++ b/apps/desktop/resources/agent-skills/ade-deeplinks/SKILL.md @@ -0,0 +1,150 @@ +--- +name: ade-deeplinks +description: Use this skill when an agent needs to mint, share, or open ADE deeplinks (lane, branch, PR, Linear issue) so users — or the agent itself — can jump straight to a specific ADE surface from anywhere (GitHub PR description, Linear issue, Slack, email, terminal, mobile). +--- + +# ADE deeplinks + +## What a deeplink is + +ADE deeplinks are URLs that route directly to a specific ADE entity. Two forms, +identical semantics: + +``` +ade://lane/ # local-only — focuses an existing lane +ade://repo///branch/ # cross-machine — find or offer-to-create lane +ade://pr/// # PR detail view +ade://linear-issue/[?branch=] # Linear handoff — resolves via lane.linearIssue + +https://ade.app/open?type=lane&id= +https://ade.app/open?type=branch&repo=&branch=[&pr=] +https://ade.app/open?type=pr&repo=&number= +https://ade.app/open?type=linear-issue&issue=[&branch=] +``` + +The HTTPS form is the share-friendly variant (it gets a Vercel-rendered +OpenGraph card in Slack/Discord/iMessage/Gmail/Linear). The web landing page +tries the `ade://` upgrade and falls back to an install card. Both forms parse +to the same target shape. + +**Lane links are local** — the UUID is meaningful only on the machine that +created the lane. **Branch and Linear-issue links are portable** — they re- +resolve to whichever lane (if any) owns that branch / Linear identifier on the +receiving machine. + +## When to use which form + +| Need | Use | +| ------------------------------------------------------------- | ------------------------------------ | +| Jump back to MY lane from another terminal on the same Mac | `ade://lane/` | +| Share a branch with a teammate or your other devices | `https://ade.app/open?type=branch&…` | +| Drop into a PR's detail tab | `https://ade.app/open?type=pr&…` | +| Linear "Open in coding tool" hand-off (resolves to your lane) | `https://ade.app/open?type=linear-issue&…` | + +## Minting a deeplink — `ade link` + +```bash +ade link lane # local lane link +ade link branch [--pr ] # cross-machine branch +ade link pr # PR detail +ade link linear-issue [--branch ] # Linear hand-off +ade link # round-trip an existing link + +# Flags +--ade # emit ade:// instead of https:// (default: https) +--no-clipboard # print without copying +``` + +Every form copies to the clipboard by default and prints the URL to stdout. +Use `--no-clipboard` in scripts. + +## Opening a deeplink — `ade open` + +```bash +ade open # any ade:// or https://ade.app/open URL +ade open --linear-issue --branch # Linear coding-tool entry point +``` + +The CLI hands the URL to the OS, which routes through the registered `ade://` +protocol back to a running ADE desktop window (or launches ADE cold). If the +focused window's project doesn't match the deeplink's repo, the cross-repo +banner appears with a "Switch to " button. + +## Wiring Linear hand-off — `ade linear install` + +```bash +ade linear install # writes ~/.linear/coding-tools.json +ade linear install --dry-run # show what would be written +``` + +Adds an entry so Linear's "Open issue in coding tool" picker can launch ADE. +The template uses Linear's documented placeholders (`{{issue.identifier}}`, +`{{issue.branchName}}`) — Linear does NOT expose the GitHub repo, so the +desktop renderer resolves the lane locally by `lane.linearIssue.identifier` +match (falling back to a lanes-page filter on the branch hint). + +## RPC for programmatic dispatch + +For agents working through the ADE RPC layer, deeplinks dispatch via the +existing `app/navigate` method: + +```jsonc +// JSON-RPC over the ADE socket (`ade serve` / desktop RPC port) +{ "method": "app/navigate", "params": { + "target": { "kind": "lane", "laneId": "" } +}} +{ "method": "app/navigate", "params": { + "target": { "kind": "branch", "repoOwner": "anthropics", "repoName": "claude-code", "branch": "feat-x" } +}} +{ "method": "app/navigate", "params": { + "target": { "kind": "pr", "repoOwner": "anthropics", "repoName": "claude-code", "prNumber": 1234 } +}} +{ "method": "app/navigate", "params": { + "target": { "kind": "linear-issue", "issueIdentifier": "ADE-123", "branch": "arul/ade-123-feat" } +}} +``` + +Use `app/navigate` (not `deeplinks.open`) when you have structured fields. +Use `ade open ` when you already have a stringified deeplink (e.g. +something a user pasted). + +## Auto-attached deeplinks + +ADE automatically appends an "Open in ADE" footer to PR descriptions it +creates or adopts (idempotent via an HTML marker), and pushes the same +cross-machine link to any Linear issue linked to the lane (Linear attachment ++ one-time comment). Agents do not need to call `ade link` for those flows — +they fire on PR creation / Linear-link events. + +When you copy a deeplink from a lane context menu in the desktop UI, the +right-click menu offers: Copy lane link, Copy branch link (cross-machine), +Copy PR link, Copy Linear-issue link. + +## What deeplinks NEVER do silently + +- Clone a repository you don't have — the user always confirms via the + inbound modal that reuses the PRs-tab "Create lane from PR branch" + preflight (with its existing safety blocks). +- Mutate state for pure navigation — opening an existing lane is silent; + creating one is not. +- Trust the URL params blindly — the parser rejects bad UUIDs, traversal + segments, malformed Linear identifiers, and non-https hosts on the mirror + form. + +## Quick command palette for the CTO + +```bash +# Mint links the user can paste anywhere +ade link branch anthropics/claude-code feat-deeplinks # share with teammates +ade link pr anthropics/claude-code 1234 # PR detail +ade link linear-issue ADE-512 --branch arul/ade-512-feat # Linear hand-off +ade link lane "$(ade lanes list --text | head -2 | tail -1 | awk '{print $1}')" # current lane + +# Open a link locally (any of these reach a running ADE) +ade open ade://lane/ +ade open "https://ade.app/open?type=branch&repo=a/b&branch=feat" +ade open --linear-issue ADE-123 --branch feat-x + +# One-time setup so Linear's "Open in coding tool" calls into ADE +ade linear install +``` diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index aa4a703a6..3cbc5e32a 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -6,6 +6,10 @@ import { pathToFileURL } from "node:url"; import type * as NodePty from "node-pty"; type NodePtyType = typeof NodePty; import { isAdeRuntimeNamedPipePath } from "../shared/adeRuntimeIpc"; +import { + handleDeeplinkUrl, + registerAdeProtocolHandler, +} from "./services/deeplinks/protocolHandler"; import { registerIpc } from "./services/ipc/registerIpc"; import { createFileLogger } from "./services/logging/logger"; import { initPerfRunFromEnv } from "./services/perf/perfLog"; @@ -29,6 +33,7 @@ import { createRuntimeDiagnosticsService } from "./services/lanes/runtimeDiagnos import { createSessionService } from "./services/sessions/sessionService"; import { createSessionDeltaService } from "./services/sessions/sessionDeltaService"; import { createPtyService } from "./services/pty/ptyService"; +import { createProcessRegistryService } from "./services/runtime/processRegistryService"; import { createDiffService } from "./services/diffs/diffService"; import { createFileService } from "./services/files/fileService"; import { createConflictService } from "./services/conflicts/conflictService"; @@ -810,6 +815,31 @@ protocol.registerSchemesAsPrivileged([ }, ]); +// Register the user-facing `ade://` deeplink scheme + single-instance lock so a +// second `open ade://...` invocation reuses the running window. Dispatch to the +// focused window's renderer via the existing IPC.appNavigate channel. +registerAdeProtocolHandler({ + dispatch: (request) => { + const focusable = + BrowserWindow.getFocusedWindow() ?? + BrowserWindow.getAllWindows().find((win) => !win.isDestroyed()) ?? + null; + if (!focusable) return; + if (focusable.isMinimized()) focusable.restore(); + focusable.show(); + focusable.focus(); + focusable.webContents.send(IPC.appNavigate, request); + }, + log: (event, fields) => { + // Avoid throwing if console is gone; structured logger may not be ready yet. + try { + console.log(`[main] ${event}`, fields); + } catch { + // ignore + } + }, +}); + let pendingProjectOpenFiles: string[] = []; let handleProjectOpenFile: ((filePath: string) => void) | null = null; @@ -1854,13 +1884,29 @@ app.whenReady().then(async () => { onLinearIssueLinked: ({ lane, issue, linkedAt }) => { const tracker = linearIssueTrackerRef; if (!tracker) return; - void publishLinearLaneCard({ - issueTracker: tracker, - lane, - issue, - projectRoot, - linkedAt, - }).catch((error) => { + // Resolve repo lazily so cards posted to Linear carry the cross-machine + // ADE deeplink (https://ade.app/open?type=branch&...). If the project + // has no GitHub remote, fall back to the legacy hash-anchor URL. + const resolveRepo = async (): Promise<{ owner: string; name: string } | null> => { + try { + return await githubService.getRepoOrThrow(); + } catch { + return null; + } + }; + void resolveRepo().then((repo) => + publishLinearLaneCard({ + issueTracker: tracker, + lane, + issue, + projectRoot, + linkedAt, + repoOwner: repo?.owner ?? null, + repoName: repo?.name ?? null, + postInitialComment: true, + log: (event, fields) => logger.warn(event, fields), + }), + ).catch((error) => { logger.warn("linear.lane_card_publish_failed", { laneId: lane.id, issueId: issue.id, @@ -1894,8 +1940,16 @@ app.whenReady().then(async () => { sessionService.onChanged((event) => { emitProjectEvent(projectRoot, IPC.sessionsChanged, event); }); + const processRegistry = createProcessRegistryService({ + db, + logger, + role: "desktop-main", + projectRoot, + }); + processRegistry.start(); const reconciledSessions = sessionService.reconcileStaleRunningSessions({ status: "disposed", + liveOwnerPids: processRegistry.listLivePids(), }); if (reconciledSessions > 0) { logger.warn("sessions.reconciled_stale_running", { @@ -2417,6 +2471,7 @@ app.whenReady().then(async () => { transcriptsDir: adePaths.transcriptsDir, laneService, sessionService, + processRegistry, aiIntegrationService, projectConfigService, getLaneRuntimeEnv, @@ -2825,6 +2880,7 @@ app.whenReady().then(async () => { episodicSummaryService, laneService, sessionService, + processRegistry, projectConfigService, aiIntegrationService, ctoStateService, @@ -3449,6 +3505,33 @@ app.whenReady().then(async () => { snapshot, }); }, + // iOS "Send to your Mac" handler. Parses the inbound `ade://...` URL + // and routes it through the same protocol dispatcher main.ts wires up + // for direct OS clicks, so the renderer's existing AppNavigationBridge + // / InboundDeeplinkModal / CrossRepoPrBanner all fire normally. + dispatchDeeplinkUrl: async (rawUrl) => { + try { + const focusable = + BrowserWindow.getFocusedWindow() ?? + BrowserWindow.getAllWindows().find((win) => !win.isDestroyed()) ?? + null; + if (!focusable) { + return { ok: false, message: "No ADE window is available." }; + } + handleDeeplinkUrl(rawUrl, "sync:ios", (request) => { + if (focusable.isMinimized()) focusable.restore(); + focusable.show(); + focusable.focus(); + focusable.webContents.send(IPC.appNavigate, request); + }); + return { ok: true }; + } catch (error) { + return { + ok: false, + message: error instanceof Error ? error.message : String(error), + }; + } + }, }); syncServiceRef = syncService; // Late-bind the sync service into the notification bus dependencies so @@ -4276,6 +4359,7 @@ app.whenReady().then(async () => { rebaseSuggestionService, autoRebaseService, sessionService, + processRegistry, ptyService, diffService, fileService, @@ -4696,6 +4780,11 @@ app.whenReady().then(async () => { } catch { // ignore } + try { + ctx.processRegistry?.stop(); + } catch { + // ignore + } try { ctx.db.flushNow(); ctx.db.close(); diff --git a/apps/desktop/src/main/services/adeActions/registry.ts b/apps/desktop/src/main/services/adeActions/registry.ts index a8b99fb20..7bbd82feb 100644 --- a/apps/desktop/src/main/services/adeActions/registry.ts +++ b/apps/desktop/src/main/services/adeActions/registry.ts @@ -771,7 +771,7 @@ export const ADE_ACTION_ALLOWLIST: Partial { event.event.type === "system_notice" && event.event.noticeKind === "hook" && event.event.message.includes("Trimmed large tool output"), - )).toBe(true); + )).toBe(false); }); it("emits failed tool results from PostToolUseFailure hooks", async () => { diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index 63df8a56a..bef24e2f0 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -326,6 +326,7 @@ import { getApiKey } from "../ai/apiKeyStore"; import type { createMissionService } from "../missions/missionService"; import type { createAiOrchestratorService } from "../orchestrator/aiOrchestratorService"; import type { TurnMemoryPolicyState } from "../ai/tools/memoryTools"; +import type { ProcessRegistryService } from "../runtime/processRegistryService"; const CLAUDE_AGENT_SDK_VERSION = "0.2.139"; const CLAUDE_AGENT_SDK_API = "v1_query"; @@ -4274,6 +4275,7 @@ export function createAgentChatService(args: { computerUseArtifactBrokerService?: ComputerUseArtifactBrokerService | null; laneService: ReturnType; sessionService: ReturnType; + processRegistry?: ProcessRegistryService | null; projectConfigService: ReturnType; aiIntegrationService: ReturnType; logger: Logger; @@ -4314,6 +4316,7 @@ export function createAgentChatService(args: { computerUseArtifactBrokerService, laneService, sessionService, + processRegistry, projectConfigService, aiIntegrationService, logger, @@ -9644,12 +9647,10 @@ export function createAgentChatService(args: { if (msg.type === "system" && ((msg as any).subtype === "hook_started" || (msg as any).subtype === "hook_progress" || (msg as any).subtype === "hook_response")) { const hookMsg = msg as any; if (hookMsg.subtype === "hook_started") { - emitChatEvent(managed, { - type: "system_notice", - noticeKind: "hook", - message: `Hook: ${hookMsg.hook_name ?? hookMsg.hook_event ?? "hook"} started`, - turnId, - }); + // Claude SDK hook start messages are high-frequency lifecycle noise. + // Keep failures below, but do not persist successful hook bookkeeping + // into the user-visible transcript. + continue; } else if (hookMsg.subtype === "hook_response") { const outcome = hookMsg.outcome ?? (hookMsg.exit_code === 0 ? "passed" : "failed"); if (outcome !== "passed" && outcome !== "success") { @@ -14188,22 +14189,6 @@ export function createAgentChatService(args: { originalBytes: trimmed.originalBytes, trimmedBytes: trimmed.trimmedBytes, }); - emitChatEvent(managed, { - type: "system_notice", - noticeKind: "hook", - message: "Trimmed large tool output before sending it back to Claude.", - detail: { - title: "Large tool output trimmed", - summary: input.hook_event_name === "PostToolUse" - ? `${input.tool_name} output exceeded ${CLAUDE_TOOL_OUTPUT_TRIM_THRESHOLD_BYTES} bytes.` - : `Output exceeded ${CLAUDE_TOOL_OUTPUT_TRIM_THRESHOLD_BYTES} bytes.`, - metrics: [ - { label: "Original", value: `${trimmed.originalBytes} bytes` }, - { label: "Sent", value: `${trimmed.trimmedBytes} bytes`, tone: "success" }, - ], - }, - turnId: runtime.activeTurnId ?? undefined, - }); return { continue: true, hookSpecificOutput: { @@ -15424,7 +15409,8 @@ export function createAgentChatService(args: { startedAt, transcriptPath, toolType: toolTypeFromProvider(effectiveProvider), - resumeCommand: resumeCommandForProvider(effectiveProvider, sessionId) + resumeCommand: resumeCommandForProvider(effectiveProvider, sessionId), + ...(processRegistry ? { ownerPid: processRegistry.pid } : {}), }); if (normalizedTitle.length > 0) { sessionService.updateMeta({ sessionId, title: initialTitle, manuallyNamed: true }); @@ -19486,6 +19472,16 @@ export function createAgentChatService(args: { }; const resumeSession = async ({ sessionId }: { sessionId: string }): Promise => { + const row = sessionService.get(sessionId); + if ( + processRegistry + && row?.ownerPid != null + && row.ownerPid !== processRegistry.pid + && processRegistry.isPidLive(row.ownerPid) + ) { + throw new Error("Chat session is owned by another ADE process; cannot resume from here."); + } + let managed = ensureManagedSession(sessionId); // Identity-pinned sessions (CTO + worker agents) must always run on the @@ -19626,6 +19622,9 @@ export function createAgentChatService(args: { managed.ctoSessionStartedAt = managed.session.identityKey === "cto" ? nowIso() : null; persistChatState(managed); + if (processRegistry) { + sessionService.setOwnerPid(sessionId, processRegistry.pid); + } return managed.session; }; diff --git a/apps/desktop/src/main/services/cto/linearClient.ts b/apps/desktop/src/main/services/cto/linearClient.ts index c3888d11e..6bfbc6b2a 100644 --- a/apps/desktop/src/main/services/cto/linearClient.ts +++ b/apps/desktop/src/main/services/cto/linearClient.ts @@ -52,19 +52,24 @@ function toNormalizedIssue(node: Record): NormalizedLinearIssue const title = asString(node.title); if (!id || !identifier || !title) return null; + // Linear allows issues without a project (they live in a team only), so + // `project` is optional during normalization — falling through to empty + // strings keeps the existing display fallbacks (`projectName || projectSlug + // || teamKey`) intact. `team` and `state` remain required since every + // Linear issue has them. const project = isRecord(node.project) ? node.project : null; const team = isRecord(node.team) ? node.team : null; const state = isRecord(node.state) ? node.state : null; - if (!project || !team || !state) return null; + if (!team || !state) return null; - const projectId = asString(project.id); - const projectSlug = asString(project.slug) ?? asString(project.slugId); + const projectId = project ? (asString(project.id) ?? "") : ""; + const projectSlug = project ? (asString(project.slug) ?? asString(project.slugId) ?? "") : ""; const teamId = asString(team.id); const teamKey = asString(team.key); const stateId = asString(state.id); const stateName = asString(state.name); const stateType = asString(state.type); - if (!projectId || !projectSlug || !teamId || !teamKey || !stateId || !stateName || !stateType) return null; + if (!teamId || !teamKey || !stateId || !stateName || !stateType) return null; const labelsNodes = isRecord(node.labels) ? asArray(node.labels.nodes) : []; const labels = labelsNodes @@ -100,7 +105,7 @@ function toNormalizedIssue(node: Record): NormalizedLinearIssue url: asString(node.url), projectId, projectSlug, - projectName: asString(project.name), + projectName: project ? asString(project.name) : null, teamId, teamKey, teamName: asString(team.name), diff --git a/apps/desktop/src/main/services/cto/linearLaneCardService.test.ts b/apps/desktop/src/main/services/cto/linearLaneCardService.test.ts index 7968fb4c6..d41bba7e1 100644 --- a/apps/desktop/src/main/services/cto/linearLaneCardService.test.ts +++ b/apps/desktop/src/main/services/cto/linearLaneCardService.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it, vi } from "vitest"; import type { LaneLinearIssue, LaneSummary } from "../../../shared/types"; -import { buildLinearLaneCardAttachment, publishLinearLaneCard } from "./linearLaneCardService"; +import { + buildLinearLaneCardAttachment, + buildLinearLaneInitialComment, + publishLinearLaneCard, +} from "./linearLaneCardService"; function makeLane(overrides: Partial = {}): LaneSummary { return { @@ -119,4 +123,53 @@ describe("linearLaneCardService", () => { title: "ADE lane: ABC-42 Fix flaky sync run", })); }); + + it("uses the cross-machine ADE deeplink when repo is known", () => { + const attachment = buildLinearLaneCardAttachment({ + lane: makeLane(), + issue: makeIssue(), + projectRoot: "/Users/admin/Projects/ADE", + linkedAt: "2026-05-12T20:05:00.000Z", + repoOwner: "anthropics", + repoName: "claude-code", + }); + expect(attachment.url).toContain("https://ade.app/open?type=branch"); + expect(attachment.url).toContain("repo=anthropics%2Fclaude-code"); + expect(attachment.url).toContain("branch=abc-42-fix-flaky-sync-run"); + expect(attachment.title).toBe("Open in ADE: ABC-42 Fix flaky sync run"); + }); + + it("builds an initial comment with the deeplink", () => { + const body = buildLinearLaneInitialComment({ + lane: makeLane(), + issue: makeIssue(), + repoOwner: "anthropics", + repoName: "claude-code", + }); + expect(body).toContain("Open in ADE"); + expect(body).toContain("https://ade.app/open?type=branch"); + }); + + it("returns null comment when repo is unknown", () => { + const body = buildLinearLaneInitialComment({ + lane: makeLane(), + issue: makeIssue(), + }); + expect(body).toBeNull(); + }); + + it("posts the initial comment when requested and repo is known", async () => { + const createIssueAttachment = vi.fn(async () => ({ id: "attachment-1", url: "https://ade.app/open?type=branch" })); + const createComment = vi.fn(async () => ({})); + await publishLinearLaneCard({ + issueTracker: { createIssueAttachment, createComment } as any, + lane: makeLane(), + issue: makeIssue(), + projectRoot: "/Users/admin/Projects/ADE", + repoOwner: "a", + repoName: "b", + postInitialComment: true, + }); + expect(createComment).toHaveBeenCalledWith("issue-1", expect.stringContaining("Open in ADE")); + }); }); diff --git a/apps/desktop/src/main/services/cto/linearLaneCardService.ts b/apps/desktop/src/main/services/cto/linearLaneCardService.ts index 368185271..ee9e86060 100644 --- a/apps/desktop/src/main/services/cto/linearLaneCardService.ts +++ b/apps/desktop/src/main/services/cto/linearLaneCardService.ts @@ -1,6 +1,7 @@ import path from "node:path"; import type { LaneLinearIssue, LaneSummary } from "../../../shared/types"; import type { IssueTracker, IssueTrackerIssueAttachmentInput } from "./issueTracker"; +import { buildDeeplink } from "../../../shared/deeplinks"; function truncate(value: string, maxLength: number): string { const trimmed = value.trim(); @@ -14,7 +15,7 @@ function dateLabel(value: string): string { return parsed.toISOString(); } -function buildCardUrl(issue: LaneLinearIssue, laneId: string): string { +function buildFallbackLinearUrl(issue: LaneLinearIssue, laneId: string): string { const fallback = `https://linear.app/issue/${encodeURIComponent(issue.identifier)}`; let url: URL; try { @@ -26,11 +27,37 @@ function buildCardUrl(issue: LaneLinearIssue, laneId: string): string { return url.toString(); } +function buildCardUrl(args: { + issue: LaneLinearIssue; + laneId: string; + branch: string; + repoOwner?: string | null; + repoName?: string | null; +}): string { + // Prefer the cross-machine ADE deeplink (so the attachment is actually + // clickable from Linear and lands in another teammate's ADE). Fall back to + // the historical Linear-issue-hash URL when we don't know the repo. + if (args.repoOwner && args.repoName) { + return buildDeeplink( + { + kind: "branch", + repoOwner: args.repoOwner, + repoName: args.repoName, + branch: args.branch, + }, + { form: "https" }, + ); + } + return buildFallbackLinearUrl(args.issue, args.laneId); +} + export function buildLinearLaneCardAttachment(args: { lane: LaneSummary; issue: LaneLinearIssue; projectRoot: string; linkedAt?: string | null; + repoOwner?: string | null; + repoName?: string | null; }): IssueTrackerIssueAttachmentInput { const linkedAt = args.linkedAt?.trim() || args.lane.createdAt || new Date().toISOString(); const branch = args.issue.branchName?.trim() || args.lane.branchRef; @@ -38,12 +65,20 @@ export function buildLinearLaneCardAttachment(args: { const teamName = args.issue.teamName?.trim() || args.issue.teamKey; const projectLabel = args.issue.projectName?.trim() || args.issue.projectSlug; const labels = args.issue.labels.length ? args.issue.labels.join(", ") : "None"; + const cardUrl = buildCardUrl({ + issue: args.issue, + laneId: args.lane.id, + branch, + repoOwner: args.repoOwner ?? null, + repoName: args.repoName ?? null, + }); + const hasDeeplink = Boolean(args.repoOwner && args.repoName); return { issueId: args.issue.id, - title: `ADE lane: ${truncate(args.lane.name, 64)}`, + title: hasDeeplink ? `Open in ADE: ${truncate(args.lane.name, 56)}` : `ADE lane: ${truncate(args.lane.name, 64)}`, subtitle: `${truncate(branch, 56)} - linked {linkedAt__since}`, - url: buildCardUrl(args.issue, args.lane.id), + url: cardUrl, metadata: { title: `ADE lane linked to ${args.issue.identifier}`, laneId: args.lane.id, @@ -77,19 +112,72 @@ export function buildLinearLaneCardAttachment(args: { }; } +export function buildLinearLaneInitialComment(args: { + lane: LaneSummary; + issue: LaneLinearIssue; + repoOwner?: string | null; + repoName?: string | null; +}): string | null { + if (!args.repoOwner || !args.repoName) return null; + const branch = args.issue.branchName?.trim() || args.lane.branchRef; + const url = buildDeeplink( + { + kind: "branch", + repoOwner: args.repoOwner, + repoName: args.repoName, + branch, + }, + { form: "https" }, + ); + return `ADE lane available for this issue — [Open in ADE](${url}) (branch \`${branch}\`).`; +} + export async function publishLinearLaneCard(args: { issueTracker: IssueTracker; lane: LaneSummary; issue: LaneLinearIssue; projectRoot: string; linkedAt?: string | null; + /** Optional: when known, used to render the cross-machine ADE deeplink. */ + repoOwner?: string | null; + repoName?: string | null; + /** When true, also post a one-time comment so timeline-watchers see the link. */ + postInitialComment?: boolean; + /** Optional log hook used for the (best-effort) comment-create step. */ + log?: (event: string, fields: Record) => void; }): Promise<{ url: string; id?: string }> { - return args.issueTracker.createIssueAttachment( + const result = await args.issueTracker.createIssueAttachment( buildLinearLaneCardAttachment({ lane: args.lane, issue: args.issue, projectRoot: args.projectRoot, linkedAt: args.linkedAt, + repoOwner: args.repoOwner ?? null, + repoName: args.repoName ?? null, }), ); + + if (args.postInitialComment) { + const body = buildLinearLaneInitialComment({ + lane: args.lane, + issue: args.issue, + repoOwner: args.repoOwner ?? null, + repoName: args.repoName ?? null, + }); + const createComment = (args.issueTracker as IssueTracker & { + createComment?: (issueId: string, body: string) => Promise; + }).createComment; + if (body && typeof createComment === "function") { + try { + await createComment(args.issue.id, body); + } catch (error) { + args.log?.("linear.lane_initial_comment_failed", { + issueId: args.issue.id, + error: error instanceof Error ? error.message : String(error), + }); + } + } + } + + return result; } diff --git a/apps/desktop/src/main/services/deeplinks/protocolHandler.test.ts b/apps/desktop/src/main/services/deeplinks/protocolHandler.test.ts new file mode 100644 index 000000000..47020520f --- /dev/null +++ b/apps/desktop/src/main/services/deeplinks/protocolHandler.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it, vi } from "vitest"; + +import { deeplinkToNavigationTarget, handleDeeplinkUrl } from "./protocolHandler"; + +const UUID = "550e8400-e29b-41d4-a716-446655440000"; + +describe("deeplinkToNavigationTarget", () => { + it("maps lane targets", () => { + expect(deeplinkToNavigationTarget({ kind: "lane", laneId: UUID })).toEqual({ + kind: "lane", + laneId: UUID, + }); + }); + + it("maps pr targets with repo identity", () => { + expect( + deeplinkToNavigationTarget({ + kind: "pr", + repoOwner: "a", + repoName: "b", + prNumber: 42, + }), + ).toEqual({ + kind: "pr", + prNumber: 42, + repoOwner: "a", + repoName: "b", + }); + }); + + it("maps branch targets with optional pr number", () => { + expect( + deeplinkToNavigationTarget({ + kind: "branch", + repoOwner: "a", + repoName: "b", + branch: "feat-x", + prNumber: 7, + }), + ).toEqual({ + kind: "branch", + repoOwner: "a", + repoName: "b", + branch: "feat-x", + prNumber: 7, + }); + }); + + it("maps branch targets without pr number", () => { + expect( + deeplinkToNavigationTarget({ + kind: "branch", + repoOwner: "a", + repoName: "b", + branch: "feat-x", + }), + ).toEqual({ + kind: "branch", + repoOwner: "a", + repoName: "b", + branch: "feat-x", + prNumber: null, + }); + }); +}); + +describe("handleDeeplinkUrl", () => { + it("dispatches valid URLs", () => { + const dispatch = vi.fn(); + const log = vi.fn(); + handleDeeplinkUrl(`ade://lane/${UUID}`, "test", dispatch, log); + expect(dispatch).toHaveBeenCalledTimes(1); + expect(dispatch).toHaveBeenCalledWith( + expect.objectContaining({ + target: { kind: "lane", laneId: UUID }, + source: "deeplink:test", + }), + ); + }); + + it("logs and skips dispatching invalid URLs", () => { + const dispatch = vi.fn(); + const log = vi.fn(); + handleDeeplinkUrl("ade://lane/not-a-uuid", "test", dispatch, log); + expect(dispatch).not.toHaveBeenCalled(); + expect(log).toHaveBeenCalledWith( + "deeplink.parse_failed", + expect.objectContaining({ source: "test" }), + ); + }); + + it("dispatches https mirror URLs", () => { + const dispatch = vi.fn(); + handleDeeplinkUrl( + "https://ade.app/open?type=branch&repo=a/b&branch=feat", + "test", + dispatch, + ); + expect(dispatch).toHaveBeenCalledWith( + expect.objectContaining({ + target: { kind: "branch", repoOwner: "a", repoName: "b", branch: "feat", prNumber: null }, + }), + ); + }); +}); diff --git a/apps/desktop/src/main/services/deeplinks/protocolHandler.ts b/apps/desktop/src/main/services/deeplinks/protocolHandler.ts new file mode 100644 index 000000000..58878fcfe --- /dev/null +++ b/apps/desktop/src/main/services/deeplinks/protocolHandler.ts @@ -0,0 +1,189 @@ +import path from "node:path"; +import { app, BrowserWindow } from "electron"; + +import { + ADE_DEEPLINK_SCHEME, + parseDeeplink, + type DeeplinkTarget, +} from "../../../shared/deeplinks"; +import { IPC } from "../../../shared/ipc"; +import type { + AppNavigationRequest, + AppNavigationTarget, +} from "../../../shared/types"; + +export type DeeplinkDispatchTarget = AppNavigationTarget; + +export type DeeplinkDispatcher = ( + request: AppNavigationRequest, +) => Promise | void; + +/** + * Register ADE as the OS handler for `ade://` URLs and wire up the + * single-instance lock so a second `open ade://...` invocation reuses the + * already-running window rather than spawning a new one. + * + * Must be called BEFORE `app.whenReady()` so the OS routes the cold-start URL + * through this process. + */ +export function registerAdeProtocolHandler(options: { + /** Called for every successfully parsed inbound deeplink. */ + dispatch: DeeplinkDispatcher; + /** Optional structured log hook. */ + log?: (event: string, fields: Record) => void; +}): void { + const { dispatch } = options; + const log = options.log ?? (() => {}); + + // Register URL scheme. The argv variant is required on Windows/Linux so the + // OS spawn picks up the URL on cold-start. + if (process.defaultApp) { + if (process.argv.length >= 2) { + app.setAsDefaultProtocolClient(ADE_DEEPLINK_SCHEME, process.execPath, [ + path.resolve(process.argv[1]), + ]); + } + } else { + app.setAsDefaultProtocolClient(ADE_DEEPLINK_SCHEME); + } + + // Single-instance lock: a second invocation routes through `second-instance` + // instead of starting a fresh Electron process. We rely on whoever wires + // this up to have already called `app.whenReady()` semantics correctly; + // requesting the lock is idempotent. + const acquired = app.requestSingleInstanceLock(); + if (!acquired) { + log("deeplink.single_instance.lock_lost", {}); + app.quit(); + return; + } + + // Buffer URLs received before whenReady so they aren't dropped. + const pendingUrls: string[] = []; + let ready = false; + + const consume = (url: string, source: string) => { + if (!url) return; + if (!ready) { + pendingUrls.push(url); + log("deeplink.buffered", { url, source }); + return; + } + handleDeeplinkUrl(url, source, dispatch, log); + }; + + // macOS: cold-start URL arrives via `open-url`. Hot-state URLs do too. + app.on("open-url", (event, url) => { + event.preventDefault(); + consume(url, "open-url"); + }); + + // Windows / Linux: second invocation passes argv. The OS spawn launches a + // second process; the lock holder receives `second-instance` with that argv. + app.on("second-instance", (_event, argv) => { + // Focus an existing window first so the user sees the action visually. + const wins = BrowserWindow.getAllWindows(); + const focusable = wins.find((win) => !win.isDestroyed()); + if (focusable) { + if (focusable.isMinimized()) focusable.restore(); + focusable.show(); + focusable.focus(); + } + for (const arg of argv) { + if (typeof arg !== "string") continue; + if ( + arg.startsWith(`${ADE_DEEPLINK_SCHEME}://`) || + /^https?:\/\/ade\.app\/open\b/i.test(arg) + ) { + consume(arg, "second-instance"); + } + } + }); + + // Pick up any URL embedded in this process's own argv (Windows cold-start). + for (const arg of process.argv.slice(1)) { + if (typeof arg !== "string") continue; + if ( + arg.startsWith(`${ADE_DEEPLINK_SCHEME}://`) || + /^https?:\/\/ade\.app\/open\b/i.test(arg) + ) { + pendingUrls.push(arg); + } + } + + // Flush buffer once the app is ready. Use `whenReady()` rather than + // `app.on('ready')` so we don't re-fire after activate. + void app.whenReady().then(() => { + ready = true; + const buffered = pendingUrls.splice(0); + for (const url of buffered) { + handleDeeplinkUrl(url, "buffered", dispatch, log); + } + }); +} + +/** + * Parse + dispatch a single URL. Exposed so callers (e.g., `ade open` CLI + * subcommand routing through RPC) can reuse the exact mapping. + */ +export function handleDeeplinkUrl( + rawUrl: string, + source: string, + dispatch: DeeplinkDispatcher, + log: (event: string, fields: Record) => void = () => {}, +): void { + const parsed = parseDeeplink(rawUrl); + if (!parsed.ok) { + log("deeplink.parse_failed", { + url: rawUrl, + source, + error: parsed.error, + }); + return; + } + const navigationTarget = deeplinkToNavigationTarget(parsed.target); + log("deeplink.dispatch", { url: rawUrl, source, kind: navigationTarget.kind }); + void Promise.resolve( + dispatch({ + target: navigationTarget, + source: `deeplink:${source}`, + }), + ).catch((err: unknown) => { + log("deeplink.dispatch_failed", { + url: rawUrl, + source, + error: err instanceof Error ? err.message : String(err), + }); + }); +} + +export function deeplinkToNavigationTarget(target: DeeplinkTarget): AppNavigationTarget { + switch (target.kind) { + case "lane": + return { kind: "lane", laneId: target.laneId }; + case "pr": + return { + kind: "pr", + prNumber: target.prNumber, + repoOwner: target.repoOwner, + repoName: target.repoName, + }; + case "branch": + return { + kind: "branch", + repoOwner: target.repoOwner, + repoName: target.repoName, + branch: target.branch, + prNumber: target.prNumber ?? null, + }; + case "linear-issue": + return { + kind: "linear-issue", + issueIdentifier: target.issueIdentifier, + branch: target.branch ?? null, + }; + } +} + +/** Reference helper: the IPC channel used to broadcast the navigation event. */ +export const DEEPLINK_NAVIGATE_IPC = IPC.appNavigate; diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index 8069e00b8..b9e0855b9 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -722,6 +722,7 @@ import { } from "../chat/codexCliLauncher"; import { sanitizeResumeTargetId } from "../../utils/terminalSessionSignals"; import { probeLocalhostPort } from "../probeLocalhostPort"; +import type { ProcessRegistryService } from "../runtime/processRegistryService"; export type AppContext = { db: AdeDb; @@ -748,6 +749,7 @@ export type AppContext = { rebaseSuggestionService: ReturnType | null; autoRebaseService: ReturnType | null; sessionService: ReturnType; + processRegistry?: ProcessRegistryService | null; ptyService: ReturnType; diffService: ReturnType; fileService: ReturnType; @@ -3523,6 +3525,10 @@ export function registerIpc({ clipboard.writeText(text); }); + ipcMain.handle(IPC.appReadClipboardText, async (): Promise => { + return clipboard.readText() ?? ""; + }); + ipcMain.handle(IPC.appHasClipboardImage, async (): Promise => { return !clipboard.readImage().isEmpty(); }); diff --git a/apps/desktop/src/main/services/ipc/runtimeBridge.ts b/apps/desktop/src/main/services/ipc/runtimeBridge.ts index 39987c253..b014dcfdb 100644 --- a/apps/desktop/src/main/services/ipc/runtimeBridge.ts +++ b/apps/desktop/src/main/services/ipc/runtimeBridge.ts @@ -692,7 +692,7 @@ export function registerRuntimeBridge({ { cursor: arg?.request?.cursor, limit: arg?.request?.limit, - category: "runtime", + category: arg?.request?.category, }, onEvent, onEnded, @@ -739,7 +739,7 @@ export function registerRuntimeBridge({ { cursor: arg?.request?.cursor, limit: arg?.request?.limit, - category: "runtime", + category: arg?.request?.category, }, onEvent, onEnded, diff --git a/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.test.ts b/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.test.ts index 3036a442e..d58a4c52a 100644 --- a/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.test.ts +++ b/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.test.ts @@ -653,7 +653,7 @@ describe("local runtime connection pool", () => { { id: 12, timestamp: "2026-05-10T12:00:00.000Z", - category: "runtime", + category: "pty", payload: { type: "pty_data", event: { ptyId: "pty-1", data: "hello" } }, }, { id: "bad", timestamp: "nope", category: "runtime", payload: {} }, @@ -685,7 +685,7 @@ describe("local runtime connection pool", () => { const result = await pool.streamEventsForRoot(rootPath, { cursor: 7.5, limit: 2, - category: "runtime", + category: "pty", }); expect(call).toHaveBeenCalledWith( @@ -696,7 +696,7 @@ describe("local runtime connection pool", () => { arguments: { cursor: 7, limit: 2, - category: "runtime", + category: "pty", }, }, { timeoutMs: 2_000 }, @@ -706,7 +706,7 @@ describe("local runtime connection pool", () => { { id: 12, timestamp: "2026-05-10T12:00:00.000Z", - category: "runtime", + category: "pty", payload: { type: "pty_data", event: { ptyId: "pty-1", data: "hello" } }, }, ], diff --git a/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.ts b/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.ts index 229439d1e..d92586c95 100644 --- a/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.ts +++ b/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.ts @@ -859,7 +859,7 @@ function clampLimit(value: unknown): number { } function isRemoteRuntimeEventCategory(value: unknown): value is RemoteRuntimeEventCategory { - return value === "orchestrator" || value === "dag_mutation" || value === "runtime" || value === "mission"; + return value === "orchestrator" || value === "dag_mutation" || value === "runtime" || value === "mission" || value === "pty"; } function normalizeBufferedEvent(value: unknown): RemoteRuntimeBufferedEvent | null { diff --git a/apps/desktop/src/main/services/prs/prService.test.ts b/apps/desktop/src/main/services/prs/prService.test.ts index a43f2d42c..ecfffa9c4 100644 --- a/apps/desktop/src/main/services/prs/prService.test.ts +++ b/apps/desktop/src/main/services/prs/prService.test.ts @@ -2456,7 +2456,16 @@ describe("prService.createFromLane", () => { method: "POST", body: expect.objectContaining({ title: "My PR", - body: "Refs ADE-123\n\ndescription", + // Body starts with the Linear ref + description, then the auto-appended + // "Open in ADE" deeplink footer block (idempotent marker). + body: expect.stringMatching(/^Refs ADE-123\n\ndescription/), + }), + }), + ); + expect(ghService.apiRequest).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ + body: expect.stringContaining(""); + }); + + it("omits the pr link when prNumber is missing", () => { + const block = renderAdeDeeplinkFooter({ + repoOwner: "a", + repoName: "b", + branch: "feat", + }); + expect(block).toMatch(/ade\.app\/open\?type=branch/); + expect(block).not.toMatch(/type=pr/); + expect(block).toContain(""), + ).toBe(true); + }); + it("returns false for missing markers", () => { + expect(hasAdeDeeplinkFooter("body")).toBe(false); + expect(hasAdeDeeplinkFooter(null)).toBe(false); + expect(hasAdeDeeplinkFooter(undefined)).toBe(false); + }); +}); diff --git a/apps/desktop/src/shared/adeDeeplinkFooter.ts b/apps/desktop/src/shared/adeDeeplinkFooter.ts new file mode 100644 index 000000000..b28caec59 --- /dev/null +++ b/apps/desktop/src/shared/adeDeeplinkFooter.ts @@ -0,0 +1,118 @@ +// --------------------------------------------------------------------------- +// "Open in ADE" footer block — appended to GitHub PR descriptions (and used +// as Linear attachment subtitle). +// +// Idempotent: every block is wrapped in HTML comment markers so a second pass +// finds and replaces the block in place rather than appending duplicates. +// --------------------------------------------------------------------------- + +import { buildDeeplink } from "./deeplinks"; + +const MARKER_OPEN = "/i; +const MARKER_CLOSE = ""; +const MARKER_CLOSE_RE = //i; + +export type AdeDeeplinkFooterOptions = { + repoOwner: string; + repoName: string; + branch: string; + prNumber?: number | null; +}; + +/** + * Render the branded "Open in ADE" footer block as the markdown/HTML that + * lives at the bottom of a PR description. + * + * The PR body's renderer is GitHub-flavored markdown which supports a useful + * but small subset of HTML; ,

, all render. Linear's renderer + * accepts the same subset. + */ +export function renderAdeDeeplinkFooter(opts: AdeDeeplinkFooterOptions): string { + const branchUrl = buildDeeplink({ + kind: "branch", + repoOwner: opts.repoOwner, + repoName: opts.repoName, + branch: opts.branch, + ...(opts.prNumber ? { prNumber: opts.prNumber } : {}), + }); + const prUrl = opts.prNumber + ? buildDeeplink({ + kind: "pr", + repoOwner: opts.repoOwner, + repoName: opts.repoName, + prNumber: opts.prNumber, + }) + : null; + const meta = formatMarker(opts); + const lines = [ + meta, + "

", + ' ', + ' ', + ' ADE', + " ", + "   Open in ADE", + `  ·  ${escapeHtml(opts.branch)} branch`, + prUrl + ? `  ·  PR #${opts.prNumber}` + : null, + "

", + MARKER_CLOSE, + ].filter((line): line is string => line !== null); + return lines.join("\n"); +} + +/** + * Ensure `body` contains the "Open in ADE" footer block. + * - If a marker block already exists, replace it in place (idempotent). + * - Otherwise, append it after a blank line. + */ +export function ensureAdeDeeplinkFooter( + body: string, + opts: AdeDeeplinkFooterOptions, +): string { + const block = renderAdeDeeplinkFooter(opts); + const safeBody = typeof body === "string" ? body : ""; + const openIdx = safeBody.search(MARKER_OPEN_RE); + if (openIdx >= 0) { + const closeMatch = MARKER_CLOSE_RE.exec(safeBody.slice(openIdx)); + if (closeMatch) { + const before = safeBody.slice(0, openIdx); + const after = safeBody.slice(openIdx + closeMatch.index + closeMatch[0].length); + return `${before.trimEnd()}\n\n${block}${after.startsWith("\n") ? after : (after ? `\n${after}` : "")}`.trimEnd() + "\n"; + } + } + const trimmed = safeBody.trimEnd(); + if (!trimmed) return `${block}\n`; + return `${trimmed}\n\n${block}\n`; +} + +/** + * Detect whether `body` already carries an ADE deeplink footer block. Used by + * callers that want to know whether to PATCH on update. + */ +export function hasAdeDeeplinkFooter(body: string | null | undefined): boolean { + if (!body) return false; + return MARKER_OPEN_RE.test(body); +} + +function formatMarker(opts: AdeDeeplinkFooterOptions): string { + const parts = [ + "v=1", + `type=${opts.prNumber ? "pr" : "branch"}`, + `repo=${opts.repoOwner}/${opts.repoName}`, + `branch=${opts.branch}`, + ]; + if (opts.prNumber) parts.push(`num=${opts.prNumber}`); + return ``; +} + +function escapeHtml(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} diff --git a/apps/desktop/src/shared/deeplinks.test.ts b/apps/desktop/src/shared/deeplinks.test.ts new file mode 100644 index 000000000..df9c52bae --- /dev/null +++ b/apps/desktop/src/shared/deeplinks.test.ts @@ -0,0 +1,262 @@ +import { describe, expect, it } from "vitest"; + +import { + buildDeeplink, + describeTarget, + looksLikeAdeDeeplink, + parseDeeplink, + type DeeplinkTarget, +} from "./deeplinks"; + +const UUID = "550e8400-e29b-41d4-a716-446655440000"; + +function expectOk(result: ReturnType): DeeplinkTarget { + if (!result.ok) { + throw new Error(`expected ok, got ${result.error.kind}: ${(result.error as { reason?: string }).reason ?? ""}`); + } + return result.target; +} + +describe("parseDeeplink — ade:// scheme", () => { + it("parses lane links", () => { + const target = expectOk(parseDeeplink(`ade://lane/${UUID}`)); + expect(target).toEqual({ kind: "lane", laneId: UUID }); + }); + + it("rejects lane links with non-UUID ids", () => { + const result = parseDeeplink("ade://lane/not-a-uuid"); + expect(result.ok).toBe(false); + }); + + it("parses repo/branch links with simple branches", () => { + const target = expectOk(parseDeeplink("ade://repo/anthropics/claude-code/branch/feat-deeplinks")); + expect(target).toEqual({ + kind: "branch", + repoOwner: "anthropics", + repoName: "claude-code", + branch: "feat-deeplinks", + }); + }); + + it("parses repo/branch links with slash-containing branches", () => { + const target = expectOk(parseDeeplink("ade://repo/anthropics/claude-code/branch/users/arul/feat-x")); + expect(target).toEqual({ + kind: "branch", + repoOwner: "anthropics", + repoName: "claude-code", + branch: "users/arul/feat-x", + }); + }); + + it("parses pr-number on branch link", () => { + const target = expectOk(parseDeeplink("ade://repo/anthropics/claude-code/branch/feat-x?pr=1234")); + expect(target).toEqual({ + kind: "branch", + repoOwner: "anthropics", + repoName: "claude-code", + branch: "feat-x", + prNumber: 1234, + }); + }); + + it("rejects branch links with traversal segments", () => { + const result = parseDeeplink("ade://repo/anthropics/claude-code/branch/../etc/passwd"); + expect(result.ok).toBe(false); + }); + + it("parses pr links", () => { + const target = expectOk(parseDeeplink("ade://pr/anthropics/claude-code/1234")); + expect(target).toEqual({ + kind: "pr", + repoOwner: "anthropics", + repoName: "claude-code", + prNumber: 1234, + }); + }); + + it("rejects pr links with non-integer numbers", () => { + const result = parseDeeplink("ade://pr/anthropics/claude-code/notanumber"); + expect(result.ok).toBe(false); + }); + + it("rejects unknown ade:// hosts", () => { + const result = parseDeeplink("ade://surprise/anything"); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error.kind).toBe("unknown_type"); + }); +}); + +describe("parseDeeplink — https://ade.app/open", () => { + it("parses lane links", () => { + const target = expectOk(parseDeeplink(`https://ade.app/open?type=lane&id=${UUID}`)); + expect(target).toEqual({ kind: "lane", laneId: UUID }); + }); + + it("parses branch links", () => { + const target = expectOk( + parseDeeplink("https://ade.app/open?type=branch&repo=anthropics/claude-code&branch=feat-x"), + ); + expect(target).toEqual({ + kind: "branch", + repoOwner: "anthropics", + repoName: "claude-code", + branch: "feat-x", + }); + }); + + it("parses branch links with pr query", () => { + const target = expectOk( + parseDeeplink("https://ade.app/open?type=branch&repo=a/b&branch=f&pr=42"), + ); + expect(target).toEqual({ + kind: "branch", + repoOwner: "a", + repoName: "b", + branch: "f", + prNumber: 42, + }); + }); + + it("parses pr links", () => { + const target = expectOk(parseDeeplink("https://ade.app/open?type=pr&repo=a/b&number=99")); + expect(target).toEqual({ kind: "pr", repoOwner: "a", repoName: "b", prNumber: 99 }); + }); + + it("rejects http with wrong host", () => { + const result = parseDeeplink("https://example.com/open?type=lane&id=" + UUID); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error.kind).toBe("unsupported_host"); + }); + + it("rejects unknown scheme", () => { + const result = parseDeeplink("javascript:alert(1)"); + expect(result.ok).toBe(false); + }); + + it("rejects empty input", () => { + const result = parseDeeplink(""); + expect(result.ok).toBe(false); + }); + + it("rejects garbage", () => { + const result = parseDeeplink("totally not a url"); + expect(result.ok).toBe(false); + }); +}); + +describe("buildDeeplink", () => { + it("round-trips lane (ade)", () => { + const url = buildDeeplink({ kind: "lane", laneId: UUID }, { form: "ade" }); + expect(url).toBe(`ade://lane/${UUID}`); + expect(expectOk(parseDeeplink(url))).toEqual({ kind: "lane", laneId: UUID }); + }); + + it("round-trips lane (https)", () => { + const url = buildDeeplink({ kind: "lane", laneId: UUID }); + expect(url).toBe(`https://ade.app/open?type=lane&id=${UUID}`); + expect(expectOk(parseDeeplink(url))).toEqual({ kind: "lane", laneId: UUID }); + }); + + it("round-trips branch (ade) with slash branches", () => { + const target = { kind: "branch", repoOwner: "a", repoName: "b", branch: "users/me/x" } as const; + const url = buildDeeplink(target, { form: "ade" }); + expect(url).toBe("ade://repo/a/b/branch/users/me/x"); + expect(expectOk(parseDeeplink(url))).toEqual(target); + }); + + it("round-trips branch with pr number (https)", () => { + const target = { kind: "branch", repoOwner: "a", repoName: "b", branch: "feat", prNumber: 7 } as const; + const url = buildDeeplink(target); + expect(expectOk(parseDeeplink(url))).toEqual(target); + }); + + it("round-trips pr (ade)", () => { + const target = { kind: "pr", repoOwner: "a", repoName: "b", prNumber: 100 } as const; + const url = buildDeeplink(target, { form: "ade" }); + expect(url).toBe("ade://pr/a/b/100"); + expect(expectOk(parseDeeplink(url))).toEqual(target); + }); + + it("round-trips pr (https)", () => { + const target = { kind: "pr", repoOwner: "a", repoName: "b", prNumber: 100 } as const; + const url = buildDeeplink(target); + expect(expectOk(parseDeeplink(url))).toEqual(target); + }); +}); + +describe("parseDeeplink — linear-issue", () => { + it("parses ade://linear-issue/", () => { + const target = expectOk(parseDeeplink("ade://linear-issue/ADE-123")); + expect(target).toEqual({ kind: "linear-issue", issueIdentifier: "ADE-123" }); + }); + + it("parses ade://linear-issue/?branch=", () => { + const target = expectOk(parseDeeplink("ade://linear-issue/ADE-123?branch=arul/ade-123-feat")); + expect(target).toEqual({ + kind: "linear-issue", + issueIdentifier: "ADE-123", + branch: "arul/ade-123-feat", + }); + }); + + it("parses https mirror", () => { + const target = expectOk( + parseDeeplink("https://ade.app/open?type=linear-issue&issue=ADE-123&branch=feat-x"), + ); + expect(target).toEqual({ + kind: "linear-issue", + issueIdentifier: "ADE-123", + branch: "feat-x", + }); + }); + + it("rejects bad identifier shapes", () => { + expect(parseDeeplink("ade://linear-issue/not-a-real-id").ok).toBe(false); + expect(parseDeeplink("ade://linear-issue/123-456").ok).toBe(false); + expect(parseDeeplink("ade://linear-issue/").ok).toBe(false); + }); + + it("round-trips through buildDeeplink", () => { + const target = { kind: "linear-issue" as const, issueIdentifier: "ADE-123", branch: "feat" }; + const ade = buildDeeplink(target, { form: "ade" }); + expect(ade).toBe("ade://linear-issue/ADE-123?branch=feat"); + expect(expectOk(parseDeeplink(ade))).toEqual(target); + + const https = buildDeeplink(target); + expect(expectOk(parseDeeplink(https))).toEqual(target); + }); +}); + +describe("looksLikeAdeDeeplink", () => { + it("matches ade:// urls", () => { + expect(looksLikeAdeDeeplink("ade://lane/abc")).toBe(true); + }); + + it("matches https://ade.app/open urls", () => { + expect(looksLikeAdeDeeplink("https://ade.app/open?type=lane&id=" + UUID)).toBe(true); + }); + + it("rejects unrelated urls", () => { + expect(looksLikeAdeDeeplink("https://github.com/foo")).toBe(false); + expect(looksLikeAdeDeeplink("hello world")).toBe(false); + expect(looksLikeAdeDeeplink("")).toBe(false); + }); + + it("rejects suspiciously long input", () => { + expect(looksLikeAdeDeeplink("ade://lane/" + "a".repeat(4096))).toBe(false); + }); +}); + +describe("describeTarget", () => { + it("summarizes branch targets", () => { + expect(describeTarget({ kind: "branch", repoOwner: "a", repoName: "b", branch: "feat" })).toBe( + "a/b@feat", + ); + }); + it("summarizes pr targets", () => { + expect(describeTarget({ kind: "pr", repoOwner: "a", repoName: "b", prNumber: 7 })).toBe("a/b#7"); + }); + it("summarizes lane targets", () => { + expect(describeTarget({ kind: "lane", laneId: UUID })).toBe("lane link"); + }); +}); diff --git a/apps/desktop/src/shared/deeplinks.ts b/apps/desktop/src/shared/deeplinks.ts new file mode 100644 index 0000000000000000000000000000000000000000..747f8d378a191bc2f5d3445f40d8bbdf84ff7666 GIT binary patch literal 15386 zcmeHO|8m>75$@l4i6W{eCnXYTCwDU!*@|l?zO$!p>~WmlOyitQOOVAgMRG{WzSPmQ z578&=lk{6G0D=;w=sN23I&J=mMFNY({`__U`o8F_@82We7kh6G#6Ze43gg>c{Nrzb z7w^xG#q}hN1~L=RL>go{{>Vo`CI=$uXGxxmaS+DB7nzJP2ycDy`uNBra_eKR19C+1 z{5}!+BpU{ODTYZl&f6j!$hZjmK_qfH4(QLkOGAYS2D0n>Q4mXiXEF%~uiVF(OcQ@6 zxsPS`%HN@1ul(yQi2EadhySk(PqW#@B8~W;HaJ>E2Fk_&y&=ad>`y`cBDHMB$yx zMG_0_AouU(^|lBL5fp_Sr$sIbjApTlNqUpvPXXoQDT($vOYX@$f_NZ?K@{cUI_Teu zBEb_3%L_d7$3b>0i!i_DmCE`0 z#RQ>_l&PYEh!TXp=##h$1NGEH?8dH+3}aD}+)mPMHJS7VAA`pg+HE<$%YHD)rIE)V zk$L?*myVK>(wqA0NJVUtm1`L+5ZDD9sIrUNHO)dW z#LqRRUwORmm~-WYv98L)x!u6e*mvK*KYDX{Mvm+M_+hj2GUyCHK72dv*uTGC;qR^I z(+xk|rV#giSR78SyF%q3vDJC;QuL8jbMaxX^XH)R7c9~FxUC!wagwaCA59Q!FTGBi z0%jT@EanpVI4F`V_cWW|A6}mP@b=)$vTCV)bO}FfwqJY;UQA@dw6lZLllfVfo-+?? zf5gm}FV#$Jg2m;Se%=*@494PCeiF?{B57bp&xG*>F$izM0x5f|v$e_lZQ=DG$Vd23 znT8Gu3OyYB9E{Tl@dVD$*?Rt>EfBst-)uc^i|M zv~w|d*lODiuysJn!(0H-h*V}{uz?f^%%idf-u)!|l=!mX4!eQtJ)@jbWJ$z3Fg)y^ z?Y(_{c)7o4?4U`lI0_?o)Wcp8pg?;ov>MN%Jm< zCN_Asy5bt)Mnd%uFwsmFlPnh63cW(+1&?pRP3Gx+KT^?o6_Sp{tU&e*0c1t0+Mryb z;>i=Cb}1hBkR83o>;n%mqGmhVEp60Tg#*_lTpnr{U^`e!7C5E(n3@0lnRnquGQNQu zz7o&B`KD!b1mKfP%7-*VP4EDGvLgQ>EJn?S-)IR-0El_{V-L~&w$Z95Ua>fLkMqN| z{S?^*tho}FF_N3){lAkpV?gQVwThW04#N^PVjx3=*YjYcBZ^Dp3IzYJ{HtwoFX8zz zrBpK(L5{F5az<-ZML-Ila!4r{1lN&lGckgSnMC@6GcYgj&yHRv<1~rE3T0$qN4y2e z3LgQgt4Mf z71%(Q0y{+zZc-#RBC5(t3!Mh6$Q4PL{uS9L<@Wqb)8Um}@mQA0-00sFIw|_2`MG-Q zcAOeNt=%a!yxy`+>~s03p#3^YSHOW<eHtT@$tJq$%|wx@;*#cwnams z?*?9=!V%*z%aY6kmKX+;h=P4$MtB5#ghGZuCaPcn=+M()86^<6F zlx{=XIi%9H^4<*+^8{x1Q{_3o56KGkVjh(tEp3zi063E?lCCydZQfTix13wR9<2aT?E2A1SCF@W-G;cA4D{bj4!;-@wJ;y9{@FpPPzqd9__ zaLX3r@ff=as~`Q^)G=b0c&A-Vchju5@i6m#dL;lI@S0^YbtHn)X=RzC-LYmF)Ntym zt5PZaVx_sWmoh$6e=HO?Yr_>bKY_p(+o4tAoC>Cuq^taC#^siK6x%ML{V_$iSl(0N z_6~Jke{`yzHkD8RGS6drUM`U;sj6!rFsf#OqD8LcjtB>MudPjc94k5v8k)XkSu4k|Oz3RJby}6}!du$no$8g$ z4YUi+ZQSv(x=rJ?o$J0|$A-C`@~<%Mde2gwRR-2>MEO@~C$sKWBYmxxitN9jJ1cdH zZe{u?`)kwPjR!5>BGEcju8yp>etdJPn&RswTtPQ#i336@qK7?yE}MJQmi^QaBnju~ zB+e%(^*`m{k}(e&fc%HSs>`N~lDxnK`fu^@+ZfG@OXbe!E+Kp@8afUlLMCzmk_Z%e z5|<-O{6rLl+UJ?ffx_%YlUsBZsBqE2XEsuTXmAhgQWDj&pov_K2}z>sGI=G&`AH=w z7RkYca%quVbrm2Um)QrbsY*m1MI|0>C>d(zxZ>qP3doFxTho0ImeaQ3)HZL8e%p4J zdIf@i&^vh)DIeVyF(Dx!FVM5Hguv4>Wi)xVhqyLwwv-E3iw5mZOL;043a+$58`M(_ zXmv1Um@U~O*?JOh#2b3J@#5loH2U0L8_QrS-tF9 zMHm8P@TwtOB*)3U%w7XWHCsw|3!3zX)76yex~1~5w)Qb%&@4VHKGv_ZOVpFZVO3O>Mt2GM?XqB20o%9G3Q`P3T=IyHtVdguvw`oY-jcW z#h*e_U9t4!+4HtX=`FJvHWwaF>%-<2S=iP`jZJwpbB6J?y*p!< zULJ)AATL^;?fhvfrd96&1Wh#OT?Xt?k8Mfaf8LX25 zqPNoe`>H*xyTt3{KpXC?OI0l;&CY^*9mR5|{`JshYc|UIN{2pu+DrjTP23fH{M=MO zDG*I?Yh{Qd(?J=Jc<5u)*^?-uGm7S8p5=x4QC!Fygh|tnQQds=1E_}QkZ+o$cf{5@ zrIcp+jBKH@j4f)@H2B%LBUimnK+|$Z@`DMS@Ugx!M}9&zc2~V?>qF~yxjF0Pu8V6> ziuJ4cgS4YPW>Flpmue_b&}=uvndnESM=uPee@A>2WpUoA;;)b$1Q_4_H@XB8hcfydlh7Dv`K6C&PD}oRhZa-WT9$o0a3aM8_#*_1iE>iUr zR5L_8oW07QPloy?gNfNZbf+gasf^SUp+0wggSI+rNsG3fL`(D(VDPE0@r8J%2F~t@ zE459uf@sH;T92sL59duj!)E5q?0>kbD~(ZGXbFMbjIz)e?JN}@ShQ^W#)tRagyH0h z61Xpmhf9lQ<5ZQvgR5q$2G`n873`7X3~^R zqO%nwP8am87TXllnpg#&^3b{(as}w7p}H-4>Qf4jGHJzicvh=Z$Mewt^)g^KTe$$+ z*6bV)W-!yjz@Ww%70id+|AXlM$c30rU^M=>{;)^~g=HAg{ypp6*0-(y|9U`l$^0_m z)L`gpLE)ljr}R^|b)B;x=iY01oT{}`a~o4lSHY(CNL9TiM^Kl6&;VQKT}bMu;d?23 z28X-hYrl$s?`6a}PD7|8cg0B<5fsms*FVyGbq%Bq6@N0vJap8OO>u>G-N=wTf>fHHWbjT zqAQuZ-3$MMJ}A7PFV8O2QEU?rcRz2m{E+W_Yu;gaFl*A(>mo-sba!-eW04(r*|+xV7AE2v0nGLU&c3$OL(W%@?c>{X04Tmw~yjIgTs zw!ulGdYeLI3~& literal 0 HcmV?d00001 diff --git a/apps/desktop/src/shared/ipc.ts b/apps/desktop/src/shared/ipc.ts index 976d7bf70..00568b2b6 100644 --- a/apps/desktop/src/shared/ipc.ts +++ b/apps/desktop/src/shared/ipc.ts @@ -14,6 +14,7 @@ export const IPC = { appRevealPath: "ade.app.revealPath", appOpenPath: "ade.app.openPath", appWriteClipboardText: "ade.app.writeClipboardText", + appReadClipboardText: "ade.app.readClipboardText", appHasClipboardImage: "ade.app.hasClipboardImage", appReadClipboardImage: "ade.app.readClipboardImage", appSaveClipboardImageAttachment: "ade.app.saveClipboardImageAttachment", diff --git a/apps/desktop/src/shared/types/core.ts b/apps/desktop/src/shared/types/core.ts index e3ad7d45a..3b86aed4a 100644 --- a/apps/desktop/src/shared/types/core.ts +++ b/apps/desktop/src/shared/types/core.ts @@ -119,6 +119,30 @@ export type AppNavigationTarget = prId?: string | null; prNumber?: number | null; laneId?: string | null; + // For inbound deeplinks: when the PR isn't local yet, the renderer needs the + // owner/name to either jump to the PRs tab pre-filtered or fall back to the + // create-lane-from-PR-branch modal. + repoOwner?: string | null; + repoName?: string | null; + } + | { + kind: "branch"; + // Cross-machine deeplink target. The renderer opens the existing + // "Create lane from PR branch" preflight modal with these inputs; + // if a lane already exists for this branch the renderer routes to it + // instead of showing the modal. + repoOwner: string; + repoName: string; + branch: string; + prNumber?: number | null; + } + | { + kind: "linear-issue"; + // Linear coding-tool hand-off. The renderer resolves the actual lane + // by looking up lane.linearIssue.identifier; if no lane matches, the + // user is offered a fallback (e.g., open lanes filtered by branch). + issueIdentifier: string; + branch?: string | null; } | { kind: "route"; diff --git a/apps/desktop/src/shared/types/remoteRuntime.ts b/apps/desktop/src/shared/types/remoteRuntime.ts index e7d674187..2ab8aaf80 100644 --- a/apps/desktop/src/shared/types/remoteRuntime.ts +++ b/apps/desktop/src/shared/types/remoteRuntime.ts @@ -93,7 +93,8 @@ export type RemoteRuntimeEventCategory = | "orchestrator" | "dag_mutation" | "runtime" - | "mission"; + | "mission" + | "pty"; export type RemoteRuntimeBufferedEvent = { id: number; diff --git a/apps/desktop/src/shared/types/sessions.ts b/apps/desktop/src/shared/types/sessions.ts index bc966d901..1ac825b09 100644 --- a/apps/desktop/src/shared/types/sessions.ts +++ b/apps/desktop/src/shared/types/sessions.ts @@ -64,6 +64,7 @@ export type TerminalSessionSummary = { laneId: string; laneName: string; ptyId: string | null; + ownerPid?: number | null; tracked: boolean; pinned: boolean; manuallyNamed?: boolean; diff --git a/apps/desktop/src/shared/types/sync.ts b/apps/desktop/src/shared/types/sync.ts index 6803373e2..9b3b380f2 100644 --- a/apps/desktop/src/shared/types/sync.ts +++ b/apps/desktop/src/shared/types/sync.ts @@ -715,7 +715,8 @@ export type SyncRemoteCommandAction = | "modelPicker.setFavorites" | "modelPicker.toggleFavorite" | "modelPicker.getRecents" - | "modelPicker.pushRecent"; + | "modelPicker.pushRecent" + | "deeplinks.open"; export type SyncRemoteCommandPolicy = { viewerAllowed: boolean; diff --git a/apps/ios/ADE.xcodeproj/project.pbxproj b/apps/ios/ADE.xcodeproj/project.pbxproj index 4d9df1961..1ec5b2e7e 100644 --- a/apps/ios/ADE.xcodeproj/project.pbxproj +++ b/apps/ios/ADE.xcodeproj/project.pbxproj @@ -38,6 +38,7 @@ AA5300000000000000000001 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA5300000000000000000011 /* AppDelegate.swift */; }; AA5300000000000000000002 /* DeepLinkRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA5300000000000000000012 /* DeepLinkRouter.swift */; }; AA5300000000000000000003 /* NotificationCategories.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA5300000000000000000013 /* NotificationCategories.swift */; }; + K10000000000000000000001 /* SendToMacCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = K20000000000000000000001 /* SendToMacCard.swift */; }; AA5200000000000000000011 /* ADEWidgetBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA5200000000000000000001 /* ADEWidgetBundle.swift */; }; AA5200000000000000000012 /* ADEWorkspaceWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA5200000000000000000002 /* ADEWorkspaceWidget.swift */; }; AA5200000000000000000013 /* ADEWorkspaceWidgetViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA5200000000000000000003 /* ADEWorkspaceWidgetViews.swift */; }; @@ -242,6 +243,7 @@ AA5300000000000000000011 /* AppDelegate.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = AppDelegate.swift; path = ADE/App/AppDelegate.swift; sourceTree = ""; }; AA5300000000000000000012 /* DeepLinkRouter.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = DeepLinkRouter.swift; path = ADE/App/DeepLinkRouter.swift; sourceTree = ""; }; AA5300000000000000000013 /* NotificationCategories.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = NotificationCategories.swift; path = ADE/App/NotificationCategories.swift; sourceTree = ""; }; + K20000000000000000000001 /* SendToMacCard.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = SendToMacCard.swift; path = ADE/Views/Deeplinks/SendToMacCard.swift; sourceTree = ""; }; AA5200000000000000000001 /* ADEWidgetBundle.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ADEWidgetBundle.swift; path = ADEWidgets/ADEWidgetBundle.swift; sourceTree = ""; }; AA5200000000000000000002 /* ADEWorkspaceWidget.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ADEWorkspaceWidget.swift; path = ADEWidgets/ADEWorkspaceWidget.swift; sourceTree = ""; }; AA5200000000000000000003 /* ADEWorkspaceWidgetViews.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ADEWorkspaceWidgetViews.swift; path = ADEWidgets/ADEWorkspaceWidgetViews.swift; sourceTree = ""; }; @@ -503,6 +505,7 @@ G20000000000000000000000 /* PRs */, A10000000000000000000015 /* Settings */, D30000000000000000000004 /* AttentionDrawer */, + K30000000000000000000001 /* Deeplinks */, 9270CF8A67F3FA79089F39C1 /* LanesTabView.swift */, 5EE4D463D21266B62B422D11 /* PRsTabView.swift */, ); @@ -519,6 +522,14 @@ name = AttentionDrawer; sourceTree = ""; }; + K30000000000000000000001 /* Deeplinks */ = { + isa = PBXGroup; + children = ( + K20000000000000000000001 /* SendToMacCard.swift */, + ); + name = Deeplinks; + sourceTree = ""; + }; G20000000000000000000000 /* PRs */ = { isa = PBXGroup; children = ( @@ -1149,6 +1160,7 @@ AA5300000000000000000001 /* AppDelegate.swift in Sources */, AA5300000000000000000002 /* DeepLinkRouter.swift in Sources */, AA5300000000000000000003 /* NotificationCategories.swift in Sources */, + K10000000000000000000001 /* SendToMacCard.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/apps/ios/ADE/App/ADEApp.swift b/apps/ios/ADE/App/ADEApp.swift index 7a9c65067..66a6551ff 100644 --- a/apps/ios/ADE/App/ADEApp.swift +++ b/apps/ios/ADE/App/ADEApp.swift @@ -7,6 +7,9 @@ struct ADEApp: App { @StateObject private var syncService = SyncService() @State private var didBootstrapSync = false @State private var lastActivationSyncAt = Date.distantPast + /// Pending cross-machine deep link awaiting a "Send to Mac" confirmation. + /// Driven by `.adeSendToMacRequested` notifications from `DeepLinkRouter`. + @State private var sendToMacTarget: SendToMacTarget? var body: some Scene { WindowGroup { @@ -31,6 +34,28 @@ struct ADEApp: App { .onOpenURL { url in DeepLinkRouter.shared.handle(url) } + .onReceive(NotificationCenter.default.publisher(for: .adeSendToMacRequested)) { note in + // Parse the URL out of the payload posted by `DeepLinkRouter`. We + // accept either a `URL` or a `String` so callers don't have to + // serialise the same value twice. + let url: URL? + if let direct = note.userInfo?["url"] as? URL { + url = direct + } else if let string = note.userInfo?["url"] as? String { + url = URL(string: string) + } else { + url = nil + } + guard let resolved = url else { return } + sendToMacTarget = SendToMacTarget(url: resolved) + } + .sheet(item: $sendToMacTarget) { target in + SendToMacCard(target: target) { + sendToMacTarget = nil + } + .environmentObject(syncService) + } } } } + diff --git a/apps/ios/ADE/App/DeepLinkRouter.swift b/apps/ios/ADE/App/DeepLinkRouter.swift index 97ce160e6..6446baa30 100644 --- a/apps/ios/ADE/App/DeepLinkRouter.swift +++ b/apps/ios/ADE/App/DeepLinkRouter.swift @@ -2,7 +2,10 @@ import Foundation /// Central router for `ade://` URLs and deep-link requests posted from the /// notification delegate. Existing tab/navigation views listen to -/// `.adeDeepLinkRequested` and flip their selection when fired. +/// `.adeDeepLinkRequested` and flip their selection when fired; new +/// cross-machine shapes (lane / repo / extended pr / linear-issue) instead +/// post `.adeSendToMacRequested` so the parent view can show a "Send to your +/// Mac" confirmation card. /// /// Kept intentionally tiny — the heavy lifting lives in the individual tabs. @MainActor @@ -12,8 +15,15 @@ final class DeepLinkRouter { private init() {} /// Parse and dispatch an incoming URL or synthesised deep-link from a - /// notification response. Supports `ade://session/` and `ade://pr/` - /// today; unknown hosts are ignored rather than crashing on malformed input. + /// notification response. Supports the legacy `ade://session/` and + /// `ade://pr/` forms plus the four new desktop-originated shapes: + /// + /// * `ade://lane/` + /// * `ade://repo///branch/` + /// * `ade://pr///` + /// * `ade://linear-issue/[?branch=]` + /// + /// Unknown hosts are ignored rather than crashing on malformed input. func handle(_ url: URL) { guard url.scheme?.lowercased() == "ade" else { return } let host = url.host?.lowercased() @@ -23,8 +33,45 @@ final class DeepLinkRouter { guard let sessionId = pathComponents.first, !sessionId.isEmpty else { return } post(kind: "session", identifier: sessionId) case "pr": + // Two accepted shapes today: + // `ade://pr/` (legacy widget/live-activity) + // `ade://pr///` (desktop cross-machine form) + // Anything else is ignored so a malformed link can't crash navigation. + if pathComponents.count >= 3 { + let raw = pathComponents[2] + guard !raw.isEmpty else { return } + post(kind: "pr", identifier: raw) + return + } guard let raw = pathComponents.first, !raw.isEmpty else { return } post(kind: "pr", identifier: raw) + case "lane": + // Lanes are a local-only desktop concept — the iOS client has no + // counterpart UI, so we surface a "Send to your Mac" card instead of + // trying to navigate. + guard let laneId = pathComponents.first, !laneId.isEmpty else { return } + postSendToMac(url: url) + case "repo": + // `ade://repo///branch/` — also cross-machine. + // We validate the shape so a stray `ade://repo/foo` doesn't trigger + // an empty send-to-mac sheet. + guard pathComponents.count >= 4, + pathComponents[2].lowercased() == "branch", + !pathComponents[0].isEmpty, + !pathComponents[1].isEmpty, + !pathComponents[3].isEmpty + else { return } + postSendToMac(url: url) + case "linear-issue": + // `ade://linear-issue/[?branch=]` — Linear "Open in + // coding tool" hand-off. Resolving the issue to a lane requires the + // workspace's `lane.linearIssue` mapping, which only the desktop has, + // so we bounce the link to the paired Mac. We validate the identifier + // shape so a stray `ade://linear-issue/` doesn't pop an empty sheet. + guard let identifier = pathComponents.first, + !identifier.isEmpty + else { return } + postSendToMac(url: url) default: return } @@ -67,6 +114,18 @@ final class DeepLinkRouter { } } + /// Cross-machine deep links (lane / repo-branch / linear-issue) post on the + /// send-to-mac channel so the presentation layer can pop the confirmation + /// card. We pass the raw URL through so the card can render the target + /// plainly without the router needing to know about each shape. + private func postSendToMac(url: URL) { + NotificationCenter.default.post( + name: .adeSendToMacRequested, + object: nil, + userInfo: ["url": url.absoluteString] + ) + } + /// PR deep links carry either a numeric PR number (from `ade://pr/` /// widget/live-activity URLs) or a stable `prId` (from notification payloads /// that include both). Resolve the number to the matching `prId` via the @@ -95,4 +154,9 @@ extension Notification.Name { /// Posted by `DeepLinkRouter` so navigation views can switch tabs and push /// detail destinations without referencing the router directly. static let adeDeepLinkRequested = Notification.Name("ade.deepLinkRequested") + + /// Posted by `DeepLinkRouter` for cross-machine deep links (lane / repo / + /// branch / linear-issue) that the mobile client can't open directly. + /// `userInfo["url"]` carries the original `ade://...` URL string. + static let adeSendToMacRequested = Notification.Name("ade.sendToMacRequested") } diff --git a/apps/ios/ADE/Services/SyncService.swift b/apps/ios/ADE/Services/SyncService.swift index 592a92ffd..d59709767 100644 --- a/apps/ios/ADE/Services/SyncService.swift +++ b/apps/ios/ADE/Services/SyncService.swift @@ -7364,6 +7364,10 @@ enum RemoteCommandKind: String, Sendable { case retryPrChecks case openPr case setMutePush + /// Ask the paired desktop to open an `ade://...` deep link locally. + /// Used when iOS encounters a link it can't render itself (lane, repo + /// branch, owner/repo PR) and the user wants to bounce it to their Mac. + case openDeeplink } extension SyncService: LiveActivityHost { @@ -7482,6 +7486,7 @@ extension SyncService { case .retryPrChecks: action = "prs.rerunChecks" case .openPr: action = "prs.getDetail" case .setMutePush: action = "notification_prefs" + case .openDeeplink: action = "deeplinks.open" } var args: [String: Any] = [:] @@ -7529,6 +7534,13 @@ extension SyncService { } else { args["muteUntil"] = NSNull() } + case .openDeeplink: + // Desktop `deeplinks.open` expects a `url` arg — the same `ade://...` + // string the user tapped on iOS. We pass it through verbatim so the + // host can reuse its own router. + if let url = payload["url"] as? String, !url.isEmpty { + args["url"] = url + } } // For now we send via the opaque command envelope — the desktop's diff --git a/apps/ios/ADE/Views/Deeplinks/SendToMacCard.swift b/apps/ios/ADE/Views/Deeplinks/SendToMacCard.swift new file mode 100644 index 000000000..17ce19070 --- /dev/null +++ b/apps/ios/ADE/Views/Deeplinks/SendToMacCard.swift @@ -0,0 +1,317 @@ +import SwiftUI + +/// Categorises an `ade://...` URL that iOS can't open natively so the +/// `SendToMacCard` can render a short, human description ("Lane shared with +/// you" / "Branch feat-x in acme/widget" / "ADE-123") without re-parsing the +/// URL inside the view body. +struct SendToMacTarget: Equatable, Identifiable { + enum Kind: Equatable { + case lane(id: String) + case repoBranch(owner: String, repo: String, branch: String) + case linearIssue(identifier: String, branch: String?) + case other + } + + var url: URL + var kind: Kind + + /// `Identifiable` conformance powers the `.sheet(item:)` binding in + /// `ADEApp`; using the URL string keeps repeat shares of the same link + /// from spawning duplicate sheets. + var id: String { url.absoluteString } + + /// Best-effort parse of an `ade://...` URL. Unknown shapes fall back to + /// `.other` so the card can still render a generic "Open this on your Mac" + /// message rather than refusing to display. + init(url: URL) { + self.url = url + let host = url.host?.lowercased() + let parts = url.pathComponents.filter { $0 != "/" } + switch host { + case "lane": + if let id = parts.first, !id.isEmpty { + self.kind = .lane(id: id) + } else { + self.kind = .other + } + case "repo": + if parts.count >= 4, + parts[2].lowercased() == "branch", + !parts[0].isEmpty, + !parts[1].isEmpty, + !parts[3].isEmpty { + self.kind = .repoBranch(owner: parts[0], repo: parts[1], branch: parts[3]) + } else { + self.kind = .other + } + case "linear-issue": + // `ade://linear-issue/[?branch=]` — Linear hand-off. + // The branch hint is optional; the desktop resolves the actual lane + // via its `lane.linearIssue` mapping. + if let identifier = parts.first, !identifier.isEmpty { + let branchHint = URLComponents(url: url, resolvingAgainstBaseURL: false)? + .queryItems? + .first(where: { $0.name == "branch" })? + .value? + .trimmingCharacters(in: .whitespacesAndNewlines) + let normalisedBranch = (branchHint?.isEmpty == false) ? branchHint : nil + self.kind = .linearIssue(identifier: identifier, branch: normalisedBranch) + } else { + self.kind = .other + } + default: + self.kind = .other + } + } + + var headline: String { + switch kind { + case .lane: return "Lane shared with you" + case .repoBranch(_, _, _): return "Branch shared with you" + case .linearIssue: return "Linear issue shared with you" + case .other: return "Shared from your Mac" + } + } + + var detail: String { + switch kind { + case .lane(let id): + return "Lane \(shortenedLaneId(id))" + case .repoBranch(let owner, let repo, let branch): + return "Branch \(branch) in \(owner)/\(repo)" + case .linearIssue(let identifier, let branch): + if let branch, !branch.isEmpty { + return "\(identifier) on \(branch)" + } + return identifier + case .other: + return url.absoluteString + } + } + + private func shortenedLaneId(_ id: String) -> String { + // Lane ids are UUIDs (36 chars); show only the leading segment so the + // detail line stays readable on small screens. + guard id.count > 8, let dash = id.firstIndex(of: "-") else { return id } + return String(id[.. Void + + @EnvironmentObject private var syncService: SyncService + @State private var isSending = false + @State private var sendCompleted = false + + var body: some View { + VStack(spacing: 20) { + header + targetCard + machineRow + actions + } + .padding(.horizontal, 20) + .padding(.top, 22) + .padding(.bottom, 24) + .background(ADEColor.surfaceBackground) + .presentationDetents([.medium]) + .presentationDragIndicator(.visible) + } + + private var header: some View { + VStack(spacing: 6) { + Image(systemName: "laptopcomputer.and.arrow.down") + .font(.system(size: 30, weight: .semibold)) + .foregroundStyle(ADEColor.accent) + .padding(.bottom, 4) + + Text("Open on your Mac") + .font(.system(.title3, design: .rounded).weight(.semibold)) + .foregroundStyle(ADEColor.textPrimary) + .multilineTextAlignment(.center) + + Text("This link works best on the desktop app. Send it to your paired Mac and it'll open there.") + .font(.system(.footnote, design: .rounded)) + .foregroundStyle(ADEColor.textSecondary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + } + } + + private var targetCard: some View { + HStack(alignment: .top, spacing: 12) { + Image(systemName: targetSymbol) + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(ADEColor.accent) + .frame(width: 34, height: 34) + .background(ADEColor.accent.opacity(0.15), in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + + VStack(alignment: .leading, spacing: 4) { + Text(target.headline) + .font(.system(.subheadline, design: .rounded).weight(.semibold)) + .foregroundStyle(ADEColor.textPrimary) + Text(target.detail) + .font(.system(.footnote, design: .monospaced)) + .foregroundStyle(ADEColor.textSecondary) + .lineLimit(2) + .truncationMode(.middle) + } + Spacer(minLength: 0) + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(ADEColor.cardBackground, in: RoundedRectangle(cornerRadius: 10, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .stroke(ADEColor.border, lineWidth: 1) + ) + } + + private var targetSymbol: String { + switch target.kind { + case .lane: return "square.stack.3d.up" + case .repoBranch: return "arrow.triangle.branch" + case .linearIssue: return "smallcircle.filled.circle" + case .other: return "link" + } + } + + private var machineRow: some View { + HStack(spacing: 10) { + Circle() + .fill(machineTint) + .frame(width: 8, height: 8) + Image(systemName: "desktopcomputer") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(ADEColor.textSecondary) + VStack(alignment: .leading, spacing: 1) { + Text(machineLabel) + .font(.system(.footnote, design: .rounded).weight(.semibold)) + .foregroundStyle(ADEColor.textPrimary) + .lineLimit(1) + if let secondary = machineSecondaryLabel { + Text(secondary) + .font(.system(.caption2, design: .rounded)) + .foregroundStyle(ADEColor.textMuted) + .lineLimit(1) + } + } + Spacer(minLength: 0) + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + .background(ADEColor.recessedBackground, in: RoundedRectangle(cornerRadius: 10, style: .continuous)) + } + + /// Display name for the paired Mac. Prefers the live `hostName` published + /// by `SyncService`, falls back to a placeholder when no machine is + /// attached so the card still reads correctly. The user can still try to + /// send; the queueing path inside `SyncService` will surface the offline + /// case in its own way. + private var machineLabel: String { + let trimmed = syncService.hostName?.trimmingCharacters(in: .whitespacesAndNewlines) + if let trimmed, !trimmed.isEmpty { + return trimmed + } + // TODO: thread the paired-device record through here once SyncService + // exposes a richer "last paired" identity; today `hostName` is the only + // stable display string we have. + return "Your Mac" + } + + private var machineSecondaryLabel: String? { + switch syncService.connectionState { + case .connected, .syncing: + return "Connected" + case .connecting: + return "Connecting…" + case .error: + return "Last seen offline" + case .disconnected: + return "Offline — we'll deliver when it's back" + } + } + + private var machineTint: Color { + switch syncService.connectionState { + case .connected, .syncing: return ADEColor.success + case .connecting: return ADEColor.warning + case .error: return ADEColor.danger + case .disconnected: return ADEColor.textMuted + } + } + + private var actions: some View { + VStack(spacing: 10) { + Button { + Task { await sendToMac() } + } label: { + HStack(spacing: 8) { + if isSending { + ProgressView() + .progressViewStyle(.circular) + .controlSize(.small) + .tint(.white) + } else if sendCompleted { + Image(systemName: "checkmark") + .font(.system(size: 14, weight: .semibold)) + } else { + Image(systemName: "paperplane.fill") + .font(.system(size: 14, weight: .semibold)) + } + Text(sendButtonTitle) + .font(.system(.body, design: .rounded).weight(.semibold)) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 13) + .foregroundStyle(.white) + .background(ADEColor.accent, in: RoundedRectangle(cornerRadius: 10, style: .continuous)) + } + .buttonStyle(.plain) + .disabled(isSending || sendCompleted) + + Button(action: onDismiss) { + Text(sendCompleted ? "Done" : "Cancel") + .font(.system(.body, design: .rounded).weight(.medium)) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .foregroundStyle(ADEColor.textPrimary) + .background(ADEColor.cardBackground, in: RoundedRectangle(cornerRadius: 10, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .stroke(ADEColor.border, lineWidth: 1) + ) + } + .buttonStyle(.plain) + } + } + + private var sendButtonTitle: String { + if isSending { return "Sending…" } + if sendCompleted { return "Sent" } + return "Send to Mac" + } + + private func sendToMac() async { + guard let sync = SyncService.shared else { + // Without a SyncService singleton there's nothing to dispatch to; + // collapse to the dismiss path so the user isn't stuck. + onDismiss() + return + } + isSending = true + await sync.sendRemoteCommand(.openDeeplink, payload: ["url": target.url.absoluteString]) + isSending = false + sendCompleted = true + // Brief confirmation pause so the user sees the "Sent" state before the + // sheet collapses; 0.6s is short enough not to feel sluggish. + try? await Task.sleep(nanoseconds: 600_000_000) + onDismiss() + } +} diff --git a/apps/web/api/open.ts b/apps/web/api/open.ts new file mode 100644 index 000000000..27e3f9ef4 --- /dev/null +++ b/apps/web/api/open.ts @@ -0,0 +1,245 @@ +/** + * Vercel serverless function for the /open route. Serves the SPA shell with + * OpenGraph + Twitter tags rewritten from query params so chat-app unfurlers + * (Slack, Discord, iMessage, Gmail, Linear) show a rich card without needing + * to execute JavaScript. + * + * The SPA still hydrates and refines tags client-side via OpenPage's + * useEffect; this function just bakes the initial HTML so unfurlers see + * useful content on first byte. + * + * The function self-fetches `/index.html` rather than reading from + * `process.cwd()` because Vercel does not bundle the static-asset build + * output (`dist/`) into serverless-function code. The catch-all SPA rewrite + * in vercel.json explicitly excludes paths containing a dot, so `/index.html` + * is served as a flat static asset and won't loop back into this function. + */ + +type Vercel = { + query: Record; +}; + +type VercelReq = { + query: Vercel["query"]; + url?: string; + headers: Record; +}; + +type VercelRes = { + status: (code: number) => VercelRes; + setHeader: (name: string, value: string) => void; + send: (body: string) => void; + end: () => void; +}; + +type OpenTarget = + | { kind: "lane"; laneId: string } + | { kind: "branch"; repo: string; branch: string; pr?: number } + | { kind: "pr"; repo: string; number: number } + | { kind: "linear-issue"; issue: string; branch?: string } + | { kind: "unknown" }; + +// Fallback HTML used when the self-fetch of /index.html fails for any +// reason (network error, hostname missing). It's a complete page with the +// right OG meta tags + a small script that triggers a soft redirect to "/" +// so a real browser still lands on the SPA root. +function fallbackShell(title: string, description: string, canonical: string): string { + const esc = (v: string) => escapeHtmlAttr(v); + return ` + + + + +${esc(title)} + + + + + + + + + + + + + +`; +} + +function pickQuery(value: string | string[] | undefined): string { + if (Array.isArray(value)) return value[0] ?? ""; + return value ?? ""; +} + +function parseTarget(query: Vercel["query"]): OpenTarget { + const type = pickQuery(query.type).toLowerCase(); + if (type === "lane") { + const laneId = pickQuery(query.id); + if (laneId) return { kind: "lane", laneId }; + } + if (type === "branch") { + const repo = pickQuery(query.repo); + const branch = pickQuery(query.branch); + if (repo && branch) { + const prRaw = pickQuery(query.pr); + const pr = prRaw ? Number(prRaw) : undefined; + return Number.isInteger(pr) && pr! > 0 + ? { kind: "branch", repo, branch, pr } + : { kind: "branch", repo, branch }; + } + } + if (type === "pr") { + const repo = pickQuery(query.repo); + const numberRaw = pickQuery(query.number); + const num = Number(numberRaw); + if (repo && Number.isInteger(num) && num > 0) { + return { kind: "pr", repo, number: num }; + } + } + if (type === "linear-issue") { + const issue = pickQuery(query.issue); + if (issue) { + const branch = pickQuery(query.branch); + return branch ? { kind: "linear-issue", issue, branch } : { kind: "linear-issue", issue }; + } + } + return { kind: "unknown" }; +} + +function describe(target: OpenTarget): { title: string; description: string } { + switch (target.kind) { + case "lane": + return { + title: "Open lane in ADE", + description: `Open this ADE lane (${target.laneId.slice(0, 8)}…) on your desktop.`, + }; + case "branch": + return { + title: `${target.repo} · ${target.branch} — Open in ADE`, + description: target.pr + ? `Branch ${target.branch} (PR #${target.pr}) shared via ADE. One click to spin up a lane.` + : `Branch ${target.branch} shared via ADE. One click to spin up a lane.`, + }; + case "pr": + return { + title: `${target.repo} #${target.number} — Open in ADE`, + description: `Pull request #${target.number} in ${target.repo}. Open it in your ADE desktop app.`, + }; + case "linear-issue": + return { + title: `${target.issue} — Open in ADE`, + description: target.branch + ? `Linear issue ${target.issue} on branch ${target.branch}. Click to open in ADE.` + : `Open Linear issue ${target.issue} in ADE.`, + }; + case "unknown": + return { + title: "Open in ADE", + description: "ADE is a local-first desktop app for orchestrating parallel AI coding agents.", + }; + } +} + +function escapeHtmlAttr(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function rewriteOg(template: string, target: OpenTarget, url: string): string { + const { title, description } = describe(target); + const escTitle = escapeHtmlAttr(title); + const escDesc = escapeHtmlAttr(description); + const escUrl = escapeHtmlAttr(url); + + return template + // element — match any whitespace/text inside, single-line only. + .replace(/<title>[^<]*<\/title>/i, `<title>${escTitle}`) + // — attribute order may vary; tolerate single + // OR double quotes, and self-closing OR open-ended form. + .replace( + //i, + ``, + ) + .replace( + //i, + ``, + ) + .replace( + //i, + ``, + ) + .replace( + //i, + ``, + ) + .replace( + //i, + ``, + ) + .replace( + //i, + ``, + ); +} + +function resolveSelfOrigin(req: VercelReq): string | null { + const headerHost = req.headers["x-forwarded-host"] ?? req.headers.host; + const host = Array.isArray(headerHost) ? headerHost[0] : headerHost; + if (!host) return null; + // Vercel sets x-forwarded-proto; default to https because all Vercel + // deployments serve HTTPS and the function may be invoked from edges + // that strip the protocol header. + const proto = req.headers["x-forwarded-proto"]; + const scheme = Array.isArray(proto) ? proto[0] : (proto ?? "https"); + return `${scheme}://${host}`; +} + +async function loadShellFromSelf(req: VercelReq): Promise { + const origin = resolveSelfOrigin(req); + if (!origin) return null; + try { + const res = await fetch(`${origin}/index.html`, { + // Cache the SPA shell for 60s edge-to-edge — index.html changes only + // on deploy. + headers: { "cache-control": "max-age=60" }, + }); + if (!res.ok) return null; + return await res.text(); + } catch { + return null; + } +} + +export default async function handler(req: VercelReq, res: VercelRes): Promise { + const target = parseTarget(req.query); + const search = (req.url ?? "").includes("?") ? `?${(req.url ?? "").split("?")[1]}` : ""; + const canonical = `https://ade.app/open${search}`; + const { title, description } = describe(target); + + res.setHeader("Content-Type", "text/html; charset=utf-8"); + // Allow CDN caching for 10 minutes; OG params are deterministic so identical + // URLs are cheap. Serve a stale response while revalidating for a day. + res.setHeader("Cache-Control", "public, max-age=600, stale-while-revalidate=86400"); + + const shell = await loadShellFromSelf(req); + if (shell) { + res.status(200).send(rewriteOg(shell, target, canonical)); + return; + } + // Degraded mode: inline shell with OG + a soft redirect so the user still + // lands on the SPA root. Unfurlers see the OG card; humans hit "/". + res.status(200).send(fallbackShell(title, description, canonical)); +} + +// Exported for unit testing. +export { + describe as describeOpenTarget, + fallbackShell, + parseTarget as parseOpenTarget, + rewriteOg, +}; diff --git a/apps/web/api/tsconfig.json b/apps/web/api/tsconfig.json new file mode 100644 index 000000000..47283f3fc --- /dev/null +++ b/apps/web/api/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2022"], + "types": ["node"], + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "skipLibCheck": true, + "isolatedModules": true, + "resolveJsonModule": true, + "baseUrl": "." + }, + "include": ["./**/*.ts"] +} diff --git a/apps/web/src/app/SiteRoutes.tsx b/apps/web/src/app/SiteRoutes.tsx index efaf9f660..0afa71f0a 100644 --- a/apps/web/src/app/SiteRoutes.tsx +++ b/apps/web/src/app/SiteRoutes.tsx @@ -3,6 +3,7 @@ import { Route, Routes, useLocation } from "react-router-dom"; import { AnimatePresence } from "framer-motion"; import { HomePage } from "./pages/HomePage"; import { DownloadPage } from "./pages/DownloadPage"; +import { OpenPage } from "./pages/OpenPage"; import { PrivacyPage } from "./pages/PrivacyPage"; import { TermsPage } from "./pages/TermsPage"; import { NotFoundPage } from "./pages/NotFoundPage"; @@ -39,6 +40,7 @@ export function SiteRoutes() { } /> } /> + } /> } /> } /> } /> diff --git a/apps/web/src/app/pages/OpenPage.tsx b/apps/web/src/app/pages/OpenPage.tsx new file mode 100644 index 000000000..b55fb2384 --- /dev/null +++ b/apps/web/src/app/pages/OpenPage.tsx @@ -0,0 +1,177 @@ +import { useEffect, useMemo, useState } from "react"; +import { useLocation } from "react-router-dom"; +import { ArrowUpRight, Download, GitBranch } from "lucide-react"; + +import { LinkButton } from "../../components/LinkButton"; +import { Page } from "../../components/Page"; +import { Section } from "../../components/Section"; +import { useDocumentTitle } from "../../lib/useDocumentTitle"; + +type OpenTarget = + | { kind: "lane"; laneId: string } + | { kind: "branch"; repo: string; branch: string; pr?: number } + | { kind: "pr"; repo: string; number: number } + | { kind: "unknown" }; + +function parseQuery(search: string): OpenTarget { + const params = new URLSearchParams(search); + const type = params.get("type"); + if (type === "lane") { + const laneId = params.get("id") ?? ""; + if (laneId) return { kind: "lane", laneId }; + } + if (type === "branch") { + const repo = params.get("repo") ?? ""; + const branch = params.get("branch") ?? ""; + if (repo && branch) { + const prRaw = params.get("pr"); + const pr = prRaw ? Number(prRaw) : undefined; + return Number.isInteger(pr) && pr! > 0 ? { kind: "branch", repo, branch, pr } : { kind: "branch", repo, branch }; + } + } + if (type === "pr") { + const repo = params.get("repo") ?? ""; + const number = Number(params.get("number") ?? ""); + if (repo && Number.isInteger(number) && number > 0) { + return { kind: "pr", repo, number }; + } + } + return { kind: "unknown" }; +} + +function buildAdeUrl(target: OpenTarget): string | null { + switch (target.kind) { + case "lane": + return `ade://lane/${encodeURIComponent(target.laneId)}`; + case "branch": { + const [owner, name] = target.repo.split("/"); + if (!owner || !name) return null; + const branchSegments = target.branch + .split("/") + .map(encodeURIComponent) + .join("/"); + const base = `ade://repo/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/branch/${branchSegments}`; + return target.pr ? `${base}?pr=${target.pr}` : base; + } + case "pr": { + const [owner, name] = target.repo.split("/"); + if (!owner || !name) return null; + return `ade://pr/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/${target.number}`; + } + case "unknown": + return null; + } +} + +function describeTarget(target: OpenTarget): { title: string; summary: string } { + switch (target.kind) { + case "lane": + return { + title: "Open lane in ADE", + summary: `Lane ${target.laneId.slice(0, 8)}…`, + }; + case "branch": + return { + title: `Open ${target.repo} in ADE`, + summary: `Branch ${target.branch}${target.pr ? ` · PR #${target.pr}` : ""}`, + }; + case "pr": + return { + title: `Open ${target.repo}#${target.number} in ADE`, + summary: `Pull request #${target.number}`, + }; + case "unknown": + return { + title: "Open in ADE", + summary: "Missing or invalid link parameters.", + }; + } +} + +function setOgTag(property: string, content: string): void { + if (typeof document === "undefined") return; + let el = document.querySelector(`meta[property="${property}"]`); + if (!el) { + el = document.createElement("meta"); + el.setAttribute("property", property); + document.head.appendChild(el); + } + el.setAttribute("content", content); +} + +export function OpenPage() { + const location = useLocation(); + const target = useMemo(() => parseQuery(location.search), [location.search]); + const adeUrl = useMemo(() => buildAdeUrl(target), [target]); + const { title, summary } = describeTarget(target); + const [launchAttempted, setLaunchAttempted] = useState(false); + + useDocumentTitle(title); + + // Set dynamic OpenGraph tags so unfurlers that execute JS pick them up. + // CAVEAT: most chat-app unfurlers (Slack, Discord, iMessage) do NOT execute + // JS; for full unfurl coverage the apps/web build should be migrated to a + // static-site pre-render step that bakes per-route OG tags into the served + // HTML. og:image intentionally omitted until an actual asset exists at + // /og/open.png. + useEffect(() => { + setOgTag("og:title", title); + setOgTag("og:description", summary); + setOgTag("og:type", "website"); + setOgTag("og:url", `https://ade.app/open${location.search}`); + }, [title, summary, location.search]); + + // Try the ade:// upgrade automatically. If the OS routes the URL away from + // this page we won't see the result; if it stays here, we show the fallback. + useEffect(() => { + if (!adeUrl) return; + setLaunchAttempted(true); + const t = setTimeout(() => { + try { + window.location.href = adeUrl; + } catch { + // ignore — fallback already visible + } + }, 250); + return () => clearTimeout(t); + }, [adeUrl]); + + return ( + +
+
+
+ ADE deeplink +
+

{title}

+

{summary}

+ {target.kind === "unknown" ? ( +
+ This link is missing required parameters. Verify the URL or generate a fresh link from ADE. +
+ ) : null} + {adeUrl ? ( +
+
+ {launchAttempted + ? "Trying to open ADE — if nothing happens, ADE may not be installed." + : "Click below to launch ADE."} +
+
+ + Open in ADE + + + Install ADE + +
+
+ ) : null} +
+
+
+ ); +} diff --git a/apps/web/vercel.json b/apps/web/vercel.json index 4a139a771..5ac6ebe17 100644 --- a/apps/web/vercel.json +++ b/apps/web/vercel.json @@ -9,6 +9,10 @@ "source": "/docs/:match*", "destination": "https://ade-ac1c6011.mintlify.dev/docs/:match*" }, + { + "source": "/open", + "destination": "/api/open" + }, { "source": "/((?!.*\\.).*)", "destination": "/index.html" diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 7be7987e4..f34ca9a58 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -164,7 +164,9 @@ Native SwiftUI app acting as a controller. It pairs with a runtime daemon over W ### 2.5 Web app (`apps/web/`) -A Vite/React SPA that serves the public marketing site and download page. Four pages: `HomePage`, `DownloadPage`, `PrivacyPage`, `TermsPage`. Independent package (`ade-web`), deployed via Vercel (`apps/web/vercel.json`). Not a runtime dependency of the desktop app. Shared-origin with the Mintlify docs site (`docs.json` at repo root). +A Vite/React SPA that serves the public marketing site, download page, and the deeplink landing page. Five pages: `HomePage`, `DownloadPage`, `OpenPage`, `PrivacyPage`, `TermsPage`. Independent package (`ade-web`), deployed via Vercel (`apps/web/vercel.json`). Not a runtime dependency of the desktop app. Shared-origin with the Mintlify docs site (`docs.json` at repo root). + +The `/open` route is the HTTPS half of the ADE deeplink scheme (`https://ade.app/open?type=...&...`). `apps/web/api/open.ts` is a Vercel serverless function that self-fetches `index.html`, rewrites OpenGraph + Twitter meta tags from the query params so chat-app unfurlers (Slack, Discord, iMessage, Gmail, Linear) show a rich card without executing JavaScript, then hands the SPA over to `OpenPage` which attempts the `ade://` upgrade in the browser and falls back to an install/marketing card if no handler is registered. See [features/deeplinks/README.md](./features/deeplinks/README.md). --- @@ -190,7 +192,8 @@ Schema bootstrap in `kvDb.ts` creates ~103 tables. Anchor tables for agents read |-------|---------| | `projects` | One row per opened repo. Keyed by `root_path`. | | `lanes` | Worktree-backed units of work. Types: `primary`, `worktree`, `attached`. Supports parent/child stacks, mission binding, color/icon/tags. | -| `terminal_sessions` | Tracked PTY sessions per lane with transcript path and head SHAs. The `chat_session_id` column (indexed) marks terminals owned by a chat (chat terminal drawer, App Control launch terminal); `ptyService` exposes them through the `ade.terminal.*` IPC and the `terminal` ADE action domain. | +| `terminal_sessions` | Tracked PTY sessions per lane with transcript path and head SHAs. The `chat_session_id` column (indexed) marks terminals owned by a chat (chat terminal drawer, App Control launch terminal); `ptyService` exposes them through the `ade.terminal.*` IPC and the `terminal` ADE action domain. The `owner_pid` column (indexed) identifies the ADE OS process that owns the live runtime for the row — cross-process reconcile/dispose paths check it before sweeping so concurrent surfaces don't mark each other's live sessions dead. See §3.5. | +| `runtime_processes` | Process-liveness registry. Every ADE process (desktop main, TUI runtime, `ade serve` daemon) inserts a row on boot keyed by `pid` and refreshes `last_seen` on a 5 s heartbeat. Reconcile / dispose paths cross-reference `terminal_sessions.owner_pid` against the live rows in this table to tell "row whose owner crashed" from "row a sibling process is actively managing." See §3.5. | | `session_deltas` | Post-session diff stats + touched files + failure lines. Input to pack generation. | | `operations` | Audit log of every significant mutation (git, pack updates). Pre/post HEAD SHAs enable undo. | | `process_definitions` / `process_runtime` / `process_runs` | Managed-process lifecycle (derived from `ade.yaml`). | @@ -256,7 +259,22 @@ Types for these tables are split into domain modules under `apps/desktop/src/sha - `mode: "auto"` (the default for `openProject`) keeps the project local-only when no shared scaffold files exist yet — it ensures `.git/info/exclude` has a `.ade/` entry so a brand-new clone or a personal-only setup never accidentally promotes runtime state into git, and only flips to the shared layout when shared scaffold files are already present (or after a save call promotes them). - `mode: "local"` is reserved for force-local repair flows. -### 3.4 Migration strategy +### 3.4 Cross-process ownership + +ADE is a multi-process system on a single machine: the desktop main process, the `ade serve` daemon, and any number of TUI runtimes can all be live against the same project DB simultaneously. To prevent one process from disposing or reconciling another's live PTYs and SDK sessions, every long-lived row gets an `owner_pid` and every process maintains a heartbeat in `runtime_processes`. + +`apps/desktop/src/main/services/runtime/processRegistryService.ts` is the per-process registrar. + +- On `start()` it inserts/refreshes its own row in `runtime_processes` (`pid`, `role`, optional `projectRoot`, `startedAt`, `lastSeen`) and runs an idempotent `pruneStale()` over rows older than 10× the liveness window. +- A 5 s heartbeat (`heartbeatIntervalMs`, configurable) writes `last_seen` so siblings can see this process is alive. The interval `unref()`s so it never blocks shutdown. +- Liveness checks (`isPidLive(pid)`, `listLivePids()`) consider a row live when `last_seen` is within `livenessWindowMs` (default 15 s = 3× heartbeat) so a single missed heartbeat doesn't false-positive a sibling as dead. The registrar's own pid is always reported as live. +- `stop()` clears its row outright on graceful shutdown so siblings don't have to wait the liveness window to free up ownership. + +`ptyService.create()` records `processRegistry.pid` on the new `terminal_sessions` row's `owner_pid`. `sessionService.reconcileStaleRunningSessions()` accepts the registry's `listLivePids()` set and skips any row whose `owner_pid` is in it — only orphaned rows whose owner crashed or exited get swept to `disposed`. Dispose paths run the same check before tearing down runtimes a sibling still manages. + +Roles are open-ended strings; today's vocabulary is `desktop-main`, `ade-serve-daemon`, and `tui-runtime`. The desktop main process constructs the registry in `main.ts` and threads it into `ptyService`, `sessionService`, and reconcile callers via the per-project context. + +### 3.5 Migration strategy - Schema is defined idempotently — `CREATE TABLE IF NOT EXISTS` + `CREATE INDEX IF NOT EXISTS`. - One-time schema-compat migration at startup: retrofits `NOT NULL` on PKs and strips UNIQUE/FK constraints incompatible with cr-sqlite CRRs. A pre-cr-sqlite backup (`.pre-crsqlite-w1.bak`) is written on first CRR enablement. @@ -366,7 +384,7 @@ Related feature docs: [Chat](./features/chat/README.md), [Agents](./features/age `apps/desktop/src/shared/ipc.ts` defines the single `IPC` const with ~550 named channel strings in a `ade..` namespace: ``` -ade.app.* # app lifecycle, clipboard text and image (writeClipboardText, writeClipboardImage, saveClipboardImageAttachment), paths, image data-URL preview (getImageDataUrl) +ade.app.* # app lifecycle, clipboard text and image (writeClipboardText, writeClipboardImage, saveClipboardImageAttachment), paths, image data-URL preview (getImageDataUrl), and the deeplink navigation push channel ade.app.navigate (AppNavigationRequest payloads from the ade:// protocol handler, the ade code app/navigate JSON-RPC, and the iOS deeplinks.open sync command — see features/deeplinks/README.md) ade.project.* # project open/close/switch/state, in-app directory browser (browseDirectories, getDetail), favicon resolver (resolveIcon) ade.onboarding.* ade.lanes.* # lane list/create/delete/stack/template/env/port/proxy/rebase @@ -463,7 +481,8 @@ Most services described here live under `apps/desktop/src/main/services/ | `computerUse/` | `computerUseArtifactBrokerService.ts`, `controlPlane.ts`, `localComputerUse.ts`, `agentBrowserArtifactAdapter.ts`, `syntheticToolResult.ts` | Proof-artifact broker (ingests, owner links, review state, routing), control-plane snapshot helpers, macOS capture capability descriptor, agent-browser payload parser, and the synthetic-tool-result helper used by the Claude compaction path. `proofObserver.ts` was removed in the rebuild — there is no passive auto-ingest. | | `config/` | `projectConfigService.ts`, `laneOverlayMatcher.ts` | Load/save `.ade/ade.yaml` + `local.yaml`; trust enforcement; lane overlays. | | `conflicts/` | `conflictService.ts` | Pairwise dry-merge simulation, risk matrix, proposal generation. | -| `cto/` | `ctoStateService.ts`, `workerAgentService.ts`, `workerBudgetService.ts`, `workerHeartbeatService.ts`, `linearSyncService.ts`, `linearIngressService.ts`, `linearOAuthService.ts`, `linearRoutingService.ts`, `linearDispatcherService.ts`, `linearCloseoutService.ts`, `flowPolicyService.ts` | CTO identity + core memory; worker agents; Linear sync/ingress/OAuth/routing/dispatcher/closeout. | +| `cto/` | `ctoStateService.ts`, `workerAgentService.ts`, `workerBudgetService.ts`, `workerHeartbeatService.ts`, `linearSyncService.ts`, `linearIngressService.ts`, `linearOAuthService.ts`, `linearRoutingService.ts`, `linearDispatcherService.ts`, `linearCloseoutService.ts`, `flowPolicyService.ts`, `linearLaneCardService.ts` | CTO identity + core memory; worker agents; Linear sync/ingress/OAuth/routing/dispatcher/closeout. `linearLaneCardService` posts the Linear attachment card and builds the cross-machine ADE deeplink that backs the card's URL. | +| `deeplinks/` | `protocolHandler.ts` | Registers the `ade://` OS protocol handler, owns the single-instance lock, buffers cold-start URLs until `app.whenReady()`, and dispatches parsed URLs through `IPC.appNavigate` to the focused window. Re-used by the iOS Send-to-Mac sync command (`syncRemoteCommandService.deeplinks.open`). Shared parser + builder live in `apps/desktop/src/shared/deeplinks.ts`; the PR "Open in ADE" footer is in `apps/desktop/src/shared/adeDeeplinkFooter.ts`. See [features/deeplinks/README.md](./features/deeplinks/README.md). | | `devTools/` | `devToolsService.ts` | Probe for git + `gh` CLI availability. | | `diffs/` | `diffService.ts` | Diff computation for file panes. | | `feedback/` | `feedbackReporterService.ts` | In-app feedback reporting. Two-stage: `prepareDraft` generates a structured issue title + labels (AI-assisted when a model is selected, deterministic fallback otherwise) so the user can review before posting; `submitPreparedDraft` files the GitHub issue. Each submission records `generationMode` and a `generationWarning` so the UI can flag deterministic drafts. | @@ -489,7 +508,7 @@ Most services described here live under `apps/desktop/src/main/services/ | `prs/` | `prService.ts`, `prPollingService.ts`, `prSummaryService.ts`, `queueLandingService.ts`, `issueInventoryService.ts`, `prIssueResolver.ts`, `prRebaseResolver.ts`, `integrationPlanning.ts`, `integrationValidation.ts` | PR CRUD, polling (with per-PR `last_polled_at` cursor), AI summary cache keyed by `(prId, head_sha)`, stacked-queue landing, issue inventory, AI-assisted resolution, integration planning, and merge-into-existing-lane proposal adoption. | | `pty/` | `ptyService.ts` | `node-pty` spawn, PTY I/O bridging, transcript writing. | | `remoteRuntime/` | `remoteTargetRegistry.ts`, `sshTransport.ts`, `remoteBootstrap.ts`, `remoteConnectionPool.ts`, `runtimeRpcClient.ts` | Saved SSH machines, ssh-agent/key based transport, first-connect runtime upload/version verification, remote project catalog, action dispatch, and reconnect/eviction for remote runtime bindings. | -| `runtime/` | `tempCleanupService.ts` | Runtime temp cleanup. | +| `runtime/` | `tempCleanupService.ts`, `processRegistryService.ts`, `machineStateMigration.ts` | Runtime temp cleanup. `processRegistryService` is the per-process heartbeat registrar against `runtime_processes` (see §3.4); reconcile/dispose paths in `sessionService` and `ptyService` consult it via `listLivePids()` / `isPidLive()` before sweeping `terminal_sessions` rows owned by sibling processes. `machineStateMigration` carries one-shot migrations of the per-machine state files under `~/.ade/`. | | `sessions/` | `sessionService.ts`, `sessionDeltaService.ts` | Terminal session CRUD, post-session delta computation. | | `shared/` | `utils.ts`, `queueRebase.ts`, `packLegacyUtils.ts`, `transcriptInsights.ts` | Cross-domain utilities. | | `state/` | `kvDb.ts`, `crsqliteExtension.ts`, `globalState.ts`, `projectState.ts`, `onConflictAudit.ts` | SQLite schema + open, CRR extension loader, global state file, per-project state init. `globalState.upsertRecentProject` accepts `preserveRecentOrder` so reactivating an already-known project (by app focus, deep link, etc.) refreshes its `lastOpenedAt` in place instead of jumping it to the front of the recents list. | @@ -1086,6 +1105,7 @@ Post-packaging hardening (`apps/desktop/scripts/`): - Multi-device sync and iOS · [Sync and Multi-device](./features/sync-and-multi-device/README.md) - Terminal sessions and Work · [Terminals and Sessions](./features/terminals-and-sessions/README.md) - Computer-use proof · [Computer Use](./features/computer-use/README.md) +- Deeplinks · [Deeplinks](./features/deeplinks/README.md) - Memory · [Memory](./features/memory/README.md) - Settings and onboarding · [Onboarding and Settings](./features/onboarding-and-settings/README.md) - Feature index · [features/](./features/) diff --git a/docs/features/ade-code/README.md b/docs/features/ade-code/README.md index 249038bca..91162b84e 100644 --- a/docs/features/ade-code/README.md +++ b/docs/features/ade-code/README.md @@ -23,7 +23,9 @@ Point Cursor’s browser inspector at the served page for layout debugging. The | `scripts/tui-web.mjs` | Dev browser mirror for `ade code`: ensures the dev runtime, spawns one PTY, serves xterm.js + WebSocket bridge (`npm run dev:code:web`). | | `apps/ade-cli/src/cli.ts` | Resolves the built or source TUI entry and forwards the parsed launch context to `runAdeCodeCli`. | | `apps/ade-cli/src/tuiClient/cli.tsx` | TUI entry: argv parsing, project discovery, connection bootstrap, Ink mount. Built to `apps/ade-cli/dist/tuiClient/cli.mjs`. | -| `apps/ade-cli/src/tuiClient/app.tsx` | Primary Ink/React surface: navigation, composer, drawers, right pane, session lifecycle, slash command dispatch. | +| `apps/ade-cli/src/tuiClient/app.tsx` | Primary Ink/React surface: navigation, composer, drawers, right pane, session lifecycle, slash command dispatch. Owns the `Ctrl+Y` "copy ADE deeplink" handler which resolves the focused lane / PR row through `buildDeeplinkForRow` and copies the canonical `ade://...` URL to the system clipboard. | +| `apps/ade-cli/src/tuiClient/deeplinkRow.ts` | Pure helper used by the `Ctrl+Y` keybinding. Maps the focused lane or PR row (including parsing a GitHub PR URL when the right pane only carries the URL) onto a `DeeplinkTarget` and returns the built `ade://` URL. Tested in `tuiClient/__tests__/deeplinkKeybind.test.ts`. | +| `apps/ade-cli/src/commands/deeplinks.ts` | `ade open`, `ade link`, and `ade linear install` subcommands. Shares the parser + builder with the desktop main process so URLs round-trip across both surfaces. See [features/deeplinks/README.md](../deeplinks/README.md). | | `apps/ade-cli/src/tuiClient/connection.ts` | Resolves attached vs embedded mode, runs the `ade/initialize` handshake, registers the project with `projects.add`, wraps subsequent requests with `projectId`. | | `apps/ade-cli/src/tuiClient/jsonRpcClient.ts` | Socket client: connect, request/response, `chat/event` notifications. | | `apps/ade-cli/src/tuiClient/adeApi.ts` | Typed wrappers over `AdeCodeConnection.action` / `actionList` for lanes, chat, models, navigation, provider readiness, API-key status, OpenCode diagnostics, project slash-command discovery, lane diff stats (`listLaneDiffStats`), per-lane PR summaries (`listPrsByLane`), the Claude steer family (`steerChatMessage`, `cancelSteerMessage`, `editSteerMessage`, `dispatchSteerMessage`), the provider-grouped model catalog (`getModelCatalog(args?: AgentChatModelCatalogArgs)` → `AgentChatModelCatalog`), and the cross-surface model-picker favorites / recents (`getModelPickerFavorites`, `toggleModelPickerFavorite`, `getModelPickerRecents`, `pushModelPickerRecent`) backed by the top-level `modelPicker.*` JSON-RPC methods on `adeRpcServer`. | @@ -241,9 +243,21 @@ After local changes, run `npm run build` inside `apps/ade-cli` so both `dist/cli - `/login` delegates only to provider CLIs that can authenticate in the current terminal: Claude (`claude auth login`), Codex (`codex login`), and OpenCode (`opencode auth login`). Cursor chat is `@cursor/sdk` and needs `CURSOR_API_KEY` or desktop Settings > AI Providers. Droid chat runs Factory Droid over ACP and needs `FACTORY_API_KEY` or Factory's interactive `droid` login. - The middle composer shows the selected provider, model, reasoning, and permission mode under the prompt so draft changes on the right are visible before the chat starts. +## Deeplinks (`ade open` / `ade link` / `ade linear install`) + +`ade code` exposes the ADE deeplink contract at three points: + +- **`Ctrl+Y`** over a highlighted lane or PR row in the drawer / right pane copies the canonical `ade://` URL to the system clipboard via `buildDeeplinkForRow` (`deeplinkRow.ts`). A toast confirms the copy or explains why the focused row can't be linked (e.g. no PR is attached to a chat preview). +- **`ade open `** invokes the OS opener on a validated `ade://` or `https://ade.app/open?...` URL, which routes back to the running desktop process (or starts it cold). The `--linear-issue --branch ` variant is what Linear's "Open issue in coding tool" entry passes; the desktop resolves the actual lane/repo from the active project. +- **`ade link …`** builds and clipboard-copies a deeplink for a lane / branch / PR / Linear issue. `--ade` emits the custom scheme, the default is the HTTPS form. `ade link ` round-trips a parsed URL into the chosen form. +- **`ade linear install`** writes `~/.linear/coding-tools.json` so Linear's "Open issue in coding tool" dropdown can launch `ade open --linear-issue ... --branch ...` directly. + +See [features/deeplinks/README.md](../deeplinks/README.md) for the full URL grammar, parser semantics, and the desktop / iOS / web sides of the protocol. + ## Related docs - [ADE CLI](../../../apps/ade-cli/README.md) — runtime daemon, install paths, service manager, full CLI surface. - [Chat feature](../chat/README.md) — in-app Work chat architecture (service + renderer); same agent chat backend. - [Remote runtime](../remote-runtime/README.md) — how the same runtime daemon is reached over SSH. +- [Deeplinks](../deeplinks/README.md) — `ade://` and `https://ade.app/open` URL grammar shared across desktop, ADE Code, iOS, and the marketing site. - [System overview](../../ARCHITECTURE.md) — CLI / terminal client placement in the system diagram. diff --git a/docs/features/deeplinks/README.md b/docs/features/deeplinks/README.md new file mode 100644 index 000000000..dfda715f2 --- /dev/null +++ b/docs/features/deeplinks/README.md @@ -0,0 +1,238 @@ +# Deeplinks + +Deeplinks are the shared URL contract that lets every ADE surface — desktop, +ADE Code TUI, iOS, the marketing site, and external tools (Linear, GitHub +PR descriptions, chat apps) — point at the same lane, branch, PR, or Linear +issue. Two forms carry identical semantics: + +``` +ade://lane/ +ade://repo///branch/[?pr=] +ade://pr/// +ade://linear-issue/[?branch=] + +https://ade.app/open?type=lane&id= +https://ade.app/open?type=branch&repo=/&branch=[&pr=] +https://ade.app/open?type=pr&repo=/&number= +https://ade.app/open?type=linear-issue&issue=[&branch=] +``` + +The HTTPS form lives on `apps/web` (Vercel) and acts as a marketing landing +page plus an OS-level upgrade into the `ade://` form when an ADE client is +registered. The `ade://` form routes directly through the OS to the running +desktop process (or starts it cold). Both forms parse to the same +`AppNavigationTarget` shape and dispatch through `IPC.appNavigate`. + +## Source file map + +Shared contract: + +- `apps/desktop/src/shared/deeplinks.ts` — builder + parser shared across + main, renderer, ADE CLI, and the web `/open` API route. Validates UUIDs, + GitHub owner/repo, Linear issue identifiers, and branch refs (rejects + traversal, control chars, trailing `.lock`). Exports `buildDeeplink`, + `parseDeeplink`, `looksLikeAdeDeeplink`, and `describeTarget` plus the + `DeeplinkTarget` union (`lane | branch | pr | linear-issue`). +- `apps/desktop/src/shared/adeDeeplinkFooter.ts` — renders the branded + "Open in ADE" footer block (markdown + small HTML subset) appended to + GitHub PR descriptions and reused as Linear attachment subtitle. + Idempotent: the block is wrapped in `` / + `` markers so subsequent renders replace in place + instead of appending duplicates. Used by `prService.ts` when creating + or updating PRs. +- `apps/desktop/src/shared/types/core.ts` — `AppNavigationTarget` / + `AppNavigationRequest` / `AppNavigationResult` carry the parsed deeplink + payload across IPC. Targets cover `lane`, `chat`/`work`, `pr` (with + optional repoOwner/repoName for not-yet-local PRs), `branch` (cross-machine + send-to-mac payload), `linear-issue`, and the generic `route` shape. + +Desktop main process — protocol handler: + +- `apps/desktop/src/main/services/deeplinks/protocolHandler.ts` — registers + ADE as the OS handler for the `ade://` scheme, acquires the + single-instance lock so a second `open ade://...` reuses the running + window, listens for `open-url` (macOS) and `second-instance` (Win/Linux), + buffers URLs received before `app.whenReady()`, and on dispatch parses + the URL, maps it to an `AppNavigationTarget`, and forwards through the + caller-supplied dispatcher. `main.ts` wires the dispatcher to focus the + most-suitable `BrowserWindow` and `webContents.send(IPC.appNavigate, …)`. + `handleDeeplinkUrl` is also re-used by the iOS Send-to-Mac sync command + (`syncRemoteCommandService.ts`'s `deeplinks.open`). +- `apps/desktop/src/main/main.ts` — calls `registerAdeProtocolHandler({...})` + before `app.whenReady()` so cold-start URLs aren't lost. + +Desktop main process — PR footer integration: + +- `apps/desktop/src/main/services/prs/prService.ts` calls + `ensureAdeDeeplinkFooter` when creating, updating, and re-rendering PR + descriptions so the branded block always reflects the current branch + + PR number. Re-render fires a follow-up PATCH once the PR number is + known. +- `apps/desktop/src/main/services/cto/linearLaneCardService.ts` builds the + same deeplink target when posting the Linear attachment so Linear's + card surfaces the cross-machine `https://ade.app/open?...` URL instead + of a Mac-only path. + +ADE CLI — outbound + inbound: + +- `apps/ade-cli/src/commands/deeplinks.ts` — `ade open`, `ade link`, + and `ade linear install` subcommands. + - `ade open ` invokes the OS opener (`open` / `xdg-open` / `start`) + on a validated `ade://` or `https://ade.app/open?...` URL, which + routes back through the registered protocol handler. The + `--linear-issue --branch ` form is what Linear's + "Open issue in coding tool" entry passes; the receiving install + resolves the actual lane/repo from the active project. + - `ade link …` builds a deeplink for a lane / branch / PR / Linear + issue and copies it to the clipboard. `--ade` emits the custom + scheme; the default is the HTTPS form. Round-trip form + (`ade link `) re-emits a parsed URL in the chosen form. + - `ade linear install` writes `~/.linear/coding-tools.json` so Linear's + "Open issue in coding tool" dropdown can launch ADE. Backs up the + previous file alongside. +- `apps/ade-cli/src/tuiClient/deeplinkRow.ts` — pure helper used by the + TUI's `Ctrl+Y` keybinding. Resolves the focused row (lane / PR) to a + canonical `ade://` URL, including parsing GitHub PR URLs to lift + owner/repo/number when the right-pane only carries the URL. +- `apps/ade-cli/src/tuiClient/keybindings/index.ts` — registers + `copy_deeplink` so `Ctrl+Y` over a highlighted lane or PR row builds + and copies the link via `tuiClient/app.tsx`. + +Apps/web — landing page + OG unfurl: + +- `apps/web/src/app/pages/OpenPage.tsx` — React SPA page mounted at + `/open`. Reads the same query shape as the parser, attempts the + `ade://` upgrade, and falls back to a download / install card with + the parsed target described inline. +- `apps/web/api/open.ts` — Vercel serverless function for `/open`. Self- + fetches `/index.html`, rewrites ``, `og:*`, and `twitter:*` + meta tags from query params so chat-app unfurlers (Slack, Discord, + iMessage, Gmail, Linear) show a rich card without executing + JavaScript. Cache: `public, max-age=600, stale-while-revalidate=86400`. +- `apps/web/vercel.json` — adds `/open → /api/open` rewrite ahead of the + catch-all SPA rewrite. + +iOS — inbound deeplinks + Send-to-Mac: + +- `apps/ios/ADE/App/DeepLinkRouter.swift` — parses inbound `ade://` URLs. + `ade://session/<id>` and `ade://pr/<n>` (and the longer + `ade://pr/<owner>/<repo>/<number>` form) flip the active tab via + `.adeDeepLinkRequested`. `ade://lane/<uuid>` and + `ade://repo/<owner>/<repo>/branch/<branch>` are local-only desktop + concepts and instead post `.adeSendToMacRequested` so the parent view + shows the "Send to your Mac" confirmation card. +- `apps/ios/ADE/Views/Deeplinks/SendToMacCard.swift` — SwiftUI sheet + bound to `.adeSendToMacRequested`. Parses the URL into a + `SendToMacTarget` (`lane`, `repoBranch`, `other`) for a human-readable + headline / detail and forwards the URL to a paired desktop through the + sync command surface. +- `apps/ade-cli/src/services/sync/syncRemoteCommandService.ts` — receives + the iOS "Send to your Mac" payload as the `deeplinks.open` sync + command and feeds the URL through `handleDeeplinkUrl` so the desktop + dispatches it identically to an OS-routed `open ade://...`. +- `apps/ade-cli/src/services/sync/syncHostService.ts` — exposes the same + `deeplinks.open` command from the runtime daemon so a phone paired + directly to a headless host can still bounce a URL out to the host. + +## URL semantics + +| Form | Target | Notes | +|------|--------|-------| +| `ade://lane/<uuid>` | `{ kind: "lane", laneId }` | UUID v4 required. | +| `ade://repo/<owner>/<repo>/branch/<branch>[?pr=<n>]` | `{ kind: "branch", repoOwner, repoName, branch, prNumber? }` | Cross-machine. Renderer opens the "Create lane from PR branch" modal or routes to the lane that already owns the branch. | +| `ade://pr/<owner>/<repo>/<number>` | `{ kind: "pr", repoOwner, repoName, prNumber }` | If the PR isn't yet local, the renderer jumps to the PRs tab pre-filtered or falls back to the create-lane-from-branch flow. | +| `ade://linear-issue/<ADE-123>[?branch=<branch>]` | `{ kind: "linear-issue", issueIdentifier, branch? }` | Linear hand-off — repo is not in the URL (Linear doesn't surface it), the renderer resolves the lane via `lane.linearIssue.identifier`. | + +Validation lives in one place (`shared/deeplinks.ts`) so the parser, the +TUI builders, and the web `/open` handler agree on what counts as +malformed. + +## End-to-end flow + +``` + ┌────────────────────────────────────────────┐ + │ Producer │ + │ - ade link … (CLI clipboard) │ + │ - Ctrl+Y in ade code (TUI copy) │ + │ - PR description footer (auto-rendered) │ + │ - Linear attachment (post on lane) │ + │ - iOS share / chat app │ + └────────────────────────────────────────────┘ + │ + ade:// URL or https://ade.app/open?... + │ + ┌────────────────────────────────┼─────────────────────────────────┐ + │ │ │ + ▼ ▼ ▼ + Browser / chat unfurler Desktop OS handler iOS / paired phone + hits apps/web/api/open.ts (registerAdeProtocolHandler) (DeepLinkRouter.swift) + → rich OG card + SPA → handleDeeplinkUrl → adeDeepLinkRequested + → IPC.appNavigate → adeSendToMacRequested + → sync deeplinks.open + on paired host + → handleDeeplinkUrl + → IPC.appNavigate +``` + +The dispatcher is the same function in every case: parse, map to +`AppNavigationTarget`, send through `IPC.appNavigate`. The renderer's +`window.ade.app.onNavigate` listener owns the routing decision (open lane +tab, jump to PR, prompt to create lane from branch, resolve Linear issue +to lane). + +## PR description footer + +```html +<!-- ade:link v=1 type=pr repo=<owner>/<repo> branch=<branch> num=<n> --> +<p> + <picture> + <source media="(prefers-color-scheme: dark)" srcset="https://ade.app/logo-dark.svg"> + <img src="https://ade.app/logo-light.svg" height="18" align="left" alt="ADE"> + </picture> +   <strong>Open in ADE</strong> +  ·  <a href="…">branch link</a> +  ·  <a href="…">PR link</a> +</p> +<!-- /ade:link --> +``` + +`prService.ts` calls `ensureAdeDeeplinkFooter` on PR create and update. +Re-renders are idempotent — the marker comments mark the block boundary +so the next call replaces it in place rather than appending. When the PR +is first created, the footer initially carries the branch link only; +once the PR number is known, a follow-up patch re-renders the block +with the PR link included. + +## Gotchas + +- **Register the protocol handler before `app.whenReady()`.** Otherwise + the cold-start URL on macOS / Windows gets dropped before the listener + attaches. `protocolHandler.ts` buffers URLs that arrive between + registration and `whenReady` so they aren't lost. +- **Validate before dispatch.** The parser rejects malformed inputs + (non-UUID lane ids, traversal in branch refs, non-positive PR numbers, + unknown hosts). Don't bypass it for "trusted" callers — Linear's + template substitution can produce empty strings. +- **The HTTPS form is the social form.** When linking from chat apps, + emails, or anywhere a preview matters, prefer the `https://ade.app/open` + shape so the unfurl works. Use `ade://` when the target is guaranteed + to be a machine with ADE installed (TUI copy, terminal share). +- **Linear hand-off doesn't carry the GitHub repo.** The `linear-issue` + shape only has the identifier (`ADE-123`) and optionally Linear's + generated branch name. The desktop resolves the GitHub repo by + looking up the lane that owns that Linear issue; if none matches, the + renderer opens the lanes page filtered by branch so the user can pick. +- **The PR footer is GitHub-flavored markdown, not full HTML.** Only the + `<p>`, `<picture>`, `<source>`, `<img>`, `<a>`, `<strong>` subset + renders. Linear accepts the same subset. + +## Cross-links + +- [Pull requests](../pull-requests/README.md) — PR description rendering, + footer integration. +- [Sync and multi-device](../sync-and-multi-device/README.md) — the + iOS Send-to-Mac sync command bounces deeplinks to a paired host. +- [ADE Code](../ade-code/README.md) — `ade open` / `ade link` / + `ade linear install` subcommands, `Ctrl+Y` copy. +- [System overview](../../ARCHITECTURE.md) — `IPC.appNavigate`, web /open + route placement. diff --git a/docs/features/sync-and-multi-device/ios-companion.md b/docs/features/sync-and-multi-device/ios-companion.md index 2cb026755..f6db3bbcb 100644 --- a/docs/features/sync-and-multi-device/ios-companion.md +++ b/docs/features/sync-and-multi-device/ios-companion.md @@ -36,10 +36,18 @@ apps/ios/ │ │ │ # strip is hidden and individual screens │ │ │ # can hide the custom bar via │ │ │ # `adeRootTabBarHidden()` -│ │ ├── DeepLinkRouter.swift # ade://session/<id> + ade://pr/<n> URL handler -│ │ │ # plus notification userInfo dispatch -│ │ │ # (sessionId / prId / prNumber → prId via -│ │ │ # WorkspaceSnapshot lookup) +│ │ ├── DeepLinkRouter.swift # ade:// URL handler. ade://session/<id>, +│ │ │ # ade://pr/<n>, and ade://pr/<owner>/<repo>/<num> +│ │ │ # flip tabs via .adeDeepLinkRequested. +│ │ │ # ade://lane/<uuid> and +│ │ │ # ade://repo/<owner>/<repo>/branch/<branch> +│ │ │ # are local-only desktop concepts — they +│ │ │ # post .adeSendToMacRequested instead so the +│ │ │ # SendToMacCard sheet can bounce the URL to +│ │ │ # a paired host via the deeplinks.open sync +│ │ │ # command. Also dispatches notification +│ │ │ # userInfo (sessionId / prId / prNumber → prId +│ │ │ # via WorkspaceSnapshot lookup). │ │ └── NotificationCategories.swift # UNNotificationCategory / UNNotificationAction set │ ├── Models/ │ │ ├── RemoteModels.swift # Codable structs mirroring shared types diff --git a/docs/features/terminals-and-sessions/README.md b/docs/features/terminals-and-sessions/README.md index a65279722..e6ec56e7d 100644 --- a/docs/features/terminals-and-sessions/README.md +++ b/docs/features/terminals-and-sessions/README.md @@ -43,12 +43,31 @@ desktop fallback IPC path. terminal control, and `readTranscriptTail({ sessionId, ... })`, which merges the on-disk transcript tail with the live PTY output tail so Work/TUI terminal hydration can replay output that is still buffered - in the transcript write stream. ~1,500 lines. Branch rewrite. + in the transcript write stream. PTY create stamps the new + `terminal_sessions` row's `owner_pid` with the + `processRegistryService` pid so cross-process reconcile/dispose paths + can tell live siblings from crashed owners. ~1,500 lines. Branch + rewrite. - `apps/desktop/src/main/services/pty/ptyService.test.ts` — PTY behavior tests. Branch updated. - `apps/desktop/src/main/services/sessions/sessionService.ts` — persistence - layer for `terminal_sessions` rows. CRUD, continuation metadata normalization, - `reattach`, `reconcileStaleRunningSessions`. ~580 lines. Branch rewrite. + layer for `terminal_sessions` rows. CRUD, continuation metadata + normalization, `reattach`, `reconcileStaleRunningSessions`. Reconcile and + ownership-aware queries gate row sweeps on the live-pid set returned by + `processRegistryService.listLivePids()`: a `running` row whose `owner_pid` + is still in the set belongs to a live sibling and must be left alone, only + unowned or owner-crashed rows get marked `disposed`. ~580 lines. Branch + rewrite. +- `apps/desktop/src/main/services/runtime/processRegistryService.ts` — per- + process heartbeat registrar against the `runtime_processes` table. Every + ADE process (desktop main, TUI runtime, `ade serve` daemon) inserts a row + on boot keyed by `pid`, refreshes `last_seen` on a 5 s heartbeat, and + reports liveness through `isPidLive(candidatePid)` / + `listLivePids()`. The default liveness window is 3× the heartbeat + interval so a single missed tick doesn't false-positive a sibling as + dead. PTY create stamps the new row's `owner_pid` with the registry's + `pid`, and reconcile / dispose paths consult the registry before + sweeping. See [ARCHITECTURE.md §3.4](../../ARCHITECTURE.md#34-cross-process-ownership). - `apps/desktop/src/main/services/sessions/sessionService.test.ts` — session persistence tests. - `apps/desktop/src/main/services/sessions/sessionDeltaService.ts` — @@ -495,12 +514,16 @@ See `apps/desktop/src/shared/types/sessions.ts` for the full shape. lane association, and transcript history intact. 7. **Reconcile** — on startup, `reconcileStaleRunningSessions` marks - orphaned `running` rows as `disposed`. The service still accepts an - `excludeToolTypes` option, but `main.ts` no longer passes chat tool - types: chat runtimes always warm up afresh on app start, so leaving - stale `running` chat rows behind only causes UI confusion. Ended - chat sessions stay in the table and are resumable through the SDK - (or removable via `ade.agentChat.delete`). + orphaned `running` rows as `disposed`. Ownership is gated through + `processRegistryService.listLivePids()`: a row whose `owner_pid` is + still in the live set belongs to a sibling process (another desktop + window, the `ade serve` daemon, an attached `ade code` TUI) and is + left alone, only owner-crashed or unowned rows get swept. The service + still accepts an `excludeToolTypes` option, but `main.ts` no longer + passes chat tool types: chat runtimes always warm up afresh on app + start, so leaving stale `running` chat rows behind only causes UI + confusion. Ended chat sessions stay in the table and are resumable + through the SDK (or removable via `ade.agentChat.delete`). 8. **Delete** — `sessionService.deleteSession(sessionId)` removes a row outright and emits `terminalSessionChanged` with @@ -630,6 +653,13 @@ Processes (managed): `transcriptBytesWritten` is not persisted. - Preview updates are throttled (~900 ms) and the string is capped at 220 chars via `derivePreviewFromChunk`. +- Reconcile and dispose paths gate on `processRegistryService.listLivePids()`. + Adding a new sweep path that operates on `terminal_sessions` without + consulting the registry will happily mark another process's live + sessions dead — always run the candidate row set through the registry + before disposing. The same heartbeat backs PTY cleanup: `owner_pid` + stamping happens inside `ptyService.create`, so any new lifecycle + surface that bypasses that helper needs to write `owner_pid` itself. - `PaneTilingLayout` mounts every leaf pane in the Work grid; each `SessionSurface` still passes `terminalVisible={true}` for grid tiles because the tiling layout keeps them on screen. Do not unmount a grid diff --git a/goal.md b/goal.md index 2f857e411..64fdf6e47 100644 --- a/goal.md +++ b/goal.md @@ -1,60 +1,378 @@ -You are working in /Users/arul/ADE/.ade/worktrees/fixing-cli-send-error-099dff5b only. Do not switch repos or lanes. - -Goal: -Fix the ADE Work tab bug where sending a prompt to a CLI session shows a blank black terminal with an orange cursor or falls back to a closed-session resume pane instead of a live Claude/Codex session. Then verify it with Computer Use on the live ADE desktop app. - -Important context: -- The prior “success” was false. The UI was showing a closed session resume pane, not a live CLI session. -- The user wants the actual live terminal to appear after sending a prompt from the Work tab. -- This must work for both Claude and Codex providers. -- Verification must be visual with Computer Use, not just logs. -- Keep the scope to the Work-tab CLI launch/render path. - -What I already learned: -- `useWorkSessions.ts` already opens the optimistic terminal immediately after creating a PTY session. -- `TerminalView.tsx` hydrates missed startup output from `window.ade.sessions.readTranscriptTail(...)`. -- The local-runtime path previously looked suspicious because transcript hydration could miss live PTY output, but the remaining problem now appears to be the actual CLI launch command path, not just rendering. -- The likely place to inspect next is the CLI launch builder in: - - `apps/desktop/src/main/services/orchestrator/orchestratorService.ts` - - `apps/desktop/src/main/services/ai/providerTaskRunner.ts` - - anything that generates the Codex/Claude startup command or resume command -- There are existing files already modified in the worktree from earlier attempts: - - `apps/desktop/src/main/services/adeActions/registry.ts` - - `apps/desktop/src/main/services/adeActions/registry.test.ts` - - `apps/desktop/src/renderer/components/terminals/useWorkSessions.ts` - - `apps/desktop/src/renderer/components/terminals/useWorkSessions.test.ts` - -Likely bug shape: -- A bad launch arg / command string is causing the CLI to exit immediately or launch into a resume flow. -- The UI then shows the closed session/resume pane instead of a live terminal. -- There may be a provider-specific mismatch between Claude and Codex startup/resume commands. - -What to do: -1. Inspect the actual command generation for Work-tab CLI launches for Claude and Codex. -2. Trace how the session is created, how startup commands are assembled, and how the session gets marked as live vs resumable. -3. Fix the underlying launch command issue, not just the renderer. -4. Keep changes narrow and preserve the existing Work-tab optimistic open behavior unless it is directly part of the bug. -5. Add or update tests around the real failure mode. -6. Verify with focused tests first. -7. Run the desktop dev app and use Computer Use to send a fresh Work-tab prompt like “test message” for both CLI providers if feasible. -8. Confirm visually that the app shows a live terminal session, not the closed resume pane. - -Validation expectations: -- Run the smallest relevant tests first. -- Then run whatever broader checks are necessary for the touched code path. -- Use Computer Use for the final proof of the live UI state. -- If you capture proof, make sure it is visual, not just textual. - -Useful files to inspect: -- `apps/desktop/src/main/services/orchestrator/orchestratorService.ts` -- `apps/desktop/src/main/services/ai/providerTaskRunner.ts` -- `apps/desktop/src/main/services/sessions/sessionService.ts` -- `apps/desktop/src/main/services/ipc/registerIpc.ts` -- `apps/desktop/src/renderer/components/terminals/useWorkSessions.ts` -- `apps/desktop/src/renderer/components/terminals/TerminalView.tsx` -- `apps/desktop/src/renderer/components/terminals/WorkCliSessionHeader.tsx` - -Known recent state: -- There was already a false-positive verification run. -- The user is frustrated and wants the actual fix plus proof. -- Stay direct and keep the work scoped to the live CLI launch issue. +# Goal: Cross-process ownership for ADE sessions + +You are picking this up mid-investigation. The previous agent traced two related bugs to a single root cause and started landing the fix. This document is the complete plan — finish it end-to-end. Worktree is `/Users/admin/Projects/ADE/.ade/worktrees/deeplinks-d52aa89e/`. Do not switch repos or lanes. + +--- + +## 1. The bugs the user is hitting + +Two user-visible symptoms, same underlying cause: + +### Symptom A — "frozen snapshot after PTY resume" + +User opens the Work tab on a CLI session whose status is `stopped`/`ended`, types a message, hits send. The PTY actually resumes in the main process (visible in the TUI, and the OS-level `claude --session-id ...` process is alive in `ps aux`), but the desktop renderer stays on `ClosedCliSessionSurface` showing a frozen pre-resume snapshot. The composer placeholder reads "Type to continue this Claude Code session..." and never flips to a live `TerminalView`. + +### Symptom B — "session randomly appears as Stopped while it's still running" + +User has the ADE desktop app and `ade code` (TUI) both open on the same lane. A Claude Code session is running fine. Without any user action, the desktop view randomly switches to `ClosedCliSessionSurface` ("Stopped", "Ended <timestamp>", "Type to continue this Claude Code session…"). The TUI still shows the session alive and producing output. The OS-level `claude --session-id <id>` process is still alive. Only the desktop's DB row says the session ended. + +The user has also seen this hit Codex CLI sessions and Cursor CLI sessions, not just Claude Code. + +--- + +## 2. Root cause — single-owner assumption in a multi-process world + +ADE runs the same project across **multiple OS processes simultaneously**, all opening the same `.ade/ade.db` SQLite file: + +- The desktop app (electron) — executes `cli.cjs serve --socket ~/.ade-beta/sock/ade.sock` as its main; it IS both the GUI and an `ade serve` daemon. +- A separate per-lane `ade serve` daemon (e.g. `/tmp/ade-runtime-lane-<lane>.sock`). +- The TUI's embedded runtime when `ade code` is invoked — `apps/ade-cli/src/bootstrap.ts` calls `createPtyService` (imported from `apps/desktop/src/main/services/pty/ptyService.ts`) inside the TUI's own process. +- Mobile is a remote client of one of the above. + +I verified live with `ps aux`, `lsof`, and `sqlite3` queries (see §10 for the exact commands you can re-run). The DB has rows for `claude` CLI sessions marked `status='disposed'` while the corresponding `claude --session-id <id>` OS process is still in the process table. Different process, different ptyService map, different opinion about who's alive. + +The DB schema has **no concept of which OS process owns which row**. Every process treats `terminal_sessions` as if it were the sole owner: + +1. **`sessionService.reconcileStaleRunningSessions`** (`apps/desktop/src/main/services/sessions/sessionService.ts:483`) blindly disposes every `status='running'` row at process startup. Desktop main calls it at `apps/desktop/src/main/main.ts:1939`. TUI bootstrap calls it at `apps/ade-cli/src/bootstrap.ts:450`. When `ade code` starts while the desktop has a live PTY, the TUI's reconcile silently marks the desktop's session disposed. + +2. **`ptyService.dispose({ptyId, sessionId})`** (`apps/desktop/src/main/services/pty/ptyService.ts:3497`) has an "orphan" branch: if `ptyId` doesn't match any entry in the calling process's PTY map but `sessionId` does match a DB row, it calls `sessionService.end(..., status='disposed')` to "clean up." Callers from the renderer (`ChatTerminalDrawer.tsx:387-392`, `useWorkSessions.ts:1221`) trigger this on tab unmount / drawer teardown / stop button. If the PTY lives in another process, the "orphan dispose" fires against a perfectly live session. + +3. **`closeEntry`** (`apps/desktop/src/main/services/pty/ptyService.ts:2080`) is fine — it only runs when the calling process's own `pty.onExit` fires. It is in-process by definition. Don't touch it for ownership reasons. + +I tried two stopgap fixes earlier in this lane: + +1. `sessionService.reopen`/`reattach` now emit `emitChanged({reason: "meta-updated"})` so the renderer learns about resume immediately (was silent — sessions/sessionService.ts:725, 745). +2. `reconcileStaleRunningSessions` got an `activityThresholdMs` (default 5 min) so rows whose `last_output_at` is recent are skipped (sessions/sessionService.ts:483). +3. `TerminalsPage.handleContinueCliSession` optimistically upserts the new session snapshot returned by `pty.sendToSession` (terminals/TerminalsPage.tsx:371). +4. `useWorkSessions` exposes `upsertSessionSnapshot` (terminals/useWorkSessions.ts:790). + +These help but are heuristics. They do not fix Symptom B in general — `last_output_at` is session-level activity and is flaky for idle sessions. The dispose path still fires across processes with no guard. Replace the `activityThresholdMs` guard with a proper ownership check (see §3, §5). Keep the optimistic upsert and the `emitChanged` additions — they're good independent of the ownership work. + +--- + +## 3. Architecture finding that surprised me — agent chats already do this right + +While investigating, I expected to find that the TUI and desktop each ran their own `agentChatService` and never converged. They don't. The user proved it: started a `claude-chat` in the TUI, watched it appear live and in-sync in the desktop in the same lane. The mechanism: + +- `ade serve` is a JSON-RPC daemon (`apps/ade-cli/src/adeRpcServer.ts`). It hosts the runtime services (agentChatService, sessionService, etc.). +- The desktop app embeds and connects to it through `localRuntimeConnectionPool` (`apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.ts`) and `runtimeRpcClient`. +- The TUI client (`apps/ade-cli/src/tuiClient/connection.ts:335 spawnDaemon`, `:356 connectAttachedSocket`) auto-connects to an existing daemon or spawns one detached (`spawn(... detached: true, stdio: "ignore")`), then makes RPC calls. +- The daemon owns the SDK agent-chat session. It pushes events out via `runtimeEvents.subscribe` (`apps/ade-cli/src/adeRpcServer.ts:7254` and surrounding) to every connected client. Desktop and TUI both subscribe and render the same event stream. +- The renderer preload (`apps/desktop/src/preload/preload.ts`) has a helper `callProjectRuntimeActionIfBound("chat", "sendMessage", ...)` that tries the daemon first and falls back to local IPC if no daemon is bound. This is why chats sync. + +**This is exactly the "stage 2" architecture you want for CLI PTYs.** It already exists for chats. It does not exist for raw PTYs — each process still spawns and owns its own PTYs locally. That's the asymmetry that makes Symptom B uniquely a CLI/PTY problem. + +So the work splits cleanly into: + +- **Tier 1** — add process-level ownership tracking and gate dispose/reconcile on it. Stops the immediate bleeding for *every* row type (CLI and chat). +- **Tier 2** — move PTY ownership into the daemon, the same way agent chats already work. Makes CLI sessions truly cross-surface live. + +The user wants both. Do both. + +--- + +## 4. What I already changed in this worktree + +Don't redo these — finish on top of them. All in `/Users/admin/Projects/ADE/.ade/worktrees/deeplinks-d52aa89e/`. Verify with `git diff` before continuing. + +Schema: +- `apps/desktop/src/main/services/state/kvDb.ts` — added `owner_pid INTEGER` column on `terminal_sessions`, added `runtime_processes` table, indexes. (See the diff for exact migration ALTERs.) + +New service (skeleton, **not wired anywhere yet**): +- `apps/desktop/src/main/services/runtime/processRegistryService.ts` — `createProcessRegistryService({db, logger, pid?, role, projectRoot?, heartbeatIntervalMs?, livenessWindowMs?})` with `start/heartbeat/stop/listLivePids/isPidLive/listAllProcesses/pruneStale`. Heartbeats default 5s; liveness window default 15s. **This file already exists. Read it before extending.** + +Earlier-lane fixes that should stay: +- `sessionService.reopen/reattach` now emit `emitChanged({reason: "meta-updated"})` — `apps/desktop/src/main/services/sessions/sessionService.ts:725-742, 745-765`. +- `sessionService.reconcileStaleRunningSessions` got `activityThresholdMs` heuristic (`apps/desktop/src/main/services/sessions/sessionService.ts:483`). **Tier 1 replaces this heuristic with proper ownership — see §5.4.** +- `TerminalsPage.handleContinueCliSession` does optimistic `work.upsertSessionSnapshot(result.session)` (`apps/desktop/src/renderer/components/terminals/TerminalsPage.tsx:371`). +- `useWorkSessions` exposes `upsertSessionSnapshot` (`apps/desktop/src/renderer/components/terminals/useWorkSessions.ts:790`). +- Two new sessionService tests for the activity-threshold guard. **Update them when you replace the heuristic with ownership.** + +--- + +## 5. Tier 1 — ownership + heartbeat + gated mutations + +### 5.1 Schema (DONE in §4) + +`terminal_sessions.owner_pid INTEGER NULL` + `runtime_processes(pid PRIMARY KEY, role TEXT, project_root TEXT, started_at TEXT, last_seen TEXT)` + the two indexes. Migrations are idempotent ALTER/CREATE-IF-NOT-EXISTS — safe to re-run on existing DBs. Verify the columns exist with: + +``` +sqlite3 /path/to/.ade/ade.db "pragma table_info(terminal_sessions);" +sqlite3 /path/to/.ade/ade.db "pragma table_info(runtime_processes);" +``` + +### 5.2 ProcessRegistry service (DONE skeleton, needs wiring) + +File: `apps/desktop/src/main/services/runtime/processRegistryService.ts`. API is fixed — extend tests around it, don't reshape unless you find a bug. + +**Roles:** `"desktop-main" | "ade-serve-daemon" | "tui-runtime"`. + +**Wiring (NOT YET DONE — do this):** + +1. **Desktop main** — instantiate in `apps/desktop/src/main/main.ts`, in the project context init (near where `sessionService` is created, around line 1935 today). Role `"desktop-main"`. Project root = the current project root. Call `start()` immediately. Tear it down on context close. **Critical:** the desktop's main process and the `ade serve --socket /Users/admin/.ade-beta/sock/ade.sock` it runs are *the same OS process* (PID 53089 in my investigation — see §10). One heartbeat row, not two. + +2. **TUI runtime bootstrap** — `apps/ade-cli/src/bootstrap.ts` around line 446 where `sessionService` is created. Role `"tui-runtime"`. `start()` before the reconcile call. + +3. **`ade serve` daemon** — if `cli.cjs serve` is launched as a standalone daemon (the lane runtime daemon, e.g. PID 88995 in my investigation), it goes through the same `bootstrap.ts` path, so the wiring above covers it. But verify: trace `apps/ade-cli/src/cli.ts` line 10201 (`Promise.all([import("./bootstrap"), import("./adeRpcServer")])`) and confirm `createAdeRuntime` is what `serve` ends up calling. If yes, single wiring suffices. If not, wire `serve` separately. + +4. **Stop on exit** — wire `processRegistry.stop()` to `process.on('beforeExit', ...)` and to the desktop's `before-quit` electron event. Best-effort; if the process crashes the row will simply go stale and reconcile cleans it up. Don't block exit on this. + +### 5.3 `sessionService` — accept and read `owner_pid` + +File: `apps/desktop/src/main/services/sessions/sessionService.ts`. + +- `create({...})` (line 657 today) — add `ownerPid?: number | null` to the args type. Persist into the new column. Default `null` if not provided (legacy callers). +- `mapRow` and `SESSION_COLUMNS` — add `owner_pid as ownerPid`. Surface `ownerPid` on `TerminalSessionSummary` and `TerminalSessionDetail` (types live in `apps/desktop/src/shared/types/sessions.ts`). +- `reattach(args)` (line 745) — add optional `ownerPid` arg. When provided, set `owner_pid = ?` in the UPDATE. Reattach is the resume path; the new owner is whoever called `ptyService.create` (see §5.5). +- `clearOwnerPid(sessionId)` — new method, sets `owner_pid = null`. Used by tier 2 when the daemon takes over a previously-local session. +- `setOwnerPid(sessionId, pid)` — new method. Used by tests and migration helpers. + +### 5.4 `sessionService.reconcileStaleRunningSessions` — gate on ownership + +Replace the `activityThresholdMs` guard with a proper ownership check. Signature: + +```ts +reconcileStaleRunningSessions({ + endedAt?: string; + status?: TerminalSessionStatus; + excludeToolTypes?: string[]; + liveOwnerPids: Set<number>; // NEW — caller passes this in +}): number +``` + +Semantics: + +- A row is "stale" iff `status='running'` AND (`owner_pid IS NULL` OR `owner_pid NOT IN (liveOwnerPids)`). +- The `owner_pid IS NULL` branch catches pre-migration rows (always treated as orphan — they came from before ownership existed). +- Build the SQL with a parameterized `NOT IN (?,?,?...)` clause, with care for the empty-set case (use `NOT IN (-1)` sentinel to keep SQL valid). +- Emit `emitChanged({reason: "meta-updated"})` for each disposed sessionId so renderers refresh. + +Callers (`main.ts:1939`, `bootstrap.ts:450`) become: + +```ts +processRegistry.start(); +const reconciledSessions = sessionService.reconcileStaleRunningSessions({ + status: "disposed", + liveOwnerPids: processRegistry.listLivePids(), + // bootstrap.ts also passes its existing excludeToolTypes +}); +``` + +**Delete** `activityThresholdMs` and the two tests that exercised it. Add the new tests in §5.7. + +### 5.5 `ptyService` — write owner_pid on every spawn, gate dispose on ownership + +File: `apps/desktop/src/main/services/pty/ptyService.ts`. + +- Constructor takes a new option `processRegistry: ProcessRegistryService` (or just `ownerPid: () => number` if you want the minimal coupling — the registry is the source of truth either way). +- `create(args)` (line 2392) — when calling `sessionService.create(...)` (line ~2485), pass `ownerPid: registry.pid`. +- `create(args)` — when the resume branch calls `sessionService.reattach(...)` (line 2654), pass `ownerPid: registry.pid`. The resuming process becomes the new owner. **Watch out:** there's another reattach call at line 2436 (live-attached-entry branch) for the rare case where a live PTY is found for an existingSession. That branch should also write `ownerPid: registry.pid`. +- `dispose({ptyId, sessionId})` (line 3497) — **the orphan branch is the dangerous one.** Right now if `ptyId` is unknown but `sessionId` resolves, it disposes the row unconditionally. New behavior: if `session.ownerPid != null && session.ownerPid !== registry.pid && registry.isPidLive(session.ownerPid)`, **skip the dispose** and emit a `warn` log (`pty.dispose_skipped_owned_by_peer`). Return the existing PtyCreateResult shape (caller already handles missing PTY). The "PTY in our map" branch (line 3534 onwards) is fine — if we have the entry, we own it by definition. +- `closeEntry` (line 2080) — no change. It only fires from `pty.onExit` in our process, so by definition we own the row. Leave it alone. + +### 5.6 `agentChatService` — write owner_pid on chat row creation + +File: `apps/desktop/src/main/services/chat/agentChatService.ts`. The chat rows are also `terminal_sessions` (toolType `claude-chat`, `codex-chat`, etc.). + +- Wherever `sessionService.create({...})` is called from `createSession` / `ensureIdentitySession`, pass `ownerPid: registry.pid`. +- `resumeSession` (line 19584 today) and `endSession` (line 8489) — no ownership flip needed for the in-process case (we own it because the runtime is in this process). But: in the multi-daemon world the user has, a chat row created by daemon A may get a `resumeSession` call from daemon B. **Tier 2 fixes this properly by routing to whichever process owns the row.** For tier 1, do an ownership guard in `resumeSession`: if the row's `owner_pid` is a live peer pid (not us), throw `Error("Chat session is owned by another ADE process; cannot resume from here.")`. The renderer should fall back to using the existing daemon RPC path (`callProjectRuntimeActionIfBound`). + +### 5.7 Tests + +Add to `apps/desktop/src/main/services/sessions/sessionService.test.ts`: + +1. `create` with `ownerPid` persists and `get(id)` returns it on `ownerPid`. +2. `reconcileStaleRunningSessions` with a `liveOwnerPids = {12345}` set leaves rows with `owner_pid=12345` alone, sweeps rows with `owner_pid=99999`, sweeps rows with `owner_pid=null` (legacy). +3. `reattach` sets `owner_pid` to the new owner. + +New test file `apps/desktop/src/main/services/runtime/processRegistryService.test.ts`: + +1. `start` inserts a row. +2. `heartbeat` advances `last_seen`. +3. `listLivePids` includes own pid even before first heartbeat. +4. `listLivePids` excludes a peer pid whose `last_seen` is older than the liveness window. +5. `isPidLive` matches the listLivePids predicate. +6. `pruneStale` deletes peer rows older than 10x liveness window, keeps own. +7. `stop` removes own row. + +Add to `apps/desktop/src/main/services/pty/ptyService.test.ts`: + +1. `dispose({ptyId: "missing", sessionId})` against a row owned by a live peer is a no-op (does not call `sessionService.end`); emits the warn log. +2. `dispose({ptyId: "missing", sessionId})` against a row owned by us OR a dead peer DOES call `sessionService.end`. +3. `create` writes `owner_pid` on the row. + +### 5.8 Edge cases for tier 1 + +- **Same OS process opens DB twice (sqlite WAL etc.).** Not an issue here — process pid is unique per OS process. +- **Pid reuse after a crash.** A new ADE process happens to grab the same pid as a dead one. The dead one's `runtime_processes` row will have a stale `last_seen` — `listLivePids` won't include it. As soon as `processRegistry.start()` writes the new row, the pid maps to the live process. The narrow race: between the new process starting and writing its first row, a sibling could mistake the stale row's pid (now the new pid) for "still dead." Acceptable; reconcile is best-effort and the new owner will heartbeat within seconds. +- **DB in `journal_mode=delete` (current state).** Confirmed live via `sqlite3 .../ade.db "pragma journal_mode;"` → `delete`. Concurrent writers serialize via SQLite's reserved/exclusive lock. WAL would be better for concurrent readers + writers; that's a separate task. For tier 1, the heartbeat write contention is bounded (one row per process, 5s cadence) and SQLite's `busy_timeout` handles brief stalls. +- **Long-running processes that pause heartbeats during sync GC.** Liveness window is 3x heartbeat interval (15s default) to absorb single missed beats. Tune if you see false positives. +- **Mobile / sync workers.** They also open the DB. Decide their role: probably `"sync-worker"` and treat like any other process. Don't let them dispose anything they didn't create — gate on owner_pid as elsewhere. +- **Stale `runtime_processes` rows after a hard crash.** `pruneStale` cleans them up; call it from each `processRegistry.start()` once at boot. +- **`ChatTerminalDrawer.tsx:387-392` `disposeTabsOnUnmount`** — this is the renderer calling `pty.dispose` from a React effect cleanup. The renderer doesn't know the owner. The main-process `dispose` now refuses to dispose rows it doesn't own, so this is safe even when the renderer unmounts a tab whose backing PTY lives in a daemon. Same goes for `useWorkSessions.stopRuntime` and the chat drawer's "session deleted" branch. + +--- + +## 6. Tier 2 — daemon owns PTYs + +Tier 1 makes everything safe. Tier 2 makes it *interactive across surfaces*. The pattern already exists for chats. Mirror it for PTYs. + +### 6.1 Add PTY RPC methods to `adeRpcServer` + +File: `apps/ade-cli/src/adeRpcServer.ts`. Open the file, find where `sync.*` and `modelPicker.*` methods are dispatched (around line 7763, 7826), and add a similar `pty.*` block. + +Methods needed (mirror `ptyService` interface): + +- `pty.create` → wraps `ptyService.create(args)` and returns `PtyCreateResult` + the new session row. +- `pty.write` → wraps `ptyService.write({ptyId, data})`. +- `pty.resize` → wraps `ptyService.resize({ptyId, cols, rows})`. +- `pty.dispose` → wraps `ptyService.dispose({ptyId, sessionId})`. +- `pty.sendToSession` → wraps `ptyService.sendToSession(args)`. This is the critical one for the "resume an ended CLI session" flow. +- `pty.list` → returns `service.enrichSessions(...)` snapshot. + +The daemon already has an `eventBuffer`. Add a new event category `"pty"` (alongside `"runtime"`, `"mission"`, etc.) and push every `broadcastData`/`broadcastExit` event into it: + +```ts +const ptyService = createPtyService({ + ..., + broadcastData: (event) => { + runtime.eventBuffer.push({ timestamp: nowIso(), category: "pty", payload: { type: "pty_data", event } }); + }, + broadcastExit: (event) => { + runtime.eventBuffer.push({ timestamp: nowIso(), category: "pty", payload: { type: "pty_exit", event } }); + }, +}); +``` + +Clients subscribe via the existing `runtimeEvents.subscribe` mechanism with `category: "pty"` and get the live stream. + +### 6.2 Route the desktop's PTY calls through the daemon + +File: `apps/desktop/src/preload/preload.ts`. Look at how `chat.send` works (around line 5173). The pattern: + +```ts +const runtime = await callProjectRuntimeActionIfBound<void>("chat", "sendMessage", { args }); +if (!runtime.handled) await ipcRenderer.invoke(IPC.agentChatSend, args); +``` + +Add the same pattern for every `pty.*` method exposed on `window.ade.pty`. When the daemon is bound, route through it. Otherwise fall back to local IPC (the existing behavior). + +The IPC handlers in `apps/desktop/src/main/services/ipc/registerIpc.ts` (lines 7572-7589 for pty methods) stay as the local fallback — they call `ctx.ptyService` directly. The desktop-main `ptyService` becomes a legacy fallback that only fires when the daemon isn't reachable. + +### 6.3 Renderer subscribes to daemon PTY events + +`TerminalView` currently reads PTY data via `window.ade.pty.onData(...)` which fans out from the local main process. Add a parallel subscription to the daemon's `runtimeEvents.subscribe({category: "pty"})` stream. The preload already has `subscribeAgentChatEvents` doing exactly this pattern (preload.ts:2366) — clone it for PTY events. + +### 6.4 TUI gets the same byte stream + +`apps/ade-cli/src/tuiClient/connection.ts:448 subscribeRuntimeEvents` already supports arbitrary categories. The TUI can subscribe to `category: "pty"` the moment it has an Ink terminal renderer to display the bytes. **Building that Ink terminal widget is out of scope for tier 2** — leave a TODO, ship tier 2 with the wire ready. + +### 6.5 Mobile and remote runtimes + +`apps/desktop/src/main/services/remoteRuntime/` already brokers chat events to/from a remote daemon via SSH. The PTY event category needs the same forwarding. Audit `remoteConnectionPool.ts` and add the new category to its allowed list. + +### 6.6 Migrate `ChatTerminalDrawer` and `WorkViewArea` to be daemon-aware + +These components hold direct `window.ade.pty.*` calls. Audit: + +- `apps/desktop/src/renderer/components/terminals/WorkViewArea.tsx` — `WorkCliContinuationComposer` and `ClosedCliSessionSurface` invoke `onContinue` which threads through to `pty.sendToSession`. The preload-level routing in §6.2 covers this transparently. +- `apps/desktop/src/renderer/components/chat/ChatTerminalDrawer.tsx` — calls `pty.dispose` on unmount and on session-deleted events. The daemon-side `pty.dispose` (already ownership-gated from tier 1) will no-op cross-process correctly. + +### 6.7 Tests + +Integration test in `apps/ade-cli/src/adeRpcServer.test.ts`: a client subscribes to `category: "pty"`, calls `pty.create`, then receives `pty_data` notifications when the PTY produces output. + +Integration test for the desktop preload routing: when `callProjectRuntimeActionIfBound` is bound, `pty.sendToSession` does NOT hit the local IPC handler. + +--- + +## 7. Acceptance criteria + +Run these by hand at the end. None of them require the user. + +1. **Concurrent boot, no false dispose.** Start the desktop. Start `ade code` on the same project. Confirm no rows flip to `disposed` purely from the TUI boot. (`sqlite3 .../ade.db "select id, tool_type, status, owner_pid from terminal_sessions where status='running';"` before/after.) +2. **Crash-resilient cleanup.** Start a CLI session in the desktop. `kill -9` the desktop process. Wait > liveness window. Open desktop again. The row is now `disposed` (the new desktop's reconcile sees the dead owner_pid). No live PTYs were killed — verify the runaway claude/codex processes are still in `ps aux` and will be reaped by the OS / a follow-up cleanup pass. +3. **Cross-surface live CLI rendering (tier 2).** Start `claude` from the desktop's Work tab. Open `ade code` in another terminal on the same lane. The TUI sees the same byte stream live (or at minimum receives pty events on `category: "pty"` — the Ink renderer is out of scope but the wire test confirms data is flowing). +4. **Mobile sees what desktop sees** when sync is configured — same `category: "pty"` events forwarded over the remote runtime transport. +5. **Symptom A regression test** — resume an ended CLI session from `ClosedCliSessionSurface`, observe the surface swap to live `TerminalView` immediately (already fixed by `upsertSessionSnapshot` + `reattach` emitChanged from §4; still works). +6. **Symptom B regression test** — start a Claude Code CLI in the desktop, immediately open `ade code` on the same lane. Confirm the desktop's view stays as live `TerminalView`. Confirm the `terminal_sessions` row keeps `status='running'` and `owner_pid` matches the desktop's pid. + +--- + +## 8. Out of scope / follow-ups (do not do as part of this work) + +- Build the Ink terminal widget so TUI can render raw PTY bytes for Codex CLI etc. (Tier 2 makes the data available; rendering is a UI task.) +- Move the DB to `journal_mode=wal` for true concurrent readers/writers. Worthwhile but separate. +- Replace the renderer's `disposeTabsOnUnmount` pattern with explicit user intent. The ownership gate makes it safe enough. +- Make the daemon survive desktop crashes when desktop spawned it (currently child of init thanks to `detached: true`, but verify `apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.ts:824 spawnRuntime` does the same). + +--- + +## 9. Working notes for the next agent + +- **Worktree:** `/Users/admin/Projects/ADE/.ade/worktrees/deeplinks-d52aa89e/`. Stay in it. Don't switch to project root. +- **Git diff to inspect before starting:** there are pre-existing changes from other in-flight work in this lane (e.g. `agentChatService.ts` hook-noise removal, `chatTranscriptRows.ts` tweaks). Run `git diff --stat` to see what's untouched-by-me vs touched. Don't revert anyone else's changes. +- **Branch:** `ade/deeplinks-d52aa89e`. Don't push or open a PR until acceptance criteria pass. +- **Test sharding** — the test suite is large. Always run scoped (`npx vitest run src/main/services/sessions/sessionService.test.ts` etc.). Full-suite invocations OOM. +- **Type-check:** `npm run typecheck` from `apps/desktop/` and from `apps/ade-cli/`. Both must be green. +- **Lint:** `npm run lint` from `apps/desktop/`. Run only after typecheck. +- **Don't touch normal ADE chats.** The user has been explicit that agent chats already work and any UI changes to `AgentChatPane.tsx` will be rejected unless they're strictly ownership-related. The chat path already has cross-surface sync via the daemon (§3). Don't try to "improve" it. + +--- + +## 10. Investigation log — commands you can re-run to verify + +These are the queries that proved the diagnosis. Re-run them to confirm the state matches what's described above. + +Find live ADE processes and which sockets they own: + +```sh +ps aux | grep -E "ade.*serve|ade-runtime|claude " | grep -v grep +ls -la /tmp/ade-runtime-*.sock /Users/admin/.ade*/sock/*.sock +lsof /Users/admin/.ade-beta/sock/ade.sock +lsof /tmp/ade-runtime-lane-*.sock +``` + +Confirm multiple ADE processes share the same project DB: + +```sh +lsof /Users/admin/Projects/ADE/.ade/ade.db +``` + +(I saw five processes with the same inode open: desktop electron, ADE Beta main, two TUI lane runtimes, and a dev runtime.) + +DB state — running sessions vs OS-level claude processes: + +```sh +sqlite3 /Users/admin/Projects/ADE/.ade/ade.db \ + "select id, lane_id, tool_type, status, started_at, ended_at, last_output_at, pty_id from terminal_sessions where status in ('running','disposed') order by started_at desc limit 30;" +``` + +Cross-reference any `claude` row marked `disposed` against `ps aux | grep "claude --session-id <that id>"`. If the OS process is alive and the row is disposed, you've reproduced Symptom B. + +DB journal mode (informational — tier 1 doesn't require WAL): + +```sh +sqlite3 /Users/admin/Projects/ADE/.ade/ade.db "pragma journal_mode;" +``` + +--- + +## 11. Why this is the right fix + +Three things have to be true for the user's bugs to recur. Each tier eliminates one. + +- The DB has no concept of "who owns this row." → Tier 1 (`owner_pid` + heartbeat). +- Multiple processes mutate the same row believing they're the sole owner. → Tier 1 (dispose/reconcile gated on ownership). +- Surfaces other than the spawner can't see live output for raw PTY sessions, so the user thinks the session "ended" when really only their view stopped updating. → Tier 2 (daemon owns PTYs, every surface subscribes to the byte stream). + +The user's "running for a while and finally responded" anecdote is consistent with this: the agent chat (`claude-chat`) was healthy throughout because it goes through the daemon's chat path. The CLI row (`claude` tool type) had its `owner_pid`-less ancestor stomped at some point — likely by a `dispose` from a renderer effect or a reconcile pass from a sibling process — leaving the OS-level `claude` process orphaned in the DB sense but alive in reality. Tier 1 + Tier 2 together make this configuration impossible. + +The alternative fixes considered and rejected: + +- **`last_output_at` heuristic only.** Already in place from §4. Doesn't help idle sessions; doesn't help dispose path. Acceptable as belt-and-suspenders, not as the primary mechanism. +- **`process.kill(pid, 0)` instead of a registry table.** Works on the local machine. Falls over for sync / remote-runtime scenarios where the owner is on another host. The registry table generalizes. +- **Lazy verification on interaction (skip reconcile entirely).** Discussed with the user. UI would briefly show stale "running" rows for crashed sessions; user explicitly rejected this UX. +- **Move the DB to WAL mode.** Helps contention but doesn't change the ownership semantics. Worth doing eventually; orthogonal. + +The right fix is the registry-backed ownership model + daemon-owned PTYs because both are *consistent with how chats already work in this codebase*. Tier 2 isn't introducing a new pattern, it's extending the one that demonstrably already works to the missing case. From 78f5846902e97499c2098fb762f46df01cf8f907 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Thu, 21 May 2026 02:45:40 -0400 Subject: [PATCH 2/6] deeplinks: simplifier pass + multi-chat grid TUI - Code simplification across ade-cli, desktop main, and web (extract helpers, collapse nested ternaries, dead-branch removal) - Add multi-chat grid layout, hit-test registry, and add-chat mode to TUI client Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- apps/ade-cli/src/adeRpcServer.ts | 41 +- apps/ade-cli/src/cli.ts | 3 + apps/ade-cli/src/commands/deeplinks.ts | 113 +- .../src/tuiClient/__tests__/ChatView.test.tsx | 19 + .../src/tuiClient/__tests__/Drawer.test.tsx | 38 + .../tuiClient/__tests__/GridMiniMap.test.ts | 13 + .../src/tuiClient/__tests__/appInput.test.ts | 35 +- .../__tests__/hitTestRegistry.test.ts | 35 + .../__tests__/multiChatLayout.test.ts | 45 + apps/ade-cli/src/tuiClient/aggregate.ts | 33 +- apps/ade-cli/src/tuiClient/app.tsx | 1238 +++++++++++++++-- apps/ade-cli/src/tuiClient/cli.tsx | 3 + .../src/tuiClient/components/AddChatMode.tsx | 16 + .../src/tuiClient/components/ChatView.tsx | 75 +- .../src/tuiClient/components/Drawer.tsx | 25 +- .../tuiClient/components/FooterControls.tsx | 30 +- .../src/tuiClient/components/GridMiniMap.tsx | 26 + .../tuiClient/components/MultiChatGrid.tsx | 265 ++++ .../src/tuiClient/components/RightPane.tsx | 2 + apps/ade-cli/src/tuiClient/hitTestRegistry.ts | 115 ++ apps/ade-cli/src/tuiClient/multiChatLayout.ts | 82 ++ apps/desktop/src/main/main.ts | 92 +- .../main/services/chat/agentChatService.ts | 2 +- .../services/cto/linearLaneCardService.ts | 7 +- .../services/deeplinks/protocolHandler.ts | 74 +- .../main/services/lanes/laneService.test.ts | 80 ++ .../src/main/services/lanes/laneService.ts | 152 +- .../src/main/services/pty/ptyService.ts | 2 +- .../review/reviewContextBuilder.test.ts | 8 - .../services/review/reviewService.test.ts | 121 +- .../src/main/services/review/reviewService.ts | 94 +- .../review/reviewTargetMaterializer.test.ts | 6 - .../main/services/sessions/sessionService.ts | 16 +- apps/desktop/src/preload/preload.test.ts | 11 +- apps/desktop/src/renderer/browserMock.ts | 28 +- .../src/renderer/components/app/AppShell.tsx | 1 + .../components/app/InboundDeeplinkModal.tsx | 27 +- .../src/renderer/components/app/TopBar.tsx | 4 +- .../chat/AgentChatComposer.test.tsx | 10 +- .../components/chat/AgentChatComposer.tsx | 45 - .../chat/AgentChatPane.submit.test.tsx | 3 +- .../components/chat/AgentChatPane.tsx | 47 +- .../components/chat/ChatComposerShell.tsx | 2 +- .../components/chat/ChatGitToolbar.tsx | 59 +- .../components/chat/ChatSubagentsPanel.tsx | 86 +- .../components/chat/ChatTerminalDrawer.tsx | 4 +- .../components/chat/ChatWorkLogBlock.tsx | 4 +- .../components/chat/chatSurfaceTheme.ts | 24 +- .../chat/codex/CodexOpenInCliButton.tsx | 7 +- .../renderer/components/files/FilesPage.tsx | 1 - .../components/lanes/LaneWorkPane.tsx | 91 +- .../components/lanes/LanesPage.test.ts | 17 +- .../renderer/components/lanes/LanesPage.tsx | 94 +- .../components/lanes/laneDesignTokens.ts | 25 +- .../renderer/components/lanes/laneUtils.ts | 16 - .../lanes/useLaneWorkSessions.test.ts | 60 +- .../components/lanes/useLaneWorkSessions.ts | 33 +- .../components/onboarding/HelpMenu.tsx | 4 +- .../components/review/ReviewPage.test.tsx | 99 +- .../renderer/components/review/ReviewPage.tsx | 657 +++++---- .../renderer/components/review/reviewTypes.ts | 1 - .../components/terminals/SessionCard.tsx | 131 +- .../components/terminals/SessionListPane.tsx | 48 +- .../components/terminals/WorkViewArea.tsx | 10 +- .../renderer/components/ui/TabBackground.tsx | 4 +- .../components/usage/HeaderUsageControl.tsx | 6 +- apps/desktop/src/renderer/index.css | 23 +- .../tours/laneWorkPaneHighlightsTour.ts | 11 +- .../onboarding/tours/laneWorkPaneTour.ts | 56 +- apps/desktop/src/shared/adeDeeplinkFooter.ts | 9 +- apps/desktop/src/shared/types/review.ts | 11 - apps/web/api/open.ts | 16 +- apps/web/src/app/pages/OpenPage.tsx | 7 +- docs/features/deeplinks/README.md | 23 + goal.md | 896 ++++++++---- 75 files changed, 3819 insertions(+), 1798 deletions(-) create mode 100644 apps/ade-cli/src/tuiClient/__tests__/GridMiniMap.test.ts create mode 100644 apps/ade-cli/src/tuiClient/__tests__/hitTestRegistry.test.ts create mode 100644 apps/ade-cli/src/tuiClient/__tests__/multiChatLayout.test.ts create mode 100644 apps/ade-cli/src/tuiClient/components/AddChatMode.tsx create mode 100644 apps/ade-cli/src/tuiClient/components/GridMiniMap.tsx create mode 100644 apps/ade-cli/src/tuiClient/components/MultiChatGrid.tsx create mode 100644 apps/ade-cli/src/tuiClient/hitTestRegistry.ts create mode 100644 apps/ade-cli/src/tuiClient/multiChatLayout.ts diff --git a/apps/ade-cli/src/adeRpcServer.ts b/apps/ade-cli/src/adeRpcServer.ts index 09dbaa31d..04bb810ff 100644 --- a/apps/ade-cli/src/adeRpcServer.ts +++ b/apps/ade-cli/src/adeRpcServer.ts @@ -7601,6 +7601,16 @@ async function readResource(runtime: AdeRuntime, uri: string): Promise<Record<st throw new JsonRpcError(JsonRpcErrorCode.invalidParams, `Unsupported resource URI: ${uri}`); } +const APP_NAVIGATE_SUPPORTED_KINDS = new Set([ + "work", + "chat", + "lane", + "pr", + "route", + "branch", + "linear-issue", +]); + export function createAdeRpcRequestHandler(args: { runtime: AdeRuntime; serverVersion: string; @@ -7923,15 +7933,7 @@ export function createAdeRpcRequestHandler(args: { if (!kind) { throw new JsonRpcError(JsonRpcErrorCode.invalidParams, "app/navigate requires target.kind."); } - if ( - kind !== "work" && - kind !== "chat" && - kind !== "lane" && - kind !== "pr" && - kind !== "route" && - kind !== "branch" && - kind !== "linear-issue" - ) { + if (!APP_NAVIGATE_SUPPORTED_KINDS.has(kind)) { throw new JsonRpcError(JsonRpcErrorCode.invalidParams, `Unsupported app navigation target kind: ${kind}.`); } if (kind === "lane" && !asOptionalTrimmedString(target.laneId)) { @@ -7940,17 +7942,16 @@ export function createAdeRpcRequestHandler(args: { if (kind === "route" && !asOptionalTrimmedString(target.route)) { throw new JsonRpcError(JsonRpcErrorCode.invalidParams, "app/navigate target 'route' requires route."); } - if (kind === "branch") { - if ( - !asOptionalTrimmedString(target.repoOwner) || - !asOptionalTrimmedString(target.repoName) || - !asOptionalTrimmedString(target.branch) - ) { - throw new JsonRpcError( - JsonRpcErrorCode.invalidParams, - "app/navigate target 'branch' requires repoOwner, repoName, and branch.", - ); - } + if ( + kind === "branch" + && (!asOptionalTrimmedString(target.repoOwner) + || !asOptionalTrimmedString(target.repoName) + || !asOptionalTrimmedString(target.branch)) + ) { + throw new JsonRpcError( + JsonRpcErrorCode.invalidParams, + "app/navigate target 'branch' requires repoOwner, repoName, and branch.", + ); } if (kind === "linear-issue" && !asOptionalTrimmedString(target.issueIdentifier)) { throw new JsonRpcError( diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index 4e7c8937c..c931a7b0d 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -910,6 +910,9 @@ const HELP_BY_COMMAND: Record<string, string> = { Keys: ctrl-o Open or focus lanes and chats ctrl-p Open or focus details + ctrl-g Split chat: add another chat tile + ctrl-w Split chat: close focused tile + tab Split chat: cycle focused tile shift-tab Cycle pane focus esc Return or cancel the active pane ? Help when it is the first prompt character diff --git a/apps/ade-cli/src/commands/deeplinks.ts b/apps/ade-cli/src/commands/deeplinks.ts index 88e58b1a7..db4330639 100644 --- a/apps/ade-cli/src/commands/deeplinks.ts +++ b/apps/ade-cli/src/commands/deeplinks.ts @@ -100,15 +100,7 @@ export function runOpenCommand(args: string[]): DeeplinkCliResult { params.set("type", "linear-issue"); if (linearIssue) params.set("issue", linearIssue); if (branch) params.set("branch", branch); - const url = `https://ade.app/open?${params.toString()}`; - const result = openUrlViaOs(url); - if (result.failed) { - return { - output: `Could not invoke OS opener for ${url}: ${result.message}\n`, - exitCode: 1, - }; - } - return { output: `Opened ${url}\n`, exitCode: 0 }; + return openAndReport(`https://ade.app/open?${params.toString()}`); } // URL form. @@ -117,24 +109,19 @@ export function runOpenCommand(args: string[]): DeeplinkCliResult { throw new CliDeeplinkUsageError("ade open <url> (or --linear-issue / --branch)"); } const parsed = parseDeeplink(url); - if (!parsed.ok) { - // Allow unknown_type URLs (like the linear-issue form above) to still pass - // through to the OS opener — the landing page / desktop can decide what - // to do. - if (parsed.error.kind === "unknown_type" && /^https?:\/\/ade\.app\/open\b/i.test(url)) { - const result = openUrlViaOs(url); - if (result.failed) { - return { - output: `Could not invoke OS opener for ${url}: ${result.message}\n`, - exitCode: 1, - }; - } - return { output: `Opened ${url}\n`, exitCode: 0 }; - } - throw new CliDeeplinkUsageError( - `Invalid deeplink: ${(parsed.error as { kind: string; reason?: string }).reason ?? parsed.error.kind}`, - ); + if (parsed.ok) return openAndReport(url); + // Allow unknown_type URLs (like the linear-issue form above) to still pass + // through to the OS opener — the landing page / desktop can decide what + // to do. + if (parsed.error.kind === "unknown_type" && /^https?:\/\/ade\.app\/open\b/i.test(url)) { + return openAndReport(url); } + throw new CliDeeplinkUsageError( + `Invalid deeplink: ${(parsed.error as { kind: string; reason?: string }).reason ?? parsed.error.kind}`, + ); +} + +function openAndReport(url: string): DeeplinkCliResult { const result = openUrlViaOs(url); if (result.failed) { return { @@ -187,16 +174,15 @@ export function runLinkCommand(args: string[]): DeeplinkCliResult { valued: ["pr", "branch"], }); const positional = flags.positional; - const useAdeForm = flags.booleans.has("ade"); + const form = flags.booleans.has("ade") ? "ade" : "https"; const skipClipboard = flags.booleans.has("no-clipboard"); + const emit = (target: DeeplinkTarget): DeeplinkCliResult => + finishLink(buildDeeplink(target, { form }), skipClipboard); // `ade link <url>` — accept a deeplink and re-emit it in the chosen form. if (positional.length === 1) { const parsed = parseDeeplink(positional[0]); - if (parsed.ok) { - const out = buildDeeplink(parsed.target, { form: useAdeForm ? "ade" : "https" }); - return finishLink(out, skipClipboard); - } + if (parsed.ok) return emit(parsed.target); } const verb = positional[0]; @@ -205,9 +191,7 @@ export function runLinkCommand(args: string[]): DeeplinkCliResult { if (!laneId) { throw new CliDeeplinkUsageError("ade link lane <lane-uuid>"); } - const target: DeeplinkTarget = { kind: "lane", laneId }; - const url = buildDeeplink(target, { form: useAdeForm ? "ade" : "https" }); - return finishLink(url, skipClipboard); + return emit({ kind: "lane", laneId }); } if (verb === "branch") { const repo = positional[1]; @@ -217,20 +201,12 @@ export function runLinkCommand(args: string[]): DeeplinkCliResult { "ade link branch <owner/repo> <branch> [--pr <number>]", ); } - const [repoOwner, repoName] = repo.split("/"); - if (!repoOwner || !repoName) { - throw new CliDeeplinkUsageError("Repo must be in 'owner/repo' form"); - } + const { repoOwner, repoName } = parseRepoSlug(repo); const prNumberRaw = flags.valued.get("pr"); - const prNumber = prNumberRaw ? Number(prNumberRaw) : undefined; - if (prNumberRaw != null && (!Number.isInteger(prNumber) || prNumber == null || prNumber < 1)) { - throw new CliDeeplinkUsageError("--pr must be a positive integer"); - } - const target: DeeplinkTarget = prNumber != null + const prNumber = prNumberRaw != null ? parsePositiveInteger(prNumberRaw, "--pr") : undefined; + return emit(prNumber != null ? { kind: "branch", repoOwner, repoName, branch, prNumber } - : { kind: "branch", repoOwner, repoName, branch }; - const url = buildDeeplink(target, { form: useAdeForm ? "ade" : "https" }); - return finishLink(url, skipClipboard); + : { kind: "branch", repoOwner, repoName, branch }); } if (verb === "pr") { const repo = positional[1]; @@ -238,17 +214,9 @@ export function runLinkCommand(args: string[]): DeeplinkCliResult { if (!repo || !numberRaw) { throw new CliDeeplinkUsageError("ade link pr <owner/repo> <number>"); } - const [repoOwner, repoName] = repo.split("/"); - if (!repoOwner || !repoName) { - throw new CliDeeplinkUsageError("Repo must be in 'owner/repo' form"); - } - const prNumber = Number(numberRaw); - if (!Number.isInteger(prNumber) || prNumber < 1) { - throw new CliDeeplinkUsageError("PR number must be a positive integer"); - } - const target: DeeplinkTarget = { kind: "pr", repoOwner, repoName, prNumber }; - const url = buildDeeplink(target, { form: useAdeForm ? "ade" : "https" }); - return finishLink(url, skipClipboard); + const { repoOwner, repoName } = parseRepoSlug(repo); + const prNumber = parsePositiveInteger(numberRaw, "PR number"); + return emit({ kind: "pr", repoOwner, repoName, prNumber }); } if (verb === "linear-issue") { const issueIdentifier = positional[1]; @@ -256,16 +224,30 @@ export function runLinkCommand(args: string[]): DeeplinkCliResult { throw new CliDeeplinkUsageError("ade link linear-issue <ADE-123> [--branch <branch>]"); } const branchHint = flags.valued.get("branch"); - const target: DeeplinkTarget = branchHint + return emit(branchHint ? { kind: "linear-issue", issueIdentifier, branch: branchHint } - : { kind: "linear-issue", issueIdentifier }; - const url = buildDeeplink(target, { form: useAdeForm ? "ade" : "https" }); - return finishLink(url, skipClipboard); + : { kind: "linear-issue", issueIdentifier }); } throw new CliDeeplinkUsageError(HELP_LINK); } +function parseRepoSlug(repo: string): { repoOwner: string; repoName: string } { + const [repoOwner, repoName] = repo.split("/"); + if (!repoOwner || !repoName) { + throw new CliDeeplinkUsageError("Repo must be in 'owner/repo' form"); + } + return { repoOwner, repoName }; +} + +function parsePositiveInteger(value: string, label: string): number { + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed < 1) { + throw new CliDeeplinkUsageError(`${label} must be a positive integer`); + } + return parsed; +} + function finishLink(url: string, skipClipboard: boolean): DeeplinkCliResult { let clipboardNote = ""; if (!skipClipboard) { @@ -361,13 +343,10 @@ export function runLinearInstall(args: string[], opts: { home?: string; argv0?: function resolveAdeBinaryPath(): string { // When invoked via the bundled binary, argv[0] is the absolute path. When - // run via node, argv[0] is node — fall back to a `which`-like resolution. + // run via node, argv[0] is node — fall back to bare `ade` and hope it's on + // PATH (the Linear coding-tool spawner inherits the user's PATH). const argv0 = process.argv[0] ?? ""; - if (argv0 && /\b(ade|node)\b/.test(argv0)) { - if (/\bade\b/.test(argv0)) return argv0; - } - // Last-resort: hope `ade` is on PATH. - return "ade"; + return /\bade\b/.test(argv0) ? argv0 : "ade"; } // --------------------------------------------------------------------------- diff --git a/apps/ade-cli/src/tuiClient/__tests__/ChatView.test.tsx b/apps/ade-cli/src/tuiClient/__tests__/ChatView.test.tsx index 1cee610e7..4b1f581a4 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/ChatView.test.tsx +++ b/apps/ade-cli/src/tuiClient/__tests__/ChatView.test.tsx @@ -106,6 +106,25 @@ describe("ChatView", () => { expect(frame).toContain("cmds"); }); + it("uses a compact empty state inside multi-chat tiles", () => { + const result = render( + <ChatView + events={[]} + notices={[]} + activeSession={session} + projectName="ADE" + laneName="Primary" + maxRows={10} + width={44} + focused + />, + ); + const frame = stripAnsi(result.lastFrame() ?? ""); + + expect(frame).toContain("No transcript yet."); + expect(frame).not.toContain("AGENTIC DEVELOPMENT ENVIRONMENT"); + }); + it("does not invite chat input when the selected lane worktree is missing", () => { const result = render( <ChatView diff --git a/apps/ade-cli/src/tuiClient/__tests__/Drawer.test.tsx b/apps/ade-cli/src/tuiClient/__tests__/Drawer.test.tsx index df71f64b5..7cbb3a00b 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/Drawer.test.tsx +++ b/apps/ade-cli/src/tuiClient/__tests__/Drawer.test.tsx @@ -179,6 +179,44 @@ describe("Drawer lane and chat navigation layout", () => { expect(chatModeFrame).not.toContain("next lane"); }); + it("makes split add mode obvious in the drawer chrome", () => { + const sessions: AgentChatSessionSummary[] = [ + { + sessionId: "chat-1", + laneId: "lane-1", + provider: "codex", + model: "gpt-5.5", + title: "First chat", + status: "idle", + startedAt: "2026-05-12T11:30:00.000Z", + endedAt: null, + lastActivityAt: "2026-05-12T11:31:00.000Z", + lastOutputPreview: null, + summary: null, + }, + ]; + + const frame = stripAnsi(render( + <Drawer + lanes={[lane("lane-1", "Feature", "feature/chat-nav", "2026-05-12T11:55:00.000Z")]} + sessions={sessions} + activeLaneId="lane-1" + activeSessionId="chat-1" + browsingLaneId="lane-1" + selectedLaneIndex={0} + selectedChatIndex={0} + panelHeight={30} + mode="chats" + focused + addMode + />, + ).lastFrame() ?? ""); + + expect(frame).toContain("PICK CHAT"); + expect(frame).toContain("select chat in left pane"); + expect(frame).toContain("↵/click"); + }); + it("does not offer a new chat action for a missing lane worktree", () => { const frame = stripAnsi(render( <Drawer diff --git a/apps/ade-cli/src/tuiClient/__tests__/GridMiniMap.test.ts b/apps/ade-cli/src/tuiClient/__tests__/GridMiniMap.test.ts new file mode 100644 index 000000000..d13864a9c --- /dev/null +++ b/apps/ade-cli/src/tuiClient/__tests__/GridMiniMap.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from "vitest"; +import { gridMiniMapText } from "../components/GridMiniMap"; + +describe("grid mini map", () => { + it("renders focused tile markers for every supported count", () => { + expect(gridMiniMapText(1, 0)).toBe("▣"); + expect(gridMiniMapText(2, 1)).toBe("▢▣"); + expect(gridMiniMapText(3, 2)).toBe("▢▢▣"); + expect(gridMiniMapText(4, 0)).toBe("▣▢ / ▢▢"); + expect(gridMiniMapText(5, 3)).toBe("▢▢ / ▢▣▢"); + expect(gridMiniMapText(6, 5)).toBe("▢▢▢ / ▢▢▣"); + }); +}); diff --git a/apps/ade-cli/src/tuiClient/__tests__/appInput.test.ts b/apps/ade-cli/src/tuiClient/__tests__/appInput.test.ts index b64dd030f..9ba620bf5 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/appInput.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/appInput.test.ts @@ -9,7 +9,6 @@ import { encodeTerminalPromptSubmit, encodeTerminalPromptSubmitConfirm, footerControlsForAvailability, - formFieldIndexForMouseLine, formFieldUsesPromptInput, isChatSessionAnimating, isPromptLineBackspace, @@ -28,14 +27,12 @@ import { parseTerminalMouseInput, promptDisplayRows, promptHitLine, - laneDetailsActionIndexForMouseLine, modelPickerSurfaceForSetupPane, resolveContextDefault, resolveDrawerPaneWidth, resolveModelPickerEscape, resolveChatWrapWidth, resolveTerminalPaneWidth, - setupPaneRowIndexForMouseLine, splitTerminalControlInput, subagentSnapshotsFromEvents, } from "../app"; @@ -126,6 +123,14 @@ describe("parseTerminalMouseInput", () => { }); }); + it("parses any-event pointer moves for hover hit-testing", () => { + expect(parseTerminalMouseInput("[<35;9;10M")).toEqual({ + kind: "move", + x: 9, + y: 10, + }); + }); + it("parses mouse modifier bits", () => { expect(parseTerminalMouseInput("[<20;5;6M")).toEqual({ kind: "click", @@ -318,30 +323,6 @@ describe("drawer mouse hit testing", () => { }); }); - it("maps lane details action lines to selectable action indexes", () => { - expect(laneDetailsActionIndexForMouseLine(18, 8)).toBe(0); - expect(laneDetailsActionIndexForMouseLine(25, 8)).toBe(7); - expect(laneDetailsActionIndexForMouseLine(26, 8)).toBeNull(); - }); - - it("maps setup pane rows including selected detail lines", () => { - expect(setupPaneRowIndexForMouseLine({ y: 6, rowCount: 4, selectedIndex: 2, hasLaneLabel: false })).toBe(0); - expect(setupPaneRowIndexForMouseLine({ y: 7, rowCount: 4, selectedIndex: 2, hasLaneLabel: false })).toBe(1); - expect(setupPaneRowIndexForMouseLine({ y: 8, rowCount: 4, selectedIndex: 2, hasLaneLabel: false })).toBe(2); - expect(setupPaneRowIndexForMouseLine({ y: 9, rowCount: 4, selectedIndex: 2, hasLaneLabel: false })).toBe(2); - expect(setupPaneRowIndexForMouseLine({ y: 10, rowCount: 4, selectedIndex: 2, hasLaneLabel: false })).toBe(3); - expect(setupPaneRowIndexForMouseLine({ y: 7, rowCount: 4, selectedIndex: 0, hasLaneLabel: true })).toBe(0); - }); - - it("maps form pane rows to their field indexes", () => { - expect(formFieldIndexForMouseLine({ y: 5, fieldCount: 2, command: "new-lane" })).toBe(0); - expect(formFieldIndexForMouseLine({ y: 6, fieldCount: 2, command: "new-lane" })).toBe(1); - expect(formFieldIndexForMouseLine({ y: 9, fieldCount: 4, command: "lane-delete" })).toBe(0); - expect(formFieldIndexForMouseLine({ y: 13, fieldCount: 4, command: "lane-delete" })).toBe(2); - expect(formFieldIndexForMouseLine({ y: 16, fieldCount: 4, command: "lane-delete" })).toBe(1); - expect(formFieldIndexForMouseLine({ y: 19, fieldCount: 4, command: "lane-delete" })).toBe(3); - expect(formFieldIndexForMouseLine({ y: 21, fieldCount: 4, command: "lane-delete" })).toBeNull(); - }); }); describe("prompt mouse hit testing", () => { diff --git a/apps/ade-cli/src/tuiClient/__tests__/hitTestRegistry.test.ts b/apps/ade-cli/src/tuiClient/__tests__/hitTestRegistry.test.ts new file mode 100644 index 000000000..344125e25 --- /dev/null +++ b/apps/ade-cli/src/tuiClient/__tests__/hitTestRegistry.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it, vi } from "vitest"; +import { createHitTestRegistry } from "../hitTestRegistry"; + +describe("hit test registry", () => { + it("finds registered targets and unregisters by id", () => { + const registry = createHitTestRegistry(); + registry.register({ id: "one", rect: { x: 2, y: 3, w: 4, h: 2 } }); + + expect(registry.hitTest(2, 3)?.id).toBe("one"); + expect(registry.hitTest(5, 4)?.id).toBe("one"); + expect(registry.hitTest(6, 4)).toBeNull(); + + registry.unregister("one"); + expect(registry.hitTest(2, 3)).toBeNull(); + }); + + it("lets higher z-index targets win overlapping cells", () => { + const registry = createHitTestRegistry(); + registry.register({ id: "base", rect: { x: 1, y: 1, w: 10, h: 5 }, zIndex: 1 }); + registry.register({ id: "top", rect: { x: 3, y: 2, w: 4, h: 2 }, zIndex: 10 }); + + expect(registry.hitTest(4, 3)?.id).toBe("top"); + expect(registry.hitTest(9, 3)?.id).toBe("base"); + }); + + it("replaces duplicate ids instead of accumulating stale targets", () => { + const registry = createHitTestRegistry(); + const onClick = vi.fn(); + registry.register({ id: "same", rect: { x: 1, y: 1, w: 2, h: 2 } }); + registry.register({ id: "same", rect: { x: 5, y: 5, w: 2, h: 2 }, onClick }); + + expect(registry.hitTest(1, 1)).toBeNull(); + expect(registry.hitTest(5, 5)?.onClick).toBe(onClick); + }); +}); diff --git a/apps/ade-cli/src/tuiClient/__tests__/multiChatLayout.test.ts b/apps/ade-cli/src/tuiClient/__tests__/multiChatLayout.test.ts new file mode 100644 index 000000000..520b47260 --- /dev/null +++ b/apps/ade-cli/src/tuiClient/__tests__/multiChatLayout.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; +import { canRenderMultiChatGrid, computeTileRects, focusedSessionIdForMultiView } from "../multiChatLayout"; + +describe("multi chat layout", () => { + it("computes the locked 1-6 tile patterns inside the available area", () => { + for (const count of [1, 2, 3, 4, 5, 6] as const) { + const rects = computeTileRects(count, 120, 30); + expect(rects).toHaveLength(count); + for (const rect of rects) { + expect(rect.x).toBeGreaterThanOrEqual(0); + expect(rect.y).toBeGreaterThanOrEqual(0); + expect(rect.x + rect.w).toBeLessThanOrEqual(120); + expect(rect.y + rect.h).toBeLessThanOrEqual(30); + } + } + }); + + it("uses two top and three bottom cells for the five-tile layout", () => { + const rects = computeTileRects(5, 120, 30); + expect(rects.map((rect) => [rect.x, rect.y, rect.w, rect.h])).toEqual([ + [0, 0, 60, 15], + [60, 0, 60, 15], + [0, 15, 40, 15], + [40, 15, 40, 15], + [80, 15, 40, 15], + ]); + }); + + it("flags layouts that are too narrow or too short for readable tiles", () => { + expect(canRenderMultiChatGrid(6, 120, 24)).toBe(true); + expect(canRenderMultiChatGrid(6, 80, 24)).toBe(false); + expect(canRenderMultiChatGrid(4, 120, 12)).toBe(false); + }); + + it("resolves the focused session id safely", () => { + expect(focusedSessionIdForMultiView(null)).toBeNull(); + expect(focusedSessionIdForMultiView({ + focusedIndex: 1, + tiles: [ + { sessionId: "a", laneId: "l1" }, + { sessionId: "b", laneId: "l2" }, + ], + })).toBe("b"); + }); +}); diff --git a/apps/ade-cli/src/tuiClient/aggregate.ts b/apps/ade-cli/src/tuiClient/aggregate.ts index 127a3ba9a..7a29f3940 100644 --- a/apps/ade-cli/src/tuiClient/aggregate.ts +++ b/apps/ade-cli/src/tuiClient/aggregate.ts @@ -332,29 +332,28 @@ function runtimeStatus(value: unknown): RuntimeActivityEntry["status"] { return "info"; } +// Activity events that mirror the real tool/command/file events the transcript +// already renders. Suppress them in the TUI so the same fragment doesn't appear +// twice (once as Runtime, once as Tool calls). Matches desktop suppression. +const SUPPRESSED_RUNTIME_ACTIVITIES = new Set([ + "thinking", + "working", + "tool_calling", + "searching", + "reading", + "running_command", + "editing_file", + "web_searching", +]); + function runtimeActivityFromEvent(id: string, event: AgentChatEvent): RuntimeActivityEntry | null { if (event.type === "activity") { const activity = event.activity; - const detail = compactActivityDetail(event.detail); - // The desktop transcript intentionally hides activity events because they are - // a live status shadow of the real tool/command/file events. Showing them in - // the TUI makes the same transcript fragment into Runtime + Tool calls pairs. - if ( - activity === "thinking" - || activity === "working" - || activity === "tool_calling" - || activity === "searching" - || activity === "reading" - || activity === "running_command" - || activity === "editing_file" - || activity === "web_searching" - ) { - return null; - } + if (SUPPRESSED_RUNTIME_ACTIVITIES.has(activity)) return null; return { id, label: activity.replace(/_/g, " "), - detail, + detail: compactActivityDetail(event.detail), status: "running", }; } diff --git a/apps/ade-cli/src/tuiClient/app.tsx b/apps/ade-cli/src/tuiClient/app.tsx index 8ca638f3e..eaae72bac 100644 --- a/apps/ade-cli/src/tuiClient/app.tsx +++ b/apps/ade-cli/src/tuiClient/app.tsx @@ -106,12 +106,14 @@ import { import { TerminalPane, clampTerminalPaneCols } from "./components/TerminalPane"; import { Header } from "./components/Header"; import { computeLaneChatCounts, LANE_DETAIL_ACTIONS, LANE_DETAIL_PR_ACTION_INDEX, RightPane } from "./components/RightPane"; -import { buildModelPickerLayout, defaultSelectionFor } from "./components/ModelPicker/modelPickerLayout"; +import { buildModelPickerLayout, defaultSelectionFor, railEntrySelection } from "./components/ModelPicker/modelPickerLayout"; import { SlashPalette, SLASH_PALETTE_ROWS } from "./components/SlashPalette"; import { MentionPalette, MENTION_PALETTE_ROWS } from "./components/MentionPalette"; import { ApprovalPrompt } from "./components/ApprovalPrompt"; import { ModelStatus } from "./components/ModelStatus"; import { FooterControls } from "./components/FooterControls"; +import { MultiChatGrid } from "./components/MultiChatGrid"; +import { AddChatModeBanner } from "./components/AddChatMode"; import { theme } from "./theme"; import { resolveTuiChatRefreshTarget } from "./project"; import { resolveDrawerChatSelection } from "./drawerSelection"; @@ -151,6 +153,16 @@ import { type SubagentPaneRow, } from "./subagentPane"; import { readClaudeStatusLineConfig, runClaudeStatusLineCommand } from "./statusline"; +import { + createHitTestRegistry, + HitTestProvider, + type HitTarget, +} from "./hitTestRegistry"; +import { + focusedSessionIdForMultiView, + type MultiViewState, + type MultiViewTile, +} from "./multiChatLayout"; import type { AdeCodeConnection, AdeCodeProvider, @@ -186,7 +198,8 @@ const CLAUDE_PERMISSION_OPTIONS = ["default", "auto", "plan", "acceptEdits", "by const OPENCODE_PERMISSION_OPTIONS = ["plan", "edit", "full-auto"] as const; const DROID_PERMISSION_OPTIONS = ["read-only", "auto-low", "auto-medium", "auto-high"] as const; const SETTINGS_AI_ROUTE = "/settings?tab=ai#ai-providers"; -type PaneFocus = "drawer" | "chat" | "details"; +type PaneFocus = "drawer" | "chat" | "details" | "addMode"; +type AddModeState = { cursorLaneId: string; cursorChatId: string | null }; export type FooterControl = "drawer" | "details" | "agents"; type DrawerLaneAction = "new-lane"; type DrawerChatAction = "new-chat"; @@ -1410,8 +1423,18 @@ function useTerminalAlternateScroll(): void { }, []); } +function useTerminalAlternateScreen(): void { + useEffect(() => { + if (!process.stdin.isTTY || !process.stdout.isTTY) return; + process.stdout.write("\x1b[?1049h"); + return () => { + process.stdout.write("\x1b[?1049l"); + }; + }, []); +} + type TerminalMouseInput = { - kind: "wheel" | "click" | "drag" | "release" | "other"; + kind: "wheel" | "click" | "drag" | "release" | "move" | "other"; x: number | null; y: number | null; direction?: "up" | "down" | "left" | "right"; @@ -1447,6 +1470,7 @@ function decodeMouseButton(code: number, x: number | null, y: number | null, pre if (wheelButton === 2) return withMouseModifiers({ kind: "wheel", direction: "left", x, y }, code); return withMouseModifiers({ kind: "wheel", direction: "right", x, y }, code); } + if ((code & 32) && (code & 3) === 3) return withMouseModifiers({ kind: "move", x, y }, code); if ((code & 32) && (code & 3) === 0) return withMouseModifiers({ kind: "drag", x, y }, code); if ((code & 3) === 0) return withMouseModifiers({ kind: "click", x, y }, code); return withMouseModifiers({ kind: "other", x, y }, code); @@ -1554,54 +1578,6 @@ export function drawerMouseHitForLine({ return null; } -export function laneDetailsActionIndexForMouseLine(y: number | null, actionCount: number): number | null { - if (y == null || actionCount <= 0) return null; - const firstActionLine = 18; - const index = y - firstActionLine; - return index >= 0 && index < actionCount ? index : null; -} - -export function setupPaneRowIndexForMouseLine({ - y, - rowCount, - selectedIndex, - hasLaneLabel, -}: { - y: number | null; - rowCount: number; - selectedIndex: number; - hasLaneLabel: boolean; -}): number | null { - if (y == null || rowCount <= 0) return null; - let line = hasLaneLabel ? 7 : 6; - for (let index = 0; index < rowCount; index += 1) { - if (y === line || (index === selectedIndex && y === line + 1)) return index; - line += index === selectedIndex ? 2 : 1; - } - return null; -} - -export function formFieldIndexForMouseLine({ - y, - fieldCount, - command, -}: { - y: number | null; - fieldCount: number; - command: string; -}): number | null { - if (y == null || fieldCount <= 0) return null; - if (command === "lane-delete") { - if (y >= 9 && y <= 11) return fieldCount > 0 ? 0 : null; - if (y >= 13 && y <= 14) return fieldCount > 2 ? 2 : null; - if (y >= 16 && y <= 17) return fieldCount > 1 ? 1 : null; - if (y >= 19 && y <= 20) return fieldCount > 3 ? 3 : null; - return null; - } - const index = y - 5; - return index >= 0 && index < fieldCount ? index : null; -} - type LaneDeleteScope = "worktree" | "local_branch" | "remote_branch"; const LANE_DELETE_SCOPES: LaneDeleteScope[] = ["worktree", "local_branch", "remote_branch"]; @@ -1727,7 +1703,7 @@ function useTerminalMouseTracking(): void { if (!process.stdin.isTTY || !process.stdout.isTTY) return; disableTerminalMouseTracking(); if (!isTerminalMouseTrackingEnabled(process.env.ADE_TUI_MOUSE)) return; - process.stdout.write("\x1b[?1000h\x1b[?1002h\x1b[?1006h\x1b[?1015h"); + process.stdout.write("\x1b[?1000h\x1b[?1002h\x1b[?1003h\x1b[?1006h\x1b[?1015h"); return () => { disableTerminalMouseTracking(); }; @@ -1944,6 +1920,7 @@ function resolveCenterPaneWidth(columns: number, drawerOpen: boolean, rightPaneW export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath }: AdeCodeAppProps) { const { exit } = useApp(); const [columns, rows] = useTerminalDimensions(); + useTerminalAlternateScreen(); useTerminalAlternateScroll(); useTerminalMouseTracking(); const [connection, setConnection] = useState<AdeCodeConnection | null>(null); @@ -1992,7 +1969,16 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } const [vimModeEnabled, setVimModeEnabled] = useState(() => readClaudeVimMode(project.workspaceRoot)); const [vimMode, setVimMode] = useState<"insert" | "normal">("insert"); const [hideVimModeIndicator, setHideVimModeIndicator] = useState(false); - const [streaming, setStreaming] = useState(false); + const [streamingBySessionId, setStreamingBySessionId] = useState<Record<string, boolean>>({}); + const [interruptedBySessionId, setInterruptedBySessionId] = useState<Record<string, boolean>>({}); + const [eventsBySessionId, setEventsBySessionId] = useState<Record<string, AgentChatEventEnvelope[]>>({}); + const [multiView, setMultiView] = useState<MultiViewState | null>(null); + const [scrollBySessionId, setScrollBySessionId] = useState<Record<string, number>>({}); + const [selectionBySessionId, setSelectionBySessionId] = useState<Record<string, ChatTextSelection | null>>({}); + const [promptHistoryBySessionId, setPromptHistoryBySessionId] = useState<Record<string, string[]>>({}); + const [addMode, setAddMode] = useState<AddModeState | null>(null); + const [multiViewNotice, setMultiViewNotice] = useState<string | null>(null); + const [hoveredHitId, setHoveredHitId] = useState<string | null>(null); const [interrupted, setInterrupted] = useState(false); const [chatMouseSelection, setChatMouseSelection] = useState<ChatSelectionState | null>(null); const [clearedAt, setClearedAt] = useState<string | null>(null); @@ -2023,6 +2009,17 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } const connectionRef = useRef<AdeCodeConnection | null>(null); const activeLaneIdRef = useRef<string | null>(null); const activeSessionIdRef = useRef<string | null>(null); + const multiViewRef = useRef<MultiViewState | null>(null); + const addModeRef = useRef<AddModeState | null>(null); + const streamingBySessionIdRef = useRef<Record<string, boolean>>({}); + const interruptedBySessionIdRef = useRef<Record<string, boolean>>({}); + const eventsBySessionIdRef = useRef<Record<string, AgentChatEventEnvelope[]>>({}); + const promptHistoryBySessionIdRef = useRef<Record<string, string[]>>({}); + const dragAddSessionRef = useRef<MultiViewTile | null>(null); + const hitTestRegistryRef = useRef(createHitTestRegistry()); + const hoveredTargetRef = useRef<HitTarget | null>(null); + const appHitTargetIdsRef = useRef<string[]>([]); + const previousDimensionsRef = useRef<[number, number]>([columns, rows]); const draftChatActiveRef = useRef(false); const formDiscardArmedRef = useRef(false); const activePaneRef = useRef<PaneFocus>("chat"); @@ -2038,6 +2035,8 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } const promptHistoryRef = useRef<string[]>([]); const promptHistoryIndexRef = useRef<number | null>(null); const promptHistoryDraftRef = useRef(""); + const promptHistoryIndexBySessionIdRef = useRef<Record<string, number | null>>({}); + const promptHistoryDraftBySessionIdRef = useRef<Record<string, string>>({}); const rightPaneKindRef = useRef<RightPaneContent["kind"]>("empty"); const lastLocalSendAtRef = useRef<number>(0); const eventCountRef = useRef<number>(0); @@ -2060,6 +2059,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } const lastUserOpenedPaneRef = useRef<RightPaneContent["kind"] | null>(null); const userDismissedRightPaneRef = useRef(false); const activeSessionRef = useRef<AgentChatSessionSummary | null>(null); + const sessionsRef = useRef<AgentChatSessionSummary[]>([]); const activeTerminalSessionRef = useRef<ChatTerminalSession | null>(null); const terminalSessionsRef = useRef<ChatTerminalSession[]>([]); const attachedTerminalIdRef = useRef<string | null>(null); @@ -2087,6 +2087,58 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } const pendingModelCommitTimerRef = useRef<NodeJS.Timeout | null>(null); const pendingModelCommitStateRef = useRef<AdeCodeModelState | null>(null); + useEffect(() => { + multiViewRef.current = multiView; + }, [multiView]); + + useEffect(() => { + addModeRef.current = addMode; + }, [addMode]); + + useEffect(() => { + streamingBySessionIdRef.current = streamingBySessionId; + }, [streamingBySessionId]); + + useEffect(() => { + interruptedBySessionIdRef.current = interruptedBySessionId; + }, [interruptedBySessionId]); + + useEffect(() => { + eventsBySessionIdRef.current = eventsBySessionId; + }, [eventsBySessionId]); + + useEffect(() => { + promptHistoryBySessionIdRef.current = promptHistoryBySessionId; + }, [promptHistoryBySessionId]); + + const setSessionStreaming = useCallback((sessionId: string | null | undefined, value: boolean) => { + if (!sessionId) { + if (!value) setStreamingBySessionId({}); + return; + } + setStreamingBySessionId((prev) => { + if ((prev[sessionId] ?? false) === value) return prev; + return { ...prev, [sessionId]: value }; + }); + }, []); + + const setStreaming = useCallback((value: boolean) => { + setSessionStreaming(activeSessionIdRef.current, value); + }, [setSessionStreaming]); + + const setSessionInterrupted = useCallback((sessionId: string | null | undefined, value: boolean) => { + if (!sessionId) { + if (!value) setInterruptedBySessionId({}); + return; + } + setInterruptedBySessionId((prev) => { + if ((prev[sessionId] ?? false) === value) return prev; + return { ...prev, [sessionId]: value }; + }); + }, []); + + const streaming = activeSessionId ? !!streamingBySessionId[activeSessionId] : false; + const persistAdeCodeState = useCallback(() => { if (lastChatByLaneWriteTimerRef.current) { clearTimeout(lastChatByLaneWriteTimerRef.current); @@ -2102,6 +2154,15 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } }, [project.projectRoot]); const setChatScrollOffset = useCallback((value: number | ((previous: number) => number)) => { + const multiSessionId = focusedSessionIdForMultiView(multiViewRef.current); + if (multiSessionId) { + setScrollBySessionId((prev) => { + const previous = prev[multiSessionId] ?? 0; + const raw = typeof value === "function" ? value(previous) : value; + return { ...prev, [multiSessionId]: clampChatScrollOffsetRows(raw, chatScrollMaxOffsetRef.current) }; + }); + return; + } setChatScrollOffsetRows((previous) => { const raw = typeof value === "function" ? value(previous) : value; const next = clampChatScrollOffsetRows(raw, chatScrollMaxOffsetRef.current); @@ -2121,8 +2182,9 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } setTokenSummary(null); setStatusLineStats(null); setStreaming(false); + setSessionInterrupted(activeSessionIdRef.current, false); setInterrupted(false); - }, []); + }, [setSessionInterrupted, setStreaming]); const selectActiveLaneId = useCallback((laneId: string | null) => { if (activeLaneIdRef.current !== laneId) { @@ -2179,6 +2241,15 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } setActivePane(pane); }, []); + useEffect(() => { + const previous = previousDimensionsRef.current; + previousDimensionsRef.current = [columns, rows]; + if (addModeRef.current && (previous[0] !== columns || previous[1] !== rows)) { + setAddMode(null); + setPaneFocus("chat"); + } + }, [columns, rows, setPaneFocus]); + const selectFooterControl = useCallback((control: FooterControl | null) => { footerControlRef.current = control; setFooterControl(control); @@ -2206,6 +2277,10 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } useEffect(() => { chatMouseSelectionRef.current = chatMouseSelection; + const focusedSessionId = focusedSessionIdForMultiView(multiViewRef.current); + if (focusedSessionId) { + setSelectionBySessionId((prev) => ({ ...prev, [focusedSessionId]: chatMouseSelection })); + } }, [chatMouseSelection]); useEffect(() => { @@ -2446,6 +2521,28 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } }), [sessions, terminalSessions], ); + const sessionBySessionId = useMemo(() => { + const out: Record<string, AgentChatSessionSummary> = {}; + for (const session of displaySessions) out[session.sessionId] = session; + return out; + }, [displaySessions]); + const tileableSessionIds = useMemo(() => new Set(sessions.map((session) => session.sessionId)), [sessions]); + const tileableDisplaySessions = useMemo( + () => displaySessions.filter((session) => tileableSessionIds.has(session.sessionId)), + [displaySessions, tileableSessionIds], + ); + const lanesById = useMemo(() => { + const out: Record<string, LaneSummary> = {}; + for (const lane of lanes) out[lane.id] = lane; + return out; + }, [lanes]); + useEffect(() => { + if (!activeSessionId || multiViewRef.current) return; + setEventsBySessionId((prev) => { + if (prev[activeSessionId] === events) return prev; + return { ...prev, [activeSessionId]: events }; + }); + }, [activeSessionId, events]); const claudeTerminalControlAvailable = Boolean( activeTerminalSession && activeTerminalSession.status === "running" @@ -2601,7 +2698,10 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } useEffect(() => { promptHistoryRef.current = promptHistory; promptHistoryIndexRef.current = null; - }, [promptHistory]); + if (activeSessionId) { + setPromptHistoryBySessionId((prev) => ({ ...prev, [activeSessionId]: promptHistory.slice(-100) })); + } + }, [activeSessionId, promptHistory]); useEffect(() => { setVimModeEnabled(readClaudeVimMode(project.workspaceRoot)); setVimMode("insert"); @@ -2639,6 +2739,18 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } const index = drawerVisibleLaneSessions.findIndex((session) => session.sessionId === targetId); return index >= 0 ? index : 0; }, [activeLaneId, activeSessionId, drawerLaneId, drawerVisibleLaneSessions, selectedDrawerChatAction, selectedDrawerChatId]); + const addModeLaneIndex = useMemo(() => { + if (!addMode) return selectedLaneIndex; + const index = drawerLaneRows.findIndex((lane) => lane.id === addMode.cursorLaneId); + return index >= 0 ? index : 0; + }, [addMode, drawerLaneRows, selectedLaneIndex]); + const addModeChatIndex = useMemo(() => { + if (!addMode) return selectedChatIndex; + const allLaneSessions = tileableDisplaySessions.filter((session) => session.laneId === addMode.cursorLaneId); + const laneSessions = allLaneSessions.slice(0, visibleDrawerChatCount(allLaneSessions.length)); + const index = laneSessions.findIndex((session) => session.sessionId === addMode.cursorChatId); + return index >= 0 ? index : 0; + }, [addMode, selectedChatIndex, tileableDisplaySessions]); const applyDrawerChatSelection = useCallback(( selection: { session: AgentChatSessionSummary | null; action: DrawerChatAction | null }, ) => { @@ -2648,6 +2760,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } setEvents([]); setStreaming(false); setInterrupted(false); + setSessionInterrupted(activeSessionIdRef.current, false); setCurrentGoal(null); setContextPercent(null); setTokenSummary(null); @@ -2705,6 +2818,8 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } setContextPercent(null); setTokenSummary(null); setStatusLineStats(null); + setSessionStreaming(sessionId, false); + setSessionInterrupted(sessionId, false); setStreaming(false); setInterrupted(false); return; @@ -2718,19 +2833,23 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } eventCountRef.current = history.events.length; eventDedupKeyOrderRef.current = syncTuiEventDedupKeys(eventDedupKeysRef.current, historyEvents); setEvents(historyEvents); + setEventsBySessionId((prev) => ({ ...prev, [sessionId]: historyEvents })); setCurrentGoal(latestGoal(history.events)); const fallbackContext = session.modelId ? getModelById(session.modelId)?.contextWindow ?? null : null; const stats = latestTokenStats(history.events, fallbackContext); setContextPercent(stats.percent); setTokenSummary(formatTokenSummary(stats)); setStatusLineStats(stats); - setStreaming(session.status === "active"); - if (session.status === "active") setInterrupted(false); + setSessionStreaming(sessionId, session.status === "active"); + if (session.status === "active") { + setSessionInterrupted(sessionId, false); + setInterrupted(false); + } } catch { // Best-effort preview hydration; leave prior content on transient errors. } })(); - }, [selectActiveLaneId, selectActiveSessionId, setDraftChatMode]); + }, [selectActiveLaneId, selectActiveSessionId, setDraftChatMode, setSessionInterrupted, setSessionStreaming, setStreaming]); const enterDrawerChatListForLane = useCallback((lane: LaneSummary) => { const laneSessions = displaySessions.filter((entry) => entry.laneId === lane.id); const visibleSessions = laneSessions.slice(0, visibleDrawerChatCount(laneSessions.length)); @@ -2767,12 +2886,13 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } const modelStatusOverlayRows = statusRows + (draftChatActive || (vimModeEnabled && !hideVimModeIndicator) || modelState.codexFastMode ? 1 : 0); const goalBannerRows = goalBannerText ? 1 : 0; + const addModeRows = addMode ? 1 : 0; const rightPaneMaxWidth = RIGHT_PANE_MAX_WIDTH; const rightPaneWidth = resolveRightPaneWidth(columns, rightOpen, drawerOpen, rightPaneMaxWidth); const centerWidth = resolveCenterPaneWidth(columns, drawerOpen, rightPaneWidth); const promptPaneWidth = Math.max(MIN_CENTER_PANE_WIDTH, finiteFloor(columns, MIN_CENTER_PANE_WIDTH)); const promptRows = promptDisplayRows(prompt, Math.max(1, promptPaneWidth - 5), PROMPT_MAX_ROWS); - const chatRowBudget = Math.max(4, rows - 8 - (promptRows.length - 1) - statusRows - goalBannerRows); + const chatRowBudget = Math.max(4, rows - 8 - (promptRows.length - 1) - statusRows - goalBannerRows - addModeRows); // Only cap the prose chat wrap width. Embedded CLI terminals should track the // center pane so PTYs resize when panes or the host window resize. const chatWrapWidth = resolveChatWrapWidth(centerWidth, drawerOpen, rightPaneWidth); @@ -2795,6 +2915,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } updateChatMouseSelection(null); }, [selectedAgentSnapshot?.id, stopChatSelectionEdgeScroll, updateChatMouseSelection]); const spinTickActive = displayStreaming + || (multiView?.tiles.some((tile) => streamingBySessionId[tile.sessionId]) ?? false) || mode === "connecting" || (drawerOpen && displaySessions.some(isChatSessionAnimating)) || (activeTerminalSession != null && isTerminalSessionWorking(activeTerminalSession)) @@ -2914,6 +3035,10 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } activeSessionRef.current = activeSession; }, [activeSession]); + useEffect(() => { + sessionsRef.current = sessions; + }, [sessions]); + useEffect(() => { activeTerminalSessionRef.current = activeTerminalSession; }, [activeTerminalSession]); @@ -3537,6 +3662,201 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } ]); }, []); + const flashMultiViewNotice = useCallback((text: string) => { + setMultiViewNotice(text); + setTimeout(() => { + setMultiViewNotice((current) => current === text ? null : current); + }, 1000); + }, []); + + const recordPromptHistoryForSession = useCallback((sessionId: string | null | undefined, text: string) => { + const trimmed = text.trim(); + if (!sessionId || !trimmed) return; + promptHistoryIndexBySessionIdRef.current[sessionId] = null; + setPromptHistoryBySessionId((prev) => ({ + ...prev, + [sessionId]: [...(prev[sessionId] ?? []).filter((entry) => entry !== trimmed), trimmed].slice(-100), + })); + }, []); + + const hydrateTileHistory = useCallback(async (sessionId: string) => { + const conn = connectionRef.current; + if (!conn || activeTerminalSessionRef.current?.terminalId === sessionId) return; + const history = await getChatHistory(conn, sessionId); + if (history.sessionFound === false) return; + const nextEvents = clearedAt + ? history.events.filter((event) => event.timestamp > clearedAt) + : history.events; + setEventsBySessionId((prev) => ({ ...prev, [sessionId]: nextEvents })); + const historyPrompts = history.events + .map((envelope) => envelope.event) + .filter((event): event is Extract<AgentChatEventEnvelope["event"], { type: "user_message" }> => event.type === "user_message") + .map((event) => (event.displayText || event.text || "").trim()) + .filter(Boolean) + .slice(-100); + if (historyPrompts.length) { + setPromptHistoryBySessionId((prev) => ({ ...prev, [sessionId]: historyPrompts })); + } + }, [clearedAt]); + + const focusMultiViewTile = useCallback((index: number) => { + setMultiView((prev) => { + if (!prev) return prev; + const focusedIndex = Math.max(0, Math.min(index, prev.tiles.length - 1)); + return focusedIndex === prev.focusedIndex ? prev : { ...prev, focusedIndex }; + }); + setPaneFocus("chat"); + }, [setPaneFocus]); + + const removeMultiViewTile = useCallback((index: number) => { + const prev = multiViewRef.current; + if (!prev) return; + const tiles = prev.tiles.filter((_, tileIndex) => tileIndex !== index); + const survivor = tiles.length < 2 ? tiles[0] ?? null : null; + setMultiView(tiles.length < 2 ? null : { tiles, focusedIndex: Math.min(prev.focusedIndex, tiles.length - 1) }); + if (survivor) { + selectActiveLaneId(survivor.laneId); + selectActiveSessionId(survivor.sessionId); + } + setPaneFocus("chat"); + }, [selectActiveLaneId, selectActiveSessionId, setPaneFocus]); + + const isTileableChatSessionId = useCallback((sessionId: string | null | undefined) => { + if (!sessionId) return false; + return sessionsRef.current.some((session) => session.sessionId === sessionId); + }, []); + + const addTileToGrid = useCallback((sessionId: string, laneId: string) => { + if (!sessionId || !laneId) return; + if (!isTileableChatSessionId(sessionId)) { + flashMultiViewNotice("Only agent chats can be split right now"); + addNotice("Only agent chats can be added to split view.", "info"); + setPaneFocus("addMode"); + return; + } + const prev = multiViewRef.current; + if (prev) { + const tiles = prev.tiles.filter((tile) => isTileableChatSessionId(tile.sessionId)); + const focusedIndex = Math.max(0, Math.min(prev.focusedIndex, Math.max(0, tiles.length - 1))); + const existingIndex = tiles.findIndex((tile) => tile.sessionId === sessionId); + if (existingIndex >= 0) { + setMultiView({ tiles, focusedIndex: existingIndex }); + setAddMode(null); + setPaneFocus("chat"); + return; + } + if (tiles.length >= 6) { + flashMultiViewNotice("Multi-view full (max 6)"); + setAddMode(null); + setPaneFocus("chat"); + return; + } + if (!tiles.length) { + setMultiView(null); + selectActiveLaneId(laneId); + selectActiveSessionId(sessionId); + } else { + setMultiView({ + tiles: [...tiles, { sessionId, laneId }], + focusedIndex: Math.max(focusedIndex, tiles.length), + }); + } + } else { + const currentSessionId = activeSessionIdRef.current; + const currentLaneId = activeLaneIdRef.current; + if (!currentSessionId || !currentLaneId || !isTileableChatSessionId(currentSessionId)) { + selectActiveLaneId(laneId); + selectActiveSessionId(sessionId); + } else if (currentSessionId !== sessionId) { + setMultiView({ + tiles: [ + { sessionId: currentSessionId, laneId: currentLaneId }, + { sessionId, laneId }, + ], + focusedIndex: 1, + }); + } + } + void hydrateTileHistory(sessionId).catch((err) => addNotice(err instanceof Error ? err.message : String(err), "error")); + const currentSessionId = activeSessionIdRef.current; + if (currentSessionId && isTileableChatSessionId(currentSessionId) && !eventsBySessionIdRef.current[currentSessionId]) { + void hydrateTileHistory(currentSessionId).catch(() => undefined); + } + setAddMode(null); + setPaneFocus("chat"); + }, [addNotice, flashMultiViewNotice, hydrateTileHistory, isTileableChatSessionId, selectActiveLaneId, selectActiveSessionId, setPaneFocus]); + + const startAddMode = useCallback(() => { + const firstLane = orderedDrawerLanes[0] ?? null; + const laneId = activeLaneIdRef.current ?? drawerLaneIdRef.current ?? firstLane?.id ?? null; + if (!laneId) { + addNotice("No lanes are available to add chats from.", "error"); + return; + } + const laneSessions = tileableDisplaySessions.filter((session) => session.laneId === laneId); + const cursorChatId = activeSessionIdRef.current && laneSessions.some((session) => session.sessionId === activeSessionIdRef.current) + ? activeSessionIdRef.current + : laneSessions[0]?.sessionId ?? null; + setAddMode({ cursorLaneId: laneId, cursorChatId }); + setDrawerOpen(true); + setDrawerSection("chats"); + setDrawerLaneId(laneId); + setPaneFocus("addMode"); + }, [addNotice, orderedDrawerLanes, setPaneFocus, tileableDisplaySessions]); + + const cancelAddMode = useCallback(() => { + setAddMode(null); + focusChat(); + }, [focusChat]); + + const moveAddModeCursor = useCallback((direction: "up" | "down" | "left" | "right") => { + setAddMode((prev) => { + if (!prev) return prev; + const laneIndex = Math.max(0, orderedDrawerLanes.findIndex((lane) => lane.id === prev.cursorLaneId)); + if (direction === "left" || direction === "right") { + const delta = direction === "right" ? 1 : -1; + const nextLane = orderedDrawerLanes[(laneIndex + delta + orderedDrawerLanes.length) % orderedDrawerLanes.length]; + if (!nextLane) return prev; + const nextSessions = tileableDisplaySessions.filter((session) => session.laneId === nextLane.id); + return { cursorLaneId: nextLane.id, cursorChatId: nextSessions[0]?.sessionId ?? null }; + } + const laneSessions = tileableDisplaySessions.filter((session) => session.laneId === prev.cursorLaneId); + if (!laneSessions.length) return prev; + const currentIndex = Math.max(0, laneSessions.findIndex((session) => session.sessionId === prev.cursorChatId)); + const delta = direction === "down" ? 1 : -1; + const nextSession = laneSessions[(currentIndex + delta + laneSessions.length) % laneSessions.length]; + return { ...prev, cursorChatId: nextSession?.sessionId ?? null }; + }); + }, [orderedDrawerLanes, tileableDisplaySessions]); + + const confirmAddMode = useCallback(() => { + const current = addModeRef.current; + if (!current?.cursorChatId) { + addNotice("This lane has no chat to add.", "info"); + return; + } + addTileToGrid(current.cursorChatId, current.cursorLaneId); + }, [addNotice, addTileToGrid]); + + useEffect(() => { + if (!multiView) return; + const tile = multiView.tiles[multiView.focusedIndex] ?? multiView.tiles[0] ?? null; + if (!tile) return; + if (tile.laneId !== activeLaneIdRef.current) { + selectActiveLaneId(tile.laneId); + setDrawerLaneId(tile.laneId); + setSelectedDrawerLaneId(tile.laneId); + } + if (tile.sessionId !== activeSessionIdRef.current) { + selectActiveSessionId(tile.sessionId); + setSelectedDrawerChatId(tile.sessionId); + setSelectedDrawerChatAction(null); + } + if (!eventsBySessionIdRef.current[tile.sessionId]) { + void hydrateTileHistory(tile.sessionId).catch(() => undefined); + } + }, [hydrateTileHistory, multiView, selectActiveLaneId, selectActiveSessionId]); + useEffect(() => { if (!connection || !attachedTerminalId) return; const handleRawInput = (chunk: Buffer | string) => { @@ -4158,13 +4478,18 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } loadedSessionIdRef.current = nextSessionId; } } - setStreaming(selectedSessionFound && nextSession?.status === "active"); - if (selectedSessionFound && nextSession?.status === "active") setInterrupted(false); + setSessionStreaming(nextSessionId, selectedSessionFound && nextSession?.status === "active"); + if (selectedSessionFound && nextSession?.status === "active") { + setSessionInterrupted(nextSessionId, false); + setInterrupted(false); + } } else { setContextPercent(null); setTokenSummary(null); setStatusLineStats(null); setCurrentGoal(null); + setSessionStreaming(nextSessionId, false); + setSessionInterrupted(nextSessionId, false); setStreaming(false); setInterrupted(false); eventCountRef.current = 0; @@ -4205,6 +4530,9 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } if (nextEvents !== null) { eventDedupKeyOrderRef.current = syncTuiEventDedupKeys(eventDedupKeysRef.current, nextEvents); setEvents(nextEvents); + if (nextSessionId) { + setEventsBySessionId((prev) => ({ ...prev, [nextSessionId]: nextEvents ?? [] })); + } } setSlashCommands(nextCommands); setModels(nextModels); @@ -4267,7 +4595,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } })); if (draftMode) draftSeededFromHistoryRef.current = true; } - }, [clearedAt, drawerLaneId, loadProviderModels, modelState.provider, project, selectActiveLaneId, selectActiveSessionId, selectedDrawerChatAction, setDraftChatMode]); + }, [clearedAt, drawerLaneId, loadProviderModels, modelState.provider, project, selectActiveLaneId, selectActiveSessionId, selectedDrawerChatAction, setDraftChatMode, setSessionInterrupted, setSessionStreaming, setStreaming]); const commitModelStateToSession = useCallback(async (nextState: AdeCodeModelState) => { const conn = connectionRef.current; @@ -4419,31 +4747,45 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } useEffect(() => { if (!connection) return; return connection.onChatEvent((envelope) => { - if (envelope.sessionId !== activeSessionIdRef.current) { + const currentMultiView = multiViewRef.current; + const openSessionIds = new Set( + currentMultiView + ? currentMultiView.tiles.map((tile) => tile.sessionId) + : [activeSessionIdRef.current].filter((value): value is string => Boolean(value)), + ); + if (!openSessionIds.has(envelope.sessionId)) { void refreshState({ hydrateHistory: false }).catch(() => undefined); return; } if (clearedAt && envelope.timestamp <= clearedAt) return; const event = envelope.event as Record<string, unknown>; - const reservedKey = reserveTuiEventDedupKey(envelope, eventDedupKeysRef.current); - if (reservedKey !== null) { - setEvents((prev) => { - const next = appendReservedTuiEvent( - prev, - envelope, - eventDedupKeysRef.current, - eventDedupKeyOrderRef.current, - reservedKey, - ); - eventDedupKeyOrderRef.current = next.eventKeys; - eventCountRef.current = next.events.length; - return next.events; - }); + const isActiveSessionEvent = envelope.sessionId === activeSessionIdRef.current; + setEventsBySessionId((prev) => ({ + ...prev, + [envelope.sessionId]: [...(prev[envelope.sessionId] ?? []), envelope], + })); + if (isActiveSessionEvent) { + const reservedKey = reserveTuiEventDedupKey(envelope, eventDedupKeysRef.current); + if (reservedKey !== null) { + setEvents((prev) => { + const next = appendReservedTuiEvent( + prev, + envelope, + eventDedupKeysRef.current, + eventDedupKeyOrderRef.current, + reservedKey, + ); + eventDedupKeyOrderRef.current = next.eventKeys; + eventCountRef.current = next.events.length; + return next.events; + }); + } } if (event.type === "status" && event.turnStatus === "started") { - setStreaming(true); - setInterrupted(false); - if (activePaneRef.current !== "drawer") { + setSessionStreaming(envelope.sessionId, true); + setSessionInterrupted(envelope.sessionId, false); + if (isActiveSessionEvent) setInterrupted(false); + if (isActiveSessionEvent && activePaneRef.current !== "drawer") { setRightPane((prev) => { if (prev.kind === "chat-info") return { kind: "chat-info", info: chatInfoRef.current }; if (prev.kind !== "empty" && prev.kind !== "lane-details") return prev; @@ -4453,21 +4795,24 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } } } if (event.type === "status" && event.turnStatus === "interrupted") { - setStreaming(false); - setInterrupted(true); + setSessionStreaming(envelope.sessionId, false); + setSessionInterrupted(envelope.sessionId, true); + if (isActiveSessionEvent) setInterrupted(true); } if (event.type === "done") { - setStreaming(false); - setInterrupted(event.status === "interrupted"); + setSessionStreaming(envelope.sessionId, false); + setSessionInterrupted(envelope.sessionId, event.status === "interrupted"); + if (isActiveSessionEvent) setInterrupted(event.status === "interrupted"); } if (event.type === "status" && (event.turnStatus === "completed" || event.turnStatus === "failed")) { - setStreaming(false); - setInterrupted(false); + setSessionStreaming(envelope.sessionId, false); + setSessionInterrupted(envelope.sessionId, false); + if (isActiveSessionEvent) setInterrupted(false); } if (event.type === "subagent_started" || event.type === "subagent.started") { // Auto-open chat info only when the user is in the chat surface. // Drawer navigation keeps lane details in the right pane. - if (activePaneRef.current === "drawer") return; + if (!isActiveSessionEvent || activePaneRef.current === "drawer") return; setRightPane((prev) => { if (prev.kind === "chat-info") return { kind: "chat-info", info: chatInfoRef.current }; if (prev.kind !== "empty" && prev.kind !== "lane-details") return prev; @@ -4479,7 +4824,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } }); } }); - }, [clearedAt, connection, refreshState, setPaneFocus]); + }, [clearedAt, connection, refreshState, setSessionInterrupted, setSessionStreaming]); useEffect(() => { if (!connection) return; @@ -4926,11 +5271,11 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } } }; const activeTurnVisible = ( - (streaming && sessionId === activeSessionIdRef.current) + (streamingBySessionIdRef.current[sessionId] === true) || sessions.some((session) => session.sessionId === sessionId && session.status === "active") ); if (activeTurnVisible) { - setStreaming(true); + setSessionStreaming(sessionId, true); try { await steerActiveTurn(); } catch (error) { @@ -4944,23 +5289,26 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } await steerActiveTurn(); } } + recordPromptHistoryForSession(sessionId, text); await refreshState(); return; } - setStreaming(true); + setSessionStreaming(sessionId, true); try { await sendChatMessage(conn, sessionId, text, attachments); + recordPromptHistoryForSession(sessionId, text); await refreshState(); } catch (error) { const message = error instanceof Error ? error.message : String(error); if (/turn is already active|already active/i.test(message)) { await steerActiveTurn(); + recordPromptHistoryForSession(sessionId, text); await refreshState(); return; } throw error; } - }, [addNotice, refreshState, sessions, streaming]); + }, [addNotice, recordPromptHistoryForSession, refreshState, sessions, setSessionStreaming]); const runRightCommand = useCallback(async (name: string, args: string) => { const conn = connectionRef.current; @@ -5435,7 +5783,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } setRightPane({ kind: "details", title: "Linear pull", body: `Linear issue ${args} was not found.` }); return; } - const targetSessionId = await ensureActiveSession(); + const targetSessionId = focusedSessionIdForMultiView(multiViewRef.current) ?? await ensureActiveSession(); const issueContext = `Linear issue context:\n${renderObject(issue, 28)}`; if (targetSessionId) { await sendOrSteerChatMessage(targetSessionId, issueContext); @@ -6145,7 +6493,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } await submitClaudePromptToTerminal(activeTerminal, selected.name); return; } - const sessionId = await ensureActiveSession(); + const sessionId = focusedSessionIdForMultiView(multiViewRef.current) ?? await ensureActiveSession(); if (sessionId) { await sendOrSteerChatMessage(sessionId, selected.name); } @@ -6189,7 +6537,8 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } } return; } - const sessionId = await ensureActiveSession(); + const focusedSessionId = focusedSessionIdForMultiView(multiViewRef.current); + const sessionId = focusedSessionId ?? await ensureActiveSession(); if (!sessionId) { addNotice("No active lane is available for chat.", "error"); return; @@ -6600,7 +6949,10 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } }, [addNotice, applyModelState, cycleModel, cyclePermission, cycleProvider, cycleReasoning, focusChat, refreshAiSetupStatus, refreshState, sendClaudeModelCommandToTerminal]); const recallPromptHistory = useCallback((direction: "previous" | "next"): boolean => { - const history = promptHistoryRef.current; + const focusedSessionId = focusedSessionIdForMultiView(multiViewRef.current); + const history = focusedSessionId + ? promptHistoryBySessionIdRef.current[focusedSessionId] ?? [] + : promptHistoryRef.current; if (!history.length) { addNotice("No prompt history in this chat yet.", "info"); return true; @@ -6608,16 +6960,29 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } if (activePaneRef.current !== "chat") { focusChat(); } - let index = promptHistoryIndexRef.current; + let index = focusedSessionId + ? promptHistoryIndexBySessionIdRef.current[focusedSessionId] ?? null + : promptHistoryIndexRef.current; if (index == null) { - promptHistoryDraftRef.current = promptRef.current || chatDraftRef.current; + if (focusedSessionId) { + promptHistoryDraftBySessionIdRef.current[focusedSessionId] = promptRef.current || chatDraftRef.current; + } else { + promptHistoryDraftRef.current = promptRef.current || chatDraftRef.current; + } index = history.length; } const nextIndex = direction === "previous" ? Math.max(0, index - 1) : Math.min(history.length, index + 1); - promptHistoryIndexRef.current = nextIndex >= history.length ? null : nextIndex; - const nextPrompt = nextIndex >= history.length ? promptHistoryDraftRef.current : history[nextIndex] ?? ""; + if (focusedSessionId) { + promptHistoryIndexBySessionIdRef.current[focusedSessionId] = nextIndex >= history.length ? null : nextIndex; + } else { + promptHistoryIndexRef.current = nextIndex >= history.length ? null : nextIndex; + } + const draft = focusedSessionId + ? promptHistoryDraftBySessionIdRef.current[focusedSessionId] ?? "" + : promptHistoryDraftRef.current; + const nextPrompt = nextIndex >= history.length ? draft : history[nextIndex] ?? ""; chatDraftRef.current = nextPrompt; promptRef.current = nextPrompt; setPrompt(nextPrompt); @@ -7035,7 +7400,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } const drawerWidth = resolveDrawerPaneWidth(columns, drawerOpen); const textStartColumn = drawerWidth + 2; const textEndColumn = textStartColumn + Math.max(1, chatWrapWidth) - 1; - const topRow = 2 + goalBannerRows; + const topRow = 3 + goalBannerRows + addModeRows; const bottomRow = topRow + Math.max(1, chatRowBudget) - 1; if (!clampToChat && (x < textStartColumn || x > textEndColumn || y < topRow || y > bottomRow)) { return null; @@ -7043,10 +7408,10 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } const visibleRow = Math.max(0, Math.min(y - topRow, Math.max(0, chatRowBudget - 1))); const column = Math.max(0, Math.min(x - textStartColumn, Math.max(0, chatWrapWidth - 1))); return chatSelectionPointFromVisibleRows(visibleChatSelectionRows, visibleRow, column, clampToChat); - }, [chatRowBudget, chatWrapWidth, drawerOpen, goalBannerRows, visibleChatSelectionRows]); + }, [addModeRows, chatRowBudget, chatWrapWidth, drawerOpen, goalBannerRows, visibleChatSelectionRows]); const chatSelectionEdgeFromMouseY = useCallback((y: number | null): ChatSelectionEdgeDirection | null => { - const topRow = 2 + goalBannerRows; + const topRow = 3 + goalBannerRows + addModeRows; return chatSelectionEdgeDirectionForMouseY({ y, topRow, @@ -7054,7 +7419,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } scrollOffsetRows: chatScrollOffsetRowsRef.current, maxScrollOffsetRows: chatScrollMaxOffsetRef.current, }); - }, [chatRowBudget, goalBannerRows]); + }, [addModeRows, chatRowBudget, goalBannerRows]); useInput((input, key) => { if (attachedTerminalIdRef.current) { @@ -7079,6 +7444,53 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } const rightWidth = resolveRightPaneWidth(columns, rightOpen, drawerOpen); const drawerWidth = resolveDrawerPaneWidth(columns, drawerOpen); const rightStart = columns - rightWidth + 1; + const mainPaneTopRow = 3 + goalBannerRows + addModeRows; + const drawerLocalY = mouse.y == null ? null : mouse.y - mainPaneTopRow + 1; + if (mouse.kind === "move" && mouse.x != null && mouse.y != null) { + const target = hitTestRegistryRef.current.hoverTest(mouse.x, mouse.y); + if (target?.id !== hoveredTargetRef.current?.id) { + hoveredTargetRef.current?.onHover?.(false); + target?.onHover?.(true); + hoveredTargetRef.current = target; + setHoveredHitId(target?.id ?? null); + } + return; + } + if (mouse.kind === "click" && mouse.x != null && mouse.y != null) { + const target = hitTestRegistryRef.current.hitTest(mouse.x, mouse.y); + if (target?.onClick) { + stopChatSelectionEdgeScroll(); + chatSelectionAnchorRef.current = null; + if (activeSelection) updateChatMouseSelection(null); + target.onClick(mouse); + return; + } + } + if (mouse.kind === "drag" && mouse.x != null && mouse.y != null && drawerOpen && mouse.x <= drawerWidth) { + const hit = drawerMouseHitForLine({ + y: drawerLocalY, + laneCount: drawerLaneRows.length, + selectedLaneIndex, + chatCount: drawerVisibleLaneSessions.length, + }); + if (hit?.kind === "chat") { + const session = drawerVisibleLaneSessions[hit.index]; + if (session) { + dragAddSessionRef.current = { sessionId: session.sessionId, laneId: session.laneId }; + return; + } + } + } + if (mouse.kind === "release" && dragAddSessionRef.current) { + const dragged = dragAddSessionRef.current; + dragAddSessionRef.current = null; + const centerStart = drawerWidth + 1; + const centerEnd = columns - rightWidth; + if (mouse.x != null && mouse.x >= centerStart && mouse.x <= centerEnd) { + addTileToGrid(dragged.sessionId, dragged.laneId); + return; + } + } if (mouse.kind === "click") { if (promptHitLine({ y: mouse.y, @@ -7099,7 +7511,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } if (activeSelection) updateChatMouseSelection(null); focusDrawerOnly(); const hit = drawerMouseHitForLine({ - y: mouse.y, + y: drawerLocalY, laneCount: drawerLaneRows.length, selectedLaneIndex, chatCount: drawerVisibleLaneSessions.length, @@ -7137,40 +7549,8 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } if (activeSelection) updateChatMouseSelection(null); setRightOpen(true); focusDetailsOnly(); - if (rightPane.kind === "lane-details") { - const nextActionIndex = laneDetailsActionIndexForMouseLine(mouse.y, LANE_DETAIL_ACTIONS.length); - if (nextActionIndex != null) { - setRightPane({ ...rightPane, selectedActionIndex: nextActionIndex }); - } - } - if (rightPane.kind === "new-chat-setup" || rightPane.kind === "model-setup") { - const nextIndex = setupPaneRowIndexForMouseLine({ - y: mouse.y, - rowCount: rightPane.rows.length, - selectedIndex: rightSelectionIndex, - hasLaneLabel: rightPane.kind === "new-chat-setup", - }); - if (nextIndex != null) setRightSelectionIndex(nextIndex); - } - if (rightPane.kind === "form") { - const nextIndex = formFieldIndexForMouseLine({ - y: mouse.y, - fieldCount: rightPane.fields.length, - command: rightPane.command, - }); - if (nextIndex != null) { - const field = rightPane.fields[nextIndex]; - setFormFieldIndex(nextIndex); - setFormDiscardArmed(false); - if (field && formFieldUsesPromptInput(rightPane.command, field.name)) { - setPrompt(formValues[field.name] ?? field.initialValue ?? ""); - } else { - setPrompt(""); - } - } - } if (rightPane.kind === "chat-info") { - const subagentPaneTop = 4 + goalBannerRows; + const subagentPaneTop = 4 + goalBannerRows + addModeRows; const subagentContent = subagentPaneContentFromRightPane(rightPane); const nextIndex = subagentContent ? subagentIndexForPaneLine(subagentContent, mouse.y - subagentPaneTop, rightSelectionIndex) : null; if (nextIndex != null) setRightSelectionIndex(nextIndex); @@ -7250,7 +7630,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } && mouse.y != null ) { if (mouse.x >= rightStart) { - const subagentPaneTop = 4 + goalBannerRows; + const subagentPaneTop = 4 + goalBannerRows + addModeRows; const subagentContent = subagentPaneContentFromRightPane(rightPane); const nextIndex = subagentContent ? subagentIndexForPaneLine(subagentContent, mouse.y - subagentPaneTop, rightSelectionIndex) : null; if (nextIndex != null) { @@ -7268,6 +7648,46 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } const footerActive = footerControlRef.current != null; const textInputActive = (pane === "chat" && !footerActive) || detailsFormActive; + if (pane === "addMode" || addModeRef.current) { + if (key.escape) { + cancelAddMode(); + return; + } + if (key.return) { + confirmAddMode(); + return; + } + if (key.upArrow || key.downArrow || key.leftArrow || key.rightArrow) { + if (key.upArrow) moveAddModeCursor("up"); + else if (key.downArrow) moveAddModeCursor("down"); + else if (key.leftArrow) moveAddModeCursor("left"); + else moveAddModeCursor("right"); + return; + } + if (isCtrlInput(input, key, "g")) { + cancelAddMode(); + return; + } + return; + } + + if (pane === "chat" && isCtrlInput(input, key, "g")) { + startAddMode(); + return; + } + + if (pane === "chat" && multiViewRef.current && isCtrlInput(input, key, "w")) { + removeMultiViewTile(multiViewRef.current.focusedIndex); + return; + } + + if (pane === "chat" && multiViewRef.current && key.tab && !key.shift) { + setMultiView((prev) => prev + ? { ...prev, focusedIndex: (prev.focusedIndex + 1) % Math.max(1, prev.tiles.length) } + : prev); + return; + } + // Inline model row state machine. Lives above the chat-pane arrow handlers // so navigation events route here first when the row is focused. // Down arrow cycles values in the current cell; up arrow exits to prompt; @@ -8368,6 +8788,493 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } const drawerPaneWidth = resolveDrawerPaneWidth(columns, drawerOpen); const paletteOverlayLeft = drawerPaneWidth; const paletteOverlayWidth = Math.max(MIN_CENTER_PANE_WIDTH, centerWidth); + // Drawer selected-chat index: in add-mode the cursor tracks the candidate + // chat to add; otherwise we only highlight when the drawer is on the chats + // section, leaving lane rows un-marked. + const drawerSelectedChatIndex = (() => { + if (addMode) return addModeChatIndex; + if (drawerSection === "chats") return selectedChatIndex; + return -1; + })(); + + useEffect(() => { + const registry = hitTestRegistryRef.current; + for (const id of appHitTargetIdsRef.current) registry.unregister(id); + const targets: HitTarget[] = []; + const addTarget = (target: HitTarget) => { + targets.push(target); + registry.register(target); + }; + + const promptRowsCount = Math.max(1, promptRows.length); + const promptBoxRows = promptRowsCount + 2; + const firstPromptLine = rows - 1 - modelStatusOverlayRows - promptBoxRows + 1; + addTarget({ + id: "header:context", + rect: { x: 1, y: 1, w: columns, h: 1 }, + onClick: () => { + setDrawerOpen(true); + focusDrawerOnly(); + }, + zIndex: 1, + }); + addTarget({ + id: "prompt:focus", + rect: { x: 1, y: Math.max(1, firstPromptLine - 1), w: promptPaneWidth, h: promptBoxRows + 1 }, + onClick: () => focusChat(), + zIndex: 1, + }); + addTarget({ + id: "footer:model-row", + rect: { x: 1, y: rows, w: Math.max(10, Math.floor(columns * 0.55)), h: 1 }, + onClick: () => { + selectFooterControl(null); + setInlineRowFocus({ cell: providerLockedRef.current ? "model" : "provider" }); + setPaneFocus("chat"); + }, + zIndex: 2, + }); + addTarget({ + id: "footer:lanes", + rect: { x: Math.max(1, columns - 38), y: rows, w: 10, h: 1 }, + onClick: () => toggleDrawerPane(), + zIndex: 3, + }); + addTarget({ + id: "footer:pane", + rect: { x: Math.max(1, columns - 26), y: rows, w: 9, h: 1 }, + onClick: () => toggleDetailsPane(), + zIndex: 3, + }); + addTarget({ + id: "footer:chat-info", + rect: { x: Math.max(1, columns - 15), y: rows, w: 14, h: 1 }, + onClick: () => toggleSubagentsPane(), + zIndex: 3, + }); + + if (drawerOpen && drawerPaneWidth > 0) { + const drawerTopRow = 3 + goalBannerRows + addModeRows; + const addModeLaneSessions = addMode + ? tileableDisplaySessions.filter((session) => session.laneId === addMode.cursorLaneId) + : []; + const registeredDrawerSessions = addMode + ? addModeLaneSessions.slice(0, visibleDrawerChatCount(addModeLaneSessions.length)) + : drawerVisibleLaneSessions; + for (let y = drawerTopRow; y <= rows; y += 1) { + const localY = y - drawerTopRow + 1; + const hit = drawerMouseHitForLine({ + y: localY, + laneCount: drawerLaneRows.length, + selectedLaneIndex: addMode ? addModeLaneIndex : selectedLaneIndex, + chatCount: registeredDrawerSessions.length, + }); + if (hit?.kind === "lane") { + const lane = drawerLaneRows[hit.index]; + if (!lane) continue; + addTarget({ + id: `drawer:lane:${lane.id}:${y}`, + rect: { x: 1, y, w: drawerPaneWidth, h: 1 }, + onClick: () => { + if (addModeRef.current) { + const laneSessions = tileableDisplaySessions.filter((session) => session.laneId === lane.id); + setAddMode({ cursorLaneId: lane.id, cursorChatId: laneSessions[0]?.sessionId ?? null }); + setDrawerLaneId(lane.id); + return; + } + focusDrawerOnly(); + setDrawerSection("lanes"); + setSelectedDrawerLaneAction(null); + setSelectedDrawerLaneId(lane.id); + setDrawerLaneId(lane.id); + selectActiveLaneId(lane.id); + applyDrawerChatSelection({ session: null, action: null }); + }, + zIndex: 2, + }); + } else if (hit?.kind === "chat") { + const session = registeredDrawerSessions[hit.index]; + if (!session) continue; + addTarget({ + id: `drawer:chat:${session.sessionId}:${y}`, + rect: { x: 1, y, w: drawerPaneWidth, h: 1 }, + onClick: () => { + if (addModeRef.current) { + addTileToGrid(session.sessionId, session.laneId); + return; + } + focusDrawerOnly(); + setDrawerSection("chats"); + setSelectedDrawerChatAction(null); + setSelectedDrawerChatId(session.sessionId); + applyDrawerChatSelection({ session, action: null }); + }, + onDragStart: () => { + dragAddSessionRef.current = { sessionId: session.sessionId, laneId: session.laneId }; + }, + zIndex: 2, + }); + } else if (hit?.kind === "new-chat") { + addTarget({ + id: `drawer:new-chat:${y}`, + rect: { x: 1, y, w: drawerPaneWidth, h: 1 }, + onClick: () => { + if (addModeRef.current) return; + focusDrawerOnly(); + setDrawerSection("chats"); + setSelectedDrawerChatAction("new-chat"); + setSelectedDrawerChatId(null); + openNewChatSetup(); + setRightOpen(true); + }, + zIndex: 2, + }); + } + } + } + + if (showMentionPalette) { + mentionSuggestions.forEach((suggestion, index) => { + addTarget({ + id: `mention:${index}`, + rect: { x: paletteOverlayLeft + 1, y: paletteOverlayTop + index + 1, w: paletteOverlayWidth, h: 1 }, + onClick: () => insertMention(suggestion), + onHover: (hovered) => { if (hovered) setMentionIndex(index); }, + zIndex: 20, + }); + }); + } else if (showSlashPalette) { + slashRows.forEach((row, index) => { + addTarget({ + id: `slash:${row.name}:${index}`, + rect: { x: paletteOverlayLeft + 1, y: paletteOverlayTop + index + 1, w: paletteOverlayWidth, h: 1 }, + onClick: () => { + setSlashIndex(index); + setPrompt(`${row.name}${row.argumentHint ? " " : ""}`); + }, + onHover: (hovered) => { if (hovered) setSlashIndex(index); }, + zIndex: 20, + }); + }); + } + + if (pendingApproval?.mode === "approval" && !pendingApproval.highStakes) { + const approvalY = Math.max(1, 2 + goalBannerRows + addModeRows + chatRowBudget - 2); + const centerStart = drawerPaneWidth + 1; + addTarget({ + id: "approval:accept", + rect: { x: centerStart + 1, y: approvalY, w: Math.max(8, Math.floor(centerWidth / 2) - 2), h: 2 }, + onClick: () => { + void resolvePendingApproval(pendingApproval, "accept") + .catch((err) => addNotice(err instanceof Error ? err.message : String(err), "error")); + }, + zIndex: 8, + }); + addTarget({ + id: "approval:decline", + rect: { x: centerStart + Math.max(8, Math.floor(centerWidth / 2)), y: approvalY, w: Math.max(8, Math.floor(centerWidth / 2) - 2), h: 2 }, + onClick: () => { + void resolvePendingApproval(pendingApproval, "decline") + .catch((err) => addNotice(err instanceof Error ? err.message : String(err), "error")); + }, + zIndex: 8, + }); + } + + if (rightPaneVisible && rightPaneWidth > 0) { + const rightStartColumn = columns - rightPaneWidth + 1; + const rightBodyTop = 2 + goalBannerRows + addModeRows; + if (rightPane.kind === "model-picker") { + const picker = rightPane; + const layout = buildModelPickerLayout({ + models, + catalog: modelCatalogRef.current ?? modelCatalog, + favorites: modelPickerFavorites, + recents: modelPickerRecents, + activeModelId: modelState.modelId, + query: picker.query, + selection: picker.selection, + providerTabKey: picker.providerTabKey ?? null, + focusedIndex: picker.focusedIndex, + searchMode: picker.searchMode, + }); + const visibleCapacity = 12; + const half = Math.floor(visibleCapacity / 2); + let windowStart = Math.max(0, layout.focusedIndex - half); + if (windowStart + visibleCapacity > layout.entries.length) { + windowStart = Math.max(0, layout.entries.length - visibleCapacity); + } + const windowEnd = Math.min(layout.entries.length, windowStart + visibleCapacity); + addTarget({ + id: "right:model-picker:search", + rect: { x: rightStartColumn, y: rightBodyTop + 1, w: rightPaneWidth, h: 1 }, + onClick: () => setRightPane({ ...picker, searchMode: true, query: picker.query, focusedIndex: 0 }), + zIndex: 4, + }); + layout.railEntries.forEach((entry, index) => { + addTarget({ + id: `right:model-picker:rail:${index}`, + rect: { x: rightStartColumn, y: rightBodyTop + 6 + index, w: Math.max(8, Math.floor(rightPaneWidth / 4)), h: 1 }, + onClick: () => { + const nextSelection = railEntrySelection(entry); + if (nextSelection.kind === "provider") { + const refreshProvider = + nextSelection.provider === "opencode" || nextSelection.provider === "cursor" || nextSelection.provider === "droid" + || nextSelection.provider === "lmstudio" || nextSelection.provider === "ollama" + ? nextSelection.provider + : null; + if (refreshProvider) void refreshModelCatalog({ refreshProvider }); + } + setRightPane({ + ...picker, + selection: nextSelection, + providerTabKey: null, + focusedIndex: 0, + query: "", + searchMode: false, + }); + }, + zIndex: 4, + }); + }); + layout.providerTabs.forEach((tab, index) => { + addTarget({ + id: `right:model-picker:provider-tab:${tab.key}`, + rect: { x: rightStartColumn + Math.floor(rightPaneWidth / 4) + 1 + index * 13, y: rightBodyTop + 7, w: 13, h: 1 }, + onClick: () => setRightPane({ ...picker, providerTabKey: tab.key, focusedIndex: 0 }), + zIndex: 4, + }); + }); + let modelEntryY = rightBodyTop + 8; + layout.entries.slice(windowStart, windowEnd).forEach((entry, sliceIndex) => { + const index = windowStart + sliceIndex; + const rowHeight = entry.subProvider || !entry.isAvailable ? 2 : 1; + const y = modelEntryY; + addTarget({ + id: `right:model-picker:favorite:${entry.modelId}`, + rect: { x: rightStartColumn + Math.floor(rightPaneWidth / 4) + 2, y, w: 3, h: 1 }, + onClick: () => toggleModelPickerFavoriteId(entry.modelId), + zIndex: 6, + }); + addTarget({ + id: `right:model-picker:entry:${entry.modelId}`, + rect: { x: rightStartColumn + Math.floor(rightPaneWidth / 4) + 1, y, w: Math.max(10, rightPaneWidth - Math.floor(rightPaneWidth / 4) - 2), h: rowHeight }, + onClick: () => { + setRightPane({ ...picker, focusedIndex: index }); + if (entry.isAvailable) commitModelPickerSelection(entry.modelId); + }, + zIndex: 5, + }); + modelEntryY += rowHeight; + }); + } else if (rightPane.kind === "chat-info") { + const subagentContent = subagentPaneContentFromRightPane(rightPane); + const subagentPaneTop = 4 + goalBannerRows + addModeRows; + if (subagentContent) { + for (let y = rightBodyTop; y <= Math.max(rightBodyTop, rows - 2); y += 1) { + const index = subagentIndexForPaneLine(subagentContent, y - subagentPaneTop, rightSelectionIndex); + if (index == null) continue; + addTarget({ + id: `right:chat-info:${index}:${y}`, + rect: { x: rightStartColumn, y, w: rightPaneWidth, h: 1 }, + onClick: () => { + setRightSelectionIndex(index); + setRightOpen(true); + setPaneFocus("details"); + }, + zIndex: 3, + }); + } + } + } else if (rightPane.kind === "lane-details") { + LANE_DETAIL_ACTIONS.forEach((_, index) => { + const y = 18 + index; + addTarget({ + id: `right:lane-action:${index}`, + rect: { x: rightStartColumn, y, w: rightPaneWidth, h: 1 }, + onClick: () => { + setRightPane((prev) => prev.kind === "lane-details" ? { ...prev, selectedActionIndex: index } : prev); + if (rightPane.worktreeAvailable === false) { + addNotice(laneWorktreeUnavailableMessage(rightPane.lane) ?? "Lane worktree is unavailable.", "error"); + return; + } + const action = LANE_DETAIL_ACTIONS[index]; + if (!action) return; + if (action.intent === "rescue-unstaged") { + openMoveUnstagedForm(); + return; + } + const text = action.slashCommand === "/commit" ? `${action.slashCommand} ` : action.slashCommand; + setPrompt(text); + promptRef.current = text; + chatDraftRef.current = text; + focusChat(); + }, + zIndex: 3, + }); + }); + if (rightPane.pr) { + addTarget({ + id: "right:lane-pr", + rect: { x: rightStartColumn, y: 18 + LANE_DETAIL_ACTIONS.length + 1, w: rightPaneWidth, h: 3 }, + onClick: () => { + setRightPane((prev) => prev.kind === "lane-details" ? { ...prev, selectedActionIndex: LANE_DETAIL_PR_ACTION_INDEX } : prev); + setPrompt("/pr open"); + promptRef.current = "/pr open"; + void submitPrompt("/pr open"); + }, + zIndex: 3, + }); + } + } else if (rightPane.kind === "new-chat-setup" || rightPane.kind === "model-setup") { + const firstRow = rightPane.kind === "new-chat-setup" ? rightBodyTop + 5 : rightBodyTop + 4; + rightPane.rows.forEach((row, index) => { + const y = firstRow + index + (index === rightSelectionIndex ? 1 : 0); + addTarget({ + id: `right:setup:${row.kind}:${index}`, + rect: { x: rightStartColumn, y, w: rightPaneWidth, h: index === rightSelectionIndex && row.detail ? 2 : 1 }, + onClick: () => { + setRightSelectionIndex(index); + if (row.kind === "model" && !row.disabled) openModelPicker({ surface: modelPickerSurfaceForSetupPane(rightPane.kind) }); + else if (row.kind === "apply") handleSetupRow(row, 1); + }, + zIndex: 3, + }); + }); + } else if (rightPane.kind === "form") { + rightPane.fields.forEach((field, index) => { + const y = rightPane.command === "lane-delete" ? [9, 13, 16, 19][index] ?? (rightBodyTop + 3 + index) : rightBodyTop + 3 + index; + addTarget({ + id: `right:form:${field.name}`, + rect: { x: rightStartColumn, y, w: rightPaneWidth, h: rightPane.command === "lane-delete" ? 2 : 1 }, + onClick: (ev) => { + setFormFieldIndex(index); + setFormDiscardArmed(false); + if (rightPane.command === "lane-delete" && field.name === "scope") { + const relX = Math.max(0, (ev.x ?? rightStartColumn) - rightStartColumn); + const scope: LaneDeleteScope = relX < 14 ? "worktree" : relX < 22 ? "local_branch" : "remote_branch"; + setFormValues((prev) => ({ ...prev, scope })); + setPrompt(""); + return; + } + if (rightPane.command === "lane-delete" && field.name === "force") { + setFormValues((prev) => ({ ...prev, force: prev.force === "yes" ? "no" : "yes" })); + setPrompt(""); + return; + } + if (field && formFieldUsesPromptInput(rightPane.command, field.name)) { + setPrompt(formValues[field.name] ?? field.initialValue ?? ""); + } else { + setPrompt(""); + } + }, + zIndex: 3, + }); + }); + } else if (rightPane.kind === "list" && rightPane.action) { + rightPane.rows.forEach((_, index) => { + addTarget({ + id: `right:list:${index}`, + rect: { x: rightStartColumn, y: rightBodyTop + 2 + index, w: rightPaneWidth, h: 1 }, + onClick: () => { + setRightSelectionIndex(index); + const selectedId = rightPane.action?.ids[index] ?? null; + if (!selectedId || !rightPane.action) return; + if (rightPane.action.kind === "switch-lane") { + const lane = lanes.find((entry) => entry.id === selectedId); + if (!lane) return; + selectActiveLaneId(lane.id); + setDrawerLaneId(lane.id); + setSelectedDrawerLaneId(lane.id); + const session = newestSession(displaySessions.filter((entry) => entry.laneId === lane.id)); + selectActiveSessionId(session?.sessionId ?? null); + setSelectedDrawerChatId(session?.sessionId ?? null); + addNotice(`Switched to lane ${lane.name}.`, "success"); + return; + } + const session = displaySessions.find((entry) => entry.sessionId === selectedId); + if (!session) return; + selectActiveLaneId(session.laneId); + setDrawerLaneId(session.laneId); + setSelectedDrawerLaneId(session.laneId); + selectActiveSessionId(session.sessionId); + setSelectedDrawerChatId(session.sessionId); + addNotice(`Switched to chat ${session.title ?? session.sessionId}.`, "success"); + }, + zIndex: 3, + }); + }); + } + } + + appHitTargetIdsRef.current = targets.map((target) => target.id); + return () => { + for (const id of targets.map((target) => target.id)) registry.unregister(id); + }; + }, [ + addMode, + addModeChatIndex, + addModeLaneIndex, + addModeRows, + addNotice, + addTileToGrid, + applyDrawerChatSelection, + centerWidth, + chatRowBudget, + columns, + commitModelPickerSelection, + displaySessions, + drawerLaneRows, + drawerOpen, + drawerPaneWidth, + drawerVisibleLaneSessions, + focusChat, + focusDrawerOnly, + formValues, + goalBannerRows, + handleSetupRow, + insertMention, + lanes, + mentionSuggestions, + modelCatalog, + modelPickerFavorites, + modelPickerRecents, + modelState.modelId, + modelStatusOverlayRows, + models, + openModelPicker, + openMoveUnstagedForm, + openNewChatSetup, + paletteOverlayLeft, + paletteOverlayTop, + paletteOverlayWidth, + pendingApproval, + promptPaneWidth, + promptRows.length, + rightPane, + rightPaneVisible, + rightPaneWidth, + resolvePendingApproval, + rows, + selectActiveLaneId, + selectActiveSessionId, + selectFooterControl, + selectedLaneIndex, + selectedChatIndex, + rightSelectionIndex, + refreshModelCatalog, + setFormDiscardArmed, + setPaneFocus, + showMentionPalette, + showSlashPalette, + slashRows, + submitPrompt, + tileableDisplaySessions, + toggleDetailsPane, + toggleDrawerPane, + toggleModelPickerFavoriteId, + toggleSubagentsPane, + ]); if (error && !connection) { return ( @@ -8378,8 +9285,21 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } ); } + // Footer mini-map: show tile state when multi-view is open, or just the + // transient notice when we have something to flash but no grid yet. + const footerMultiViewMap = (() => { + if (multiView) { + return { count: multiView.tiles.length, focusedIndex: multiView.focusedIndex, notice: multiViewNotice }; + } + if (multiViewNotice) { + return { count: 1, focusedIndex: 0, notice: multiViewNotice }; + } + return null; + })(); + return ( <SpinTickProvider active={spinTickActive}> + <HitTestProvider registry={hitTestRegistryRef.current} hoveredId={hoveredHitId}> <Box flexDirection="column" height={rows}> <Header projectName={projectName} @@ -8392,19 +9312,21 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } {streaming ? <Text color={theme.color.mutedFg} dimColor>{" · streaming"}</Text> : null} </Box> ) : null} + {addMode ? <AddChatModeBanner /> : null} <Box flexGrow={1} minHeight={8}> {drawerOpen ? ( <Drawer lanes={lanes} - sessions={displaySessions} + sessions={addMode ? tileableDisplaySessions : displaySessions} activeLaneId={activeLaneId} activeSessionId={activeSessionId} - browsingLaneId={drawerLaneId ?? activeLaneId} - selectedLaneIndex={selectedLaneIndex} - selectedChatIndex={drawerSection === "chats" ? selectedChatIndex : -1} + browsingLaneId={addMode?.cursorLaneId ?? drawerLaneId ?? activeLaneId} + selectedLaneIndex={addMode ? addModeLaneIndex : selectedLaneIndex} + selectedChatIndex={drawerSelectedChatIndex} panelHeight={rows} - focused={activePane === "drawer"} - mode={drawerSection} + focused={activePane === "drawer" || activePane === "addMode"} + addMode={Boolean(addMode)} + mode={addMode ? "chats" : drawerSection} loading={mode === "connecting" || lanes.length === 0} prByLaneId={prByLaneId} diffByLaneId={diffByLaneId} @@ -8415,6 +9337,29 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } <Box width={centerWidth} flexDirection="column"> {pendingApproval?.highStakes ? ( <ApprovalPrompt approval={pendingApproval} modal /> + ) : multiView ? ( + <MultiChatGrid + tiles={multiView.tiles} + focusedIndex={multiView.focusedIndex} + width={chatWrapWidth} + height={chatRowBudget} + baseX={drawerPaneWidth + 1} + baseY={3 + goalBannerRows + addModeRows} + projectName={projectName} + provider={modelState.provider} + modelDisplay={modelState.displayName} + lanesById={lanesById} + sessionBySessionId={sessionBySessionId} + eventsBySessionId={eventsBySessionId} + notices={displayNotices} + streamingBySessionId={streamingBySessionId} + interruptedBySessionId={interruptedBySessionId} + scrollBySessionId={scrollBySessionId} + selectionBySessionId={selectionBySessionId} + expandedLineIds={expandedLineIds} + onFocusTile={focusMultiViewTile} + onRemoveTile={removeMultiViewTile} + /> ) : activeTerminalSession ? ( <TerminalPane title={activeTerminalSession.title} @@ -8551,8 +9496,11 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } planMode={isPlanMode(modelState)} terminalControlAvailable={claudeTerminalControlAvailable} terminalControlActive={claudeTerminalControlActive} + multiViewActive={Boolean(multiView)} + multiViewMap={footerMultiViewMap} /> </Box> + </HitTestProvider> </SpinTickProvider> ); } diff --git a/apps/ade-cli/src/tuiClient/cli.tsx b/apps/ade-cli/src/tuiClient/cli.tsx index 8c2427d55..e111b60ac 100644 --- a/apps/ade-cli/src/tuiClient/cli.tsx +++ b/apps/ade-cli/src/tuiClient/cli.tsx @@ -53,7 +53,10 @@ Usage: Keys: ctrl-o open or focus lanes and chats ctrl-p open or focus details + ctrl-g split chat: add another chat tile + ctrl-w split chat: close focused tile ctrl-t toggle Claude terminal control when a Claude Code terminal is active + tab split chat: cycle focused tile shift-tab cycle pane focus esc return or cancel the active pane ? help when it is the first and only prompt character diff --git a/apps/ade-cli/src/tuiClient/components/AddChatMode.tsx b/apps/ade-cli/src/tuiClient/components/AddChatMode.tsx new file mode 100644 index 000000000..5f2064ef9 --- /dev/null +++ b/apps/ade-cli/src/tuiClient/components/AddChatMode.tsx @@ -0,0 +1,16 @@ +import React from "react"; +import { Box, Text } from "ink"; +import { theme } from "../theme"; + +export function AddChatModeBanner() { + return ( + <Box paddingX={1} flexShrink={0}> + <Text color={theme.color.attention2} bold>Split chat: pick a chat in the left pane</Text> + <Text color={theme.color.t4}>{" · "}</Text> + <Text color={theme.color.attention2}>↵</Text> + <Text dimColor>{" Add · "}</Text> + <Text color={theme.color.attention2}>Esc</Text> + <Text dimColor>{" Cancel"}</Text> + </Box> + ); +} diff --git a/apps/ade-cli/src/tuiClient/components/ChatView.tsx b/apps/ade-cli/src/tuiClient/components/ChatView.tsx index 938f8d59e..7d0205b9d 100644 --- a/apps/ade-cli/src/tuiClient/components/ChatView.tsx +++ b/apps/ade-cli/src/tuiClient/components/ChatView.tsx @@ -1517,6 +1517,10 @@ export function ChatView({ unseenMessageCount = 0, selection = null, width = DEFAULT_VIEW_WIDTH, + focused = false, + hovered = false, + removeHovered = false, + onRemove, }: { events: AgentChatEventEnvelope[]; notices: LocalNotice[]; @@ -1535,6 +1539,10 @@ export function ChatView({ unseenMessageCount?: number; selection?: ChatTextSelection | null; width?: number; + focused?: boolean; + hovered?: boolean; + removeHovered?: boolean; + onRemove?: () => void; }) { // Memoize the heavy aggregation pass — it walks the entire transcript and // shouldn't re-run on every spinner tick. Events identity changes only when @@ -1543,6 +1551,8 @@ export function ChatView({ () => aggregateChatBlocks({ events, notices, activeSession, expandedLineIds }), [events, notices, activeSession, expandedLineIds], ); + const tileMode = focused || Boolean(onRemove); + const bodyRows = tileMode ? Math.max(1, (maxRows ?? 4) - 4) : maxRows; const brailleFrame = useBrailleSpin(); const spinFrame = useSpinFrame(); const dotPulse = useDotPulse(); @@ -1553,7 +1563,7 @@ export function ChatView({ const rows = useMemo( () => visibleRowsForBlocks({ blocks, - maxRows, + maxRows: bodyRows, scrollOffsetRows, unseenMessageCount, width, @@ -1563,10 +1573,18 @@ export function ChatView({ spinFrame, dotPulse, }), - [blocks, brailleFrame, dotPulse, interrupted, maxRows, scrollOffsetRows, spinFrame, streaming, unseenMessageCount, width], + [blocks, bodyRows, brailleFrame, dotPulse, interrupted, scrollOffsetRows, spinFrame, streaming, unseenMessageCount, width], ); - if (!blocks.length && !streaming && !interrupted) { - return ( + const isEmpty = !blocks.length && !streaming && !interrupted; + let content: React.ReactNode; + if (isEmpty && tileMode) { + content = ( + <Box flexDirection="column" paddingX={1} height={bodyRows}> + <Text color={theme.color.t4} dimColor>No transcript yet.</Text> + </Box> + ); + } else if (isEmpty) { + content = ( <BootHero projectName={projectName} laneName={laneName} @@ -1577,12 +1595,53 @@ export function ChatView({ width={width} /> ); + } else { + content = ( + <Box flexDirection="column" paddingX={1} height={bodyRows}> + {rows.map((row, index) => ( + <ChatRow key={`${row.id}:${index}`} row={row} selection={selection} /> + ))} + </Box> + ); } + + if (!tileMode) return content; + + const innerWidth = Math.max(8, width - 4); + const title = activeSession?.title ?? activeSession?.goal ?? activeSession?.summary ?? activeSession?.sessionId ?? "chat"; + const streamingDot = streaming ? " ●" : ""; + const removeSlot = onRemove ? " ×" : ""; + const available = Math.max(4, innerWidth - streamingDot.length - removeSlot.length - 3); + const lanePart = truncateEnd(laneName || "(lane removed)", Math.max(3, Math.floor(available * 0.4))); + const titlePart = truncateEnd(title, Math.max(3, available - textWidth(lanePart) - 3)); + const header = truncateEnd(`${lanePart} / ${titlePart}${streamingDot}`, Math.max(4, innerWidth - removeSlot.length)); + let tileBorderColor: string; + if (focused) tileBorderColor = "cyan"; + else if (hovered) tileBorderColor = theme.color.borderFocused; + else tileBorderColor = theme.color.border; + let headerColor: string; + if (focused) headerColor = "cyan"; + else if (activeSession?.status === "ended") headerColor = theme.color.t4; + else headerColor = theme.color.t2; return ( - <Box flexDirection="column" paddingX={1} height={maxRows}> - {rows.map((row, index) => ( - <ChatRow key={`${row.id}:${index}`} row={row} selection={selection} /> - ))} + <Box + flexDirection="column" + borderStyle={focused ? "double" : "round"} + borderColor={tileBorderColor} + height={maxRows} + width={width} + > + <Box paddingX={1} justifyContent="space-between" flexShrink={0}> + <Text color={headerColor} wrap="truncate-end"> + {header} + </Text> + {onRemove ? ( + <Text color={removeHovered ? theme.color.error : theme.color.t4} inverse={removeHovered}> + × + </Text> + ) : null} + </Box> + {content} </Box> ); } diff --git a/apps/ade-cli/src/tuiClient/components/Drawer.tsx b/apps/ade-cli/src/tuiClient/components/Drawer.tsx index 956093522..c02c02640 100644 --- a/apps/ade-cli/src/tuiClient/components/Drawer.tsx +++ b/apps/ade-cli/src/tuiClient/components/Drawer.tsx @@ -121,6 +121,7 @@ export function Drawer({ selectedChatIndex, panelHeight, focused = false, + addMode = false, density = "full", mode = "lanes", prByLaneId = {}, @@ -138,6 +139,7 @@ export function Drawer({ selectedChatIndex: number; panelHeight?: number; focused?: boolean; + addMode?: boolean; density?: DrawerDensity; mode?: DrawerMode; prByLaneId?: Record<string, DrawerPrSummary>; @@ -162,7 +164,11 @@ export function Drawer({ const width = density === "mini" ? DRAWER_WIDTH_MINI : Math.max(DRAWER_WIDTH_FULL, Math.min(DRAWER_WIDTH_MAX, Math.floor(requestedWidth ?? DRAWER_WIDTH_FULL))); - const borderColor = focused ? theme.color.violet : theme.color.border; + const emphasisColor = addMode ? theme.color.attention2 : theme.color.violet; + let borderColor: string; + if (addMode) borderColor = emphasisColor; + else if (focused) borderColor = theme.color.violet; + else borderColor = theme.color.border; if (density === "mini") { return ( @@ -189,8 +195,8 @@ export function Drawer({ return ( <Box width={width} flexDirection="column" borderStyle="single" borderColor={borderColor}> <Box paddingX={1} flexShrink={0}> - <Text bold color={theme.color.violet}> - LANES · {loading && lanes.length === 0 ? "…" : lanes.length} + <Text bold color={emphasisColor}> + {addMode ? "PICK CHAT" : `LANES · ${loading && lanes.length === 0 ? "…" : lanes.length}`} </Text> </Box> @@ -256,6 +262,19 @@ export function Drawer({ <Text> </Text> <Text> </Text> </> + ) : addMode ? ( + <> + <Text color={theme.color.t4} wrap="truncate-end"> + <Text color={emphasisColor}>↑↓</Text> + {" select chat in left pane"} + </Text> + <Text color={theme.color.t4} wrap="truncate-end"> + <Text color={emphasisColor}>↵/click</Text> + {" add · "} + <Text color={emphasisColor}>esc</Text> + {" cancel"} + </Text> + </> ) : mode === "chats" ? ( <> <Text color={theme.color.t4} wrap="truncate-end"> diff --git a/apps/ade-cli/src/tuiClient/components/FooterControls.tsx b/apps/ade-cli/src/tuiClient/components/FooterControls.tsx index 8b921233c..9a9d4c09e 100644 --- a/apps/ade-cli/src/tuiClient/components/FooterControls.tsx +++ b/apps/ade-cli/src/tuiClient/components/FooterControls.tsx @@ -2,6 +2,7 @@ import React from "react"; import { Box, Text } from "ink"; import { theme } from "../theme"; import type { AdeCodeProvider } from "../types"; +import { gridMiniMapText } from "./GridMiniMap"; const TOKEN_BAR_CELLS = 10; @@ -94,6 +95,8 @@ export function FooterControls({ planMode, terminalControlAvailable, terminalControlActive, + multiViewActive, + multiViewMap, }: { provider?: AdeCodeProvider | null; providerLocked?: boolean; @@ -111,6 +114,8 @@ export function FooterControls({ planMode?: boolean; terminalControlAvailable?: boolean; terminalControlActive?: boolean; + multiViewActive?: boolean; + multiViewMap?: { count: number; focusedIndex: number; notice?: string | null } | null; }) { const brand = provider ? theme.provider(provider) : null; const rowFocused = inlineRowFocused === true; @@ -215,6 +220,15 @@ export function FooterControls({ ) : null} </> ) : null} + {multiViewMap ? ( + <> + <Text color={theme.color.t4}>{" "}</Text> + <Text color={theme.color.t3}>{gridMiniMapText(multiViewMap.count, multiViewMap.focusedIndex)}</Text> + {multiViewMap.notice ? ( + <Text color={theme.color.warning}>{` ${multiViewMap.notice}`}</Text> + ) : null} + </> + ) : null} </Text> <Text wrap="truncate-start"> {terminalControlActive ? ( @@ -248,7 +262,21 @@ export function FooterControls({ <Text dimColor>{" "}</Text> <Hint keyLabel="^p" action="pane" /> <Text dimColor>{" "}</Text> - <Hint keyLabel="^a" action="chat info" /> + {!showSubagents ? ( + <> + <Hint keyLabel="^a" action="chat info" /> + <Text dimColor>{" "}</Text> + </> + ) : null} + <Hint keyLabel="^g" action={multiViewActive ? "add chat" : "split"} /> + {multiViewActive ? ( + <> + <Text dimColor>{" "}</Text> + <Hint keyLabel="tab" action="tile" /> + <Text dimColor>{" "}</Text> + <Hint keyLabel="^w" action="close tile" /> + </> + ) : null} <Text dimColor>{" "}</Text> <Hint keyLabel="/" action="cmds" /> <Text dimColor>{" "}</Text> diff --git a/apps/ade-cli/src/tuiClient/components/GridMiniMap.tsx b/apps/ade-cli/src/tuiClient/components/GridMiniMap.tsx new file mode 100644 index 000000000..d5b939c4f --- /dev/null +++ b/apps/ade-cli/src/tuiClient/components/GridMiniMap.tsx @@ -0,0 +1,26 @@ +import React from "react"; +import { Text } from "ink"; + +function mapFor(count: number, focusedIndex: number): string { + const cells = Array.from({ length: Math.max(1, Math.min(6, count)) }, (_, index) => ( + index === focusedIndex ? "▣" : "▢" + )); + if (cells.length <= 3) return cells.join(""); + if (cells.length === 4) return `${cells.slice(0, 2).join("")} / ${cells.slice(2).join("")}`; + return `${cells.slice(0, cells.length === 5 ? 2 : 3).join("")} / ${cells.slice(cells.length === 5 ? 2 : 3).join("")}`; +} + +export function gridMiniMapText(count: number, focusedIndex: number): string { + if (count <= 1) return "▣"; + return mapFor(count, Math.max(0, Math.min(focusedIndex, count - 1))); +} + +export function GridMiniMap({ + count, + focusedIndex, +}: { + count: number; + focusedIndex: number; +}) { + return <Text>{gridMiniMapText(count, focusedIndex)}</Text>; +} diff --git a/apps/ade-cli/src/tuiClient/components/MultiChatGrid.tsx b/apps/ade-cli/src/tuiClient/components/MultiChatGrid.tsx new file mode 100644 index 000000000..56340b61a --- /dev/null +++ b/apps/ade-cli/src/tuiClient/components/MultiChatGrid.tsx @@ -0,0 +1,265 @@ +import React, { useMemo } from "react"; +import { Box, Text } from "ink"; +import type { AgentChatEventEnvelope, AgentChatSessionSummary } from "../../../../desktop/src/shared/types/chat"; +import type { LaneSummary } from "../../../../desktop/src/shared/types/lanes"; +import type { LocalNotice, AdeCodeProvider } from "../types"; +import type { ChatTextSelection } from "./ChatView"; +import { ChatView } from "./ChatView"; +import { + asTileCount, + canRenderMultiChatGrid, + computeTileRects, + type MultiViewTile, +} from "../multiChatLayout"; +import { useHitTestTarget } from "../hitTestRegistry"; +import { theme } from "../theme"; + +type TileData = { + tile: MultiViewTile; + session: AgentChatSessionSummary | null; + lane: LaneSummary | null; +}; + +function groupRows<T extends { rect: { x: number; y: number } }>(entries: T[]): T[][] { + const rows = new Map<number, T[]>(); + for (const entry of entries) { + const bucket = rows.get(entry.rect.y) ?? []; + bucket.push(entry); + rows.set(entry.rect.y, bucket); + } + return [...rows.entries()] + .sort(([a], [b]) => a - b) + .map(([, row]) => row.sort((a, b) => a.rect.x - b.rect.x)); +} + +function providerForTile(session: AgentChatSessionSummary | null, fallback?: AdeCodeProvider | null): AdeCodeProvider | null | undefined { + const provider = session?.provider; + if ( + provider === "codex" + || provider === "claude" + || provider === "opencode" + || provider === "cursor" + || provider === "droid" + ) { + return provider as AdeCodeProvider; + } + return fallback; +} + +function MultiChatTile({ + index, + data, + rect, + baseX, + baseY, + projectName, + provider, + modelDisplay, + focused, + events, + notices, + streaming, + interrupted, + expandedLineIds, + scrollOffsetRows, + selection, + onFocusTile, + onRemoveTile, +}: { + index: number; + data: TileData; + rect: { x: number; y: number; w: number; h: number }; + baseX: number; + baseY: number; + projectName: string; + provider?: AdeCodeProvider | null; + modelDisplay?: string | null; + focused: boolean; + events: AgentChatEventEnvelope[]; + notices: LocalNotice[]; + streaming: boolean; + interrupted: boolean; + expandedLineIds?: Set<string>; + scrollOffsetRows: number; + selection: ChatTextSelection | null; + onFocusTile: (index: number) => void; + onRemoveTile: (index: number) => void; +}) { + const tileId = `multi-chat:tile:${data.tile.sessionId}`; + const removeId = `multi-chat:remove:${data.tile.sessionId}`; + const hovered = useHitTestTarget({ + id: tileId, + rect: { + x: baseX + rect.x, + y: baseY + rect.y, + w: rect.w, + h: rect.h, + }, + onClick: () => onFocusTile(index), + zIndex: 5, + }); + const removeHovered = useHitTestTarget({ + id: removeId, + rect: { + x: baseX + rect.x + Math.max(0, rect.w - 3), + y: baseY + rect.y + 1, + w: 2, + h: 1, + }, + onClick: () => onRemoveTile(index), + zIndex: 10, + }); + return ( + <ChatView + events={events} + notices={notices} + activeSession={data.session} + projectName={projectName} + laneName={data.lane?.name ?? data.tile.laneId} + lane={data.lane} + provider={providerForTile(data.session, provider)} + modelDisplay={data.session?.model ?? modelDisplay} + streaming={streaming} + interrupted={interrupted} + expandedLineIds={expandedLineIds} + maxRows={rect.h} + scrollOffsetRows={scrollOffsetRows} + selection={selection} + width={rect.w} + focused={focused} + hovered={hovered} + removeHovered={removeHovered} + onRemove={() => onRemoveTile(index)} + /> + ); +} + +export function MultiChatGrid({ + tiles, + focusedIndex, + width, + height, + baseX, + baseY, + projectName, + provider, + modelDisplay, + lanesById, + sessionBySessionId, + eventsBySessionId, + notices, + streamingBySessionId, + interruptedBySessionId, + scrollBySessionId, + selectionBySessionId, + expandedLineIds, + onFocusTile, + onRemoveTile, +}: { + tiles: MultiViewTile[]; + focusedIndex: number; + width: number; + height: number; + baseX: number; + baseY: number; + projectName: string; + provider?: AdeCodeProvider | null; + modelDisplay?: string | null; + lanesById: Record<string, LaneSummary>; + sessionBySessionId: Record<string, AgentChatSessionSummary>; + eventsBySessionId: Record<string, AgentChatEventEnvelope[]>; + notices: LocalNotice[]; + streamingBySessionId: Record<string, boolean>; + interruptedBySessionId: Record<string, boolean>; + scrollBySessionId: Record<string, number>; + selectionBySessionId: Record<string, ChatTextSelection | null>; + expandedLineIds?: Set<string>; + onFocusTile: (index: number) => void; + onRemoveTile: (index: number) => void; +}) { + const safeTiles = useMemo(() => tiles.slice(0, 6), [tiles]); + const tileCount = asTileCount(safeTiles.length); + const rects = useMemo(() => computeTileRects(tileCount, width, height), [height, tileCount, width]); + const rows = useMemo(() => groupRows(safeTiles.map((tile, index) => ({ + tile, + index, + rect: rects[index] ?? rects[0]!, + }))), [rects, safeTiles]); + + if (!safeTiles.length) { + return ( + <Box width={width} height={height} paddingX={1}> + <Text color={theme.color.t4} dimColor>No chats open.</Text> + </Box> + ); + } + + if (!canRenderMultiChatGrid(safeTiles.length, width, height)) { + const tile = safeTiles[Math.max(0, Math.min(focusedIndex, safeTiles.length - 1))]; + const session = sessionBySessionId[tile.sessionId] ?? null; + const lane = lanesById[tile.laneId] ?? null; + return ( + <MultiChatTile + index={Math.max(0, Math.min(focusedIndex, safeTiles.length - 1))} + data={{ tile, session, lane }} + rect={{ x: 0, y: 0, w: width, h: height }} + baseX={baseX} + baseY={baseY} + projectName={projectName} + provider={provider} + modelDisplay={modelDisplay} + focused + events={eventsBySessionId[tile.sessionId] ?? []} + notices={notices} + streaming={!!streamingBySessionId[tile.sessionId]} + interrupted={!!interruptedBySessionId[tile.sessionId]} + expandedLineIds={expandedLineIds} + scrollOffsetRows={scrollBySessionId[tile.sessionId] ?? 0} + selection={selectionBySessionId[tile.sessionId] ?? null} + onFocusTile={onFocusTile} + onRemoveTile={onRemoveTile} + /> + ); + } + + return ( + <Box width={width} height={height} flexDirection="column"> + {rows.map((row, rowIndex) => ( + <Box + key={`row:${rowIndex}:${row[0]?.rect.y ?? 0}`} + flexDirection="row" + height={row[0]?.rect.h ?? height} + > + {row.map(({ tile, index, rect }) => { + const session = sessionBySessionId[tile.sessionId] ?? null; + const lane = lanesById[tile.laneId] ?? null; + return ( + <Box key={tile.sessionId} width={rect.w} height={rect.h}> + <MultiChatTile + index={index} + data={{ tile, session, lane }} + rect={rect} + baseX={baseX} + baseY={baseY} + projectName={projectName} + provider={provider} + modelDisplay={modelDisplay} + focused={index === focusedIndex} + events={eventsBySessionId[tile.sessionId] ?? []} + notices={notices} + streaming={!!streamingBySessionId[tile.sessionId]} + interrupted={!!interruptedBySessionId[tile.sessionId]} + expandedLineIds={expandedLineIds} + scrollOffsetRows={scrollBySessionId[tile.sessionId] ?? 0} + selection={selectionBySessionId[tile.sessionId] ?? null} + onFocusTile={onFocusTile} + onRemoveTile={onRemoveTile} + /> + </Box> + ); + })} + </Box> + ))} + </Box> + ); +} diff --git a/apps/ade-cli/src/tuiClient/components/RightPane.tsx b/apps/ade-cli/src/tuiClient/components/RightPane.tsx index cec8cf74b..d148ece07 100644 --- a/apps/ade-cli/src/tuiClient/components/RightPane.tsx +++ b/apps/ade-cli/src/tuiClient/components/RightPane.tsx @@ -730,6 +730,8 @@ function HelpPane() { <Text color={theme.color.t3} dimColor>in the row: ← → moves between cells, ↓ cycles values</Text> <Text color={theme.color.t3} dimColor>/model opens the model picker · /info opens chat info</Text> <Text color={theme.color.t3} dimColor>ctrl-o opens or focuses lanes and chats</Text> + <Text color={theme.color.t3} dimColor>ctrl-g starts split chat add-mode; enter adds, esc cancels</Text> + <Text color={theme.color.t3} dimColor>in split chat: tab focuses tiles, ctrl-w closes the focused tile</Text> <Text color={theme.color.t3} dimColor>ctrl-p opens or focuses info · ctrl-a toggles chat info</Text> <Text color={theme.color.t3} dimColor>shift-tab cycles pane focus · esc closes the active side pane</Text> <Text color={theme.color.t3} dimColor>ctrl-c interrupts a running chat; press again to quit</Text> diff --git a/apps/ade-cli/src/tuiClient/hitTestRegistry.ts b/apps/ade-cli/src/tuiClient/hitTestRegistry.ts new file mode 100644 index 000000000..a8e4504c4 --- /dev/null +++ b/apps/ade-cli/src/tuiClient/hitTestRegistry.ts @@ -0,0 +1,115 @@ +import React, { createContext, useContext, useEffect, useMemo, useRef } from "react"; + +export type HitRect = { x: number; y: number; w: number; h: number }; + +export type HitMouseEvent = { + kind: "wheel" | "click" | "drag" | "release" | "move" | "other"; + x: number | null; + y: number | null; + direction?: "up" | "down" | "left" | "right"; + shift?: boolean; + alt?: boolean; + ctrl?: boolean; +}; + +export type HitTarget = { + id: string; + rect: HitRect; + onClick?: (ev: HitMouseEvent) => void; + onHover?: (hovered: boolean) => void; + onDragStart?: (ev: HitMouseEvent) => void; + onDrag?: (ev: HitMouseEvent) => void; + onDrop?: (ev: HitMouseEvent) => void; + zIndex?: number; +}; + +export interface HitTestRegistry { + register(target: HitTarget): void; + unregister(id: string): void; + hitTest(x: number, y: number): HitTarget | null; + hoverTest(x: number, y: number): HitTarget | null; + clear(): void; +} + +function contains(rect: HitRect, x: number, y: number): boolean { + return x >= rect.x && x < rect.x + rect.w && y >= rect.y && y < rect.y + rect.h; +} + +function bestTarget(targets: HitTarget[], x: number, y: number): HitTarget | null { + let best: HitTarget | null = null; + for (const target of targets) { + if (!contains(target.rect, x, y)) continue; + if (!best || (target.zIndex ?? 0) >= (best.zIndex ?? 0)) { + best = target; + } + } + return best; +} + +export function createHitTestRegistry(): HitTestRegistry { + let targets: HitTarget[] = []; + return { + register(target) { + targets = [...targets.filter((entry) => entry.id !== target.id), target]; + }, + unregister(id) { + targets = targets.filter((entry) => entry.id !== id); + }, + hitTest(x, y) { + return bestTarget(targets, x, y); + }, + hoverTest(x, y) { + return bestTarget(targets, x, y); + }, + clear() { + targets = []; + }, + }; +} + +type HitTestContextValue = { + registry: HitTestRegistry; + hoveredId: string | null; +}; + +const HitTestContext = createContext<HitTestContextValue | null>(null); + +export function HitTestProvider({ + registry, + hoveredId, + children, +}: { + registry?: HitTestRegistry; + hoveredId?: string | null; + children: React.ReactNode; +}) { + const fallbackRegistry = useMemo(() => createHitTestRegistry(), []); + const value = useMemo<HitTestContextValue>(() => ({ + registry: registry ?? fallbackRegistry, + hoveredId: hoveredId ?? null, + }), [fallbackRegistry, hoveredId, registry]); + return React.createElement(HitTestContext.Provider, { value }, children); +} + +export function useHitTest(): HitTestContextValue { + const context = useContext(HitTestContext); + if (!context) { + throw new Error("useHitTest must be used inside HitTestProvider"); + } + return context; +} + +export function useHitTestTarget(target: HitTarget | null | false | undefined): boolean { + const { registry, hoveredId } = useHitTest(); + const latestTargetRef = useRef<HitTarget | null>(null); + latestTargetRef.current = target || null; + + useEffect(() => { + const latest = latestTargetRef.current; + if (!latest) return; + registry.register(latest); + return () => registry.unregister(latest.id); + }, [registry, target]); + + return Boolean(target && hoveredId === target.id); +} diff --git a/apps/ade-cli/src/tuiClient/multiChatLayout.ts b/apps/ade-cli/src/tuiClient/multiChatLayout.ts new file mode 100644 index 000000000..51c442aac --- /dev/null +++ b/apps/ade-cli/src/tuiClient/multiChatLayout.ts @@ -0,0 +1,82 @@ +export type MultiViewTile = { sessionId: string; laneId: string }; +export type MultiViewState = { tiles: MultiViewTile[]; focusedIndex: number }; +export type TileRect = { x: number; y: number; w: number; h: number }; + +type TilePattern = { + row: number; + col: number; + rowSpan: number; + colSpan: number; + rows: number; + cols: number; +}; + +export const MIN_MULTI_CHAT_TILE_WIDTH = 30; +export const MIN_MULTI_CHAT_TILE_HEIGHT = 8; + +const PATTERNS: Record<1 | 2 | 3 | 4 | 5 | 6, ReadonlyArray<TilePattern>> = { + 1: [{ row: 0, col: 0, rowSpan: 1, colSpan: 1, rows: 1, cols: 1 }], + 2: [ + { row: 0, col: 0, rowSpan: 1, colSpan: 1, rows: 1, cols: 2 }, + { row: 0, col: 1, rowSpan: 1, colSpan: 1, rows: 1, cols: 2 }, + ], + 3: [ + { row: 0, col: 0, rowSpan: 1, colSpan: 1, rows: 1, cols: 3 }, + { row: 0, col: 1, rowSpan: 1, colSpan: 1, rows: 1, cols: 3 }, + { row: 0, col: 2, rowSpan: 1, colSpan: 1, rows: 1, cols: 3 }, + ], + 4: [ + { row: 0, col: 0, rowSpan: 1, colSpan: 1, rows: 2, cols: 2 }, + { row: 0, col: 1, rowSpan: 1, colSpan: 1, rows: 2, cols: 2 }, + { row: 1, col: 0, rowSpan: 1, colSpan: 1, rows: 2, cols: 2 }, + { row: 1, col: 1, rowSpan: 1, colSpan: 1, rows: 2, cols: 2 }, + ], + 5: [ + { row: 0, col: 0, rowSpan: 1, colSpan: 3, rows: 2, cols: 6 }, + { row: 0, col: 3, rowSpan: 1, colSpan: 3, rows: 2, cols: 6 }, + { row: 1, col: 0, rowSpan: 1, colSpan: 2, rows: 2, cols: 6 }, + { row: 1, col: 2, rowSpan: 1, colSpan: 2, rows: 2, cols: 6 }, + { row: 1, col: 4, rowSpan: 1, colSpan: 2, rows: 2, cols: 6 }, + ], + 6: [ + { row: 0, col: 0, rowSpan: 1, colSpan: 1, rows: 2, cols: 3 }, + { row: 0, col: 1, rowSpan: 1, colSpan: 1, rows: 2, cols: 3 }, + { row: 0, col: 2, rowSpan: 1, colSpan: 1, rows: 2, cols: 3 }, + { row: 1, col: 0, rowSpan: 1, colSpan: 1, rows: 2, cols: 3 }, + { row: 1, col: 1, rowSpan: 1, colSpan: 1, rows: 2, cols: 3 }, + { row: 1, col: 2, rowSpan: 1, colSpan: 1, rows: 2, cols: 3 }, + ], +}; + +export function asTileCount(value: number): 1 | 2 | 3 | 4 | 5 | 6 { + if (value <= 1) return 1; + if (value >= 6) return 6; + return value as 1 | 2 | 3 | 4 | 5 | 6; +} + +export function computeTileRects(n: 1 | 2 | 3 | 4 | 5 | 6, width: number, height: number): TileRect[] { + const safeWidth = Math.max(1, Math.floor(width)); + const safeHeight = Math.max(1, Math.floor(height)); + const pattern = PATTERNS[n]; + const cols = pattern[0]?.cols ?? 1; + const rows = pattern[0]?.rows ?? 1; + const colW = Math.max(1, Math.floor(safeWidth / cols)); + const rowH = Math.max(1, Math.floor(safeHeight / rows)); + return pattern.map((tile) => ({ + x: tile.col * colW, + y: tile.row * rowH, + w: tile.colSpan * colW, + h: tile.rowSpan * rowH, + })); +} + +export function canRenderMultiChatGrid(count: number, width: number, height: number): boolean { + const tileCount = asTileCount(count); + const rects = computeTileRects(tileCount, width, height); + return rects.every((rect) => rect.w >= MIN_MULTI_CHAT_TILE_WIDTH && rect.h >= MIN_MULTI_CHAT_TILE_HEIGHT); +} + +export function focusedSessionIdForMultiView(multiView: MultiViewState | null): string | null { + if (!multiView) return null; + return multiView.tiles[multiView.focusedIndex]?.sessionId ?? null; +} diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index 52a87c8ed..a6448c241 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -822,10 +822,21 @@ protocol.registerSchemesAsPrivileged([ }, ]); +// Only Stable claims `ade://` as the default handler. Beta and Alpha still +// install the single-instance lock + `open-url` listeners (so a manual +// `duti` binding still works), but they don't ask the OS to make them the +// default on boot. Source builds opt in via `ADE_REGISTER_DEEPLINK_HANDLER=1`, +// which is the dev-test workaround on a machine with Stable also installed. +const deeplinkChannel = normalizeAdePackageChannel(process.env.ADE_PACKAGE_CHANNEL); +const deeplinkClaimAsDefault = + process.env.ADE_REGISTER_DEEPLINK_HANDLER === "1" || + (app.isPackaged && deeplinkChannel === null); + // Register the user-facing `ade://` deeplink scheme + single-instance lock so a // second `open ade://...` invocation reuses the running window. Dispatch to the // focused window's renderer via the existing IPC.appNavigate channel. registerAdeProtocolHandler({ + claimAsDefault: deeplinkClaimAsDefault, dispatch: (request) => { const focusable = BrowserWindow.getFocusedWindow() ?? @@ -1474,6 +1485,14 @@ app.whenReady().then(async () => { return true; }; + try { + if (ctx.laneService?.hasRunningDelete?.()) { + return true; + } + } catch (error) { + return keepAliveOnProbeFailure("lane_deletes", error); + } + try { if (ctx.sessionService?.list({ status: "running", limit: 1 }).length > 0) { return true; @@ -1948,15 +1967,9 @@ app.whenReady().then(async () => { // Resolve repo lazily so cards posted to Linear carry the cross-machine // ADE deeplink (https://ade.app/open?type=branch&...). If the project // has no GitHub remote, fall back to the legacy hash-anchor URL. - const resolveRepo = async (): Promise<{ owner: string; name: string } | null> => { - try { - return await githubService.getRepoOrThrow(); - } catch { - return null; - } - }; - void resolveRepo().then((repo) => - publishLinearLaneCard({ + void githubService.getRepoOrThrow() + .catch(() => null) + .then((repo) => publishLinearLaneCard({ issueTracker: tracker, lane, issue, @@ -1966,15 +1979,15 @@ app.whenReady().then(async () => { repoName: repo?.name ?? null, postInitialComment: true, log: (event, fields) => logger.warn(event, fields), - }), - ).catch((error) => { - logger.warn("linear.lane_card_publish_failed", { - laneId: lane.id, - issueId: issue.id, - issueIdentifier: issue.identifier, - error: error instanceof Error ? error.message : String(error), + })) + .catch((error) => { + logger.warn("linear.lane_card_publish_failed", { + laneId: lane.id, + issueId: issue.id, + issueIdentifier: issue.identifier, + error: error instanceof Error ? error.message : String(error), + }); }); - }); }, teardownDeps: laneTeardownDeps, logger, @@ -5682,6 +5695,49 @@ app.whenReady().then(async () => { return true; }; + const getRunningLaneDeleteLabels = (): string[] => { + const labels: string[] = []; + for (const ctx of projectContexts.values()) { + try { + if (!ctx.laneService?.hasRunningDelete?.()) continue; + labels.push(ctx.project?.displayName ?? ctx.project?.rootPath ?? "Unknown project"); + } catch (error) { + ctx.logger.warn("lane_delete.quit_probe_failed", { + projectRoot: ctx.project?.rootPath ?? null, + error: error instanceof Error ? error.message : String(error), + }); + labels.push(ctx.project?.displayName ?? ctx.project?.rootPath ?? "Unknown project"); + } + } + return Array.from(new Set(labels)); + }; + + const confirmNoRunningLaneDeleteForQuit = (ownerWindow?: BrowserWindow | null): boolean => { + const runningDeletes = getRunningLaneDeleteLabels(); + if (runningDeletes.length === 0) return true; + const detail = + runningDeletes.length === 1 + ? `${runningDeletes[0]} is deleting a lane. Wait for deletion to finish before quitting ADE.` + : `These projects are deleting lanes: ${runningDeletes.join(", ")}. Wait for deletion to finish before quitting ADE.`; + const dialogOptions = { + type: "warning" as const, + buttons: ["Keep ADE open"], + defaultId: 0, + cancelId: 0, + noLink: true, + title: "Lane delete in progress", + message: "ADE cannot quit while a lane is being deleted.", + detail, + }; + const parentWindow = + ownerWindow && !ownerWindow.isDestroyed() + ? ownerWindow + : BrowserWindow.getFocusedWindow() ?? BrowserWindow.getAllWindows()[0]; + if (parentWindow) dialog.showMessageBoxSync(parentWindow, dialogOptions); + else dialog.showMessageBoxSync(dialogOptions); + return false; + }; + const confirmQuitWarning = (ownerWindow?: BrowserWindow | null): boolean => showWindowCloseWarning(ownerWindow, { buttons: ["Keep ADE open", "Quit ADE"], @@ -5722,6 +5778,7 @@ app.whenReady().then(async () => { closeWindowWithoutPrompt(win); return; } + if (!confirmNoRunningLaneDeleteForQuit(win)) return; if (!confirmQuitWarning(win)) return; requestAppShutdown({ reason: "window_close", exitCode: 0 }); }; @@ -6072,6 +6129,7 @@ app.whenReady().then(async () => { if (shutdownFinalized) return; event.preventDefault(); if (shutdownRequested) return; + if (!confirmNoRunningLaneDeleteForQuit()) return; if (!confirmQuitWarning()) return; requestAppShutdown({ reason: "before_quit", exitCode: 0 }); }); diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index ece3cffa7..1eae7339b 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -15488,7 +15488,7 @@ export function createAgentChatService(args: { transcriptPath, toolType: toolTypeFromProvider(effectiveProvider), resumeCommand: resumeCommandForProvider(effectiveProvider, sessionId), - ...(processRegistry ? { ownerPid: processRegistry.pid } : {}), + ownerPid: processRegistry?.pid ?? null, }); if (normalizedTitle.length > 0) { sessionService.updateMeta({ sessionId, title: initialTitle, manuallyNamed: true }); diff --git a/apps/desktop/src/main/services/cto/linearLaneCardService.ts b/apps/desktop/src/main/services/cto/linearLaneCardService.ts index ee9e86060..f9e701d3a 100644 --- a/apps/desktop/src/main/services/cto/linearLaneCardService.ts +++ b/apps/desktop/src/main/services/cto/linearLaneCardService.ts @@ -164,12 +164,9 @@ export async function publishLinearLaneCard(args: { repoOwner: args.repoOwner ?? null, repoName: args.repoName ?? null, }); - const createComment = (args.issueTracker as IssueTracker & { - createComment?: (issueId: string, body: string) => Promise<unknown>; - }).createComment; - if (body && typeof createComment === "function") { + if (body) { try { - await createComment(args.issue.id, body); + await args.issueTracker.createComment(args.issue.id, body); } catch (error) { args.log?.("linear.lane_initial_comment_failed", { issueId: args.issue.id, diff --git a/apps/desktop/src/main/services/deeplinks/protocolHandler.ts b/apps/desktop/src/main/services/deeplinks/protocolHandler.ts index 58878fcfe..47681acb1 100644 --- a/apps/desktop/src/main/services/deeplinks/protocolHandler.ts +++ b/apps/desktop/src/main/services/deeplinks/protocolHandler.ts @@ -1,4 +1,3 @@ -import path from "node:path"; import { app, BrowserWindow } from "electron"; import { @@ -18,6 +17,13 @@ export type DeeplinkDispatcher = ( request: AppNavigationRequest, ) => Promise<void> | void; +const ADE_OPEN_HTTPS_RE = /^https?:\/\/ade\.app\/open\b/i; + +function isAdeDeeplinkArg(arg: unknown): arg is string { + if (typeof arg !== "string") return false; + return arg.startsWith(`${ADE_DEEPLINK_SCHEME}://`) || ADE_OPEN_HTTPS_RE.test(arg); +} + /** * Register ADE as the OS handler for `ade://` URLs and wire up the * single-instance lock so a second `open ade://...` invocation reuses the @@ -31,20 +37,56 @@ export function registerAdeProtocolHandler(options: { dispatch: DeeplinkDispatcher; /** Optional structured log hook. */ log?: (event: string, fields: Record<string, unknown>) => void; + /** + * When true, ask the OS to make this app the default `ade://` handler. When + * false the single-instance lock and `open-url` / `second-instance` + * listeners are still installed so the app can dispatch deeplinks delivered + * to it (e.g. via `duti` or a manual user binding), but it won't try to + * claim the scheme on boot. Defaults to false — callers decide based on + * channel. + */ + claimAsDefault?: boolean; }): void { const { dispatch } = options; const log = options.log ?? (() => {}); + const claimAsDefault = options.claimAsDefault === true; // Register URL scheme. The argv variant is required on Windows/Linux so the - // OS spawn picks up the URL on cold-start. - if (process.defaultApp) { - if (process.argv.length >= 2) { - app.setAsDefaultProtocolClient(ADE_DEEPLINK_SCHEME, process.execPath, [ - path.resolve(process.argv[1]), - ]); + // OS spawn picks up the URL on cold-start. Beta/Alpha channels (and dev + // builds by default) skip this so they don't fight Stable for the binding + // on machines where multiple channels are installed. + if (claimAsDefault) { + if (process.defaultApp) { + // Dev mode: we're running as `electron <flags> <app-path> <more-flags>`. + // To make `open ade://...` re-launch the SAME dev process we must pass + // all original argv to the OS-spawned process, not just `argv[1]` (which + // in practice is usually a flag like `--remote-debugging-port=9222`, + // not the app path — so passing only that gives you the bare Electron + // splash screen instead of ADE). Strip any deeplink URLs that may + // already be in argv (cold-start) so they don't get re-issued. + const respawnArgs = process.argv + .slice(1) + .filter((arg): arg is string => typeof arg === "string" && !isAdeDeeplinkArg(arg)); + // npm exec / the dev launcher already pass an absolute path for the + // app entry, so pass argv through unchanged — applying path.resolve to + // non-flag args turns flag VALUES (e.g. `YES` after + // `-ApplePersistenceIgnoreState`) into bogus paths. + app.setAsDefaultProtocolClient( + ADE_DEEPLINK_SCHEME, + process.execPath, + respawnArgs, + ); + log("deeplink.scheme_claimed", { + scheme: ADE_DEEPLINK_SCHEME, + mode: "dev", + respawnArgs, + }); + } else { + app.setAsDefaultProtocolClient(ADE_DEEPLINK_SCHEME); + log("deeplink.scheme_claimed", { scheme: ADE_DEEPLINK_SCHEME, mode: "packaged" }); } } else { - app.setAsDefaultProtocolClient(ADE_DEEPLINK_SCHEME); + log("deeplink.scheme_skipped", { scheme: ADE_DEEPLINK_SCHEME }); } // Single-instance lock: a second invocation routes through `second-instance` @@ -90,25 +132,13 @@ export function registerAdeProtocolHandler(options: { focusable.focus(); } for (const arg of argv) { - if (typeof arg !== "string") continue; - if ( - arg.startsWith(`${ADE_DEEPLINK_SCHEME}://`) || - /^https?:\/\/ade\.app\/open\b/i.test(arg) - ) { - consume(arg, "second-instance"); - } + if (isAdeDeeplinkArg(arg)) consume(arg, "second-instance"); } }); // Pick up any URL embedded in this process's own argv (Windows cold-start). for (const arg of process.argv.slice(1)) { - if (typeof arg !== "string") continue; - if ( - arg.startsWith(`${ADE_DEEPLINK_SCHEME}://`) || - /^https?:\/\/ade\.app\/open\b/i.test(arg) - ) { - pendingUrls.push(arg); - } + if (isAdeDeeplinkArg(arg)) pendingUrls.push(arg); } // Flush buffer once the app is ready. Use `whenReady()` rather than diff --git a/apps/desktop/src/main/services/lanes/laneService.test.ts b/apps/desktop/src/main/services/lanes/laneService.test.ts index cb23544aa..8601aaa1b 100644 --- a/apps/desktop/src/main/services/lanes/laneService.test.ts +++ b/apps/desktop/src/main/services/lanes/laneService.test.ts @@ -3038,6 +3038,86 @@ describe("laneService delete teardown + cancellation + streaming", () => { expect(completedProgress[0]?.overallStatus).toBe("completed"); }); + it("reports whether a lane delete is currently running", async () => { + const events: any[] = []; + const fake = makeFakeServices(); + let releaseStop: (() => void) | null = null; + fake.processService.stopAll.mockImplementation(async () => { + fake.calls.push("stop_processes"); + await new Promise<void>((resolve) => { + releaseStop = resolve; + }); + }); + const { service } = await setupWithLane({ teardown: fake, events }); + vi.mocked(runGit).mockResolvedValue({ exitCode: 0, stdout: "", stderr: "" } as any); + vi.mocked(runGitOrThrow).mockResolvedValue({ exitCode: 0, stdout: "", stderr: "" } as any); + + const deletePromise = service.delete({ laneId: "lane-child", deleteBranch: false, force: true }); + await new Promise((r) => setTimeout(r, 10)); + expect(service.hasRunningDelete()).toBe(true); + + expect(releaseStop).not.toBeNull(); + releaseStop!(); + await deletePromise; + + expect(service.hasRunningDelete()).toBe(false); + }); + + it("queues lane creation while an in-flight delete owns the worktree mutation slot", async () => { + const events: any[] = []; + const fake = makeFakeServices(); + const order: string[] = []; + let releaseStop: (() => void) | null = null; + let stopStarted: (() => void) | null = null; + const stopStartedPromise = new Promise<void>((resolve) => { + stopStarted = resolve; + }); + fake.processService.stopAll.mockImplementation(async () => { + fake.calls.push("stop_processes"); + order.push("delete:stop_processes"); + stopStarted?.(); + await new Promise<void>((resolve) => { + releaseStop = resolve; + }); + }); + const { service } = await setupWithLane({ teardown: fake, events }); + vi.mocked(getHeadSha).mockResolvedValue("parent-head"); + vi.mocked(runGit).mockImplementation(async (args: string[]) => { + const laneBranchGitStub = defaultLaneBranchGitStub(args); + if (laneBranchGitStub) return laneBranchGitStub; + if (args[0] === "status") return { exitCode: 0, stdout: "", stderr: "" } as any; + if (args[0] === "worktree" && args[1] === "remove") { + order.push("delete:worktree_remove"); + return { exitCode: 0, stdout: "", stderr: "" } as any; + } + if (args[0] === "push") return { exitCode: 0, stdout: "", stderr: "" } as any; + if (args[0] === "rev-list") return { exitCode: 0, stdout: "0\n", stderr: "" } as any; + if (args[0] === "rev-parse") return { exitCode: 0, stdout: "parent-head\n", stderr: "" } as any; + return { exitCode: 0, stdout: "", stderr: "" } as any; + }); + vi.mocked(runGitOrThrow).mockImplementation(async (args: string[]) => { + if (args[0] === "worktree" && args[1] === "add") { + order.push("create:worktree_add"); + } + return { exitCode: 0, stdout: "", stderr: "" } as any; + }); + + const deletePromise = service.delete({ laneId: "lane-child", deleteBranch: false, force: true }); + await stopStartedPromise; + + const createPromise = service.create({ name: "New lane", parentLaneId: "lane-parent" }); + await new Promise((r) => setTimeout(r, 20)); + expect(order).not.toContain("create:worktree_add"); + + expect(releaseStop).not.toBeNull(); + releaseStop!(); + await Promise.all([deletePromise, createPromise]); + + expect(order.indexOf("delete:worktree_remove")).toBeGreaterThanOrEqual(0); + expect(order.indexOf("create:worktree_add")).toBeGreaterThanOrEqual(0); + expect(order.indexOf("delete:worktree_remove")).toBeLessThan(order.indexOf("create:worktree_add")); + }); + it("deletes the lane locally when optional remote branch cleanup fails", async () => { const events: any[] = []; const fake = makeFakeServices(); diff --git a/apps/desktop/src/main/services/lanes/laneService.ts b/apps/desktop/src/main/services/lanes/laneService.ts index f00599e56..232b44877 100644 --- a/apps/desktop/src/main/services/lanes/laneService.ts +++ b/apps/desktop/src/main/services/lanes/laneService.ts @@ -2,6 +2,7 @@ import fs from "node:fs"; import fsp from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import { AsyncLocalStorage } from "node:async_hooks"; import { randomUUID } from "node:crypto"; import type { AdeDb } from "../state/kvDb"; import { getHeadSha, runGit, runGitOrThrow } from "../git/git"; @@ -1863,10 +1864,12 @@ export function createLaneService({ const runtimePlacement = normalizeRuntimePlacement(args.runtimePlacement); const worktreePath = path.join(worktreesDir, `${slug}-${suffix}`); - await runGitOrThrow(["worktree", "add", "-b", branchRef, worktreePath, args.startPoint], { - cwd: projectRoot, - timeoutMs: 60_000 - }); + await runGitWorktreeMutation(() => + runGitOrThrow(["worktree", "add", "-b", branchRef, worktreePath, args.startPoint], { + cwd: projectRoot, + timeoutMs: 60_000 + }) + ); linkExistingDependencyInstalls(worktreePath); db.run( @@ -2008,6 +2011,20 @@ export function createLaneService({ }; const deleteProgressByLaneId = new Map<string, LaneDeleteProgress>(); + let gitWorktreeMutationQueue: Promise<void> = Promise.resolve(); + const gitWorktreeMutationOwner = new AsyncLocalStorage<boolean>(); + + const runGitWorktreeMutation = async <T>(work: () => Promise<T>): Promise<T> => { + if (gitWorktreeMutationOwner.getStore()) { + return work(); + } + const run = gitWorktreeMutationQueue.then(() => gitWorktreeMutationOwner.run(true, work)); + gitWorktreeMutationQueue = run.then( + () => undefined, + () => undefined, + ); + return run; + }; const pruneDeleteProgressHistory = (now = Date.now()): void => { for (const [laneId, progress] of deleteProgressByLaneId.entries()) { @@ -2395,10 +2412,12 @@ export function createLaneService({ if (!row) return; if (row.lane_type === "worktree" && row.worktree_path && fs.existsSync(row.worktree_path)) { - await runGitOrThrow(["worktree", "remove", "--force", row.worktree_path], { - cwd: projectRoot, - timeoutMs: 60_000, - }); + await runGitWorktreeMutation(() => + runGitOrThrow(["worktree", "remove", "--force", row.worktree_path], { + cwd: projectRoot, + timeoutMs: 60_000, + }) + ); } if (row.branch_ref) { @@ -2551,10 +2570,12 @@ export function createLaneService({ } // Attaching an existing branch: do NOT create a new branch, just add a worktree checkout. - await runGitOrThrow(["worktree", "add", worktreePath, branchRef], { - cwd: projectRoot, - timeoutMs: 60_000 - }); + await runGitWorktreeMutation(() => + runGitOrThrow(["worktree", "add", worktreePath, branchRef], { + cwd: projectRoot, + timeoutMs: 60_000 + }) + ); worktreeAdded = true; linkExistingDependencyInstalls(worktreePath); @@ -2657,19 +2678,23 @@ export function createLaneService({ const cleanupErrors: string[] = []; if (worktreeAdded) { try { - await runGitOrThrow(["worktree", "remove", "--force", worktreePath], { - cwd: projectRoot, - timeoutMs: 60_000, - }); + await runGitWorktreeMutation(() => + runGitOrThrow(["worktree", "remove", "--force", worktreePath], { + cwd: projectRoot, + timeoutMs: 60_000, + }) + ); } catch (cleanupError) { try { fs.rmSync(worktreePath, { recursive: true, force: true }); // Directory removed but git metadata may be orphaned; prune to clean up try { - await runGitOrThrow(["worktree", "prune"], { - cwd: projectRoot, - timeoutMs: 60_000, - }); + await runGitWorktreeMutation(() => + runGitOrThrow(["worktree", "prune"], { + cwd: projectRoot, + timeoutMs: 60_000, + }) + ); } catch (pruneError) { cleanupErrors.push(`worktree prune failed: ${pruneError instanceof Error ? pruneError.message : String(pruneError)}`); } @@ -3691,6 +3716,11 @@ export function createLaneService({ .map(cloneLaneDeleteProgress); }, + hasRunningDelete(): boolean { + pruneDeleteProgressHistory(); + return Array.from(deleteProgressByLaneId.values()).some((progress) => progress.overallStatus === "running"); + }, + async delete( args: DeleteLaneArgs, runtimeOpts?: { teardownEnv?: () => Promise<void> } @@ -3796,6 +3826,7 @@ export function createLaneService({ broadcastDeleteEvent(progress); + await runGitWorktreeMutation(async () => { try { if (hasWorktree) { await runStep("git_status", async () => { @@ -3865,41 +3896,43 @@ export function createLaneService({ if (hasWorktree) { await runStep("git_worktree_remove", async () => { - const removeArgs = ["worktree", "remove"]; - if (force) removeArgs.push("--force"); - removeArgs.push(row.worktree_path); - const removeResidualDirectory = async (detail: string, failurePrefix?: string) => { - try { - await removeWorktreeDirectoryWithRecovery(row.worktree_path); - } catch (rmError) { - throw new Error( - `${failurePrefix ? `${failurePrefix}; ` : ""}manual cleanup failed: ${ - rmError instanceof Error ? rmError.message : String(rmError) - }` - ); - } - await runGitOrThrow(["worktree", "prune"], { cwd: projectRoot, timeoutMs: 30_000 }); - return { detail }; - }; - // 60s — large worktrees (e.g. with node_modules) can take longer than 15s - // to walk; a timeout here mid-remove leaves the worktree in a half-deleted - // state that blocks future deletes. - const removeRes = await runGit(removeArgs, { cwd: projectRoot, timeoutMs: 60_000 }); - if (removeRes.exitCode === 0) { - if (fs.existsSync(row.worktree_path)) { - return removeResidualDirectory(`${row.worktree_path} (removed residual files)`); + return runGitWorktreeMutation(async () => { + const removeArgs = ["worktree", "remove"]; + if (force) removeArgs.push("--force"); + removeArgs.push(row.worktree_path); + const removeResidualDirectory = async (detail: string, failurePrefix?: string) => { + try { + await removeWorktreeDirectoryWithRecovery(row.worktree_path); + } catch (rmError) { + throw new Error( + `${failurePrefix ? `${failurePrefix}; ` : ""}manual cleanup failed: ${ + rmError instanceof Error ? rmError.message : String(rmError) + }` + ); + } + await runGitOrThrow(["worktree", "prune"], { cwd: projectRoot, timeoutMs: 30_000 }); + return { detail }; + }; + // 60s — large worktrees (e.g. with node_modules) can take longer than 15s + // to walk; a timeout here mid-remove leaves the worktree in a half-deleted + // state that blocks future deletes. + const removeRes = await runGit(removeArgs, { cwd: projectRoot, timeoutMs: 60_000 }); + if (removeRes.exitCode === 0) { + if (fs.existsSync(row.worktree_path)) { + return removeResidualDirectory(`${row.worktree_path} (removed residual files)`); + } + return { detail: row.worktree_path }; } - return { detail: row.worktree_path }; - } - // Recovery path: a previous failed delete (or this one's first attempt) - // can leave the worktree dir present without its `.git` pointer file, or - // the dir gone with stale metadata still registered. Either way: rm the - // dir if any, then prune git's metadata. - const original = (removeRes.stderr || removeRes.stdout || "").trim(); - return removeResidualDirectory( - `${row.worktree_path} (recovered from stale state)`, - `git worktree remove failed (${original})` - ); + // Recovery path: a previous failed delete (or this one's first attempt) + // can leave the worktree dir present without its `.git` pointer file, or + // the dir gone with stale metadata still registered. Either way: rm the + // dir if any, then prune git's metadata. + const original = (removeRes.stderr || removeRes.stdout || "").trim(); + return removeResidualDirectory( + `${row.worktree_path} (recovered from stale state)`, + `git worktree remove failed (${original})` + ); + }); }); } @@ -3983,6 +4016,7 @@ export function createLaneService({ finalize("failed"); throw error; } + }); }, cancelDelete(laneId: string): { cancelled: boolean; reason?: string } { @@ -4487,10 +4521,12 @@ export function createLaneService({ throw new Error(`Destination path is already in use by lane '${existingTarget.name}'.`); } - await runGitOrThrow(["worktree", "move", currentPath, targetPath], { - cwd: projectRoot, - timeoutMs: 120_000 - }); + await runGitWorktreeMutation(() => + runGitOrThrow(["worktree", "move", currentPath, targetPath], { + cwd: projectRoot, + timeoutMs: 120_000 + }) + ); } db.run( diff --git a/apps/desktop/src/main/services/pty/ptyService.ts b/apps/desktop/src/main/services/pty/ptyService.ts index 1104a803e..993f30227 100644 --- a/apps/desktop/src/main/services/pty/ptyService.ts +++ b/apps/desktop/src/main/services/pty/ptyService.ts @@ -2499,7 +2499,7 @@ export function createPtyService({ resumeCommand: initialResumeCommand, resumeMetadata: initialResumeMetadata, chatSessionId, - ...(ownerPid != null ? { ownerPid } : {}), + ownerPid, }); setRuntimeState(sessionId, "running"); diff --git a/apps/desktop/src/main/services/review/reviewContextBuilder.test.ts b/apps/desktop/src/main/services/review/reviewContextBuilder.test.ts index d700c9675..83ab5dfb7 100644 --- a/apps/desktop/src/main/services/review/reviewContextBuilder.test.ts +++ b/apps/desktop/src/main/services/review/reviewContextBuilder.test.ts @@ -136,14 +136,6 @@ function makeRun() { dirtyOnly: false, modelId: "openai/gpt-5.4", reasoningEffort: "medium", - budgets: { - maxFiles: 60, - maxDiffChars: 180_000, - maxPromptChars: 220_000, - maxFindings: 12, - maxFindingsPerPass: 6, - maxPublishedFindings: 6, - }, publishBehavior: "local_only", }, targetLabel: "lane-review vs main", diff --git a/apps/desktop/src/main/services/review/reviewService.test.ts b/apps/desktop/src/main/services/review/reviewService.test.ts index e3ee6662c..aa8b16464 100644 --- a/apps/desktop/src/main/services/review/reviewService.test.ts +++ b/apps/desktop/src/main/services/review/reviewService.test.ts @@ -244,14 +244,6 @@ function makeConfig(overrides: Partial<ReviewRunConfig> = {}): ReviewRunConfig { dirtyOnly: false, modelId: "openai/gpt-5.4", reasoningEffort: "medium", - budgets: { - maxFiles: 60, - maxDiffChars: 180_000, - maxPromptChars: 220_000, - maxFindings: 12, - maxFindingsPerPass: 6, - maxPublishedFindings: 6, - }, publishBehavior: "local_only", ...overrides, }; @@ -919,53 +911,6 @@ describe("reviewService", () => { expect(harness.runSessionTurn).not.toHaveBeenCalled(); }); - it("enforces the final prompt budget without embedding the full diff bundle", async () => { - const longDiff = [ - "diff --git a/src/review.ts b/src/review.ts", - "@@ -1,2 +1,200 @@", - " context", - ...Array.from({ length: 1_000 }, (_, index) => `+exact changed line ${index}: ${"x".repeat(60)}`), - ].join("\n"); - const harness = createHarness({ - outputs: [ - makeOutput("No direct findings.", []), - makeOutput("No cross-file findings.", []), - makeOutput("No checks findings.", []), - makeOutput("No security findings.", []), - makeOutput("No UI findings.", []), - ], - config: { - budgets: { - maxFiles: 60, - maxDiffChars: 100_000, - maxPromptChars: 4_000, - maxFindings: 12, - maxFindingsPerPass: 6, - maxPublishedFindings: 6, - }, - }, - }); - mockMaterializer.materialize.mockResolvedValueOnce(makeMaterializedTarget({ - fullPatchText: longDiff, - changedFiles: [makeChangedFile({ excerpt: longDiff.slice(0, 4_000) })], - })); - - const run = await harness.start(); - await waitFor( - () => harness.service.listRuns(), - (runs) => runs.some((entry) => entry.id === run.id && entry.status === "completed"), - ); - - const detail = await harness.service.getRunDetail({ runId: run.id }); - const passPrompts = detail?.artifacts.filter((artifact) => artifact.artifactType === "pass_prompt") ?? []; - expect(passPrompts).toHaveLength(5); - for (const prompt of passPrompts) { - expect(prompt.contentText ?? "").toHaveLength(4_000); - expect(prompt.contentText ?? "").toContain("...(truncated)..."); - expect(prompt.contentText ?? "").not.toContain("exact changed line 999"); - } - }); - it("passes Codex fast mode to the automation chat while keeping plan permissions", async () => { const harness = createHarness({ outputs: [ @@ -989,42 +934,7 @@ describe("reviewService", () => { })); }); - it("preserves unlimited review budgets instead of clamping them", async () => { - const harness = createHarness({ - outputs: [ - makeOutput("No direct findings.", []), - makeOutput("No cross-file findings.", []), - makeOutput("No checks findings.", []), - ], - config: { - budgets: { - unlimited: true, - maxFiles: 1, - maxDiffChars: 4_000, - maxPromptChars: 4_000, - maxFindings: 1, - maxFindingsPerPass: 1, - maxPublishedFindings: 1, - }, - }, - }); - - const run = await harness.start(); - await waitFor( - () => harness.service.listRuns(), - (runs) => runs.some((entry) => entry.id === run.id && entry.status === "completed"), - ); - - const saved = (await harness.service.listRuns()).find((entry) => entry.id === run.id); - expect(saved?.config.budgets).toMatchObject({ - unlimited: true, - maxFiles: Number.MAX_SAFE_INTEGER, - maxFindings: Number.MAX_SAFE_INTEGER, - maxPublishedFindings: Number.MAX_SAFE_INTEGER, - }); - }); - - it("caps the prompt manifest even when review budgets are unlimited", async () => { + it("caps the prompt manifest for large diffs", async () => { const changedFiles = Array.from({ length: 120 }, (_, index) => makeChangedFile({ filePath: `src/file-${index}.ts`, excerpt: `@@ -1 +1 @@\n+file ${index} ${"x".repeat(200)}`, @@ -1042,17 +952,6 @@ describe("reviewService", () => { materializedTarget: { changedFiles, }, - config: { - budgets: { - unlimited: true, - maxFiles: 1, - maxDiffChars: 4_000, - maxPromptChars: 4_000, - maxFindings: 1, - maxFindingsPerPass: 1, - maxPublishedFindings: 1, - }, - }, }); const run = await harness.start(); @@ -1577,7 +1476,7 @@ describe("reviewService", () => { expect(adjudicationArtifact?.contentText).toContain("low_evidence"); }); - it("applies run and publication budgets and only publishes adjudicated findings", async () => { + it("publishes only adjudicated findings", async () => { const destination: ReviewPublicationDestination = { kind: "github_pr_review", prId: "pr-80", @@ -1607,14 +1506,6 @@ describe("reviewService", () => { }, config: { publishBehavior: "auto_publish", - budgets: { - maxFiles: 60, - maxDiffChars: 180_000, - maxPromptChars: 220_000, - maxFindings: 2, - maxFindingsPerPass: 4, - maxPublishedFindings: 1, - }, }, outputs: [ makeOutput("Diff-risk findings.", [ @@ -1646,12 +1537,12 @@ describe("reviewService", () => { expect(harness.publishReviewPublication).toHaveBeenCalledTimes(1); const publicationArgs = harness.publishReviewPublication.mock.calls[0]?.[0]; - expect(publicationArgs.findings).toHaveLength(1); - expect(publicationArgs.findings[0]?.sourcePass).toBe("adjudicated"); + expect(publicationArgs.findings.length).toBeGreaterThan(0); + expect(publicationArgs.findings.every((finding: { sourcePass: string }) => finding.sourcePass === "adjudicated")).toBe(true); const detail = await harness.service.getRunDetail({ runId: run.id }); - expect(detail?.findings).toHaveLength(2); - expect(detail?.findings.filter((finding) => finding.publicationState === "published")).toHaveLength(1); + expect(detail?.findings.length).toBeGreaterThan(0); + expect(detail?.findings.filter((finding) => finding.publicationState === "published")).toHaveLength(publicationArgs.findings.length); expect(detail?.publications).toHaveLength(1); expect(detail?.artifacts.some((artifact) => artifact.artifactType === "publication_request")).toBe(true); }); diff --git a/apps/desktop/src/main/services/review/reviewService.ts b/apps/desktop/src/main/services/review/reviewService.ts index c1708e6a2..ef644cdc2 100644 --- a/apps/desktop/src/main/services/review/reviewService.ts +++ b/apps/desktop/src/main/services/review/reviewService.ts @@ -206,15 +206,6 @@ function resolveBuiltinReviewModelId(): string { const DEFAULT_REVIEW_MODEL_ID = resolveBuiltinReviewModelId(); -const DEFAULT_BUDGETS: ReviewRunConfig["budgets"] = { - maxFiles: 60, - maxDiffChars: 180_000, - maxPromptChars: 220_000, - maxFindings: 12, - maxFindingsPerPass: 6, - maxPublishedFindings: 6, -}; -const UNLIMITED_BUDGET_VALUE = Number.MAX_SAFE_INTEGER; const MANIFEST_PROMPT_FILE_LIMIT = 100; const REVIEW_PASS_ORDER: ReviewPassKey[] = [ @@ -291,14 +282,13 @@ type PassExecutionResult = { promptArtifactId: string; outputArtifactId: string; findingsArtifactId: string; - budgetTrimmedCount: number; }; type AdjudicationRejectedFinding = { candidateIds: string[]; passKeys: ReviewPassKey[]; title: string; - reason: "low_evidence" | "low_signal" | "duplicate" | "budget" | "rule_policy"; + reason: "low_evidence" | "low_signal" | "duplicate" | "rule_policy"; detail: string; score: number; }; @@ -383,36 +373,6 @@ function mergeFindingClass(classes: Array<ReviewFindingClass | null | undefined> return null; } -function normalizeBudgetConfig(budgets?: Partial<ReviewRunConfig["budgets"]> | null): ReviewRunConfig["budgets"] { - if (budgets?.unlimited === true) { - return { - unlimited: true, - maxFiles: UNLIMITED_BUDGET_VALUE, - maxDiffChars: UNLIMITED_BUDGET_VALUE, - maxPromptChars: UNLIMITED_BUDGET_VALUE, - maxFindings: UNLIMITED_BUDGET_VALUE, - maxFindingsPerPass: UNLIMITED_BUDGET_VALUE, - maxPublishedFindings: UNLIMITED_BUDGET_VALUE, - }; - } - return { - maxFiles: clampNumber(Number(budgets?.maxFiles ?? DEFAULT_BUDGETS.maxFiles), 1, 500), - maxDiffChars: clampNumber(Number(budgets?.maxDiffChars ?? DEFAULT_BUDGETS.maxDiffChars), 4_000, 1_000_000), - maxPromptChars: clampNumber(Number(budgets?.maxPromptChars ?? DEFAULT_BUDGETS.maxPromptChars), 4_000, 1_000_000), - maxFindings: clampNumber(Number(budgets?.maxFindings ?? DEFAULT_BUDGETS.maxFindings), 1, 50), - maxFindingsPerPass: clampNumber( - Number(budgets?.maxFindingsPerPass ?? DEFAULT_BUDGETS.maxFindingsPerPass ?? DEFAULT_BUDGETS.maxFindings), - 1, - 50, - ), - maxPublishedFindings: clampNumber( - Number(budgets?.maxPublishedFindings ?? DEFAULT_BUDGETS.maxPublishedFindings ?? DEFAULT_BUDGETS.maxFindings), - 1, - 50, - ), - }; -} - function extractJsonObject(raw: string): Record<string, unknown> | null { const candidates: string[] = []; const trimmed = raw.trim(); @@ -566,13 +526,11 @@ function buildChangedFileManifestPayload(args: { targetLabel: string; compareTarget: ReviewResolvedCompareTarget | null; changedFiles: MaterializedChangedFile[]; - budgets: ReviewRunConfig["budgets"]; }): Record<string, unknown> { return { targetLabel: args.targetLabel, compareTarget: args.compareTarget, fileCount: args.changedFiles.length, - budgetMode: args.budgets.unlimited === true ? "unlimited" : "bounded", files: args.changedFiles.map((file) => ({ path: file.filePath, changedLineCount: file.lineNumbers.length, @@ -694,9 +652,7 @@ function buildPassPrompt(args: { '- "line": line number when known, otherwise null', '- "evidence": array of objects with {"kind": "diff_hunk"|"file_snapshot"|"artifact"|"quote", "summary": string, "quote": string|null, "filePath": string|null, "line": number|null, "artifactId": string|null}', '- Use "diff_hunk" for changed lines and "file_snapshot" for exact lines you inspected outside the changed hunks.', - args.run.config.budgets.unlimited === true - ? "Return every real issue you can substantiate from the supplied evidence." - : `Return at most ${args.run.config.budgets.maxFindingsPerPass ?? args.run.config.budgets.maxFindings} findings.`, + "Return every real issue you can substantiate from the supplied evidence.", "If there are no real issues, return an empty findings array and explain that in summary.", "", `Reviewer key: ${args.pass.key}`, @@ -1263,7 +1219,6 @@ function evaluateRuleEvidencePolicy(args: { function adjudicatePassFindings(args: { runId: string; passResults: PassExecutionResult[]; - budgets: ReviewRunConfig["budgets"]; context: ReviewContextPacket; artifactIds: ReviewContextArtifactIds; }): AdjudicationOutcome { @@ -1425,20 +1380,7 @@ function adjudicatePassFindings(args: { const lineDelta = (left.line ?? Number.MAX_SAFE_INTEGER) - (right.line ?? Number.MAX_SAFE_INTEGER); if (lineDelta !== 0) return lineDelta; return left.title.localeCompare(right.title); - }) - .slice(0, args.budgets.maxFindings); - const keptIds = new Set(keptFindings.map((finding) => finding.id)); - for (const finding of findings) { - if (keptIds.has(finding.id)) continue; - rejected.push({ - candidateIds: finding.adjudication?.mergedFindingIds ?? [], - passKeys: finding.originatingPasses ?? [], - title: finding.title, - reason: "budget", - detail: "The finding cleared adjudication but was trimmed by the run-level budget.", - score: finding.adjudication?.score ?? 0, }); - } const publicationEligibleCount = keptFindings.filter((finding) => finding.adjudication?.publicationEligible).length; return { @@ -1478,7 +1420,6 @@ function mapRunRow(row: ReviewRunRow): ReviewRun { dirtyOnly: false, modelId: DEFAULT_REVIEW_MODEL_ID, reasoningEffort: null, - budgets: DEFAULT_BUDGETS, publishBehavior: "local_only", }); return { @@ -1489,10 +1430,7 @@ function mapRunRow(row: ReviewRunRow): ReviewRun { mode: "working_tree", laneId: row.lane_id, }), - config: { - ...config, - budgets: normalizeBudgetConfig(config.budgets), - }, + config, targetLabel: row.target_label, compareTarget: safeJsonParse<ReviewResolvedCompareTarget | null>(row.compare_target_json, null), status: (row.status as ReviewRunStatus) ?? "failed", @@ -2071,7 +2009,6 @@ export function createReviewService({ modelId: partial?.modelId?.trim() || defaultReviewModelId, reasoningEffort: partial?.reasoningEffort?.trim() || null, codexFastMode: partial?.codexFastMode === true, - budgets: normalizeBudgetConfig(partial?.budgets), publishBehavior: target.mode === "pr" && partial?.publishBehavior === "auto_publish" ? "auto_publish" : "local_only", @@ -2093,8 +2030,7 @@ export function createReviewService({ const publishableFindings = [...args.findings] .filter((finding) => finding.sourcePass === "adjudicated" && finding.adjudication?.publicationEligible) - .sort((left, right) => (right.adjudication?.score ?? 0) - (left.adjudication?.score ?? 0)) - .slice(0, args.config.budgets.maxPublishedFindings ?? args.config.budgets.maxFindings); + .sort((left, right) => (right.adjudication?.score ?? 0) - (left.adjudication?.score ?? 0)); insertArtifact(args.runId, { artifactType: "publication_request", @@ -2194,7 +2130,6 @@ export function createReviewService({ promptArtifactId: "", outputArtifactId: "", findingsArtifactId: "", - budgetTrimmedCount: 0, }; } const startedAt = nowIso(); @@ -2258,14 +2193,14 @@ export function createReviewService({ throw new Error("Review run cancelled before reviewer prompt dispatch."); } - const prompt = truncateText(buildPassPrompt({ + const prompt = buildPassPrompt({ run: args.run, pass: args.pass, - manifestPrompt: truncateText(args.manifestPrompt, args.run.config.budgets.maxPromptChars), + manifestPrompt: args.manifestPrompt, changedFiles: args.changedFiles, context: args.context, contextArtifactIds: args.contextArtifactIds, - }), args.run.config.budgets.maxPromptChars); + }); const promptArtifact = insertArtifact(args.runId, { artifactType: "pass_prompt", title: `${args.pass.label} prompt`, @@ -2316,8 +2251,7 @@ export function createReviewService({ changedFilesByPath: args.changedFilesByPath, }); const candidates = [...normalized.findings] - .sort(compareCandidatesStable) - .slice(0, args.run.config.budgets.maxFindingsPerPass ?? args.run.config.budgets.maxFindings); + .sort(compareCandidatesStable); for (const candidate of normalized.findings) { insertCandidateFinding(reviewerRun.id, candidate); } @@ -2330,7 +2264,6 @@ export function createReviewService({ summary: normalized.summary, totalParsedCount: normalized.findings.length, keptCount: candidates.length, - budgetTrimmedCount: Math.max(0, normalized.findings.length - candidates.length), candidates, }, null, 2), metadata: { @@ -2338,7 +2271,6 @@ export function createReviewService({ summary: normalized.summary, totalParsedCount: normalized.findings.length, keptCount: candidates.length, - budgetTrimmedCount: Math.max(0, normalized.findings.length - candidates.length), }, }); findingsArtifactId = findingsArtifact.id; @@ -2372,7 +2304,6 @@ export function createReviewService({ promptArtifactId: promptArtifact.id, outputArtifactId: outputArtifact.id, findingsArtifactId: findingsArtifact.id, - budgetTrimmedCount: Math.max(0, normalized.findings.length - candidates.length), }; } catch (error) { const status: ReviewReviewerRunStatus = cancelledRuns.has(args.runId) ? "cancelled" : "failed"; @@ -2422,7 +2353,6 @@ export function createReviewService({ promptArtifactId: promptArtifactId ?? "", outputArtifactId, findingsArtifactId: findingsArtifactId ?? "", - budgetTrimmedCount: 0, }; } finally { if (sessionId) { @@ -2511,13 +2441,12 @@ export function createReviewService({ targetLabel: materialized.targetLabel, compareTarget: materialized.compareTarget, }; - const changedFiles = materialized.changedFiles.slice(0, effectiveRun.config.budgets.maxFiles); + const changedFiles = materialized.changedFiles; const promptChangedFiles = changedFiles.slice(0, MANIFEST_PROMPT_FILE_LIMIT); const manifestPayload = buildChangedFileManifestPayload({ targetLabel: materialized.targetLabel, compareTarget: materialized.compareTarget, changedFiles, - budgets: effectiveRun.config.budgets, }); const riskMapPayload = buildRiskMapPayload(changedFiles); const promptManifestPayload = { @@ -2525,7 +2454,6 @@ export function createReviewService({ targetLabel: materialized.targetLabel, compareTarget: materialized.compareTarget, changedFiles: promptChangedFiles, - budgets: effectiveRun.config.budgets, }), totalFileCount: changedFiles.length, omittedFileCount: Math.max(0, changedFiles.length - promptChangedFiles.length), @@ -2540,7 +2468,6 @@ export function createReviewService({ metadata: { fileCount: changedFiles.length, totalFileCount: materialized.changedFiles.length, - budgetMode: effectiveRun.config.budgets.unlimited === true ? "unlimited" : "bounded", }, }); const riskMapArtifact = insertArtifact(runId, { @@ -2598,7 +2525,6 @@ export function createReviewService({ targetLabel: materialized.targetLabel, architecture: "parallel_specialist_reviewers", passKeys: REVIEW_PASSES.map((pass) => pass.key), - budgets: effectiveRun.config.budgets, changedFiles: changedFiles.map((entry) => entry.filePath), context: { provenanceSummary: reviewContext.provenance.summary, @@ -2719,7 +2645,6 @@ export function createReviewService({ const adjudication = adjudicatePassFindings({ runId, passResults: completedPassResults, - budgets: effectiveRun.config.budgets, context: reviewContext, artifactIds: contextArtifactIds, }); @@ -2745,7 +2670,6 @@ export function createReviewService({ summary: result.summary, errorMessage: result.errorMessage, keptCount: result.candidates.length, - budgetTrimmedCount: result.budgetTrimmedCount, findingsArtifactId: result.findingsArtifactId, })), }, null, 2), diff --git a/apps/desktop/src/main/services/review/reviewTargetMaterializer.test.ts b/apps/desktop/src/main/services/review/reviewTargetMaterializer.test.ts index e462fa756..81e0d8158 100644 --- a/apps/desktop/src/main/services/review/reviewTargetMaterializer.test.ts +++ b/apps/desktop/src/main/services/review/reviewTargetMaterializer.test.ts @@ -23,12 +23,6 @@ function makeConfig(overrides: Partial<ReviewRunConfig> = {}): ReviewRunConfig { dirtyOnly: false, modelId: "openai/gpt-5.4", reasoningEffort: "medium", - budgets: { - maxFiles: 60, - maxDiffChars: 180_000, - maxPromptChars: 220_000, - maxFindings: 12, - }, publishBehavior: "local_only", ...overrides, }; diff --git a/apps/desktop/src/main/services/sessions/sessionService.ts b/apps/desktop/src/main/services/sessions/sessionService.ts index 1ef7662ef..89203f246 100644 --- a/apps/desktop/src/main/services/sessions/sessionService.ts +++ b/apps/desktop/src/main/services/sessions/sessionService.ts @@ -508,19 +508,13 @@ export function createSessionService({ db }: { db: AdeDb }) { const exclusionSql = normalizedExcludedToolTypes.length ? ` and (tool_type is null or tool_type not in (${normalizedExcludedToolTypes.map(() => "?").join(", ")}))` : ""; - const normalizedLiveOwnerPids = liveOwnerPids + const ownerParams = liveOwnerPids ? Array.from(liveOwnerPids) .map((pid) => normalizeOwnerPid(pid)) .filter((pid): pid is number => pid != null) - : null; - const ownerParams = normalizedLiveOwnerPids && normalizedLiveOwnerPids.length - ? normalizedLiveOwnerPids : []; - const ownerPlaceholders = ownerParams.map(() => "?").join(", "); - const ownerGuardSql = normalizedLiveOwnerPids - ? ownerParams.length - ? ` and (owner_pid is null or owner_pid not in (${ownerPlaceholders}))` - : " and owner_pid is null" + const ownerGuardSql = ownerParams.length + ? ` and (owner_pid is null or owner_pid not in (${ownerParams.map(() => "?").join(", ")}))` : " and owner_pid is null"; const whereSql = `status = 'running'${exclusionSql}${ownerGuardSql}`; const params = [...normalizedExcludedToolTypes, ...ownerParams]; @@ -788,10 +782,6 @@ export function createSessionService({ db }: { db: AdeDb }) { emitChanged({ sessionId: trimmed, reason: "meta-updated" }); }, - clearOwnerPid(sessionId: string): void { - this.setOwnerPid(sessionId, null); - }, - setHeadShaStart(sessionId: string, sha: string): void { db.run("update terminal_sessions set head_sha_start = ? where id = ?", [sha, sessionId]); }, diff --git a/apps/desktop/src/preload/preload.test.ts b/apps/desktop/src/preload/preload.test.ts index d2814f75e..c90620e4a 100644 --- a/apps/desktop/src/preload/preload.test.ts +++ b/apps/desktop/src/preload/preload.test.ts @@ -174,7 +174,7 @@ describe("preload OAuth bridge", () => { expect(removeListener).toHaveBeenCalledWith(IPC.reviewEvent, listener); }); - it("routes review.startRun through a bound local runtime without dropping unlimited budgets", async () => { + it("routes review.startRun through a bound local runtime without dropping config fields", async () => { const binding = { kind: "local", key: "local:/repo", @@ -189,15 +189,6 @@ describe("preload OAuth bridge", () => { dirtyOnly: false, modelId: "openai/gpt-5.4", reasoningEffort: "medium", - budgets: { - unlimited: true, - maxFiles: Number.MAX_SAFE_INTEGER, - maxDiffChars: Number.MAX_SAFE_INTEGER, - maxPromptChars: Number.MAX_SAFE_INTEGER, - maxFindings: Number.MAX_SAFE_INTEGER, - maxFindingsPerPass: Number.MAX_SAFE_INTEGER, - maxPublishedFindings: Number.MAX_SAFE_INTEGER, - }, publishBehavior: "local_only", }, }; diff --git a/apps/desktop/src/renderer/browserMock.ts b/apps/desktop/src/renderer/browserMock.ts index 34823d8ac..9b924b669 100644 --- a/apps/desktop/src/renderer/browserMock.ts +++ b/apps/desktop/src/renderer/browserMock.ts @@ -3686,12 +3686,6 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { dirtyOnly: false, modelId: DEFAULT_BROWSER_MOCK_CODEX_MODEL, reasoningEffort: "medium", - budgets: { - maxFiles: 60, - maxDiffChars: 180000, - maxPromptChars: 220000, - maxFindings: 12, - }, publishBehavior: "local_only", }, targetLabel: "feature/auth-flow vs main", @@ -3725,12 +3719,6 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { dirtyOnly: false, modelId: DEFAULT_BROWSER_MOCK_CODEX_MODEL, reasoningEffort: "medium", - budgets: { - maxFiles: 60, - maxDiffChars: 180000, - maxPromptChars: 220000, - maxFindings: 12, - }, publishBehavior: "local_only", }, targetLabel: "feature/auth-flow vs main", @@ -3835,12 +3823,6 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { dirtyOnly: false, modelId: DEFAULT_BROWSER_MOCK_CODEX_MODEL, reasoningEffort: "medium", - budgets: { - maxFiles: 60, - maxDiffChars: 180000, - maxPromptChars: 220000, - maxFindings: 12, - }, publishBehavior: "local_only", }, targetLabel: "feature/auth-flow review", @@ -3867,12 +3849,6 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { dirtyOnly: false, modelId: DEFAULT_BROWSER_MOCK_CODEX_MODEL, reasoningEffort: "medium", - budgets: { - maxFiles: 60, - maxDiffChars: 180000, - maxPromptChars: 220000, - maxFindings: 12, - }, publishBehavior: "local_only", }, targetLabel: "feature/auth-flow review", @@ -5139,6 +5115,10 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { repoAccessError: null, connected: true, }), + getRemoteStatus: resolved({ + repo: { owner: "arul28", name: "ADE" }, + hasOrigin: true, + }), setToken: resolvedArg({ tokenStored: true, tokenDecryptionFailed: false, diff --git a/apps/desktop/src/renderer/components/app/AppShell.tsx b/apps/desktop/src/renderer/components/app/AppShell.tsx index 4b05d7054..855892fd1 100644 --- a/apps/desktop/src/renderer/components/app/AppShell.tsx +++ b/apps/desktop/src/renderer/components/app/AppShell.tsx @@ -1016,6 +1016,7 @@ export function AppShell({ children }: { children: React.ReactNode }) { "/work": "tab-tint-work", "/graph": "tab-tint-graph", "/prs": "tab-tint-prs", + "/review": "tab-tint-review", "/history": "tab-tint-history", "/automations": "tab-tint-automations", "/missions": "tab-tint-missions", diff --git a/apps/desktop/src/renderer/components/app/InboundDeeplinkModal.tsx b/apps/desktop/src/renderer/components/app/InboundDeeplinkModal.tsx index 4fcb805c9..7b91e557a 100644 --- a/apps/desktop/src/renderer/components/app/InboundDeeplinkModal.tsx +++ b/apps/desktop/src/renderer/components/app/InboundDeeplinkModal.tsx @@ -10,9 +10,7 @@ import { primaryButton, } from "../lanes/laneDesignTokens"; import type { - CreateLaneFromPrBranchArgs, CreateLaneFromPrBranchPreflightResult, - CreateLaneFromPrBranchResult, LaneSummary, } from "../../../shared/types"; @@ -30,19 +28,6 @@ export type InboundDeeplinkModalProps = { lanes: LaneSummary[]; }; -type ApiShape = { - preflightCreateLaneFromPrBranch?: ( - args: CreateLaneFromPrBranchArgs, - ) => Promise<CreateLaneFromPrBranchPreflightResult>; - createLaneFromPrBranch?: ( - args: CreateLaneFromPrBranchArgs, - ) => Promise<CreateLaneFromPrBranchResult>; -}; - -function getPrsApi(): ApiShape { - return (window.ade as unknown as { prs?: ApiShape }).prs ?? {}; -} - function formatActionError(err: unknown): string { if (err instanceof Error) return err.message; return String(err); @@ -96,15 +81,15 @@ export function InboundDeeplinkModal({ return; } let cancelled = false; - const api = getPrsApi(); - if (!api.preflightCreateLaneFromPrBranch) { + const prsApi = window.ade?.prs; + if (!prsApi?.preflightCreateLaneFromPrBranch) { setError("Inbound deeplinks are not available in this build."); return; } setLoading(true); setError(null); setPreflight(null); - void api + void prsApi .preflightCreateLaneFromPrBranch({ repoOwner: target.repoOwner, repoName: target.repoName, @@ -152,14 +137,14 @@ export function InboundDeeplinkModal({ if (!target.prNumber) return; setBusy(true); setError(null); - const api = getPrsApi(); - if (!api.createLaneFromPrBranch) { + const prsApi = window.ade?.prs; + if (!prsApi?.createLaneFromPrBranch) { setError("Inbound deeplinks are not available in this build."); setBusy(false); return; } try { - const result = await api.createLaneFromPrBranch({ + const result = await prsApi.createLaneFromPrBranch({ repoOwner: target.repoOwner, repoName: target.repoName, githubPrNumber: target.prNumber, diff --git a/apps/desktop/src/renderer/components/app/TopBar.tsx b/apps/desktop/src/renderer/components/app/TopBar.tsx index da9725197..b377257bf 100644 --- a/apps/desktop/src/renderer/components/app/TopBar.tsx +++ b/apps/desktop/src/renderer/components/app/TopBar.tsx @@ -1930,14 +1930,14 @@ export function TopBar() { <button type="button" className={cn( - "ade-shell-control inline-flex h-[20px] w-[20px] items-center justify-center", + "ade-shell-control inline-flex h-[24px] w-[24px] items-center justify-center", "transition-[background-color,color,border-color,box-shadow] duration-150", )} onClick={() => setFeedbackOpen(true)} title="Report bug or suggest feature" style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties} > - <ChatCircleDots size={12} weight="regular" /> + <ChatCircleDots size={16} weight="regular" /> </button> </div> {/* /actions group */} diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx index 737de60bb..330da2798 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx @@ -237,7 +237,9 @@ describe("AgentChatComposer", () => { }], }); - fireEvent.click(screen.getByLabelText("Open command picker")); + fireEvent.change(screen.getByRole("textbox"), { + target: { value: "/", selectionStart: 1 }, + }); const statusCommand = await screen.findByText("/status"); const menu = statusCommand.closest(".ade-chat-drawer-glass"); const composerShell = container.querySelector("[data-chat-composer-mode]"); @@ -628,9 +630,7 @@ describe("AgentChatComposer", () => { expect(textbox.disabled).toBe(true); expect(textbox.placeholder).toBe("Answer the question card above, or decline it."); expect(screen.queryByLabelText("Send steer message")).toBeNull(); - expect((screen.getByLabelText("Open attachment picker") as HTMLButtonElement).disabled).toBe(true); expect((screen.getByLabelText("Upload file from disk") as HTMLButtonElement).disabled).toBe(true); - expect((screen.getByLabelText("Open command picker") as HTMLButtonElement).disabled).toBe(true); fireEvent.keyDown(textbox, { key: "Enter" }); @@ -772,7 +772,6 @@ describe("AgentChatComposer", () => { it("allows attachments while steering an active Codex turn", () => { renderComposer({ turnActive: true }); - expect((screen.getByLabelText("Open attachment picker") as HTMLButtonElement).disabled).toBe(false); expect((screen.getByLabelText("Upload file from disk") as HTMLButtonElement).disabled).toBe(false); }); @@ -784,7 +783,6 @@ describe("AgentChatComposer", () => { availableModelIds: ["anthropic/claude-sonnet-4-6"], }); - expect((screen.getByLabelText("Open attachment picker") as HTMLButtonElement).disabled).toBe(false); expect((screen.getByLabelText("Upload file from disk") as HTMLButtonElement).disabled).toBe(false); }); @@ -796,7 +794,6 @@ describe("AgentChatComposer", () => { availableModelIds: ["cursor/auto"], }); - expect((screen.getByLabelText("Open attachment picker") as HTMLButtonElement).disabled).toBe(false); expect((screen.getByLabelText("Upload file from disk") as HTMLButtonElement).disabled).toBe(false); }); @@ -808,7 +805,6 @@ describe("AgentChatComposer", () => { availableModelIds: ["opencode/openai/gpt-5.4"], }); - expect((screen.getByLabelText("Open attachment picker") as HTMLButtonElement).disabled).toBe(false); expect((screen.getByLabelText("Upload file from disk") as HTMLButtonElement).disabled).toBe(false); }); diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx index 011c7d299..152844309 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx @@ -3458,25 +3458,6 @@ export function AgentChatComposer({ {/* Right: attachment, commands, proof, context, send */} <div className="ml-auto flex max-w-full shrink-0 items-center gap-0.5 sm:gap-1"> - <SmartTooltip - content={{ - label: "Attach from project", - description: parallelChatMode - ? attachBlockedReason ?? "Search the project for files to send to every parallel lane." - : "Search the project for files or images to attach to this message.", - shortcut: "@", - }} - > - <button - type="button" - className="inline-flex h-8 min-w-8 max-w-full items-center justify-center rounded-lg px-1.5 font-sans text-[length:calc(var(--chat-font-size)*11/14)] font-medium text-muted-fg/35 transition-colors hover:bg-violet-500/[0.06] hover:text-violet-300/60" - disabled={!canAttach} - onClick={() => canAttach && setAttachmentPickerOpen((o) => !o)} - aria-label="Open attachment picker" - > - @ - </button> - </SmartTooltip> <SmartTooltip content={{ label: "Upload file", @@ -3528,32 +3509,6 @@ export function AgentChatComposer({ ) : null} </button> </SmartTooltip> - <SmartTooltip content={{ label: "Commands", description: "Open the slash-command picker for this chat.", shortcut: "/" }}> - <button - type="button" - className="inline-flex h-8 min-w-8 items-center justify-center rounded-lg px-1.5 font-sans text-[length:calc(var(--chat-font-size)*11/14)] font-medium text-muted-fg/35 transition-colors hover:bg-violet-500/[0.06] hover:text-violet-300/60" - disabled={composerInputLocked} - onClick={() => { - if (composerInputLocked) return; - const richEl = richEditorRef.current; - const el = textareaRef.current; - const currentDraft = useRichComposer ? serializeRichEditor() : el?.value ?? ""; - if (!currentDraft.length) onDraftChange("/"); - if (useRichComposer && !currentDraft.length) setRichEditorText("/"); - setCommandMenuTrigger({ - type: "slash", - query: currentDraft.startsWith("/") ? currentDraft.slice(1).match(/^[^\s/]*/)?.[0] ?? "" : "", - cursorIndex: 0, - }); - const anchor = getCommandMenuAnchor(useRichComposer ? richEl : el); - if (anchor) setCommandMenuAnchor(anchor); - (useRichComposer ? richEl : el)?.focus(); - }} - aria-label="Open command picker" - > - / - </button> - </SmartTooltip> {showParallelChatToggle && !parallelChatMode ? ( <SmartTooltip diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.submit.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.submit.test.tsx index 4f8c1c167..dd4feb4a9 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.submit.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.submit.test.tsx @@ -914,7 +914,8 @@ describe("AgentChatPane submit recovery", () => { }); }); - fireEvent.click(await screen.findByLabelText("Open command picker")); + const textbox = await screen.findByRole("textbox"); + fireEvent.change(textbox, { target: { value: "/", selectionStart: 1 } }); expect(await screen.findByText("/agents")).toBeTruthy(); }); diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index 52254944e..d7c96d9bd 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useNavigate } from "react-router-dom"; import { AnimatePresence, motion } from "motion/react"; -import { Cube, Desktop, DeviceMobile, Lightning, Plus, Terminal, TreeStructure, X } from "@phosphor-icons/react"; +import { Cube, Desktop, DeviceMobile, ArrowBendUpRight, Lightning, Plus, Terminal, TreeStructure, X } from "@phosphor-icons/react"; import { inferAttachmentType, PARALLEL_CHAT_MAX_ATTACHMENTS, @@ -99,6 +99,7 @@ import { RewindFilesConfirmDialog, type RewindFilesConfirmDialogState } from "./ import { buildRewindPreviewFiles, deriveRewindDiffSummaries } from "./rewindFilesPreview"; import { ChatCursorCloudPanel, type ChatCursorCloudPanelHandle } from "./ChatCursorCloudPanel"; import { CursorCloudInlineLaunch, type CursorCloudInlineLaunchHandle } from "./CursorCloudInlineLaunch"; +import { QuickRunMenu } from "../run/QuickRunMenu"; import { ChatGitToolbar } from "./ChatGitToolbar"; import { ChatTerminalDrawer, ChatTerminalToggle } from "./ChatTerminalDrawer"; import { deriveChatSubagentSnapshots, deriveTodoItems, deriveTurnDiffSummaries } from "./chatExecutionSummary"; @@ -153,6 +154,11 @@ const SUBAGENT_AUTOOPEN_FIRED_KEY_PREFIX = "ade.chat.subagentAutoOpenFired"; const SUBAGENT_AUTOOPEN_FIRED_TTL_MS = 7 * 24 * 60 * 60 * 1000; export const DEFAULT_PARALLEL_ATTACHMENT_REQUEST = "Please review the attached files."; +const CHAT_TOOLBAR_ACTION_BASE = + "relative inline-flex h-7 shrink-0 items-center gap-1.5 rounded-md border px-2.5 font-sans text-[10px] font-medium transition-colors"; +const CHAT_TOOLBAR_ACTION_IDLE = + "border-white/[0.06] bg-white/[0.02] text-muted-fg/40 hover:border-white/[0.10] hover:text-fg/65"; + const AUTO_CREATE_LANE_OPTION_ID = "__ade_auto_create_lane__"; const AUTO_CREATE_LANE_OPTION = { id: AUTO_CREATE_LANE_OPTION_ID, @@ -6219,7 +6225,7 @@ export function AgentChatPane({ {showWorkspaceChrome && laneId ? <ChatGitToolbar laneId={laneId} /> : null} - <div className="ml-auto flex shrink-0 items-center gap-1.5"> + <div className="ml-auto flex shrink-0 items-center gap-4"> {laneToolsVisible && iosSimulatorAvailable ? ( <SmartTooltip content={{ @@ -6233,10 +6239,10 @@ export function AgentChatPane({ <button type="button" className={cn( - "relative inline-flex h-7 w-7 items-center justify-center rounded-md border transition-colors", + CHAT_TOOLBAR_ACTION_BASE, iosSimulatorOpen ? "border-cyan-300/22 bg-cyan-500/10 text-cyan-100/80" - : "border-white/[0.06] bg-white/[0.02] text-muted-fg/40 hover:border-white/[0.10] hover:text-fg/65", + : CHAT_TOOLBAR_ACTION_IDLE, )} onClick={() => { setIosSimulatorOpen((current) => { @@ -6253,6 +6259,7 @@ export function AgentChatPane({ aria-label={iosSimulatorOpen ? "Close iOS simulator drawer" : "Open iOS simulator drawer"} aria-pressed={iosSimulatorOpen} > + <span>Simulator</span> <DeviceMobile size={13} weight={iosSimulatorOpen ? "fill" : "regular"} /> {iosElementContextItems.length ? ( <span className="absolute -right-1 -top-1 inline-flex h-[13px] min-w-[13px] items-center justify-center rounded-full border border-black/30 bg-cyan-500/80 px-0.5 font-mono text-[8px] font-bold text-black"> @@ -6275,10 +6282,10 @@ export function AgentChatPane({ <button type="button" className={cn( - "relative inline-flex h-7 w-7 items-center justify-center rounded-md border transition-colors", + CHAT_TOOLBAR_ACTION_BASE, appControlOpen ? "border-sky-300/22 bg-sky-500/10 text-sky-100/80" - : "border-white/[0.06] bg-white/[0.02] text-muted-fg/40 hover:border-white/[0.10] hover:text-fg/65", + : CHAT_TOOLBAR_ACTION_IDLE, )} onClick={() => { setAppControlOpen((current) => { @@ -6294,6 +6301,7 @@ export function AgentChatPane({ aria-label={appControlOpen ? "Close App Control drawer" : "Open App Control drawer"} aria-pressed={appControlOpen} > + <span>Desktop</span> <Desktop size={13} weight={appControlOpen ? "fill" : "regular"} /> {appControlContextItems.length ? ( <span className="absolute -right-1 -top-1 inline-flex h-[13px] min-w-[13px] items-center justify-center rounded-full border border-black/30 bg-sky-500/80 px-0.5 font-mono text-[8px] font-bold text-black"> @@ -6303,6 +6311,15 @@ export function AgentChatPane({ </button> </SmartTooltip> ) : null} + {showWorkspaceChrome && laneId ? ( + <QuickRunMenu + laneId={laneId} + compact + label="Run" + align="end" + triggerStyle={{ height: 28, padding: "0 10px" }} + /> + ) : null} {showWorkspaceChrome && laneId ? ( <SmartTooltip content={{ @@ -6316,10 +6333,10 @@ export function AgentChatPane({ <button type="button" className={cn( - "relative inline-flex h-7 w-7 items-center justify-center rounded-md border transition-colors", + CHAT_TOOLBAR_ACTION_BASE, proofDrawerOpen ? "border-emerald-400/22 bg-emerald-500/10 text-emerald-100/80" - : "border-white/[0.06] bg-white/[0.02] text-muted-fg/40 hover:border-white/[0.10] hover:text-fg/65", + : CHAT_TOOLBAR_ACTION_IDLE, )} onClick={() => { setProofDrawerOpen((current) => { @@ -6337,6 +6354,7 @@ export function AgentChatPane({ aria-label={proofDrawerOpen ? "Close proof drawer" : "Open proof drawer"} aria-pressed={proofDrawerOpen} > + <span>Proof</span> <Cube size={13} weight={proofDrawerOpen ? "fill" : "regular"} /> {proofArtifactCount > 0 ? ( <span className="absolute -right-1 -top-1 inline-flex h-[13px] min-w-[13px] items-center justify-center rounded-full border border-black/30 bg-emerald-500/80 px-0.5 font-mono text-[8px] font-bold text-black"> @@ -6357,10 +6375,10 @@ export function AgentChatPane({ <button type="button" className={cn( - "relative inline-flex h-7 w-7 items-center justify-center rounded-md border transition-colors", + CHAT_TOOLBAR_ACTION_BASE, subagentPaneOpen ? "border-amber-300/22 bg-amber-500/10 text-amber-100/80" - : "border-white/[0.06] bg-white/[0.02] text-muted-fg/40 hover:border-white/[0.10] hover:text-fg/65", + : CHAT_TOOLBAR_ACTION_IDLE, )} onClick={() => { setSubagentPaneOpen((current) => { @@ -6378,6 +6396,7 @@ export function AgentChatPane({ aria-label={subagentPaneOpen ? "Close subagents panel" : "Open subagents panel"} aria-pressed={subagentPaneOpen} > + <span>Agents</span> <TreeStructure size={13} weight={subagentPaneOpen ? "fill" : "regular"} /> <span className="absolute -right-1 -top-1 inline-flex h-[13px] min-w-[13px] items-center justify-center rounded-full border border-black/30 bg-amber-400/85 px-0.5 font-mono text-[8px] font-bold text-black"> {selectedSubagentSnapshots.length} @@ -6447,7 +6466,10 @@ export function AgentChatPane({ <div ref={handoffRef} className="relative"> <button type="button" - className="inline-flex items-center rounded-lg border border-violet-400/[0.12] bg-violet-500/[0.04] px-2.5 py-1 font-sans text-[10px] font-medium text-violet-200/60 transition-colors hover:border-violet-400/20 hover:bg-violet-500/[0.08] hover:text-violet-200/80 disabled:cursor-not-allowed disabled:opacity-40" + className={cn( + CHAT_TOOLBAR_ACTION_BASE, + "border-violet-400/[0.12] bg-violet-500/[0.04] text-violet-200/60 hover:border-violet-400/20 hover:bg-violet-500/[0.08] hover:text-violet-200/80 disabled:cursor-not-allowed disabled:opacity-40", + )} onClick={() => { setError(null); setHandoffOpen((current) => !current); @@ -6455,7 +6477,8 @@ export function AgentChatPane({ disabled={handoffBlocked} title={handoffButtonTitle} > - Handoff + <span>Handoff</span> + <ArrowBendUpRight size={13} weight="regular" /> </button> {handoffOpen ? ( <div data-chat-handoff-menu="true" className="absolute right-0 top-full z-[100] mt-2 w-[min(26rem,calc(100vw-2rem))] rounded-[14px] border border-violet-400/[0.10] bg-[#13101a] p-4 shadow-[0_20px_50px_-12px_rgba(0,0,0,0.55)]"> diff --git a/apps/desktop/src/renderer/components/chat/ChatComposerShell.tsx b/apps/desktop/src/renderer/components/chat/ChatComposerShell.tsx index cac243365..1549c3ec3 100644 --- a/apps/desktop/src/renderer/components/chat/ChatComposerShell.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatComposerShell.tsx @@ -44,7 +44,7 @@ export function ChatComposerShell({ {pickerLayer} {children} </div> - {footer ? <div className="relative min-w-0 max-w-full border-t border-[color:var(--chat-panel-border)]">{footer}</div> : null} + {footer ? <div className="relative min-w-0 max-w-full">{footer}</div> : null} </div> ); } diff --git a/apps/desktop/src/renderer/components/chat/ChatGitToolbar.tsx b/apps/desktop/src/renderer/components/chat/ChatGitToolbar.tsx index b44ae7445..afdb5629b 100644 --- a/apps/desktop/src/renderer/components/chat/ChatGitToolbar.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatGitToolbar.tsx @@ -16,7 +16,6 @@ import { } from "@phosphor-icons/react"; import { AnimatePresence, motion } from "motion/react"; import { cn } from "../ui/cn"; -import { QuickRunMenu } from "../run/QuickRunMenu"; import type { DiffChanges, PrSummary, PrCheck } from "../../../shared/types"; import { beginLaneGitActionRuntime, @@ -24,7 +23,7 @@ import { scheduleLaneGitActionRuntimeClear, useLaneGitActionRuntimeState, } from "../lanes/LaneGitActionsPane"; -import { LaneAccentDot } from "../lanes/LaneAccentDot"; +import { getLaneAccent } from "../lanes/laneColorPalette"; import { useAppStore } from "../../state/appStore"; import { formatPrBadgeLabel } from "../prs/shared/prFormatters"; @@ -105,6 +104,24 @@ function summarizeChecks(checks: PrCheck[]): { passed: number; failed: number; r return { passed, failed, running, total: checks.length }; } +function LaneLogoMark({ color, size = 16 }: { color: string; size?: number }) { + return ( + <span + aria-hidden + className="inline-flex shrink-0 items-center justify-center rounded-[5px]" + style={{ + width: size, + height: size, + background: `color-mix(in srgb, ${color} 30%, transparent)`, + border: `1px solid color-mix(in srgb, ${color} 48%, transparent)`, + color, + }} + > + <GitBranch size={10} weight="bold" /> + </span> + ); +} + // --------------------------------------------------------------------------- // Component // --------------------------------------------------------------------------- @@ -114,8 +131,17 @@ export const ChatGitToolbar = React.memo(function ChatGitToolbar({ }: ChatGitToolbarProps) { const navigate = useNavigate(); const runtime = useLaneGitActionRuntimeState(laneId); - const laneColor = useAppStore((s) => s.lanes.find((l) => l.id === laneId)?.color ?? null); - const laneName = useAppStore((s) => s.lanes.find((l) => l.id === laneId)?.name ?? null); + const lane = useAppStore((s) => s.lanes.find((l) => l.id === laneId) ?? null); + const laneName = lane?.name ?? null; + const laneAccent = getLaneAccent(lane, 0); + + const openLaneInLanesTab = useCallback(() => { + const params = new URLSearchParams({ + laneId, + focus: "single", + }); + navigate(`/lanes?${params.toString()}`); + }, [laneId, navigate]); const [dirtyCount, setDirtyCount] = useState(0); const [diffStats, setDiffStats] = useState<{ adds: number; dels: number; files: number } | null>(null); @@ -500,21 +526,16 @@ export const ChatGitToolbar = React.memo(function ChatGitToolbar({ <div className="flex items-center gap-1.5"> {/* Lane name (navigates to lane detail) */} {laneId ? ( - <> - <button - type="button" - onClick={() => navigate(`/lanes/${laneId}`)} - className="inline-flex items-center gap-1.5 rounded-lg border border-violet-400/10 bg-violet-500/[0.04] px-2.5 py-1 font-mono text-[10px] text-violet-200/60 cursor-pointer transition-colors hover:border-violet-400/20 hover:bg-violet-500/[0.08]" - > - {laneColor ? ( - <LaneAccentDot lane={{ color: laneColor }} size={7} className="shrink-0" /> - ) : ( - <GitBranch size={10} weight="bold" className="shrink-0 text-violet-400/50" /> - )} - <span className="max-w-[140px] truncate">{laneName ?? laneId}</span> - </button> - <QuickRunMenu laneId={laneId} compact label="Run" triggerStyle={{ height: 22, padding: "0 8px" }} /> - </> + <button + type="button" + onClick={openLaneInLanesTab} + title={`Open ${laneName ?? "lane"} in Lanes`} + aria-label={`Open ${laneName ?? "lane"} in Lanes tab`} + className="inline-flex h-7 items-center gap-1.5 rounded-lg border border-violet-400/10 bg-violet-500/[0.04] px-2.5 font-mono text-[10px] text-violet-200/60 cursor-pointer transition-colors hover:border-violet-400/20 hover:bg-violet-500/[0.08]" + > + <LaneLogoMark color={laneAccent} /> + <span className="max-w-[140px] truncate">{laneName ?? laneId}</span> + </button> ) : null} {/* Dirty count badge */} diff --git a/apps/desktop/src/renderer/components/chat/ChatSubagentsPanel.tsx b/apps/desktop/src/renderer/components/chat/ChatSubagentsPanel.tsx index 28f910238..122071397 100644 --- a/apps/desktop/src/renderer/components/chat/ChatSubagentsPanel.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatSubagentsPanel.tsx @@ -49,7 +49,7 @@ function runtimeText(snapshot: ChatSubagentSnapshot): string | null { * does NOT collide with the "never started" empty circle reading. */ -const GLYPH_SIZE = 14; +const GLYPH_SIZE = 16; type GlyphCategory = "subagent" | "background"; @@ -118,22 +118,31 @@ function SectionHeader({ label, hint, tone = "neutral", + emphasized = false, }: { label: string; hint?: string; tone?: SectionTone; + emphasized?: boolean; }) { return ( - <div className="flex items-baseline justify-between px-4 pb-2 pt-3.5"> - <span className="flex items-center gap-2 font-sans text-[11.5px] font-medium tracking-[0.005em] text-fg/65"> + <div className={cn("flex items-baseline justify-between px-4", emphasized ? "pb-3 pt-4" : "pb-2 pt-3.5")}> + <span + className={cn( + "flex items-center gap-2 font-sans tracking-[0.005em]", + emphasized + ? "text-[15px] font-semibold text-fg/85" + : "text-[11.5px] font-medium text-fg/65", + )} + > <span aria-hidden - className={cn("inline-block h-1 w-1 rounded-full", SECTION_DOT_CLASS[tone])} + className={cn("inline-block rounded-full", emphasized ? "h-1.5 w-1.5" : "h-1 w-1", SECTION_DOT_CLASS[tone])} /> {label} </span> {hint ? ( - <span className="font-sans text-[11px] tabular-nums text-fg/40"> + <span className={cn("font-sans tabular-nums text-fg/45", emphasized ? "text-[13px] font-medium" : "text-[11px]")}> {hint} </span> ) : null} @@ -206,25 +215,23 @@ function SubagentRow({ title={snapshot.description} data-selected={selected || undefined} className={cn( - "group relative flex w-full items-center gap-3 px-4 py-1.5 text-left", + "group relative flex w-full items-center gap-3.5 rounded-lg border px-3.5 py-3 text-left", "transition-colors duration-150", - "hover:bg-white/[0.025]", - "data-[selected=true]:bg-white/[0.04]", - // Running rows get a soft left rail by default — helps the eye pick out - // active work without leaning on the spinner alone. Selected rows - // upgrade to the saturated rail. + "border-white/[0.06] bg-white/[0.02]", + "hover:border-white/[0.10] hover:bg-white/[0.035]", + selected && "border-[color:color-mix(in_srgb,var(--color-accent)_28%,transparent)] bg-white/[0.045]", isRunning && !selected - && cn("before:absolute before:left-0 before:top-1/2 before:h-3 before:w-px before:-translate-y-1/2", runningRailColor), + && cn("before:absolute before:left-0 before:top-3 before:bottom-3 before:w-0.5 before:rounded-full", runningRailColor), selected - && "before:absolute before:left-0 before:top-1/2 before:h-3 before:w-px before:-translate-y-1/2 before:bg-[color:var(--color-accent,#A78BFA)]/85", + && "before:absolute before:left-0 before:top-3 before:bottom-3 before:w-0.5 before:rounded-full before:bg-[color:var(--color-accent,#A78BFA)]/85", )} > - <span className="flex h-3.5 w-3.5 shrink-0 items-center justify-center"> + <span className="flex h-4 w-4 shrink-0 items-center justify-center"> <StatusGlyph status={snapshot.status} category={category} /> </span> <span className={cn( - "min-w-0 flex-1 truncate font-sans text-[12.5px] leading-5", + "min-w-0 flex-1 truncate font-sans text-[14px] leading-6", isRunning && runningLabelTint, isFailed && "text-rose-200/90", isCompleted && "text-fg/55", @@ -234,18 +241,18 @@ function SubagentRow({ > {name} {isStopped ? ( - <span className="ml-1.5 font-sans text-[10.5px] tracking-[0.01em] text-amber-300/55"> + <span className="ml-2 font-sans text-[12px] tracking-[0.01em] text-amber-300/55"> halted </span> ) : null} {snapshot.workflowName ? ( - <span className="ml-1.5 font-sans text-[10.5px] tracking-[0.01em] text-amber-300/65"> + <span className="ml-2 font-sans text-[12px] tracking-[0.01em] text-amber-300/65"> {snapshot.workflowName} </span> ) : null} </span> {runtime ? ( - <span className="shrink-0 truncate font-sans text-[10.5px] tabular-nums text-fg/40 group-hover:text-fg/55"> + <span className="shrink-0 truncate font-sans text-[12px] tabular-nums text-fg/45 group-hover:text-fg/60"> {runtime} </span> ) : null} @@ -382,7 +389,7 @@ export function ChatSubagentsPanel({ {/* ── Subagents ────────────────────────────────────────────── */} <section className={cn( - "pb-2", + "pb-3", (plan || background.length) && "border-t border-white/[0.04]", )} > @@ -390,9 +397,10 @@ export function ChatSubagentsPanel({ label="Subagents" hint={foreground.length ? `${foreground.length}` : undefined} tone="subagent" + emphasized /> {foreground.length ? ( - <div className="pb-1"> + <div className="space-y-2 px-3 pb-2"> {foreground.map((snap) => ( <SubagentRow key={snap.taskId} @@ -404,7 +412,7 @@ export function ChatSubagentsPanel({ ))} </div> ) : ( - <p className="px-4 pb-2 text-[11.5px] text-fg/40"> + <p className="px-4 pb-3 text-[13px] text-fg/40"> None active. </p> )} @@ -412,9 +420,9 @@ export function ChatSubagentsPanel({ {/* ── Background tasks ─────────────────────────────────────── */} {background.length ? ( - <section className="border-t border-white/[0.04] pb-2"> - <SectionHeader label="Background" hint={`${background.length}`} tone="background" /> - <div className="pb-1"> + <section className="border-t border-white/[0.04] pb-3"> + <SectionHeader label="Background" hint={`${background.length}`} tone="background" emphasized /> + <div className="space-y-2 px-3 pb-2"> {background.map((snap) => ( <SubagentRow key={snap.taskId} @@ -459,36 +467,10 @@ export function ChatSubagentsPanel({ if (variant === "pane") { return ( <div className={cn( - // Subtle elevation so the panel reads as a real surface against the - // black background instead of dissolving into it. - "flex h-full min-h-0 flex-col font-sans bg-white/[0.012]", + "flex h-full min-h-0 flex-col overflow-y-auto font-sans bg-white/[0.012]", className, )}> - {/* Single-line header: "Work" + dimmed summary clause + close. - The TreeStructure icon moved into the toggle button where it - actually means something. */} - <div className="flex shrink-0 items-baseline gap-3 px-4 pb-2.5 pt-3.5"> - <span className="text-[12.5px] font-medium tracking-[0.005em] text-fg/85"> - Work - </span> - <span className="min-w-0 flex-1 truncate text-[11px] text-fg/45"> - {headerSummary} - </span> - {onClose ? ( - <button - type="button" - className="-mr-1 inline-flex h-6 w-6 items-center justify-center rounded text-fg/35 transition-colors hover:bg-white/[0.04] hover:text-fg/70" - onClick={onClose} - title="Close work panel" - aria-label="Close work panel" - > - <X size={11} weight="bold" /> - </button> - ) : null} - </div> - <div className="min-h-0 flex-1 overflow-y-auto border-t border-white/[0.04]"> - {body} - </div> + {body} </div> ); } diff --git a/apps/desktop/src/renderer/components/chat/ChatTerminalDrawer.tsx b/apps/desktop/src/renderer/components/chat/ChatTerminalDrawer.tsx index 990492485..cc6f3b697 100644 --- a/apps/desktop/src/renderer/components/chat/ChatTerminalDrawer.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatTerminalDrawer.tsx @@ -587,15 +587,15 @@ export const ChatTerminalToggle = memo(function ChatTerminalToggle({ type="button" onClick={onToggle} className={cn( - "inline-flex items-center gap-1.5 rounded-lg border px-2.5 py-1 font-sans text-[10px] font-medium transition-all", + "inline-flex h-7 shrink-0 items-center gap-1.5 rounded-md border px-2.5 font-sans text-[10px] font-medium transition-all", open ? "border-violet-400/20 bg-violet-500/[0.08] text-violet-200/80" : "border-white/[0.08] bg-white/[0.03] text-fg/45 hover:border-white/[0.12] hover:text-fg/65", )} title={open ? "Close terminal" : "Open terminal"} > - <TerminalIcon size={12} weight={open ? "fill" : "regular"} /> <span>Terminal</span> + <TerminalIcon size={13} weight={open ? "fill" : "regular"} /> </button> ); }); diff --git a/apps/desktop/src/renderer/components/chat/ChatWorkLogBlock.tsx b/apps/desktop/src/renderer/components/chat/ChatWorkLogBlock.tsx index 0d1c202fb..7adc879e2 100644 --- a/apps/desktop/src/renderer/components/chat/ChatWorkLogBlock.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatWorkLogBlock.tsx @@ -409,7 +409,7 @@ function ToolCallRow({ {kindSlug} </span> {argText ? ( - <span className="min-w-0 truncate font-mono text-[length:calc(var(--chat-font-size)*11/14)] text-fg/40">{argText}</span> + <span className="min-w-0 truncate font-sans text-[length:calc(var(--chat-font-size)*13/14)] leading-[1.55] text-fg/88">{argText}</span> ) : null} </button> {open && navigationSuggestions.length > 0 && onNavigateSuggestion ? ( @@ -501,7 +501,7 @@ function ToolCallsPanel({ {latestSlug} </span> {latestArg ? ( - <span className="min-w-0 flex-1 truncate font-mono text-[length:calc(var(--chat-font-size)*10/14)] text-fg/38">{latestArg}</span> + <span className="min-w-0 flex-1 truncate font-sans text-[length:calc(var(--chat-font-size)*13/14)] leading-[1.55] text-fg/88">{latestArg}</span> ) : null} </span> )} diff --git a/apps/desktop/src/renderer/components/chat/chatSurfaceTheme.ts b/apps/desktop/src/renderer/components/chat/chatSurfaceTheme.ts index b956ea1ef..69daa2c84 100644 --- a/apps/desktop/src/renderer/components/chat/chatSurfaceTheme.ts +++ b/apps/desktop/src/renderer/components/chat/chatSurfaceTheme.ts @@ -109,13 +109,32 @@ function sharedSurfaceTokens(accent: string, m: number): CSSProperties { }; } +function isLightChatAccent(accent: string): boolean { + const normalized = normalizeHex(accent); + const r = hexChannel(normalized.slice(1, 3)); + const g = hexChannel(normalized.slice(3, 5)); + const b = hexChannel(normalized.slice(5, 7)); + return (0.299 * r + 0.587 * g + 0.114 * b) / 255 > 0.72; +} + +const CHAT_USER_BUBBLE_GRADIENT_DEFAULT = + "linear-gradient(135deg, color-mix(in srgb, var(--chat-accent) 76%, #ffffff 6%) 0%, color-mix(in srgb, var(--chat-accent) 60%, #7c3aed 40%) 50%, color-mix(in srgb, var(--chat-accent) 58%, #4c1d95 42%) 100%)"; + +/** Codex/OpenAI and other very light accents — pull the highlight stop down slightly. */ +const CHAT_USER_BUBBLE_GRADIENT_LIGHT = + "linear-gradient(135deg, color-mix(in srgb, var(--chat-accent) 74%, #78716c 10%) 0%, color-mix(in srgb, var(--chat-accent) 58%, #7c3aed 42%) 50%, color-mix(in srgb, var(--chat-accent) 56%, #4c1d95 44%) 100%)"; + /** Provider-colored chrome (default) — former “standard” lane accent strength. */ function coloredChatSurfaceVars(mode: ChatSurfaceMode, accentColor?: string | null): CSSProperties { const accent = resolveChatSurfaceAccent(mode, accentColor); const m = 1; + const light = isLightChatAccent(accent); return { - ["--chat-user-border-accent-mix" as string]: "28%", - ["--chat-user-shadow-accent-mix" as string]: "34%", + ["--chat-user-border-accent-mix" as string]: light ? "22%" : "28%", + ["--chat-user-shadow-accent-mix" as string]: light ? "28%" : "34%", + ["--chat-user-bubble-gradient" as string]: light + ? CHAT_USER_BUBBLE_GRADIENT_LIGHT + : CHAT_USER_BUBBLE_GRADIENT_DEFAULT, ...sharedSurfaceTokens(accent, m), }; } @@ -127,6 +146,7 @@ function neutralChatSurfaceVars(): CSSProperties { return { ["--chat-user-border-accent-mix" as string]: "20%", ["--chat-user-shadow-accent-mix" as string]: "24%", + ["--chat-user-bubble-gradient" as string]: CHAT_USER_BUBBLE_GRADIENT_DEFAULT, ...sharedSurfaceTokens(accent, m), }; } diff --git a/apps/desktop/src/renderer/components/chat/codex/CodexOpenInCliButton.tsx b/apps/desktop/src/renderer/components/chat/codex/CodexOpenInCliButton.tsx index 45f6fd51e..5287c3654 100644 --- a/apps/desktop/src/renderer/components/chat/codex/CodexOpenInCliButton.tsx +++ b/apps/desktop/src/renderer/components/chat/codex/CodexOpenInCliButton.tsx @@ -114,16 +114,15 @@ export function CodexOpenInCliButton({ sessionId, onUseAdeTerminal }: CodexOpenI aria-haspopup="menu" aria-expanded={open} className={cn( - "inline-flex items-center gap-1.5 rounded-lg border px-2.5 py-1 font-sans text-[10px] font-medium transition-all disabled:cursor-not-allowed disabled:opacity-50", + "inline-flex h-7 shrink-0 items-center gap-1.5 rounded-md border px-2.5 font-sans text-[10px] font-medium transition-all disabled:cursor-not-allowed disabled:opacity-50", open ? "border-violet-400/20 bg-violet-500/[0.08] text-violet-200/80" : "border-white/[0.08] bg-white/[0.03] text-fg/45 hover:border-white/[0.12] hover:text-fg/65", )} title="Open this Codex thread in the Codex CLI" > - <Terminal size={12} weight={open ? "fill" : "regular"} /> - <span>Open in Codex CLI</span> - <ArrowSquareOut size={9} weight="bold" className="opacity-60" /> + <span>Codex</span> + <Terminal size={13} weight={open ? "fill" : "regular"} /> </button> {open ? ( diff --git a/apps/desktop/src/renderer/components/files/FilesPage.tsx b/apps/desktop/src/renderer/components/files/FilesPage.tsx index 3259833b3..749eb3ec3 100644 --- a/apps/desktop/src/renderer/components/files/FilesPage.tsx +++ b/apps/desktop/src/renderer/components/files/FilesPage.tsx @@ -2275,7 +2275,6 @@ export function FilesPage({ </select> {embedded ? null : ( <> - <HelpChip termId="worktree" side="bottom" /> <SmartTooltip content={ activeWorkspace?.laneId diff --git a/apps/desktop/src/renderer/components/lanes/LaneWorkPane.tsx b/apps/desktop/src/renderer/components/lanes/LaneWorkPane.tsx index 5558cbe5a..7f6e705bb 100644 --- a/apps/desktop/src/renderer/components/lanes/LaneWorkPane.tsx +++ b/apps/desktop/src/renderer/components/lanes/LaneWorkPane.tsx @@ -1,44 +1,10 @@ import { useEffect, useMemo } from "react"; -import { ChatCircleText, Command } from "@phosphor-icons/react"; import type { LaneLinearIssue } from "../../../shared/types"; -import type { WorkDraftKind } from "../../state/appStore"; import { EmptyState } from "../ui/EmptyState"; -import { SmartTooltip } from "../ui/SmartTooltip"; -import { COLORS, SANS_FONT, SPACING } from "./laneDesignTokens"; +import { SANS_FONT } from "./laneDesignTokens"; import { WorkViewArea } from "../terminals/WorkViewArea"; import { dispatchWorkSurfaceRevealed } from "../terminals/workSurfaceVisibility"; import { useLaneWorkSessions } from "./useLaneWorkSessions"; -import { HelpChip } from "../onboarding/HelpChip"; -import { docs } from "../../onboarding/docsLinks"; - -const ENTRY_OPTIONS: Array<{ - kind: WorkDraftKind; - label: string; - icon: typeof ChatCircleText; - color: string; - description: string; - docUrl: string; - dataTour: string; -}> = [ - { - kind: "chat", - label: "New Chat", - icon: ChatCircleText, - color: COLORS.entryChat, - description: "Start a new AI chat session in this lane's context.", - docUrl: docs.chatOverview, - dataTour: "lanes.workNewChat", - }, - { - kind: "cli", - label: "CLI Tool", - icon: Command, - color: COLORS.entryCli, - description: "Open the CLI tool for running commands with AI assistance.", - docUrl: docs.terminals, - dataTour: "lanes.workCliTool", - }, -]; export function LaneWorkPane({ laneId, @@ -86,61 +52,6 @@ export function LaneWorkPane({ return ( <div className="flex h-full min-h-0 flex-col" style={{ background: "var(--color-bg)", fontFamily: SANS_FONT }}> - <div className="shrink-0 border-b border-white/[0.04] bg-white/[0.02] px-3 py-2 backdrop-blur-xl" data-tour="work.toolbar"> - <div className="flex flex-wrap items-center justify-between gap-2"> - <div className="flex flex-wrap items-center gap-1" data-tour="work.entryOptions"> - {ENTRY_OPTIONS.map((entry) => { - const Icon = entry.icon; - const active = work.activeItemId == null && work.draftKind === entry.kind; - return ( - <SmartTooltip - key={entry.kind} - content={{ label: entry.label, description: entry.description, docUrl: entry.docUrl }} - > - <button - type="button" - data-tour={entry.dataTour} - onClick={() => work.showDraftKind(entry.kind)} - style={{ - display: "inline-flex", - alignItems: "center", - gap: SPACING.xs, - padding: "5px 10px", - border: active ? `1px solid ${entry.color}20` : "1px solid transparent", - borderRadius: 8, - background: active ? `${entry.color}0C` : "transparent", - color: active ? "var(--color-fg)" : "var(--color-muted-fg)", - fontFamily: SANS_FONT, - fontSize: 11, - fontWeight: 500, - letterSpacing: "-0.01em", - cursor: "pointer", - transition: "all 120ms", - }} - > - <Icon size={12} weight="regular" style={{ color: entry.color, opacity: active ? 1 : 0.7 }} /> - {entry.label} - </button> - </SmartTooltip> - ); - })} - </div> - <div className="flex items-center gap-2 text-[11px] text-muted-fg"> - {work.lane ? ( - <span className="truncate" data-tour="work.laneName"> - {work.lane.name} - <HelpChip termId="lane" side="bottom" /> - </span> - ) : null} - <span className="rounded-full border border-white/[0.06] bg-white/[0.03] px-2 py-1" data-tour="work.sessionCount"> - {work.visibleSessions.length} open - </span> - <HelpChip termId="worker" side="bottom" /> - {work.loading ? <span className="text-muted-fg/70">Refreshing…</span> : null} - </div> - </div> - </div> - <div className="min-h-0 flex-1" data-tour="work.viewArea"> <WorkViewArea gridLayoutId={work.gridLayoutId} diff --git a/apps/desktop/src/renderer/components/lanes/LanesPage.test.ts b/apps/desktop/src/renderer/components/lanes/LanesPage.test.ts index 48b5388a3..e2d2e03a4 100644 --- a/apps/desktop/src/renderer/components/lanes/LanesPage.test.ts +++ b/apps/desktop/src/renderer/components/lanes/LanesPage.test.ts @@ -16,7 +16,7 @@ import { shouldApplyLaneIdsDeepLink, sortLaneListRows, } from "./lanePageModel"; -import { createVmRuntimeStatusReason, shouldMountGitActionsPane } from "./LanesPage"; +import { buildLaneSplitColumnsKey, createVmRuntimeStatusReason, shouldMountGitActionsPane } from "./LanesPage"; import type { GitHubPrListItem, LaneSummary, @@ -321,6 +321,21 @@ describe("resolveVisibleLaneIds", () => { }); }); +describe("buildLaneSplitColumnsKey", () => { + it("does not depend on the current split lane ids", () => { + const beforeDelete = buildLaneSplitColumnsKey({ + laneTilingLayoutSuffix: ":wf", + gridResetKey: 2, + }); + const afterDelete = buildLaneSplitColumnsKey({ + laneTilingLayoutSuffix: ":wf", + gridResetKey: 2, + }); + + expect(afterDelete).toBe(beforeDelete); + }); +}); + describe("selectLanePrTag", () => { it("surfaces a merged PR when it still matches the lane branch", () => { const mergedPr = makePr({ state: "merged" }); diff --git a/apps/desktop/src/renderer/components/lanes/LanesPage.tsx b/apps/desktop/src/renderer/components/lanes/LanesPage.tsx index 6f37fd69a..d4dd94fda 100644 --- a/apps/desktop/src/renderer/components/lanes/LanesPage.tsx +++ b/apps/desktop/src/renderer/components/lanes/LanesPage.tsx @@ -2,14 +2,14 @@ import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "rea import { useClickOutside } from "../../hooks/useClickOutside"; import { useLocation, useNavigate, useSearchParams } from "react-router-dom"; import { Group, Panel } from "react-resizable-panels"; -import { Check, CaretDown, FileCode, GitBranch, GitPullRequest, House, Stack, Link, ArrowsOutSimple, ArrowsInSimple, PushPin, Plus, MagnifyingGlass, Terminal, X, ArrowSquareOut, Info, ArrowCounterClockwise, UsersThree, CircleNotch } from "@phosphor-icons/react"; +import { Check, CaretDown, FileCode, GitBranch, GitPullRequest, Stack, Link, ArrowsOutSimple, ArrowsInSimple, PushPin, Plus, MagnifyingGlass, Terminal, X, ArrowSquareOut, Info, ArrowCounterClockwise, UsersThree, CircleNotch } from "@phosphor-icons/react"; import { useAppStore, useAppStoreApi, type LaneInspectorTab } from "../../state/appStore"; import { buildIntegrationSourcesByLaneId } from "../../lib/integrationLanes"; import { EmptyState } from "../ui/EmptyState"; import { Button } from "../ui/Button"; import { PaneTilingLayout } from "../ui/PaneTilingLayout"; import { useDockLayout } from "../ui/DockLayoutState"; -import { COLORS, LABEL_STYLE, MONO_FONT, SANS_FONT, conflictDotColor, inlineBadge, outlineButton, primaryButton } from "./laneDesignTokens"; +import { COLORS, LABEL_STYLE, MONO_FONT, SANS_FONT, inlineBadge, outlineButton, primaryButton } from "./laneDesignTokens"; import { ResizeGutter } from "../ui/ResizeGutter"; import { LaneStackPane } from "./LaneStackPane"; import { LaneGitActionsPane } from "./LaneGitActionsPane"; @@ -48,7 +48,6 @@ import { laneMatchesFilter, isMissionLaneHiddenByDefault, isMissionResultLane, - chipLabel, LANES_TILING_TREE, LANES_TILING_WORK_FOCUS_TREE, LANES_TILING_LAYOUT_VERSION, @@ -68,7 +67,6 @@ import { logRendererDebugEvent } from "../../lib/debugLog"; import { linearIssueBranchName, linearIssueLaneName } from "../../../shared/linearIssueBranch"; import type { BranchPullRequest, - ConflictChip, DeleteLaneArgs, GitCommitSummary, GitHubPrListItem, @@ -223,6 +221,13 @@ export function isLaneDeleteProgressActive(progress: LaneDeleteProgress | null | || progress?.overallStatus === "completed_with_warnings"; } +export function buildLaneSplitColumnsKey(args: { + laneTilingLayoutSuffix: string; + gridResetKey: number; +}): string { + return `lanes-split-columns:${args.laneTilingLayoutSuffix}:${args.gridResetKey}`; +} + function createPendingDeleteProgress(laneId: string): LaneDeleteProgress { return { laneId, @@ -390,8 +395,6 @@ export function LanesPage({ active = true }: { active?: boolean } = {}) { const setDeleteProgressByLaneId = useAppStore((s) => s.setLaneDeleteProgressByLaneId); const laneDeleteWarningMessagesRef = useRef<Map<string, string>>(new Map()); const [managedLaneIds, setManagedLaneIds] = useState<string[]>([]); - const [conflictChipsByLane, setConflictChipsByLane] = useState<Record<string, ConflictChip[]>>({}); - const chipTimersRef = useRef<Map<string, number>>(new Map()); const lanePrTagsRequestRef = useRef(0); const laneGithubPrTagsRequestRef = useRef(0); const hasActiveLaneRuntimeRef = useRef(false); @@ -802,37 +805,6 @@ export function LanesPage({ active = true }: { active?: boolean } = {}) { } }, [appStore]); - const pushConflictChips = useCallback((chips: ConflictChip[]) => { - if (chips.length === 0) return; - setConflictChipsByLane((prev) => { - const next: Record<string, ConflictChip[]> = { ...prev }; - for (const chip of chips) { - const laneList = next[chip.laneId] ? [...next[chip.laneId]!] : []; - laneList.unshift(chip); - next[chip.laneId] = laneList.slice(0, 3); - } - return next; - }); - for (const chip of chips) { - const key = `${chip.laneId}:${chip.peerId ?? "base"}:${chip.kind}`; - const existing = chipTimersRef.current.get(key); - if (existing) window.clearTimeout(existing); - const now = Date.now(); - const timer = window.setTimeout(() => { - setConflictChipsByLane((prev) => { - const laneChips = prev[chip.laneId] ?? []; - const filtered = laneChips.filter((entry) => { - return !(entry.kind === chip.kind && entry.peerId === chip.peerId && entry.overlapCount === chip.overlapCount); - }); - if (filtered.length === laneChips.length) return prev; - return { ...prev, [chip.laneId]: filtered }; - }); - chipTimersRef.current.delete(key); - }, Math.max(8_000, 12_000 - (Date.now() - now))); - chipTimersRef.current.set(key, timer); - } - }, []); - const scheduleLaneDeleteRefresh = useCallback(() => { if (laneDeleteRefreshTimerRef.current != null) return; laneDeleteRefreshTimerRef.current = window.setTimeout(() => { @@ -926,30 +898,6 @@ export function LanesPage({ active = true }: { active?: boolean } = {}) { return unsubscribe; }, [active, clearLaneInspectorTab, queueLaneDeleteRefresh, selectLane, setDeleteProgressByLaneId]); - useEffect(() => { - if (!active) return; - const unsubscribe = window.ade.conflicts.onEvent((event) => { - if (event.type !== "prediction-complete") return; - // Only refresh conflict statuses — avoid a full refreshLanes() which fires - // five parallel queries via buildLaneListSnapshots. - void window.ade.conflicts.getBatchAssessment().then((assessment) => { - const conflictByLaneId = new Map( - assessment.lanes.map((entry) => [entry.laneId, entry] as const), - ); - const prev = appStore.getState().laneSnapshots; - const next = prev.map((snapshot) => { - const updated = conflictByLaneId.get(snapshot.lane.id) ?? null; - return updated !== snapshot.conflictStatus - ? { ...snapshot, conflictStatus: updated } - : snapshot; - }); - appStore.setState({ laneSnapshots: next }); - }).catch((err) => { console.error("getBatchAssessment failed:", err); }); - pushConflictChips(event.chips); - }); - return unsubscribe; - }, [active, appStore, pushConflictChips]); - useEffect(() => { if (!active) return; const unsubscribe = window.ade.lanes.onRebaseSuggestionsEvent((event) => { @@ -1107,8 +1055,6 @@ export function LanesPage({ active = true }: { active?: boolean } = {}) { useEffect(() => { const pendingLaneDeleteRefreshIds = pendingLaneDeleteRefreshIdsRef.current; return () => { - for (const timer of chipTimersRef.current.values()) window.clearTimeout(timer); - chipTimersRef.current.clear(); if (laneDeleteRefreshTimerRef.current != null) { window.clearTimeout(laneDeleteRefreshTimerRef.current); laneDeleteRefreshTimerRef.current = null; @@ -3404,8 +3350,8 @@ export function LanesPage({ active = true }: { active?: boolean } = {}) { </button> </SmartTooltip> ) : null} - <span style={{ fontFamily: MONO_FONT, fontSize: 10, fontWeight: 700, letterSpacing: "1px", color: COLORS.textMuted, textTransform: "uppercase", whiteSpace: "nowrap" }}> - {filteredLanes.length}/{sortedLanes.length} LANES + <span style={{ fontFamily: MONO_FONT, fontSize: 10, fontWeight: 700, letterSpacing: "0.5px", color: COLORS.textMuted, whiteSpace: "nowrap" }}> + {sortedLanes.length} lane{sortedLanes.length === 1 ? "" : "s"} </span> </div> @@ -3424,8 +3370,6 @@ export function LanesPage({ active = true }: { active?: boolean } = {}) { const isPinned = pinnedLaneIds.has(lane.id); const closable = isVisible && visibleLaneIds.length > 1 && !isPinned; const laneSnapshot = laneSnapshotByLaneId.get(lane.id) ?? null; - const conflictStatus = laneSnapshot?.conflictStatus ?? null; - const chips = conflictChipsByLane[lane.id] ?? []; const laneRuntime = laneRuntimeById.get(lane.id) ?? { bucket: "none", runningCount: 0, @@ -3554,12 +3498,6 @@ export function LanesPage({ active = true }: { active?: boolean } = {}) { </button> ) : null} </span> - {/* Primary: house icon; non-primary: conflict status dot */} - {isPrimary ? ( - <House size={12} className="shrink-0" style={{ color: COLORS.accent }} /> - ) : ( - <span className="shrink-0" style={{ width: 10, height: 10, borderRadius: "50%", background: conflictDotColor(conflictStatus?.status) }} /> - )} {/* Terminal attention state */} {!isDeleting && (laneRuntime.bucket === "running" || laneRuntime.bucket === "awaiting-input") ? ( <span @@ -3694,14 +3632,6 @@ export function LanesPage({ active = true }: { active?: boolean } = {}) { CONFLICT{autoRebaseStatus.conflictCount > 0 ? ` ${autoRebaseStatus.conflictCount}` : ""} </span> ) : null} - {!isDeleting && chips.slice(0, 1).map((chip, chipIndex) => ( - <span - key={`${chip.kind}:${chip.peerId ?? "base"}:${chipIndex}`} - style={inlineBadge(chip.kind === "high-risk" ? COLORS.danger : COLORS.warning, { fontSize: 9 })} - > - {chipLabel(chip.kind)} - </span> - ))} {/* Pin toggle — appears on hover */} {!isDeleting && !isPrimary ? ( <button @@ -3836,7 +3766,7 @@ export function LanesPage({ active = true }: { active?: boolean } = {}) { /> ) : ( <Group - key={`lanes-split-columns:${visibleLaneIds.join(",")}:${gridResetKey}`} + key={buildLaneSplitColumnsKey({ laneTilingLayoutSuffix, gridResetKey })} id="lanes-split-columns" orientation="horizontal" resizeTargetMinimumSize={RESIZE_TARGET_MINIMUM_SIZE} diff --git a/apps/desktop/src/renderer/components/lanes/laneDesignTokens.ts b/apps/desktop/src/renderer/components/lanes/laneDesignTokens.ts index 0732e6ca0..7b7ced619 100644 --- a/apps/desktop/src/renderer/components/lanes/laneDesignTokens.ts +++ b/apps/desktop/src/renderer/components/lanes/laneDesignTokens.ts @@ -85,7 +85,7 @@ export function inlineBadge(color: string, overrides?: CSSProperties): CSSProper */ export function laneSurfaceTint( color: string | null | undefined, - strength: "soft" | "default" = "default", + strength: "soft" | "default" | "pastel" = "default", alpha?: number, ): { background: string; @@ -102,6 +102,14 @@ export function laneSurfaceTint( }; } const c = String(color).trim(); + if (strength === "pastel") { + return { + background: `color-mix(in srgb, ${c} 8%, rgba(255, 255, 255, 0.035))`, + border: `1px solid color-mix(in srgb, ${c} 14%, rgba(255, 255, 255, 0.05))`, + borderLeftAccent: `2px solid color-mix(in srgb, ${c} 40%, transparent)`, + text: `color-mix(in srgb, ${c} 52%, var(--color-muted-fg))`, + }; + } const p = alpha != null && Number.isFinite(alpha) ? Math.max(0, Math.min(100, Math.round(alpha * 100))) : strength === "soft" ? 10 : 16; @@ -236,18 +244,3 @@ export function formatTimestamp(iso: string): string { return iso; } } - -export function conflictDotColor(status: string | undefined): string { - switch (status) { - case "conflict-active": - return COLORS.danger; - case "conflict-predicted": - return COLORS.warning; - case "behind-base": - return COLORS.warning; - case "merge-ready": - return COLORS.success; - default: - return COLORS.textMuted; - } -} diff --git a/apps/desktop/src/renderer/components/lanes/laneUtils.ts b/apps/desktop/src/renderer/components/lanes/laneUtils.ts index 13e03f1ab..cbe6c4494 100644 --- a/apps/desktop/src/renderer/components/lanes/laneUtils.ts +++ b/apps/desktop/src/renderer/components/lanes/laneUtils.ts @@ -1,6 +1,4 @@ import type { - ConflictChip, - ConflictStatus, LaneSummary } from "../../../shared/types"; import type { PaneSplit } from "../ui/PaneTilingLayout"; @@ -120,20 +118,6 @@ export function laneMatchesFilter(lane: LaneSummary, isPinned: boolean, query: s return tokens.every((token) => matchesLaneFilterToken(lane, isPinned, token)); } -/* ---- Conflict helpers ---- */ - -export function conflictDotClass(status: ConflictStatus["status"] | undefined): string { - if (status === "conflict-active") return "bg-red-600"; - if (status === "conflict-predicted") return "bg-orange-500"; - if (status === "behind-base") return "bg-amber-500"; - if (status === "merge-ready") return "bg-emerald-500"; - return "bg-muted-fg"; -} - -export function chipLabel(kind: ConflictChip["kind"]): string { - return kind === "high-risk" ? "high risk" : "new overlap"; -} - /* ---- Default tiling layouts ---- */ /** Work + Git only — stack graph lives in the lanes header; file/commit diffs render inside Git Actions. */ diff --git a/apps/desktop/src/renderer/components/lanes/useLaneWorkSessions.test.ts b/apps/desktop/src/renderer/components/lanes/useLaneWorkSessions.test.ts index 6bfae403d..380bd5719 100644 --- a/apps/desktop/src/renderer/components/lanes/useLaneWorkSessions.test.ts +++ b/apps/desktop/src/renderer/components/lanes/useLaneWorkSessions.test.ts @@ -76,7 +76,7 @@ vi.mock("../../state/appStore", () => ({ // --------------------------------------------------------------------------- // Import the hook under test (after mocks are declared) // --------------------------------------------------------------------------- -import { useLaneWorkSessions } from "./useLaneWorkSessions"; +import { __clearLaneWorkSessionCacheForTests, useLaneWorkSessions } from "./useLaneWorkSessions"; // --------------------------------------------------------------------------- // window.ade stubs @@ -105,6 +105,7 @@ function installWindowAde() { describe("useLaneWorkSessions — refresh-before-focus ordering", () => { beforeEach(() => { vi.clearAllMocks(); + __clearLaneWorkSessionCacheForTests(); installWindowAde(); // Default: instant resolve for mount-time refresh calls listSessionsCachedMock.mockResolvedValue([]); @@ -114,6 +115,63 @@ describe("useLaneWorkSessions — refresh-before-focus ordering", () => { delete (window as any).ade; }); + it("hydrates cached lane sessions immediately on remount while refreshing in the background", async () => { + const cachedSession = { + id: "session-cached", + laneId: "lane-1", + laneName: "Lane 1", + ptyId: null, + tracked: true, + pinned: false, + toolType: "claude-chat", + title: "Cached chat", + status: "running", + startedAt: "2026-05-01T12:00:00.000Z", + endedAt: null, + exitCode: null, + transcriptPath: "", + headShaStart: null, + headShaEnd: null, + lastOutputPreview: null, + summary: null, + runtimeState: "idle", + resumeCommand: null, + } as any; + const refreshedSession = { + ...cachedSession, + id: "session-refreshed", + title: "Refreshed chat", + }; + + listSessionsCachedMock.mockResolvedValueOnce([cachedSession]); + const first = renderHook(() => useLaneWorkSessions("lane-1")); + await act(async () => { + await new Promise((r) => setTimeout(r, 0)); + }); + expect(first.result.current.sessions.map((session) => session.id)).toEqual(["session-cached"]); + first.unmount(); + + let resolveRefresh: ((value: unknown[]) => void) | null = null; + listSessionsCachedMock.mockImplementationOnce(() => new Promise((resolve) => { + resolveRefresh = resolve; + })); + const second = renderHook(() => useLaneWorkSessions("lane-1")); + await act(async () => { + await new Promise((r) => setTimeout(r, 0)); + }); + + expect(second.result.current.loading).toBe(false); + expect(second.result.current.sessions.map((session) => session.id)).toEqual(["session-cached"]); + + await act(async () => { + expect(resolveRefresh).not.toBeNull(); + resolveRefresh!([refreshedSession]); + await new Promise((r) => setTimeout(r, 0)); + }); + + expect(second.result.current.sessions.map((session) => session.id)).toEqual(["session-refreshed"]); + }); + // ----------------------------------------------------------------------- // launchPtySession: refresh() must complete before focusSession / openSessionTab // ----------------------------------------------------------------------- diff --git a/apps/desktop/src/renderer/components/lanes/useLaneWorkSessions.ts b/apps/desktop/src/renderer/components/lanes/useLaneWorkSessions.ts index ace2a24b3..eef129515 100644 --- a/apps/desktop/src/renderer/components/lanes/useLaneWorkSessions.ts +++ b/apps/desktop/src/renderer/components/lanes/useLaneWorkSessions.ts @@ -34,6 +34,12 @@ const EMPTY_WORK_STATE: WorkProjectViewState = { pinnedSessionIds: [], }; +const laneSessionsCacheByScope = new Map<string, TerminalSessionSummary[]>(); + +export function __clearLaneWorkSessionCacheForTests(): void { + laneSessionsCacheByScope.clear(); +} + type QueuedRefresh = { showLoading: boolean; force: boolean; @@ -81,6 +87,7 @@ export function useLaneWorkSessions(laneId: string | null) { const hasActiveSessionsRef = useRef(false); const hasLoadedOnceRef = useRef(false); const hasFetchedOnceRef = useRef(false); + const scopeKeyRef = useRef(""); const currentLane = useMemo( () => (laneId ? lanes.find((lane) => lane.id === laneId) ?? null : null), @@ -93,6 +100,10 @@ export function useLaneWorkSessions(laneId: string | null) { return `${normalizedProjectRoot}::${laneId}`; }, [projectRoot, laneId]); + useEffect(() => { + scopeKeyRef.current = scopeKey; + }, [scopeKey]); + const hasStoredState = scopeKey.length > 0 && scopeKey in laneWorkViewByScope; const laneViewState = scopeKey ? laneWorkViewByScope[scopeKey] ?? EMPTY_WORK_STATE @@ -140,11 +151,17 @@ export function useLaneWorkSessions(laneId: string | null) { refreshInFlightRef.current = true; if (showLoading) setLoading(true); try { + const requestedScopeKey = scopeKeyRef.current; const rows = await listSessionsCached( { laneId, limit: 200 }, { force: Boolean(options.force), projectRoot }, ); - setSessions(rows.filter((session) => !isRunOwnedSession(session))); + if (scopeKeyRef.current !== requestedScopeKey) return; + const nextSessions = rows.filter((session) => !isRunOwnedSession(session)); + setSessions(nextSessions); + if (requestedScopeKey) { + laneSessionsCacheByScope.set(requestedScopeKey, nextSessions); + } hasLoadedOnceRef.current = true; hasFetchedOnceRef.current = true; } catch (err) { @@ -186,17 +203,21 @@ export function useLaneWorkSessions(laneId: string | null) { next.sort((left, right) => ( new Date(right.startedAt).getTime() - new Date(left.startedAt).getTime() )); + if (scopeKey) { + laneSessionsCacheByScope.set(scopeKey, next); + } return next; }); - }, [currentLane?.name, laneId, lanes]); + }, [currentLane?.name, laneId, lanes, scopeKey]); useEffect(() => { - setSessions([]); - hasLoadedOnceRef.current = false; + const cachedSessions = scopeKey ? laneSessionsCacheByScope.get(scopeKey) ?? null : null; + setSessions(cachedSessions ?? []); + hasLoadedOnceRef.current = Boolean(cachedSessions); hasFetchedOnceRef.current = false; if (!laneId) return; - void refresh({ showLoading: true, force: true }); - }, [laneId, refresh]); + void refresh({ showLoading: !cachedSessions, force: !cachedSessions }); + }, [laneId, refresh, scopeKey]); useEffect(() => { return () => { diff --git a/apps/desktop/src/renderer/components/onboarding/HelpMenu.tsx b/apps/desktop/src/renderer/components/onboarding/HelpMenu.tsx index 6afdeddb4..e62a29f15 100644 --- a/apps/desktop/src/renderer/components/onboarding/HelpMenu.tsx +++ b/apps/desktop/src/renderer/components/onboarding/HelpMenu.tsx @@ -127,7 +127,7 @@ export function HelpMenu() { aria-expanded={open} title="Help · tours, glossary, and preferences" className={cn( - "ade-shell-control inline-flex h-[20px] w-[20px] items-center justify-center", + "ade-shell-control inline-flex h-[24px] w-[24px] items-center justify-center", "transition-[background-color,color,border-color,box-shadow] duration-150" )} onClick={() => (open ? close() : openAt())} @@ -136,7 +136,7 @@ export function HelpMenu() { color: open ? "var(--color-accent)" : undefined, } as React.CSSProperties} > - <Question size={12} weight={open ? "fill" : "regular"} /> + <Question size={16} weight={open ? "fill" : "regular"} /> </button> {open && position diff --git a/apps/desktop/src/renderer/components/review/ReviewPage.test.tsx b/apps/desktop/src/renderer/components/review/ReviewPage.test.tsx index 21dc7db97..83b018e8d 100644 --- a/apps/desktop/src/renderer/components/review/ReviewPage.test.tsx +++ b/apps/desktop/src/renderer/components/review/ReviewPage.test.tsx @@ -2,7 +2,7 @@ import React from "react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom"; import { ReviewPage } from "./ReviewPage"; import { useAppStore } from "../../state/appStore"; @@ -80,7 +80,6 @@ describe("ReviewPage", () => { dirtyOnly: false, modelId: "openai/gpt-5.5", reasoningEffort: "medium", - budgets: { maxFiles: 25, maxDiffChars: 120000, maxPromptChars: 60000, maxFindings: 8, maxFindingsPerPass: 6, maxPublishedFindings: 6 }, publishBehavior: "local_only", }, summary: "Reviewed against default branch", @@ -110,7 +109,6 @@ describe("ReviewPage", () => { dirtyOnly: false, modelId: "openai/gpt-5.5", reasoningEffort: "high", - budgets: { maxFiles: 25, maxDiffChars: 120000, maxPromptChars: 60000, maxFindings: 8, maxFindingsPerPass: 6, maxPublishedFindings: 6 }, publishBehavior: "local_only", }, summary: "Reviewed lane-to-lane diff", @@ -140,7 +138,6 @@ describe("ReviewPage", () => { dirtyOnly: false, modelId: "openai/gpt-5.5", reasoningEffort: "medium", - budgets: { maxFiles: 25, maxDiffChars: 120000, maxPromptChars: 60000, maxFindings: 8, maxFindingsPerPass: 6, maxPublishedFindings: 6 }, publishBehavior: "local_only", }, summary: null, @@ -244,7 +241,7 @@ describe("ReviewPage", () => { title: "Diff risk findings", mimeType: "application/json", contentText: "{\"summary\":\"Direct diff risk\"}", - metadata: { passKey: "diff-risk", summary: "Direct diff risk", totalParsedCount: 1, keptCount: 1, budgetTrimmedCount: 0 }, + metadata: { passKey: "diff-risk", summary: "Direct diff risk", totalParsedCount: 1, keptCount: 1 }, createdAt: "2026-04-03T12:02:00.000Z", }, { @@ -254,7 +251,7 @@ describe("ReviewPage", () => { title: "Cross-file impact findings", mimeType: "application/json", contentText: "{\"summary\":\"Cross-file corroboration\"}", - metadata: { passKey: "cross-file-impact", summary: "Cross-file corroboration", totalParsedCount: 1, keptCount: 1, budgetTrimmedCount: 0 }, + metadata: { passKey: "cross-file-impact", summary: "Cross-file corroboration", totalParsedCount: 1, keptCount: 1 }, createdAt: "2026-04-03T12:02:30.000Z", }, { @@ -399,12 +396,26 @@ describe("ReviewPage", () => { await waitFor(() => expect(screen.getByTestId("location-search").textContent).toContain("runId=run-2")); expect((await screen.findAllByText("Reviewed lane-to-lane diff")).length).toBeGreaterThan(0); + const detailPane = screen.getByTestId("pane-detail"); + expect(within(detailPane).getByText("Review scope")).toBeTruthy(); + expect(within(detailPane).getByText("vs.")).toBeTruthy(); + expect(within(detailPane).getByText("Compare against")).toBeTruthy(); + expect(within(detailPane).getByText(/Reviewed how bugfix\/review-engine differs from feature\/review-tab/i)).toBeTruthy(); + expect(within(detailPane).queryByText("Target mode")).toBeNull(); expect(await screen.findByText("Missing guard on empty result")).toBeTruthy(); expect(await screen.findByText("Reviewer outputs")).toBeTruthy(); expect(await screen.findByText(/strong evidence/i)).toBeTruthy(); expect(await screen.findByText("Review agent transcript available")).toBeTruthy(); expect(screen.getByRole("button", { name: /open diff risk transcript in work/i })).toBeTruthy(); expect(screen.getAllByRole("button", { name: /open.*work/i }).length).toBeGreaterThan(0); + expect(within(detailPane).getByText(/Run run-2 · Started .* · Completed/i)).toBeTruthy(); + expect(within(detailPane).getByText("Model and reasoning")).toBeTruthy(); + expect(within(detailPane).getByRole("button", { name: /select model \(current: GPT-5\.5\)/i })).toBeTruthy(); + expect(within(detailPane).getByRole("button", { name: "Reasoning effort" })).toBeTruthy(); + expect(within(detailPane).getByRole("button", { name: "Fast mode" })).toBeTruthy(); + expect(within(detailPane).queryByText("Run id")).toBeNull(); + expect(within(detailPane).queryByText(/^Lane$/)).toBeNull(); + expect(within(detailPane).queryByText(/^Publish$/)).toBeNull(); fireEvent.click(screen.getByRole("button", { name: /rerun/i })); @@ -443,7 +454,6 @@ describe("ReviewPage", () => { dirtyOnly: false, modelId: "openai/gpt-5.5", reasoningEffort: "medium", - budgets: { maxFiles: 25, maxDiffChars: 120000, maxPromptChars: 60000, maxFindings: 8, maxFindingsPerPass: 6, maxPublishedFindings: 6 }, publishBehavior: "local_only", }, summary: "Multi-pass review kept 1 high-signal finding(s) from 1 candidate(s). Partial review: 1 specialist reviewer failed (Security/data).", @@ -574,7 +584,8 @@ describe("ReviewPage", () => { ); await waitFor(() => expect(screen.getAllByText("Launch review").length).toBeGreaterThan(0)); - expect(screen.getByRole("button", { name: /select model/i })).toBeTruthy(); + const launchPane = screen.getByTestId("pane-launch"); + expect(within(launchPane).getByRole("button", { name: /select model/i })).toBeTruthy(); fireEvent.click(screen.getByRole("button", { name: /start review/i })); await waitFor(() => expect((window.ade.review as any).startRun).toHaveBeenCalled()); @@ -600,10 +611,12 @@ describe("ReviewPage", () => { ); await waitFor(() => expect(screen.getAllByText("Launch review").length).toBeGreaterThan(0)); - expect(screen.getByText(/Can inspect files and run read-only analysis/i)).toBeTruthy(); - expect(screen.getByText("Read-only")).toBeTruthy(); + expect( + screen.getByText(/This is read only and the model can only read and inspect files/i), + ).toBeTruthy(); - const fastModeButton = screen.getByRole("button", { name: "Fast mode" }); + const launchPane = screen.getByTestId("pane-launch"); + const fastModeButton = within(launchPane).getByRole("button", { name: "Fast mode" }); fireEvent.click(fastModeButton); expect(fastModeButton.getAttribute("aria-pressed")).toBe("true"); @@ -617,7 +630,7 @@ describe("ReviewPage", () => { }); }); - it("starts a review without ADE budget limits", async () => { + it("starts a review with the selected launch config", async () => { render( <MemoryRouter initialEntries={["/review"]}> <Routes> @@ -627,25 +640,14 @@ describe("ReviewPage", () => { ); await waitFor(() => expect(screen.getAllByText("Launch review").length).toBeGreaterThan(0)); - fireEvent.click(screen.getByText("Advanced review budgets")); - fireEvent.click(screen.getByRole("button", { name: "No limits" })); - - expect(screen.getByText(/No ADE budget limits will be applied/i)).toBeTruthy(); - expect(screen.queryByLabelText("Files")).toBeNull(); - fireEvent.click(screen.getByRole("button", { name: /start review/i })); await waitFor(() => expect((window.ade.review as any).startRun).toHaveBeenCalled()); const [{ config }] = (window.ade.review as any).startRun.mock.calls[0]; - expect(config.budgets).toMatchObject({ - unlimited: true, - maxFiles: Number.MAX_SAFE_INTEGER, - maxDiffChars: Number.MAX_SAFE_INTEGER, - maxPromptChars: Number.MAX_SAFE_INTEGER, - maxFindings: Number.MAX_SAFE_INTEGER, - maxFindingsPerPass: Number.MAX_SAFE_INTEGER, - maxPublishedFindings: Number.MAX_SAFE_INTEGER, + expect(config).toMatchObject({ + publishBehavior: "local_only", }); + expect(config.budgets).toBeUndefined(); }); it("uses explicit local base language for non-primary lane comparisons", async () => { @@ -660,9 +662,9 @@ describe("ReviewPage", () => { await waitFor(() => expect(screen.getAllByText("Launch review").length).toBeGreaterThan(0)); expect(screen.getByText("Compare with main")).toBeTruthy(); - expect(screen.getByText(/compares this lane's branch to local main/i)).toBeTruthy(); expect(screen.getByText(/feature\/review-tab: branch changes vs local main/i)).toBeTruthy(); - expect(screen.getByText(/Pull remote changes into that local base first/i)).toBeTruthy(); + expect(screen.getByText(/since it split from local main/i)).toBeTruthy(); + expect(screen.getByText(/Pull or merge remote changes into main first/i)).toBeTruthy(); }); it("uses local upstream ref language for primary lane comparisons", async () => { @@ -703,7 +705,7 @@ describe("ReviewPage", () => { expect(screen.getByText("Compare with origin/main")).toBeTruthy(); expect(screen.getByText(/local main vs local origin\/main/i)).toBeTruthy(); - expect(screen.getByText(/local primary branch to local origin\/main/i)).toBeTruthy(); + expect(screen.getByText(/Reviews local commits on main against local origin\/main/i)).toBeTruthy(); expect(screen.getAllByText(/fetch or pull first when you want latest remote changes included/i).length).toBeGreaterThan(0); }); @@ -793,7 +795,6 @@ describe("ReviewPage", () => { dirtyOnly: false, modelId: "openai/gpt-5.5", reasoningEffort: "medium", - budgets: { maxFiles: 25, maxDiffChars: 120000, maxPromptChars: 60000, maxFindings: 8, maxFindingsPerPass: 6, maxPublishedFindings: 6 }, publishBehavior: "local_only", }, summary: "Missing timestamps should stay visible as missing.", @@ -826,7 +827,7 @@ describe("ReviewPage", () => { ); expect(await screen.findByText("Missing timestamps should stay visible as missing.")).toBeTruthy(); - expect(screen.getAllByText("—").length).toBeGreaterThan(0); + expect(screen.getByText(/Run run-missing-time · Started —/)).toBeTruthy(); }); it("shows an inline error banner when refreshing runs fails after runs are already loaded", async () => { @@ -841,11 +842,45 @@ describe("ReviewPage", () => { ); expect(await screen.findByText("Reviewed against default branch")).toBeTruthy(); + const detailPane = screen.getByTestId("pane-detail"); + expect(within(detailPane).getByText("Review scope")).toBeTruthy(); + expect(within(detailPane).getByText(/Comparing against local main\. Fetch or pull first when you want latest remote changes included\. Selection: Full diff\./i)).toBeTruthy(); + expect(within(detailPane).queryByText("Selection mode")).toBeNull(); reviewBridge.listRuns.mockRejectedValueOnce(new Error("Refresh failed")); fireEvent.click(screen.getByRole("button", { name: /refresh runs/i })); - expect((await screen.findByRole("alert")).textContent).toContain("Refresh failed"); + await waitFor(() => { + expect(screen.getByRole("alert").textContent).toContain("Refresh failed"); + }); expect(screen.getAllByText("feature/review-tab vs main").length).toBeGreaterThan(0); }); + + it("uses the header refresh control for all review tab reloads", async () => { + const reviewBridge = window.ade.review as any; + + render( + <MemoryRouter initialEntries={["/review?runId=run-2"]}> + <Routes> + <Route path="/review" element={<ReviewPage />} /> + </Routes> + </MemoryRouter>, + ); + + expect(await screen.findByText("Missing guard on empty result")).toBeTruthy(); + expect(screen.queryByRole("button", { name: /^refresh$/i })).toBeNull(); + + const listRunsCalls = reviewBridge.listRuns.mock.calls.length; + const listLaunchContextCalls = reviewBridge.listLaunchContext.mock.calls.length; + const getRunDetailCalls = reviewBridge.getRunDetail.mock.calls.length; + + fireEvent.click(screen.getByRole("button", { name: /refresh runs/i })); + + await waitFor(() => { + expect(reviewBridge.listRuns.mock.calls.length).toBeGreaterThan(listRunsCalls); + expect(reviewBridge.listLaunchContext.mock.calls.length).toBeGreaterThan(listLaunchContextCalls); + expect(reviewBridge.getRunDetail.mock.calls.length).toBeGreaterThan(getRunDetailCalls); + }); + expect(reviewBridge.getRunDetail.mock.calls.at(-1)?.[0]).toBe("run-2"); + }); }); diff --git a/apps/desktop/src/renderer/components/review/ReviewPage.tsx b/apps/desktop/src/renderer/components/review/ReviewPage.tsx index 25b73edb4..baa79c347 100644 --- a/apps/desktop/src/renderer/components/review/ReviewPage.tsx +++ b/apps/desktop/src/renderer/components/review/ReviewPage.tsx @@ -2,6 +2,7 @@ import React from "react"; import { useLocation, useNavigate, useSearchParams } from "react-router-dom"; import { ArrowsClockwise, + ArrowRight, CaretDown, ClockCounterClockwise, GitBranch, @@ -52,6 +53,12 @@ import type { } from "./reviewTypes"; import { buildReviewSearch, readReviewRunId } from "./reviewRouteState"; +const REVIEW_CARD_SURFACE = "rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)]/75"; +const REVIEW_INSET_SURFACE = "rounded-xl border border-white/[0.06] bg-[var(--color-muted)]/45"; +const REVIEW_TOGGLE_ACTIVE = "bg-sky-500/15 text-[#F5FAFF] ring-1 ring-sky-400/30"; +const REVIEW_LIST_ACTIVE = "border-sky-400/28 bg-sky-500/[0.08]"; +const REVIEW_INPUT_FOCUS = "focus:border-sky-400/45"; + const REVIEW_TILING_TREE: PaneSplit = { type: "split", direction: "horizontal", @@ -71,13 +78,6 @@ type LaunchDraft = { modelId: string; reasoningEffort: string; codexFastMode: boolean; - budgetMode: "bounded" | "unlimited"; - maxFiles: number; - maxDiffChars: number; - maxPromptChars: number; - maxFindings: number; - maxFindingsPerPass: number; - maxPublishedFindings: number; }; const DEFAULT_REVIEW_LAUNCH_MODEL_ID = getDefaultModelDescriptor("codex")?.id ?? "openai/gpt-5.4"; @@ -118,6 +118,24 @@ function formatTime(value: string | null | undefined): string { return new Date(ts).toLocaleString(); } +function formatRunTimingFooter(run: Pick<NormalizedRun, "startedAt" | "endedAt" | "status">): string { + const startedLabel = `Started ${formatTime(run.startedAt)}`; + if (run.endedAt) { + return `${startedLabel} · Completed ${formatTime(run.endedAt)}`; + } + if (run.status === "running" || run.status === "queued") { + return `${startedLabel} · In progress`; + } + return startedLabel; +} + +function formatRunSummaryFooter(run: Pick<NormalizedRun, "id" | "startedAt" | "endedAt" | "status">): string { + const timingFooter = formatRunTimingFooter(run); + const runId = run.id.trim(); + if (!runId) return timingFooter; + return `Run ${runId} · ${timingFooter}`; +} + function formatRelativeTime(value: string | null | undefined): string { if (!value) return "unknown time"; const ts = Date.parse(value); @@ -211,10 +229,6 @@ function readArtifactMetaCount(artifact: ReviewArtifact, keys: string[]): number return null; } -function formatBudgetValue(budgets: ReviewRunConfig["budgets"], value: number | undefined): string | number { - return budgets.unlimited ? "No limit" : value ?? "default"; -} - function toContextArtifactLabel(artifactType: string): string { switch (artifactType) { case "provenance_brief": @@ -324,9 +338,120 @@ function branchDisplayName(ref: string | null | undefined): string | null { return normalized.length ? normalized : null; } +function ScopeBranchNode({ + label, + caption, + emphasized = false, +}: { + label: string; + caption: string; + emphasized?: boolean; +}) { + return ( + <div + className={cn( + "min-w-0 flex-1 rounded-lg border px-3 py-2.5", + emphasized + ? "border-teal-400/25 bg-teal-500/[0.08] shadow-[inset_0_1px_0_rgba(255,255,255,0.04)]" + : "border-white/[0.08] bg-[var(--color-muted)]/40", + )} + > + <div className="flex min-w-0 items-center gap-1.5"> + <GitBranch size={12} weight="bold" className={emphasized ? "shrink-0 text-teal-400" : "shrink-0 text-[#8FA1B8]"} /> + <span className="truncate font-mono text-[11px] font-semibold text-[#F5FAFF]">{label}</span> + </div> + <div className="mt-0.5 truncate text-[10px] text-[#94A3B8]">{caption}</div> + </div> + ); +} + +function ReviewLaunchScopeVisual({ + targetMode, + compareKind, + title, + description, + laneName, + compareLaneName, + baseRefLabel, + branchRefLabel, + baseCommitLabel, + headCommitLabel, +}: { + targetMode: ReviewTargetMode; + compareKind: LaunchDraft["compareKind"]; + title: string; + description: string; + laneName: string; + compareLaneName: string | null; + baseRefLabel: string; + branchRefLabel: string; + baseCommitLabel: string | null; + headCommitLabel: string | null; +}) { + let leftNode = { label: branchRefLabel, caption: laneName, emphasized: true }; + let rightNode = { label: baseRefLabel, caption: "Base ref", emphasized: false }; + const connectorLabel = "vs."; + + if (targetMode === "lane_diff" && compareKind === "lane") { + leftNode = { + label: branchRefLabel, + caption: laneName, + emphasized: true, + }; + rightNode = { + label: compareLaneName ?? "Comparison lane", + caption: "Compare against", + emphasized: false, + }; + } else if (targetMode === "commit_range") { + leftNode = { + label: headCommitLabel ?? "Later commit", + caption: "Included head", + emphasized: true, + }; + rightNode = { + label: baseCommitLabel ?? "Earlier commit", + caption: "Excluded base", + emphasized: false, + }; + } else if (targetMode === "working_tree") { + leftNode = { + label: "Working tree", + caption: "Staged + unstaged + untracked", + emphasized: true, + }; + rightNode = { + label: "HEAD commit", + caption: "Checked-out tip", + emphasized: false, + }; + } + + return ( + <div className={cn(REVIEW_INSET_SURFACE, "p-3")}> + <div className="flex items-stretch gap-2"> + <ScopeBranchNode label={leftNode.label} caption={leftNode.caption} emphasized={leftNode.emphasized} /> + <div className="flex shrink-0 flex-col items-center justify-center gap-1 px-0.5 pt-3"> + <div className="flex items-center gap-0.5"> + <div className="h-px w-3 bg-white/[0.08]" /> + <ArrowRight size={12} weight="bold" className="text-sky-400" /> + <div className="h-px w-3 bg-white/[0.08]" /> + </div> + <span className="font-mono text-[8px] font-bold tracking-[0.14em] text-sky-400/75"> + {connectorLabel} + </span> + </div> + <ScopeBranchNode label={rightNode.label} caption={rightNode.caption} emphasized={rightNode.emphasized} /> + </div> + <div className="mt-3 text-sm font-semibold text-[#F5FAFF]">{title}</div> + <div className="mt-1 text-[11px] leading-relaxed text-[#C5D2E6]">{description}</div> + </div> + ); +} + function MetaCard({ label, value }: { label: string; value: React.ReactNode }) { return ( - <div className="rounded-xl border border-white/[0.08] bg-black/15 p-3"> + <div className={cn(REVIEW_INSET_SURFACE, "p-3")}> <div className="font-mono text-[9px] uppercase tracking-[1px] text-[#8FA1B8]">{label}</div> <div className="mt-1 break-all text-xs text-[#F5FAFF]">{value}</div> </div> @@ -345,11 +470,11 @@ function SectionCard({ action?: React.ReactNode; }) { return ( - <section className="rounded-2xl border border-white/[0.08] bg-black/15 p-4"> + <section className={cn(REVIEW_CARD_SURFACE, "p-4")}> <div className="flex items-start justify-between gap-3"> <div className="flex items-center gap-2"> - <div className="flex h-8 w-8 items-center justify-center rounded-lg border border-white/[0.08] bg-white/[0.03]"> - <Icon size={15} weight="bold" className="text-[#A78BFA]" /> + <div className="flex h-8 w-8 items-center justify-center rounded-lg border border-white/[0.08] bg-sky-500/[0.06]"> + <Icon size={15} weight="bold" className="text-sky-400" /> </div> <div> <div className="text-sm font-semibold text-[#F5FAFF]">{title}</div> @@ -467,6 +592,101 @@ function formatCompareTargetDescription(run: NormalizedRun): string { return "Comparing against the local configured base. Fetch or pull first when you want latest remote changes included."; } +function resolveRunCompareKind(config: ReviewRunConfig): LaunchDraft["compareKind"] { + return config.compareAgainst.kind === "lane" ? "lane" : "default_branch"; +} + +function buildRunScopeCopy( + run: Pick<NormalizedRun, "target" | "targetLabel" | "compareTarget" | "config">, + lane: LaneSummary | null, + compareLane: LaneSummary | null, + defaultBranchLabel: string, +): { title: string; description: string } { + const laneLabel = laneDisplayName(lane); + const branchRefLabel = branchDisplayName(lane?.branchRef) ?? laneLabel; + const baseLabel = branchDisplayName(lane?.baseRef) ?? defaultBranchLabel; + const laneIsPrimary = lane?.laneType === "primary"; + const defaultCompareLabel = laneIsPrimary ? `local origin/${baseLabel}` : `local ${baseLabel}`; + const compareKind = resolveRunCompareKind(run.config); + const selectionLabel = toSelectionModeLabel(run.config.selectionMode); + + if (run.target.mode === "lane_diff") { + if (compareKind === "lane") { + const compareLaneLabel = compareLane + ? laneDisplayName(compareLane) + : run.compareTarget?.label ?? "Comparison lane"; + return { + title: run.targetLabel?.trim() || `${laneLabel} against ${compareLaneLabel}`, + description: `Reviewed how ${laneLabel} differs from ${compareLaneLabel}. Selection: ${selectionLabel}.`, + }; + } + if (run.targetLabel?.trim()) { + return { + title: run.targetLabel.trim(), + description: `${formatCompareTargetDescription(run)} Selection: ${selectionLabel}.`, + }; + } + return { + title: laneIsPrimary + ? `${laneLabel}: local ${branchRefLabel} vs ${defaultCompareLabel}` + : `${laneLabel}: branch changes vs ${defaultCompareLabel}`, + description: `${formatCompareTargetDescription(run)} Selection: ${selectionLabel}.`, + }; + } + if (run.target.mode === "commit_range") { + const baseShort = run.target.baseCommit.slice(0, 7); + const headShort = run.target.headCommit.slice(0, 7); + return { + title: run.targetLabel?.trim() || `${laneLabel}: commit range ${baseShort}..${headShort}`, + description: `Reviewed commits after ${baseShort} through ${headShort}. The base commit is excluded; the head commit is included. Selection: ${selectionLabel}.`, + }; + } + if (run.target.mode === "working_tree") { + return { + title: run.targetLabel?.trim() || `${laneLabel}: uncommitted changes`, + description: `${formatCompareTargetDescription(run)} Selection: ${selectionLabel}.`, + }; + } + return { + title: run.targetLabel?.trim() || describeRunTarget(run), + description: `${formatCompareTargetDescription(run) || "Pull request review."} Selection: ${selectionLabel}.`, + }; +} + +function buildRunScopeVisualProps( + run: NormalizedRun, + lane: LaneSummary | null, + compareLane: LaneSummary | null, + defaultBranchLabel: string, +): React.ComponentProps<typeof ReviewLaunchScopeVisual> { + const compareKind = resolveRunCompareKind(run.config); + const scopeCopy = buildRunScopeCopy(run, lane, compareLane, defaultBranchLabel); + const laneLabel = laneDisplayName(lane); + const branchRefLabel = branchDisplayName(lane?.branchRef) ?? laneLabel; + const baseLabel = branchDisplayName(lane?.baseRef) ?? defaultBranchLabel; + const laneIsPrimary = lane?.laneType === "primary"; + const defaultCompareLabel = laneIsPrimary ? `local origin/${baseLabel}` : `local ${baseLabel}`; + const resolvedCompareLabel = run.compareTarget?.label + ?? branchDisplayName(run.compareTarget?.branchRef) + ?? branchDisplayName(run.compareTarget?.ref) + ?? null; + + return { + targetMode: run.target.mode, + compareKind, + title: scopeCopy.title, + description: scopeCopy.description, + laneName: laneLabel, + compareLaneName: compareKind === "lane" + ? (compareLane ? laneDisplayName(compareLane) : run.compareTarget?.label ?? null) + : null, + baseRefLabel: resolvedCompareLabel ?? defaultCompareLabel, + branchRefLabel, + baseCommitLabel: run.target.mode === "commit_range" ? run.target.baseCommit.slice(0, 7) : null, + headCommitLabel: run.target.mode === "commit_range" ? run.target.headCommit.slice(0, 7) : null, + }; +} + function isLaunchDraftComplete(draft: LaunchDraft): boolean { if (!draft.laneId.trim()) return false; if (draft.targetMode === "lane_diff" && draft.compareKind === "lane" && !draft.compareLaneId.trim()) return false; @@ -546,7 +766,7 @@ function CommitSelectField({ value={value} onChange={(event) => onChange(event.target.value)} disabled={disabled} - className="h-10 w-full appearance-none rounded-xl border border-white/[0.08] bg-black/20 px-3 pr-8 text-sm text-[#F5FAFF] outline-none transition-colors disabled:cursor-not-allowed disabled:opacity-60 focus:border-[#A78BFA55]" + className={cn("h-10 w-full appearance-none rounded-xl border border-white/[0.08] bg-[var(--color-muted)]/55 px-3 pr-8 text-sm text-[#F5FAFF] outline-none transition-colors disabled:cursor-not-allowed disabled:opacity-60", REVIEW_INPUT_FOCUS)} > <option value="">{disabled ? "Not enough commits" : `Choose ${label.toLowerCase()}...`}</option> {options.map((commit) => ( @@ -559,7 +779,7 @@ function CommitSelectField({ </div> <div className="text-[11px] text-[#94A3B8]">{helper}</div> {selectedCommit ? ( - <div className="rounded-xl border border-white/[0.06] bg-black/15 p-3"> + <div className={cn(REVIEW_INSET_SURFACE, "p-3")}> <div className="flex flex-wrap items-center gap-2"> <Chip className="text-[9px]">{selectedCommit.shortSha}</Chip> <Chip className="text-[9px]">{formatRelativeTime(selectedCommit.authoredAt)}</Chip> @@ -577,25 +797,6 @@ function buildTargetConfig( targetMode: ReviewTargetMode, draft: LaunchDraft, ): { target: ReviewTarget; config: ReviewRunConfig } { - const budgets: ReviewRunConfig["budgets"] = draft.budgetMode === "unlimited" - ? { - unlimited: true, - maxFiles: Number.MAX_SAFE_INTEGER, - maxDiffChars: Number.MAX_SAFE_INTEGER, - maxPromptChars: Number.MAX_SAFE_INTEGER, - maxFindings: Number.MAX_SAFE_INTEGER, - maxFindingsPerPass: Number.MAX_SAFE_INTEGER, - maxPublishedFindings: Number.MAX_SAFE_INTEGER, - } - : { - maxFiles: draft.maxFiles, - maxDiffChars: draft.maxDiffChars, - maxPromptChars: draft.maxPromptChars, - maxFindings: draft.maxFindings, - maxFindingsPerPass: draft.maxFindingsPerPass, - maxPublishedFindings: draft.maxPublishedFindings, - }; - if (targetMode === "lane_diff") { const compareAgainst: ReviewRunConfig["compareAgainst"] = draft.compareKind === "lane" ? { kind: "lane", laneId: draft.compareLaneId || draft.laneId } @@ -609,7 +810,6 @@ function buildTargetConfig( modelId: draft.modelId.trim(), reasoningEffort: draft.reasoningEffort.trim() || null, codexFastMode: draft.codexFastMode, - budgets, publishBehavior: "local_only", }, }; @@ -625,7 +825,6 @@ function buildTargetConfig( modelId: draft.modelId.trim(), reasoningEffort: draft.reasoningEffort.trim() || null, codexFastMode: draft.codexFastMode, - budgets, publishBehavior: "local_only", }, }; @@ -640,7 +839,6 @@ function buildTargetConfig( modelId: draft.modelId.trim(), reasoningEffort: draft.reasoningEffort.trim() || null, codexFastMode: draft.codexFastMode, - budgets, publishBehavior: "local_only", }, }; @@ -679,13 +877,6 @@ export function ReviewPage({ active = true }: { active?: boolean } = {}) { modelId: DEFAULT_REVIEW_LAUNCH_MODEL_ID, reasoningEffort: DEFAULT_REVIEW_REASONING_EFFORT, codexFastMode: false, - budgetMode: "bounded", - maxFiles: 25, - maxDiffChars: 120_000, - maxPromptChars: 60_000, - maxFindings: 8, - maxFindingsPerPass: 6, - maxPublishedFindings: 6, })); const recommendedModelHydratedRef = React.useRef(false); @@ -702,6 +893,19 @@ export function ReviewPage({ active = true }: { active?: boolean } = {}) { () => (selectedRun ? laneById.get(selectedRun.laneId) ?? null : null), [laneById, selectedRun], ); + const selectedRunCompareLane = React.useMemo(() => { + if (!selectedRun || selectedRun.config.compareAgainst.kind !== "lane") return null; + return laneById.get(selectedRun.config.compareAgainst.laneId) ?? null; + }, [laneById, selectedRun]); + const selectedRunScopeVisual = React.useMemo(() => { + if (!selectedRun) return null; + return buildRunScopeVisualProps( + selectedRun, + selectedRunLane, + selectedRunCompareLane, + launchContext?.defaultBranchName?.trim() || "default branch", + ); + }, [launchContext?.defaultBranchName, selectedRun, selectedRunCompareLane, selectedRunLane]); const selectedPassArtifacts = React.useMemo( () => selectedDetail?.artifacts?.filter((artifact) => artifact.artifactType === "pass_findings") ?? [], [selectedDetail?.artifacts], @@ -792,7 +996,7 @@ export function ReviewPage({ active = true }: { active?: boolean } = {}) { } }, [selectedRunId]); - const loadDetail = React.useCallback(async (runId: string | null) => { + const loadDetail = React.useCallback(async (runId: string | null, options?: { clearError?: boolean }) => { if (!runId) { setDetail(null); return; @@ -801,7 +1005,9 @@ export function ReviewPage({ active = true }: { active?: boolean } = {}) { try { const next = await getReviewRunDetail(runId); setDetail(next ? normalizeDetail(next) : null); - setError(null); + if (options?.clearError !== false) { + setError(null); + } } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { @@ -907,7 +1113,6 @@ export function ReviewPage({ active = true }: { active?: boolean } = {}) { const defaultCompareOptionLabel = selectedLaneIsPrimary ? `Compare with origin/${selectedLaneBaseLabel}` : `Compare with ${selectedLaneBaseLabel}`; - const defaultCompareChipLabel = `Comparing against ${selectedLaneDefaultCompareLabel}`; const selectedLaneCommits = React.useMemo( () => orderLaunchCommits(launchContext?.recentCommitsByLane?.[launchDraft.laneId] ?? []), [launchContext?.recentCommitsByLane, launchDraft.laneId], @@ -997,8 +1202,6 @@ export function ReviewPage({ active = true }: { active?: boolean } = {}) { selectedLaneDefaultCompareLabel, selectedLaneIsPrimary, ]); - const activeRuns = runs.filter((run) => run.status === "running" || run.status === "queued").length; - const totalFindings = runs.reduce((sum, run) => sum + (run.findingCount ?? 0), 0); const launchReady = isLaunchDraftComplete(launchDraft) && !launchValidationMessage; const handleSelectRun = React.useCallback((runId: string) => { @@ -1140,6 +1343,20 @@ export function ReviewPage({ active = true }: { active?: boolean } = {}) { const [showSuppressed, setShowSuppressed] = React.useState(false); const [feedbackError, setFeedbackError] = React.useState<string | null>(null); const [cancelInFlight, setCancelInFlight] = React.useState(false); + const [refreshingTab, setRefreshingTab] = React.useState(false); + + const refreshReviewTab = React.useCallback(async () => { + setRefreshingTab(true); + try { + await Promise.all([ + refreshLaunchContext(), + refreshRuns(), + selectedRunId ? loadDetail(selectedRunId, { clearError: false }) : Promise.resolve(), + ]); + } finally { + setRefreshingTab(false); + } + }, [loadDetail, refreshLaunchContext, refreshRuns, selectedRunId]); const handleFindingAction = React.useCallback(async (req: FindingActionRequest) => { setFeedbackError(null); @@ -1202,7 +1419,7 @@ export function ReviewPage({ active = true }: { active?: boolean } = {}) { }, [loadDetail, refreshRuns, selectedRunId]); const launchPane = ( - <div className="flex h-full min-h-0 flex-col gap-3 overflow-hidden"> + <div className="flex h-full min-h-0 flex-col gap-3 overflow-hidden bg-[var(--color-surface-recessed)]/35 px-1 pt-1"> <SectionCard title="Launch review" icon={Sparkle} @@ -1215,10 +1432,10 @@ export function ReviewPage({ active = true }: { active?: boolean } = {}) { > <div className="grid gap-3"> <label className="grid gap-1.5"> - <span className="font-mono text-[9px] uppercase tracking-[1px] text-[#8FA1B8]">Lane</span> + <span className="font-mono text-[11px] font-medium tracking-[0.02em] text-[#8FA1B8]">Lane to review</span> <div className="relative"> <select - className="h-9 w-full appearance-none rounded-xl border border-white/[0.08] bg-black/20 px-3 pr-8 text-sm text-[#F5FAFF] outline-none transition-colors focus:border-[#A78BFA55]" + className={cn("h-9 w-full appearance-none rounded-xl border border-white/[0.08] bg-[var(--color-muted)]/55 px-3 pr-8 text-sm text-[#F5FAFF] outline-none transition-colors", REVIEW_INPUT_FOCUS)} value={launchDraft.laneId} onChange={(e) => updateDraft("laneId", e.target.value)} > @@ -1232,7 +1449,7 @@ export function ReviewPage({ active = true }: { active?: boolean } = {}) { <label className="grid gap-1.5"> <span className="font-mono text-[9px] uppercase tracking-[1px] text-[#8FA1B8]">Target mode</span> - <div className="grid grid-cols-3 gap-1 rounded-xl border border-white/[0.08] bg-black/15 p-1"> + <div className={cn("grid grid-cols-3 gap-1 p-1", REVIEW_INSET_SURFACE)}> {([ ["lane_diff", "Lane diff"], ["commit_range", "Commit range"], @@ -1245,7 +1462,7 @@ export function ReviewPage({ active = true }: { active?: boolean } = {}) { type="button" className={cn( "rounded-lg px-2 py-2 text-[11px] font-semibold transition-colors", - active ? "bg-[#A78BFA1A] text-[#F5FAFF] ring-1 ring-[#A78BFA33]" : "text-[#94A3B8] hover:text-[#F5FAFF]" + active ? REVIEW_TOGGLE_ACTIVE : "text-[#94A3B8] hover:text-[#F5FAFF]" )} onClick={() => updateDraft("targetMode", mode)} > @@ -1256,29 +1473,11 @@ export function ReviewPage({ active = true }: { active?: boolean } = {}) { </div> </label> - <div className="rounded-xl border border-white/[0.06] bg-white/[0.03] p-3"> - <div className="flex flex-wrap items-center gap-2"> - <Chip className="text-[9px]">{toTargetModeLabel(launchDraft.targetMode)}</Chip> - {launchDraft.targetMode === "lane_diff" ? ( - <Chip className="text-[9px]"> - {launchDraft.compareKind === "lane" ? "Lane to lane" : defaultCompareChipLabel} - </Chip> - ) : null} - </div> - <div className="mt-2 text-sm font-semibold text-[#F5FAFF]">{launchScope.title}</div> - <div className="mt-1 text-[11px] text-[#C5D2E6]">{launchScope.description}</div> - </div> - {launchDraft.targetMode === "lane_diff" ? ( - <div className="grid gap-2 rounded-xl border border-white/[0.06] bg-white/[0.03] p-3"> - <div className="text-[11px] text-[#C5D2E6]"> - {selectedLaneIsPrimary - ? `ADE compares your local primary branch to ${selectedLaneDefaultCompareLabel}. It uses refs already in this checkout, so fetch or pull first when you want latest remote changes included.` - : `ADE compares this lane's branch to ${selectedLaneDefaultCompareLabel}. Pull remote changes into that local base first when you want them included.`} - </div> + <div className={cn("grid gap-2 p-3", REVIEW_INSET_SURFACE)}> <label className="grid gap-1.5"> <span className="font-mono text-[9px] uppercase tracking-[1px] text-[#8FA1B8]">Compare against</span> - <div className="grid grid-cols-2 gap-1 rounded-xl border border-white/[0.08] bg-black/15 p-1"> + <div className={cn("grid grid-cols-2 gap-1 p-1", REVIEW_INSET_SURFACE)}> {(["default_branch", "lane"] as const).map((kind) => { const active = launchDraft.compareKind === kind; return ( @@ -1287,7 +1486,7 @@ export function ReviewPage({ active = true }: { active?: boolean } = {}) { type="button" className={cn( "rounded-lg px-2 py-2 text-[11px] font-semibold transition-colors", - active ? "bg-[#A78BFA1A] text-[#F5FAFF] ring-1 ring-[#A78BFA33]" : "text-[#94A3B8] hover:text-[#F5FAFF]" + active ? REVIEW_TOGGLE_ACTIVE : "text-[#94A3B8] hover:text-[#F5FAFF]" )} onClick={() => updateDraft("compareKind", kind)} > @@ -1302,7 +1501,7 @@ export function ReviewPage({ active = true }: { active?: boolean } = {}) { <span className="font-mono text-[9px] uppercase tracking-[1px] text-[#8FA1B8]">Compare lane</span> <div className="relative"> <select - className="h-9 w-full appearance-none rounded-xl border border-white/[0.08] bg-black/20 px-3 pr-8 text-sm text-[#F5FAFF] outline-none transition-colors focus:border-[#A78BFA55]" + className={cn("h-9 w-full appearance-none rounded-xl border border-white/[0.08] bg-[var(--color-muted)]/55 px-3 pr-8 text-sm text-[#F5FAFF] outline-none transition-colors", REVIEW_INPUT_FOCUS)} value={launchDraft.compareLaneId} onChange={(e) => updateDraft("compareLaneId", e.target.value)} > @@ -1318,8 +1517,21 @@ export function ReviewPage({ active = true }: { active?: boolean } = {}) { </div> ) : null} + <ReviewLaunchScopeVisual + targetMode={launchDraft.targetMode} + compareKind={launchDraft.compareKind} + title={launchScope.title} + description={launchScope.description} + laneName={laneDisplayName(selectedLane)} + compareLaneName={selectedCompareLane ? laneDisplayName(selectedCompareLane) : null} + baseRefLabel={selectedLaneDefaultCompareLabel} + branchRefLabel={selectedLaneBranchLabel ?? laneDisplayName(selectedLane)} + baseCommitLabel={selectedBaseCommit?.shortSha ?? null} + headCommitLabel={selectedHeadCommit?.shortSha ?? null} + /> + {launchDraft.targetMode === "commit_range" ? ( - <div className="grid gap-2 rounded-xl border border-white/[0.06] bg-white/[0.03] p-3"> + <div className="grid gap-2 rounded-xl border border-white/[0.06] bg-[var(--color-muted)]/40 p-3"> <div className="text-[11px] text-[#C5D2E6]"> Review only part of this lane's history. Commit lists are ordered from earlier to later so you can pick the start and end of the range without typing raw SHAs. </div> @@ -1355,7 +1567,7 @@ export function ReviewPage({ active = true }: { active?: boolean } = {}) { ) : null} {launchDraft.targetMode === "working_tree" ? ( - <div className="grid gap-2 rounded-xl border border-white/[0.06] bg-white/[0.03] p-3"> + <div className="grid gap-2 rounded-xl border border-white/[0.06] bg-[var(--color-muted)]/40 p-3"> <div className="text-[11px] text-[#C5D2E6]"> Review the current staged, unstaged, and untracked changes in the selected lane. This mode compares the working tree against the lane's current HEAD commit. It does not compare against another lane. </div> @@ -1373,118 +1585,9 @@ export function ReviewPage({ active = true }: { active?: boolean } = {}) { onCodexFastModeChange={(value) => updateDraft("codexFastMode", value)} disabled={launching} /> - <Chip className="w-fit text-[9px]">Read-only</Chip> - <div className="text-[11px] text-[#94A3B8]"> - Can inspect files and run read-only analysis. Cannot edit files. - </div> - </div> - - <details className="rounded-xl border border-white/[0.06] bg-white/[0.03]"> - <summary className="cursor-pointer list-none px-3 py-3"> - <div className="flex items-start justify-between gap-3"> - <div> - <div className="text-sm font-semibold text-[#F5FAFF]">Advanced review budgets</div> - <div className="mt-1 text-[11px] text-[#94A3B8]"> - These limits keep runs bounded. Most reviews can keep the defaults. - </div> - </div> - <Chip className="text-[9px]">{launchDraft.budgetMode === "unlimited" ? "no limits" : "advanced"}</Chip> - </div> - </summary> - <div className="grid gap-3 px-3 pb-3"> - <div className="grid grid-cols-2 gap-1 rounded-xl border border-white/[0.08] bg-black/15 p-1"> - {(["bounded", "unlimited"] as const).map((mode) => { - const active = launchDraft.budgetMode === mode; - return ( - <button - key={mode} - type="button" - className={cn( - "rounded-lg px-2 py-2 text-[11px] font-semibold transition-colors", - active ? "bg-[#A78BFA1A] text-[#F5FAFF] ring-1 ring-[#A78BFA33]" : "text-[#94A3B8] hover:text-[#F5FAFF]" - )} - onClick={() => updateDraft("budgetMode", mode)} - > - {mode === "bounded" ? "Use limits" : "No limits"} - </button> - ); - })} - </div> - {launchDraft.budgetMode === "unlimited" ? ( - <div className="rounded-xl border border-white/[0.06] bg-black/15 p-3 text-[11px] text-[#C5D2E6]"> - No ADE budget limits will be applied to files, diff text, prompt text, findings, or publication count for this run. - </div> - ) : ( - <div className="grid gap-2 md:grid-cols-3 xl:grid-cols-6"> - <label className="grid gap-1.5"> - <span className="font-mono text-[9px] uppercase tracking-[1px] text-[#8FA1B8]">Files</span> - <input - type="number" - min={1} - value={launchDraft.maxFiles} - onChange={(e) => updateDraft("maxFiles", Number(e.target.value) || 1)} - className="h-9 rounded-xl border border-white/[0.08] bg-black/20 px-3 text-sm text-[#F5FAFF] outline-none focus:border-[#A78BFA55]" - /> - </label> - <label className="grid gap-1.5"> - <span className="font-mono text-[9px] uppercase tracking-[1px] text-[#8FA1B8]">Diff chars</span> - <input - type="number" - min={1024} - step={1024} - value={launchDraft.maxDiffChars} - onChange={(e) => updateDraft("maxDiffChars", Number(e.target.value) || 1024)} - className="h-9 rounded-xl border border-white/[0.08] bg-black/20 px-3 text-sm text-[#F5FAFF] outline-none focus:border-[#A78BFA55]" - /> - </label> - <label className="grid gap-1.5"> - <span className="font-mono text-[9px] uppercase tracking-[1px] text-[#8FA1B8]">Prompt chars</span> - <input - type="number" - min={1024} - step={1024} - value={launchDraft.maxPromptChars} - onChange={(e) => updateDraft("maxPromptChars", Number(e.target.value) || 1024)} - className="h-9 rounded-xl border border-white/[0.08] bg-black/20 px-3 text-sm text-[#F5FAFF] outline-none focus:border-[#A78BFA55]" - /> - </label> - <label className="grid gap-1.5"> - <span className="font-mono text-[9px] uppercase tracking-[1px] text-[#8FA1B8]">Findings</span> - <input - type="number" - min={1} - value={launchDraft.maxFindings} - onChange={(e) => updateDraft("maxFindings", Number(e.target.value) || 1)} - className="h-9 rounded-xl border border-white/[0.08] bg-black/20 px-3 text-sm text-[#F5FAFF] outline-none focus:border-[#A78BFA55]" - /> - </label> - <label className="grid gap-1.5"> - <span className="font-mono text-[9px] uppercase tracking-[1px] text-[#8FA1B8]">Per pass</span> - <input - type="number" - min={1} - value={launchDraft.maxFindingsPerPass} - onChange={(e) => updateDraft("maxFindingsPerPass", Number(e.target.value) || 1)} - className="h-9 rounded-xl border border-white/[0.08] bg-black/20 px-3 text-sm text-[#F5FAFF] outline-none focus:border-[#A78BFA55]" - /> - </label> - <label className="grid gap-1.5"> - <span className="font-mono text-[9px] uppercase tracking-[1px] text-[#8FA1B8]">Published</span> - <input - type="number" - min={1} - value={launchDraft.maxPublishedFindings} - onChange={(e) => updateDraft("maxPublishedFindings", Number(e.target.value) || 1)} - className="h-9 rounded-xl border border-white/[0.08] bg-black/20 px-3 text-sm text-[#F5FAFF] outline-none focus:border-[#A78BFA55]" - /> - </label> - </div> - )} - </div> - </details> - - <div className="rounded-xl border border-white/[0.06] bg-white/[0.03] p-3 text-[11px] text-[#94A3B8]"> - Every review run is saved locally. Use the list below as the run picker, then inspect the selected run's findings, context, and transcript in the right pane. + <p className="text-[13px] text-[#C5D2E6]"> + This is read only and the model can only read and inspect files. + </p> </div> </div> </SectionCard> @@ -1493,16 +1596,10 @@ export function ReviewPage({ active = true }: { active?: boolean } = {}) { title="Review runs" icon={ClockCounterClockwise} action={( - <div className="flex items-center gap-1.5"> - <Button size="sm" variant="ghost" onClick={() => setShowLearnings((prev) => !prev)}> - <GitBranch size={12} /> - {showLearnings ? "Hide learnings" : "Learnings"} - </Button> - <Button size="sm" variant="ghost" onClick={() => void refreshRuns()} disabled={loadingRuns}> - <ArrowsClockwise size={12} weight="regular" className={cn(loadingRuns && "animate-spin")} /> - Refresh - </Button> - </div> + <Button size="sm" variant="ghost" onClick={() => setShowLearnings((prev) => !prev)}> + <GitBranch size={12} /> + {showLearnings ? "Hide learnings" : "Learnings"} + </Button> )} > <div className="space-y-2"> @@ -1510,7 +1607,7 @@ export function ReviewPage({ active = true }: { active?: boolean } = {}) { Pick a saved run here to inspect it on the right. </div> {runs.length === 0 ? ( - <div className="rounded-xl border border-white/[0.06] bg-white/[0.03] p-3 text-xs text-[#94A3B8]"> + <div className="rounded-xl border border-white/[0.06] bg-[var(--color-muted)]/40 p-3 text-xs text-[#94A3B8]"> No review runs yet in this workspace. New runs will show up here and open on the right. </div> ) : runs.map((run) => { @@ -1523,7 +1620,7 @@ export function ReviewPage({ active = true }: { active?: boolean } = {}) { onClick={() => handleSelectRun(run.id)} className={cn( "w-full rounded-xl border p-3 text-left transition-colors", - active ? "border-[#A78BFA33] bg-[#A78BFA10]" : "border-white/[0.06] bg-white/[0.03] hover:bg-white/[0.05]" + active ? REVIEW_LIST_ACTIVE : "border-white/[0.06] bg-[var(--color-muted)]/35 hover:bg-[var(--color-muted)]/50" )} > <div className="flex items-start justify-between gap-2"> @@ -1557,10 +1654,14 @@ export function ReviewPage({ active = true }: { active?: boolean } = {}) { <ReviewLearningsPanel onClose={() => setShowLearnings(false)} /> </div> ) : ( - <div className="flex h-full min-h-0 flex-col overflow-hidden"> + <div className="flex h-full min-h-0 flex-col overflow-hidden bg-[var(--color-bg)]"> {selectedRun ? ( <div className="flex h-full min-h-0 flex-col gap-4 overflow-y-auto px-5 py-5"> - <section className="rounded-2xl border border-white/[0.08] bg-black/15 p-4"> + <SectionCard title="Review scope" icon={GitBranch}> + {selectedRunScopeVisual ? <ReviewLaunchScopeVisual {...selectedRunScopeVisual} /> : null} + </SectionCard> + + <section className={cn(REVIEW_CARD_SURFACE, "p-4")}> <div className="flex items-start justify-between gap-3"> <div className="min-w-0"> <div className="flex flex-wrap items-center gap-2"> @@ -1581,6 +1682,21 @@ export function ReviewPage({ active = true }: { active?: boolean } = {}) { {selectedRun.errorMessage ? ( <div className="mt-2 text-sm text-red-200">{selectedRun.errorMessage}</div> ) : null} + <div className="mt-3 font-mono text-[10px] text-[#94A3B8]"> + {formatRunSummaryFooter(selectedRun)} + </div> + <div className="mt-4 grid gap-1.5 border-t border-white/[0.06] pt-3"> + <span className="font-mono text-[9px] uppercase tracking-[1px] text-[#8FA1B8]">Model and reasoning</span> + <ReviewLaunchModelControls + modelId={selectedRun.config.modelId} + reasoningEffort={selectedRun.config.reasoningEffort ?? DEFAULT_REVIEW_REASONING_EFFORT} + codexFastMode={selectedRun.config.codexFastMode ?? false} + onModelChange={() => undefined} + onReasoningEffortChange={() => undefined} + onCodexFastModeChange={() => undefined} + disabled + /> + </div> </div> <Button size="sm" variant="outline" onClick={() => void handleRerun(selectedRun)} disabled={launching}> <ArrowClockwise size={12} weight="regular" /> @@ -1589,44 +1705,8 @@ export function ReviewPage({ active = true }: { active?: boolean } = {}) { </div> </section> - <section className="grid gap-3 md:grid-cols-2 xl:grid-cols-4"> - <MetaCard label="Run id" value={selectedRun.id} /> - <MetaCard label="Lane" value={selectedRunLane?.name ?? selectedRun.laneId} /> - <MetaCard label="Started" value={formatTime(selectedRun.startedAt)} /> - <MetaCard label="Completed" value={formatTime(selectedRun.endedAt)} /> - <MetaCard label="Model" value={selectedRun.config.modelId} /> - <MetaCard label="Reasoning" value={selectedRun.config.reasoningEffort ?? "default"} /> - <MetaCard label="Fast mode" value={selectedRun.config.codexFastMode ? "on" : "off"} /> - <MetaCard label="Publish" value={selectedRun.config.publishBehavior === "auto_publish" ? "Auto-publish enabled" : "Local only"} /> - </section> - - <SectionCard title="Launch setup" icon={GitBranch}> - <div className="grid gap-3 md:grid-cols-2"> - <MetaCard label="Target mode" value={toTargetModeLabel(selectedRun.target.mode)} /> - <MetaCard label="Review target" value={describeRunTarget(selectedRun)} /> - <MetaCard label="Selection mode" value={toSelectionModeLabel(selectedRun.config.selectionMode)} /> - <MetaCard - label="Comparison" - value={formatCompareTargetDescription(selectedRun)} - /> - </div> - <details className="mt-3 rounded-xl border border-white/[0.06] bg-white/[0.03]"> - <summary className="cursor-pointer list-none px-3 py-2 text-[11px] font-semibold uppercase tracking-[0.18em] text-[#94A3B8]"> - Run limits - </summary> - <div className="grid gap-3 px-3 pb-3 md:grid-cols-2 xl:grid-cols-3"> - <MetaCard label="File budget" value={formatBudgetValue(selectedRun.config.budgets, selectedRun.config.budgets.maxFiles)} /> - <MetaCard label="Diff budget" value={formatBudgetValue(selectedRun.config.budgets, selectedRun.config.budgets.maxDiffChars)} /> - <MetaCard label="Prompt budget" value={formatBudgetValue(selectedRun.config.budgets, selectedRun.config.budgets.maxPromptChars)} /> - <MetaCard label="Finding budget" value={formatBudgetValue(selectedRun.config.budgets, selectedRun.config.budgets.maxFindings)} /> - <MetaCard label="Per-pass budget" value={formatBudgetValue(selectedRun.config.budgets, selectedRun.config.budgets.maxFindingsPerPass ?? selectedRun.config.budgets.maxFindings)} /> - <MetaCard label="Publish budget" value={formatBudgetValue(selectedRun.config.budgets, selectedRun.config.budgets.maxPublishedFindings ?? selectedRun.config.budgets.maxFindings)} /> - </div> - </details> - </SectionCard> - {selectedContextArtifacts.length > 0 ? ( - <details className="rounded-2xl border border-white/[0.08] bg-black/15"> + <details className={REVIEW_CARD_SURFACE}> <summary className="cursor-pointer list-none px-4 py-3"> <div className="flex flex-wrap items-center justify-between gap-3"> <div> @@ -1640,7 +1720,7 @@ export function ReviewPage({ active = true }: { active?: boolean } = {}) { {selectedDetail?.reviewerRuns.length ? ( <div className="grid gap-2 md:grid-cols-2 xl:grid-cols-5"> {selectedDetail.reviewerRuns.map((reviewer) => ( - <article key={reviewer.id} className="rounded-xl border border-white/[0.06] bg-white/[0.03] p-3"> + <article key={reviewer.id} className="rounded-xl border border-white/[0.06] bg-[var(--color-muted)]/40 p-3"> <div className="flex flex-wrap items-center gap-2"> <Chip className={cn("text-[9px]", toReviewStatusTone(reviewer.status as ReviewRunStatus))}>{reviewer.status}</Chip> <Chip className="text-[9px]">{toPassLabel(reviewer.reviewerKey)}</Chip> @@ -1694,7 +1774,7 @@ export function ReviewPage({ active = true }: { active?: boolean } = {}) { ].filter((value): value is string => Boolean(value)); return ( - <article key={artifact.id} className="rounded-xl border border-white/[0.06] bg-white/[0.03] p-3"> + <article key={artifact.id} className="rounded-xl border border-white/[0.06] bg-[var(--color-muted)]/40 p-3"> <div className="flex flex-wrap items-center gap-2"> <Chip className="text-[9px]">{toContextArtifactLabel(artifactType)}</Chip> {countValue !== null ? <Chip className="text-[9px]">{countValue} items</Chip> : null} @@ -1713,18 +1793,18 @@ export function ReviewPage({ active = true }: { active?: boolean } = {}) { <MetaCard label="Mime type" value={artifact.mimeType} /> </div> {artifact.contentText || artifact.metadata ? ( - <details className="mt-3 rounded-lg border border-white/[0.06] bg-black/15"> + <details className={cn("mt-3", REVIEW_INSET_SURFACE)}> <summary className="cursor-pointer list-none px-3 py-2 text-[11px] font-semibold text-[#C5D2E6]"> Debug payload </summary> <div className="grid gap-2 px-3 pb-3"> {artifact.contentText ? ( - <pre className="max-h-72 overflow-auto whitespace-pre-wrap rounded-lg border border-white/[0.06] bg-black/20 p-3 font-mono text-[11px] leading-relaxed text-[#D8E3F2]"> + <pre className="max-h-72 overflow-auto whitespace-pre-wrap rounded-lg border border-white/[0.06] bg-[var(--color-surface-recessed)]/80 p-3 font-mono text-[11px] leading-relaxed text-[#D8E3F2]"> {artifact.contentText} </pre> ) : null} {artifact.metadata ? ( - <pre className="max-h-72 overflow-auto whitespace-pre-wrap rounded-lg border border-white/[0.06] bg-black/20 p-3 font-mono text-[11px] leading-relaxed text-[#B7C4D7]"> + <pre className="max-h-72 overflow-auto whitespace-pre-wrap rounded-lg border border-white/[0.06] bg-[var(--color-surface-recessed)]/80 p-3 font-mono text-[11px] leading-relaxed text-[#B7C4D7]"> {JSON.stringify(artifact.metadata, null, 2)} </pre> ) : null} @@ -1742,7 +1822,7 @@ export function ReviewPage({ active = true }: { active?: boolean } = {}) { ) : null} {(selectedPassArtifacts.length > 0 || selectedAdjudicationArtifact || selectedMergedArtifact) ? ( - <details className="rounded-2xl border border-white/[0.08] bg-black/15"> + <details className={REVIEW_CARD_SURFACE}> <summary className="cursor-pointer list-none px-4 py-3"> <div className="flex flex-wrap items-center justify-between gap-3"> <div> @@ -1756,13 +1836,10 @@ export function ReviewPage({ active = true }: { active?: boolean } = {}) { {selectedPassArtifacts.length > 0 ? ( <div className="grid gap-3 md:grid-cols-3"> {selectedPassArtifacts.map((artifact) => ( - <article key={artifact.id} className="rounded-xl border border-white/[0.06] bg-white/[0.03] p-3"> + <article key={artifact.id} className="rounded-xl border border-white/[0.06] bg-[var(--color-muted)]/40 p-3"> <div className="flex flex-wrap items-center gap-2"> <Chip className="text-[9px]">{toPassLabel(readArtifactMetaString(artifact, "passKey") ?? artifact.title)}</Chip> <Chip className="text-[9px]">{readArtifactMetaNumber(artifact, "keptCount") ?? 0} used</Chip> - {(readArtifactMetaNumber(artifact, "budgetTrimmedCount") ?? 0) > 0 ? ( - <Chip className="text-[9px]">filtered {readArtifactMetaNumber(artifact, "budgetTrimmedCount")}</Chip> - ) : null} </div> <div className="mt-2 text-xs text-[#C5D2E6]"> {readArtifactMetaString(artifact, "summary") ?? "No summary recorded for this pass."} @@ -1779,19 +1856,19 @@ export function ReviewPage({ active = true }: { active?: boolean } = {}) { {(selectedAdjudicationArtifact || selectedMergedArtifact) ? ( <div className="grid gap-3 md:grid-cols-2"> {selectedAdjudicationArtifact ? ( - <article className="rounded-xl border border-white/[0.06] bg-white/[0.03] p-3"> + <article className="rounded-xl border border-white/[0.06] bg-[var(--color-muted)]/40 p-3"> <div className="flex flex-wrap items-center gap-2"> <Chip className="text-[9px]">Adjudication</Chip> <Chip className="text-[9px]">accepted {readArtifactMetaNumber(selectedAdjudicationArtifact, "acceptedCount") ?? 0}</Chip> <Chip className="text-[9px]">rejected {readArtifactMetaNumber(selectedAdjudicationArtifact, "rejectedCount") ?? 0}</Chip> </div> <div className="mt-2 text-xs text-[#C5D2E6]"> - Merged overlaps, filtered low-signal candidates, and applied the explicit run/publication budgets before findings became final. + Merged overlaps and filtered low-signal candidates before findings became final. </div> </article> ) : null} {selectedMergedArtifact ? ( - <article className="rounded-xl border border-white/[0.06] bg-white/[0.03] p-3"> + <article className="rounded-xl border border-white/[0.06] bg-[var(--color-muted)]/40 p-3"> <div className="flex flex-wrap items-center gap-2"> <Chip className="text-[9px]">Final result</Chip> <Chip className="text-[9px]">findings {readArtifactMetaNumber(selectedMergedArtifact, "findingCount") ?? 0}</Chip> @@ -1814,7 +1891,7 @@ export function ReviewPage({ active = true }: { active?: boolean } = {}) { <SectionCard title="Publication" icon={ArrowSquareOut}> <div className="space-y-2"> {selectedDetail.publications.map((publication) => ( - <article key={publication.id} className="rounded-xl border border-white/[0.06] bg-white/[0.03] p-3"> + <article key={publication.id} className="rounded-xl border border-white/[0.06] bg-[var(--color-muted)]/40 p-3"> <div className="flex flex-wrap items-center gap-2"> <Chip className="text-[9px]">{publication.destination.kind}</Chip> <Chip className={cn("text-[9px]", publication.status === "published" ? "border-emerald-400/20 bg-emerald-400/[0.08] text-emerald-300" : "border-red-400/20 bg-red-400/[0.08] text-red-300")}> @@ -1835,7 +1912,7 @@ export function ReviewPage({ active = true }: { active?: boolean } = {}) { {publication.errorMessage ? ( <div className="mt-3 text-sm text-red-200">{publication.errorMessage}</div> ) : null} - <pre className="mt-3 whitespace-pre-wrap rounded-lg border border-white/[0.06] bg-black/20 p-3 font-mono text-[11px] leading-relaxed text-[#D8E3F2]"> + <pre className="mt-3 whitespace-pre-wrap rounded-lg border border-white/[0.06] bg-[var(--color-surface-recessed)]/80 p-3 font-mono text-[11px] leading-relaxed text-[#D8E3F2]"> {publication.summaryBody} </pre> </article> @@ -1910,7 +1987,7 @@ export function ReviewPage({ active = true }: { active?: boolean } = {}) { type="checkbox" checked={showSuppressed} onChange={(e) => setShowSuppressed(e.target.checked)} - className="h-3 w-3 accent-violet-400" + className="h-3 w-3 accent-sky-400" /> Show {suppressedCount} filtered </label> @@ -1946,7 +2023,7 @@ export function ReviewPage({ active = true }: { active?: boolean } = {}) { onOpenInEditor={finding.filePath ? handleOpenFindingInEditor : undefined} /> )) : rawFindings.length > 0 ? ( - <div className="rounded-xl border border-white/[0.06] bg-white/[0.03] p-3 text-xs text-[#94A3B8]"> + <div className="rounded-xl border border-white/[0.06] bg-[var(--color-muted)]/40 p-3 text-xs text-[#94A3B8]"> No findings match the current filters. {!showSuppressed && suppressedCount > 0 ? ( <button type="button" @@ -1964,7 +2041,7 @@ export function ReviewPage({ active = true }: { active?: boolean } = {}) { description="The review passes found nothing actionable in this diff. That could mean the diff was clean or the target was too small to review." /> ) : ( - <div className="rounded-xl border border-white/[0.06] bg-white/[0.03] p-3 text-xs text-[#94A3B8]"> + <div className="rounded-xl border border-white/[0.06] bg-[var(--color-muted)]/40 p-3 text-xs text-[#94A3B8]"> Findings will appear here once the review completes. </div> )} @@ -1974,7 +2051,7 @@ export function ReviewPage({ active = true }: { active?: boolean } = {}) { })()} </SectionCard> - <details className="rounded-2xl border border-white/[0.08] bg-black/15"> + <details className={REVIEW_CARD_SURFACE}> <summary className="cursor-pointer list-none px-4 py-3"> <div className="flex flex-wrap items-center justify-between gap-3"> <div> @@ -1986,21 +2063,21 @@ export function ReviewPage({ active = true }: { active?: boolean } = {}) { </summary> <div className="space-y-2 px-4 pb-4"> {selectedDetail?.artifacts?.length ? selectedDetail.artifacts.map((artifact) => ( - <div key={artifact.id} className="rounded-xl border border-white/[0.06] bg-white/[0.03] p-3"> + <div key={artifact.id} className="rounded-xl border border-white/[0.06] bg-[var(--color-muted)]/40 p-3"> <div className="flex flex-wrap items-center gap-2"> <Chip className="text-[9px]">{toContextArtifactLabel(String(artifact.artifactType))}</Chip> <div className="text-sm font-semibold text-[#F5FAFF]">{artifact.title}</div> <span className="text-[11px] text-[#94A3B8]">{artifact.mimeType}</span> </div> - {artifact.contentText ? <pre className="mt-2 max-h-72 overflow-auto whitespace-pre-wrap rounded-lg border border-white/[0.06] bg-black/20 p-3 font-mono text-[11px] leading-relaxed text-[#D8E3F2]">{artifact.contentText}</pre> : null} + {artifact.contentText ? <pre className="mt-2 max-h-72 overflow-auto whitespace-pre-wrap rounded-lg border border-white/[0.06] bg-[var(--color-surface-recessed)]/80 p-3 font-mono text-[11px] leading-relaxed text-[#D8E3F2]">{artifact.contentText}</pre> : null} {artifact.metadata ? ( - <pre className="mt-2 max-h-72 overflow-auto whitespace-pre-wrap rounded-lg border border-white/[0.06] bg-black/20 p-3 font-mono text-[11px] leading-relaxed text-[#B7C4D7]"> + <pre className="mt-2 max-h-72 overflow-auto whitespace-pre-wrap rounded-lg border border-white/[0.06] bg-[var(--color-surface-recessed)]/80 p-3 font-mono text-[11px] leading-relaxed text-[#B7C4D7]"> {JSON.stringify(artifact.metadata, null, 2)} </pre> ) : null} </div> )) : ( - <div className="rounded-xl border border-white/[0.06] bg-white/[0.03] p-3 text-xs text-[#94A3B8]"> + <div className="rounded-xl border border-white/[0.06] bg-[var(--color-muted)]/40 p-3 text-xs text-[#94A3B8]"> No artifacts were captured for this run. </div> )} @@ -2009,7 +2086,7 @@ export function ReviewPage({ active = true }: { active?: boolean } = {}) { <SectionCard title="Review agent transcript" icon={Sparkle}> {selectedDetail?.chatSession ? ( - <div className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-white/[0.06] bg-white/[0.03] p-3"> + <div className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-white/[0.06] bg-[var(--color-muted)]/40 p-3"> <div> <div className="text-sm font-semibold text-[#F5FAFF]">Review agent transcript available</div> <div className="mt-1 text-xs text-[#94A3B8]"> @@ -2026,7 +2103,7 @@ export function ReviewPage({ active = true }: { active?: boolean } = {}) { </Button> </div> ) : selectedReviewerTranscripts.length > 0 ? ( - <div className="rounded-xl border border-white/[0.06] bg-white/[0.03] p-3"> + <div className="rounded-xl border border-white/[0.06] bg-[var(--color-muted)]/40 p-3"> <div className="flex flex-wrap items-center justify-between gap-3"> <div> <div className="text-sm font-semibold text-[#F5FAFF]">Specialist reviewer transcripts available</div> @@ -2052,7 +2129,7 @@ export function ReviewPage({ active = true }: { active?: boolean } = {}) { </div> </div> ) : ( - <div className="rounded-xl border border-white/[0.06] bg-white/[0.03] p-3 text-xs text-[#94A3B8]"> + <div className="rounded-xl border border-white/[0.06] bg-[var(--color-muted)]/40 p-3 text-xs text-[#94A3B8]"> No transcript session was linked to this run. </div> )} @@ -2074,38 +2151,23 @@ export function ReviewPage({ active = true }: { active?: boolean } = {}) { launch: { title: "Launch and saved runs", icon: Sparkle, - bodyClassName: "flex flex-col min-h-0", + bodyClassName: "flex flex-col min-h-0 bg-[var(--color-surface-recessed)]/20", children: launchPane, }, detail: { title: "Selected run", icon: MagnifyingGlass, - bodyClassName: "flex flex-col min-h-0", + bodyClassName: "flex flex-col min-h-0 bg-[var(--color-bg)]", children: detailPane, }, }; return ( <div className="flex h-full min-w-0 flex-col bg-bg text-fg"> - <div - className="flex h-16 shrink-0 items-center gap-4 px-6" - style={{ - background: "linear-gradient(180deg, rgba(167,139,250,0.06) 0%, rgba(167,139,250,0.01) 100%)", - borderBottom: "1px solid rgba(167,139,250,0.10)", - }} - > + <div className="flex h-16 shrink-0 items-center gap-4 border-b border-[var(--color-border)] bg-[var(--color-surface)]/90 px-6"> <div className="flex items-center gap-3"> - <div - className="flex items-center justify-center" - style={{ - width: 30, - height: 30, - borderRadius: 8, - background: "linear-gradient(135deg, rgba(167,139,250,0.18) 0%, rgba(139,92,246,0.08) 100%)", - border: "1px solid rgba(167,139,250,0.15)", - }} - > - <Sparkle size={16} weight="bold" className="text-[#A78BFA]" /> + <div className="flex h-[30px] w-[30px] items-center justify-center rounded-lg border border-sky-400/20 bg-sky-500/10"> + <MagnifyingGlass size={16} weight="regular" className="text-sky-400" /> </div> <div> <div className="text-[15px] font-bold tracking-tight text-[#FAFAFA]">Review</div> @@ -2113,22 +2175,9 @@ export function ReviewPage({ active = true }: { active?: boolean } = {}) { </div> </div> - <div className="flex flex-wrap items-center gap-2"> - <Chip className="text-[9px]">{runs.length} runs</Chip> - <Chip className="text-[9px]">{activeRuns} active</Chip> - <Chip className="text-[9px]">{totalFindings} findings</Chip> - <Chip className="text-[9px]">{selectedLane ? selectedLane.name : "No lane selected"}</Chip> - <Chip className="text-[9px]">{launchContext?.defaultBranchName ?? "default branch"}</Chip> - {loadingLaunch ? <Chip className="text-[9px]">loading context</Chip> : null} - </div> - <div className="ml-auto flex items-center gap-2"> - <Button size="sm" variant="ghost" onClick={() => void refreshLaunchContext()}> - <ArrowsClockwise size={12} weight="regular" className={cn(loadingLaunch && "animate-spin")} /> - Refresh context - </Button> - <Button size="sm" variant="outline" onClick={() => void refreshRuns()}> - <ArrowsClockwise size={12} weight="regular" /> + <Button size="sm" variant="outline" onClick={() => void refreshReviewTab()} disabled={refreshingTab}> + <ArrowsClockwise size={12} weight="regular" className={cn(refreshingTab && "animate-spin")} /> Refresh runs </Button> </div> diff --git a/apps/desktop/src/renderer/components/review/reviewTypes.ts b/apps/desktop/src/renderer/components/review/reviewTypes.ts index e9e69fa40..6c2605307 100644 --- a/apps/desktop/src/renderer/components/review/reviewTypes.ts +++ b/apps/desktop/src/renderer/components/review/reviewTypes.ts @@ -31,7 +31,6 @@ export type { ReviewResolvedCompareTarget, ReviewRun, ReviewRunArtifact, - ReviewRunBudgetConfig, ReviewRunConfig, ReviewRunDetail, ReviewRunStatus, diff --git a/apps/desktop/src/renderer/components/terminals/SessionCard.tsx b/apps/desktop/src/renderer/components/terminals/SessionCard.tsx index b3f54f2ba..85a80b5c5 100644 --- a/apps/desktop/src/renderer/components/terminals/SessionCard.tsx +++ b/apps/desktop/src/renderer/components/terminals/SessionCard.tsx @@ -1,19 +1,17 @@ import React from "react"; -import { GitBranch, Info, WarningCircle } from "@phosphor-icons/react"; +import { Info, WarningCircle } from "@phosphor-icons/react"; import type { LaneSummary, TerminalSessionSummary } from "../../../shared/types"; import { sessionStatusDot, sanitizeTerminalInlineText } from "../../lib/terminalAttention"; import { getStaleRunningCliSessionAgeHours, primarySessionLabel, preferredSessionLabel, - shortToolTypeLabel, } from "../../lib/sessions"; import { relativeTimeCompact } from "../../lib/format"; import { useSessionDelta } from "./useSessionDelta"; import { cn } from "../ui/cn"; import { MONO_FONT } from "../lanes/laneDesignTokens"; import { ToolLogo } from "./ToolLogos"; -import { iconGlyph } from "../graph/graphHelpers"; import { SmartTooltip } from "../ui/SmartTooltip"; import { ClaudeCacheTtlBadge } from "../shared/ClaudeCacheTtlBadge"; import { shouldShowClaudeCacheTtl } from "../../lib/claudeCacheTtl"; @@ -59,7 +57,6 @@ export const SessionCard = React.memo(function SessionCard({ const delta = useSessionDelta(session.id, true); const primaryText = primarySessionLabel(session); const previewLine = getPreviewLine(session, primaryText); - const laneMarker = lane?.icon ? iconGlyph(lane.icon) : <GitBranch size={11} weight="regular" />; const staleAgeHours = getStaleRunningCliSessionAgeHours(session); const isHighlighted = isSelected || isMultiSelected; const highlightedBorder = isHighlighted @@ -80,6 +77,9 @@ export const SessionCard = React.memo(function SessionCard({ idleSinceAt: session.chatIdleSinceAt, awaitingInput: session.runtimeState === "waiting-input", }); + const hasDeltaChips = Boolean(delta && (delta.insertions > 0 || delta.deletions > 0)); + const hasFooterMeta = + showClaudeCacheTimer || hasDeltaChips || (session.exitCode != null && session.exitCode !== 0); return ( <div className="group relative" onContextMenu={onContextMenu}> @@ -111,26 +111,17 @@ export const SessionCard = React.memo(function SessionCard({ <div className={cn("flex items-stretch", compact ? "gap-2 px-2 py-1" : "gap-2.5 px-2.5 py-2")}> {/* Logo — vertically centered against full card height */} <div className="flex shrink-0 self-stretch items-center justify-center"> - <ToolLogo toolType={session.toolType} size={compact ? 16 : 22} /> + <ToolLogo toolType={session.toolType} size={compact ? 18 : 26} /> </div> {/* Content — 3 rows */} <div className="min-w-0 flex-1"> - {/* Row 1: Status dot + Title + Relative time */} - <div className="flex items-center gap-1.5 min-w-0"> - <span - title={dot.label} - className={cn( - "shrink-0 rounded-full", - compact ? "h-1.5 w-1.5" : "h-2 w-2", - dot.cls, - dot.spinning && "animate-spin", - )} - /> + {/* Row 1: Title + status dot + relative time */} + <div className="flex items-center gap-2 min-w-0"> <span className={cn( "min-w-0 flex-1 truncate font-semibold text-fg/90", - compact ? "text-[10px]" : "text-[11px]", + compact ? "text-[11px]" : "text-[13px]", )} > {primaryText} @@ -144,6 +135,15 @@ export const SessionCard = React.memo(function SessionCard({ <WarningCircle size={11} weight="fill" /> </span> ) : null} + <span + title={dot.label} + className={cn( + "shrink-0 rounded-full", + compact ? "h-2.5 w-2.5" : "h-3 w-3", + dot.cls, + dot.spinning && "animate-spin", + )} + /> <span className={cn("shrink-0 text-muted-fg/45 tabular-nums", compact ? "text-[9px]" : "text-[10px]")}> {relativeTimeCompact(session.endedAt ?? session.startedAt)} </span> @@ -158,64 +158,49 @@ export const SessionCard = React.memo(function SessionCard({ </div> ) : null} - {/* Row 3: Tool type + Lane + Cache badge + Delta chips + Exit code */} - <div className={cn("flex items-center gap-1.5 min-w-0", compact ? "mt-px" : "mt-0.5")}> - <span className={cn("shrink-0 text-muted-fg/55", compact ? "text-[9px]" : "text-[10px]")}> - {shortToolTypeLabel(session.toolType)} - </span> - <span className="text-muted-fg/25">·</span> - <span - className="inline-flex shrink-0 items-center justify-center" - style={{ color: laneAccent ?? undefined }} - > - {laneMarker} - </span> - <span - className={cn("min-w-0 flex-1 truncate text-muted-fg/50", compact ? "text-[9px]" : "text-[10px]")} - style={laneAccent ? { color: laneAccent, opacity: 0.85 } : undefined} - > - {lane?.name ?? session.laneName} - </span> - - {showClaudeCacheTimer ? ( - <ClaudeCacheTtlBadge idleSinceAt={session.chatIdleSinceAt} /> - ) : null} + {/* Row 3: Cache badge + Delta chips + Exit code */} + {hasFooterMeta ? ( + <div className={cn("flex items-center gap-1.5 min-w-0", compact ? "mt-px" : "mt-0.5")}> + {showClaudeCacheTimer ? ( + <ClaudeCacheTtlBadge idleSinceAt={session.chatIdleSinceAt} /> + ) : null} - {delta ? ( - <> - {delta.insertions > 0 ? ( - <span - className="border border-emerald-500/30 bg-emerald-500/15 px-1 py-0.5 text-emerald-300 leading-none shrink-0" - style={DELTA_CHIP_STYLE} - > - +{delta.insertions} - </span> - ) : null} - {delta.deletions > 0 ? ( - <span - className="border border-red-500/30 bg-red-500/15 px-1 py-0.5 text-red-300 leading-none shrink-0" - style={DELTA_CHIP_STYLE} - > - -{delta.deletions} - </span> - ) : null} - </> - ) : null} + {hasDeltaChips && delta ? ( + <> + {delta.insertions > 0 ? ( + <span + className="border border-emerald-500/30 bg-emerald-500/15 px-1 py-0.5 text-emerald-300 leading-none shrink-0" + style={DELTA_CHIP_STYLE} + > + +{delta.insertions} + </span> + ) : null} + {delta.deletions > 0 ? ( + <span + className="border border-red-500/30 bg-red-500/15 px-1 py-0.5 text-red-300 leading-none shrink-0" + style={DELTA_CHIP_STYLE} + > + -{delta.deletions} + </span> + ) : null} + </> + ) : null} - {session.exitCode != null && session.exitCode !== 0 ? ( - <span - className={cn( - "px-1 py-0.5 leading-none shrink-0 border", - stoppedBySignal - ? "border-amber-500/30 bg-amber-500/15 text-amber-300" - : "border-red-500/30 bg-red-500/15 text-red-300", - )} - style={DELTA_CHIP_STYLE} - > - {stoppedBySignal ? "STOPPED" : `EXIT ${session.exitCode}`} - </span> - ) : null} - </div> + {session.exitCode != null && session.exitCode !== 0 ? ( + <span + className={cn( + "px-1 py-0.5 leading-none shrink-0 border", + stoppedBySignal + ? "border-amber-500/30 bg-amber-500/15 text-amber-300" + : "border-red-500/30 bg-red-500/15 text-red-300", + )} + style={DELTA_CHIP_STYLE} + > + {stoppedBySignal ? "STOPPED" : `EXIT ${session.exitCode}`} + </span> + ) : null} + </div> + ) : null} </div> </div> </button> diff --git a/apps/desktop/src/renderer/components/terminals/SessionListPane.tsx b/apps/desktop/src/renderer/components/terminals/SessionListPane.tsx index 80911b537..830ba7ddf 100644 --- a/apps/desktop/src/renderer/components/terminals/SessionListPane.tsx +++ b/apps/desktop/src/renderer/components/terminals/SessionListPane.tsx @@ -71,50 +71,57 @@ function StickyGroupHeader({ const isLane = variant === "lane"; const branchText = subLabel?.trim() ?? ""; const showBranchCluster = branchText.length > 0; - const laneTint = laneSurfaceTint(accentColor, isLane ? "default" : "soft"); + const laneTint = laneSurfaceTint(accentColor, isLane ? "pastel" : "soft"); + const laneLabelColor = isLane && laneTint.text ? laneTint.text : accentColor ?? undefined; return ( - <div className="mt-0.5 first:mt-0"> + <div className={cn(isLane ? "mb-2" : "mt-0.5 first:mt-0")}> <button type="button" className={cn( - "sticky top-0 z-10 flex w-full items-center gap-1.5 rounded-md text-left transition-colors backdrop-blur-xl cursor-pointer select-none", + "sticky top-0 z-10 flex w-full items-center text-left transition-colors backdrop-blur-xl cursor-pointer select-none", + isLane ? "gap-2 rounded-lg px-3 py-3" : "gap-1.5 rounded-md px-2 py-1.5", laneTint.text ? "hover:brightness-[1.03]" : "hover:bg-white/[0.04]", - isLane ? "px-2.5 py-2" : "px-2 py-1.5", )} style={{ background: laneTint.background, + border: isLane + ? laneTint.border ?? "1px solid rgba(255, 255, 255, 0.08)" + : undefined, borderBottom: isLane ? undefined : "1px solid rgba(255, 255, 255, 0.04)", + boxShadow: isLane + ? "0 1px 6px -2px rgba(0, 0, 0, 0.35), inset 0 1px 0 rgba(255, 255, 255, 0.04)" + : undefined, }} onClick={onToggleCollapsed} onContextMenu={onContextMenu} data-section-id={sectionId} > {isLane ? ( - <div className="flex w-full min-w-0 items-center gap-1.5"> + <div className="flex w-full min-w-0 items-center gap-2"> {collapsed ? ( - <CaretRight size={12} className="shrink-0 text-muted-fg/30" /> + <CaretRight size={14} className="shrink-0 text-muted-fg/35" /> ) : ( - <CaretDown size={12} className="shrink-0 text-muted-fg/30" /> + <CaretDown size={14} className="shrink-0 text-muted-fg/35" /> )} {icon} <span - className="min-w-0 flex-1 truncate text-[13px] font-semibold leading-tight text-fg/90" - style={accentColor ? { color: accentColor } : undefined} + className="min-w-0 flex-1 truncate text-[15px] font-semibold leading-snug text-fg/90" + style={laneLabelColor ? { color: laneLabelColor } : undefined} > {label} </span> {showBranchCluster ? ( <div - className="flex min-w-0 max-w-[min(50%,12rem)] shrink items-center gap-1" + className="flex min-w-0 max-w-[min(50%,12rem)] shrink items-center gap-1.5" style={{ color: "var(--color-muted-fg)" }} > - <GitBranch size={10} weight="regular" className="shrink-0 opacity-60" aria-hidden /> - <span className="truncate text-[10px] font-medium leading-tight text-muted-fg/75" title={branchText}> + <GitBranch size={12} weight="regular" className="shrink-0 opacity-60" aria-hidden /> + <span className="truncate text-[12px] font-medium leading-snug text-muted-fg/75" title={branchText}> {branchText} </span> </div> ) : null} - <span className="shrink-0 rounded-full bg-white/[0.06] px-1.5 py-0.5 text-[9px] font-medium text-muted-fg/50"> + <span className="shrink-0 rounded-full bg-white/[0.08] px-2 py-0.5 text-[11px] font-semibold text-muted-fg/60"> {count} </span> </div> @@ -142,7 +149,7 @@ function StickyGroupHeader({ <div className={cn( "space-y-px pb-0.5", - isLane && "pl-2", + isLane && "mt-1.5 pl-3", )} > {children} @@ -474,18 +481,19 @@ export const SessionListPane = React.memo(function SessionListPane({ ); const byLaneList = ( - <div className="px-1.5 pb-2"> + <div className="space-y-1 px-2 pb-3"> {orderedLanes.map((lane) => { const list = sessionsGroupedByLane?.get(lane.id) ?? []; const collapsed = workCollapsedLaneIds.includes(lane.id); const total = list.length; const laneAccent = lane.color ?? null; + const laneHeaderTint = laneSurfaceTint(laneAccent, "pastel"); const laneIcon = ( <span - className="inline-flex h-3.5 w-3.5 shrink-0 items-center justify-center" - style={{ color: laneAccent ?? "var(--color-muted-fg)" }} + className="inline-flex h-4 w-4 shrink-0 items-center justify-center" + style={{ color: laneHeaderTint.text ?? laneAccent ?? "var(--color-muted-fg)" }} > - {lane.icon ? iconGlyph(lane.icon) : <Terminal size={12} weight="regular" />} + {lane.icon ? iconGlyph(lane.icon) : <Terminal size={14} weight="regular" />} </span> ); return ( @@ -514,7 +522,7 @@ export const SessionListPane = React.memo(function SessionListPane({ <StickyGroupHeader key={laneId} sectionId={laneId} - icon={<GitBranch size={12} weight="regular" className="h-3.5 w-3.5 shrink-0 text-muted-fg/55" />} + icon={<GitBranch size={14} weight="regular" className="h-4 w-4 shrink-0 text-muted-fg/55" />} label={label} variant="lane" count={list.length} @@ -745,7 +753,7 @@ export const SessionListPane = React.memo(function SessionListPane({ {/* Session list */} <div - className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden pt-1" + className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-1 pt-2" data-tour="work.crossLaneSwitch" > {!hasAnySessions ? ( diff --git a/apps/desktop/src/renderer/components/terminals/WorkViewArea.tsx b/apps/desktop/src/renderer/components/terminals/WorkViewArea.tsx index b562a4f35..35f729d0e 100644 --- a/apps/desktop/src/renderer/components/terminals/WorkViewArea.tsx +++ b/apps/desktop/src/renderer/components/terminals/WorkViewArea.tsx @@ -1058,7 +1058,7 @@ function ModeSwitcherPills({ onShowDraftKind: (kind: WorkDraftKind) => void; }) { return ( - <div className="ade-liquid-glass-pill inline-flex items-center gap-0.5 rounded-full p-1"> + <div className="ade-liquid-glass-pill inline-flex items-center gap-1 rounded-full p-1.5"> {MODE_OPTIONS.map((opt) => { const active = draftKind === opt.kind; const Icon = opt.Icon; @@ -1074,7 +1074,7 @@ function ModeSwitcherPills({ <button type="button" className={cn( - "inline-flex min-h-[36px] items-center gap-2 rounded-full px-3.5 py-2 text-[12px] font-medium transition-all", + "inline-flex min-h-[48px] items-center gap-2.5 rounded-full px-5 py-2.5 text-[14px] font-medium transition-all", active && "ade-work-tab-active", )} style={{ @@ -1085,7 +1085,7 @@ function ModeSwitcherPills({ }} onClick={() => onShowDraftKind(opt.kind)} > - <Icon size={15} weight="regular" className="shrink-0 opacity-80" /> + <Icon size={18} weight="regular" className="shrink-0 opacity-80" /> {opt.label} </button> </SmartTooltip> @@ -1694,7 +1694,7 @@ export function WorkViewArea({ {visibleSessions.length === 0 ? ( <div className="flex h-full flex-col"> - <div className="flex shrink-0 items-center justify-center py-3"> + <div className="flex shrink-0 items-center justify-center pb-3 pt-6"> <ModeSwitcherPills draftKind={draftKind} onShowDraftKind={onShowDraftKind} /> </div> <div className="min-h-0 flex-1"> @@ -1754,7 +1754,7 @@ export function WorkViewArea({ {!activeSession ? ( <div className="absolute inset-0 flex flex-col"> - <div className="flex shrink-0 items-center justify-center py-3"> + <div className="flex shrink-0 items-center justify-center pb-3 pt-6"> <ModeSwitcherPills draftKind={draftKind} onShowDraftKind={onShowDraftKind} /> </div> <div className="min-h-0 flex-1"> diff --git a/apps/desktop/src/renderer/components/ui/TabBackground.tsx b/apps/desktop/src/renderer/components/ui/TabBackground.tsx index a15b719a3..d6a478c3d 100644 --- a/apps/desktop/src/renderer/components/ui/TabBackground.tsx +++ b/apps/desktop/src/renderer/components/ui/TabBackground.tsx @@ -3,7 +3,7 @@ import { useLocation } from "react-router-dom"; import { cn } from "./cn"; function primaryTabPath(pathname: string): string { - const roots = ["/project", "/lanes", "/files", "/work", "/graph", "/prs", "/history", "/automations", "/missions", "/cto", "/vm", "/settings"]; + const roots = ["/project", "/lanes", "/files", "/work", "/graph", "/prs", "/review", "/history", "/automations", "/missions", "/cto", "/vm", "/settings"]; return roots.find((root) => pathname === root || pathname.startsWith(`${root}/`)) ?? pathname; } @@ -14,6 +14,7 @@ const routeToTabBg: Record<string, string> = { "/work": "ade-tab-bg-terminals", "/graph": "ade-tab-bg-graph", "/prs": "ade-tab-bg-prs", + "/review": "ade-tab-bg-review", "/history": "ade-tab-bg-history", "/automations": "ade-tab-bg-automations", "/missions": "ade-tab-bg-missions", @@ -29,6 +30,7 @@ const routeToTint: Record<string, string> = { "/work": "tab-tint-work", "/graph": "tab-tint-graph", "/prs": "tab-tint-prs", + "/review": "tab-tint-review", "/history": "tab-tint-history", "/automations": "tab-tint-automations", "/missions": "tab-tint-missions", diff --git a/apps/desktop/src/renderer/components/usage/HeaderUsageControl.tsx b/apps/desktop/src/renderer/components/usage/HeaderUsageControl.tsx index 08332b512..3c3e42960 100644 --- a/apps/desktop/src/renderer/components/usage/HeaderUsageControl.tsx +++ b/apps/desktop/src/renderer/components/usage/HeaderUsageControl.tsx @@ -237,10 +237,10 @@ export function HeaderUsageControl() { </div> ) : ( <Gauge - size={14} + size={18} weight="regular" - className={cn("opacity-80", hasErrors && "animate-pulse")} - style={hasErrors ? { color: "#F59E0B" } : undefined} + className={cn(hasErrors && "animate-pulse")} + style={{ color: hasErrors ? "#F59E0B" : "var(--color-accent)" }} /> )} </button> diff --git a/apps/desktop/src/renderer/index.css b/apps/desktop/src/renderer/index.css index aa4257ca9..65aa77e1f 100644 --- a/apps/desktop/src/renderer/index.css +++ b/apps/desktop/src/renderer/index.css @@ -324,6 +324,7 @@ .tab-tint-automations { --tab-tint-rgb: 249, 115, 22; } .tab-tint-missions { --tab-tint-rgb: 59, 130, 246; } .tab-tint-settings { --tab-tint-rgb: 113, 113, 122; } +.tab-tint-review { --tab-tint-rgb: 56, 189, 248; } /* ═══════════════════════════════════════════════════════════ Base Styles @@ -2140,6 +2141,24 @@ button:active, [role="button"]:active { animation: ade-particle-drift 30s ease-in-out infinite; } +/* Review — inspection / findings (sky #38bdf8) */ +.ade-tab-bg-review { + background: + linear-gradient(135deg, + rgba(56, 189, 248, 0.03) 0%, + transparent 35%), + radial-gradient(ellipse 80% 60% at 12% 88%, rgba(20, 184, 166, 0.025) 0%, transparent 55%); +} + +.ade-tab-bg-review::before { + content: ""; + position: absolute; + inset: 0; + background: + radial-gradient(ellipse 50% 40% at 88% 18%, rgba(56, 189, 248, 0.04) 0%, transparent 70%); + opacity: 0.85; +} + /* 8. PRs — Launch Pad / Upward Purple Flow (purple #A78BFA) */ .ade-tab-bg-prs { background: @@ -2491,8 +2510,8 @@ button:active, [role="button"]:active { } .ade-chat-message-card-user { - background: - linear-gradient(135deg, color-mix(in srgb, var(--chat-accent) 76%, #ffffff 6%) 0%, color-mix(in srgb, var(--chat-accent) 60%, #7c3aed 40%) 50%, color-mix(in srgb, var(--chat-accent) 58%, #4c1d95 42%) 100%); + background: var(--chat-user-bubble-gradient, + linear-gradient(135deg, color-mix(in srgb, var(--chat-accent) 76%, #ffffff 6%) 0%, color-mix(in srgb, var(--chat-accent) 60%, #7c3aed 40%) 50%, color-mix(in srgb, var(--chat-accent) 58%, #4c1d95 42%) 100%)); border-color: color-mix(in srgb, var(--chat-accent) 26%, rgba(255, 255, 255, 0.14)); box-shadow: inset 0 1px 0 rgba(255,255,255,0.12), diff --git a/apps/desktop/src/renderer/onboarding/tours/laneWorkPaneHighlightsTour.ts b/apps/desktop/src/renderer/onboarding/tours/laneWorkPaneHighlightsTour.ts index 61104e26b..d0139f662 100644 --- a/apps/desktop/src/renderer/onboarding/tours/laneWorkPaneHighlightsTour.ts +++ b/apps/desktop/src/renderer/onboarding/tours/laneWorkPaneHighlightsTour.ts @@ -9,18 +9,11 @@ export const laneWorkPaneHighlightsTour: Tour = { steps: [ { id: "h.lwp.what", - target: "", + target: '[data-tour="work.viewArea"]', title: "Lane work pane", body: "Work inside a lane — chats, CLI tools, and shells all run in that lane's worktree, nothing else.", docUrl: docs.chatOverview, - }, - { - id: "h.lwp.entry", - target: '[data-tour="work.entryOptions"]', - title: "Three ways in", - body: "New Chat talks to a worker. CLI Tool wraps commands in AI. New Shell drops you into a terminal.", - docUrl: docs.terminals, - placement: "bottom", + placement: "top", }, { id: "h.lwp.next", diff --git a/apps/desktop/src/renderer/onboarding/tours/laneWorkPaneTour.ts b/apps/desktop/src/renderer/onboarding/tours/laneWorkPaneTour.ts index 8a4984967..3708b982d 100644 --- a/apps/desktop/src/renderer/onboarding/tours/laneWorkPaneTour.ts +++ b/apps/desktop/src/renderer/onboarding/tours/laneWorkPaneTour.ts @@ -7,61 +7,19 @@ export const laneWorkPaneTour: Tour = { route: "/lanes", steps: [ { - target: '[data-tour="work.toolbar"]', - title: "The lane's command center", - body: "This is where you start work *inside* a specific lane. Anything you launch from here — AI chats, scripts, terminals — runs in this lane's copy of the project, so it can't mess up your real one or any other lane.", - docUrl: docs.chatOverview, - placement: "bottom", - }, - { - target: '[data-tour="work.entryOptions"]', - title: "Three ways to get help", - body: "Three buttons start three kinds of helpers — an AI chat, a command-line AI tool, or a plain terminal. Pick the one that fits the kind of help you need.", - docUrl: docs.chatOverview, - placement: "bottom", - }, - { - target: '[data-tour="lanes.workNewChat"]', - title: "AI chat", - body: "Best for *\"please figure this out and do it\"* tasks. Example: *\"Why does the login screen flash on Safari? Find it and fix it in this lane.\"* The AI reads your files, makes changes, and shows you what it did.", + target: '[data-tour="work.viewArea"]', + title: "Lane work surface", + body: "This is where work runs inside the selected lane — AI chats, CLI tools, and terminals all appear here in this lane's copy of the project.", docUrl: docs.chatOverview, - placement: "bottom", - }, - { - target: '[data-tour="lanes.workCliTool"]', - title: "Command-line AI tool", - body: "Best when the work is command-shaped — running scripts, processing files. Example: *\"Run the tests for the files I changed and summarize what failed.\"*", - docUrl: docs.terminals, - placement: "bottom", - }, - { - target: '[data-tour="lanes.workNewShell"]', - title: "Plain terminal", - body: "Just a regular terminal — no AI involved. Already pointed at this lane's folder, so commands you type only affect this lane's copy.", - docUrl: docs.terminals, - placement: "bottom", - }, - { - target: '[data-tour="work.laneName"]', - title: "A safety check", - body: "This label tells you which lane you're inside. **Always glance here before starting a chat or running a command** — whatever you do affects this lane only. Switch lanes if you wanted a different one.", - docUrl: docs.lanesOverview, - placement: "bottom", + placement: "top", }, { - target: '[data-tour="work.sessionCount"]', - title: "How busy is this lane?", - body: "How many chats and terminals are open inside this lane right now. Zero is normal until you start something.", + target: '[data-tour="work.focusToolbar"]', + title: "Tabs and layout", + body: "Switch between open sessions, change tab vs grid layout, and focus the session you want to work in.", docUrl: docs.chatOverview, placement: "bottom", }, - { - target: '[data-tour="work.viewArea"]', - title: "Where it all shows up", - body: "Once you start a chat or terminal, it appears here. If it's empty, that's fine — this lane just doesn't have anything running yet.", - docUrl: docs.chatOverview, - placement: "top", - }, ], }; diff --git a/apps/desktop/src/shared/adeDeeplinkFooter.ts b/apps/desktop/src/shared/adeDeeplinkFooter.ts index b28caec59..8fcd4d79c 100644 --- a/apps/desktop/src/shared/adeDeeplinkFooter.ts +++ b/apps/desktop/src/shared/adeDeeplinkFooter.ts @@ -8,7 +8,6 @@ import { buildDeeplink } from "./deeplinks"; -const MARKER_OPEN = "<!-- ade:link v=1"; const MARKER_OPEN_RE = /<!--\s*ade:link\s+v=\d+[^>]*-->/i; const MARKER_CLOSE = "<!-- /ade:link -->"; const MARKER_CLOSE_RE = /<!--\s*\/ade:link\s*-->/i; @@ -78,9 +77,13 @@ export function ensureAdeDeeplinkFooter( if (openIdx >= 0) { const closeMatch = MARKER_CLOSE_RE.exec(safeBody.slice(openIdx)); if (closeMatch) { - const before = safeBody.slice(0, openIdx); + const before = safeBody.slice(0, openIdx).trimEnd(); const after = safeBody.slice(openIdx + closeMatch.index + closeMatch[0].length); - return `${before.trimEnd()}\n\n${block}${after.startsWith("\n") ? after : (after ? `\n${after}` : "")}`.trimEnd() + "\n"; + let tail: string; + if (!after) tail = ""; + else if (after.startsWith("\n")) tail = after; + else tail = `\n${after}`; + return `${before}\n\n${block}${tail}`.trimEnd() + "\n"; } } const trimmed = safeBody.trimEnd(); diff --git a/apps/desktop/src/shared/types/review.ts b/apps/desktop/src/shared/types/review.ts index 5d8c12cd2..d7653488a 100644 --- a/apps/desktop/src/shared/types/review.ts +++ b/apps/desktop/src/shared/types/review.ts @@ -139,16 +139,6 @@ export type ReviewResolvedCompareTarget = { branchRef: string | null; }; -export type ReviewRunBudgetConfig = { - unlimited?: boolean; - maxFiles: number; - maxDiffChars: number; - maxPromptChars: number; - maxFindings: number; - maxFindingsPerPass?: number; - maxPublishedFindings?: number; -}; - export type ReviewRunConfig = { compareAgainst: ReviewCompareAgainstTarget; selectionMode: ReviewSelectionMode; @@ -156,7 +146,6 @@ export type ReviewRunConfig = { modelId: string; reasoningEffort: string | null; codexFastMode?: boolean; - budgets: ReviewRunBudgetConfig; publishBehavior: ReviewPublishBehavior; }; diff --git a/apps/web/api/open.ts b/apps/web/api/open.ts index 27e3f9ef4..a513208e1 100644 --- a/apps/web/api/open.ts +++ b/apps/web/api/open.ts @@ -15,12 +15,10 @@ * is served as a flat static asset and won't loop back into this function. */ -type Vercel = { - query: Record<string, string | string[] | undefined>; -}; +type VercelQuery = Record<string, string | string[] | undefined>; type VercelReq = { - query: Vercel["query"]; + query: VercelQuery; url?: string; headers: Record<string, string | string[] | undefined>; }; @@ -72,7 +70,7 @@ function pickQuery(value: string | string[] | undefined): string { return value ?? ""; } -function parseTarget(query: Vercel["query"]): OpenTarget { +function parseTarget(query: VercelQuery): OpenTarget { const type = pickQuery(query.type).toLowerCase(); if (type === "lane") { const laneId = pickQuery(query.id); @@ -82,9 +80,8 @@ function parseTarget(query: Vercel["query"]): OpenTarget { const repo = pickQuery(query.repo); const branch = pickQuery(query.branch); if (repo && branch) { - const prRaw = pickQuery(query.pr); - const pr = prRaw ? Number(prRaw) : undefined; - return Number.isInteger(pr) && pr! > 0 + const pr = Number(pickQuery(query.pr)); + return Number.isInteger(pr) && pr > 0 ? { kind: "branch", repo, branch, pr } : { kind: "branch", repo, branch }; } @@ -217,7 +214,8 @@ async function loadShellFromSelf(req: VercelReq): Promise<string | null> { export default async function handler(req: VercelReq, res: VercelRes): Promise<void> { const target = parseTarget(req.query); - const search = (req.url ?? "").includes("?") ? `?${(req.url ?? "").split("?")[1]}` : ""; + const queryIdx = (req.url ?? "").indexOf("?"); + const search = queryIdx >= 0 ? (req.url ?? "").slice(queryIdx) : ""; const canonical = `https://ade.app/open${search}`; const { title, description } = describe(target); diff --git a/apps/web/src/app/pages/OpenPage.tsx b/apps/web/src/app/pages/OpenPage.tsx index b55fb2384..5e1eb1f52 100644 --- a/apps/web/src/app/pages/OpenPage.tsx +++ b/apps/web/src/app/pages/OpenPage.tsx @@ -24,9 +24,10 @@ function parseQuery(search: string): OpenTarget { const repo = params.get("repo") ?? ""; const branch = params.get("branch") ?? ""; if (repo && branch) { - const prRaw = params.get("pr"); - const pr = prRaw ? Number(prRaw) : undefined; - return Number.isInteger(pr) && pr! > 0 ? { kind: "branch", repo, branch, pr } : { kind: "branch", repo, branch }; + const pr = Number(params.get("pr") ?? ""); + return Number.isInteger(pr) && pr > 0 + ? { kind: "branch", repo, branch, pr } + : { kind: "branch", repo, branch }; } } if (type === "pr") { diff --git a/docs/features/deeplinks/README.md b/docs/features/deeplinks/README.md index dfda715f2..923604c06 100644 --- a/docs/features/deeplinks/README.md +++ b/docs/features/deeplinks/README.md @@ -203,6 +203,29 @@ is first created, the footer initially carries the branch link only; once the PR number is known, a follow-up patch re-renders the block with the PR link included. +## Channel handling + +Only the **Stable** channel claims `ade://` as the OS-default handler. +Beta and Alpha builds still install the single-instance lock and the +`open-url` / `second-instance` listeners (so a manual `duti` binding +still routes deeplinks to them), but they skip +`app.setAsDefaultProtocolClient` so they don't fight Stable for the +binding on machines where multiple channels are installed. + +Source builds default to the same skip behavior. To make a source dev +build claim the binding for testing, set +`ADE_REGISTER_DEEPLINK_HANDLER=1` in its environment before launch: + +```bash +ADE_REGISTER_DEEPLINK_HANDLER=1 npm run dev +``` + +This is the supported workaround for testing deeplinks against a dev +build when Stable / Beta / Alpha are also installed. The gate lives in +`apps/desktop/src/main/main.ts` (channel detection + env override) and +the registration mechanics live in `protocolHandler.ts` behind the +`claimAsDefault` option. + ## Gotchas - **Register the protocol handler before `app.whenReady()`.** Otherwise diff --git a/goal.md b/goal.md index 64fdf6e47..583da7aa3 100644 --- a/goal.md +++ b/goal.md @@ -1,378 +1,804 @@ -# Goal: Cross-process ownership for ADE sessions +# Goal: ADE TUI — Universal Click + Multi-Chat Middle Pane -You are picking this up mid-investigation. The previous agent traced two related bugs to a single root cause and started landing the fix. This document is the complete plan — finish it end-to-end. Worktree is `/Users/admin/Projects/ADE/.ade/worktrees/deeplinks-d52aa89e/`. Do not switch repos or lanes. +You are implementing two interlocking TUI features for the ADE CLI. Read this whole brief before touching code. Worktree is `/Users/admin/Projects/ADE/.ade/worktrees/deeplinks-d52aa89e/`. Do not switch lanes. --- -## 1. The bugs the user is hitting +## Why we're doing this -Two user-visible symptoms, same underlying cause: +The ADE TUI today gives you one chat at a time in the middle pane. Power users running agents across multiple lanes constantly want to watch two, three, or six chats stream in parallel — currently they tile multiple TUI instances side by side, which is wasteful and forces them to manage N copies of the same drawer + right pane. -### Symptom A — "frozen snapshot after PTY resume" +The other long-standing irritation: mouse support is partial. The drawer, chat text selection, and a handful of right-pane targets accept clicks; most things (model picker, approval prompts, slash/mention palettes, lane-details actions, lane-delete form, prompt-history nav) don't. Add multi-chat without expanding click coverage and the new feature is unusable for mouse-driven users. -User opens the Work tab on a CLI session whose status is `stopped`/`ended`, types a message, hits send. The PTY actually resumes in the main process (visible in the TUI, and the OS-level `claude --session-id ...` process is alive in `ps aux`), but the desktop renderer stays on `ClosedCliSessionSurface` showing a frozen pre-resume snapshot. The composer placeholder reads "Type to continue this Claude Code session..." and never flips to a live `TerminalView`. +Ship them together: +1. **Universal click** — every keyboard handler gets a matching mouse hit-test, with hover-highlight so users can see what's clickable before committing. +2. **Multi-chat middle pane** — middle splits into 1–6 chat tiles in fixed grids; chats can be from any lane; focus drives prompt routing and right-pane context. -### Symptom B — "session randomly appears as Stopped while it's still running" - -User has the ADE desktop app and `ade code` (TUI) both open on the same lane. A Claude Code session is running fine. Without any user action, the desktop view randomly switches to `ClosedCliSessionSurface` ("Stopped", "Ended <timestamp>", "Type to continue this Claude Code session…"). The TUI still shows the session alive and producing output. The OS-level `claude --session-id <id>` process is still alive. Only the desktop's DB row says the session ended. +--- -The user has also seen this hit Codex CLI sessions and Cursor CLI sessions, not just Claude Code. +## TL;DR feature spec + +**Universal click** +- Add hit-testing for every existing keyboard action across drawer, right pane, palettes, prompt area, and overlays. +- Build a `HitTestRegistry` so components register their bounds + handlers declaratively; future components opt in for free. +- Enable terminal mouse mode 1003 (any-event) so we receive `move` events and can highlight whatever's under the cursor. + +**Multi-chat middle pane** +- Middle pane state: `multiView = { tiles: Array<{ sessionId, laneId }>, focusedIndex: number } | null`. +- Up to 6 tiles. Hardcoded layouts: 1=full, 2=2 cols, 3=3 cols, 4=2×2, 5=2-top/3-bot, 6=3×2. +- Chats can come from any lane (cross-lane mixing). State is global to the TUI and **ephemeral** — never persisted across restart. +- Add flow: shortcut while chat pane focused → enter "add-mode" (sidebar focused, non-sidebar regions dimmed, banner overlay) → arrow-nav across all lanes' chats → `Enter` adds, `Esc` cancels. +- Remove flow: shortcut or click `×` in tile header. Dropping below 2 tiles exits multi-view entirely. +- No duplicate chats — adding an already-open chat refocuses the existing tile. +- Focused tile is the source of truth: bottom prompt routes to it; right pane / status / sidebar lane highlight follow it. +- Concurrent streaming: every open tile streams events live whether focused or not. + +**Extras (locked picks)** +- `×` affordance in each tile header for mouse-driven removal. +- Status-bar grid mini-map (e.g. `▣▢ / ▢▢`). +- Per-tile prompt history recall (up/down only cycles the focused tile's prompts). +- Drag a sidebar chat onto the middle pane to add (bypasses add-mode). --- -## 2. Root cause — single-owner assumption in a multi-process world +## Current architecture you must understand first + +The TUI is **Ink v5.2.1** (React for terminal). Entry point: `apps/ade-cli/src/tuiClient/cli.tsx`. Top-level component: `AdeCodeApp` in `apps/ade-cli/src/tuiClient/app.tsx`. That single file is ~8000 lines and owns the entire app's state, focus, mouse parsing, and layout. The rest of `tuiClient/` is split into focused components and helpers. -ADE runs the same project across **multiple OS processes simultaneously**, all opening the same `.ade/ade.db` SQLite file: +### Layout (read `app.tsx` lines ~7626–7800) + +Root is `<Box flexDirection="column" height={rows}>`: + +``` +┌────────────────────────────────────────────────────────┐ +│ <Header /> fixed 1-2 │ +├────────────────────────────────────────────────────────┤ +│ {goal-banner conditional} 0 or 1 │ +├──────────┬──────────────────────────────┬──────────────┤ +│ Drawer │ (middle: <ChatView />) │ RightPane │ +│ 32 cols │ flexGrow:1 │ 30–42 cols │ +│ (left) │ │ │ +│ │ │ │ +├──────────┴──────────────────────────────┴──────────────┤ +│ <Box borderStyle="round"> prompt input </Box> │ +├────────────────────────────────────────────────────────┤ +│ <ModelStatus /> <FooterControls /> fixed 1-2 │ +└────────────────────────────────────────────────────────┘ +``` -- The desktop app (electron) — executes `cli.cjs serve --socket ~/.ade-beta/sock/ade.sock` as its main; it IS both the GUI and an `ade serve` daemon. -- A separate per-lane `ade serve` daemon (e.g. `/tmp/ade-runtime-lane-<lane>.sock`). -- The TUI's embedded runtime when `ade code` is invoked — `apps/ade-cli/src/bootstrap.ts` calls `createPtyService` (imported from `apps/desktop/src/main/services/pty/ptyService.ts`) inside the TUI's own process. -- Mobile is a remote client of one of the above. +Constants in `app.tsx` ~1508–1511: `DRAWER_PANE_WIDTH = 32`, right pane is computed `30–42`, middle gets the remainder (`min 24`). -I verified live with `ps aux`, `lsof`, and `sqlite3` queries (see §10 for the exact commands you can re-run). The DB has rows for `claude` CLI sessions marked `status='disposed'` while the corresponding `claude --session-id <id>` OS process is still in the process table. Different process, different ptyService map, different opinion about who's alive. +The chat row budget (~line 2514): +```ts +const chatRowBudget = Math.max(4, rows - 8 - (promptRows.length - 1) - statusRows - goalBannerRows); +``` +You'll need to subdivide this budget across grid rows when multi-view is active. -The DB schema has **no concept of which OS process owns which row**. Every process treats `terminal_sessions` as if it were the sole owner: +### Focus / pane state (read `app.tsx` ~1733–1776) -1. **`sessionService.reconcileStaleRunningSessions`** (`apps/desktop/src/main/services/sessions/sessionService.ts:483`) blindly disposes every `status='running'` row at process startup. Desktop main calls it at `apps/desktop/src/main/main.ts:1939`. TUI bootstrap calls it at `apps/ade-cli/src/bootstrap.ts:450`. When `ade code` starts while the desktop has a live PTY, the TUI's reconcile silently marks the desktop's session disposed. +```ts +type PaneFocus = "chat" | "drawer" | "details"; +const [activePane, setActivePane] = useState<PaneFocus>("chat"); +const activePaneRef = useRef<PaneFocus>("chat"); +``` -2. **`ptyService.dispose({ptyId, sessionId})`** (`apps/desktop/src/main/services/pty/ptyService.ts:3497`) has an "orphan" branch: if `ptyId` doesn't match any entry in the calling process's PTY map but `sessionId` does match a DB row, it calls `sessionService.end(..., status='disposed')` to "clean up." Callers from the renderer (`ChatTerminalDrawer.tsx:387-392`, `useWorkSessions.ts:1221`) trigger this on tab unmount / drawer teardown / stop button. If the PTY lives in another process, the "orphan dispose" fires against a perfectly live session. +The single `useInput` handler (~line 3400+) branches on `activePane` to dispatch keystrokes. **You will extend `PaneFocus` with `"addMode"`** and add `multiView.focusedIndex` for tile focus (the chat pane itself owns the sub-focus). -3. **`closeEntry`** (`apps/desktop/src/main/services/pty/ptyService.ts:2080`) is fine — it only runs when the calling process's own `pty.onExit` fires. It is in-process by definition. Don't touch it for ownership reasons. +### Mouse (read `app.tsx` `parseTerminalMouseInput` + hit-test helpers ~1557–1620) -I tried two stopgap fixes earlier in this lane: +The custom parser handles SGR (`\x1b[<…M/m`), X10, and RXVT escape sequences. It's enabled at startup by writing mode-enable sequences to stdout. Today's modes used: +- `\x1b[?1000h` — basic click tracking +- `\x1b[?1002h` — drag tracking +- `\x1b[?1006h` — SGR extended (for x/y > 223) -1. `sessionService.reopen`/`reattach` now emit `emitChanged({reason: "meta-updated"})` so the renderer learns about resume immediately (was silent — sessions/sessionService.ts:725, 745). -2. `reconcileStaleRunningSessions` got an `activityThresholdMs` (default 5 min) so rows whose `last_output_at` is recent are skipped (sessions/sessionService.ts:483). -3. `TerminalsPage.handleContinueCliSession` optimistically upserts the new session snapshot returned by `pty.sendToSession` (terminals/TerminalsPage.tsx:371). -4. `useWorkSessions` exposes `upsertSessionSnapshot` (terminals/useWorkSessions.ts:790). +**You will add `\x1b[?1003h`** (any-event tracking, i.e. mouse-move without buttons) and disable it cleanly on exit. Reference: <https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h2-Mouse-Tracking>. -These help but are heuristics. They do not fix Symptom B in general — `last_output_at` is session-level activity and is flaky for idle sessions. The dispose path still fires across processes with no guard. Replace the `activityThresholdMs` guard with a proper ownership check (see §3, §5). Keep the optimistic upsert and the `emitChanged` additions — they're good independent of the ownership work. +The current hit-test pattern is a family of per-pane helpers with hardcoded Y offsets, e.g.: ---- +```ts +// app.tsx ~1557 +export function laneDetailsActionIndexForMouseLine(y, actionCount) { + if (y == null || actionCount <= 0) return null; + const firstActionLine = 18; + const index = y - firstActionLine; + return index >= 0 && index < actionCount ? index : null; +} +``` + +This pattern repeats in `formFieldIndexForMouseLine`, `setupPaneRowIndexForMouseLine`, `subagentIndexForPaneLine`. **You will replace these with a unified registry (see Feature 1 below).** Don't delete the old helpers in one go — migrate component by component; once a component is on the registry, delete its old helper. -## 3. Architecture finding that surprised me — agent chats already do this right +### Chat model (read `apps/desktop/src/shared/types/chat.ts` ~725–773) + +A chat is an `AgentChatSession`: +```ts +type AgentChatSession = { + id: string; // session ID (unique per chat) + laneId: string; // lane it belongs to + provider: "claude" | "codex" | "cursor" | "droid" | "opencode"; + model: string; + status: "active" | "idle" | "ended"; + sessionProfile?: …; + permissionMode?: …; + interactionMode?: …; + // … +}; +``` -While investigating, I expected to find that the TUI and desktop each ran their own `agentChatService` and never converged. They don't. The user proved it: started a `claude-chat` in the TUI, watched it appear live and in-sync in the desktop in the same lane. The mechanism: +### Single-source streaming today (read `app.tsx` ~580, ~1190) -- `ade serve` is a JSON-RPC daemon (`apps/ade-cli/src/adeRpcServer.ts`). It hosts the runtime services (agentChatService, sessionService, etc.). -- The desktop app embeds and connects to it through `localRuntimeConnectionPool` (`apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.ts`) and `runtimeRpcClient`. -- The TUI client (`apps/ade-cli/src/tuiClient/connection.ts:335 spawnDaemon`, `:356 connectAttachedSocket`) auto-connects to an existing daemon or spawns one detached (`spawn(... detached: true, stdio: "ignore")`), then makes RPC calls. -- The daemon owns the SDK agent-chat session. It pushes events out via `runtimeEvents.subscribe` (`apps/ade-cli/src/adeRpcServer.ts:7254` and surrounding) to every connected client. Desktop and TUI both subscribe and render the same event stream. -- The renderer preload (`apps/desktop/src/preload/preload.ts`) has a helper `callProjectRuntimeActionIfBound("chat", "sendMessage", ...)` that tries the daemon first and falls back to local IPC if no daemon is bound. This is why chats sync. +```ts +const [streaming, setStreaming] = useState(false); // GLOBAL flag — must die +… +connection.onChatEvent((envelope) => { + if (envelope.sessionId !== activeSessionIdRef.current) { + refreshState({ hydrateHistory: false }); // silently drop the event UI-side + return; + } + if (event.type === "status" && event.turnStatus === "started") setStreaming(true); + … +}); +``` -**This is exactly the "stage 2" architecture you want for CLI PTYs.** It already exists for chats. It does not exist for raw PTYs — each process still spawns and owns its own PTYs locally. That's the asymmetry that makes Symptom B uniquely a CLI/PTY problem. +This is the single biggest blocker for multi-chat. Two changes: +1. `streaming: boolean` → `streamingBySessionId: Record<string, boolean>` (or a `Map`). +2. The early-return filter widens from `=== activeSessionIdRef.current` to `openSessionIds.has(envelope.sessionId)`, where `openSessionIds` is `multiView ? new Set(tiles.map(t=>t.sessionId)) : new Set([activeSessionIdRef.current])`. -So the work splits cleanly into: +### ChatView (read `apps/ade-cli/src/tuiClient/components/ChatView.tsx`) -- **Tier 1** — add process-level ownership tracking and gate dispose/reconcile on it. Stops the immediate bleeding for *every* row type (CLI and chat). -- **Tier 2** — move PTY ownership into the daemon, the same way agent chats already work. Makes CLI sessions truly cross-surface live. +```ts +function ChatView({ + events, activeSession, streaming, interrupted, + expandedLineIds, maxRows, scrollOffsetRows, selection, + width, laneName, projectName, provider, notices, … +}) +``` -The user wants both. Do both. +No local state for messages — fully driven from parent. **You will extend the props with `focused?: boolean` (for tile-level focus rendering) and `onRemove?: () => void` (for the `×` affordance).** Internal logic computes `RenderedChatRow` from aggregated blocks (`aggregateChatBlocks()` in `aggregate.ts`). ---- +### Submit / prompt routing (read `app.tsx` `submitPrompt` ~4050) -## 4. What I already changed in this worktree +```ts +async function submitPrompt(value: string) { + // ...validate, parse slash commands, extract attachments + const sessionId = activeSessionIdRef.current; // <-- this line is the only routing + await sendChatMessage(conn, sessionId, text, attachments); + setStreaming(true); + await refreshState(); +} +``` -Don't redo these — finish on top of them. All in `/Users/admin/Projects/ADE/.ade/worktrees/deeplinks-d52aa89e/`. Verify with `git diff` before continuing. +In multi-view, replace the `sessionId` line with: +```ts +const sessionId = multiView + ? multiView.tiles[multiView.focusedIndex].sessionId + : activeSessionIdRef.current; +``` -Schema: -- `apps/desktop/src/main/services/state/kvDb.ts` — added `owner_pid INTEGER` column on `terminal_sessions`, added `runtime_processes` table, indexes. (See the diff for exact migration ALTERs.) +### Persistence (read `apps/ade-cli/src/tuiClient/state.ts`) -New service (skeleton, **not wired anywhere yet**): -- `apps/desktop/src/main/services/runtime/processRegistryService.ts` — `createProcessRegistryService({db, logger, pid?, role, projectRoot?, heartbeatIntervalMs?, livenessWindowMs?})` with `start/heartbeat/stop/listLivePids/isPidLive/listAllProcesses/pruneStale`. Heartbeats default 5s; liveness window default 15s. **This file already exists. Read it before extending.** +`~/.ade/ade-code-state.json` stores `lastChatByLane` and `lastChatByProjectLane` (per-lane last-active session). Debounced 500ms saves via `saveAdeCodeProjectState()`. -Earlier-lane fixes that should stay: -- `sessionService.reopen/reattach` now emit `emitChanged({reason: "meta-updated"})` — `apps/desktop/src/main/services/sessions/sessionService.ts:725-742, 745-765`. -- `sessionService.reconcileStaleRunningSessions` got `activityThresholdMs` heuristic (`apps/desktop/src/main/services/sessions/sessionService.ts:483`). **Tier 1 replaces this heuristic with proper ownership — see §5.4.** -- `TerminalsPage.handleContinueCliSession` does optimistic `work.upsertSessionSnapshot(result.session)` (`apps/desktop/src/renderer/components/terminals/TerminalsPage.tsx:371`). -- `useWorkSessions` exposes `upsertSessionSnapshot` (`apps/desktop/src/renderer/components/terminals/useWorkSessions.ts:790`). -- Two new sessionService tests for the activity-threshold guard. **Update them when you replace the heuristic with ownership.** +**Do not** add `multiView` here. Multi-view is in-memory only. --- -## 5. Tier 1 — ownership + heartbeat + gated mutations +## Feature 1: Universal click — detailed design + +### Step 1: Hit-test registry + +Create `apps/ade-cli/src/tuiClient/hitTestRegistry.ts`: -### 5.1 Schema (DONE in §4) +```ts +export type HitRect = { x: number; y: number; w: number; h: number }; + +export type HitTarget = { + id: string; // stable id for the click target + rect: HitRect; // absolute terminal coordinates (1-based) + onClick?: (ev: MouseEvent) => void; + onHover?: (hovered: boolean) => void; + zIndex?: number; // higher wins on overlap (default 0) +}; + +export interface HitTestRegistry { + register(target: HitTarget): void; + unregister(id: string): void; + hitTest(x: number, y: number): HitTarget | null; // for clicks + hoverTest(x: number, y: number): HitTarget | null; // same lookup, used for hover state + clear(): void; +} + +// Backed by a flat array; linear scan is plenty fast (<500 targets, called per mouse event ≤ 60Hz). +export function createHitTestRegistry(): HitTestRegistry { … } +``` + +Wire one registry instance into `app.tsx` via a React context (`HitTestProvider`). Components grab the registry through a `useHitTest()` hook and call `register` in a `useEffect` (cleanup unregisters). Use `useLayoutEffect` if you find render-order races between mouse event and registration. + +Inside `app.tsx`'s `parseTerminalMouseInput` consumer: +```ts +case "click": { + const target = registry.hitTest(mouse.x, mouse.y); + if (target?.onClick) target.onClick(mouse); + break; +} +case "move": { + const target = registry.hoverTest(mouse.x, mouse.y); + if (target?.id !== currentHoveredId) { + currentHovered?.onHover?.(false); + target?.onHover?.(true); + currentHoveredId = target?.id ?? null; + setHoveredId(currentHoveredId); // triggers re-render so highlight updates + } + break; +} +``` -`terminal_sessions.owner_pid INTEGER NULL` + `runtime_processes(pid PRIMARY KEY, role TEXT, project_root TEXT, started_at TEXT, last_seen TEXT)` + the two indexes. Migrations are idempotent ALTER/CREATE-IF-NOT-EXISTS — safe to re-run on existing DBs. Verify the columns exist with: +### Step 2: Enable mode 1003 (mouse move) +In the mouse-enable block in `app.tsx`: +```ts +process.stdout.write("\x1b[?1003h"); // any-event tracking (includes mouse-move) +``` +And on shutdown: +```ts +process.stdout.write("\x1b[?1003l"); ``` -sqlite3 /path/to/.ade/ade.db "pragma table_info(terminal_sessions);" -sqlite3 /path/to/.ade/ade.db "pragma table_info(runtime_processes);" + +Note: mode 1003 generates events for *every* cursor movement, which can be heavy. Throttle the hover-test path with `requestAnimationFrame`-equivalent (e.g. setImmediate-based coalescing) if profiling shows it dominating render cost. + +### Step 3: Hover styling + +Components decide their own hover render. Standard pattern: +```tsx +const [hovered, setHovered] = useState(false); +useHitTest({ id, rect, onClick, onHover: setHovered }); +return <Box backgroundColor={hovered ? "blueBright" : undefined}>…</Box>; ``` -### 5.2 ProcessRegistry service (DONE skeleton, needs wiring) +Use Ink's `Box backgroundColor` or `Text inverse` for the highlight — pick whichever reads better with the existing palette. Keep it subtle; the hover is meant to teach, not strobe. -File: `apps/desktop/src/main/services/runtime/processRegistryService.ts`. API is fixed — extend tests around it, don't reshape unless you find a bug. +### Step 4: Migrate components (full parity list) -**Roles:** `"desktop-main" | "ade-serve-daemon" | "tui-runtime"`. +Convert each of these to register hit-test rects and call their existing keyboard action on click. Source columns reference where the keyboard handler lives today. -**Wiring (NOT YET DONE — do this):** +| Pane | Component / handler | File:line (approx) | +|-------------------|------------------------------------------------------|-------------------------------| +| Right pane | Model picker rows + tab rail + favorite toggle (`f`) | `app.tsx` 7878–7937 | +| Right pane | Model picker search (`/`) | `app.tsx` 7940–7970 | +| Right pane | Lane-details actions (`Return`, t-toggle file tree) | `app.tsx` 7979–8044 | +| Right pane | List pane rows + scroll | `app.tsx` 8048–8056 | +| Right pane | Lane-delete form radio (`1/2/3`, space/`f`) | `app.tsx` 7750–7765 | +| Overlay | Approval prompt accept/decline (`a`/`d`) | `app.tsx` 7732–7735 | +| Overlay | Mention palette nav + insert (up/down/Tab/Enter) | `app.tsx` 8118–8142 | +| Overlay | Slash palette nav + insert | `app.tsx` 8118–8142 | +| Bottom prompt | Prompt-history recall (k/j or up/down in vim mode) | `app.tsx` 7597–7603 | +| Bottom prompt | Prompt submit (vim normal mode) | `app.tsx` 7605–7608 | +| Footer | Status-line clickable model swap, etc. | `ModelStatus`, `FooterControls` | +| Header | Project / lane / chat title clickable to open palette| `Header` component | -1. **Desktop main** — instantiate in `apps/desktop/src/main/main.ts`, in the project context init (near where `sessionService` is created, around line 1935 today). Role `"desktop-main"`. Project root = the current project root. Call `start()` immediately. Tear it down on context close. **Critical:** the desktop's main process and the `ade serve --socket /Users/admin/.ade-beta/sock/ade.sock` it runs are *the same OS process* (PID 53089 in my investigation — see §10). One heartbeat row, not two. +For each: the keyboard handler stays; clicks dispatch the same intent. After migration, delete the old `*IndexForMouseLine` helpers (~`app.tsx` 1557–1620). -2. **TUI runtime bootstrap** — `apps/ade-cli/src/bootstrap.ts` around line 446 where `sessionService` is created. Role `"tui-runtime"`. `start()` before the reconcile call. +### Step 5: Don't break what works -3. **`ade serve` daemon** — if `cli.cjs serve` is launched as a standalone daemon (the lane runtime daemon, e.g. PID 88995 in my investigation), it goes through the same `bootstrap.ts` path, so the wiring above covers it. But verify: trace `apps/ade-cli/src/cli.ts` line 10201 (`Promise.all([import("./bootstrap"), import("./adeRpcServer")])`) and confirm `createAdeRuntime` is what `serve` ends up calling. If yes, single wiring suffices. If not, wire `serve` separately. +Things already mouse-wired (don't touch their behavior, just port them to the registry on the way through): -4. **Stop on exit** — wire `processRegistry.stop()` to `process.on('beforeExit', ...)` and to the desktop's `before-quit` electron event. Best-effort; if the process crashes the row will simply go stale and reconcile cleans it up. Don't block exit on this. +| Target | File:line | +|-----------------------------------------------|-------------------------| +| Drawer lanes/chats select | `app.tsx` 7096–7131 | +| Chat transcript text select (click/drag/release) | `app.tsx` 7181–7225 | +| Chat scroll wheel | `app.tsx` 7239–7245 | +| Prompt focus click | `app.tsx` 7083–7094 | +| Lane detail action click (existing partial) | `app.tsx` 7141–7144 | +| Form field click | `app.tsx` 7156–7170 | +| Subagent transcript list click | `app.tsx` 7175–7176 | -### 5.3 `sessionService` — accept and read `owner_pid` +--- + +## Feature 2: Multi-chat middle pane — detailed design + +### Step 1: State -File: `apps/desktop/src/main/services/sessions/sessionService.ts`. +In `app.tsx`: +```ts +type MultiViewTile = { sessionId: string; laneId: string }; +type MultiViewState = { tiles: MultiViewTile[]; focusedIndex: number }; + +const [multiView, setMultiView] = useState<MultiViewState | null>(null); +const multiViewRef = useRef<MultiViewState | null>(null); +useEffect(() => { multiViewRef.current = multiView; }, [multiView]); -- `create({...})` (line 657 today) — add `ownerPid?: number | null` to the args type. Persist into the new column. Default `null` if not provided (legacy callers). -- `mapRow` and `SESSION_COLUMNS` — add `owner_pid as ownerPid`. Surface `ownerPid` on `TerminalSessionSummary` and `TerminalSessionDetail` (types live in `apps/desktop/src/shared/types/sessions.ts`). -- `reattach(args)` (line 745) — add optional `ownerPid` arg. When provided, set `owner_pid = ?` in the UPDATE. Reattach is the resume path; the new owner is whoever called `ptyService.create` (see §5.5). -- `clearOwnerPid(sessionId)` — new method, sets `owner_pid = null`. Used by tier 2 when the daemon takes over a previously-local session. -- `setOwnerPid(sessionId, pid)` — new method. Used by tests and migration helpers. +// Per-tile transient state +const [streamingBySessionId, setStreamingBySessionId] = useState<Record<string, boolean>>({}); +const [scrollBySessionId, setScrollBySessionId] = useState<Record<string, number>>({}); +const [selectionBySessionId, setSelectionBySessionId] = useState<Record<string, ChatTextSelection | null>>({}); +const [promptHistoryBySessionId, setPromptHistoryBySessionId] = useState<Record<string, string[]>>({}); -### 5.4 `sessionService.reconcileStaleRunningSessions` — gate on ownership +// Add-mode +const [addMode, setAddMode] = useState<{ cursorLaneId: string; cursorChatId: string | null } | null>(null); +``` -Replace the `activityThresholdMs` guard with a proper ownership check. Signature: +### Step 2: Streaming refactor +Replace every reference to global `streaming` with `streamingBySessionId[sessionId]`. The places that set `setStreaming(true/false)` (in `submitPrompt`, `onChatEvent`) update the record instead: ```ts -reconcileStaleRunningSessions({ - endedAt?: string; - status?: TerminalSessionStatus; - excludeToolTypes?: string[]; - liveOwnerPids: Set<number>; // NEW — caller passes this in -}): number +setStreamingBySessionId(prev => ({ ...prev, [sessionId]: true })); ``` -Semantics: +Widen the event subscription filter: +```ts +const openSessionIds = multiView + ? new Set(multiView.tiles.map(t => t.sessionId)) + : new Set([activeSessionIdRef.current]); + +if (!openSessionIds.has(envelope.sessionId)) { + refreshState({ hydrateHistory: false }); + return; +} +``` -- A row is "stale" iff `status='running'` AND (`owner_pid IS NULL` OR `owner_pid NOT IN (liveOwnerPids)`). -- The `owner_pid IS NULL` branch catches pre-migration rows (always treated as orphan — they came from before ownership existed). -- Build the SQL with a parameterized `NOT IN (?,?,?...)` clause, with care for the empty-set case (use `NOT IN (-1)` sentinel to keep SQL valid). -- Emit `emitChanged({reason: "meta-updated"})` for each disposed sessionId so renderers refresh. +### Step 3: Layout math -Callers (`main.ts:1939`, `bootstrap.ts:450`) become: +Create `apps/ade-cli/src/tuiClient/multiChatLayout.ts`: ```ts -processRegistry.start(); -const reconciledSessions = sessionService.reconcileStaleRunningSessions({ - status: "disposed", - liveOwnerPids: processRegistry.listLivePids(), - // bootstrap.ts also passes its existing excludeToolTypes -}); +export type TileRect = { x: number; y: number; w: number; h: number }; + +const PATTERNS: Record<number, ReadonlyArray<{ row: number; col: number; rowSpan: number; colSpan: number; rows: number; cols: number }>> = { + 1: [{ row: 0, col: 0, rowSpan: 1, colSpan: 1, rows: 1, cols: 1 }], + 2: [ + { row: 0, col: 0, rowSpan: 1, colSpan: 1, rows: 1, cols: 2 }, + { row: 0, col: 1, rowSpan: 1, colSpan: 1, rows: 1, cols: 2 }, + ], + 3: [ + { row: 0, col: 0, rowSpan: 1, colSpan: 1, rows: 1, cols: 3 }, + { row: 0, col: 1, rowSpan: 1, colSpan: 1, rows: 1, cols: 3 }, + { row: 0, col: 2, rowSpan: 1, colSpan: 1, rows: 1, cols: 3 }, + ], + 4: [ /* 2x2 */ + { row: 0, col: 0, rowSpan: 1, colSpan: 1, rows: 2, cols: 2 }, + { row: 0, col: 1, rowSpan: 1, colSpan: 1, rows: 2, cols: 2 }, + { row: 1, col: 0, rowSpan: 1, colSpan: 1, rows: 2, cols: 2 }, + { row: 1, col: 1, rowSpan: 1, colSpan: 1, rows: 2, cols: 2 }, + ], + 5: [ /* 2 top, 3 bottom — row 0 has 2 cells over a 6-col virtual grid (each spans 3); row 1 has 3 cells (each spans 2) */ + { row: 0, col: 0, rowSpan: 1, colSpan: 3, rows: 2, cols: 6 }, + { row: 0, col: 3, rowSpan: 1, colSpan: 3, rows: 2, cols: 6 }, + { row: 1, col: 0, rowSpan: 1, colSpan: 2, rows: 2, cols: 6 }, + { row: 1, col: 2, rowSpan: 1, colSpan: 2, rows: 2, cols: 6 }, + { row: 1, col: 4, rowSpan: 1, colSpan: 2, rows: 2, cols: 6 }, + ], + 6: [ /* 3x2 */ + { row: 0, col: 0, rowSpan: 1, colSpan: 1, rows: 2, cols: 3 }, + { row: 0, col: 1, rowSpan: 1, colSpan: 1, rows: 2, cols: 3 }, + { row: 0, col: 2, rowSpan: 1, colSpan: 1, rows: 2, cols: 3 }, + { row: 1, col: 0, rowSpan: 1, colSpan: 1, rows: 2, cols: 3 }, + { row: 1, col: 1, rowSpan: 1, colSpan: 1, rows: 2, cols: 3 }, + { row: 1, col: 2, rowSpan: 1, colSpan: 1, rows: 2, cols: 3 }, + ], +}; + +export function computeTileRects(n: 1|2|3|4|5|6, width: number, height: number): TileRect[] { + const pat = PATTERNS[n]; + const colW = Math.floor(width / pat[0].cols); + const rowH = Math.floor(height / pat[0].rows); + return pat.map(p => ({ + x: p.col * colW, + y: p.row * rowH, + w: p.colSpan * colW, + h: p.rowSpan * rowH, + })); +} ``` -**Delete** `activityThresholdMs` and the two tests that exercised it. Add the new tests in §5.7. +Edge cases: +- If `width < 2 * MIN_TILE_W` for an n>=2 layout, refuse to render the grid and surface a notice in the status line ("terminal too narrow for multi-view"). Suggest `MIN_TILE_W = 30`, `MIN_TILE_H = 8` — tune by feel. +- Round-down division leaves a few unused cells at the right/bottom edge. That's fine; the parent `<Box>` clips. + +### Step 4: `MultiChatGrid` component + +Create `apps/ade-cli/src/tuiClient/components/MultiChatGrid.tsx`: + +```tsx +type Props = { + tiles: MultiViewTile[]; + focusedIndex: number; + width: number; + height: number; + eventsBySessionId: Record<string, AgentChatEventEnvelope[]>; + sessionBySessionId: Record<string, AgentChatSessionSummary>; + streamingBySessionId: Record<string, boolean>; + scrollBySessionId: Record<string, number>; + selectionBySessionId: Record<string, ChatTextSelection | null>; + onFocusTile: (index: number) => void; + onRemoveTile: (index: number) => void; +}; + +export function MultiChatGrid(props: Props) { + const rects = useMemo(() => + computeTileRects(props.tiles.length as 1|2|3|4|5|6, props.width, props.height), + [props.tiles.length, props.width, props.height]); + + return ( + <Box position="relative" width={props.width} height={props.height}> + {props.tiles.map((t, i) => { + const rect = rects[i]; + const isFocused = i === props.focusedIndex; + return ( + <Box + key={t.sessionId} + position="absolute" + marginLeft={rect.x} + marginTop={rect.y} + width={rect.w} + height={rect.h} + > + <ChatView + events={props.eventsBySessionId[t.sessionId] ?? []} + activeSession={props.sessionBySessionId[t.sessionId]} + streaming={!!props.streamingBySessionId[t.sessionId]} + scrollOffsetRows={props.scrollBySessionId[t.sessionId] ?? 0} + selection={props.selectionBySessionId[t.sessionId] ?? null} + width={rect.w} + maxRows={rect.h} + focused={isFocused} + onRemove={() => props.onRemoveTile(i)} + // … other passthrough props + /> + </Box> + ); + })} + </Box> + ); +} +``` + +Note: Ink supports `position="absolute"` via Yoga's positioning model; verify with a quick smoke test before relying on it. If it doesn't, render tiles in normal flow with computed line padding (the chat-row-budget pattern already does this). + +### Step 5: Per-tile prompt history -### 5.5 `ptyService` — write owner_pid on every spawn, gate dispose on ownership +```ts +// when submitting +setPromptHistoryBySessionId(prev => ({ + ...prev, + [sessionId]: [...(prev[sessionId] ?? []).slice(-99), text], // cap at 100 +})); + +// when up-arrow recalls +const history = promptHistoryBySessionId[focusedSessionId] ?? []; +``` + +### Step 6: Add-mode -File: `apps/desktop/src/main/services/pty/ptyService.ts`. +State machine: +``` +normal ──(Ctrl+A, only when activePane === "chat")──> addMode +addMode ──(Esc)──> normal (no changes) +addMode ──(Enter on highlighted chat)──> normal + multiView updated +``` -- Constructor takes a new option `processRegistry: ProcessRegistryService` (or just `ownerPid: () => number` if you want the minimal coupling — the registry is the source of truth either way). -- `create(args)` (line 2392) — when calling `sessionService.create(...)` (line ~2485), pass `ownerPid: registry.pid`. -- `create(args)` — when the resume branch calls `sessionService.reattach(...)` (line 2654), pass `ownerPid: registry.pid`. The resuming process becomes the new owner. **Watch out:** there's another reattach call at line 2436 (live-attached-entry branch) for the rare case where a live PTY is found for an existingSession. That branch should also write `ownerPid: registry.pid`. -- `dispose({ptyId, sessionId})` (line 3497) — **the orphan branch is the dangerous one.** Right now if `ptyId` is unknown but `sessionId` resolves, it disposes the row unconditionally. New behavior: if `session.ownerPid != null && session.ownerPid !== registry.pid && registry.isPidLive(session.ownerPid)`, **skip the dispose** and emit a `warn` log (`pty.dispose_skipped_owned_by_peer`). Return the existing PtyCreateResult shape (caller already handles missing PTY). The "PTY in our map" branch (line 3534 onwards) is fine — if we have the entry, we own it by definition. -- `closeEntry` (line 2080) — no change. It only fires from `pty.onExit` in our process, so by definition we own the row. Leave it alone. +Render: when `addMode` is set, the layout wraps every non-drawer region in a `<Box>` that applies dim styling (Ink's `<Text dimColor>` on all descendants, or a custom `<Dim>` wrapper). Insert a top banner row: +``` + Pick a chat to add · ↵ Add · Esc Cancel +``` -### 5.6 `agentChatService` — write owner_pid on chat row creation +The drawer renders normally but with a separate cursor state (`addMode.cursorLaneId / cursorChatId`) so navigation in add-mode does NOT touch `activeLaneId` / `activeSessionId`. Reuse the existing drawer rendering function — just thread the alternate cursor in. -File: `apps/desktop/src/main/services/chat/agentChatService.ts`. The chat rows are also `terminal_sessions` (toolType `claude-chat`, `codex-chat`, etc.). +Add behavior: +```ts +function addTileToGrid(sessionId: string, laneId: string) { + setMultiView(prev => { + // If chat is already in grid, refocus its tile, no-op the add. + if (prev) { + const existingIdx = prev.tiles.findIndex(t => t.sessionId === sessionId); + if (existingIdx >= 0) return { ...prev, focusedIndex: existingIdx }; + if (prev.tiles.length >= 6) return prev; // cap + return { + tiles: [...prev.tiles, { sessionId, laneId }], + focusedIndex: prev.tiles.length, // focus the newly added tile + }; + } + // Bootstrapping into multi-view: include the currently-active chat as tile 0 + return { + tiles: [ + { sessionId: activeSessionIdRef.current, laneId: activeLaneIdRef.current }, + { sessionId, laneId }, + ], + focusedIndex: 1, + }; + }); +} +``` -- Wherever `sessionService.create({...})` is called from `createSession` / `ensureIdentitySession`, pass `ownerPid: registry.pid`. -- `resumeSession` (line 19584 today) and `endSession` (line 8489) — no ownership flip needed for the in-process case (we own it because the runtime is in this process). But: in the multi-daemon world the user has, a chat row created by daemon A may get a `resumeSession` call from daemon B. **Tier 2 fixes this properly by routing to whichever process owns the row.** For tier 1, do an ownership guard in `resumeSession`: if the row's `owner_pid` is a live peer pid (not us), throw `Error("Chat session is owned by another ADE process; cannot resume from here.")`. The renderer should fall back to using the existing daemon RPC path (`callProjectRuntimeActionIfBound`). +### Step 7: Remove -### 5.7 Tests +```ts +function removeTile(index: number) { + setMultiView(prev => { + if (!prev) return prev; + const tiles = prev.tiles.filter((_, i) => i !== index); + if (tiles.length < 2) { + // Exit multi-view; surviving tile (if any) becomes the active chat in single mode + if (tiles[0]) { + selectActiveLaneId(tiles[0].laneId); + selectActiveSessionId(tiles[0].sessionId); + } + return null; + } + const focusedIndex = Math.min(prev.focusedIndex, tiles.length - 1); + return { tiles, focusedIndex }; + }); +} +``` -Add to `apps/desktop/src/main/services/sessions/sessionService.test.ts`: +### Step 8: Focus follows tile -1. `create` with `ownerPid` persists and `get(id)` returns it on `ownerPid`. -2. `reconcileStaleRunningSessions` with a `liveOwnerPids = {12345}` set leaves rows with `owner_pid=12345` alone, sweeps rows with `owner_pid=99999`, sweeps rows with `owner_pid=null` (legacy). -3. `reattach` sets `owner_pid` to the new owner. +When `multiView` is set and `multiView.focusedIndex` changes, sync the rest of the TUI: +```ts +useEffect(() => { + if (!multiView) return; + const tile = multiView.tiles[multiView.focusedIndex]; + if (!tile) return; + if (tile.laneId !== activeLaneIdRef.current) selectActiveLaneId(tile.laneId); + if (tile.sessionId !== activeSessionIdRef.current) selectActiveSessionId(tile.sessionId); +}, [multiView]); +``` -New test file `apps/desktop/src/main/services/runtime/processRegistryService.test.ts`: +This makes the right pane, status bar, and sidebar lane highlight all reflect the focused tile's lane automatically — they already read from `activeLaneId` / `activeSessionId`. -1. `start` inserts a row. -2. `heartbeat` advances `last_seen`. -3. `listLivePids` includes own pid even before first heartbeat. -4. `listLivePids` excludes a peer pid whose `last_seen` is older than the liveness window. -5. `isPidLive` matches the listLivePids predicate. -6. `pruneStale` deletes peer rows older than 10x liveness window, keeps own. -7. `stop` removes own row. +### Step 9: Drag-to-add -Add to `apps/desktop/src/main/services/pty/ptyService.test.ts`: +In the drawer chat row component, register a `onDragStart` via the registry (extend `HitTarget` with optional `onDragStart`/`onDrop`). When mouse-drag begins on a chat row and ends inside the middle pane bounds, invoke `addTileToGrid(sessionId, laneId)`. Mouse mode 1002 (drag) is already enabled — coordinates flow through the parser as `drag` events. -1. `dispose({ptyId: "missing", sessionId})` against a row owned by a live peer is a no-op (does not call `sessionService.end`); emits the warn log. -2. `dispose({ptyId: "missing", sessionId})` against a row owned by us OR a dead peer DOES call `sessionService.end`. -3. `create` writes `owner_pid` on the row. +### Step 10: Keybindings -### 5.8 Edge cases for tier 1 +Pick free chords. Suggested: +- `Ctrl+G` — toggle add-mode (only fires when `activePane === "chat"`) +- `Ctrl+W` — remove focused tile +- `Tab` — cycle focused tile within multi-view (existing `Tab` cycles panes; bind it to tile-cycle when in chat pane *and* multi-view is active; otherwise current behavior) +- Mouse: click anywhere inside a tile = focus that tile; click on the `×` = remove -- **Same OS process opens DB twice (sqlite WAL etc.).** Not an issue here — process pid is unique per OS process. -- **Pid reuse after a crash.** A new ADE process happens to grab the same pid as a dead one. The dead one's `runtime_processes` row will have a stale `last_seen` — `listLivePids` won't include it. As soon as `processRegistry.start()` writes the new row, the pid maps to the live process. The narrow race: between the new process starting and writing its first row, a sibling could mistake the stale row's pid (now the new pid) for "still dead." Acceptable; reconcile is best-effort and the new owner will heartbeat within seconds. -- **DB in `journal_mode=delete` (current state).** Confirmed live via `sqlite3 .../ade.db "pragma journal_mode;"` → `delete`. Concurrent writers serialize via SQLite's reserved/exclusive lock. WAL would be better for concurrent readers + writers; that's a separate task. For tier 1, the heartbeat write contention is bounded (one row per process, 5s cadence) and SQLite's `busy_timeout` handles brief stalls. -- **Long-running processes that pause heartbeats during sync GC.** Liveness window is 3x heartbeat interval (15s default) to absorb single missed beats. Tune if you see false positives. -- **Mobile / sync workers.** They also open the DB. Decide their role: probably `"sync-worker"` and treat like any other process. Don't let them dispose anything they didn't create — gate on owner_pid as elsewhere. -- **Stale `runtime_processes` rows after a hard crash.** `pruneStale` cleans them up; call it from each `processRegistry.start()` once at boot. -- **`ChatTerminalDrawer.tsx:387-392` `disposeTabsOnUnmount`** — this is the renderer calling `pty.dispose` from a React effect cleanup. The renderer doesn't know the owner. The main-process `dispose` now refuses to dispose rows it doesn't own, so this is safe even when the renderer unmounts a tab whose backing PTY lives in a daemon. Same goes for `useWorkSessions.stopRuntime` and the chat drawer's "session deleted" branch. +Document these in the footer and in `FooterControls` rendering. --- -## 6. Tier 2 — daemon owns PTYs +## UI specification (depth) -Tier 1 makes everything safe. Tier 2 makes it *interactive across surfaces*. The pattern already exists for chats. Mirror it for PTYs. +### Tile chrome -### 6.1 Add PTY RPC methods to `adeRpcServer` +Single-line header at the top of each tile: -File: `apps/ade-cli/src/adeRpcServer.ts`. Open the file, find where `sync.*` and `modelPicker.*` methods are dispatched (around line 7763, 7826), and add a similar `pty.*` block. +``` +┌ lane-slug / chat-title ●─────────────── ×┐ +``` -Methods needed (mirror `ptyService` interface): +- Leading `┌` corner from Ink's border. +- `lane-slug` truncated with ellipsis if needed to keep `chat-title` visible. +- ` / ` separator. +- `chat-title` (the session's display name) truncated as needed. +- Trailing space + `●` when `streaming === true`, otherwise space. +- Right-aligned `×` (1 cell) when `onRemove` is provided. Register this single cell as a separate hit target so click works precisely. -- `pty.create` → wraps `ptyService.create(args)` and returns `PtyCreateResult` + the new session row. -- `pty.write` → wraps `ptyService.write({ptyId, data})`. -- `pty.resize` → wraps `ptyService.resize({ptyId, cols, rows})`. -- `pty.dispose` → wraps `ptyService.dispose({ptyId, sessionId})`. -- `pty.sendToSession` → wraps `ptyService.sendToSession(args)`. This is the critical one for the "resume an ended CLI session" flow. -- `pty.list` → returns `service.enrichSessions(...)` snapshot. +### Focused tile vs unfocused -The daemon already has an `eventBuffer`. Add a new event category `"pty"` (alongside `"runtime"`, `"mission"`, etc.) and push every `broadcastData`/`broadcastExit` event into it: +- **Unfocused tile**: `borderStyle="round"` (Ink built-in), header text default color. +- **Focused tile**: `borderStyle="double"`, header text in cyan (`<Text color="cyan">…</Text>`). +- Content (message body) is **not dimmed** on unfocused tiles — keep them fully readable so background streaming is visible at a glance. -```ts -const ptyService = createPtyService({ - ..., - broadcastData: (event) => { - runtime.eventBuffer.push({ timestamp: nowIso(), category: "pty", payload: { type: "pty_data", event } }); - }, - broadcastExit: (event) => { - runtime.eventBuffer.push({ timestamp: nowIso(), category: "pty", payload: { type: "pty_exit", event } }); - }, -}); -``` +### Six grid layouts (wireframes) -Clients subscribe via the existing `runtimeEvents.subscribe` mechanism with `category: "pty"` and get the live stream. +Imagine the middle pane is ~80 cols wide × ~24 rows tall. Borders are illustrative; real rendering uses Ink's box borders. -### 6.2 Route the desktop's PTY calls through the daemon +**N=1 — full:** +``` +┌─ lane-a / chat-1 ●────────────────────────────────────────────────────────┐ +│ │ +│ (full chat body) │ +│ │ +└────────────────────────────────────────────────────────────────────────── ×┘ +``` -File: `apps/desktop/src/preload/preload.ts`. Look at how `chat.send` works (around line 5173). The pattern: +**N=2 — 2 cols:** +``` +┌─ lane-a/chat-1 ●─────────────┐ ╔ lane-b/chat-2 ●═════════════════╗ +│ │ ║ ║ +│ left chat │ ║ right chat (focused) ║ +│ │ ║ ║ +└──────────────────────────── ×┘ ╚══════════════════════════════ ×╝ +``` -```ts -const runtime = await callProjectRuntimeActionIfBound<void>("chat", "sendMessage", { args }); -if (!runtime.handled) await ipcRenderer.invoke(IPC.agentChatSend, args); +**N=3 — 3 cols:** +``` +┌ lane-a/c1 ●────┐ ┌ lane-b/c2 ●────┐ ╔ lane-c/c3 ●════════╗ +│ │ │ │ ║ ║ +│ │ │ │ ║ focused ║ +└────────────── ×┘ └────────────── ×┘ ╚════════════════ ×╝ ``` -Add the same pattern for every `pty.*` method exposed on `window.ade.pty`. When the daemon is bound, route through it. Otherwise fall back to local IPC (the existing behavior). +**N=4 — 2×2:** +``` +┌ lane-a/c1 ●────────────────┐ ╔ lane-b/c2 ●═══════════════╗ +│ │ ║ (focused) ║ +└────────────────────────── ×┘ ╚═════════════════════════ ×╝ +┌ lane-c/c3 ●────────────────┐ ┌ lane-a/c4 ●────────────────┐ +│ │ │ │ +└────────────────────────── ×┘ └────────────────────────── ×┘ +``` + +**N=5 — 2 top + 3 bottom:** +``` +┌ lane-a/c1 ●────────────────────────┐ ┌ lane-b/c2 ●───────────────────────┐ +│ │ │ │ +│ │ │ │ +└────────────────────────────────── ×┘ └─────────────────────────────────×┘ +┌ lane-c/c3 ●──────┐ ╔ lane-d/c4 ●═════╗ ┌ lane-e/c5 ●──────┐ +│ │ ║ (focused) ║ │ │ +└──────────────── ×┘ ╚═══════════════ ×╝ └──────────────── ×┘ +``` -The IPC handlers in `apps/desktop/src/main/services/ipc/registerIpc.ts` (lines 7572-7589 for pty methods) stay as the local fallback — they call `ctx.ptyService` directly. The desktop-main `ptyService` becomes a legacy fallback that only fires when the daemon isn't reachable. +**N=6 — 3×2:** +``` +┌ lane-a/c1 ●─────┐ ┌ lane-b/c2 ●─────┐ ╔ lane-c/c3 ●═══════╗ +│ │ │ │ ║ focused ║ +└─────────────── ×┘ └─────────────── ×┘ ╚═══════════════ ×╝ +┌ lane-d/c4 ●─────┐ ┌ lane-e/c5 ●─────┐ ┌ lane-f/c6 ●───────┐ +│ │ │ │ │ │ +└─────────────── ×┘ └─────────────── ×┘ └───────────────── ×┘ +``` -### 6.3 Renderer subscribes to daemon PTY events +### Add-mode -`TerminalView` currently reads PTY data via `window.ade.pty.onData(...)` which fans out from the local main process. Add a parallel subscription to the daemon's `runtimeEvents.subscribe({category: "pty"})` stream. The preload already has `subscribeAgentChatEvents` doing exactly this pattern (preload.ts:2366) — clone it for PTY events. +Full app view with dim overlay everywhere except the drawer: -### 6.4 TUI gets the same byte stream +``` + Pick a chat to add · ↵ Add · Esc Cancel +┌──────────────┐ ┌── (dim middle) ─────────────┐ ┌(dim right)┐ +│ lane-foo │ │ existing tile contents │ │ │ +│ chat-1 ▸ │ │ (still visible, just dim) │ │ │ +│ chat-2 │ │ │ │ │ +│ lane-bar │ │ │ │ │ +│ chat-3 │ │ │ │ │ +│ chat-4 │ └─────────────────────────────┘ └───────────┘ +│ lane-baz │ ┌── (dim prompt) ──────────────────────────┐ +│ chat-5 │ │ │ +└──────────────┘ └──────────────────────────────────────────┘ +``` -`apps/ade-cli/src/tuiClient/connection.ts:448 subscribeRuntimeEvents` already supports arbitrary categories. The TUI can subscribe to `category: "pty"` the moment it has an Ink terminal renderer to display the bytes. **Building that Ink terminal widget is out of scope for tier 2** — leave a TODO, ship tier 2 with the wire ready. +- Banner uses default colors (not dimmed) — it's the only bright thing besides the sidebar so the eye lands on it. +- The `▸` marker indicates the add-mode cursor (separate from the underlying active-chat highlight). +- Pressing arrow keys moves `▸` across lanes and chats freely. `Enter` adds. `Esc` exits. -### 6.5 Mobile and remote runtimes +### Status-bar grid mini-map -`apps/desktop/src/main/services/remoteRuntime/` already brokers chat events to/from a remote daemon via SSH. The PTY event category needs the same forwarding. Audit `remoteConnectionPool.ts` and add the new category to its allowed list. +Append to the footer status row, after model name: -### 6.6 Migrate `ChatTerminalDrawer` and `WorkViewArea` to be daemon-aware +- N=1: `▣` +- N=2: `▣▢` or `▢▣` depending on focus +- N=3: `▣▢▢` etc. +- N=4: `▣▢ / ▢▢` (rows separated by ` / `) +- N=5: `▣▢ / ▢▢▢` +- N=6: `▣▢▢ / ▢▢▢` -These components hold direct `window.ade.pty.*` calls. Audit: +Use `▣` for focused tile, `▢` for unfocused. Render in plain Unicode; no color needed. -- `apps/desktop/src/renderer/components/terminals/WorkViewArea.tsx` — `WorkCliContinuationComposer` and `ClosedCliSessionSurface` invoke `onContinue` which threads through to `pty.sendToSession`. The preload-level routing in §6.2 covers this transparently. -- `apps/desktop/src/renderer/components/chat/ChatTerminalDrawer.tsx` — calls `pty.dispose` on unmount and on session-deleted events. The daemon-side `pty.dispose` (already ownership-gated from tier 1) will no-op cross-process correctly. +### Hover affordance (universal click) -### 6.7 Tests +When `hoveredId` matches a registered target, the target renders with a subtle highlight. Pick **one** of these and apply consistently: -Integration test in `apps/ade-cli/src/adeRpcServer.test.ts`: a client subscribes to `category: "pty"`, calls `pty.create`, then receives `pty_data` notifications when the PTY produces output. +- Option A: `<Box backgroundColor="blackBright">` wrapping the target. +- Option B: `<Text inverse>` on the target's text. -Integration test for the desktop preload routing: when `callProjectRuntimeActionIfBound` is bound, `pty.sendToSession` does NOT hit the local IPC handler. +Recommendation: Option A for multi-cell targets (rows, buttons, tabs), Option B for inline single-line affordances (the `×`, palette items). Keep the effect very subtle — this is a confirmation, not a beacon. --- -## 7. Acceptance criteria +## Edge cases to handle -Run these by hand at the end. None of them require the user. - -1. **Concurrent boot, no false dispose.** Start the desktop. Start `ade code` on the same project. Confirm no rows flip to `disposed` purely from the TUI boot. (`sqlite3 .../ade.db "select id, tool_type, status, owner_pid from terminal_sessions where status='running';"` before/after.) -2. **Crash-resilient cleanup.** Start a CLI session in the desktop. `kill -9` the desktop process. Wait > liveness window. Open desktop again. The row is now `disposed` (the new desktop's reconcile sees the dead owner_pid). No live PTYs were killed — verify the runaway claude/codex processes are still in `ps aux` and will be reaped by the OS / a follow-up cleanup pass. -3. **Cross-surface live CLI rendering (tier 2).** Start `claude` from the desktop's Work tab. Open `ade code` in another terminal on the same lane. The TUI sees the same byte stream live (or at minimum receives pty events on `category: "pty"` — the Ink renderer is out of scope but the wire test confirms data is flowing). -4. **Mobile sees what desktop sees** when sync is configured — same `category: "pty"` events forwarded over the remote runtime transport. -5. **Symptom A regression test** — resume an ended CLI session from `ClosedCliSessionSurface`, observe the surface swap to live `TerminalView` immediately (already fixed by `upsertSessionSnapshot` + `reattach` emitChanged from §4; still works). -6. **Symptom B regression test** — start a Claude Code CLI in the desktop, immediately open `ade code` on the same lane. Confirm the desktop's view stays as live `TerminalView`. Confirm the `terminal_sessions` row keeps `status='running'` and `owner_pid` matches the desktop's pid. +- **Terminal too narrow** for the chosen grid (e.g. N=6 but `width < 90`). Refuse to render the grid; show a notice in the status line ("Multi-view: terminal too narrow, displaying focused tile only") and render only the focused tile full-width until the terminal grows. +- **Tile session ends** (chat marked `status: "ended"`). Keep the tile visible (with greyed header), don't auto-remove. Let the user decide via `×`. +- **Lane deleted** while one of its sessions is in the grid. Treat similarly to ended — keep the tile, badge it with `(lane removed)` in the header. +- **Drag-to-add when grid is already at 6**: ignore the drop, flash a 1s status-line notice ("Multi-view full (max 6)"). +- **Active session changes** outside multi-view (e.g. user clicks a sidebar chat in single-chat mode). Single-chat behavior preserved exactly. +- **Add-mode while terminal resizes**: cancel add-mode, return to normal layout, do not lose existing multi-view tiles. +- **Mouse mode 1003 not supported** by the user's terminal (rare but possible — older `tmux` versions, some SSH client wrappers). Detection is hard; if hover events never arrive, the feature degrades gracefully — clicks still work, just no hover. Acceptable. +- **High event volume** with 6 concurrent streams: confirm event aggregation in `aggregateChatBlocks` doesn't lock the render thread. Add a small throttle if needed (coalesce per-session re-renders to ~30 Hz). --- -## 8. Out of scope / follow-ups (do not do as part of this work) +## Files to create / modify -- Build the Ink terminal widget so TUI can render raw PTY bytes for Codex CLI etc. (Tier 2 makes the data available; rendering is a UI task.) -- Move the DB to `journal_mode=wal` for true concurrent readers/writers. Worthwhile but separate. -- Replace the renderer's `disposeTabsOnUnmount` pattern with explicit user intent. The ownership gate makes it safe enough. -- Make the daemon survive desktop crashes when desktop spawned it (currently child of init thanks to `detached: true`, but verify `apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.ts:824 spawnRuntime` does the same). +### Create ---- +- `apps/ade-cli/src/tuiClient/hitTestRegistry.ts` — registry implementation + React context + `useHitTest` hook. +- `apps/ade-cli/src/tuiClient/multiChatLayout.ts` — `computeTileRects` + PATTERNS table. +- `apps/ade-cli/src/tuiClient/components/MultiChatGrid.tsx` — grid wrapper rendering N `<ChatView>` instances. +- `apps/ade-cli/src/tuiClient/components/AddChatMode.tsx` — banner + dim wrapper + alt-cursor drawer rendering. +- `apps/ade-cli/src/tuiClient/components/GridMiniMap.tsx` — small footer component. +- Test files alongside each. -## 9. Working notes for the next agent +### Modify -- **Worktree:** `/Users/admin/Projects/ADE/.ade/worktrees/deeplinks-d52aa89e/`. Stay in it. Don't switch to project root. -- **Git diff to inspect before starting:** there are pre-existing changes from other in-flight work in this lane (e.g. `agentChatService.ts` hook-noise removal, `chatTranscriptRows.ts` tweaks). Run `git diff --stat` to see what's untouched-by-me vs touched. Don't revert anyone else's changes. -- **Branch:** `ade/deeplinks-d52aa89e`. Don't push or open a PR until acceptance criteria pass. -- **Test sharding** — the test suite is large. Always run scoped (`npx vitest run src/main/services/sessions/sessionService.test.ts` etc.). Full-suite invocations OOM. -- **Type-check:** `npm run typecheck` from `apps/desktop/` and from `apps/ade-cli/`. Both must be green. -- **Lint:** `npm run lint` from `apps/desktop/`. Run only after typecheck. -- **Don't touch normal ADE chats.** The user has been explicit that agent chats already work and any UI changes to `AgentChatPane.tsx` will be rejected unless they're strictly ownership-related. The chat path already has cross-surface sync via the daemon (§3). Don't try to "improve" it. +- `apps/ade-cli/src/tuiClient/app.tsx` — the bulk of the work: + - Add new state (multiView, addMode, streamingBySessionId, scrollBySessionId, selectionBySessionId, promptHistoryBySessionId). + - Replace `streaming` references throughout. + - Widen `onChatEvent` filter to `openSessionIds`. + - Branch middle-pane render: `multiView ? <MultiChatGrid …/> : <ChatView …/>`. + - Extend `useInput` to handle add-mode keys (Esc, Enter, arrows) and multi-view keys (Ctrl+G, Ctrl+W, Tab for tile cycle). + - Update `submitPrompt` to route by focused tile. + - Enable/disable mouse mode 1003. + - Migrate each existing `*IndexForMouseLine` helper to component-level registry registration. + - Add `useEffect` syncing focused-tile → active lane/session. +- `apps/ade-cli/src/tuiClient/components/ChatView.tsx` — add `focused?: boolean` + `onRemove?: () => void` props; render double-border + cyan header + clickable `×` accordingly. +- `apps/ade-cli/src/tuiClient/state.ts` — **no changes** (multiView intentionally ephemeral). --- -## 10. Investigation log — commands you can re-run to verify +## Verification plan -These are the queries that proved the diagnosis. Re-run them to confirm the state matches what's described above. +End-to-end (run the TUI in this worktree): -Find live ADE processes and which sockets they own: +1. **Universal click smoke test** + - Open model picker → click rows, tabs, favorite star → confirm parity with keyboard. + - Trigger approval prompt → click accept/decline. + - Open slash and mention palettes → click items. + - Open lane-delete form → click radio options and force toggle. + - Move mouse around → hover-highlight follows pointer. -```sh -ps aux | grep -E "ade.*serve|ade-runtime|claude " | grep -v grep -ls -la /tmp/ade-runtime-*.sock /Users/admin/.ade*/sock/*.sock -lsof /Users/admin/.ade-beta/sock/ade.sock -lsof /tmp/ade-runtime-lane-*.sock -``` +2. **Multi-chat lifecycle** + - From single chat, press `Ctrl+G` → add-mode banner appears, non-sidebar regions dim. + - Arrow into another lane → confirm underlying active lane stays put. + - Press Enter on a chat → grid switches to 2-col with new tile focused, right pane updates to new tile's lane. + - Add 3rd, 4th, 5th, 6th → confirm layouts match wireframes. + - Press `Ctrl+G` while 6 tiles open and try to add → confirm cap (no add, notice). + - Click `×` on a tile → confirm removal + grid reshapes. + - Remove down to 2 tiles → confirm remaining tile becomes single-chat mode (multiView cleared). -Confirm multiple ADE processes share the same project DB: +3. **Cross-lane focus sync** + - 2 tiles from 2 lanes → click between them → confirm right pane, status bar, sidebar lane highlight all follow focused tile. + - Switch lanes via sidebar → confirm multi-view persists. -```sh -lsof /Users/admin/Projects/ADE/.ade/ade.db -``` +4. **Concurrent streaming** + - Open 3 tiles → send a long prompt in each (focus, submit, focus next, submit, focus next, submit). + - Confirm all three stream simultaneously with ● indicators; non-focused tiles continue rendering events. -(I saw five processes with the same inode open: desktop electron, ADE Beta main, two TUI lane runtimes, and a dev runtime.) +5. **Per-tile history** + - Send 3 prompts to tile A, 2 prompts to tile B. + - Focus A → up-arrow cycles only A's. Focus B → up-arrow cycles only B's. -DB state — running sessions vs OS-level claude processes: +6. **Drag-to-add** + - Click-drag a sidebar chat row into the middle → confirm it adds without add-mode. -```sh -sqlite3 /Users/admin/Projects/ADE/.ade/ade.db \ - "select id, lane_id, tool_type, status, started_at, ended_at, last_output_at, pty_id from terminal_sessions where status in ('running','disposed') order by started_at desc limit 30;" -``` +7. **Persistence (negative)** + - Open 4 tiles → kill TUI → relaunch → confirm app starts in single-chat mode. -Cross-reference any `claude` row marked `disposed` against `ps aux | grep "claude --session-id <that id>"`. If the OS process is alive and the row is disposed, you've reproduced Symptom B. +Unit tests: -DB journal mode (informational — tier 1 doesn't require WAL): +- `hitTestRegistry.test.ts` — register/unregister, overlapping rects (higher `zIndex` wins), out-of-bounds returns null. +- `multiChatLayout.test.ts` — for each n ∈ [1,6], rects tile the area without overlap, respect minimums, total area ≤ input area. +- `streamingBySessionId.test.ts` — events for non-focused sessions still update the per-session record. +- `addMode.test.ts` — keyboard navigation does not mutate `activeSessionId` / `activeLaneId`; Enter calls `addTileToGrid` with the cursor target. -```sh -sqlite3 /Users/admin/Projects/ADE/.ade/ade.db "pragma journal_mode;" -``` +Manual perf: with 6 tiles streaming, keystroke latency in the bottom prompt stays under ~16ms. --- -## 11. Why this is the right fix +## Docs / references to read before starting -Three things have to be true for the user's bugs to recur. Each tier eliminates one. +- **Ink (terminal React)**: <https://github.com/vadimdemedes/ink#readme> — especially the section on `Box`, `Text`, `useInput`, `useStdout`, and the `position`/`marginLeft`/`marginTop` props (Yoga layout). +- **XTerm mouse tracking modes**: <https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h2-Mouse-Tracking> — for mode 1003 (any-event tracking), SGR encoding, and how to disable cleanly. +- **Yoga layout reference**: <https://yogalayout.dev/> — Ink's underlying layout engine; relevant if you opt for `position="absolute"` in `MultiChatGrid`. +- **Existing ADE patterns to study before coding**: + - `apps/ade-cli/src/tuiClient/app.tsx` — read `parseTerminalMouseInput`, `submitPrompt`, `onChatEvent`, the `useInput` block, and the layout `<Box>` tree (~7626–7800). + - `apps/ade-cli/src/tuiClient/components/ChatView.tsx` — full component, especially the event-aggregation and row-rendering paths. + - `apps/ade-cli/src/tuiClient/state.ts` — to know what NOT to touch for persistence. + - `apps/desktop/src/shared/types/chat.ts` lines 725–773 — the `AgentChatSession` type and friends. +- **Existing hit-test helpers** (to be deleted post-migration): `laneDetailsActionIndexForMouseLine`, `formFieldIndexForMouseLine`, `setupPaneRowIndexForMouseLine`, `subagentIndexForPaneLine` in `app.tsx` ~1557–1620. -- The DB has no concept of "who owns this row." → Tier 1 (`owner_pid` + heartbeat). -- Multiple processes mutate the same row believing they're the sole owner. → Tier 1 (dispose/reconcile gated on ownership). -- Surfaces other than the spawner can't see live output for raw PTY sessions, so the user thinks the session "ended" when really only their view stopped updating. → Tier 2 (daemon owns PTYs, every surface subscribes to the byte stream). - -The user's "running for a while and finally responded" anecdote is consistent with this: the agent chat (`claude-chat`) was healthy throughout because it goes through the daemon's chat path. The CLI row (`claude` tool type) had its `owner_pid`-less ancestor stomped at some point — likely by a `dispose` from a renderer effect or a reconcile pass from a sibling process — leaving the OS-level `claude` process orphaned in the DB sense but alive in reality. Tier 1 + Tier 2 together make this configuration impossible. - -The alternative fixes considered and rejected: +--- -- **`last_output_at` heuristic only.** Already in place from §4. Doesn't help idle sessions; doesn't help dispose path. Acceptable as belt-and-suspenders, not as the primary mechanism. -- **`process.kill(pid, 0)` instead of a registry table.** Works on the local machine. Falls over for sync / remote-runtime scenarios where the owner is on another host. The registry table generalizes. -- **Lazy verification on interaction (skip reconcile entirely).** Discussed with the user. UI would briefly show stale "running" rows for crashed sessions; user explicitly rejected this UX. -- **Move the DB to WAL mode.** Helps contention but doesn't change the ownership semantics. Worth doing eventually; orthogonal. +## Out of scope (do not do) -The right fix is the registry-backed ownership model + daemon-owned PTYs because both are *consistent with how chats already work in this codebase*. Tier 2 isn't introducing a new pattern, it's extending the one that demonstrably already works to the missing case. +- **Persistence of multi-view across restart** — explicitly ephemeral. +- **Broadcasting one prompt to multiple tiles** — single-tile routing only. +- **Per-tile prompt input** — keep the one bottom prompt; routing changes are enough. +- **Tile rearranging / drag-to-swap** — tiles render in insertion order; not negotiable for v1. +- **Number-key tile focus, middle-click remove, hover gutter arrow, streaming flash on non-focused tile** — not selected from the extras menu. +- **Touching the desktop or web apps** — TUI-only change. From cdb07c48988484da6d0e720cce1f4608ee997284 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Thu, 21 May 2026 03:12:59 -0400 Subject: [PATCH 3/6] finalize: typecheck + test fixes for deeplinks branch - ReviewPage: relax formatCompareTargetDescription param to the Pick subset it actually reads, fixing 4 TS2345s - SessionListPane test: align with refactored card (no inline tool label, lane name only in group header) - ChatAppearancePreview test: accept 22% or 28% accent mix (light-accent providers now use 22%) - ChatGitToolbar test: match new /lanes?laneId=... route and drop removed Run-menu test - iOS bootstrap SQL: regenerate to include lane_worktree_locks table Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- .../components/chat/ChatGitToolbar.test.tsx | 23 ++++--------------- .../renderer/components/review/ReviewPage.tsx | 4 +++- .../settings/ChatAppearancePreview.test.tsx | 3 ++- .../terminals/SessionListPane.test.tsx | 3 +-- apps/ios/ADE/Resources/DatabaseBootstrap.sql | 14 +++++++++++ 5 files changed, 24 insertions(+), 23 deletions(-) diff --git a/apps/desktop/src/renderer/components/chat/ChatGitToolbar.test.tsx b/apps/desktop/src/renderer/components/chat/ChatGitToolbar.test.tsx index d63c1937c..4684e5114 100644 --- a/apps/desktop/src/renderer/components/chat/ChatGitToolbar.test.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatGitToolbar.test.tsx @@ -1,6 +1,6 @@ /* @vitest-environment jsdom */ -import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom"; import { useAppStore } from "../../state/appStore"; @@ -107,7 +107,9 @@ describe("ChatGitToolbar", () => { fireEvent.click(await screen.findByRole("button", { name: /UI audit lane/i })); - expect(screen.getByTestId("location").textContent).toBe("/lanes/lane-1"); + expect(screen.getByTestId("location").textContent).toBe( + "/lanes?laneId=lane-1&focus=single", + ); }); it("opens the PR creation handoff when the current lane has no linked PR", async () => { @@ -119,21 +121,4 @@ describe("ChatGitToolbar", () => { "/prs?tab=normal&create=1&sourceLaneId=lane-1&target=primary", ); }); - - it("opens the Run menu from the chat Git toolbar without starting commands", async () => { - renderToolbar(); - - const runButton = await screen.findByRole("button", { name: /Run/i }); - fireEvent.pointerDown(runButton, { button: 0, ctrlKey: false, pointerId: 1 }); - fireEvent.pointerUp(runButton, { button: 0, ctrlKey: false, pointerId: 1 }); - - expect(await screen.findByText("Lane runtime")).toBeTruthy(); - expect(screen.getByText("Open Run tab")).toBeTruthy(); - expect(screen.getByText("Open shell in Work")).toBeTruthy(); - await waitFor(() => { - expect(window.ade.projectConfig.get).toHaveBeenCalled(); - }); - expect(window.ade.processes.startAll).not.toHaveBeenCalled(); - expect(window.ade.processes.stopAll).not.toHaveBeenCalled(); - }); }); diff --git a/apps/desktop/src/renderer/components/review/ReviewPage.tsx b/apps/desktop/src/renderer/components/review/ReviewPage.tsx index baa79c347..88494bb1c 100644 --- a/apps/desktop/src/renderer/components/review/ReviewPage.tsx +++ b/apps/desktop/src/renderer/components/review/ReviewPage.tsx @@ -577,7 +577,9 @@ function formatReviewEvidenceLine(run: NormalizedRun, detail: NormalizedDetail | return "Evidence and saved artifacts are available below."; } -function formatCompareTargetDescription(run: NormalizedRun): string { +function formatCompareTargetDescription( + run: Pick<NormalizedRun, "target" | "compareTarget">, +): string { if (run.target.mode === "working_tree") { return "Comparing against the current HEAD commit in this lane."; } diff --git a/apps/desktop/src/renderer/components/settings/ChatAppearancePreview.test.tsx b/apps/desktop/src/renderer/components/settings/ChatAppearancePreview.test.tsx index 22c9d3a13..daaea8aa6 100644 --- a/apps/desktop/src/renderer/components/settings/ChatAppearancePreview.test.tsx +++ b/apps/desktop/src/renderer/components/settings/ChatAppearancePreview.test.tsx @@ -89,7 +89,8 @@ describe("ChatAppearancePreview", () => { expect(sharp.length).toBe(3); for (const el of shells) { const style = (el as HTMLElement).style; - expect(style.getPropertyValue("--chat-user-border-accent-mix").trim()).toBe("28%"); + const mix = style.getPropertyValue("--chat-user-border-accent-mix").trim(); + expect(["22%", "28%"]).toContain(mix); } }); diff --git a/apps/desktop/src/renderer/components/terminals/SessionListPane.test.tsx b/apps/desktop/src/renderer/components/terminals/SessionListPane.test.tsx index f0f7d9db1..f102256e2 100644 --- a/apps/desktop/src/renderer/components/terminals/SessionListPane.test.tsx +++ b/apps/desktop/src/renderer/components/terminals/SessionListPane.test.tsx @@ -104,7 +104,7 @@ describe("SessionListPane", () => { it("renders by-lane sessions whose lane is missing from the cached lane list", () => { renderPane(); - expect(screen.getAllByText("Mobile-created lane")).toHaveLength(2); + expect(screen.getByText("Mobile-created lane")).toBeTruthy(); expect(screen.getByText("Mobile Tool Streaming UI")).toBeTruthy(); }); @@ -138,7 +138,6 @@ describe("SessionListPane", () => { expect(row).toBeTruthy(); expect(title.className).toContain("font-semibold"); - expect(within(row!).getByText("Codex").className).not.toContain("font-semibold"); expect(within(row!).getByText("Ran the latest command").className).not.toContain("font-semibold"); }); diff --git a/apps/ios/ADE/Resources/DatabaseBootstrap.sql b/apps/ios/ADE/Resources/DatabaseBootstrap.sql index 1fb84241d..b9863273b 100644 --- a/apps/ios/ADE/Resources/DatabaseBootstrap.sql +++ b/apps/ios/ADE/Resources/DatabaseBootstrap.sql @@ -186,6 +186,20 @@ alter table terminal_sessions add column chat_session_id text; create index if not exists idx_terminal_sessions_chat_session_id on terminal_sessions(chat_session_id); +alter table terminal_sessions add column owner_pid integer; + +create index if not exists idx_terminal_sessions_owner_pid on terminal_sessions(owner_pid); + +create table if not exists runtime_processes ( + pid integer primary key, + role text not null, + project_root text, + started_at text not null, + last_seen text not null + ); + +create index if not exists idx_runtime_processes_last_seen on runtime_processes(last_seen); + create table if not exists claude_sessions ( session_id text primary key, lane_id text not null, From 2c7e92eafe2d8d154b341c09be1582a37836b2e9 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Thu, 21 May 2026 04:27:34 -0400 Subject: [PATCH 4/6] ship: address deeplink review feedback --- apps/ade-cli/src/adeRpcServer.ts | 49 ++++-- apps/ade-cli/src/bootstrap.ts | 13 ++ apps/ade-cli/src/commands/deeplinks.ts | 12 +- .../services/sync/syncRemoteCommandService.ts | 11 +- .../__tests__/deeplinkKeybind.test.ts | 10 +- apps/ade-cli/src/tuiClient/aggregate.ts | 6 +- apps/ade-cli/src/tuiClient/app.tsx | 19 +-- .../src/tuiClient/components/Drawer.tsx | 18 ++- .../src/tuiClient/components/GridMiniMap.tsx | 5 +- apps/ade-cli/src/tuiClient/deeplinkRow.ts | 9 +- apps/ade-cli/src/tuiClient/multiChatLayout.ts | 7 +- apps/desktop/src/main/main.ts | 148 ++++++++++++++---- .../main/services/chat/agentChatService.ts | 5 +- .../services/deeplinks/protocolHandler.ts | 28 ++-- .../src/main/services/ipc/registerIpc.ts | 3 +- .../src/main/services/ipc/runtimeBridge.ts | 14 +- .../src/main/services/pty/ptyService.test.ts | 19 ++- .../src/main/services/pty/ptyService.ts | 6 +- .../runtime/processRegistryService.test.ts | 5 + .../runtime/processRegistryService.ts | 53 ++++++- .../services/sessions/sessionService.test.ts | 55 +++++++ .../main/services/sessions/sessionService.ts | 62 ++++++-- apps/desktop/src/main/services/state/kvDb.ts | 3 + .../app/ClipboardDeeplinkBanner.tsx | 5 +- .../components/chat/AgentChatPane.tsx | 22 +-- apps/desktop/src/shared/adeDeeplinkFooter.ts | 5 +- apps/desktop/src/shared/types/sessions.ts | 1 + apps/ios/ADE/App/DeepLinkRouter.swift | 7 +- apps/ios/ADE/Services/SyncService.swift | 7 +- .../ADE/Views/Deeplinks/SendToMacCard.swift | 11 +- apps/web/src/app/pages/OpenPage.tsx | 21 ++- 31 files changed, 499 insertions(+), 140 deletions(-) diff --git a/apps/ade-cli/src/adeRpcServer.ts b/apps/ade-cli/src/adeRpcServer.ts index 04bb810ff..d519b3309 100644 --- a/apps/ade-cli/src/adeRpcServer.ts +++ b/apps/ade-cli/src/adeRpcServer.ts @@ -7835,32 +7835,53 @@ export function createAdeRpcRequestHandler(args: { if (method.startsWith("pty.")) { const ptyArgs = safeObject(params.args ?? params.arg ?? params); + const ptyAction = method.slice("pty.".length); + if (!isAllowedAdeAction("pty", ptyAction)) { + throw new JsonRpcError(JsonRpcErrorCode.methodNotFound, `Unsupported PTY method: ${method}`); + } + if (!callerHasRoleAtLeast(session.identity.role, "agent")) { + throw new JsonRpcError(JsonRpcErrorCode.methodNotFound, `Unsupported PTY method: ${method}`); + } + if (isCtoOnlyAdeAction("pty", ptyAction) && !callerHasRoleAtLeast(session.identity.role, "cto")) { + throw new JsonRpcError(JsonRpcErrorCode.methodNotFound, `Unsupported PTY method: ${method}`); + } + const runPtyAction = async (runner: () => Promise<unknown> | unknown): Promise<unknown> => + auditActionCall(method, ptyArgs, async () => runner()); if (method === "pty.create") { - const result = await runtime.ptyService.create(ptyArgs as Parameters<typeof runtime.ptyService.create>[0]); - return { - ...result, - session: runtime.sessionService.get(result.sessionId), - }; + return await runPtyAction(async () => { + const result = await runtime.ptyService.create(ptyArgs as Parameters<typeof runtime.ptyService.create>[0]); + return { + ...result, + session: runtime.sessionService.get(result.sessionId), + }; + }); } if (method === "pty.sendToSession") { - return await runtime.ptyService.sendToSession(ptyArgs as Parameters<typeof runtime.ptyService.sendToSession>[0]); + return await runPtyAction(() => + runtime.ptyService.sendToSession(ptyArgs as Parameters<typeof runtime.ptyService.sendToSession>[0])); } if (method === "pty.write") { - runtime.ptyService.write(ptyArgs as Parameters<typeof runtime.ptyService.write>[0]); - return null; + return await runPtyAction(() => { + runtime.ptyService.write(ptyArgs as Parameters<typeof runtime.ptyService.write>[0]); + return null; + }); } if (method === "pty.resize") { - runtime.ptyService.resize(ptyArgs as Parameters<typeof runtime.ptyService.resize>[0]); - return null; + return await runPtyAction(() => { + runtime.ptyService.resize(ptyArgs as Parameters<typeof runtime.ptyService.resize>[0]); + return null; + }); } if (method === "pty.dispose") { - runtime.ptyService.dispose(ptyArgs as Parameters<typeof runtime.ptyService.dispose>[0]); - return null; + return await runPtyAction(() => { + runtime.ptyService.dispose(ptyArgs as Parameters<typeof runtime.ptyService.dispose>[0]); + return null; + }); } if (method === "pty.list") { - return { + return await runPtyAction(() => ({ sessions: runtime.ptyService.list(ptyArgs as Parameters<typeof runtime.ptyService.list>[0]), - }; + })); } throw new JsonRpcError(JsonRpcErrorCode.methodNotFound, `Unsupported PTY method: ${method}`); } diff --git a/apps/ade-cli/src/bootstrap.ts b/apps/ade-cli/src/bootstrap.ts index 5bf091919..56032cc7a 100644 --- a/apps/ade-cli/src/bootstrap.ts +++ b/apps/ade-cli/src/bootstrap.ts @@ -460,10 +460,13 @@ export async function createAdeRuntime(args: { projectRoot, }); processRegistry.start(); + let runtimeCreated = false; + try { sessionService.reconcileStaleRunningSessions({ status: "disposed", excludeToolTypes: ["claude-chat", "codex-chat", "opencode-chat", "cursor", "droid-chat"], liveOwnerPids: processRegistry.listLivePids(), + liveOwnerIdentities: processRegistry.listLiveProcessIdentities(), }); const sessionDeltaService = createSessionDeltaService({ db, @@ -1268,5 +1271,15 @@ export async function createAdeRuntime(args: { }; automationService.bindAdeActionRegistry(adeActionLookup); + runtimeCreated = true; return runtime; + } finally { + if (!runtimeCreated) { + try { + processRegistry.stop(); + } catch { + // Preserve the original startup failure. + } + } + } } diff --git a/apps/ade-cli/src/commands/deeplinks.ts b/apps/ade-cli/src/commands/deeplinks.ts index db4330639..d9e176b83 100644 --- a/apps/ade-cli/src/commands/deeplinks.ts +++ b/apps/ade-cli/src/commands/deeplinks.ts @@ -93,6 +93,9 @@ export function runOpenCommand(args: string[]): DeeplinkCliResult { const linearIssue = flags.valued.get("linear-issue"); const branch = flags.valued.get("branch"); if (linearIssue || branch) { + if (!linearIssue) { + throw new CliDeeplinkUsageError("--linear-issue is required when using --branch"); + } // Build an https://ade.app/open URL with the hints Linear gave us. The // landing page (and the renderer-side handler) interpret the linear-issue // hint by looking up the lane/project that owns it. @@ -147,8 +150,9 @@ function openUrlViaOs(url: string): { failed: boolean; message: string } { args = [url]; } try { - const r = spawnSync(cmd, args, { stdio: "ignore" }); + const r = spawnSync(cmd, args, { stdio: "ignore", timeout: 10_000 }); if (r.error) return { failed: true, message: r.error.message }; + if (r.signal) return { failed: true, message: `${cmd} exited with signal ${r.signal}` }; if (typeof r.status === "number" && r.status !== 0) { return { failed: true, message: `${cmd} exited with ${r.status}` }; } @@ -233,7 +237,11 @@ export function runLinkCommand(args: string[]): DeeplinkCliResult { } function parseRepoSlug(repo: string): { repoOwner: string; repoName: string } { - const [repoOwner, repoName] = repo.split("/"); + const parts = repo.split("/"); + if (parts.length !== 2) { + throw new CliDeeplinkUsageError("Repo must be in 'owner/repo' form"); + } + const [repoOwner, repoName] = parts; if (!repoOwner || !repoName) { throw new CliDeeplinkUsageError("Repo must be in 'owner/repo' form"); } diff --git a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts index d3d66a84a..4d9498348 100644 --- a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts +++ b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts @@ -2557,13 +2557,22 @@ export function createSyncRemoteCommandService(args: SyncRemoteCommandServiceArg if (!url) { throw new Error("deeplinks.open requires a url."); } + let parsed: URL; + try { + parsed = new URL(url); + } catch { + throw new Error("deeplinks.open requires a valid URL."); + } + if (parsed.protocol !== "ade:") { + throw new Error("deeplinks.open only supports ade:// URLs."); + } if (!args.dispatchDeeplinkUrl) { return { ok: false, message: "Desktop navigation is unavailable in this runtime.", }; } - return await args.dispatchDeeplinkUrl(url); + return await args.dispatchDeeplinkUrl(parsed.toString()); }); register("prs.getDetail", { viewerAllowed: true }, async (payload) => args.prService.getDetail(requirePrId(payload, "prs.getDetail"))); register("prs.getStatus", { viewerAllowed: true }, async (payload) => args.prService.getStatus(requirePrId(payload, "prs.getStatus"))); diff --git a/apps/ade-cli/src/tuiClient/__tests__/deeplinkKeybind.test.ts b/apps/ade-cli/src/tuiClient/__tests__/deeplinkKeybind.test.ts index f362c61a2..a0f2f6c5d 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/deeplinkKeybind.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/deeplinkKeybind.test.ts @@ -11,7 +11,7 @@ import { validateClaudeKeybindingsConfig, } from "../keybindings"; -const LANE_UUID = "550e8400-e29b-41d4-a716-446655440000"; +const laneUuid = "550e8400-e29b-41d4-a716-446655440000"; describe("copy ADE deeplink keybinding", () => { it("registers as a known TUI action and dispatches under Tabs context", () => { @@ -51,8 +51,8 @@ describe("copy ADE deeplink keybinding", () => { }); it("builds the ade:// deeplink for a lane row", () => { - const row: DeeplinkRow = { kind: "lane", lane: { id: LANE_UUID } }; - expect(buildDeeplinkForRow(row)).toBe(`ade://lane/${LANE_UUID}`); + const row: DeeplinkRow = { kind: "lane", lane: { id: laneUuid } }; + expect(buildDeeplinkForRow(row)).toBe(`ade://lane/${laneUuid}`); }); it("builds the ade:// deeplink for a PR row from explicit fields", () => { @@ -179,11 +179,11 @@ describe("end-to-end: keybinding action invokes clipboard with the right URL", ( it("copies the canonical lane URL when a lane row is focused", () => { const recorded = { url: null as string | null }; const result = runCopyAdeDeeplinkAction( - { kind: "lane", lane: { id: LANE_UUID } }, + { kind: "lane", lane: { id: laneUuid } }, recorded, ); expect(result).toBe("copied"); - expect(recorded.url).toBe(`ade://lane/${LANE_UUID}`); + expect(recorded.url).toBe(`ade://lane/${laneUuid}`); }); it("copies the canonical PR URL when a PR row is focused", () => { diff --git a/apps/ade-cli/src/tuiClient/aggregate.ts b/apps/ade-cli/src/tuiClient/aggregate.ts index 7a29f3940..daf62fd56 100644 --- a/apps/ade-cli/src/tuiClient/aggregate.ts +++ b/apps/ade-cli/src/tuiClient/aggregate.ts @@ -335,7 +335,7 @@ function runtimeStatus(value: unknown): RuntimeActivityEntry["status"] { // Activity events that mirror the real tool/command/file events the transcript // already renders. Suppress them in the TUI so the same fragment doesn't appear // twice (once as Runtime, once as Tool calls). Matches desktop suppression. -const SUPPRESSED_RUNTIME_ACTIVITIES = new Set([ +const suppressedRuntimeActivities = new Set([ "thinking", "working", "tool_calling", @@ -349,7 +349,7 @@ const SUPPRESSED_RUNTIME_ACTIVITIES = new Set([ function runtimeActivityFromEvent(id: string, event: AgentChatEvent): RuntimeActivityEntry | null { if (event.type === "activity") { const activity = event.activity; - if (SUPPRESSED_RUNTIME_ACTIVITIES.has(activity)) return null; + if (suppressedRuntimeActivities.has(activity)) return null; return { id, label: activity.replace(/_/g, " "), @@ -564,7 +564,7 @@ export function aggregateChatBlocks(args: { continue; } if (event.type === "text") { - if (!event.text.trim().length) continue; + if (event.text.length === 0) continue; const previous = blocks[blocks.length - 1]; if (previous?.kind === "assistant-text") { const previousTextEvent = assistantTextEventsByBlockId.get(previous.id); diff --git a/apps/ade-cli/src/tuiClient/app.tsx b/apps/ade-cli/src/tuiClient/app.tsx index eaae72bac..2db8c90cc 100644 --- a/apps/ade-cli/src/tuiClient/app.tsx +++ b/apps/ade-cli/src/tuiClient/app.tsx @@ -2537,7 +2537,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } return out; }, [lanes]); useEffect(() => { - if (!activeSessionId || multiViewRef.current) return; + if (!activeSessionId) return; setEventsBySessionId((prev) => { if (prev[activeSessionId] === events) return prev; return { ...prev, [activeSessionId]: events }; @@ -7671,11 +7671,6 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } return; } - if (pane === "chat" && isCtrlInput(input, key, "g")) { - startAddMode(); - return; - } - if (pane === "chat" && multiViewRef.current && isCtrlInput(input, key, "w")) { removeMultiViewTile(multiViewRef.current.focusedIndex); return; @@ -7978,6 +7973,11 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } return; } + if (pane === "chat" && isCtrlInput(input, key, "g")) { + startAddMode(); + return; + } + if ( pane === "chat" && textInputActive @@ -9087,8 +9087,9 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } } } } else if (rightPane.kind === "lane-details") { + const laneActionTop = rightBodyTop + 16; LANE_DETAIL_ACTIONS.forEach((_, index) => { - const y = 18 + index; + const y = laneActionTop + index; addTarget({ id: `right:lane-action:${index}`, rect: { x: rightStartColumn, y, w: rightPaneWidth, h: 1 }, @@ -9116,7 +9117,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } if (rightPane.pr) { addTarget({ id: "right:lane-pr", - rect: { x: rightStartColumn, y: 18 + LANE_DETAIL_ACTIONS.length + 1, w: rightPaneWidth, h: 3 }, + rect: { x: rightStartColumn, y: laneActionTop + LANE_DETAIL_ACTIONS.length + 1, w: rightPaneWidth, h: 3 }, onClick: () => { setRightPane((prev) => prev.kind === "lane-details" ? { ...prev, selectedActionIndex: LANE_DETAIL_PR_ACTION_INDEX } : prev); setPrompt("/pr open"); @@ -9143,7 +9144,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } }); } else if (rightPane.kind === "form") { rightPane.fields.forEach((field, index) => { - const y = rightPane.command === "lane-delete" ? [9, 13, 16, 19][index] ?? (rightBodyTop + 3 + index) : rightBodyTop + 3 + index; + const y = rightPane.command === "lane-delete" ? rightBodyTop + ([7, 11, 14, 17][index] ?? (3 + index)) : rightBodyTop + 3 + index; addTarget({ id: `right:form:${field.name}`, rect: { x: rightStartColumn, y, w: rightPaneWidth, h: rightPane.command === "lane-delete" ? 2 : 1 }, diff --git a/apps/ade-cli/src/tuiClient/components/Drawer.tsx b/apps/ade-cli/src/tuiClient/components/Drawer.tsx index c02c02640..b04945bca 100644 --- a/apps/ade-cli/src/tuiClient/components/Drawer.tsx +++ b/apps/ade-cli/src/tuiClient/components/Drawer.tsx @@ -175,6 +175,8 @@ export function Drawer({ <MiniDrawer width={width} borderColor={borderColor} + addMode={addMode} + emphasisColor={emphasisColor} lanes={laneRows} sessions={laneSessions} activeLaneId={activeLaneId} @@ -601,6 +603,8 @@ function ActiveChatSpin() { function MiniDrawer({ width, borderColor, + addMode, + emphasisColor, lanes, sessions, activeLaneId, @@ -617,6 +621,8 @@ function MiniDrawer({ }: { width: number; borderColor: string; + addMode: boolean; + emphasisColor: string; lanes: LaneSummary[]; sessions: AgentChatSessionSummary[]; activeLaneId: string | null; @@ -637,8 +643,8 @@ function MiniDrawer({ return ( <Box width={width} flexDirection="column" borderStyle="single" borderColor={borderColor}> <Box paddingX={1}> - <Text bold color={theme.color.violet}> - LANES · {loading && lanes.length === 0 ? "…" : lanes.length} + <Text bold color={addMode ? emphasisColor : theme.color.violet}> + {addMode ? "PICK CHAT" : `LANES · ${loading && lanes.length === 0 ? "…" : lanes.length}`} </Text> </Box> {loading && lanes.length === 0 ? ( @@ -720,7 +726,13 @@ function MiniDrawer({ ) : null} <Box paddingX={1} flexShrink={0}> <Text color={theme.color.t4} wrap="truncate"> - {!focused ? "\n" : mode === "chats" ? "↑↓ chats · Esc lanes" : "↑↓ lanes · ↵ open"} + {!focused + ? "\n" + : addMode + ? "↑↓ pick chat · ↵ add · esc cancel" + : mode === "chats" + ? "↑↓ chats · Esc lanes" + : "↑↓ lanes · ↵ open"} </Text> </Box> <Box paddingX={1} flexShrink={0}> diff --git a/apps/ade-cli/src/tuiClient/components/GridMiniMap.tsx b/apps/ade-cli/src/tuiClient/components/GridMiniMap.tsx index d5b939c4f..253b0a1bb 100644 --- a/apps/ade-cli/src/tuiClient/components/GridMiniMap.tsx +++ b/apps/ade-cli/src/tuiClient/components/GridMiniMap.tsx @@ -11,8 +11,9 @@ function mapFor(count: number, focusedIndex: number): string { } export function gridMiniMapText(count: number, focusedIndex: number): string { - if (count <= 1) return "▣"; - return mapFor(count, Math.max(0, Math.min(focusedIndex, count - 1))); + const visibleCount = Math.max(1, Math.min(6, Math.floor(Number.isFinite(count) ? count : 1))); + if (visibleCount <= 1) return "▣"; + return mapFor(visibleCount, Math.max(0, Math.min(focusedIndex, visibleCount - 1))); } export function GridMiniMap({ diff --git a/apps/ade-cli/src/tuiClient/deeplinkRow.ts b/apps/ade-cli/src/tuiClient/deeplinkRow.ts index 288ba7d41..70e2290c7 100644 --- a/apps/ade-cli/src/tuiClient/deeplinkRow.ts +++ b/apps/ade-cli/src/tuiClient/deeplinkRow.ts @@ -56,6 +56,9 @@ export function parseGitHubPrUrl(url: string): { repoOwner: string; repoName: st * when the row doesn't have enough data (e.g. a PR row with a malformed * URL and no explicit owner/repo). */ export function buildDeeplinkForRow(row: DeeplinkRow): string | null { + const isValidPrNumber = (value: number): boolean => + Number.isInteger(value) && value > 0; + if (row.kind === "lane") { if (!row.lane.id) return null; const target: DeeplinkTarget = { kind: "lane", laneId: row.lane.id }; @@ -63,7 +66,7 @@ export function buildDeeplinkForRow(row: DeeplinkRow): string | null { } const pr = row.pr; if ("repoOwner" in pr) { - if (!pr.repoOwner || !pr.repoName || !pr.prNumber) return null; + if (!pr.repoOwner || !pr.repoName || !isValidPrNumber(pr.prNumber)) return null; return buildDeeplink( { kind: "pr", repoOwner: pr.repoOwner, repoName: pr.repoName, prNumber: pr.prNumber }, { form: "ade" }, @@ -71,8 +74,10 @@ export function buildDeeplinkForRow(row: DeeplinkRow): string | null { } const parsed = parseGitHubPrUrl(pr.url); if (!parsed) return null; + const prNumber = pr.prNumber ?? parsed.prNumber; + if (!isValidPrNumber(prNumber)) return null; return buildDeeplink( - { kind: "pr", repoOwner: parsed.repoOwner, repoName: parsed.repoName, prNumber: pr.prNumber ?? parsed.prNumber }, + { kind: "pr", repoOwner: parsed.repoOwner, repoName: parsed.repoName, prNumber }, { form: "ade" }, ); } diff --git a/apps/ade-cli/src/tuiClient/multiChatLayout.ts b/apps/ade-cli/src/tuiClient/multiChatLayout.ts index 51c442aac..1a4ac3fb5 100644 --- a/apps/ade-cli/src/tuiClient/multiChatLayout.ts +++ b/apps/ade-cli/src/tuiClient/multiChatLayout.ts @@ -49,9 +49,10 @@ const PATTERNS: Record<1 | 2 | 3 | 4 | 5 | 6, ReadonlyArray<TilePattern>> = { }; export function asTileCount(value: number): 1 | 2 | 3 | 4 | 5 | 6 { - if (value <= 1) return 1; - if (value >= 6) return 6; - return value as 1 | 2 | 3 | 4 | 5 | 6; + const normalized = Math.floor(Number.isFinite(value) ? value : 1); + if (normalized <= 1) return 1; + if (normalized >= 6) return 6; + return normalized as 1 | 2 | 3 | 4 | 5 | 6; } export function computeTileRects(n: 1 | 2 | 3 | 4 | 5 | 6, width: number, height: number): TileRect[] { diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index a6448c241..3a19ff046 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -77,6 +77,8 @@ import { IPC } from "../shared/ipc"; import { resolveAdeLayout } from "../shared/adeLayout"; import type { OpenProjectBinding, + AppNavigationRequest, + LaneDeleteProgress, LaneSummary, PortLease, PrEventPayload, @@ -832,22 +834,23 @@ const deeplinkClaimAsDefault = process.env.ADE_REGISTER_DEEPLINK_HANDLER === "1" || (app.isPackaged && deeplinkChannel === null); +const pendingAppNavigationRequests: AppNavigationRequest[] = []; +let dispatchAppNavigationRequest: ((request: AppNavigationRequest) => void) | null = null; + +const dispatchOrQueueAppNavigationRequest = (request: AppNavigationRequest): void => { + if (!dispatchAppNavigationRequest) { + pendingAppNavigationRequests.push(request); + return; + } + dispatchAppNavigationRequest(request); +}; + // Register the user-facing `ade://` deeplink scheme + single-instance lock so a // second `open ade://...` invocation reuses the running window. Dispatch to the // focused window's renderer via the existing IPC.appNavigate channel. registerAdeProtocolHandler({ claimAsDefault: deeplinkClaimAsDefault, - dispatch: (request) => { - const focusable = - BrowserWindow.getFocusedWindow() ?? - BrowserWindow.getAllWindows().find((win) => !win.isDestroyed()) ?? - null; - if (!focusable) return; - if (focusable.isMinimized()) focusable.restore(); - focusable.show(); - focusable.focus(); - focusable.webContents.send(IPC.appNavigate, request); - }, + dispatch: dispatchOrQueueAppNavigationRequest, log: (event, fields) => { // Avoid throwing if console is gone; structured logger may not be ready yet. try { @@ -2024,6 +2027,7 @@ app.whenReady().then(async () => { const reconciledSessions = sessionService.reconcileStaleRunningSessions({ status: "disposed", liveOwnerPids: processRegistry.listLivePids(), + liveOwnerIdentities: processRegistry.listLiveProcessIdentities(), }); if (reconciledSessions > 0) { logger.warn("sessions.reconciled_stale_running", { @@ -3651,19 +3655,7 @@ app.whenReady().then(async () => { // / InboundDeeplinkModal / CrossRepoPrBanner all fire normally. dispatchDeeplinkUrl: async (rawUrl) => { try { - const focusable = - BrowserWindow.getFocusedWindow() ?? - BrowserWindow.getAllWindows().find((win) => !win.isDestroyed()) ?? - null; - if (!focusable) { - return { ok: false, message: "No ADE window is available." }; - } - handleDeeplinkUrl(rawUrl, "sync:ios", (request) => { - if (focusable.isMinimized()) focusable.restore(); - focusable.show(); - focusable.focus(); - focusable.webContents.send(IPC.appNavigate, request); - }); + handleDeeplinkUrl(rawUrl, "sync:ios", dispatchOrQueueAppNavigationRequest); return { ok: true }; } catch (error) { return { @@ -5498,6 +5490,7 @@ app.whenReady().then(async () => { let shutdownRequested = false; let shutdownFinalized = false; let quitWarningAcknowledged = false; + let quitConfirmationInFlight = false; let shutdownForceTimer: NodeJS.Timeout | null = null; const shutdownOpenCodeServersBestEffort = (): void => { @@ -5695,7 +5688,22 @@ app.whenReady().then(async () => { return true; }; - const getRunningLaneDeleteLabels = (): string[] => { + const isRunningLaneDeleteProgress = (value: unknown): value is LaneDeleteProgress => { + return Boolean( + value + && typeof value === "object" + && !Array.isArray(value) + && (value as { overallStatus?: unknown }).overallStatus === "running", + ); + }; + + const labelForProjectRoot = (root: string): string => { + const normalizedRoot = normalizeProjectRoot(root); + const ctx = projectContexts.get(normalizedRoot); + return ctx?.project?.displayName || path.basename(normalizedRoot) || normalizedRoot; + }; + + const getInProcessRunningLaneDeleteLabels = (): string[] => { const labels: string[] = []; for (const ctx of projectContexts.values()) { try { @@ -5709,11 +5717,47 @@ app.whenReady().then(async () => { labels.push(ctx.project?.displayName ?? ctx.project?.rootPath ?? "Unknown project"); } } + return labels; + }; + + const getRuntimeBackedRunningLaneDeleteLabels = async (): Promise<string[]> => { + if (shouldUseInProcessProjectRuntime()) return []; + const roots = new Set<string>([ + ...rootsBoundToWindows(), + ...projectContexts.keys(), + ]); + const labels: string[] = []; + await Promise.all(Array.from(roots).map(async (root) => { + try { + const response = await localRuntimePool.callActionForRoot(root, { + domain: "lane", + action: "listDeleteProgress", + }); + const progress = Array.isArray(response.result) ? response.result : []; + if (progress.some(isRunningLaneDeleteProgress)) { + labels.push(labelForProjectRoot(root)); + } + } catch (error) { + localRuntimeLogger.warn("lane_delete.runtime_quit_probe_failed", { + projectRoot: root, + error: error instanceof Error ? error.message : String(error), + }); + labels.push(labelForProjectRoot(root)); + } + })); + return labels; + }; + + const getRunningLaneDeleteLabels = async (): Promise<string[]> => { + const labels = [ + ...getInProcessRunningLaneDeleteLabels(), + ...await getRuntimeBackedRunningLaneDeleteLabels(), + ]; return Array.from(new Set(labels)); }; - const confirmNoRunningLaneDeleteForQuit = (ownerWindow?: BrowserWindow | null): boolean => { - const runningDeletes = getRunningLaneDeleteLabels(); + const confirmNoRunningLaneDeleteForQuit = async (ownerWindow?: BrowserWindow | null): Promise<boolean> => { + const runningDeletes = await getRunningLaneDeleteLabels(); if (runningDeletes.length === 0) return true; const detail = runningDeletes.length === 1 @@ -5758,6 +5802,23 @@ app.whenReady().then(async () => { rememberQuitAcknowledgement: false, }); + const requestQuitAfterWarnings = ( + ownerWindow: BrowserWindow | null | undefined, + reason: "before_quit" | "window_close", + ): void => { + if (shutdownRequested || quitConfirmationInFlight) return; + quitConfirmationInFlight = true; + void (async () => { + try { + if (!(await confirmNoRunningLaneDeleteForQuit(ownerWindow))) return; + if (!confirmQuitWarning(ownerWindow)) return; + requestAppShutdown({ reason, exitCode: 0 }); + } finally { + quitConfirmationInFlight = false; + } + })(); + }; + const closeWindowWithoutPrompt = (win: BrowserWindow): void => { closeWindowWithoutQuitPrompt.add(win.id); win.close(); @@ -5778,9 +5839,7 @@ app.whenReady().then(async () => { closeWindowWithoutPrompt(win); return; } - if (!confirmNoRunningLaneDeleteForQuit(win)) return; - if (!confirmQuitWarning(win)) return; - requestAppShutdown({ reason: "window_close", exitCode: 0 }); + requestQuitAfterWarnings(win, "window_close"); }; const FILE_LIMIT_CODES = new Set(["EMFILE", "ENFILE"]); @@ -5943,6 +6002,31 @@ app.whenReady().then(async () => { return getWindowSession(win.id); }; + dispatchAppNavigationRequest = (request) => { + void (async () => { + let targetWindow = + BrowserWindow.getFocusedWindow() ?? + BrowserWindow.getAllWindows().find((win) => !win.isDestroyed()) ?? + null; + if (!targetWindow) { + const opened = await openAdeWindow(); + targetWindow = opened.windowId != null ? BrowserWindow.fromId(opened.windowId) : null; + } + if (!targetWindow || targetWindow.isDestroyed()) return; + if (targetWindow.isMinimized()) targetWindow.restore(); + targetWindow.show(); + targetWindow.focus(); + targetWindow.webContents.send(IPC.appNavigate, request); + })().catch((error: unknown) => { + getActiveContext().logger.warn("deeplink.dispatch_window_failed", { + error: error instanceof Error ? error.message : String(error), + }); + }); + }; + for (const request of pendingAppNavigationRequests.splice(0)) { + dispatchAppNavigationRequest(request); + } + const openProjectFileRequest = async (filePath: string): Promise<void> => { const projectRoot = normalizeProjectPath(filePath); if (!isLikelyRepoRoot(projectRoot)) return; @@ -6129,9 +6213,7 @@ app.whenReady().then(async () => { if (shutdownFinalized) return; event.preventDefault(); if (shutdownRequested) return; - if (!confirmNoRunningLaneDeleteForQuit()) return; - if (!confirmQuitWarning()) return; - requestAppShutdown({ reason: "before_quit", exitCode: 0 }); + requestQuitAfterWarnings(null, "before_quit"); }); }); diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index 1eae7339b..833b7f015 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -15489,6 +15489,7 @@ export function createAgentChatService(args: { toolType: toolTypeFromProvider(effectiveProvider), resumeCommand: resumeCommandForProvider(effectiveProvider, sessionId), ownerPid: processRegistry?.pid ?? null, + ownerProcessStartedAt: processRegistry?.startedAt ?? null, }); if (normalizedTitle.length > 0) { sessionService.updateMeta({ sessionId, title: initialTitle, manuallyNamed: true }); @@ -19555,7 +19556,7 @@ export function createAgentChatService(args: { processRegistry && row?.ownerPid != null && row.ownerPid !== processRegistry.pid - && processRegistry.isPidLive(row.ownerPid) + && processRegistry.isProcessIdentityLive(row.ownerPid, row.ownerProcessStartedAt) ) { throw new Error("Chat session is owned by another ADE process; cannot resume from here."); } @@ -19701,7 +19702,7 @@ export function createAgentChatService(args: { persistChatState(managed); if (processRegistry) { - sessionService.setOwnerPid(sessionId, processRegistry.pid); + sessionService.setOwnerPid(sessionId, processRegistry.pid, processRegistry.startedAt); } return managed.session; }; diff --git a/apps/desktop/src/main/services/deeplinks/protocolHandler.ts b/apps/desktop/src/main/services/deeplinks/protocolHandler.ts index 47681acb1..6d1673deb 100644 --- a/apps/desktop/src/main/services/deeplinks/protocolHandler.ts +++ b/apps/desktop/src/main/services/deeplinks/protocolHandler.ts @@ -18,10 +18,11 @@ export type DeeplinkDispatcher = ( ) => Promise<void> | void; const ADE_OPEN_HTTPS_RE = /^https?:\/\/ade\.app\/open\b/i; +const ADE_SCHEME_RE = new RegExp(`^${ADE_DEEPLINK_SCHEME}://`, "i"); function isAdeDeeplinkArg(arg: unknown): arg is string { if (typeof arg !== "string") return false; - return arg.startsWith(`${ADE_DEEPLINK_SCHEME}://`) || ADE_OPEN_HTTPS_RE.test(arg); + return ADE_SCHEME_RE.test(arg) || ADE_OPEN_HTTPS_RE.test(arg); } /** @@ -89,15 +90,24 @@ export function registerAdeProtocolHandler(options: { log("deeplink.scheme_skipped", { scheme: ADE_DEEPLINK_SCHEME }); } + const coldStartDeeplinkArgs = process.argv.slice(1).filter(isAdeDeeplinkArg); + // Single-instance lock: a second invocation routes through `second-instance` - // instead of starting a fresh Electron process. We rely on whoever wires - // this up to have already called `app.whenReady()` semantics correctly; - // requesting the lock is idempotent. + // instead of starting a fresh Electron process. Non-claiming channels can + // still open as regular app instances when they were not launched to handle + // a deeplink. const acquired = app.requestSingleInstanceLock(); if (!acquired) { - log("deeplink.single_instance.lock_lost", {}); - app.quit(); - return; + const shouldForwardToLockHolder = claimAsDefault || coldStartDeeplinkArgs.length > 0; + log("deeplink.single_instance.lock_lost", { + claimAsDefault, + deeplinkArgCount: coldStartDeeplinkArgs.length, + shouldForwardToLockHolder, + }); + if (shouldForwardToLockHolder) { + app.quit(); + return; + } } // Buffer URLs received before whenReady so they aren't dropped. @@ -137,8 +147,8 @@ export function registerAdeProtocolHandler(options: { }); // Pick up any URL embedded in this process's own argv (Windows cold-start). - for (const arg of process.argv.slice(1)) { - if (isAdeDeeplinkArg(arg)) pendingUrls.push(arg); + for (const arg of coldStartDeeplinkArgs) { + pendingUrls.push(arg); } // Flush buffer once the app is ready. Use `whenReady()` rather than diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index 87c1d88ea..1328db284 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -3629,7 +3629,8 @@ export function registerIpc({ clipboard.writeText(text); }); - ipcMain.handle(IPC.appReadClipboardText, async (): Promise<string> => { + ipcMain.handle(IPC.appReadClipboardText, async (event): Promise<string> => { + assertTrustedAppControlSender(event, IPC.appReadClipboardText); return clipboard.readText() ?? ""; }); diff --git a/apps/desktop/src/main/services/ipc/runtimeBridge.ts b/apps/desktop/src/main/services/ipc/runtimeBridge.ts index b014dcfdb..7e79fb705 100644 --- a/apps/desktop/src/main/services/ipc/runtimeBridge.ts +++ b/apps/desktop/src/main/services/ipc/runtimeBridge.ts @@ -79,6 +79,7 @@ const REMOTE_RUNTIME_SYNC_METHODS = new Set([ type RuntimeEventWindowSubscription = { bindingKey: string; + requestKey: string; cleanup: (() => void) | null; }; @@ -263,16 +264,17 @@ export function registerRuntimeBridge({ const ensureRuntimeEventSubscription = ( sender: WebContents, bindingKey: string, + requestKey: string, subscribe: RuntimeEventSubscribe, ): void => { const existing = runtimeEventSubscriptions.get(sender.id); - if (existing?.bindingKey === bindingKey) return; + if (existing?.requestKey === requestKey) return; cleanupRuntimeEventSubscription(sender.id); watchRuntimeEventSender(sender); - runtimeEventSubscriptions.set(sender.id, { bindingKey, cleanup: null }); + runtimeEventSubscriptions.set(sender.id, { bindingKey, requestKey, cleanup: null }); const onEnded = () => { const current = runtimeEventSubscriptions.get(sender.id); - if (current?.bindingKey === bindingKey) { + if (current?.requestKey === requestKey) { runtimeEventSubscriptions.delete(sender.id); } }; @@ -284,7 +286,7 @@ export function registerRuntimeBridge({ const current = runtimeEventSubscriptions.get(sender.id); if ( !current || - current.bindingKey !== bindingKey || + current.requestKey !== requestKey || sender.isDestroyed() ) { cleanup(); @@ -294,7 +296,7 @@ export function registerRuntimeBridge({ }) .catch((error) => { const current = runtimeEventSubscriptions.get(sender.id); - if (current?.bindingKey === bindingKey && !current.cleanup) { + if (current?.requestKey === requestKey && !current.cleanup) { runtimeEventSubscriptions.delete(sender.id); } console.warn("Runtime event subscription failed", error); @@ -686,6 +688,7 @@ export function registerRuntimeBridge({ ensureRuntimeEventSubscription( event.sender, binding.key, + `${binding.key}:${arg?.request?.category ?? "*"}`, (onEvent, onEnded) => localRuntimeConnectionPool.subscribeEventsForRoot( rootPath, @@ -732,6 +735,7 @@ export function registerRuntimeBridge({ ensureRuntimeEventSubscription( event.sender, `remote:${target.id}:${projectId}`, + `remote:${target.id}:${projectId}:${arg?.request?.category ?? "*"}`, (onEvent, onEnded) => remoteConnectionPool.subscribeEventsForTarget( target, diff --git a/apps/desktop/src/main/services/pty/ptyService.test.ts b/apps/desktop/src/main/services/pty/ptyService.test.ts index 558d775f3..f3dfaf8e7 100644 --- a/apps/desktop/src/main/services/pty/ptyService.test.ts +++ b/apps/desktop/src/main/services/pty/ptyService.test.ts @@ -250,7 +250,9 @@ function createHarness(overrides: { } | null; processRegistry?: { pid: number; + startedAt?: string | null; isPidLive: ReturnType<typeof vi.fn>; + isProcessIdentityLive?: ReturnType<typeof vi.fn>; } | null; } = {}) { const mockPty = createMockPty(); @@ -2370,7 +2372,11 @@ describe("ptyService", () => { it("skips orphan dispose when a live peer owns the session", async () => { const processRegistry = { pid: 12_345, + startedAt: "2026-03-17T00:00:00.000Z", isPidLive: vi.fn((pid: number) => pid === 99_999), + isProcessIdentityLive: vi.fn((pid: number, startedAt: string | null) => ( + pid === 99_999 && startedAt === "2026-03-17T00:01:00.000Z" + )), }; const { service, sessionService, broadcastExit, logger } = createHarness({ processRegistry }); sessionService.get.mockReturnValue({ @@ -2379,6 +2385,7 @@ describe("ptyService", () => { laneId: "lane-1", tracked: true, ownerPid: 99_999, + ownerProcessStartedAt: "2026-03-17T00:01:00.000Z", lastOutputPreview: "still running elsewhere", }); @@ -2386,7 +2393,7 @@ describe("ptyService", () => { expect(sessionService.end).not.toHaveBeenCalled(); expect(broadcastExit).not.toHaveBeenCalled(); - expect(processRegistry.isPidLive).toHaveBeenCalledWith(99_999); + expect(processRegistry.isProcessIdentityLive).toHaveBeenCalledWith(99_999, "2026-03-17T00:01:00.000Z"); expect(logger.warn).toHaveBeenCalledWith( "pty.dispose_skipped_owned_by_peer", expect.objectContaining({ @@ -2400,7 +2407,9 @@ describe("ptyService", () => { it("orphan dispose still ends sessions owned by us or dead peers", async () => { const processRegistry = { pid: 12_345, + startedAt: "2026-03-17T00:00:00.000Z", isPidLive: vi.fn(() => false), + isProcessIdentityLive: vi.fn(() => false), }; const { service, sessionService } = createHarness({ processRegistry }); sessionService.get.mockReturnValueOnce({ @@ -2433,7 +2442,12 @@ describe("ptyService", () => { it("writes owner_pid when creating a tracked PTY session", async () => { const { service, sessionService } = createHarness({ - processRegistry: { pid: 12_345, isPidLive: vi.fn() }, + processRegistry: { + pid: 12_345, + startedAt: "2026-03-17T00:00:00.000Z", + isPidLive: vi.fn(), + isProcessIdentityLive: vi.fn(), + }, }); await service.create({ @@ -2447,6 +2461,7 @@ describe("ptyService", () => { expect(sessionService.create).toHaveBeenCalledWith(expect.objectContaining({ ownerPid: 12_345, + ownerProcessStartedAt: "2026-03-17T00:00:00.000Z", })); }); diff --git a/apps/desktop/src/main/services/pty/ptyService.ts b/apps/desktop/src/main/services/pty/ptyService.ts index 993f30227..38bf23e65 100644 --- a/apps/desktop/src/main/services/pty/ptyService.ts +++ b/apps/desktop/src/main/services/pty/ptyService.ts @@ -698,6 +698,7 @@ export function createPtyService({ let ptyDataMaxBatchChars = 0; const terminalSnapshotDir = path.join(projectRoot, ".ade", "cache", "terminal-snapshots"); const ownerPid = processRegistry?.pid ?? null; + const ownerProcessStartedAt = processRegistry?.startedAt ?? null; const getSessionIntelligence = () => { const ai = projectConfigService?.get().effective.ai; @@ -2442,6 +2443,7 @@ export function createPtyService({ ptyId: attachedPtyId, startedAt: new Date(attachedEntry.createdAt).toISOString(), ...(ownerPid != null ? { ownerPid } : {}), + ...(ownerProcessStartedAt != null ? { ownerProcessStartedAt } : {}), }); setRuntimeState(existingSession.id, "running"); } @@ -2500,6 +2502,7 @@ export function createPtyService({ resumeMetadata: initialResumeMetadata, chatSessionId, ownerPid, + ownerProcessStartedAt, }); setRuntimeState(sessionId, "running"); @@ -2662,6 +2665,7 @@ export function createPtyService({ ptyId, startedAt, ...(ownerPid != null ? { ownerPid } : {}), + ...(ownerProcessStartedAt != null ? { ownerProcessStartedAt } : {}), }); setRuntimeState(sessionId, "running"); Promise.resolve() @@ -3519,7 +3523,7 @@ export function createPtyService({ ownerPid != null && session.ownerPid != null && session.ownerPid !== ownerPid - && processRegistry?.isPidLive(session.ownerPid) + && processRegistry?.isProcessIdentityLive(session.ownerPid, session.ownerProcessStartedAt) ) { logger.warn("pty.dispose_skipped_owned_by_peer", { ptyId, diff --git a/apps/desktop/src/main/services/runtime/processRegistryService.test.ts b/apps/desktop/src/main/services/runtime/processRegistryService.test.ts index 368e3bd2e..48368e71d 100644 --- a/apps/desktop/src/main/services/runtime/processRegistryService.test.ts +++ b/apps/desktop/src/main/services/runtime/processRegistryService.test.ts @@ -117,6 +117,11 @@ describe("processRegistryService", () => { expect(service.listLivePids()).toEqual(new Set([12_345, 99_999])); expect(service.isPidLive(99_999)).toBe(true); + expect(service.listLiveProcessIdentities()).toEqual(expect.arrayContaining([ + { pid: 99_999, startedAt: "2026-03-17T00:00:10.000Z" }, + ])); + expect(service.isProcessIdentityLive(99_999, "2026-03-17T00:00:10.000Z")).toBe(true); + expect(service.isProcessIdentityLive(99_999, "2026-03-17T00:00:09.000Z")).toBe(false); db.close(); }); diff --git a/apps/desktop/src/main/services/runtime/processRegistryService.ts b/apps/desktop/src/main/services/runtime/processRegistryService.ts index 2bbb259dc..8828ed990 100644 --- a/apps/desktop/src/main/services/runtime/processRegistryService.ts +++ b/apps/desktop/src/main/services/runtime/processRegistryService.ts @@ -14,6 +14,11 @@ export type RuntimeProcessRow = { lastSeen: string; }; +export type RuntimeProcessIdentity = { + pid: number; + startedAt: string; +}; + export type ProcessRegistryServiceOptions = { db: AdeDb; logger: Logger; @@ -52,6 +57,7 @@ export function createProcessRegistryService(options: ProcessRegistryServiceOpti let heartbeatTimer: ReturnType<typeof setInterval> | null = null; let started = false; + let ownStartedAt: string | null = nowIso(); const writeOwnRow = (startedAtIso: string | null): void => { const lastSeen = nowIso(); @@ -76,11 +82,16 @@ export function createProcessRegistryService(options: ProcessRegistryServiceOpti pid, role, + get startedAt(): string | null { + return ownStartedAt; + }, + start(): void { if (started) return; started = true; + ownStartedAt = ownStartedAt ?? nowIso(); try { - writeOwnRow(nowIso()); + writeOwnRow(ownStartedAt); this.pruneStale(); } catch (error) { logger.warn("process_registry.start_failed", { @@ -132,6 +143,7 @@ export function createProcessRegistryService(options: ProcessRegistryServiceOpti error: error instanceof Error ? error.message : String(error), }); } + ownStartedAt = null; }, /** @@ -140,22 +152,33 @@ export function createProcessRegistryService(options: ProcessRegistryServiceOpti * `terminal_sessions.owner_pid` is still owned by a live sibling. */ listLivePids(): Set<number> { + return new Set(this.listLiveProcessIdentities().map((identity) => identity.pid)); + }, + + listLiveProcessIdentities(): RuntimeProcessIdentity[] { const cutoffIso = new Date(Date.now() - livenessWindowMs).toISOString(); - const rows = db.all<{ pid: number }>( - "select pid from runtime_processes where last_seen >= ?", + const rows = db.all<{ pid: number; started_at: string }>( + "select pid, started_at from runtime_processes where last_seen >= ?", [cutoffIso], ); - const live = new Set<number>(); + const live = new Map<number, RuntimeProcessIdentity>(); for (const row of rows) { - if (typeof row.pid === "number" && Number.isFinite(row.pid)) { - live.add(row.pid); + if ( + typeof row.pid === "number" + && Number.isFinite(row.pid) + && typeof row.started_at === "string" + && row.started_at.trim().length > 0 + ) { + live.set(row.pid, { pid: row.pid, startedAt: row.started_at }); } } // Always include our own pid even if the heartbeat row hasn't been // written yet (e.g. start() not called, or first tick hasn't fired) — // we are demonstrably alive right now. - live.add(pid); - return live; + if (ownStartedAt) { + live.set(pid, { pid, startedAt: ownStartedAt }); + } + return Array.from(live.values()); }, /** Quick "is this peer still heartbeating" check. */ @@ -170,6 +193,20 @@ export function createProcessRegistryService(options: ProcessRegistryServiceOpti return row != null; }, + /** Quick "is this exact peer process incarnation still heartbeating" check. */ + isProcessIdentityLive(candidatePid: number | null | undefined, candidateStartedAt: string | null | undefined): boolean { + if (candidatePid == null || !Number.isFinite(candidatePid)) return false; + const startedAt = typeof candidateStartedAt === "string" ? candidateStartedAt.trim() : ""; + if (!startedAt) return false; + if (candidatePid === pid && ownStartedAt === startedAt) return true; + const cutoffIso = new Date(Date.now() - livenessWindowMs).toISOString(); + const row = db.get<{ pid: number }>( + "select pid from runtime_processes where pid = ? and started_at = ? and last_seen >= ? limit 1", + [candidatePid, startedAt, cutoffIso], + ); + return row != null; + }, + /** Read-only view of every heartbeat row (live and stale). */ listAllProcesses(): RuntimeProcessRow[] { const rows = db.all<{ diff --git a/apps/desktop/src/main/services/sessions/sessionService.test.ts b/apps/desktop/src/main/services/sessions/sessionService.test.ts index 705ad22b7..79f4314b5 100644 --- a/apps/desktop/src/main/services/sessions/sessionService.test.ts +++ b/apps/desktop/src/main/services/sessions/sessionService.test.ts @@ -564,6 +564,61 @@ describe("sessionService resume metadata", () => { activeDisposers.push(async () => db.close()); }); + it("requires owner process identity to preserve peer-owned running sessions", async () => { + const projectRoot = makeProjectRoot("ade-session-service-"); + const dbPath = path.join(projectRoot, ".ade", "ade.db"); + const db = await openKvDb(dbPath, createLogger() as any); + insertProjectGraph(db); + const service = createSessionService({ db }); + + service.create({ + sessionId: "session-live-identity", + laneId: "lane-1", + ptyId: "pty-live-identity", + tracked: true, + title: "Live owner", + startedAt: "2026-03-17T00:10:00.000Z", + transcriptPath: "/tmp/session-live-identity.log", + toolType: "claude", + ownerPid: 12_345, + ownerProcessStartedAt: "2026-03-17T00:00:00.000Z", + }); + service.create({ + sessionId: "session-reused-pid", + laneId: "lane-1", + ptyId: "pty-reused-pid", + tracked: true, + title: "Reused PID", + startedAt: "2026-03-17T00:11:00.000Z", + transcriptPath: "/tmp/session-reused-pid.log", + toolType: "codex", + ownerPid: 12_345, + ownerProcessStartedAt: "2026-03-16T23:59:00.000Z", + }); + + const reconciled = service.reconcileStaleRunningSessions({ + endedAt: "2026-03-17T00:20:00.000Z", + status: "disposed", + liveOwnerPids: new Set([12_345]), + liveOwnerIdentities: [{ pid: 12_345, startedAt: "2026-03-17T00:00:00.000Z" }], + }); + + expect(reconciled).toBe(1); + expect(service.get("session-live-identity")).toEqual(expect.objectContaining({ + status: "running", + ownerPid: 12_345, + ownerProcessStartedAt: "2026-03-17T00:00:00.000Z", + })); + expect(service.get("session-reused-pid")).toEqual(expect.objectContaining({ + status: "disposed", + ptyId: null, + ownerPid: 12_345, + ownerProcessStartedAt: "2026-03-16T23:59:00.000Z", + })); + + activeDisposers.push(async () => db.close()); + }); + it("omitted live owner set only reconciles legacy ownerless sessions", async () => { const projectRoot = makeProjectRoot("ade-session-service-"); const dbPath = path.join(projectRoot, ".ade", "ade.db"); diff --git a/apps/desktop/src/main/services/sessions/sessionService.ts b/apps/desktop/src/main/services/sessions/sessionService.ts index 89203f246..86faa4b8a 100644 --- a/apps/desktop/src/main/services/sessions/sessionService.ts +++ b/apps/desktop/src/main/services/sessions/sessionService.ts @@ -29,6 +29,7 @@ type SessionRow = { laneName: string; ptyId: string | null; ownerPid: number | null; + ownerProcessStartedAt: string | null; tracked: number; pinned: number; manuallyNamed: number; @@ -67,6 +68,7 @@ const SESSION_COLUMNS = ` l.name as laneName, s.pty_id as ptyId, s.owner_pid as ownerPid, + s.owner_process_started_at as ownerProcessStartedAt, s.tracked as tracked, s.pinned as pinned, s.manually_named as manuallyNamed, @@ -209,6 +211,11 @@ function normalizeOwnerPid(ownerPid: unknown): number | null { return normalized > 0 ? normalized : null; } +function normalizeOwnerProcessStartedAt(startedAt: unknown): string | null { + const normalized = typeof startedAt === "string" ? startedAt.trim() : ""; + return normalized.length ? normalized : null; +} + export function createSessionService({ db }: { db: AdeDb }) { const changeListeners = new Set<(event: TerminalSessionChangedEvent) => void>(); @@ -296,6 +303,7 @@ export function createSessionService({ db }: { db: AdeDb }) { archivedAt: row.archivedAt ?? null, chatSessionId: row.chatSessionId ?? null, ownerPid: normalizeOwnerPid(row.ownerPid), + ownerProcessStartedAt: normalizeOwnerProcessStartedAt(row.ownerProcessStartedAt), }; }; @@ -494,11 +502,13 @@ export function createSessionService({ db }: { db: AdeDb }) { status, excludeToolTypes, liveOwnerPids, + liveOwnerIdentities, }: { endedAt?: string; status?: TerminalSessionStatus; excludeToolTypes?: string[]; liveOwnerPids?: Set<number>; + liveOwnerIdentities?: Array<{ pid: number; startedAt: string }>; } = {}): number { const normalizedExcludedToolTypes = Array.isArray(excludeToolTypes) ? excludeToolTypes @@ -513,11 +523,29 @@ export function createSessionService({ db }: { db: AdeDb }) { .map((pid) => normalizeOwnerPid(pid)) .filter((pid): pid is number => pid != null) : []; - const ownerGuardSql = ownerParams.length - ? ` and (owner_pid is null or owner_pid not in (${ownerParams.map(() => "?").join(", ")}))` - : " and owner_pid is null"; + const ownerIdentities = liveOwnerIdentities + ? liveOwnerIdentities + .map((identity) => ({ + pid: normalizeOwnerPid(identity?.pid), + startedAt: normalizeOwnerProcessStartedAt(identity?.startedAt), + })) + .filter((identity): identity is { pid: number; startedAt: string } => ( + identity.pid != null && identity.startedAt != null + )) + : []; + const ownerIdentityParams = ownerIdentities.flatMap((identity) => [ + identity.pid, + identity.startedAt, + ]); + const ownerGuardSql = liveOwnerPids === undefined + ? " and owner_pid is null" + : ownerIdentities.length + ? ` and (owner_pid is null or owner_process_started_at is null or not (${ownerIdentities.map(() => "(owner_pid = ? and owner_process_started_at = ?)").join(" or ")}))` + : ownerParams.length + ? ` and (owner_pid is null or owner_pid not in (${ownerParams.map(() => "?").join(", ")}))` + : ""; const whereSql = `status = 'running'${exclusionSql}${ownerGuardSql}`; - const params = [...normalizedExcludedToolTypes, ...ownerParams]; + const params = [...normalizedExcludedToolTypes, ...(ownerIdentities.length ? ownerIdentityParams : ownerParams)]; const rows = db.all<{ id: string }>( `select id from terminal_sessions where ${whereSql}`, params, @@ -668,6 +696,7 @@ export function createSessionService({ db }: { db: AdeDb }) { resumeMetadata, chatSessionId, ownerPid, + ownerProcessStartedAt, }: { sessionId: string; laneId: string; @@ -681,6 +710,7 @@ export function createSessionService({ db }: { db: AdeDb }) { resumeMetadata?: TerminalResumeMetadata | null; chatSessionId?: string | null; ownerPid?: number | null; + ownerProcessStartedAt?: string | null; }): void { const normalizedToolType = normalizeToolType(toolType); const normalizedMetadata = normalizeResumeMetadata(resumeMetadata); @@ -691,12 +721,13 @@ export function createSessionService({ db }: { db: AdeDb }) { ? chatSessionId.trim() : null; const normalizedOwnerPid = normalizeOwnerPid(ownerPid); + const normalizedOwnerProcessStartedAt = normalizeOwnerProcessStartedAt(ownerProcessStartedAt); db.run( ` insert into terminal_sessions( id, lane_id, pty_id, tracked, title, started_at, ended_at, exit_code, transcript_path, - head_sha_start, head_sha_end, status, last_output_preview, last_output_at, summary, tool_type, resume_command, resume_metadata_json, chat_session_id, owner_pid - ) values (?, ?, ?, ?, ?, ?, null, null, ?, null, null, 'running', null, null, null, ?, ?, ?, ?, ?) + head_sha_start, head_sha_end, status, last_output_preview, last_output_at, summary, tool_type, resume_command, resume_metadata_json, chat_session_id, owner_pid, owner_process_started_at + ) values (?, ?, ?, ?, ?, ?, null, null, ?, null, null, 'running', null, null, null, ?, ?, ?, ?, ?, ?) `, [ sessionId, @@ -711,6 +742,7 @@ export function createSessionService({ db }: { db: AdeDb }) { serializeResumeMetadata(normalizedMetadata), normalizedChatSessionId, normalizedOwnerPid, + normalizedOwnerProcessStartedAt, ] ); emitChanged({ sessionId, reason: "created" }); @@ -747,11 +779,14 @@ export function createSessionService({ db }: { db: AdeDb }) { emitChanged({ sessionId: trimmed, reason: "meta-updated" }); }, - reattach(args: { sessionId: string; ptyId: string | null; startedAt: string; ownerPid?: number | null }): TerminalSessionSummary | null { + reattach(args: { sessionId: string; ptyId: string | null; startedAt: string; ownerPid?: number | null; ownerProcessStartedAt?: string | null }): TerminalSessionSummary | null { const sessionId = typeof args.sessionId === "string" ? args.sessionId.trim() : ""; if (!sessionId) return null; const ownerPid = normalizeOwnerPid(args.ownerPid); - const ownerSql = args.ownerPid !== undefined ? ",\n owner_pid = ?" : ""; + const ownerProcessStartedAt = normalizeOwnerProcessStartedAt(args.ownerProcessStartedAt); + const ownerSql = args.ownerPid !== undefined || args.ownerProcessStartedAt !== undefined + ? ",\n owner_pid = ?,\n owner_process_started_at = ?" + : ""; db.run( ` update terminal_sessions @@ -764,8 +799,8 @@ export function createSessionService({ db }: { db: AdeDb }) { head_sha_end = null${ownerSql} where id = ? `, - args.ownerPid !== undefined - ? [args.ptyId, args.startedAt, ownerPid, sessionId] + args.ownerPid !== undefined || args.ownerProcessStartedAt !== undefined + ? [args.ptyId, args.startedAt, ownerPid, ownerProcessStartedAt, sessionId] : [args.ptyId, args.startedAt, sessionId], ); // Resuming a stopped CLI session lands here (ptyService.create → @@ -775,10 +810,13 @@ export function createSessionService({ db }: { db: AdeDb }) { return this.get(sessionId); }, - setOwnerPid(sessionId: string, ownerPid: number | null): void { + setOwnerPid(sessionId: string, ownerPid: number | null, ownerProcessStartedAt?: string | null): void { const trimmed = typeof sessionId === "string" ? sessionId.trim() : ""; if (!trimmed) return; - db.run("update terminal_sessions set owner_pid = ? where id = ?", [normalizeOwnerPid(ownerPid), trimmed]); + db.run( + "update terminal_sessions set owner_pid = ?, owner_process_started_at = ? where id = ?", + [normalizeOwnerPid(ownerPid), normalizeOwnerProcessStartedAt(ownerProcessStartedAt), trimmed], + ); emitChanged({ sessionId: trimmed, reason: "meta-updated" }); }, diff --git a/apps/desktop/src/main/services/state/kvDb.ts b/apps/desktop/src/main/services/state/kvDb.ts index 774d4c927..bbd0136f2 100644 --- a/apps/desktop/src/main/services/state/kvDb.ts +++ b/apps/desktop/src/main/services/state/kvDb.ts @@ -1179,6 +1179,7 @@ function migrate(db: MigrationDb) { resume_metadata_json text, archived_at text, chat_session_id text, + owner_process_started_at text, foreign key(lane_id) references lanes(id) ) `); @@ -1201,6 +1202,8 @@ function migrate(db: MigrationDb) { // pre-migration rows pre-date ownership tracking. try { db.run("alter table terminal_sessions add column owner_pid integer"); } catch {} try { db.run("create index if not exists idx_terminal_sessions_owner_pid on terminal_sessions(owner_pid)"); } catch {} + try { db.run("alter table terminal_sessions add column owner_process_started_at text"); } catch {} + try { db.run("create index if not exists idx_terminal_sessions_owner_process on terminal_sessions(owner_pid, owner_process_started_at)"); } catch {} // Process liveness registry. Every ADE process (desktop main, TUI runtime, // ade-serve daemon) writes its pid here on boot and refreshes last_seen diff --git a/apps/desktop/src/renderer/components/app/ClipboardDeeplinkBanner.tsx b/apps/desktop/src/renderer/components/app/ClipboardDeeplinkBanner.tsx index b4270edd7..0658a39bd 100644 --- a/apps/desktop/src/renderer/components/app/ClipboardDeeplinkBanner.tsx +++ b/apps/desktop/src/renderer/components/app/ClipboardDeeplinkBanner.tsx @@ -2,6 +2,7 @@ import React from "react"; import { Link as LinkIcon, X } from "@phosphor-icons/react"; import { + buildDeeplink, describeTarget, looksLikeAdeDeeplink, parseDeeplink, @@ -55,7 +56,9 @@ export function ClipboardDeeplinkBanner(): React.ReactElement | null { const onOpen = () => { const opener = window.ade?.app?.openExternal; if (typeof opener === "function") { - void opener(candidate.url).catch(() => {}); + const parsed = parseDeeplink(candidate.url); + const url = parsed.ok ? buildDeeplink(parsed.target, { form: "ade" }) : candidate.url; + void opener(url).catch(() => {}); } dismissedRef.current.add(candidate.url); setCandidate(null); diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index d7c96d9bd..f80d35e6b 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -154,9 +154,9 @@ const SUBAGENT_AUTOOPEN_FIRED_KEY_PREFIX = "ade.chat.subagentAutoOpenFired"; const SUBAGENT_AUTOOPEN_FIRED_TTL_MS = 7 * 24 * 60 * 60 * 1000; export const DEFAULT_PARALLEL_ATTACHMENT_REQUEST = "Please review the attached files."; -const CHAT_TOOLBAR_ACTION_BASE = +const chatToolbarActionBase = "relative inline-flex h-7 shrink-0 items-center gap-1.5 rounded-md border px-2.5 font-sans text-[10px] font-medium transition-colors"; -const CHAT_TOOLBAR_ACTION_IDLE = +const chatToolbarActionIdle = "border-white/[0.06] bg-white/[0.02] text-muted-fg/40 hover:border-white/[0.10] hover:text-fg/65"; const AUTO_CREATE_LANE_OPTION_ID = "__ade_auto_create_lane__"; @@ -6239,10 +6239,10 @@ export function AgentChatPane({ <button type="button" className={cn( - CHAT_TOOLBAR_ACTION_BASE, + chatToolbarActionBase, iosSimulatorOpen ? "border-cyan-300/22 bg-cyan-500/10 text-cyan-100/80" - : CHAT_TOOLBAR_ACTION_IDLE, + : chatToolbarActionIdle, )} onClick={() => { setIosSimulatorOpen((current) => { @@ -6282,10 +6282,10 @@ export function AgentChatPane({ <button type="button" className={cn( - CHAT_TOOLBAR_ACTION_BASE, + chatToolbarActionBase, appControlOpen ? "border-sky-300/22 bg-sky-500/10 text-sky-100/80" - : CHAT_TOOLBAR_ACTION_IDLE, + : chatToolbarActionIdle, )} onClick={() => { setAppControlOpen((current) => { @@ -6333,10 +6333,10 @@ export function AgentChatPane({ <button type="button" className={cn( - CHAT_TOOLBAR_ACTION_BASE, + chatToolbarActionBase, proofDrawerOpen ? "border-emerald-400/22 bg-emerald-500/10 text-emerald-100/80" - : CHAT_TOOLBAR_ACTION_IDLE, + : chatToolbarActionIdle, )} onClick={() => { setProofDrawerOpen((current) => { @@ -6375,10 +6375,10 @@ export function AgentChatPane({ <button type="button" className={cn( - CHAT_TOOLBAR_ACTION_BASE, + chatToolbarActionBase, subagentPaneOpen ? "border-amber-300/22 bg-amber-500/10 text-amber-100/80" - : CHAT_TOOLBAR_ACTION_IDLE, + : chatToolbarActionIdle, )} onClick={() => { setSubagentPaneOpen((current) => { @@ -6467,7 +6467,7 @@ export function AgentChatPane({ <button type="button" className={cn( - CHAT_TOOLBAR_ACTION_BASE, + chatToolbarActionBase, "border-violet-400/[0.12] bg-violet-500/[0.04] text-violet-200/60 hover:border-violet-400/20 hover:bg-violet-500/[0.08] hover:text-violet-200/80 disabled:cursor-not-allowed disabled:opacity-40", )} onClick={() => { diff --git a/apps/desktop/src/shared/adeDeeplinkFooter.ts b/apps/desktop/src/shared/adeDeeplinkFooter.ts index 8fcd4d79c..3fd78ea95 100644 --- a/apps/desktop/src/shared/adeDeeplinkFooter.ts +++ b/apps/desktop/src/shared/adeDeeplinkFooter.ts @@ -101,11 +101,12 @@ export function hasAdeDeeplinkFooter(body: string | null | undefined): boolean { } function formatMarker(opts: AdeDeeplinkFooterOptions): string { + const enc = (value: string) => encodeURIComponent(value); const parts = [ "v=1", `type=${opts.prNumber ? "pr" : "branch"}`, - `repo=${opts.repoOwner}/${opts.repoName}`, - `branch=${opts.branch}`, + `repo=${enc(opts.repoOwner)}/${enc(opts.repoName)}`, + `branch=${enc(opts.branch)}`, ]; if (opts.prNumber) parts.push(`num=${opts.prNumber}`); return `<!-- ade:link ${parts.join(" ")} -->`; diff --git a/apps/desktop/src/shared/types/sessions.ts b/apps/desktop/src/shared/types/sessions.ts index 1ac825b09..c84669024 100644 --- a/apps/desktop/src/shared/types/sessions.ts +++ b/apps/desktop/src/shared/types/sessions.ts @@ -65,6 +65,7 @@ export type TerminalSessionSummary = { laneName: string; ptyId: string | null; ownerPid?: number | null; + ownerProcessStartedAt?: string | null; tracked: boolean; pinned: boolean; manuallyNamed?: boolean; diff --git a/apps/ios/ADE/App/DeepLinkRouter.swift b/apps/ios/ADE/App/DeepLinkRouter.swift index 6446baa30..4e9ab10f1 100644 --- a/apps/ios/ADE/App/DeepLinkRouter.swift +++ b/apps/ios/ADE/App/DeepLinkRouter.swift @@ -38,9 +38,10 @@ final class DeepLinkRouter { // `ade://pr/<owner>/<repo>/<number>` (desktop cross-machine form) // Anything else is ignored so a malformed link can't crash navigation. if pathComponents.count >= 3 { - let raw = pathComponents[2] - guard !raw.isEmpty else { return } - post(kind: "pr", identifier: raw) + guard !pathComponents[0].isEmpty, + !pathComponents[1].isEmpty, + !pathComponents[2].isEmpty else { return } + postSendToMac(url: url) return } guard let raw = pathComponents.first, !raw.isEmpty else { return } diff --git a/apps/ios/ADE/Services/SyncService.swift b/apps/ios/ADE/Services/SyncService.swift index d59709767..6f2a4f150 100644 --- a/apps/ios/ADE/Services/SyncService.swift +++ b/apps/ios/ADE/Services/SyncService.swift @@ -7538,9 +7538,12 @@ extension SyncService { // Desktop `deeplinks.open` expects a `url` arg — the same `ade://...` // string the user tapped on iOS. We pass it through verbatim so the // host can reuse its own router. - if let url = payload["url"] as? String, !url.isEmpty { - args["url"] = url + guard let url = (payload["url"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines), + !url.isEmpty else { + return } + args["url"] = url } // For now we send via the opaque command envelope — the desktop's diff --git a/apps/ios/ADE/Views/Deeplinks/SendToMacCard.swift b/apps/ios/ADE/Views/Deeplinks/SendToMacCard.swift index 17ce19070..69416eaec 100644 --- a/apps/ios/ADE/Views/Deeplinks/SendToMacCard.swift +++ b/apps/ios/ADE/Views/Deeplinks/SendToMacCard.swift @@ -38,9 +38,13 @@ struct SendToMacTarget: Equatable, Identifiable { if parts.count >= 4, parts[2].lowercased() == "branch", !parts[0].isEmpty, - !parts[1].isEmpty, - !parts[3].isEmpty { - self.kind = .repoBranch(owner: parts[0], repo: parts[1], branch: parts[3]) + !parts[1].isEmpty { + let branch = parts.dropFirst(3).joined(separator: "/") + if branch.isEmpty { + self.kind = .other + } else { + self.kind = .repoBranch(owner: parts[0], repo: parts[1], branch: branch) + } } else { self.kind = .other } @@ -298,6 +302,7 @@ struct SendToMacCard: View { return "Send to Mac" } + @MainActor private func sendToMac() async { guard let sync = SyncService.shared else { // Without a SyncService singleton there's nothing to dispatch to; diff --git a/apps/web/src/app/pages/OpenPage.tsx b/apps/web/src/app/pages/OpenPage.tsx index 5e1eb1f52..ded82d2ea 100644 --- a/apps/web/src/app/pages/OpenPage.tsx +++ b/apps/web/src/app/pages/OpenPage.tsx @@ -11,11 +11,12 @@ type OpenTarget = | { kind: "lane"; laneId: string } | { kind: "branch"; repo: string; branch: string; pr?: number } | { kind: "pr"; repo: string; number: number } + | { kind: "linear-issue"; issueIdentifier: string; branch?: string } | { kind: "unknown" }; function parseQuery(search: string): OpenTarget { const params = new URLSearchParams(search); - const type = params.get("type"); + const type = (params.get("type") ?? "").toLowerCase(); if (type === "lane") { const laneId = params.get("id") ?? ""; if (laneId) return { kind: "lane", laneId }; @@ -37,6 +38,15 @@ function parseQuery(search: string): OpenTarget { return { kind: "pr", repo, number }; } } + if (type === "linear-issue") { + const issueIdentifier = params.get("issue") ?? ""; + const branch = params.get("branch") ?? ""; + if (issueIdentifier) { + return branch + ? { kind: "linear-issue", issueIdentifier, branch } + : { kind: "linear-issue", issueIdentifier }; + } + } return { kind: "unknown" }; } @@ -59,6 +69,10 @@ function buildAdeUrl(target: OpenTarget): string | null { if (!owner || !name) return null; return `ade://pr/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/${target.number}`; } + case "linear-issue": { + const base = `ade://linear-issue/${encodeURIComponent(target.issueIdentifier)}`; + return target.branch ? `${base}?branch=${encodeURIComponent(target.branch)}` : base; + } case "unknown": return null; } @@ -81,6 +95,11 @@ function describeTarget(target: OpenTarget): { title: string; summary: string } title: `Open ${target.repo}#${target.number} in ADE`, summary: `Pull request #${target.number}`, }; + case "linear-issue": + return { + title: `Open ${target.issueIdentifier} in ADE`, + summary: target.branch ? `Branch ${target.branch}` : "Linear issue", + }; case "unknown": return { title: "Open in ADE", From 758839222c2c0120373c1f7ec3a7f1feed1a2a1d Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Thu, 21 May 2026 04:55:00 -0400 Subject: [PATCH 5/6] ship: fix ci after review pass --- apps/ade-cli/src/adeRpcServer.test.ts | 16 +++++++++++++++- apps/ios/ADE/Resources/DatabaseBootstrap.sql | 5 +++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/apps/ade-cli/src/adeRpcServer.test.ts b/apps/ade-cli/src/adeRpcServer.test.ts index 2cdb1c4fc..58243adf1 100644 --- a/apps/ade-cli/src/adeRpcServer.test.ts +++ b/apps/ade-cli/src/adeRpcServer.test.ts @@ -1223,7 +1223,7 @@ describe("adeRpcServer", () => { }; runtime.sessionService.get.mockReturnValue(session); runtime.ptyService.list.mockReturnValue([session]); - await initialize(handler, { role: "external" }); + await initialize(handler, { role: "agent" }); const created = await handler({ jsonrpc: "2.0", @@ -1285,6 +1285,20 @@ describe("adeRpcServer", () => { expect(runtime.ptyService.list).toHaveBeenCalledWith({ laneId: "lane-1", limit: 20 }); }); + it("hides direct PTY RPC methods from external sessions", async () => { + const { runtime } = createRuntime(); + const handler = createAdeRpcRequestHandler({ runtime, serverVersion: "test" }); + await initialize(handler, { role: "external" }); + + await expect(handler({ + jsonrpc: "2.0", + id: 2, + method: "pty.create", + params: { args: { laneId: "lane-1", title: "Claude", cols: 120, rows: 40 } }, + })).rejects.toMatchObject({ code: JsonRpcErrorCode.methodNotFound }); + expect(runtime.ptyService.create).not.toHaveBeenCalled(); + }); + it("routes app/navigate through the runtime navigation service", async () => { const { runtime } = createRuntime(); const navigate = vi.fn(async () => ({ ok: true, mode: "desktop", windowId: 7 })); diff --git a/apps/ios/ADE/Resources/DatabaseBootstrap.sql b/apps/ios/ADE/Resources/DatabaseBootstrap.sql index b9863273b..884b3b836 100644 --- a/apps/ios/ADE/Resources/DatabaseBootstrap.sql +++ b/apps/ios/ADE/Resources/DatabaseBootstrap.sql @@ -163,6 +163,7 @@ create table if not exists terminal_sessions ( resume_metadata_json text, archived_at text, chat_session_id text, + owner_process_started_at text, foreign key(lane_id) references lanes(id) ); @@ -190,6 +191,10 @@ alter table terminal_sessions add column owner_pid integer; create index if not exists idx_terminal_sessions_owner_pid on terminal_sessions(owner_pid); +alter table terminal_sessions add column owner_process_started_at text; + +create index if not exists idx_terminal_sessions_owner_process on terminal_sessions(owner_pid, owner_process_started_at); + create table if not exists runtime_processes ( pid integer primary key, role text not null, From ff1e3fbaa3ff76fcaea5864e381c800a4229e98b Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Thu, 21 May 2026 05:20:29 -0400 Subject: [PATCH 6/6] ship: address follow-up review feedback --- apps/ade-cli/src/adeRpcServer.test.ts | 109 ++++++++++++++- apps/ade-cli/src/adeRpcServer.ts | 126 +++++++++++++++++- apps/ade-cli/src/tuiClient/app.tsx | 13 +- apps/desktop/src/main/main.ts | 16 ++- .../src/main/services/ipc/runtimeBridge.ts | 15 ++- apps/ios/ADE/Services/SyncService.swift | 10 +- 6 files changed, 266 insertions(+), 23 deletions(-) diff --git a/apps/ade-cli/src/adeRpcServer.test.ts b/apps/ade-cli/src/adeRpcServer.test.ts index 58243adf1..158d13ab5 100644 --- a/apps/ade-cli/src/adeRpcServer.test.ts +++ b/apps/ade-cli/src/adeRpcServer.test.ts @@ -1218,12 +1218,14 @@ describe("adeRpcServer", () => { const session = { id: "session-1", laneId: "lane-1", + ptyId: "pty-1", status: "running", ownerPid: 12_345, + chatSessionId: null, }; runtime.sessionService.get.mockReturnValue(session); runtime.ptyService.list.mockReturnValue([session]); - await initialize(handler, { role: "agent" }); + await initialize(handler, { role: "agent", chatSessionId: "session-1" }); const created = await handler({ jsonrpc: "2.0", @@ -1290,13 +1292,116 @@ describe("adeRpcServer", () => { const handler = createAdeRpcRequestHandler({ runtime, serverVersion: "test" }); await initialize(handler, { role: "external" }); + const blocked = [ + { + method: "pty.create", + params: { args: { laneId: "lane-1", title: "Claude", cols: 120, rows: 40 } }, + spy: runtime.ptyService.create, + }, + { + method: "pty.sendToSession", + params: { args: { sessionId: "session-1", text: "continue" } }, + spy: runtime.ptyService.sendToSession, + }, + { method: "pty.write", params: { args: { ptyId: "pty-1", data: "x" } }, spy: runtime.ptyService.write }, + { method: "pty.resize", params: { args: { ptyId: "pty-1", cols: 100, rows: 30 } }, spy: runtime.ptyService.resize }, + { method: "pty.dispose", params: { args: { ptyId: "pty-1", sessionId: "session-1" } }, spy: runtime.ptyService.dispose }, + { method: "pty.list", params: { args: { laneId: "lane-1", limit: 20 } }, spy: runtime.ptyService.list }, + ] as const; + + for (const [index, rpc] of blocked.entries()) { + await expect(handler({ + jsonrpc: "2.0", + id: 2 + index, + method: rpc.method, + params: rpc.params, + })).rejects.toMatchObject({ code: JsonRpcErrorCode.methodNotFound }); + expect(rpc.spy).not.toHaveBeenCalled(); + } + }); + + it("scopes direct PTY RPC methods to the caller terminal context", async () => { + const { runtime } = createRuntime(); + const handler = createAdeRpcRequestHandler({ runtime, serverVersion: "test" }); + const owned = { + id: "owned-session", + laneId: "lane-1", + ptyId: "pty-owned", + status: "running", + ownerPid: 12_345, + chatSessionId: null, + }; + const peer = { + id: "peer-session", + laneId: "lane-2", + ptyId: "pty-peer", + status: "running", + ownerPid: 54_321, + chatSessionId: null, + }; + runtime.sessionService.get.mockImplementation((sessionId: string) => { + if (sessionId === owned.id) return owned; + if (sessionId === peer.id) return peer; + return null; + }); + runtime.ptyService.list.mockImplementation((args: { laneId?: string } = {}) => + [owned, peer].filter((session) => !args.laneId || session.laneId === args.laneId)); + await initialize(handler, { role: "agent", chatSessionId: owned.id }); + await expect(handler({ jsonrpc: "2.0", id: 2, + method: "pty.list", + params: { args: {} }, + })).resolves.toEqual({ sessions: [owned] }); + + await expect(handler({ + jsonrpc: "2.0", + id: 3, + method: "pty.list", + params: { args: { laneId: peer.laneId } }, + })).rejects.toMatchObject({ code: JsonRpcErrorCode.methodNotFound }); + + await expect(handler({ + jsonrpc: "2.0", + id: 4, method: "pty.create", - params: { args: { laneId: "lane-1", title: "Claude", cols: 120, rows: 40 } }, + params: { args: { laneId: peer.laneId, title: "Peer", cols: 120, rows: 40 } }, + })).rejects.toMatchObject({ code: JsonRpcErrorCode.methodNotFound }); + + await expect(handler({ + jsonrpc: "2.0", + id: 5, + method: "pty.sendToSession", + params: { args: { sessionId: peer.id, text: "continue" } }, + })).rejects.toMatchObject({ code: JsonRpcErrorCode.methodNotFound }); + + await expect(handler({ + jsonrpc: "2.0", + id: 6, + method: "pty.write", + params: { args: { ptyId: peer.ptyId, data: "x" } }, + })).rejects.toMatchObject({ code: JsonRpcErrorCode.methodNotFound }); + + await expect(handler({ + jsonrpc: "2.0", + id: 7, + method: "pty.resize", + params: { args: { ptyId: peer.ptyId, cols: 100, rows: 30 } }, })).rejects.toMatchObject({ code: JsonRpcErrorCode.methodNotFound }); + + await expect(handler({ + jsonrpc: "2.0", + id: 8, + method: "pty.dispose", + params: { args: { ptyId: peer.ptyId, sessionId: owned.id } }, + })).rejects.toMatchObject({ code: JsonRpcErrorCode.methodNotFound }); + expect(runtime.ptyService.create).not.toHaveBeenCalled(); + expect(runtime.ptyService.sendToSession).not.toHaveBeenCalled(); + expect(runtime.ptyService.write).not.toHaveBeenCalled(); + expect(runtime.ptyService.resize).not.toHaveBeenCalled(); + expect(runtime.ptyService.dispose).not.toHaveBeenCalled(); }); it("routes app/navigate through the runtime navigation service", async () => { diff --git a/apps/ade-cli/src/adeRpcServer.ts b/apps/ade-cli/src/adeRpcServer.ts index d519b3309..c3042798c 100644 --- a/apps/ade-cli/src/adeRpcServer.ts +++ b/apps/ade-cli/src/adeRpcServer.ts @@ -3090,6 +3090,125 @@ function requireLaneIdForTool( return laneId; } +function isTerminalSessionSummaryLike(value: unknown): value is TerminalSessionSummary { + if (!isRecord(value)) return false; + return Boolean(asOptionalTrimmedString(value.id) && asOptionalTrimmedString(value.laneId)); +} + +function ptyAccessDenied(method: string): never { + throw new JsonRpcError(JsonRpcErrorCode.methodNotFound, `Unsupported PTY method: ${method}`); +} + +function listPtySessionsForAuthorization(runtime: AdeRuntime): TerminalSessionSummary[] { + try { + const rows = runtime.ptyService.list({}); + return Array.isArray(rows) ? rows.filter(isTerminalSessionSummaryLike) : []; + } catch { + return []; + } +} + +function getPtySessionForAuthorization(runtime: AdeRuntime, sessionId: string | null): TerminalSessionSummary | null { + if (!sessionId) return null; + try { + const session = runtime.sessionService.get(sessionId); + return isTerminalSessionSummaryLike(session) ? session : null; + } catch { + return null; + } +} + +function findPtySessionByPtyId(runtime: AdeRuntime, ptyId: string | null): TerminalSessionSummary | null { + if (!ptyId) return null; + return listPtySessionsForAuthorization(runtime).find((session) => session.ptyId === ptyId) ?? null; +} + +function authorizedPtyLaneIds(runtime: AdeRuntime, session: SessionState): Set<string> { + const laneIds = new Set<string>(); + const chatLaneId = resolveChatSessionLaneId(runtime, session); + if (chatLaneId) laneIds.add(chatLaneId); + const runLaneId = resolveRunContextLaneId(runtime, resolveCallerContext(session)); + if (runLaneId) laneIds.add(runLaneId); + return laneIds; +} + +function isPtySessionAuthorized(runtime: AdeRuntime, session: SessionState, target: TerminalSessionSummary): boolean { + if (callerHasRoleAtLeast(session.identity.role, "cto")) return true; + const targetSessionId = asOptionalTrimmedString(target.id); + const targetLaneId = asOptionalTrimmedString(target.laneId); + const targetChatSessionId = asOptionalTrimmedString(target.chatSessionId); + const callerChatSessionId = asOptionalTrimmedString(session.identity.chatSessionId); + if ( + callerChatSessionId + && (callerChatSessionId === targetSessionId || callerChatSessionId === targetChatSessionId) + ) { + return true; + } + return Boolean(targetLaneId && authorizedPtyLaneIds(runtime, session).has(targetLaneId)); +} + +function ensurePtyCreateAuthorized( + runtime: AdeRuntime, + session: SessionState, + method: string, + ptyArgs: Record<string, unknown>, +): void { + if (callerHasRoleAtLeast(session.identity.role, "cto")) return; + const laneId = asOptionalTrimmedString(ptyArgs.laneId); + const requestedChatSessionId = asOptionalTrimmedString(ptyArgs.chatSessionId); + const callerChatSessionId = asOptionalTrimmedString(session.identity.chatSessionId); + if (requestedChatSessionId && requestedChatSessionId !== callerChatSessionId) { + ptyAccessDenied(method); + } + if (!laneId || !authorizedPtyLaneIds(runtime, session).has(laneId)) { + ptyAccessDenied(method); + } +} + +function ensurePtyTargetAuthorized( + runtime: AdeRuntime, + session: SessionState, + method: string, + ptyArgs: Record<string, unknown>, +): void { + if (callerHasRoleAtLeast(session.identity.role, "cto")) return; + const ptyId = asOptionalTrimmedString(ptyArgs.ptyId); + const sessionId = asOptionalTrimmedString(ptyArgs.sessionId); + const target = findPtySessionByPtyId(runtime, ptyId) ?? getPtySessionForAuthorization(runtime, sessionId); + if (!target || !isPtySessionAuthorized(runtime, session, target)) { + ptyAccessDenied(method); + } +} + +function listAuthorizedPtySessions( + runtime: AdeRuntime, + session: SessionState, + method: string, + ptyArgs: Record<string, unknown>, +): TerminalSessionSummary[] { + if (callerHasRoleAtLeast(session.identity.role, "cto")) { + return runtime.ptyService.list(ptyArgs as Parameters<typeof runtime.ptyService.list>[0]); + } + + const callerChatSessionId = asOptionalTrimmedString(session.identity.chatSessionId); + const laneIds = authorizedPtyLaneIds(runtime, session); + const requestedLaneId = extractLaneId(ptyArgs); + if (requestedLaneId && !laneIds.has(requestedLaneId)) { + ptyAccessDenied(method); + } + if (!callerChatSessionId && !laneIds.size) { + ptyAccessDenied(method); + } + + const scopedArgs = { ...ptyArgs }; + if (!requestedLaneId && laneIds.size === 1) { + scopedArgs.laneId = [...laneIds][0]; + } + return runtime.ptyService + .list(scopedArgs as Parameters<typeof runtime.ptyService.list>[0]) + .filter((target) => isPtySessionAuthorized(runtime, session, target)); +} + async function runCtoOperatorBridgeTool( runtime: AdeRuntime, session: SessionState, @@ -7848,6 +7967,7 @@ export function createAdeRpcRequestHandler(args: { const runPtyAction = async (runner: () => Promise<unknown> | unknown): Promise<unknown> => auditActionCall(method, ptyArgs, async () => runner()); if (method === "pty.create") { + ensurePtyCreateAuthorized(runtime, session, method, ptyArgs); return await runPtyAction(async () => { const result = await runtime.ptyService.create(ptyArgs as Parameters<typeof runtime.ptyService.create>[0]); return { @@ -7857,22 +7977,26 @@ export function createAdeRpcRequestHandler(args: { }); } if (method === "pty.sendToSession") { + ensurePtyTargetAuthorized(runtime, session, method, ptyArgs); return await runPtyAction(() => runtime.ptyService.sendToSession(ptyArgs as Parameters<typeof runtime.ptyService.sendToSession>[0])); } if (method === "pty.write") { + ensurePtyTargetAuthorized(runtime, session, method, ptyArgs); return await runPtyAction(() => { runtime.ptyService.write(ptyArgs as Parameters<typeof runtime.ptyService.write>[0]); return null; }); } if (method === "pty.resize") { + ensurePtyTargetAuthorized(runtime, session, method, ptyArgs); return await runPtyAction(() => { runtime.ptyService.resize(ptyArgs as Parameters<typeof runtime.ptyService.resize>[0]); return null; }); } if (method === "pty.dispose") { + ensurePtyTargetAuthorized(runtime, session, method, ptyArgs); return await runPtyAction(() => { runtime.ptyService.dispose(ptyArgs as Parameters<typeof runtime.ptyService.dispose>[0]); return null; @@ -7880,7 +8004,7 @@ export function createAdeRpcRequestHandler(args: { } if (method === "pty.list") { return await runPtyAction(() => ({ - sessions: runtime.ptyService.list(ptyArgs as Parameters<typeof runtime.ptyService.list>[0]), + sessions: listAuthorizedPtySessions(runtime, session, method, ptyArgs), })); } throw new JsonRpcError(JsonRpcErrorCode.methodNotFound, `Unsupported PTY method: ${method}`); diff --git a/apps/ade-cli/src/tuiClient/app.tsx b/apps/ade-cli/src/tuiClient/app.tsx index 2db8c90cc..c1133cf46 100644 --- a/apps/ade-cli/src/tuiClient/app.tsx +++ b/apps/ade-cli/src/tuiClient/app.tsx @@ -2538,6 +2538,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } }, [lanes]); useEffect(() => { if (!activeSessionId) return; + if (loadedSessionIdRef.current !== activeSessionId) return; setEventsBySessionId((prev) => { if (prev[activeSessionId] === events) return prev; return { ...prev, [activeSessionId]: events }; @@ -7961,18 +7962,6 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } return; } - if (pane === "chat" && textInputActive && isCtrlInput(input, key, "g")) { - const edited = editPromptInExternalEditor(prompt); - if (edited == null) { - addNotice("External editor exited without updating the prompt.", "error"); - } else { - handlePromptChange(edited); - focusChat(); - addNotice("Loaded prompt from external editor.", "success"); - } - return; - } - if (pane === "chat" && isCtrlInput(input, key, "g")) { startAddMode(); return; diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index 3a19ff046..237d58527 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -6002,6 +6002,13 @@ app.whenReady().then(async () => { return getWindowSession(win.id); }; + let initialWindowNavigationReady = false; + const drainPendingAppNavigationRequests = (): void => { + for (const request of pendingAppNavigationRequests.splice(0)) { + dispatchAppNavigationRequest?.(request); + } + }; + dispatchAppNavigationRequest = (request) => { void (async () => { let targetWindow = @@ -6009,6 +6016,10 @@ app.whenReady().then(async () => { BrowserWindow.getAllWindows().find((win) => !win.isDestroyed()) ?? null; if (!targetWindow) { + if (!initialWindowNavigationReady) { + pendingAppNavigationRequests.push(request); + return; + } const opened = await openAdeWindow(); targetWindow = opened.windowId != null ? BrowserWindow.fromId(opened.windowId) : null; } @@ -6023,9 +6034,6 @@ app.whenReady().then(async () => { }); }); }; - for (const request of pendingAppNavigationRequests.splice(0)) { - dispatchAppNavigationRequest(request); - } const openProjectFileRequest = async (filePath: string): Promise<void> => { const projectRoot = normalizeProjectPath(filePath); @@ -6189,6 +6197,8 @@ app.whenReady().then(async () => { onCloseRequested: handleMainWindowCloseRequested, }); builtInBrowserService.attachToWindow(initialWindow); + initialWindowNavigationReady = true; + drainPendingAppNavigationRequests(); if (shouldShowRuntimeMigrationNotice && process.env.NODE_ENV !== "test") { void dialog.showMessageBox(initialWindow, { type: "info", diff --git a/apps/desktop/src/main/services/ipc/runtimeBridge.ts b/apps/desktop/src/main/services/ipc/runtimeBridge.ts index 7e79fb705..9b05fdb1e 100644 --- a/apps/desktop/src/main/services/ipc/runtimeBridge.ts +++ b/apps/desktop/src/main/services/ipc/runtimeBridge.ts @@ -245,10 +245,16 @@ export function registerRuntimeBridge({ const sendRuntimeEvent = ( sender: WebContents, bindingKey: string, + requestKey: string, event: RemoteRuntimeBufferedEvent, ): void => { const existing = runtimeEventSubscriptions.get(sender.id); - if (!existing || existing.bindingKey !== bindingKey || sender.isDestroyed()) + if ( + !existing || + existing.bindingKey !== bindingKey || + existing.requestKey !== requestKey || + sender.isDestroyed() + ) return; const payload: RemoteRuntimeEventNotificationPayload = { bindingKey, @@ -274,12 +280,12 @@ export function registerRuntimeBridge({ runtimeEventSubscriptions.set(sender.id, { bindingKey, requestKey, cleanup: null }); const onEnded = () => { const current = runtimeEventSubscriptions.get(sender.id); - if (current?.requestKey === requestKey) { + if (current?.requestKey === requestKey && current.bindingKey === bindingKey) { runtimeEventSubscriptions.delete(sender.id); } }; void subscribe( - (event) => sendRuntimeEvent(sender, bindingKey, event), + (event) => sendRuntimeEvent(sender, bindingKey, requestKey, event), onEnded, ) .then((cleanup) => { @@ -287,6 +293,7 @@ export function registerRuntimeBridge({ if ( !current || current.requestKey !== requestKey || + current.bindingKey !== bindingKey || sender.isDestroyed() ) { cleanup(); @@ -296,7 +303,7 @@ export function registerRuntimeBridge({ }) .catch((error) => { const current = runtimeEventSubscriptions.get(sender.id); - if (current?.requestKey === requestKey && !current.cleanup) { + if (current?.requestKey === requestKey && current.bindingKey === bindingKey && !current.cleanup) { runtimeEventSubscriptions.delete(sender.id); } console.warn("Runtime event subscription failed", error); diff --git a/apps/ios/ADE/Services/SyncService.swift b/apps/ios/ADE/Services/SyncService.swift index 6f2a4f150..2dba9be2e 100644 --- a/apps/ios/ADE/Services/SyncService.swift +++ b/apps/ios/ADE/Services/SyncService.swift @@ -7540,7 +7540,15 @@ extension SyncService { // host can reuse its own router. guard let url = (payload["url"] as? String)? .trimmingCharacters(in: .whitespacesAndNewlines), - !url.isEmpty else { + !url.isEmpty, + let components = URLComponents(string: url), + let scheme = components.scheme?.lowercased(), + scheme == "ade" || + ( + scheme == "https" && + components.host?.lowercased() == "ade.app" && + components.path.hasPrefix("/open") + ) else { return } args["url"] = url