diff --git a/apps/ade-cli/README.md b/apps/ade-cli/README.md index cb6767601..a104824de 100644 --- a/apps/ade-cli/README.md +++ b/apps/ade-cli/README.md @@ -281,6 +281,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..158d13ab5 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,198 @@ 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", + 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", chatSessionId: "session-1" }); + + 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("hides direct PTY RPC methods from external sessions", async () => { + const { runtime } = createRuntime(); + 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: 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 () => { const { runtime } = createRuntime(); const navigate = vi.fn(async () => ({ ok: true, mode: "desktop", windowId: 7 })); @@ -5779,6 +5974,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..c3042798c 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"] } } } }, @@ -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 { + const laneIds = new Set(); + 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, +): 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, +): 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, +): TerminalSessionSummary[] { + if (callerHasRoleAtLeast(session.identity.role, "cto")) { + return runtime.ptyService.list(ptyArgs as Parameters[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[0]) + .filter((target) => isPtySessionAuthorized(runtime, session, target)); +} + async function runCtoOperatorBridgeTool( runtime: AdeRuntime, session: SessionState, @@ -7601,6 +7720,16 @@ async function readResource(runtime: AdeRuntime, uri: string): Promise Promise | unknown): Promise => + 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[0]); + return { + ...result, + session: runtime.sessionService.get(result.sessionId), + }; + }); + } + if (method === "pty.sendToSession") { + ensurePtyTargetAuthorized(runtime, session, method, ptyArgs); + return await runPtyAction(() => + runtime.ptyService.sendToSession(ptyArgs as Parameters[0])); + } + if (method === "pty.write") { + ensurePtyTargetAuthorized(runtime, session, method, ptyArgs); + return await runPtyAction(() => { + runtime.ptyService.write(ptyArgs as Parameters[0]); + return null; + }); + } + if (method === "pty.resize") { + ensurePtyTargetAuthorized(runtime, session, method, ptyArgs); + return await runPtyAction(() => { + runtime.ptyService.resize(ptyArgs as Parameters[0]); + return null; + }); + } + if (method === "pty.dispose") { + ensurePtyTargetAuthorized(runtime, session, method, ptyArgs); + return await runPtyAction(() => { + runtime.ptyService.dispose(ptyArgs as Parameters[0]); + return null; + }); + } + if (method === "pty.list") { + return await runPtyAction(() => ({ + sessions: listAuthorizedPtySessions(runtime, session, method, ptyArgs), + })); + } + throw new JsonRpcError(JsonRpcErrorCode.methodNotFound, `Unsupported PTY method: ${method}`); + } + if (method.startsWith("modelPicker.")) { const store = getSharedModelPickerStore(); if (method === "modelPicker.getFavorites") { @@ -7891,7 +8078,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") { + if (!APP_NAVIGATE_SUPPORTED_KINDS.has(kind)) { throw new JsonRpcError(JsonRpcErrorCode.invalidParams, `Unsupported app navigation target kind: ${kind}.`); } if (kind === "lane" && !asOptionalTrimmedString(target.laneId)) { @@ -7900,6 +8087,23 @@ export function createAdeRpcRequestHandler(args: { if (kind === "route" && !asOptionalTrimmedString(target.route)) { throw new JsonRpcError(JsonRpcErrorCode.invalidParams, "app/navigate target 'route' requires route."); } + 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( + 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 +8113,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 f5e91680e..56032cc7a 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, @@ -452,9 +453,20 @@ 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(); + 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, @@ -613,9 +625,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 @@ -942,6 +955,7 @@ export async function createAdeRuntime(args: { computerUseArtifactBrokerService, laneService, sessionService, + processRegistry, projectConfigService, aiIntegrationService, ctoStateService, @@ -1234,6 +1248,7 @@ export async function createAdeRuntime(args: { swallow(() => agentChatService?.forceDisposeAll?.()); swallow(() => testService.disposeAll()); swallow(() => ptyService.disposeAll()); + swallow(() => processRegistry.stop()); swallow(() => db.flushNow()); swallow(() => db.close()); } @@ -1256,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/cli.ts b/apps/ade-cli/src/cli.ts index ae55b1ce1..c931a7b0d 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, @@ -131,7 +135,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"; @@ -359,6 +364,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 @@ -788,6 +796,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 @@ -868,6 +910,9 @@ const HELP_BY_COMMAND: Record = { 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 @@ -1360,6 +1405,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 @@ -8990,6 +9037,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 }; } @@ -13418,6 +13481,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..d9e176b83 --- /dev/null +++ b/apps/ade-cli/src/commands/deeplinks.ts @@ -0,0 +1,403 @@ +// --------------------------------------------------------------------------- +// 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) { + 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. + const params = new URLSearchParams(); + params.set("type", "linear-issue"); + if (linearIssue) params.set("issue", linearIssue); + if (branch) params.set("branch", branch); + return openAndReport(`https://ade.app/open?${params.toString()}`); + } + + // 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) 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 { + 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", 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}` }; + } + 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 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 ` — accept a deeplink and re-emit it in the chosen form. + if (positional.length === 1) { + const parsed = parseDeeplink(positional[0]); + if (parsed.ok) return emit(parsed.target); + } + + const verb = positional[0]; + if (verb === "lane") { + const laneId = positional[1]; + if (!laneId) { + throw new CliDeeplinkUsageError("ade link lane "); + } + return emit({ kind: "lane", laneId }); + } + if (verb === "branch") { + const repo = positional[1]; + const branch = positional[2]; + if (!repo || !branch) { + throw new CliDeeplinkUsageError( + "ade link branch [--pr ]", + ); + } + const { repoOwner, repoName } = parseRepoSlug(repo); + const prNumberRaw = flags.valued.get("pr"); + const prNumber = prNumberRaw != null ? parsePositiveInteger(prNumberRaw, "--pr") : undefined; + return emit(prNumber != null + ? { kind: "branch", repoOwner, repoName, branch, prNumber } + : { kind: "branch", repoOwner, repoName, branch }); + } + if (verb === "pr") { + const repo = positional[1]; + const numberRaw = positional[2]; + if (!repo || !numberRaw) { + throw new CliDeeplinkUsageError("ade link pr "); + } + 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]; + if (!issueIdentifier) { + throw new CliDeeplinkUsageError("ade link linear-issue [--branch ]"); + } + const branchHint = flags.valued.get("branch"); + return emit(branchHint + ? { kind: "linear-issue", issueIdentifier, branch: branchHint } + : { kind: "linear-issue", issueIdentifier }); + } + + throw new CliDeeplinkUsageError(HELP_LINK); +} + +function parseRepoSlug(repo: string): { repoOwner: string; repoName: string } { + 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"); + } + 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) { + 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 bare `ade` and hope it's on + // PATH (the Linear coding-tool spawner inherits the user's PATH). + const argv0 = process.argv[0] ?? ""; + return /\bade\b/.test(argv0) ? argv0 : "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..4d9498348 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,34 @@ 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."); + } + 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(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"))); 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..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( + , + ); + 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( { 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__/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( + , + ).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( { + 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__/adeApi.test.ts b/apps/ade-cli/src/tuiClient/__tests__/adeApi.test.ts index 75d4da812..eb2bd068a 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, startClaudeTerminalSession, steerChatMessage } from "../adeApi"; +import { cancelSteerMessage, createChatSession, DEFAULT_CODEX_REASONING_EFFORT, dispatchSteerMessage, discoverProjectSlashCommands, editSteerMessage, latestGoal, latestTokenStats, listLaneDiffStats, listPrsByLane, listTerminalSessions, sendChatMessage, signalTerminal, startClaudeTerminalSession, steerChatMessage } from "../adeApi"; import type { AdeCodeConnection } from "../types"; const tmpPaths: string[] = []; @@ -412,6 +412,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 32ecd1b73..9ba620bf5 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/appInput.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/appInput.test.ts @@ -1,13 +1,14 @@ import { describe, expect, it } from "vitest"; import { + CLAUDE_TERMINAL_SUBMIT_CONFIRM_DELAY_MS, clampChatScrollOffsetRows, cycleLaneDeleteScope, deletePreviousPromptLine, deletePreviousPromptWord, drawerMouseHitForLine, encodeTerminalPromptSubmit, + encodeTerminalPromptSubmitConfirm, footerControlsForAvailability, - formFieldIndexForMouseLine, formFieldUsesPromptInput, isChatSessionAnimating, isPromptLineBackspace, @@ -26,14 +27,12 @@ import { parseTerminalMouseInput, promptDisplayRows, promptHitLine, - laneDetailsActionIndexForMouseLine, modelPickerSurfaceForSetupPane, resolveContextDefault, resolveDrawerPaneWidth, resolveModelPickerEscape, resolveChatWrapWidth, resolveTerminalPaneWidth, - setupPaneRowIndexForMouseLine, splitTerminalControlInput, subagentSnapshotsFromEvents, } from "../app"; @@ -124,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", @@ -316,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", () => { @@ -769,4 +752,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..a0f2f6c5d --- /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 laneUuid = "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: laneUuid } }; + expect(buildDeeplinkForRow(row)).toBe(`ade://lane/${laneUuid}`); + }); + + 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: laneUuid } }, + recorded, + ); + expect(result).toBe("copied"); + expect(recorded.url).toBe(`ade://lane/${laneUuid}`); + }); + + 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/__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/adeApi.ts b/apps/ade-cli/src/tuiClient/adeApi.ts index bb7fac509..d9a4b3bf3 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..daf62fd56 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; @@ -296,15 +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 suppressedRuntimeActivities = 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); - if (activity === "thinking" || activity === "working") return null; + if (suppressedRuntimeActivities.has(activity)) return null; return { id, label: activity.replace(/_/g, " "), - detail, + detail: compactActivityDetail(event.detail), status: "running", }; } @@ -474,6 +523,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 +564,31 @@ export function aggregateChatBlocks(args: { continue; } if (event.type === "text") { - passthrough(id, "assistant-text"); + if (event.text.length === 0) 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 f8a6ff3a3..c1133cf46 100644 --- a/apps/ade-cli/src/tuiClient/app.tsx +++ b/apps/ade-cli/src/tuiClient/app.tsx @@ -105,13 +105,15 @@ 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 { buildModelPickerLayout, defaultSelectionFor } from "./components/ModelPicker/modelPickerLayout"; +import { computeLaneChatCounts, LANE_DETAIL_ACTIONS, LANE_DETAIL_PR_ACTION_INDEX, RightPane } from "./components/RightPane"; +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"; @@ -141,6 +143,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, @@ -149,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, @@ -184,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"; @@ -1408,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"; @@ -1445,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); @@ -1552,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"]; @@ -1725,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(); }; @@ -1738,6 +1716,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; @@ -1791,6 +1771,10 @@ export function encodeTerminalPromptSubmit(value: string): string { return `${normalized}\r`; } +export function encodeTerminalPromptSubmitConfirm(): string { + return "\r"; +} + export function isCtrlInput(input: string, key: { ctrl?: boolean; meta?: boolean }, letter: string): boolean { const normalized = letter.toLowerCase(); if (normalized.length !== 1) return false; @@ -1835,29 +1819,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; @@ -1959,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(null); @@ -2007,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>({}); + const [interruptedBySessionId, setInterruptedBySessionId] = useState>({}); + const [eventsBySessionId, setEventsBySessionId] = useState>({}); + const [multiView, setMultiView] = useState(null); + const [scrollBySessionId, setScrollBySessionId] = useState>({}); + const [selectionBySessionId, setSelectionBySessionId] = useState>({}); + const [promptHistoryBySessionId, setPromptHistoryBySessionId] = useState>({}); + const [addMode, setAddMode] = useState(null); + const [multiViewNotice, setMultiViewNotice] = useState(null); + const [hoveredHitId, setHoveredHitId] = useState(null); const [interrupted, setInterrupted] = useState(false); const [chatMouseSelection, setChatMouseSelection] = useState(null); const [clearedAt, setClearedAt] = useState(null); @@ -2038,6 +2009,17 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } const connectionRef = useRef(null); const activeLaneIdRef = useRef(null); const activeSessionIdRef = useRef(null); + const multiViewRef = useRef(null); + const addModeRef = useRef(null); + const streamingBySessionIdRef = useRef>({}); + const interruptedBySessionIdRef = useRef>({}); + const eventsBySessionIdRef = useRef>({}); + const promptHistoryBySessionIdRef = useRef>({}); + const dragAddSessionRef = useRef(null); + const hitTestRegistryRef = useRef(createHitTestRegistry()); + const hoveredTargetRef = useRef(null); + const appHitTargetIdsRef = useRef([]); + const previousDimensionsRef = useRef<[number, number]>([columns, rows]); const draftChatActiveRef = useRef(false); const formDiscardArmedRef = useRef(false); const activePaneRef = useRef("chat"); @@ -2053,6 +2035,8 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } const promptHistoryRef = useRef([]); const promptHistoryIndexRef = useRef(null); const promptHistoryDraftRef = useRef(""); + const promptHistoryIndexBySessionIdRef = useRef>({}); + const promptHistoryDraftBySessionIdRef = useRef>({}); const rightPaneKindRef = useRef("empty"); const lastLocalSendAtRef = useRef(0); const eventCountRef = useRef(0); @@ -2075,6 +2059,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } const lastUserOpenedPaneRef = useRef(null); const userDismissedRightPaneRef = useRef(false); const activeSessionRef = useRef(null); + const sessionsRef = useRef([]); const activeTerminalSessionRef = useRef(null); const terminalSessionsRef = useRef([]); const attachedTerminalIdRef = useRef(null); @@ -2102,6 +2087,58 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } const pendingModelCommitTimerRef = useRef(null); const pendingModelCommitStateRef = useRef(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); @@ -2117,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); @@ -2136,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) { @@ -2194,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); @@ -2221,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(() => { @@ -2461,6 +2521,29 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } }), [sessions, terminalSessions], ); + const sessionBySessionId = useMemo(() => { + const out: Record = {}; + 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 = {}; + for (const lane of lanes) out[lane.id] = lane; + return out; + }, [lanes]); + useEffect(() => { + if (!activeSessionId) return; + if (loadedSessionIdRef.current !== activeSessionId) return; + setEventsBySessionId((prev) => { + if (prev[activeSessionId] === events) return prev; + return { ...prev, [activeSessionId]: events }; + }); + }, [activeSessionId, events]); const claudeTerminalControlAvailable = Boolean( activeTerminalSession && activeTerminalSession.status === "running" @@ -2616,7 +2699,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"); @@ -2654,6 +2740,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 }, ) => { @@ -2663,6 +2761,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } setEvents([]); setStreaming(false); setInterrupted(false); + setSessionInterrupted(activeSessionIdRef.current, false); setCurrentGoal(null); setContextPercent(null); setTokenSummary(null); @@ -2720,6 +2819,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; @@ -2733,19 +2834,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)); @@ -2782,12 +2887,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); @@ -2810,6 +2916,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)) @@ -2929,6 +3036,10 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } activeSessionRef.current = activeSession; }, [activeSession]); + useEffect(() => { + sessionsRef.current = sessions; + }, [sessions]); + useEffect(() => { activeTerminalSessionRef.current = activeTerminalSession; }, [activeTerminalSession]); @@ -3552,6 +3663,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 => 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) => { @@ -4173,13 +4479,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; @@ -4220,6 +4531,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); @@ -4282,7 +4596,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; @@ -4434,31 +4748,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; - 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; @@ -4468,21 +4796,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; @@ -4494,13 +4825,13 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } }); } }); - }, [clearedAt, connection, refreshState, setPaneFocus]); + }, [clearedAt, connection, refreshState, setSessionInterrupted, setSessionStreaming]); useEffect(() => { 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; @@ -4785,13 +5116,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({ @@ -4939,11 +5272,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) { @@ -4957,23 +5290,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; @@ -5448,7 +5784,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); @@ -5604,7 +5940,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; } @@ -6158,7 +6494,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); } @@ -6202,7 +6538,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; @@ -6613,7 +6950,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; @@ -6621,16 +6961,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); @@ -6683,6 +7036,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"); @@ -6979,11 +7368,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, @@ -6994,7 +7401,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; @@ -7002,10 +7409,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, @@ -7013,7 +7420,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) { @@ -7038,6 +7445,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, @@ -7058,7 +7512,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, @@ -7096,40 +7550,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); @@ -7209,7 +7631,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) { @@ -7227,6 +7649,41 @@ 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" && 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; @@ -7444,6 +7901,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); @@ -7493,15 +7962,8 @@ 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"); - } + if (pane === "chat" && isCtrlInput(input, key, "g")) { + startAddMode(); return; } @@ -8315,6 +8777,494 @@ 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") { + const laneActionTop = rightBodyTop + 16; + LANE_DETAIL_ACTIONS.forEach((_, index) => { + const y = laneActionTop + 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: 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"); + 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" ? 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 }, + 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 ( @@ -8325,8 +9275,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 ( +
{" · streaming"} : null} ) : null} + {addMode ? : null} {drawerOpen ? ( {pendingApproval?.highStakes ? ( + ) : multiView ? ( + ) : activeTerminalSession ? ( + ); } 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 ( + + Split chat: pick a chat in the left pane + {" · "} + + {" Add · "} + Esc + {" Cancel"} + + ); +} 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 = ( + + No transcript yet. + + ); + } else if (isEmpty) { + content = ( ); + } else { + content = ( + + {rows.map((row, index) => ( + + ))} + + ); } + + 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 ( - - {rows.map((row, index) => ( - - ))} + + + + {header} + + {onRemove ? ( + + × + + ) : null} + + {content} ); } diff --git a/apps/ade-cli/src/tuiClient/components/Drawer.tsx b/apps/ade-cli/src/tuiClient/components/Drawer.tsx index 956093522..b04945bca 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; @@ -162,13 +164,19 @@ 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 ( - - LANES · {loading && lanes.length === 0 ? "…" : lanes.length} + + {addMode ? "PICK CHAT" : `LANES · ${loading && lanes.length === 0 ? "…" : lanes.length}`} @@ -256,6 +264,19 @@ export function Drawer({ + ) : addMode ? ( + <> + + ↑↓ + {" select chat in left pane"} + + + ↵/click + {" add · "} + esc + {" cancel"} + + ) : mode === "chats" ? ( <> @@ -582,6 +603,8 @@ function ActiveChatSpin() { function MiniDrawer({ width, borderColor, + addMode, + emphasisColor, lanes, sessions, activeLaneId, @@ -598,6 +621,8 @@ function MiniDrawer({ }: { width: number; borderColor: string; + addMode: boolean; + emphasisColor: string; lanes: LaneSummary[]; sessions: AgentChatSessionSummary[]; activeLaneId: string | null; @@ -618,8 +643,8 @@ function MiniDrawer({ return ( - - LANES · {loading && lanes.length === 0 ? "…" : lanes.length} + + {addMode ? "PICK CHAT" : `LANES · ${loading && lanes.length === 0 ? "…" : lanes.length}`} {loading && lanes.length === 0 ? ( @@ -701,7 +726,13 @@ function MiniDrawer({ ) : null} - {!focused ? "\n" : mode === "chats" ? "↑↓ chats · Esc lanes" : "↑↓ lanes · ↵ open"} + {!focused + ? "\n" + : addMode + ? "↑↓ pick chat · ↵ add · esc cancel" + : mode === "chats" + ? "↑↓ chats · Esc lanes" + : "↑↓ lanes · ↵ open"} 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 ? ( + <> + {" "} + {gridMiniMapText(multiViewMap.count, multiViewMap.focusedIndex)} + {multiViewMap.notice ? ( + {` ${multiViewMap.notice}`} + ) : null} + + ) : null} {terminalControlActive ? ( @@ -248,7 +262,21 @@ export function FooterControls({ {" "} {" "} - + {!showSubagents ? ( + <> + + {" "} + + ) : null} + + {multiViewActive ? ( + <> + {" "} + + {" "} + + + ) : null} {" "} {" "} 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..253b0a1bb --- /dev/null +++ b/apps/ade-cli/src/tuiClient/components/GridMiniMap.tsx @@ -0,0 +1,27 @@ +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 { + 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({ + count, + focusedIndex, +}: { + count: number; + focusedIndex: number; +}) { + return {gridMiniMapText(count, focusedIndex)}; +} 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(entries: T[]): T[][] { + const rows = new Map(); + 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; + 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 ( + 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; + sessionBySessionId: Record; + eventsBySessionId: Record; + notices: LocalNotice[]; + streamingBySessionId: Record; + interruptedBySessionId: Record; + scrollBySessionId: Record; + selectionBySessionId: Record; + expandedLineIds?: Set; + 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 ( + + No chats open. + + ); + } + + 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 ( + + ); + } + + return ( + + {rows.map((row, rowIndex) => ( + + {row.map(({ tile, index, rect }) => { + const session = sessionBySessionId[tile.sessionId] ?? null; + const lane = lanesById[tile.laneId] ?? null; + return ( + + + + ); + })} + + ))} + + ); +} 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() { in the row: ← → moves between cells, ↓ cycles values /model opens the model picker · /info opens chat info ctrl-o opens or focuses lanes and chats + ctrl-g starts split chat add-mode; enter adds, esc cancels + in split chat: tab focuses tiles, ctrl-w closes the focused tile ctrl-p opens or focuses info · ctrl-a toggles chat info shift-tab cycles pane focus · esc closes the active side pane ctrl-c interrupts a running chat; press again to quit diff --git a/apps/ade-cli/src/tuiClient/deeplinkRow.ts b/apps/ade-cli/src/tuiClient/deeplinkRow.ts new file mode 100644 index 000000000..70e2290c7 --- /dev/null +++ b/apps/ade-cli/src/tuiClient/deeplinkRow.ts @@ -0,0 +1,83 @@ +// --------------------------------------------------------------------------- +// 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 { + 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 }; + return buildDeeplink(target, { form: "ade" }); + } + const pr = row.pr; + if ("repoOwner" in pr) { + 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" }, + ); + } + 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 }, + { 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/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(null); + +export function HitTestProvider({ + registry, + hoveredId, + children, +}: { + registry?: HitTestRegistry; + hoveredId?: string | null; + children: React.ReactNode; +}) { + const fallbackRegistry = useMemo(() => createHitTestRegistry(), []); + const value = useMemo(() => ({ + 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(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/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/ade-cli/src/tuiClient/multiChatLayout.ts b/apps/ade-cli/src/tuiClient/multiChatLayout.ts new file mode 100644 index 000000000..1a4ac3fb5 --- /dev/null +++ b/apps/ade-cli/src/tuiClient/multiChatLayout.ts @@ -0,0 +1,83 @@ +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> = { + 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 { + 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[] { + 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/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 ca4e36f1e..237d58527 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"; @@ -34,6 +38,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"; @@ -72,6 +77,8 @@ import { IPC } from "../shared/ipc"; import { resolveAdeLayout } from "../shared/adeLayout"; import type { OpenProjectBinding, + AppNavigationRequest, + LaneDeleteProgress, LaneSummary, PortLease, PrEventPayload, @@ -817,6 +824,43 @@ 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); + +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: dispatchOrQueueAppNavigationRequest, + 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; @@ -1444,6 +1488,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; @@ -1915,20 +1967,30 @@ app.whenReady().then(async () => { onLinearIssueLinked: ({ lane, issue, linkedAt }) => { const tracker = linearIssueTrackerRef; if (!tracker) return; - void publishLinearLaneCard({ - issueTracker: tracker, - lane, - issue, - projectRoot, - linkedAt, - }).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), + // 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. + void githubService.getRepoOrThrow() + .catch(() => null) + .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, + issueIdentifier: issue.identifier, + error: error instanceof Error ? error.message : String(error), + }); }); - }); }, teardownDeps: laneTeardownDeps, logger, @@ -1955,8 +2017,17 @@ 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(), + liveOwnerIdentities: processRegistry.listLiveProcessIdentities(), }); if (reconciledSessions > 0) { logger.warn("sessions.reconciled_stale_running", { @@ -2478,6 +2549,7 @@ app.whenReady().then(async () => { transcriptsDir: adePaths.transcriptsDir, laneService, sessionService, + processRegistry, aiIntegrationService, projectConfigService, getLaneRuntimeEnv, @@ -2886,6 +2958,7 @@ app.whenReady().then(async () => { episodicSummaryService, laneService, sessionService, + processRegistry, projectConfigService, aiIntegrationService, ctoStateService, @@ -3576,6 +3649,21 @@ 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 { + handleDeeplinkUrl(rawUrl, "sync:ios", dispatchOrQueueAppNavigationRequest); + 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 @@ -4403,6 +4491,7 @@ app.whenReady().then(async () => { rebaseSuggestionService, autoRebaseService, sessionService, + processRegistry, ptyService, diffService, fileService, @@ -4860,6 +4949,11 @@ app.whenReady().then(async () => { } catch { // ignore } + try { + ctx.processRegistry?.stop(); + } catch { + // ignore + } try { ctx.db.flushNow(); ctx.db.close(); @@ -5396,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 => { @@ -5593,6 +5688,100 @@ app.whenReady().then(async () => { return true; }; + 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 { + 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 labels; + }; + + const getRuntimeBackedRunningLaneDeleteLabels = async (): Promise => { + if (shouldUseInProcessProjectRuntime()) return []; + const roots = new Set([ + ...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 => { + const labels = [ + ...getInProcessRunningLaneDeleteLabels(), + ...await getRuntimeBackedRunningLaneDeleteLabels(), + ]; + return Array.from(new Set(labels)); + }; + + const confirmNoRunningLaneDeleteForQuit = async (ownerWindow?: BrowserWindow | null): Promise => { + const runningDeletes = await 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"], @@ -5613,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(); @@ -5633,8 +5839,7 @@ app.whenReady().then(async () => { closeWindowWithoutPrompt(win); return; } - if (!confirmQuitWarning(win)) return; - requestAppShutdown({ reason: "window_close", exitCode: 0 }); + requestQuitAfterWarnings(win, "window_close"); }; const FILE_LIMIT_CODES = new Set(["EMFILE", "ENFILE"]); @@ -5797,6 +6002,39 @@ 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 = + BrowserWindow.getFocusedWindow() ?? + 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; + } + 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), + }); + }); + }; + const openProjectFileRequest = async (filePath: string): Promise => { const projectRoot = normalizeProjectPath(filePath); if (!isLikelyRepoRoot(projectRoot)) return; @@ -5959,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", @@ -5983,8 +6223,7 @@ app.whenReady().then(async () => { if (shutdownFinalized) return; event.preventDefault(); if (shutdownRequested) return; - if (!confirmQuitWarning()) return; - requestAppShutdown({ reason: "before_quit", exitCode: 0 }); + requestQuitAfterWarnings(null, "before_quit"); }); }); diff --git a/apps/desktop/src/main/services/adeActions/registry.ts b/apps/desktop/src/main/services/adeActions/registry.ts index cab4b2718..506e78011 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 70950d172..833b7f015 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"; @@ -4303,6 +4304,7 @@ export function createAgentChatService(args: { computerUseArtifactBrokerService?: ComputerUseArtifactBrokerService | null; laneService: ReturnType; sessionService: ReturnType; + processRegistry?: ProcessRegistryService | null; projectConfigService: ReturnType; aiIntegrationService: ReturnType; logger: Logger; @@ -4343,6 +4345,7 @@ export function createAgentChatService(args: { computerUseArtifactBrokerService, laneService, sessionService, + processRegistry, projectConfigService, aiIntegrationService, logger, @@ -9673,12 +9676,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") { @@ -14266,22 +14267,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: { @@ -15502,7 +15487,9 @@ export function createAgentChatService(args: { startedAt, transcriptPath, toolType: toolTypeFromProvider(effectiveProvider), - resumeCommand: resumeCommandForProvider(effectiveProvider, sessionId) + resumeCommand: resumeCommandForProvider(effectiveProvider, sessionId), + ownerPid: processRegistry?.pid ?? null, + ownerProcessStartedAt: processRegistry?.startedAt ?? null, }); if (normalizedTitle.length > 0) { sessionService.updateMeta({ sessionId, title: initialTitle, manuallyNamed: true }); @@ -19564,6 +19551,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.isProcessIdentityLive(row.ownerPid, row.ownerProcessStartedAt) + ) { + 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 @@ -19704,6 +19701,9 @@ export function createAgentChatService(args: { managed.ctoSessionStartedAt = managed.session.identityKey === "cto" ? nowIso() : null; persistChatState(managed); + if (processRegistry) { + sessionService.setOwnerPid(sessionId, processRegistry.pid, processRegistry.startedAt); + } 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..f9e701d3a 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,69 @@ 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, + }); + if (body) { + try { + await args.issueTracker.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..6d1673deb --- /dev/null +++ b/apps/desktop/src/main/services/deeplinks/protocolHandler.ts @@ -0,0 +1,229 @@ +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; + +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 ADE_SCHEME_RE.test(arg) || 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 + * 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; + /** + * 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. 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 `. + // 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 { + 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. 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) { + 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. + 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 (isAdeDeeplinkArg(arg)) consume(arg, "second-instance"); + } + }); + + // Pick up any URL embedded in this process's own argv (Windows cold-start). + for (const arg of coldStartDeeplinkArgs) { + 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 09f8af9e6..1328db284 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -736,6 +736,7 @@ import { } from "../chat/codexCliLauncher"; import { sanitizeResumeTargetId } from "../../utils/terminalSessionSignals"; import { probeLocalhostPort } from "../probeLocalhostPort"; +import type { ProcessRegistryService } from "../runtime/processRegistryService"; import { deleteMacosVmFromProjectState } from "../macosVm/macosVmRecovery"; export type AppContext = { @@ -763,6 +764,7 @@ export type AppContext = { rebaseSuggestionService: ReturnType | null; autoRebaseService: ReturnType | null; sessionService: ReturnType; + processRegistry?: ProcessRegistryService | null; ptyService: ReturnType; diffService: ReturnType; fileService: ReturnType; @@ -3627,6 +3629,11 @@ export function registerIpc({ clipboard.writeText(text); }); + ipcMain.handle(IPC.appReadClipboardText, async (event): Promise => { + assertTrustedAppControlSender(event, IPC.appReadClipboardText); + 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..9b05fdb1e 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; }; @@ -244,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, @@ -263,27 +270,29 @@ 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 && current.bindingKey === bindingKey) { runtimeEventSubscriptions.delete(sender.id); } }; void subscribe( - (event) => sendRuntimeEvent(sender, bindingKey, event), + (event) => sendRuntimeEvent(sender, bindingKey, requestKey, event), onEnded, ) .then((cleanup) => { const current = runtimeEventSubscriptions.get(sender.id); if ( !current || + current.requestKey !== requestKey || current.bindingKey !== bindingKey || sender.isDestroyed() ) { @@ -294,7 +303,7 @@ export function registerRuntimeBridge({ }) .catch((error) => { const current = runtimeEventSubscriptions.get(sender.id); - if (current?.bindingKey === bindingKey && !current.cleanup) { + if (current?.requestKey === requestKey && current.bindingKey === bindingKey && !current.cleanup) { runtimeEventSubscriptions.delete(sender.id); } console.warn("Runtime event subscription failed", error); @@ -686,13 +695,14 @@ export function registerRuntimeBridge({ ensureRuntimeEventSubscription( event.sender, binding.key, + `${binding.key}:${arg?.request?.category ?? "*"}`, (onEvent, onEnded) => localRuntimeConnectionPool.subscribeEventsForRoot( rootPath, { cursor: arg?.request?.cursor, limit: arg?.request?.limit, - category: "runtime", + category: arg?.request?.category, }, onEvent, onEnded, @@ -732,6 +742,7 @@ export function registerRuntimeBridge({ ensureRuntimeEventSubscription( event.sender, `remote:${target.id}:${projectId}`, + `remote:${target.id}:${projectId}:${arg?.request?.category ?? "*"}`, (onEvent, onEnded) => remoteConnectionPool.subscribeEventsForTarget( target, @@ -739,7 +750,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/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((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((resolve) => { + stopStarted = resolve; + }); + fake.processService.stopAll.mockImplementation(async () => { + fake.calls.push("stop_processes"); + order.push("delete:stop_processes"); + stopStarted?.(); + await new Promise((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(); + let gitWorktreeMutationQueue: Promise = Promise.resolve(); + const gitWorktreeMutationOwner = new AsyncLocalStorage(); + + const runGitWorktreeMutation = async (work: () => Promise): Promise => { + 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 } @@ -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/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..3fd78ea95 --- /dev/null +++ b/apps/desktop/src/shared/adeDeeplinkFooter.ts @@ -0,0 +1,122 @@ +// --------------------------------------------------------------------------- +// "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_RE = //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).trimEnd(); + const after = safeBody.slice(openIdx + closeMatch.index + closeMatch[0].length); + 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(); + 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 enc = (value: string) => encodeURIComponent(value); + const parts = [ + "v=1", + `type=${opts.prNumber ? "pr" : "branch"}`, + `repo=${enc(opts.repoOwner)}/${enc(opts.repoName)}`, + `branch=${enc(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 000000000..747f8d378 Binary files /dev/null and b/apps/desktop/src/shared/deeplinks.ts differ diff --git a/apps/desktop/src/shared/ipc.ts b/apps/desktop/src/shared/ipc.ts index b5809546d..fcb0bac4c 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/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/desktop/src/shared/types/sessions.ts b/apps/desktop/src/shared/types/sessions.ts index bc966d901..c84669024 100644 --- a/apps/desktop/src/shared/types/sessions.ts +++ b/apps/desktop/src/shared/types/sessions.ts @@ -64,6 +64,8 @@ export type TerminalSessionSummary = { laneId: string; laneName: string; ptyId: string | null; + ownerPid?: number | null; + ownerProcessStartedAt?: string | 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..4e9ab10f1 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,46 @@ 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 { + 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 } 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 +115,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 +155,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/Resources/DatabaseBootstrap.sql b/apps/ios/ADE/Resources/DatabaseBootstrap.sql index 1fb84241d..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) ); @@ -186,6 +187,24 @@ 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); + +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, + 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, diff --git a/apps/ios/ADE/Services/SyncService.swift b/apps/ios/ADE/Services/SyncService.swift index 592a92ffd..2dba9be2e 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,24 @@ 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. + guard let url = (payload["url"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines), + !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 } // 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..69416eaec --- /dev/null +++ b/apps/ios/ADE/Views/Deeplinks/SendToMacCard.swift @@ -0,0 +1,322 @@ +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 { + 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 + } + 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" + } + + @MainActor + 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..a513208e1 --- /dev/null +++ b/apps/web/api/open.ts @@ -0,0 +1,243 @@ +/** + * 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 VercelQuery = Record; + +type VercelReq = { + query: VercelQuery; + 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: VercelQuery): 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 pr = Number(pickQuery(query.pr)); + 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 queryIdx = (req.url ?? "").indexOf("?"); + const search = queryIdx >= 0 ? (req.url ?? "").slice(queryIdx) : ""; + 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..ded82d2ea --- /dev/null +++ b/apps/web/src/app/pages/OpenPage.tsx @@ -0,0 +1,197 @@ +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: "linear-issue"; issueIdentifier: string; branch?: string } + | { kind: "unknown" }; + +function parseQuery(search: string): OpenTarget { + const params = new URLSearchParams(search); + const type = (params.get("type") ?? "").toLowerCase(); + 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 pr = Number(params.get("pr") ?? ""); + 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 }; + } + } + 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" }; +} + +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 "linear-issue": { + const base = `ade://linear-issue/${encodeURIComponent(target.issueIdentifier)}`; + return target.branch ? `${base}?branch=${encodeURIComponent(target.branch)}` : base; + } + 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 "linear-issue": + return { + title: `Open ${target.issueIdentifier} in ADE`, + summary: target.branch ? `Branch ${target.branch}` : "Linear issue", + }; + 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 f72c3c12f..a9f5cc451 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -170,7 +170,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). --- @@ -196,7 +198,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`). | @@ -262,7 +265,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. @@ -372,7 +390,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 @@ -469,7 +487,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. | @@ -495,7 +514,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. | @@ -1093,6 +1112,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 aa1ae8e06..6e3c2c199 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`. | @@ -244,9 +246,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..923604c06 --- /dev/null +++ b/docs/features/deeplinks/README.md @@ -0,0 +1,261 @@ +# 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. + +## 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 + 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 ea84323ce..6a6c97648 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` — @@ -496,12 +515,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 @@ -631,6 +654,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..583da7aa3 100644 --- a/goal.md +++ b/goal.md @@ -1,60 +1,804 @@ -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: ADE TUI — Universal Click + Multi-Chat Middle Pane + +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. + +--- + +## Why we're doing this + +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. + +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. + +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. + +--- + +## 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). + +--- + +## 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. + +### 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 │ +└────────────────────────────────────────────────────────┘ +``` + +Constants in `app.tsx` ~1508–1511: `DRAWER_PANE_WIDTH = 32`, right pane is computed `30–42`, middle gets the remainder (`min 24`). + +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. + +### Focus / pane state (read `app.tsx` ~1733–1776) + +```ts +type PaneFocus = "chat" | "drawer" | "details"; +const [activePane, setActivePane] = useState<PaneFocus>("chat"); +const activePaneRef = useRef<PaneFocus>("chat"); +``` + +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). + +### Mouse (read `app.tsx` `parseTerminalMouseInput` + hit-test helpers ~1557–1620) + +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) + +**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>. + +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. + +### 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?: …; + // … +}; +``` + +### Single-source streaming today (read `app.tsx` ~580, ~1190) + +```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 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])`. + +### ChatView (read `apps/ade-cli/src/tuiClient/components/ChatView.tsx`) + +```ts +function ChatView({ + events, activeSession, streaming, interrupted, + expandedLineIds, maxRows, scrollOffsetRows, selection, + width, laneName, projectName, provider, notices, … +}) +``` + +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) + +```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(); +} +``` + +In multi-view, replace the `sessionId` line with: +```ts +const sessionId = multiView + ? multiView.tiles[multiView.focusedIndex].sessionId + : activeSessionIdRef.current; +``` + +### Persistence (read `apps/ade-cli/src/tuiClient/state.ts`) + +`~/.ade/ade-code-state.json` stores `lastChatByLane` and `lastChatByProjectLane` (per-lane last-active session). Debounced 500ms saves via `saveAdeCodeProjectState()`. + +**Do not** add `multiView` here. Multi-view is in-memory only. + +--- + +## Feature 1: Universal click — detailed design + +### Step 1: Hit-test registry + +Create `apps/ade-cli/src/tuiClient/hitTestRegistry.ts`: + +```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; +} +``` + +### 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"); +``` + +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>; +``` + +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. + +### Step 4: Migrate components (full parity list) + +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. + +| 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 | + +For each: the keyboard handler stays; clicks dispatch the same intent. After migration, delete the old `*IndexForMouseLine` helpers (~`app.tsx` 1557–1620). + +### Step 5: Don't break what works + +Things already mouse-wired (don't touch their behavior, just port them to the registry on the way through): + +| 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 | + +--- + +## Feature 2: Multi-chat middle pane — detailed design + +### Step 1: State + +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]); + +// 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[]>>({}); + +// Add-mode +const [addMode, setAddMode] = useState<{ cursorLaneId: string; cursorChatId: string | null } | null>(null); +``` + +### 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 +setStreamingBySessionId(prev => ({ ...prev, [sessionId]: true })); +``` + +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; +} +``` + +### Step 3: Layout math + +Create `apps/ade-cli/src/tuiClient/multiChatLayout.ts`: + +```ts +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, + })); +} +``` + +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 + +```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 + +State machine: +``` +normal ──(Ctrl+A, only when activePane === "chat")──> addMode +addMode ──(Esc)──> normal (no changes) +addMode ──(Enter on highlighted chat)──> normal + multiView updated +``` + +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 +``` + +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. + +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, + }; + }); +} +``` + +### Step 7: Remove + +```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 }; + }); +} +``` + +### Step 8: Focus follows tile + +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]); +``` + +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`. + +### Step 9: Drag-to-add + +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. + +### Step 10: Keybindings + +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 + +Document these in the footer and in `FooterControls` rendering. + +--- + +## UI specification (depth) + +### Tile chrome + +Single-line header at the top of each tile: + +``` +┌ lane-slug / chat-title ●─────────────── ×┐ +``` + +- 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. + +### Focused tile vs unfocused + +- **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. + +### Six grid layouts (wireframes) + +Imagine the middle pane is ~80 cols wide × ~24 rows tall. Borders are illustrative; real rendering uses Ink's box borders. + +**N=1 — full:** +``` +┌─ lane-a / chat-1 ●────────────────────────────────────────────────────────┐ +│ │ +│ (full chat body) │ +│ │ +└────────────────────────────────────────────────────────────────────────── ×┘ +``` + +**N=2 — 2 cols:** +``` +┌─ lane-a/chat-1 ●─────────────┐ ╔ lane-b/chat-2 ●═════════════════╗ +│ │ ║ ║ +│ left chat │ ║ right chat (focused) ║ +│ │ ║ ║ +└──────────────────────────── ×┘ ╚══════════════════════════════ ×╝ +``` + +**N=3 — 3 cols:** +``` +┌ lane-a/c1 ●────┐ ┌ lane-b/c2 ●────┐ ╔ lane-c/c3 ●════════╗ +│ │ │ │ ║ ║ +│ │ │ │ ║ focused ║ +└────────────── ×┘ └────────────── ×┘ ╚════════════════ ×╝ +``` + +**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) ║ │ │ +└──────────────── ×┘ ╚═══════════════ ×╝ └──────────────── ×┘ +``` + +**N=6 — 3×2:** +``` +┌ lane-a/c1 ●─────┐ ┌ lane-b/c2 ●─────┐ ╔ lane-c/c3 ●═══════╗ +│ │ │ │ ║ focused ║ +└─────────────── ×┘ └─────────────── ×┘ ╚═══════════════ ×╝ +┌ lane-d/c4 ●─────┐ ┌ lane-e/c5 ●─────┐ ┌ lane-f/c6 ●───────┐ +│ │ │ │ │ │ +└─────────────── ×┘ └─────────────── ×┘ └───────────────── ×┘ +``` + +### Add-mode + +Full app view with dim overlay everywhere except the drawer: + +``` + 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 │ │ │ +└──────────────┘ └──────────────────────────────────────────┘ +``` + +- 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. + +### Status-bar grid mini-map + +Append to the footer status row, after model name: + +- N=1: `▣` +- N=2: `▣▢` or `▢▣` depending on focus +- N=3: `▣▢▢` etc. +- N=4: `▣▢ / ▢▢` (rows separated by ` / `) +- N=5: `▣▢ / ▢▢▢` +- N=6: `▣▢▢ / ▢▢▢` + +Use `▣` for focused tile, `▢` for unfocused. Render in plain Unicode; no color needed. + +### Hover affordance (universal click) + +When `hoveredId` matches a registered target, the target renders with a subtle highlight. Pick **one** of these and apply consistently: + +- Option A: `<Box backgroundColor="blackBright">` wrapping the target. +- Option B: `<Text inverse>` on the target's text. + +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. + +--- + +## Edge cases to handle + +- **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). + +--- + +## Files to create / modify + +### 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. + +### Modify + +- `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). + +--- + +## Verification plan + +End-to-end (run the TUI in this worktree): + +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. + +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). + +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. + +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. + +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. + +6. **Drag-to-add** + - Click-drag a sidebar chat row into the middle → confirm it adds without add-mode. + +7. **Persistence (negative)** + - Open 4 tiles → kill TUI → relaunch → confirm app starts in single-chat mode. + +Unit tests: + +- `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. + +Manual perf: with 6 tiles streaming, keystroke latency in the bottom prompt stays under ~16ms. + +--- + +## Docs / references to read before starting + +- **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. + +--- + +## Out of scope (do not do) + +- **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.