From 321145424be47a117823d4f8cc6b5d80712a2854 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 27 May 2026 13:22:03 -0400 Subject: [PATCH 1/4] Improve Work draft launch latency --- .../services/sync/syncRemoteCommandService.ts | 1 - .../services/sync/syncHostService.test.ts | 2 +- .../sync/syncRemoteCommandService.test.ts | 4 +- .../components/chat/AgentChatPane.test.ts | 260 ------ ...submit.test.tsx => AgentChatPane.test.tsx} | 453 +++++++++- .../components/chat/AgentChatPane.tsx | 793 +++++++++++++++--- .../lanes/useLaneWorkSessions.test.ts | 49 +- .../components/lanes/useLaneWorkSessions.ts | 37 +- .../terminals/useWorkSessions.test.ts | 9 + .../components/terminals/useWorkSessions.ts | 1 - docs/features/chat/README.md | 43 +- docs/features/chat/composer-and-ui.md | 37 +- docs/features/lanes/README.md | 2 +- .../features/terminals-and-sessions/README.md | 2 +- .../terminals-and-sessions/ui-surfaces.md | 6 +- 15 files changed, 1268 insertions(+), 431 deletions(-) delete mode 100644 apps/desktop/src/renderer/components/chat/AgentChatPane.test.ts rename apps/desktop/src/renderer/components/chat/{AgentChatPane.submit.test.tsx => AgentChatPane.test.tsx} (89%) diff --git a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts index 9ad99a45b..033bcb841 100644 --- a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts +++ b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts @@ -2053,7 +2053,6 @@ export function createSyncRemoteCommandService(args: SyncRemoteCommandServiceArg cols, rows, ...launch, - ...(launch.initialInput ? { awaitInitialInput: true } : {}), }); if (initialInputMeta.goal) { diff --git a/apps/desktop/src/main/services/sync/syncHostService.test.ts b/apps/desktop/src/main/services/sync/syncHostService.test.ts index d3ba26205..1764209cc 100644 --- a/apps/desktop/src/main/services/sync/syncHostService.test.ts +++ b/apps/desktop/src/main/services/sync/syncHostService.test.ts @@ -1737,7 +1737,7 @@ describe.skipIf(!isCrsqliteAvailable())("syncHostService", () => { expect(startCliCreateCall?.args).not.toContain(expect.stringContaining("fix from phone")); expect(startCliCreateCall?.initialInput).toContain("fix from phone"); expect(startCliCreateCall?.initialInputDelayMs).toBe(750); - expect(startCliCreateCall?.awaitInitialInput).toBe(true); + expect(startCliCreateCall).not.toHaveProperty("awaitInitialInput"); expect(writeBySessionId).toHaveBeenCalledTimes(1); expect(updateSessionMeta).toHaveBeenCalledWith(expect.objectContaining({ sessionId: "session-1", diff --git a/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts b/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts index c22b2890e..8d483e5db 100644 --- a/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts +++ b/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts @@ -2029,7 +2029,7 @@ describe("createSyncRemoteCommandService", () => { expect(createCall?.args).not.toContain(expect.stringContaining("fix the tests")); expect(createCall?.initialInput).toContain("fix the tests"); expect(createCall?.initialInputDelayMs).toBe(750); - expect(createCall?.awaitInitialInput).toBe(true); + expect(createCall).not.toHaveProperty("awaitInitialInput"); expect(ptyService.writeBySessionId).not.toHaveBeenCalled(); expect(sessionService.updateMeta).toHaveBeenCalledWith(expect.objectContaining({ sessionId: "pty-1", @@ -2202,7 +2202,7 @@ describe("createSyncRemoteCommandService", () => { expect(call?.startupCommand).toContain("cursor-agent --resume"); expect(call?.initialInput).toContain("fix the tests"); expect(call?.initialInputDelayMs).toBe(750); - expect(call?.awaitInitialInput).toBe(true); + expect(call).not.toHaveProperty("awaitInitialInput"); expect(call?.command).toBe("/bin/bash"); expect(call?.args).toEqual(["-lc", call?.startupCommand]); expect(ptyService.writeBySessionId).not.toHaveBeenCalled(); diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.test.ts b/apps/desktop/src/renderer/components/chat/AgentChatPane.test.ts deleted file mode 100644 index 1f8ee3f9e..000000000 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.test.ts +++ /dev/null @@ -1,260 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { getModelById } from "../../../shared/modelRegistry"; -import type { AgentChatEventEnvelope, AgentChatSessionSummary } from "../../../shared/types"; - -vi.mock("./AgentChatComposer", () => ({ - AgentChatComposer: () => null, -})); - -import { - buildParallelLaunchPrompt, - cleanupSubagentAutoOpenStorage, - cleanupTransientParallelLaunchLanes, - formatParallelLaunchFailureMessage, - getSubagentAutoOpenStorageKey, - mergeChatHistorySnapshot, - parallelLaneModelSuffix, - resolveNextSelectedSessionId, - shouldPromoteSessionForComputerUse, -} from "./AgentChatPane"; - -function buildSession(sessionId: string): AgentChatSessionSummary { - return { - sessionId, - laneId: "lane-1", - provider: "claude", - model: "claude", - endedAt: null, - lastOutputPreview: null, - summary: null, - startedAt: "2026-03-16T00:00:00.000Z", - lastActivityAt: "2026-03-16T00:00:00.000Z", - status: "idle", - title: null, - goal: null, - completion: null, - reasoningEffort: null, - executionMode: "focused", - }; -} - -describe("resolveNextSelectedSessionId", () => { - it("keeps the pending newly created session selected while list refresh still only contains the older chat", () => { - const rows = [buildSession("claude-existing")]; - - expect(resolveNextSelectedSessionId({ - rows, - current: null, - pendingSelectedSessionId: "codex-new", - optimisticSessionIds: new Set(["codex-new"]), - draftSelectionLocked: false, - forceDraft: false, - preferDraftStart: false, - })).toBe("codex-new"); - }); - - it("falls back to the newest persisted chat once no pending selection exists", () => { - const rows = [buildSession("claude-existing"), buildSession("older")]; - - expect(resolveNextSelectedSessionId({ - rows, - current: null, - pendingSelectedSessionId: null, - optimisticSessionIds: new Set(), - draftSelectionLocked: false, - forceDraft: false, - preferDraftStart: false, - })).toBe("claude-existing"); - }); -}); - -describe("shouldPromoteSessionForComputerUse", () => { - it("promotes older light sessions when the session profile isn't already workflow", () => { - expect(shouldPromoteSessionForComputerUse({ sessionProfile: "light" })).toBe(true); - expect(shouldPromoteSessionForComputerUse({ sessionProfile: undefined })).toBe(true); - expect(shouldPromoteSessionForComputerUse({ sessionProfile: "workflow" })).toBe(false); - }); -}); - -describe("mergeChatHistorySnapshot", () => { - function envelope( - timestamp: string, - sequence: number, - text: string, - ): AgentChatEventEnvelope { - return { - sessionId: "session-1", - timestamp, - sequence, - event: { - type: "text", - text, - messageId: `message-${text}`, - }, - }; - } - - it("keeps live events after a provider sequence reset", () => { - const beforeRestart = envelope("2026-04-30T23:14:47.751Z", 1003, "before restart"); - const afterRestartUser = envelope("2026-04-30T23:19:57.083Z", 1, "after restart user"); - const afterRestartReply = envelope("2026-04-30T23:21:34.621Z", 66, "after restart reply"); - const stillLive = envelope("2026-04-30T23:25:10.427Z", 146, "still live"); - - const merged = mergeChatHistorySnapshot( - [beforeRestart, afterRestartUser, afterRestartReply], - [beforeRestart, afterRestartUser, afterRestartReply, stillLive], - ); - - expect(merged.map((entry) => entry.event.type === "text" ? entry.event.text : "")).toEqual([ - "before restart", - "after restart user", - "after restart reply", - "still live", - ]); - }); - - it("keeps same-millisecond live tail events without trusting global sequence order", () => { - const snapshotLast = envelope("2026-04-30T23:25:10.427Z", 146, "snapshot last"); - const sameMillisecondTail = envelope("2026-04-30T23:25:10.427Z", 1, "same millisecond tail"); - - const merged = mergeChatHistorySnapshot( - [snapshotLast], - [snapshotLast, sameMillisecondTail], - ); - - expect(merged.map((entry) => entry.event.type === "text" ? entry.event.text : "")).toEqual([ - "snapshot last", - "same millisecond tail", - ]); - }); -}); - -describe("subagent auto-open storage", () => { - function createStorageShim(initial: Record = {}) { - const entries = new Map(Object.entries(initial)); - return { - get length() { - return entries.size; - }, - key(index: number) { - return Array.from(entries.keys())[index] ?? null; - }, - getItem(key: string) { - return entries.get(key) ?? null; - }, - setItem(key: string, value: string) { - entries.set(key, value); - }, - removeItem(key: string) { - entries.delete(key); - }, - }; - } - - it("expires timestamped auto-open markers and migrates legacy markers", () => { - const now = Date.parse("2026-05-14T12:00:00.000Z"); - const freshKey = getSubagentAutoOpenStorageKey("fresh-session"); - const staleKey = getSubagentAutoOpenStorageKey("stale-session"); - const legacyKey = getSubagentAutoOpenStorageKey("legacy-session"); - const storage = createStorageShim(); - - storage.setItem(freshKey, JSON.stringify({ firedAt: now - 60_000 })); - storage.setItem(staleKey, JSON.stringify({ firedAt: now - 8 * 24 * 60 * 60 * 1000 })); - storage.setItem(legacyKey, "1"); - storage.setItem("ade.chat.other", "keep"); - - cleanupSubagentAutoOpenStorage(storage, now); - - expect(storage.getItem(freshKey)).toBe(JSON.stringify({ firedAt: now - 60_000 })); - expect(storage.getItem(staleKey)).toBeNull(); - expect(storage.getItem(legacyKey)).toBe(JSON.stringify({ firedAt: now })); - expect(storage.getItem("ade.chat.other")).toBe("keep"); - }); -}); - -describe("parallel launch helpers", () => { - it("keeps same-family model lane suffixes distinct", () => { - expect(parallelLaneModelSuffix(getModelById("openai/gpt-5.4"))).toBe("codex-gpt-5-4"); - expect(parallelLaneModelSuffix(getModelById("openai/gpt-5.4-mini"))).toBe("codex-gpt-5-4-mini"); - }); - - it("preserves the default attachment review request when project docs are prepended", () => { - const result = buildParallelLaunchPrompt({ - text: "", - attachmentCount: 2, - }); - - expect(result.displayText).toBe("Please review the attached files."); - expect(result.sendText).toBe("Please review the attached files."); - }); - - it("uses an issue-context prompt for context-only parallel launches", () => { - const result = buildParallelLaunchPrompt({ - text: "", - attachmentCount: 0, - contextAttachmentCount: 1, - }); - - expect(result.displayText).toBe("Use the attached issue context."); - expect(result.sendText).toBe("Use the attached issue context."); - }); - - it("force-cleans transient lanes and refreshes lane state after rollback", async () => { - const deleteLane = vi.fn() - .mockResolvedValueOnce(undefined) - .mockRejectedValueOnce(new Error("Lane has uncommitted changes.")); - const refreshLanes = vi.fn().mockResolvedValue(undefined); - const onCleanupError = vi.fn(); - - const issues = await cleanupTransientParallelLaunchLanes({ - laneIds: ["lane-a", "lane-b"], - deleteLane, - refreshLanes, - onCleanupError, - }); - - expect(deleteLane).toHaveBeenNthCalledWith(1, { laneId: "lane-a", force: true }); - expect(deleteLane).toHaveBeenNthCalledWith(2, { laneId: "lane-b", force: true }); - expect(refreshLanes).toHaveBeenCalledTimes(1); - expect(onCleanupError).toHaveBeenCalledWith(expect.objectContaining({ - phase: "delete", - laneId: "lane-b", - })); - expect(issues).toEqual([ - expect.objectContaining({ - phase: "delete", - laneId: "lane-b", - }), - ]); - }); - - it("treats already-deleted lanes as cleaned up during rollback retries", async () => { - const deleteLane = vi.fn().mockRejectedValue(new Error("Lane not found.")); - const refreshLanes = vi.fn().mockResolvedValue(undefined); - const onCleanupError = vi.fn(); - - const issues = await cleanupTransientParallelLaunchLanes({ - laneIds: ["lane-a"], - deleteLane, - refreshLanes, - onCleanupError, - }); - - expect(deleteLane).toHaveBeenCalledWith({ laneId: "lane-a", force: true }); - expect(refreshLanes).toHaveBeenCalledTimes(1); - expect(onCleanupError).not.toHaveBeenCalled(); - expect(issues).toEqual([]); - }); - - it("formats rollback failures so leaked child lanes are surfaced to the user", () => { - expect(formatParallelLaunchFailureMessage({ - launchError: "Lane 2 failed to send.", - cleanupIssues: [ - { phase: "delete", laneId: "lane-a", error: new Error("locked") }, - { phase: "refresh", laneId: null, error: new Error("refresh failed") }, - ], - })).toBe( - "Lane 2 failed to send. Cleanup could not delete lane lane-a; lane list refresh also failed. Check the lane list before retrying.", - ); - }); -}); diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.submit.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx similarity index 89% rename from apps/desktop/src/renderer/components/chat/AgentChatPane.submit.test.tsx rename to apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx index 116f84e20..f78b129e6 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.submit.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx @@ -24,7 +24,16 @@ import { } from "../shared/ModelPicker/runtimeCatalogCache"; import { AgentChatPane, + buildParallelLaunchPrompt, + cleanupSubagentAutoOpenStorage, + cleanupTransientParallelLaunchLanes, + formatParallelLaunchFailureMessage, + getSubagentAutoOpenStorageKey, isMatchingOptimisticUserMessage, + mergeChatHistorySnapshot, + parallelLaneModelSuffix, + resolveNextSelectedSessionId, + shouldPromoteSessionForComputerUse, type AgentChatSessionCreatedOptions, } from "./AgentChatPane"; @@ -760,6 +769,20 @@ function renderAutoCreateDraftPane(args?: { ); } +function composerDraftStorageKeyForTest(args: { + projectRoot: string; + companionStateKey: string; + workDraftKind?: "chat" | "cli" | "chat-orchestrator"; +}) { + return [ + "ade.chat.composerDraft.v1", + args.projectRoot, + args.companionStateKey, + "standard", + args.workDraftKind ?? "chat", + ].map(encodeURIComponent).join(":"); +} + async function clickEnabledModelOption(name: RegExp | string) { const options = await screen.findAllByRole("option", { name }); const enabledOption = options.find((option) => option.getAttribute("aria-disabled") !== "true"); @@ -2953,8 +2976,8 @@ describe("AgentChatPane submit recovery", () => { expect.objectContaining({ id: "created-session", laneId: "lane-created" }), { activate: false, source: "draft-launch" }, ); - expect(screen.getByText("Launched in background-lane")).toBeTruthy(); - expect(screen.getByRole("button", { name: "Dismiss launch notice" })).toBeTruthy(); + expect(screen.getByText(/Launched chat in background-lane/i)).toBeTruthy(); + expect(screen.getByRole("button", { name: "Dismiss launch status" })).toBeTruthy(); }); expect(screen.getByTestId("location").textContent).toBe("/work"); @@ -3007,6 +3030,48 @@ describe("AgentChatPane submit recovery", () => { }); }); + it("merges a failed launch restore into a new draft instead of discarding the restore snapshot", async () => { + const { createLane, suggestLaneName } = installAdeMocks({ + sessions: [], + sendError: new Error("send failed"), + }); + suggestLaneName.mockResolvedValue("failing-draft-lane"); + createLane.mockResolvedValue({ + id: "lane-created", + name: "failing-draft-lane", + laneType: "worktree", + branchRef: "refs/heads/failing-draft-lane", + worktreePath: "/tmp/project-under-test/failing-draft-lane", + parentLaneId: "lane-primary", + }); + + renderAutoCreateDraftPane(); + + const modelTrigger = await screen.findByRole("button", { name: /^Select model/ }); + const codexLabel = getModelById("openai/gpt-5.4")?.displayName ?? "GPT-5.4"; + fireEvent.pointerDown(modelTrigger, { button: 0 }); + fireEvent.click(modelTrigger); + fireEvent.click(await screen.findByRole("tab", { name: /^OpenAI$/i })); + await clickEnabledModelOption(new RegExp(escapeRegExp(codexLabel), "i")); + + fireEvent.click(await screen.findByRole("button", { name: "Select lane" })); + fireEvent.click(await screen.findByRole("button", { name: /Auto-create lane/i })); + + const textbox = await screen.findByRole("textbox"); + fireEvent.change(textbox, { target: { value: "Failed launch draft." } }); + fireEvent.click(await screen.findByRole("button", { name: "Send" })); + + await waitFor(() => { + expect(screen.getByText(/Launch failed: send failed/i)).toBeTruthy(); + }); + + fireEvent.change(screen.getByRole("textbox"), { target: { value: "New draft stays." } }); + fireEvent.click(screen.getByRole("button", { name: "Restore" })); + + expect((screen.getByRole("textbox") as HTMLTextAreaElement).value).toBe("New draft stays.\n\nFailed launch draft."); + expect(screen.queryByRole("button", { name: "Restore" })).toBeNull(); + }); + it("deletes an auto-created draft lane when session creation fails", async () => { const onSessionCreated = vi.fn(); const { send, createLane, suggestLaneName, deleteChat, deleteLane } = installAdeMocks({ @@ -3047,7 +3112,76 @@ describe("AgentChatPane submit recovery", () => { }); }); - it("keeps the draft box editable while auto-create launch disables send actions", async () => { + it("restores the Work draft bucket after remount with text, model, and attachment refs", async () => { + installAdeMocks({ sessions: [] }); + const codexLabel = getModelById("openai/gpt-5.4")?.displayName ?? "GPT-5.4"; + window.localStorage.setItem(composerDraftStorageKeyForTest({ + projectRoot: "/tmp/project-under-test", + companionStateKey: "draft:lane-1", + }), JSON.stringify({ + version: 1, + text: "Persist this Work draft.", + modelId: "openai/gpt-5.4", + reasoningEffort: null, + codexFastMode: false, + executionMode: "focused", + controls: {}, + attachments: [{ path: "/tmp/project-under-test/spec.md", type: "file" }], + contextAttachments: [], + iosContextItems: [], + appControlContextItems: [], + builtInBrowserContextItems: [], + macosVmContextItems: [], + draftLaunchTargetId: null, + updatedAt: "2026-05-27T00:00:00.000Z", + })); + + const firstRender = renderAutoCreateDraftPane(); + + expect(await screen.findByDisplayValue("Persist this Work draft.")).toBeTruthy(); + expect(screen.getByText("spec.md")).toBeTruthy(); + expect(await screen.findByRole("button", { name: new RegExp(`Select model \\(current: ${escapeRegExp(codexLabel)}\\)`, "i") })).toBeTruthy(); + + firstRender.unmount(); + renderAutoCreateDraftPane(); + + expect(await screen.findByDisplayValue("Persist this Work draft.")).toBeTruthy(); + expect(screen.getByText("spec.md")).toBeTruthy(); + }); + + it("ignores malformed persisted draft attachment/context entries instead of crashing", async () => { + installAdeMocks({ sessions: [] }); + window.localStorage.setItem(composerDraftStorageKeyForTest({ + projectRoot: "/tmp/project-under-test", + companionStateKey: "draft:lane-1", + }), JSON.stringify({ + version: 1, + text: "Persisted with bad refs.", + modelId: "openai/gpt-5.4", + controls: {}, + attachments: [ + { type: "file" }, + { path: 42, type: "image" }, + { path: "/tmp/project-under-test/valid.md" }, + ], + contextAttachments: [ + { type: "linear_issue", issue: { id: null } }, + { type: "orchestration_annotation", item: { runId: "run-1", anchor: {}, capturedAt: "now" } }, + ], + iosContextItems: [{ kind: "ios_element", id: "bad" }], + appControlContextItems: [{ kind: "app_control_element", componentId: "missing-id" }], + builtInBrowserContextItems: [{ kind: "built_in_browser_element" }], + macosVmContextItems: [{ kind: "macos_vm_target", id: "vm-1" }], + })); + + renderAutoCreateDraftPane(); + + expect(await screen.findByDisplayValue("Persisted with bad refs.")).toBeTruthy(); + expect(screen.getByText("valid.md")).toBeTruthy(); + expect(screen.queryByText("undefined")).toBeNull(); + }); + + it("clears the submitted draft and keeps the composer usable while auto-create launch is pending", async () => { const { suggestLaneName } = installAdeMocks({ sessions: [] }); let resolveSuggestedName!: (value: string) => void; suggestLaneName.mockImplementation(() => new Promise((resolve) => { @@ -3072,21 +3206,81 @@ describe("AgentChatPane submit recovery", () => { await waitFor(() => { expect(suggestLaneName).toHaveBeenCalled(); + expect(screen.getByText(/Creating lane for chat/i)).toBeTruthy(); expect((screen.getByRole("button", { name: "Send" }) as HTMLButtonElement).disabled).toBe(true); - expect((screen.getByRole("button", { name: "Launching" }) as HTMLButtonElement).disabled).toBe(true); + expect((screen.getByRole("button", { name: "Launch in background" }) as HTMLButtonElement).disabled).toBe(true); }); expect((textbox as HTMLTextAreaElement).disabled).toBe(false); + expect((textbox as HTMLTextAreaElement).value).toBe(""); fireEvent.change(textbox, { target: { value: "Next thought while it launches." } }); expect((textbox as HTMLTextAreaElement).value).toBe("Next thought while it launches."); + expect((screen.getByRole("button", { name: "Send" }) as HTMLButtonElement).disabled).toBe(false); + expect((screen.getByRole("button", { name: "Launch in background" }) as HTMLButtonElement).disabled).toBe(false); resolveSuggestedName("still-editable-lane"); await waitFor(() => { - expect(screen.getByText("Launched in auto-created-lane")).toBeTruthy(); + expect(screen.getByText(/Launched chat in auto-created-lane/i)).toBeTruthy(); expect((screen.getByRole("textbox") as HTMLTextAreaElement).value).toBe("Next thought while it launches."); }); }); + it("allows multiple background auto-create launches to stay pending at the same time", async () => { + const { suggestLaneName, createLane, create, send } = installAdeMocks({ sessions: [] }); + const suggestResolvers: Array<(value: string) => void> = []; + suggestLaneName.mockImplementation(() => new Promise((resolve) => { + suggestResolvers.push(resolve); + })); + createLane.mockImplementation(async ({ name }: { name: string; parentLaneId: string }) => ({ + id: `lane-${name}`, + name, + laneType: "worktree", + branchRef: `refs/heads/${name}`, + worktreePath: `/tmp/project-under-test/${name}`, + parentLaneId: "lane-primary", + })); + + renderAutoCreateDraftPane(); + + const modelTrigger = await screen.findByRole("button", { name: /^Select model/ }); + const codexLabel = getModelById("openai/gpt-5.4")?.displayName ?? "GPT-5.4"; + fireEvent.pointerDown(modelTrigger, { button: 0 }); + fireEvent.click(modelTrigger); + fireEvent.click(await screen.findByRole("tab", { name: /^OpenAI$/i })); + await clickEnabledModelOption(new RegExp(escapeRegExp(codexLabel), "i")); + + fireEvent.click(await screen.findByRole("button", { name: "Select lane" })); + fireEvent.click(await screen.findByRole("button", { name: /Auto-create lane/i })); + + const textbox = await screen.findByRole("textbox"); + fireEvent.change(textbox, { target: { value: "First auto lane." } }); + fireEvent.click(await screen.findByRole("button", { name: "Launch in background" })); + await waitFor(() => { + expect(suggestLaneName).toHaveBeenCalledTimes(1); + expect((textbox as HTMLTextAreaElement).value).toBe(""); + }); + + fireEvent.change(textbox, { target: { value: "Second auto lane." } }); + fireEvent.click(await screen.findByRole("button", { name: "Launch in background" })); + await waitFor(() => { + expect(suggestLaneName).toHaveBeenCalledTimes(2); + expect(screen.getAllByText(/Creating lane for chat/i)).toHaveLength(2); + }); + + await act(async () => { + suggestResolvers[0]?.("first-lane"); + suggestResolvers[1]?.("second-lane"); + }); + + await waitFor(() => { + expect(createLane).toHaveBeenCalledTimes(2); + expect(create).toHaveBeenCalledTimes(2); + expect(send).toHaveBeenCalledTimes(2); + expect(screen.getByText(/Launched chat in first-lane/i)).toBeTruthy(); + expect(screen.getByText(/Launched chat in second-lane/i)).toBeTruthy(); + }); + }); + it("launches a tracked CLI session from the Work draft composer instead of creating an ADE chat", async () => { const { send, create } = installAdeMocks({ sessions: [] }); const onLaunchCliSession = vi.fn().mockResolvedValue({ sessionId: "terminal-1", ptyId: "pty-1" }); @@ -3362,8 +3556,8 @@ describe("AgentChatPane submit recovery", () => { tracked: true, disposition: "background", })); - expect(screen.getByText("Launched in background-cli-lane")).toBeTruthy(); - expect(screen.getByRole("button", { name: "Dismiss launch notice" })).toBeTruthy(); + expect(screen.getByText(/Launched CLI session in background-cli-lane/i)).toBeTruthy(); + expect(screen.getByRole("button", { name: "Dismiss launch status" })).toBeTruthy(); }); const launchArgs = onLaunchCliSession.mock.calls[0]?.[0]; expect(launchArgs.startupCommand).not.toContain("Launch this CLI session in the background."); @@ -4108,3 +4302,248 @@ describe("AgentChatPane submit recovery", () => { errorSpy.mockRestore(); }); }); + +// --------------------------------------------------------------------------- +// Pure function unit tests (consolidated from AgentChatPane.test.ts) +// --------------------------------------------------------------------------- + +describe("resolveNextSelectedSessionId", () => { + function buildMinimalSession(sessionId: string): AgentChatSessionSummary { + return { + sessionId, + laneId: "lane-1", + provider: "claude", + model: "claude", + endedAt: null, + lastOutputPreview: null, + summary: null, + startedAt: "2026-03-16T00:00:00.000Z", + lastActivityAt: "2026-03-16T00:00:00.000Z", + status: "idle", + title: null, + goal: null, + completion: null, + reasoningEffort: null, + executionMode: "focused", + }; + } + + it("keeps the pending newly created session selected while list refresh still only contains the older chat", () => { + const rows = [buildMinimalSession("claude-existing")]; + + expect(resolveNextSelectedSessionId({ + rows, + current: null, + pendingSelectedSessionId: "codex-new", + optimisticSessionIds: new Set(["codex-new"]), + draftSelectionLocked: false, + forceDraft: false, + preferDraftStart: false, + })).toBe("codex-new"); + }); + + it("falls back to the newest persisted chat once no pending selection exists", () => { + const rows = [buildMinimalSession("claude-existing"), buildMinimalSession("older")]; + + expect(resolveNextSelectedSessionId({ + rows, + current: null, + pendingSelectedSessionId: null, + optimisticSessionIds: new Set(), + draftSelectionLocked: false, + forceDraft: false, + preferDraftStart: false, + })).toBe("claude-existing"); + }); +}); + +describe("shouldPromoteSessionForComputerUse", () => { + it("promotes older light sessions when the session profile isn't already workflow", () => { + expect(shouldPromoteSessionForComputerUse({ sessionProfile: "light" })).toBe(true); + expect(shouldPromoteSessionForComputerUse({ sessionProfile: undefined })).toBe(true); + expect(shouldPromoteSessionForComputerUse({ sessionProfile: "workflow" })).toBe(false); + }); +}); + +describe("mergeChatHistorySnapshot", () => { + function envelope( + timestamp: string, + sequence: number, + text: string, + ): AgentChatEventEnvelope { + return { + sessionId: "session-1", + timestamp, + sequence, + event: { + type: "text", + text, + messageId: `message-${text}`, + }, + }; + } + + it("keeps live events after a provider sequence reset", () => { + const beforeRestart = envelope("2026-04-30T23:14:47.751Z", 1003, "before restart"); + const afterRestartUser = envelope("2026-04-30T23:19:57.083Z", 1, "after restart user"); + const afterRestartReply = envelope("2026-04-30T23:21:34.621Z", 66, "after restart reply"); + const stillLive = envelope("2026-04-30T23:25:10.427Z", 146, "still live"); + + const merged = mergeChatHistorySnapshot( + [beforeRestart, afterRestartUser, afterRestartReply], + [beforeRestart, afterRestartUser, afterRestartReply, stillLive], + ); + + expect(merged.map((entry) => entry.event.type === "text" ? entry.event.text : "")).toEqual([ + "before restart", + "after restart user", + "after restart reply", + "still live", + ]); + }); + + it("keeps same-millisecond live tail events without trusting global sequence order", () => { + const snapshotLast = envelope("2026-04-30T23:25:10.427Z", 146, "snapshot last"); + const sameMillisecondTail = envelope("2026-04-30T23:25:10.427Z", 1, "same millisecond tail"); + + const merged = mergeChatHistorySnapshot( + [snapshotLast], + [snapshotLast, sameMillisecondTail], + ); + + expect(merged.map((entry) => entry.event.type === "text" ? entry.event.text : "")).toEqual([ + "snapshot last", + "same millisecond tail", + ]); + }); +}); + +describe("subagent auto-open storage", () => { + function createStorageShim(initial: Record = {}) { + const entries = new Map(Object.entries(initial)); + return { + get length() { + return entries.size; + }, + key(index: number) { + return Array.from(entries.keys())[index] ?? null; + }, + getItem(key: string) { + return entries.get(key) ?? null; + }, + setItem(key: string, value: string) { + entries.set(key, value); + }, + removeItem(key: string) { + entries.delete(key); + }, + }; + } + + it("expires timestamped auto-open markers and migrates legacy markers", () => { + const now = Date.parse("2026-05-14T12:00:00.000Z"); + const freshKey = getSubagentAutoOpenStorageKey("fresh-session"); + const staleKey = getSubagentAutoOpenStorageKey("stale-session"); + const legacyKey = getSubagentAutoOpenStorageKey("legacy-session"); + const storage = createStorageShim(); + + storage.setItem(freshKey, JSON.stringify({ firedAt: now - 60_000 })); + storage.setItem(staleKey, JSON.stringify({ firedAt: now - 8 * 24 * 60 * 60 * 1000 })); + storage.setItem(legacyKey, "1"); + storage.setItem("ade.chat.other", "keep"); + + cleanupSubagentAutoOpenStorage(storage, now); + + expect(storage.getItem(freshKey)).toBe(JSON.stringify({ firedAt: now - 60_000 })); + expect(storage.getItem(staleKey)).toBeNull(); + expect(storage.getItem(legacyKey)).toBe(JSON.stringify({ firedAt: now })); + expect(storage.getItem("ade.chat.other")).toBe("keep"); + }); +}); + +describe("parallel launch helpers", () => { + it("keeps same-family model lane suffixes distinct", () => { + expect(parallelLaneModelSuffix(getModelById("openai/gpt-5.4"))).toBe("codex-gpt-5-4"); + expect(parallelLaneModelSuffix(getModelById("openai/gpt-5.4-mini"))).toBe("codex-gpt-5-4-mini"); + }); + + it("preserves the default attachment review request when project docs are prepended", () => { + const result = buildParallelLaunchPrompt({ + text: "", + attachmentCount: 2, + }); + + expect(result.displayText).toBe("Please review the attached files."); + expect(result.sendText).toBe("Please review the attached files."); + }); + + it("uses an issue-context prompt for context-only parallel launches", () => { + const result = buildParallelLaunchPrompt({ + text: "", + attachmentCount: 0, + contextAttachmentCount: 1, + }); + + expect(result.displayText).toBe("Use the attached issue context."); + expect(result.sendText).toBe("Use the attached issue context."); + }); + + it("force-cleans transient lanes and refreshes lane state after rollback", async () => { + const deleteLane = vi.fn() + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(new Error("Lane has uncommitted changes.")); + const refreshLanes = vi.fn().mockResolvedValue(undefined); + const onCleanupError = vi.fn(); + + const issues = await cleanupTransientParallelLaunchLanes({ + laneIds: ["lane-a", "lane-b"], + deleteLane, + refreshLanes, + onCleanupError, + }); + + expect(deleteLane).toHaveBeenNthCalledWith(1, { laneId: "lane-a", force: true }); + expect(deleteLane).toHaveBeenNthCalledWith(2, { laneId: "lane-b", force: true }); + expect(refreshLanes).toHaveBeenCalledTimes(1); + expect(onCleanupError).toHaveBeenCalledWith(expect.objectContaining({ + phase: "delete", + laneId: "lane-b", + })); + expect(issues).toEqual([ + expect.objectContaining({ + phase: "delete", + laneId: "lane-b", + }), + ]); + }); + + it("treats already-deleted lanes as cleaned up during rollback retries", async () => { + const deleteLane = vi.fn().mockRejectedValue(new Error("Lane not found.")); + const refreshLanes = vi.fn().mockResolvedValue(undefined); + const onCleanupError = vi.fn(); + + const issues = await cleanupTransientParallelLaunchLanes({ + laneIds: ["lane-a"], + deleteLane, + refreshLanes, + onCleanupError, + }); + + expect(deleteLane).toHaveBeenCalledWith({ laneId: "lane-a", force: true }); + expect(refreshLanes).toHaveBeenCalledTimes(1); + expect(onCleanupError).not.toHaveBeenCalled(); + expect(issues).toEqual([]); + }); + + it("formats rollback failures so leaked child lanes are surfaced to the user", () => { + expect(formatParallelLaunchFailureMessage({ + launchError: "Lane 2 failed to send.", + cleanupIssues: [ + { phase: "delete", laneId: "lane-a", error: new Error("locked") }, + { phase: "refresh", laneId: null, error: new Error("refresh failed") }, + ], + })).toBe( + "Lane 2 failed to send. Cleanup could not delete lane lane-a; lane list refresh also failed. Check the lane list before retrying.", + ); + }); +}); diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index e6e62972e..449ef0b08 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -1,9 +1,10 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useNavigate } from "react-router-dom"; import { AnimatePresence, motion } from "motion/react"; -import { Cube, Desktop, DeviceMobile, ArrowBendUpRight, Lightning, Plus, Terminal, TreeStructure, X } from "@phosphor-icons/react"; +import { CircleNotch, Cube, Desktop, DeviceMobile, ArrowBendUpRight, Lightning, Plus, Terminal, TreeStructure, X } from "@phosphor-icons/react"; import { inferAttachmentType, + mergeAttachments, PARALLEL_CHAT_MAX_ATTACHMENTS, type AgentChatApprovalDecision, type AgentChatClaudePermissionMode, @@ -48,6 +49,7 @@ import { makeLinearIssueContextAttachment, makeOrchestrationAnnotationContextAttachment, mergeChatContextAttachments, + normalizeChatContextAttachments, removeChatContextAttachment, } from "../../../shared/chatContextAttachments"; import type { @@ -161,6 +163,7 @@ import { playAgentTurnCompletionSound } from "../../lib/agentTurnCompletionSound const LAST_MODEL_ID_KEY = "ade.chat.lastModelId"; const LAST_REASONING_KEY_PREFIX = "ade.chat.lastReasoningEffort"; const LAST_LAUNCH_CONFIG_KEY_PREFIX = "ade.chat.lastLaunchConfig.v1"; +const COMPOSER_DRAFT_STORAGE_KEY_PREFIX = "ade.chat.composerDraft.v1"; const SUBAGENT_AUTOOPEN_FIRED_KEY_PREFIX = "ade.chat.subagentAutoOpenFired"; const SUBAGENT_AUTOOPEN_FIRED_TTL_MS = 7 * 24 * 60 * 60 * 1000; const workCliStartupDelayMs = 180; @@ -247,6 +250,12 @@ const MAX_BACKGROUND_CHAT_SESSION_EVENTS = 1_000; type DraftLaunchSnapshot = { text: string; draft: string; + modelId: string; + reasoningEffort: string | null; + codexFastMode: boolean; + executionMode: AgentChatExecutionMode; + interactionMode: AgentChatInteractionMode; + nativeControls: NativeControlState; attachments: AgentChatFileRef[]; contextAttachments: AgentChatContextAttachment[]; iosContextItems: IosElementContextItem[]; @@ -274,6 +283,22 @@ type BackgroundLaunchNotice = { type DraftLaunchMode = "foreground" | "background"; type DraftLaunchKind = BackgroundLaunchNotice["draftKind"]; +type DraftLaunchJobStatus = "creating-lane" | "starting-session" | "sending-prompt" | "ready" | "failed"; + +type DraftLaunchJob = { + id: string; + mode: DraftLaunchMode; + draftKind: DraftLaunchKind; + status: DraftLaunchJobStatus; + title: string; + laneId: string | null; + laneName: string | null; + sessionId: string | null; + error: string | null; + autoOpen: boolean; + createdAtMs: number; + snapshot: DraftLaunchSnapshot; +}; type DraftLaunchLaneTarget = { laneId: string; @@ -315,6 +340,38 @@ function buildDraftLaunchNamingSeed(snapshot: DraftLaunchSnapshot): string { return parts.join(" - "); } +function createDraftLaunchJobId(): string { + try { + const uuid = globalThis.crypto?.randomUUID?.(); + if (uuid) return uuid; + } catch { + // crypto.randomUUID may throw in insecure contexts; fall through. + } + return `draft-launch-${Date.now()}-${Math.random().toString(36).slice(2)}`; +} + +function buildDraftLaunchJobTitle(kind: DraftLaunchKind, snapshot: DraftLaunchSnapshot): string { + return workCliTitleFromPrompt( + buildDraftLaunchNamingSeed(snapshot), + kind === "cli" ? "CLI session" : "Chat", + ); +} + +function draftLaunchKindLabel(kind: DraftLaunchKind): string { + return kind === "cli" ? "CLI session" : "chat"; +} + +function draftLaunchJobMessage(job: DraftLaunchJob): string { + const laneSuffix = job.laneName ? ` in ${job.laneName}` : ""; + if (job.status === "creating-lane") return `Creating lane for ${draftLaunchKindLabel(job.draftKind)}...`; + if (job.status === "starting-session") return `Starting ${draftLaunchKindLabel(job.draftKind)}${laneSuffix}...`; + if (job.status === "sending-prompt") return `Sending prompt to ${draftLaunchKindLabel(job.draftKind)}${laneSuffix}...`; + if (job.status === "failed") return job.error ? `Launch failed: ${job.error}` : "Launch failed."; + return job.mode === "background" + ? `Launched ${draftLaunchKindLabel(job.draftKind)}${laneSuffix}.` + : `Opened ${draftLaunchKindLabel(job.draftKind)}${laneSuffix}.`; +} + type AiStatusSnapshot = AiSettingsStatus & { runtimeConnections?: Record; }; @@ -571,6 +628,24 @@ type LastLaunchConfig = { updatedAt: string; }; +type ComposerDraftStorageSnapshot = { + version: 1; + text: string; + modelId: string; + reasoningEffort: string | null; + codexFastMode: boolean; + executionMode: AgentChatExecutionMode; + controls: NativeControlState; + attachments: AgentChatFileRef[]; + contextAttachments: AgentChatContextAttachment[]; + iosContextItems: IosElementContextItem[]; + appControlContextItems: AppControlContextItem[]; + builtInBrowserContextItems: BuiltInBrowserContextItem[]; + macosVmContextItems: MacosVmContextItem[]; + draftLaunchTargetId: string | null; + updatedAt: string; +}; + type ParallelModelRowState = NativeControlState & { modelId: string; reasoningEffort: string | null; @@ -593,6 +668,21 @@ function launchConfigStorageKey(scope: { ].map(encodeURIComponent).join(":"); } +function composerDraftStorageKey(scope: { + projectRoot: string | null | undefined; + companionStateKey: string; + surfaceProfile: ChatSurfaceProfile; + workDraftKind: "chat" | "cli" | "chat-orchestrator"; +}): string { + return [ + COMPOSER_DRAFT_STORAGE_KEY_PREFIX, + scope.projectRoot?.trim() || "project", + scope.companionStateKey, + scope.surfaceProfile, + scope.workDraftKind, + ].map(encodeURIComponent).join(":"); +} + function defaultNativeControls(profile: ChatSurfaceProfile): NativeControlState { if (profile === "persistent_identity") { return { @@ -1236,6 +1326,20 @@ const CODEX_CONFIG_SOURCES: readonly AgentChatCodexConfigSource[] = ["flags", "c const OPENCODE_PERMISSION_MODES: readonly AgentChatOpenCodePermissionMode[] = ["plan", "edit", "full-auto", "config-toml"]; const DROID_PERMISSION_MODES: readonly AgentChatDroidPermissionMode[] = ["read-only", "auto-low", "auto-medium", "auto-high"]; const EXECUTION_MODES: readonly AgentChatExecutionMode[] = ["focused", "parallel", "subagents", "teams"]; +const APP_CONTROL_PROVIDERS: readonly AppControlContextItem["provider"][] = ["cdp", "os-accessibility", "computer-use", "external", "coordinate-fallback"]; +const MACOS_VM_PROVIDERS: readonly MacosVmContextItem["provider"][] = ["lume", "apple-virtualization-helper"]; +const MACOS_VM_STATES: readonly MacosVmContextItem["state"][] = [ + "not_created", + "creating", + "installing", + "stopped", + "starting", + "running", + "stopping", + "paused", + "failed", + "unknown", +]; const EMPTY_CHAT_EVENTS: AgentChatEventEnvelope[] = []; const EMPTY_REASONING_TIERS: string[] = []; @@ -1460,6 +1564,236 @@ function writeLastLaunchConfig(storageKey: string, config: LastLaunchConfig): vo } } +function nonEmptyString(value: unknown): string | null { + return typeof value === "string" && value.trim().length ? value : null; +} + +function nullableString(value: unknown): string | null { + return typeof value === "string" ? value : null; +} + +function finiteNumberOrNull(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +function normalizeComposerFrame(value: unknown): { x: number; y: number; width: number; height: number } | null { + if (!isRecord(value)) return null; + const x = finiteNumberOrNull(value.x); + const y = finiteNumberOrNull(value.y); + const width = finiteNumberOrNull(value.width); + const height = finiteNumberOrNull(value.height); + return x == null || y == null || width == null || height == null + ? null + : { x, y, width, height }; +} + +function normalizeComposerFileAttachments(value: unknown): AgentChatFileRef[] { + if (!Array.isArray(value)) return []; + const out = new Map(); + for (const entry of value) { + if (!isRecord(entry)) continue; + const rawType = entry.type; + const path = nonEmptyString(entry.path); + if (rawType === "image-url") { + const url = nonEmptyString(entry.url); + if (!url) continue; + out.set(path ?? url, { path: path ?? url, type: "image-url", url }); + continue; + } + if (!path) continue; + const type = rawType === "image" || rawType === "file" + ? rawType + : inferAttachmentType(path); + out.set(path, { path, type }); + } + return [...out.values()]; +} + +function normalizeComposerOrchestrationContextAttachment(value: unknown): AgentChatContextAttachment | null { + if (!isRecord(value) || value.type !== "orchestration_annotation") return null; + const item = isRecord(value.item) ? value.item : null; + const anchor = item && isRecord(item.anchor) ? item.anchor : null; + const runId = item ? nonEmptyString(item.runId) : null; + const capturedAt = item ? nonEmptyString(item.capturedAt) : null; + const anchorKind = anchor ? nonEmptyString(anchor.kind) : null; + if (!item || !anchor || !runId || !capturedAt || !anchorKind) return null; + const normalizedItem: OrchestrationContextItem = { + type: "orchestration_annotation", + runId, + anchor: { + kind: anchorKind as OrchestrationContextItem["anchor"]["kind"], + ...(nonEmptyString(anchor.id) ? { id: nonEmptyString(anchor.id)! } : {}), + preview: typeof anchor.preview === "string" ? anchor.preview : "", + ...(nonEmptyString(anchor.href) ? { href: nonEmptyString(anchor.href)! } : {}), + ...(nonEmptyString(anchor.sectionId) ? { sectionId: nonEmptyString(anchor.sectionId)! } : {}), + }, + selectionExcerpt: typeof item.selectionExcerpt === "string" ? item.selectionExcerpt : "", + comment: typeof item.comment === "string" ? item.comment : "", + capturedAt, + }; + return { + type: "orchestration_annotation", + item: normalizedItem, + source: "manual", + attachedAt: nullableString(value.attachedAt) ?? undefined, + }; +} + +function normalizeComposerContextAttachments(value: unknown): AgentChatContextAttachment[] { + const linear = normalizeChatContextAttachments(value); + const annotations = Array.isArray(value) + ? value.flatMap((entry) => { + const normalized = normalizeComposerOrchestrationContextAttachment(entry); + return normalized ? [normalized] : []; + }) + : []; + return mergeChatContextAttachments(linear, annotations); +} + +function normalizeComposerIosContextItems(value: unknown): IosElementContextItem[] { + if (!Array.isArray(value)) return []; + return value.flatMap((entry) => { + if (!isRecord(entry) || entry.kind !== "ios_element") return []; + const id = nonEmptyString(entry.id); + const componentId = nonEmptyString(entry.componentId); + if (!id || !componentId) return []; + return [{ + kind: "ios_element", + id, + componentId, + sourceFile: nullableString(entry.sourceFile), + sourceLine: finiteNumberOrNull(entry.sourceLine), + frame: normalizeComposerFrame(entry.frame), + metadata: isRecord(entry.metadata) ? entry.metadata : {}, + accessibilityIdentifier: nullableString(entry.accessibilityIdentifier), + screenshotDataUrl: nullableString(entry.screenshotDataUrl) ?? undefined, + selectedAt: nonEmptyString(entry.selectedAt) ?? new Date(0).toISOString(), + }]; + }); +} + +function normalizeComposerAppControlContextItems(value: unknown): AppControlContextItem[] { + if (!Array.isArray(value)) return []; + return value.flatMap((entry) => { + if (!isRecord(entry) || entry.kind !== "app_control_element") return []; + const id = nonEmptyString(entry.id); + const componentId = nonEmptyString(entry.componentId); + if (!id || !componentId) return []; + return [{ + kind: "app_control_element", + id, + appKind: "electron", + sessionId: nullableString(entry.sessionId), + provider: pickStringEnum(entry.provider, APP_CONTROL_PROVIDERS, "coordinate-fallback"), + componentId, + sourceFile: nullableString(entry.sourceFile), + sourceLine: finiteNumberOrNull(entry.sourceLine), + frame: normalizeComposerFrame(entry.frame), + metadata: isRecord(entry.metadata) ? entry.metadata : {}, + screenshotDataUrl: nullableString(entry.screenshotDataUrl) ?? undefined, + selectedAt: nonEmptyString(entry.selectedAt) ?? new Date(0).toISOString(), + }]; + }); +} + +function normalizeComposerBuiltInBrowserContextItems(value: unknown): BuiltInBrowserContextItem[] { + if (!Array.isArray(value)) return []; + return value.flatMap((entry) => { + if (!isRecord(entry) || !nonEmptyString(entry.id) || !nonEmptyString(entry.componentId)) return []; + const normalized = normalizeBuiltInBrowserContextItem(entry); + return normalized ? [normalized] : []; + }); +} + +function normalizeComposerMacosVmContextItems(value: unknown): MacosVmContextItem[] { + if (!Array.isArray(value)) return []; + return value.flatMap((entry) => { + if (!isRecord(entry) || entry.kind !== "macos_vm_target") return []; + const id = nonEmptyString(entry.id); + const laneId = nonEmptyString(entry.laneId); + const vmName = nonEmptyString(entry.vmName); + if (!id || !laneId || !vmName) return []; + return [{ + kind: "macos_vm_target", + id, + laneId, + laneName: nonEmptyString(entry.laneName) ?? laneId, + vmName, + provider: pickStringEnum(entry.provider, MACOS_VM_PROVIDERS, "lume"), + state: pickStringEnum(entry.state, MACOS_VM_STATES, "unknown"), + hostLanePath: nonEmptyString(entry.hostLanePath) ?? "", + guestLanePath: nonEmptyString(entry.guestLanePath) ?? "", + runCommand: nonEmptyString(entry.runCommand) ?? "", + sshCommand: nullableString(entry.sshCommand), + vncUrl: nullableString(entry.vncUrl), + windowTitleQuery: nonEmptyString(entry.windowTitleQuery) ?? vmName, + screenshotDataUrl: nullableString(entry.screenshotDataUrl) ?? undefined, + selectedAt: nonEmptyString(entry.selectedAt) ?? new Date(0).toISOString(), + metadata: isRecord(entry.metadata) ? entry.metadata : {}, + }]; + }); +} + +function mergeComposerItemsById(current: T[], incoming: T[]): T[] { + if (!incoming.length) return current; + const merged = new Map(); + for (const item of current) merged.set(item.id, item); + for (const item of incoming) { + if (!merged.has(item.id)) merged.set(item.id, item); + } + return [...merged.values()]; +} + +function normalizeStoredComposerDraft( + value: unknown, + defaults: NativeControlState, +): ComposerDraftStorageSnapshot | null { + if (!isRecord(value)) return null; + const modelId = typeof value.modelId === "string" ? value.modelId.trim() : ""; + const desc = modelId ? getModelById(modelId) : null; + return { + version: 1, + text: typeof value.text === "string" ? value.text : "", + modelId, + reasoningEffort: nonEmptyString(value.reasoningEffort), + codexFastMode: modelSupportsFastMode(desc) && value.codexFastMode === true, + executionMode: pickStringEnum(value.executionMode, EXECUTION_MODES, "focused"), + controls: nativeControlsFromLaunchSource( + isRecord(value.controls) ? value.controls : {}, + defaults, + ), + attachments: normalizeComposerFileAttachments(value.attachments), + contextAttachments: normalizeComposerContextAttachments(value.contextAttachments), + iosContextItems: normalizeComposerIosContextItems(value.iosContextItems), + appControlContextItems: normalizeComposerAppControlContextItems(value.appControlContextItems), + builtInBrowserContextItems: normalizeComposerBuiltInBrowserContextItems(value.builtInBrowserContextItems), + macosVmContextItems: normalizeComposerMacosVmContextItems(value.macosVmContextItems), + draftLaunchTargetId: nonEmptyString(value.draftLaunchTargetId), + updatedAt: nonEmptyString(value.updatedAt) ?? new Date(0).toISOString(), + }; +} + +function readComposerDraftSnapshot( + storageKey: string, + defaults: NativeControlState, +): ComposerDraftStorageSnapshot | null { + try { + const raw = window.localStorage.getItem(storageKey); + if (!raw) return null; + return normalizeStoredComposerDraft(JSON.parse(raw), defaults); + } catch { + return null; + } +} + +function writeComposerDraftSnapshot(storageKey: string, snapshot: ComposerDraftStorageSnapshot): void { + try { + window.localStorage.setItem(storageKey, JSON.stringify(snapshot)); + } catch { + // ignore + } +} + function resolveCliRegistryModelId(provider: "codex" | "claude" | "cursor" | "droid", value: string | null | undefined): string | null { const normalized = (value ?? "").trim().toLowerCase(); if (!normalized.length) return null; @@ -1944,8 +2278,7 @@ export function AgentChatPane({ const [archivedSessions, setArchivedSessions] = useState([]); const [selectedSessionId, setSelectedSessionId] = useState(lockSessionId ?? initialSessionId ?? null); const [draftLaunchTargetId, setDraftLaunchTargetId] = useState(null); - const [backgroundLaunchBusy, setBackgroundLaunchBusy] = useState(false); - const [backgroundLaunchNotice, setBackgroundLaunchNotice] = useState(null); + const [draftLaunchJobs, setDraftLaunchJobs] = useState([]); const isWorkCliLaunchDraft = !lockSessionId && !initialSessionId @@ -2116,7 +2449,14 @@ export function AgentChatPane({ document.addEventListener("mouseup", onUp); }, [rightPaneSplit]); const companionStateKey = selectedSessionId ?? (laneId ? `draft:${laneId}` : "draft"); + const composerDraftStorageKeyValue = useMemo(() => composerDraftStorageKey({ + projectRoot, + companionStateKey, + surfaceProfile, + workDraftKind, + }), [companionStateKey, projectRoot, surfaceProfile, workDraftKind]); const companionHydrationKeyRef = useRef(initialCompanionStateKey); + const composerDraftHydratingRef = useRef(false); const [sessionDelta, setSessionDelta] = useState<{ insertions: number; deletions: number } | null>(null); const [sessionMutationKind, setSessionMutationKind] = useState<"model" | "permission" | "computer-use" | null>(null); const [promptSuggestion, setPromptSuggestion] = useState(null); @@ -2174,6 +2514,7 @@ export function AgentChatPane({ const optimisticSessionIdsRef = useRef>(new Set()); const pendingSelectedSessionIdRef = useRef(null); const submitInFlightRef = useRef(false); + const latestForegroundDraftLaunchJobIdRef = useRef(null); const createSessionPromiseRef = useRef | null>(null); const pendingNativeControlUpdateRef = useRef<{ sessionId: string; @@ -3529,18 +3870,6 @@ export function AgentChatPane({ }); }, [forceDraft, initialSessionId, laneId, lockSessionId, lockedSingleSessionMode, preferDraftStart, refreshLockedSessionSummary]); - // Save/restore per-session (or per-lane draft) composer text when scope changes. - const prevDraftKeyRef = useRef(undefined); - useEffect(() => { - if (prevDraftKeyRef.current !== undefined) { - draftsPerSessionRef.current.set(prevDraftKeyRef.current, draft); - } - prevDraftKeyRef.current = companionStateKey; - const saved = draftsPerSessionRef.current.get(companionStateKey) ?? ""; - setDraft(saved); - // eslint-disable-next-line react-hooks/exhaustive-deps -- only trigger on scope switch, not draft edits - }, [companionStateKey]); - useEffect(() => { if (!isTileActive) return; void refreshAvailableModels(); @@ -4143,15 +4472,13 @@ export function AgentChatPane({ }, [isTileActive, lockedSingleSessionMode, refreshComputerUseSnapshot, selectedSessionId]); useEffect(() => { - setAttachments([]); - setContextAttachments([]); setPromptSuggestion(null); setChatActionsOpen(false); setHandoffBusy(false); optimisticOutgoingMessageRef.current = null; setOptimisticOutgoingMessage(null); - // Also clear when the active lane changes — otherwise issue context staged - // in draft mode would persist after switching lanes. + // The full composer bucket effect above owns draft/context hydration for + // session and lane switches; this effect resets transient chat UI only. }, [selectedSessionId, laneId]); useEffect(() => { @@ -4786,6 +5113,106 @@ export function AgentChatPane({ nativeControlsRef.current = currentNativeControls; }, [currentNativeControls]); + // Save/restore per-session (or per-lane draft) composer state when scope changes. + const composerDraftTextRef = useRef(draft); + useEffect(() => { + composerDraftTextRef.current = draft; + }, [draft]); + const prevDraftKeyRef = useRef(undefined); + useEffect(() => { + if (prevDraftKeyRef.current !== undefined) { + draftsPerSessionRef.current.set(prevDraftKeyRef.current, composerDraftTextRef.current); + } + prevDraftKeyRef.current = companionStateKey; + const saved = readComposerDraftSnapshot(composerDraftStorageKeyValue, initialNativeControls); + composerDraftHydratingRef.current = true; + if (saved) { + draftsPerSessionRef.current.set(companionStateKey, saved.text); + setDraft(saved.text); + setAttachments(saved.attachments); + setContextAttachments(saved.contextAttachments); + setIosElementContextItems(saved.iosContextItems); + setAppControlContextItems(saved.appControlContextItems); + setBuiltInBrowserContextItems(saved.builtInBrowserContextItems); + setMacosVmContextItems(saved.macosVmContextItems); + setDraftLaunchTargetId(saved.draftLaunchTargetId); + if (!selectedSessionId && saved.modelId) { + draftLaunchConfigTouchedKeyRef.current = draftLaunchConfigScopeKey; + draftLaunchConfigHydratedRef.current = `${draftLaunchConfigScopeKey}:composer-draft`; + applyLaunchConfigToComposer({ + version: 1, + modelId: saved.modelId, + reasoningEffort: saved.reasoningEffort, + codexFastMode: saved.codexFastMode, + executionMode: saved.executionMode, + controls: saved.controls, + updatedAt: saved.updatedAt, + }); + } + return; + } + const savedText = draftsPerSessionRef.current.get(companionStateKey) ?? ""; + setDraft(savedText); + setAttachments([]); + setContextAttachments([]); + setIosElementContextItems([]); + setAppControlContextItems([]); + setBuiltInBrowserContextItems([]); + setMacosVmContextItems([]); + setDraftLaunchTargetId(null); + }, [ + applyLaunchConfigToComposer, + companionStateKey, + composerDraftStorageKeyValue, + draftLaunchConfigScopeKey, + initialNativeControls, + selectedSessionId, + ]); + + useEffect(() => { + if (composerDraftHydratingRef.current) { + composerDraftHydratingRef.current = false; + return; + } + draftsPerSessionRef.current.set(companionStateKey, draft); + writeComposerDraftSnapshot(composerDraftStorageKeyValue, { + version: 1, + text: draft, + modelId, + reasoningEffort, + codexFastMode, + executionMode, + controls: { + ...currentNativeControls, + cursorConfigValues: { ...currentNativeControls.cursorConfigValues }, + }, + attachments, + contextAttachments, + iosContextItems: iosElementContextItems, + appControlContextItems, + builtInBrowserContextItems, + macosVmContextItems, + draftLaunchTargetId, + updatedAt: new Date().toISOString(), + }); + }, [ + appControlContextItems, + attachments, + builtInBrowserContextItems, + codexFastMode, + companionStateKey, + composerDraftStorageKeyValue, + contextAttachments, + currentNativeControls, + draft, + draftLaunchTargetId, + executionMode, + iosElementContextItems, + macosVmContextItems, + modelId, + reasoningEffort, + ]); + useEffect(() => { if (!parallelChatMode) return; if (parallelModelSlots.length > 0) return; @@ -4888,31 +5315,44 @@ export function AgentChatPane({ const createSessionForLane = useCallback(async ( targetLaneId: string, - options: { select?: boolean; notify?: boolean; notifyOptions?: AgentChatSessionCreatedOptions } = {}, + options: { + select?: boolean; + notify?: boolean; + notifyOptions?: AgentChatSessionCreatedOptions; + launchState?: DraftLaunchSnapshot; + } = {}, ): Promise => { if (constrainedModelSelectionError) { throw new Error(constrainedModelSelectionError); } - const desc = resolveModelDescriptorWithRuntimeCatalog(modelId) ?? getModelById(modelId); - const permissionDesc = getModelDescriptorForPermissionMode(modelId); + const launchModelId = options.launchState?.modelId ?? modelId; + const launchReasoningEffort = options.launchState?.reasoningEffort ?? reasoningEffort; + const launchCodexFastMode = options.launchState?.codexFastMode ?? codexFastMode; + const launchExecutionMode = options.launchState?.executionMode ?? executionMode; + const baseNativeControls = options.launchState?.nativeControls ?? currentNativeControls; + const desc = resolveModelDescriptorWithRuntimeCatalog(launchModelId) ?? getModelById(launchModelId); + const permissionDesc = getModelDescriptorForPermissionMode(launchModelId); const provider = resolveChatRuntimeProvider(desc); - const model = provider === "opencode" ? modelId : runtimeFacingModelId(desc, modelId); + const model = provider === "opencode" ? launchModelId : runtimeFacingModelId(desc, launchModelId); const sessionProfile = resolveChatSessionProfile(); const harnessPermissionMode = provider === "opencode" ? recommendedOpenCodePermissionModeForModel(permissionDesc) : null; const launchControls = harnessPermissionMode ? { - ...currentNativeControls, + ...baseNativeControls, opencodePermissionMode: harnessPermissionMode, } - : currentNativeControls; + : baseNativeControls; const nativeControlPayload = harnessPermissionMode ? { ...summarizeNativeControls(provider, launchControls), - ...(provider === "cursor" ? { cursorConfigValues: currentNativeControls.cursorConfigValues } : {}), + ...(provider === "cursor" ? { cursorConfigValues: launchControls.cursorConfigValues } : {}), } - : buildNativeControlPayload(provider); + : { + ...summarizeNativeControls(provider, launchControls), + ...(provider === "cursor" ? { cursorConfigValues: launchControls.cursorConfigValues } : {}), + }; // Orchestrator-lead draft: force the interactionMode so the lead chat // boots with the orchestrator skill + tool gates (`goal.md` §10.1). const orchestratorOverrides: Partial[0]> = @@ -4923,10 +5363,10 @@ export function AgentChatPane({ laneId: targetLaneId, provider, model, - modelId, + modelId: launchModelId, sessionProfile, - reasoningEffort, - ...(modelSupportsFastMode(desc) ? { codexFastMode } : {}), + reasoningEffort: launchReasoningEffort, + ...(modelSupportsFastMode(desc) ? { codexFastMode: launchCodexFastMode } : {}), ...nativeControlPayload, ...orchestratorOverrides, }); @@ -4959,10 +5399,10 @@ export function AgentChatPane({ } const launchConfig = buildLastLaunchConfig({ model: created.model, - modelId: created.modelId ?? modelId, - reasoningEffort, - codexFastMode: modelSupportsFastMode(desc) && codexFastMode, - executionMode, + modelId: created.modelId ?? launchModelId, + reasoningEffort: launchReasoningEffort, + codexFastMode: modelSupportsFastMode(desc) && launchCodexFastMode, + executionMode: launchExecutionMode, permissionMode: nativeControlPayload.permissionMode, interactionMode: launchControls.interactionMode, claudePermissionMode: launchControls.claudePermissionMode, @@ -4999,7 +5439,7 @@ export function AgentChatPane({ if (desc?.isCliWrapped && (desc.family === "anthropic" || desc.family === "cursor")) { window.ade.agentChat.warmupModel({ sessionId: created.id, - modelId, + modelId: launchModelId, }).then(() => { if (targetLaneId === laneId) void refreshSessions(); }).catch(() => { /* warmup is best-effort */ }); @@ -5007,7 +5447,7 @@ export function AgentChatPane({ if (options.notify) notifySessionCreated(created, options.notifyOptions); if (targetLaneId === laneId) void refreshSessions().catch(() => {}); return created; - }, [buildNativeControlPayload, codexFastMode, constrainedModelSelectionError, currentNativeControls, executionMode, initialNativeControls, iosSimulatorOpen, laneId, modelId, notifySessionCreated, patchSessionSummary, reasoningEffort, refreshSessions, touchSession, workDraftKind]); + }, [codexFastMode, constrainedModelSelectionError, currentNativeControls, executionMode, initialNativeControls, iosSimulatorOpen, laneId, lastLaunchConfigStorageKey, modelId, notifySessionCreated, patchSessionSummary, reasoningEffort, refreshSessions, touchSession, workDraftKind]); const createSession = useCallback(async (): Promise => { if (createSessionPromiseRef.current) { @@ -5060,6 +5500,15 @@ export function AgentChatPane({ return { text, draft, + modelId, + reasoningEffort, + codexFastMode, + executionMode, + interactionMode, + nativeControls: { + ...currentNativeControls, + cursorConfigValues: { ...currentNativeControls.cursorConfigValues }, + }, attachments: [...attachments], contextAttachments: contextAttachmentsSnapshot, iosContextItems: iosContextSnapshot, @@ -5074,11 +5523,17 @@ export function AgentChatPane({ appControlContextItems, attachments, builtInBrowserContextItems, + codexFastMode, contextAttachments, + currentNativeControls, draft, + executionMode, + interactionMode, iosElementContextItems, isWorkCliLaunchDraft, macosVmContextItems, + modelId, + reasoningEffort, ]); const prepareDraftLaunchForSend = useCallback(async ( @@ -5110,17 +5565,55 @@ export function AgentChatPane({ }, []); const restoreDraftLaunchSnapshot = useCallback((snapshot: DraftLaunchSnapshot) => { - setDraft((current) => (current.trim().length ? current : snapshot.draft)); - setAttachments((current) => (current.length ? current : snapshot.attachments)); - setContextAttachments((current) => (current.length ? current : snapshot.contextAttachments)); - setIosElementContextItems((current) => (current.length ? current : snapshot.iosContextItems)); - setAppControlContextItems((current) => (current.length ? current : snapshot.appControlContextItems)); - setBuiltInBrowserContextItems((current) => (current.length ? current : snapshot.builtInBrowserContextItems)); - setMacosVmContextItems((current) => (current.length ? current : snapshot.macosVmContextItems)); + setDraft((current) => { + const snapshotDraft = snapshot.draft.trim(); + const hasCurrentDraft = current.trim().length > 0; + let next: string; + if (hasCurrentDraft && snapshotDraft && !current.includes(snapshot.draft)) { + next = `${current.trimEnd()}\n\n${snapshot.draft}`; + } else if (hasCurrentDraft) { + next = current; + } else { + next = snapshot.draft; + } + draftsPerSessionRef.current.set(companionStateKey, next); + return next; + }); + setAttachments((current) => mergeAttachments(current, snapshot.attachments)); + setContextAttachments((current) => mergeChatContextAttachments(current, snapshot.contextAttachments)); + setIosElementContextItems((current) => mergeComposerItemsById(current, snapshot.iosContextItems)); + setAppControlContextItems((current) => mergeComposerItemsById(current, snapshot.appControlContextItems)); + setBuiltInBrowserContextItems((current) => mergeComposerItemsById(current, snapshot.builtInBrowserContextItems)); + setMacosVmContextItems((current) => mergeComposerItemsById(current, snapshot.macosVmContextItems)); + if (snapshot.modelId) { + draftLaunchConfigTouchedKeyRef.current = draftLaunchConfigScopeKey; + draftLaunchConfigHydratedRef.current = `${draftLaunchConfigScopeKey}:restored-launch`; + applyLaunchConfigToComposer({ + version: 1, + modelId: snapshot.modelId, + reasoningEffort: snapshot.reasoningEffort, + codexFastMode: snapshot.codexFastMode, + executionMode: snapshot.executionMode, + controls: snapshot.nativeControls, + updatedAt: new Date().toISOString(), + }); + } + }, [applyLaunchConfigToComposer, companionStateKey, draftLaunchConfigScopeKey]); + + const patchDraftLaunchJob = useCallback((jobId: string, patch: Partial) => { + setDraftLaunchJobs((current) => current.map((job) => ( + job.id === jobId ? { ...job, ...patch } : job + ))); + }, []); + + const dismissDraftLaunchJob = useCallback((jobId: string) => { + setDraftLaunchJobs((current) => current.filter((job) => job.id !== jobId)); }, []); - const openLaunchedDraftSession = useCallback((launch: BackgroundLaunchNotice) => { - setBackgroundLaunchNotice(null); + const openLaunchedDraftSession = useCallback((launch: BackgroundLaunchNotice & { jobId?: string }) => { + if (launch.jobId) { + setDraftLaunchJobs((current) => current.filter((job) => job.id !== launch.jobId)); + } if (projectRoot) { setWorkViewState(projectRoot, (prev) => ({ ...prev, @@ -5160,7 +5653,7 @@ export function AgentChatPane({ const laneName = await window.ade.agentChat.suggestLaneName({ laneId: primaryLane.id, prompt: buildDraftLaunchNamingSeed(snapshot), - modelId, + modelId: snapshot.modelId, fallbackName: createTemporaryAutoLaneName(), }); const createdLane = await window.ade.lanes.create({ name: laneName, parentLaneId: primaryLane.id }); @@ -5183,17 +5676,21 @@ export function AgentChatPane({ worktreePath: launchLane?.worktreePath ?? projectRoot ?? null, autoCreated: false, }; - }, [availableLanes, draftLaunchTargetIsAutoCreate, laneDisplayLabel, laneId, lanes, modelId, projectRoot, refreshLanesStore]); + }, [availableLanes, draftLaunchTargetIsAutoCreate, laneDisplayLabel, laneId, lanes, projectRoot, refreshLanesStore]); const clearDraftLaunchComposer = useCallback((snapshot: DraftLaunchSnapshot) => { - setDraft((current) => (current === snapshot.draft ? "" : current)); + setDraft((current) => { + if (current !== snapshot.draft) return current; + draftsPerSessionRef.current.set(companionStateKey, ""); + return ""; + }); setAttachments([]); setContextAttachments([]); setIosElementContextItems([]); setAppControlContextItems([]); setBuiltInBrowserContextItems([]); setMacosVmContextItems([]); - }, []); + }, [companionStateKey]); const cleanupDraftChatSession = useCallback(async ( session: AgentChatSession, @@ -5218,7 +5715,7 @@ export function AgentChatPane({ ): Promise => { let createdSession: AgentChatSession | null = null; try { - createdSession = await createSessionForLane(targetLane.laneId, { select: false }); + createdSession = await createSessionForLane(targetLane.laneId, { select: false, launchState: prepared }); touchSession(createdSession.id); await window.ade.agentChat.send({ sessionId: createdSession.id, @@ -5226,9 +5723,9 @@ export function AgentChatPane({ displayText: prepared.finalDisplayText || "Selected visual app context", attachments: prepared.selectedAttachments, contextAttachments: prepared.selectedContextAttachments, - reasoningEffort, - executionMode, - interactionMode: createdSession.provider === "claude" ? interactionMode : null, + reasoningEffort: prepared.reasoningEffort, + executionMode: prepared.executionMode, + interactionMode: createdSession.provider === "claude" ? prepared.interactionMode : null, ...(createdSession.provider === "cursor" ? { runtime: "local" as const } : {}), }); notifySessionCreated(createdSession, { @@ -5248,10 +5745,7 @@ export function AgentChatPane({ }, [ cleanupDraftChatSession, createSessionForLane, - executionMode, - interactionMode, notifySessionCreated, - reasoningEffort, touchSession, ]); @@ -5261,16 +5755,15 @@ export function AgentChatPane({ mode: DraftLaunchMode, ): Promise => { if (!onLaunchCliSession) throw new Error("CLI sessions are not available from this surface."); - if (!modelId) throw new Error("Select a model before launching a CLI session."); - const desc = resolveModelDescriptorWithRuntimeCatalog(modelId) ?? getModelById(modelId); + if (!prepared.modelId) throw new Error("Select a model before launching a CLI session."); + const desc = resolveModelDescriptorWithRuntimeCatalog(prepared.modelId) ?? getModelById(prepared.modelId); if (!desc) throw new Error("Select a model before launching a CLI session."); if (desc.family === "cursor" && desc.cursorAvailability?.cli === false) { throw new Error("This Cursor model is available for chat only. Choose a Cursor CLI model for a CLI session."); } const provider = desc.family === "cursor" ? "cursor" : resolveCliProviderForModel(desc) ?? "opencode"; const runtimeModel = getRuntimeModelRefForDescriptor(desc, provider); - const launchNativeControls = nativeControlsRef.current; - const permissionMode = cliPermissionModeFromNativeControls(provider, launchNativeControls); + const permissionMode = cliPermissionModeFromNativeControls(provider, prepared.nativeControls); const cliPrompt = buildWorkCliInitialPrompt({ text: prepared.finalText, attachments: prepared.selectedAttachments, @@ -5283,7 +5776,7 @@ export function AgentChatPane({ permissionMode, ...(cliSessionId ? { sessionId: cliSessionId } : {}), model: runtimeModel, - reasoningEffort, + reasoningEffort: prepared.reasoningEffort, initialPrompt: cliPrompt, laneWorktreePath: targetLane.worktreePath ?? projectRoot, }); @@ -5313,17 +5806,12 @@ export function AgentChatPane({ draftKind: "cli", }; }, [ - modelId, onLaunchCliSession, projectRoot, - reasoningEffort, ]); const launchDraftSession = useCallback(async (kind: DraftLaunchKind, mode: DraftLaunchMode) => { - if (submitInFlightRef.current || busy || backgroundLaunchBusy || parallelLaunchBusy) { - if (submitInFlightRef.current) { - setError("Still sending the previous message. Wait a moment and try again."); - } + if (parallelLaunchBusy || projectTransitionBlocksChat) { return; } if (kind === "chat" && (selectedSessionId || (workDraftKind !== "chat" && workDraftKind !== "chat-orchestrator"))) return; @@ -5340,20 +5828,51 @@ export function AgentChatPane({ return; } - submitInFlightRef.current = true; - if (mode === "background") setBackgroundLaunchBusy(true); - else setBusy(true); + const jobId = createDraftLaunchJobId(); + if (mode === "foreground") { + latestForegroundDraftLaunchJobIdRef.current = jobId; + } + const job: DraftLaunchJob = { + id: jobId, + mode, + draftKind: kind, + status: draftLaunchTargetIsAutoCreate ? "creating-lane" : "starting-session", + title: buildDraftLaunchJobTitle(kind, snapshot), + laneId: null, + laneName: null, + sessionId: null, + error: null, + autoOpen: mode === "foreground", + createdAtMs: Date.now(), + snapshot, + }; setPromptSuggestion(null); setError(null); - setBackgroundLaunchNotice(null); - draftSelectionLockedRef.current = mode === "background"; + setDraftLaunchJobs((current) => [ + job, + ...current.map((entry) => ( + mode === "foreground" && entry.mode === "foreground" + ? { ...entry, autoOpen: false } + : entry + )), + ].slice(0, 8)); + clearDraftLaunchComposer(snapshot); let targetLane: DraftLaunchLaneTarget | null = null; try { targetLane = await resolveDraftLaunchLane(snapshot); + patchDraftLaunchJob(jobId, { + status: "starting-session", + laneId: targetLane.laneId, + laneName: targetLane.laneName, + }); const prepared = await prepareDraftLaunchForSend(snapshot, targetLane.laneId); - clearDraftLaunchComposer(snapshot); + patchDraftLaunchJob(jobId, { + status: "sending-prompt", + laneId: targetLane.laneId, + laneName: targetLane.laneName, + }); const launched = kind === "chat" ? await startDraftChatLaunch(prepared, targetLane) : await startDraftCliLaunch(prepared, targetLane, mode); @@ -5367,11 +5886,19 @@ export function AgentChatPane({ sessionId: launched.sessionId, draftKind: launched.draftKind, }; - if (mode === "foreground" && launched.draftKind === "chat") { - openLaunchedDraftSession(launch); + const shouldAutoOpen = mode === "foreground" && latestForegroundDraftLaunchJobIdRef.current === jobId; + patchDraftLaunchJob(jobId, { + status: "ready", + laneId: launch.laneId, + laneName: launch.laneName, + sessionId: launch.sessionId, + draftKind: launch.draftKind, + autoOpen: false, + }); + if (shouldAutoOpen) { + openLaunchedDraftSession({ ...launch, jobId }); } else if (mode === "background") { setSelectedSessionId(null); - setBackgroundLaunchNotice(launch); } } catch (launchError) { if (targetLane?.autoCreated) { @@ -5380,35 +5907,32 @@ export function AgentChatPane({ }); await refreshLanesStore().catch(() => undefined); } - restoreDraftLaunchSnapshot(snapshot); const message = launchError instanceof Error ? launchError.message : String(launchError); + patchDraftLaunchJob(jobId, { + status: "failed", + laneId: targetLane?.laneId ?? null, + laneName: targetLane?.laneName ?? null, + error: message, + autoOpen: false, + }); setError(message); - } finally { - submitInFlightRef.current = false; - setBusy(false); - setBackgroundLaunchBusy(false); } }, [ - backgroundLaunchBusy, buildDraftLaunchSnapshotForCurrentState, - busy, clearDraftLaunchComposer, - constrainedModelSelectionError, - createSessionForLane, draftLaunchTargetIsAutoCreate, - executionMode, - interactionMode, isWorkCliLaunchDraft, laneId, modelId, onLaunchCliSession, openLaunchedDraftSession, + patchDraftLaunchJob, parallelLaunchBusy, prepareDraftLaunchForSend, + projectTransitionBlocksChat, refreshLanesStore, refreshSessions, resolveDraftLaunchLane, - restoreDraftLaunchSnapshot, selectedSessionId, startDraftChatLaunch, startDraftCliLaunch, @@ -7499,7 +8023,7 @@ export function AgentChatPane({ } void launchDraftChat("background"); } : undefined} - backgroundLaunchBusy={backgroundLaunchBusy} + backgroundLaunchBusy={false} backgroundLaunchLabel={draftLaunchTargetIsAutoCreate ? "Auto-create" : "Background"} onInterrupt={() => { void interrupt(); @@ -7735,30 +8259,75 @@ export function AgentChatPane({ style={chatAppearanceRootStyle} className={cn(compactShell ? "min-w-0 w-full" : undefined, "space-y-2")} > - {backgroundLaunchNotice ? ( -
- - Launched in {backgroundLaunchNotice.laneName} - -
- - + {draftLaunchJobs.map((job) => { + const canOpen = job.status === "ready" && job.laneId && job.laneName && job.sessionId; + const isFailed = job.status === "failed"; + const isReady = job.status === "ready"; + return ( +
+
+ {!isReady && !isFailed ? ( + + ) : null} +
+
{job.title}
+
{draftLaunchJobMessage(job)}
+
+
+
+ {isFailed ? ( + + ) : null} + {canOpen ? ( + + ) : null} + +
-
- ) : null} + ); + })} {composerElement}
); diff --git a/apps/desktop/src/renderer/components/lanes/useLaneWorkSessions.test.ts b/apps/desktop/src/renderer/components/lanes/useLaneWorkSessions.test.ts index dac6304cd..9dcef5b40 100644 --- a/apps/desktop/src/renderer/components/lanes/useLaneWorkSessions.test.ts +++ b/apps/desktop/src/renderer/components/lanes/useLaneWorkSessions.test.ts @@ -200,9 +200,9 @@ describe("useLaneWorkSessions — refresh-before-focus ordering", () => { }); // ----------------------------------------------------------------------- - // launchPtySession: refresh() must complete before focusSession / openSessionTab + // launchPtySession: focus/open immediately; refresh reconciles in background. // ----------------------------------------------------------------------- - it("launchPtySession: awaits refresh() before calling focusSession and openSessionTab", async () => { + it("launchPtySession: opens the optimistic terminal before the forced refresh completes", async () => { const callOrder: string[] = []; const { result } = renderHook(() => useLaneWorkSessions("lane-1")); @@ -238,33 +238,31 @@ describe("useLaneWorkSessions — refresh-before-focus ordering", () => { } }); - // Act: start launchPtySession - let launchPromise!: Promise; - act(() => { - launchPromise = result.current.launchPtySession({ + await act(async () => { + await result.current.launchPtySession({ laneId: "lane-1", profile: "claude", }); }); - // Give the async function a tick to reach the refresh await await act(async () => { - await new Promise((r) => setTimeout(r, 10)); + await Promise.resolve(); }); - // refresh-start should be recorded but NOT focusSession yet + // The tab opens while the reconcile refresh is still pending. expect(callOrder).toContain("refresh-start"); - expect(callOrder).not.toContain("focusSession"); - expect(callOrder).not.toContain("openSessionTab"); + expect(callOrder).toContain("focusSession"); + expect(callOrder).toContain("openSessionTab"); + expect(callOrder).not.toContain("refresh-done"); // Resolve the refresh promise await act(async () => { expect(refreshResolve).not.toBeNull(); refreshResolve!(); - await launchPromise; + await Promise.resolve(); }); - // Verify ordering: refresh-done BEFORE focusSession and openSessionTab + // Verify ordering: focus/open happen before refresh completes. const refreshDoneIdx = callOrder.indexOf("refresh-done"); const focusIdx = callOrder.indexOf("focusSession"); const openTabIdx = callOrder.indexOf("openSessionTab"); @@ -272,11 +270,11 @@ describe("useLaneWorkSessions — refresh-before-focus ordering", () => { expect(refreshDoneIdx).toBeGreaterThanOrEqual(0); expect(focusIdx).toBeGreaterThanOrEqual(0); expect(openTabIdx).toBeGreaterThanOrEqual(0); - expect(refreshDoneIdx).toBeLessThan(focusIdx); - expect(refreshDoneIdx).toBeLessThan(openTabIdx); + expect(focusIdx).toBeLessThan(refreshDoneIdx); + expect(openTabIdx).toBeLessThan(refreshDoneIdx); }); - it("launchPtySession: waits for the queued force refresh when another refresh is already running", async () => { + it("launchPtySession: opens immediately when another refresh is already running", async () => { const callOrder: string[] = []; let refreshCallCount = 0; let initialRefreshResolve: (() => void) | null = null; @@ -319,9 +317,8 @@ describe("useLaneWorkSessions — refresh-before-focus ordering", () => { expect(callOrder).toContain("initial-refresh-start"); - let launchPromise!: Promise; - act(() => { - launchPromise = result.current.launchPtySession({ + await act(async () => { + await result.current.launchPtySession({ laneId: "lane-1", profile: "shell", }); @@ -331,8 +328,9 @@ describe("useLaneWorkSessions — refresh-before-focus ordering", () => { await new Promise((r) => setTimeout(r, 0)); }); - expect(callOrder).not.toContain("focusSession"); - expect(callOrder).not.toContain("openSessionTab"); + expect(callOrder).toContain("focusSession"); + expect(callOrder).toContain("openSessionTab"); + expect(callOrder).not.toContain("initial-refresh-done"); await act(async () => { expect(initialRefreshResolve).not.toBeNull(); @@ -341,13 +339,11 @@ describe("useLaneWorkSessions — refresh-before-focus ordering", () => { }); expect(callOrder).toContain("queued-refresh-start"); - expect(callOrder).not.toContain("focusSession"); - expect(callOrder).not.toContain("openSessionTab"); await act(async () => { expect(queuedRefreshResolve).not.toBeNull(); queuedRefreshResolve!(); - await launchPromise; + await Promise.resolve(); }); const queuedDoneIdx = callOrder.indexOf("queued-refresh-done"); @@ -357,8 +353,8 @@ describe("useLaneWorkSessions — refresh-before-focus ordering", () => { expect(queuedDoneIdx).toBeGreaterThanOrEqual(0); expect(focusIdx).toBeGreaterThanOrEqual(0); expect(openTabIdx).toBeGreaterThanOrEqual(0); - expect(queuedDoneIdx).toBeLessThan(focusIdx); - expect(queuedDoneIdx).toBeLessThan(openTabIdx); + expect(focusIdx).toBeLessThan(queuedDoneIdx); + expect(openTabIdx).toBeLessThan(queuedDoneIdx); }); it("replays a queued refresh against the latest lane after switching lanes mid-refresh", async () => { @@ -652,6 +648,7 @@ describe("useLaneWorkSessions — refresh-before-focus ordering", () => { initialInputDelayMs: 750, }), ); + expect((window as any).ade.pty.create.mock.calls.at(-1)?.[0]).not.toHaveProperty("awaitInitialInput"); }); it("continues an ended agent CLI session from lane work panes", async () => { diff --git a/apps/desktop/src/renderer/components/lanes/useLaneWorkSessions.ts b/apps/desktop/src/renderer/components/lanes/useLaneWorkSessions.ts index 99932537e..216053fc6 100644 --- a/apps/desktop/src/renderer/components/lanes/useLaneWorkSessions.ts +++ b/apps/desktop/src/renderer/components/lanes/useLaneWorkSessions.ts @@ -530,24 +530,47 @@ export function useLaneWorkSessions(laneId: string | null) { ...(launchFields.initialInput !== undefined ? { initialInput: launchFields.initialInput } : {}), ...(launchFields.initialInputDelayMs !== undefined ? { initialInputDelayMs: launchFields.initialInputDelayMs } : {}), ...launchFields, - ...(launchFields.initialInput !== undefined ? { awaitInitialInput: true } : {}), }); + const startedAt = new Date().toISOString(); + const optimisticSession: TerminalSessionSummary = { + id: result.sessionId, + laneId: args.laneId, + laneName: currentLane?.name ?? lanes.find((lane) => lane.id === args.laneId)?.name ?? args.laneId, + ptyId: result.ptyId, + tracked: args.tracked ?? true, + pinned: false, + manuallyNamed: false, + goal: null, + toolType: LAUNCH_PROFILE_TOOL_TYPE[args.profile], + title: args.title ?? LAUNCH_PROFILE_TITLE[args.profile], + status: "running", + startedAt, + endedAt: null, + archivedAt: null, + exitCode: null, + transcriptPath: "", + headShaStart: null, + headShaEnd: null, + lastOutputPreview: null, + summary: null, + runtimeState: "running", + resumeCommand: null, + resumeMetadata: null, + chatSessionId: null, + }; + upsertSessionSnapshot(optimisticSession); // Invalidate all cache entries so other views (e.g. Work tab) pick up // the new session on their next refresh. invalidateSessionListCache(); - // Refresh the session list *before* activating the tab so the new - // session exists in sessionsById when the UI resolves activeSession. - // Without this, activeItemId points to an unknown ID and the view - // falls back to the most recent session for several seconds. - await refresh({ showLoading: false, force: true }); if (args.disposition !== "background") { selectLane(args.laneId); focusSession(result.sessionId); openSessionTab(result.sessionId); } + void refresh({ showLoading: false, force: true }).catch(() => {}); return result; }, - [focusSession, openSessionTab, refresh, selectLane], + [currentLane?.name, focusSession, lanes, openSessionTab, refresh, selectLane, upsertSessionSnapshot], ); const handleOpenChatSession = useCallback((session: AgentChatSession) => { diff --git a/apps/desktop/src/renderer/components/terminals/useWorkSessions.test.ts b/apps/desktop/src/renderer/components/terminals/useWorkSessions.test.ts index 93b4d716b..1d1ceb085 100644 --- a/apps/desktop/src/renderer/components/terminals/useWorkSessions.test.ts +++ b/apps/desktop/src/renderer/components/terminals/useWorkSessions.test.ts @@ -249,6 +249,8 @@ describe("useWorkSessions — refresh-before-focus ordering", () => { await result.current.launchPtySession({ laneId: "lane-1", profile: "claude", + initialInput: "queued prompt", + initialInputDelayMs: 750, }); }); @@ -260,6 +262,13 @@ describe("useWorkSessions — refresh-before-focus ordering", () => { expect(callOrder).toContain("openSessionTab"); expect(callOrder).toContain("refresh-start"); expect(callOrder).not.toContain("refresh-done"); + expect((window as any).ade.pty.create).toHaveBeenCalledWith( + expect.objectContaining({ + initialInput: "queued prompt", + initialInputDelayMs: 750, + }), + ); + expect((window as any).ade.pty.create.mock.calls.at(-1)?.[0]).not.toHaveProperty("awaitInitialInput"); // Resolve the refresh promise await act(async () => { diff --git a/apps/desktop/src/renderer/components/terminals/useWorkSessions.ts b/apps/desktop/src/renderer/components/terminals/useWorkSessions.ts index 3608e0189..490620443 100644 --- a/apps/desktop/src/renderer/components/terminals/useWorkSessions.ts +++ b/apps/desktop/src/renderer/components/terminals/useWorkSessions.ts @@ -1317,7 +1317,6 @@ export function useWorkSessions({ active = true }: UseWorkSessionsOptions = {}) ...(launchFields.initialInput !== undefined ? { initialInput: launchFields.initialInput } : {}), ...(launchFields.initialInputDelayMs !== undefined ? { initialInputDelayMs: launchFields.initialInputDelayMs } : {}), ...launchFields, - ...(launchFields.initialInput !== undefined ? { awaitInitialInput: true } : {}), }); const startedAt = new Date().toISOString(); const optimisticSession: TerminalSessionSummary = { diff --git a/docs/features/chat/README.md b/docs/features/chat/README.md index 21145e1ce..ed595d742 100644 --- a/docs/features/chat/README.md +++ b/docs/features/chat/README.md @@ -44,7 +44,7 @@ machinery layered on top. | `apps/desktop/src/shared/chatTranscript.ts` | Pure JSON-lines parser for `AgentChatEventEnvelope` values. Used by both the main process and the renderer. | | `apps/desktop/src/shared/chatSubagents.ts` | Cross-target subagent helpers: `buildSubagentPaneRows`, `selectedSubagentSnapshot`, `subagentIndexForPaneLine`, `subagentPaneSelectableLineOffsets`, `buildSubagentTranscriptEvents`, `isLifecycleEventForSnapshot`, plus the `latestPlan` derivation. Both the desktop `ChatSubagentsPanel` and the `apps/ade-cli/src/tuiClient/subagentPane.ts` / `chatInfo.ts` modules re-export from here so the desktop pane and the terminal TUI render the same roster, transcript filter, and plan summary. | | `apps/desktop/src/shared/types/chat.ts` | All chat types: `AgentChatSession`, `AgentChatEvent` union, `AgentChatEventHistorySnapshot` (with optional `sessionFound` for stale-session detection), permission modes, pending input, completion reports, `PARALLEL_CHAT_MAX_ATTACHMENTS`, and parallel launch state DTOs. | -| `apps/desktop/src/renderer/components/chat/AgentChatPane.tsx` | Top-level renderer surface: state derivation, IPC wiring, composer mount, message-list mount, End/Delete chat controls in the header, parallel multi-model lane launch orchestration, transient-lane cleanup, and multi-lane deep-link navigation. Mounts `AgentQuestionModal` when the active pending input is a question/structured-question. Resolves the surface accent colour through `providerChatAccent(provider)` so Claude/Codex/Cursor stay visually consistent regardless of model variant. Visible Work grid tiles flush user/lifecycle/live events immediately and poll-recover active transcripts when IPC misses an event, even when the tile is not focused. Event-history snapshots with `sessionFound: false` clear stale locked-pane state instead of rendering a dead transcript. Draft chats scope their last-launch config by project/lane/surface/draft-kind and mark local model/reasoning/permission edits as touched so late lane-session hydration cannot overwrite the user's draft selection; composer text is also keyed by the real session id or the lane draft key (`draft:`) so switching draft lanes does not leak text through a shared null session key. During project transitions the pane blocks send/model/permission mutations and shows a "Project is switching..." composer placeholder so chat calls do not hit the wrong runtime binding. On macOS, polls `ade.iosSimulator.getStatus` and renders the iOS Simulator drawer toggle in the header when the platform is supported (see [iOS Simulator feature](../ios-simulator/README.md)); selecting elements inside the drawer flows back through the pane as `IosElementContextItem` chips on the composer. Polls `ade.appControl.getStatus` and exposes the App Control drawer toggle when the platform is supported, mounting `ChatAppControlPanel`; selections become `AppControlContextItem` chips + attachments on the composer. See [App Control](../computer-use/app-control.md). When mounted as a Work tile (`SessionSurface` passes `hideLaneToolDrawers={true}`) the iOS, App Control, and chat terminal drawer toggles are suppressed because the Work right-edge sidebar owns those lane-scoped drawers; hidden lane-tool mode also skips App Control status polling and terminal listing. The pane still listens on `ade:agent-chat:add-attachment` / `add-ios-context` / `add-app-control-context` / `add-builtin-browser-context` / `insert-draft` window events so selections from the sidebar flow into the active chat composer; event handlers match on either `sessionId` (for active sessions) or `draftTargetId` (for unsaved draft composers when `draftContextTargetId` is set), enabling the Work sidebar to insert context into a draft composer before a chat session exists. Work-tab CLI launches pass the active lane worktree into the shared launcher so the spawned CLI sees lane-aware Agent Skill roots. Work CLI launches intentionally skip the direct-argv path: the pane drops `command` / `args` from the `onLaunchPtySession` payload and always sends `startupCommand` plus `workCliStartupDelayMs = 180` so the spawned shell can finish drawing its prompt before the CLI invocation is typed in (see [pty-and-processes.md](../terminals-and-sessions/pty-and-processes.md#create-flow-createargs) for how `ptyService.create` consumes the delay). The `onLaunchCliSession` prop is typed as `(args: WorkPtyLaunchArgs) => Promise` and passes `disposition` matching the draft launch mode so background CLI launches do not steal focus. Internal draft launch state is structured through `DraftLaunchMode`, `DraftLaunchKind`, `DraftLaunchLaneTarget`, and `StartedDraftLaunch`; `clearDraftLaunchComposer` resets the draft, attachments, and context items after a successful launch. `BackgroundLaunchNotice` carries `draftKind` so the dismissible notice's "Open" action restores the correct Work draft kind (chat vs. CLI). Proof remains chat-scoped and stays on the chat header. | +| `apps/desktop/src/renderer/components/chat/AgentChatPane.tsx` | Top-level renderer surface: state derivation, IPC wiring, composer mount, message-list mount, End/Delete chat controls in the header, parallel multi-model lane launch orchestration, transient-lane cleanup, and multi-lane deep-link navigation. Mounts `AgentQuestionModal` when the active pending input is a question/structured-question. Resolves the surface accent colour through `providerChatAccent(provider)` so Claude/Codex/Cursor stay visually consistent regardless of model variant. Visible Work grid tiles flush user/lifecycle/live events immediately and poll-recover active transcripts when IPC misses an event, even when the tile is not focused. Event-history snapshots with `sessionFound: false` clear stale locked-pane state instead of rendering a dead transcript. Draft chats scope their last-launch config by project/lane/surface/draft-kind and mark local model/reasoning/permission edits as touched so late lane-session hydration cannot overwrite the user's draft selection; composer text is also keyed by the real session id or the lane draft key (`draft:`) so switching draft lanes does not leak text through a shared null session key. During project transitions the pane blocks send/model/permission mutations and shows a "Project is switching..." composer placeholder so chat calls do not hit the wrong runtime binding. On macOS, polls `ade.iosSimulator.getStatus` and renders the iOS Simulator drawer toggle in the header when the platform is supported (see [iOS Simulator feature](../ios-simulator/README.md)); selecting elements inside the drawer flows back through the pane as `IosElementContextItem` chips on the composer. Polls `ade.appControl.getStatus` and exposes the App Control drawer toggle when the platform is supported, mounting `ChatAppControlPanel`; selections become `AppControlContextItem` chips + attachments on the composer. See [App Control](../computer-use/app-control.md). When mounted as a Work tile (`SessionSurface` passes `hideLaneToolDrawers={true}`) the iOS, App Control, and chat terminal drawer toggles are suppressed because the Work right-edge sidebar owns those lane-scoped drawers; hidden lane-tool mode also skips App Control status polling and terminal listing. The pane still listens on `ade:agent-chat:add-attachment` / `add-ios-context` / `add-app-control-context` / `add-builtin-browser-context` / `insert-draft` window events so selections from the sidebar flow into the active chat composer; event handlers match on either `sessionId` (for active sessions) or `draftTargetId` (for unsaved draft composers when `draftContextTargetId` is set), enabling the Work sidebar to insert context into a draft composer before a chat session exists. Work-tab CLI launches pass the active lane worktree into the shared launcher so the spawned CLI sees lane-aware Agent Skill roots. Work CLI launches intentionally skip the direct-argv path: the pane drops `command` / `args` from the `onLaunchPtySession` payload and always sends `startupCommand` plus `workCliStartupDelayMs = 180` so the spawned shell can finish drawing its prompt before the CLI invocation is typed in (see [pty-and-processes.md](../terminals-and-sessions/pty-and-processes.md#create-flow-createargs) for how `ptyService.create` consumes the delay). The `onLaunchCliSession` prop is typed as `(args: WorkPtyLaunchArgs) => Promise` and passes `disposition` matching the draft launch mode so background CLI launches do not steal focus. Internal draft launch state is structured through `DraftLaunchMode`, `DraftLaunchKind`, `DraftLaunchLaneTarget`, `StartedDraftLaunch`, and `DraftLaunchJob`. Each draft launch creates a `DraftLaunchJob` that tracks multi-step progress through a state machine (`creating-lane` -> `starting-session` -> `sending-prompt` -> `ready` | `failed`). The composer is cleared optimistically when the job starts rather than after it finishes, and up to 8 concurrent jobs are tracked; the pane renders a status strip per active job with Open/Restore/Dismiss actions. Failed jobs offer a Restore button that merges the snapshot back into the composer (merging attachments and context items by identity rather than replacing). `clearDraftLaunchComposer` resets the draft, attachments, and context items after a successful launch. `DraftLaunchJob` carries `draftKind` so the dismissible job strip's "Open" action restores the correct Work draft kind (chat vs. CLI). Proof remains chat-scoped and stays on the chat header. | | `apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx` | Virtualized transcript renderer. Coalesces resize / measurement updates and, while sticky-to-bottom is active, follows height changes across multiple animation frames so streamed output and late row measurements do not leave the user above the newest message. Programmatic scroll writes are tracked by target scroll position, not a stale counter, so browser-coalesced scroll events do not swallow the next real user gesture. | | `apps/desktop/src/renderer/components/chat/ChatGitToolbar.tsx` | Git / PR quick-action toolbar above the composer. If the lane already has a linked PR, the PR button opens that PR; otherwise it routes to the PR workspace with a create-PR handoff (`create=1&sourceLaneId=&target=primary`). | | `apps/desktop/src/renderer/lib/visualContextFormatting.ts` | Serializes iOS, App Control, built-in browser, macOS VM, and attachment context into prompt text. Automatic macOS VM capability context is prompt-intent gated (`ADE VM`, `macOS VM`, Lume, isolated macOS GUI, etc.) so ordinary sends do not query or inject VM state. | @@ -154,11 +154,25 @@ render them, but neither one *runs* them. structured around typed envelopes: `DraftLaunchMode` (`"foreground" | "background"`), `DraftLaunchKind` (`"chat" | "cli"`), `DraftLaunchLaneTarget` (resolved lane + worktree + auto-created - flag), and `StartedDraftLaunch` (returned session id + kind). - Foreground auto-create opens the new session in Work. Background - auto-create records the session without stealing focus and shows a - dismissible `BackgroundLaunchNotice` that carries `draftKind` so - the "Open" action can restore the correct Work draft kind. + flag), `StartedDraftLaunch` (returned session id + kind), and + `DraftLaunchJob` (multi-step progress tracker). Each launch creates + a `DraftLaunchJob` with status states `creating-lane` -> + `starting-session` -> `sending-prompt` -> `ready` | `failed`. The + composer is cleared optimistically at job creation rather than after + the async flow completes, so users can begin composing the next + prompt immediately. Up to 8 concurrent jobs are tracked; the pane + renders a status strip per active job with progress messages, an + Open button (ready jobs), a Restore button (failed jobs that merges + the draft snapshot back into the composer), and Dismiss. The + `DraftLaunchSnapshot` now captures the full composer state including + `modelId`, `reasoningEffort`, `codexFastMode`, `executionMode`, + `interactionMode`, and `nativeControls` so that + `createSessionForLane` and `startDraftCliLaunch` use the + snapshot's frozen settings rather than the current composer state. + Foreground auto-create opens the new session in Work only if it is + still the latest foreground job when the async flow finishes + (tracked via `latestForegroundDraftLaunchJobIdRef`). Background + auto-create records the session without stealing focus. `clearDraftLaunchComposer` resets the draft text, attachments, and context items after a successful launch so the composer is ready for the next prompt. CLI draft launches forward the prompt into the PTY @@ -167,6 +181,23 @@ render them, but neither one *runs* them. launch mode. Parallel launch still creates child lanes, starts one chat in each lane, sends the same prompt and attachments to every session, then opens the Lanes view focused on the new lane set. +- **Composer draft persistence.** Draft composer state (text, model, + reasoning effort, attachments, context items, draft launch target) + is persisted to `localStorage` under the + `ade.chat.composerDraft.v1` key family, scoped by + `projectRoot:companionStateKey:surfaceProfile:workDraftKind`. + `ComposerDraftStorageSnapshot` is the on-disk shape; it is + normalized on read through `normalizeStoredComposerDraft` which + validates every field, re-infers attachment types, dedupes context + items, and falls back to defaults for invalid entries. On scope + change (session switch, lane switch) the pane writes the current + composer state and hydrates the destination scope's saved snapshot, + restoring model/reasoning/permission settings for draft chats. + Active session scopes skip model restoration so the session's + server-side config is not overwritten by a stale draft. The + persistence effect uses `composerDraftHydratingRef` to skip the + first write-back after hydration so the freshly restored state + does not immediately re-persist with a new timestamp. - **Built-in browser.** The main process owns a persistent `persist:ade-browser` partition with multiple `WebContentsView` tabs. The Work right-edge sidebar's `browser` tab renders this surface diff --git a/docs/features/chat/composer-and-ui.md b/docs/features/chat/composer-and-ui.md index a40a139a2..f8dbaeace 100644 --- a/docs/features/chat/composer-and-ui.md +++ b/docs/features/chat/composer-and-ui.md @@ -11,7 +11,7 @@ stream plus session metadata. | Path | Role | |---|---| -| `AgentChatPane.tsx` | Top-level pane; IPC wiring, session state, presentation profile resolution, lane navigation, parallel launch orchestration, mounting of sub-panels and composer. Visible Work grid tiles flush user/lifecycle/live events immediately and poll-recover active transcripts so inactive-but-visible tiles stay current. Draft chats preserve user-touched model/reasoning/permission controls across late lane-session hydration, and composer text is keyed by session id or lane draft key so switching draft lanes does not reuse another draft's text. Accepts an optional `draftContextTargetId` prop so the Work sidebar can target an unsaved draft composer for context insertions (attachments, iOS/App Control/browser selections, draft text) even before a chat session exists — window event handlers match on either `sessionId` or `draftTargetId`. When auto-creating a lane the draft resolves the primary lane for the `onLaneChange` callback so the sidebar lane context stays in sync. | +| `AgentChatPane.tsx` | Top-level pane; IPC wiring, session state, presentation profile resolution, lane navigation, parallel launch orchestration, mounting of sub-panels and composer. Visible Work grid tiles flush user/lifecycle/live events immediately and poll-recover active transcripts so inactive-but-visible tiles stay current. Draft chats preserve user-touched model/reasoning/permission controls across late lane-session hydration, and composer text is keyed by session id or lane draft key so switching draft lanes does not reuse another draft's text. Accepts an optional `draftContextTargetId` prop so the Work sidebar can target an unsaved draft composer for context insertions (attachments, iOS/App Control/browser selections, draft text) even before a chat session exists; window event handlers match on either `sessionId` or `draftTargetId`. When auto-creating a lane the draft resolves the primary lane for the `onLaneChange` callback so the sidebar lane context stays in sync. Composer draft state (text, model, reasoning, attachments, context items) is persisted to `localStorage` under the `ade.chat.composerDraft.v1` key family and restored on scope change through `ComposerDraftStorageSnapshot`. Draft launches are tracked through `DraftLaunchJob` state machines with multi-step progress (`creating-lane` -> `starting-session` -> `sending-prompt` -> `ready` / `failed`); the composer is cleared optimistically at job start and the `DraftLaunchSnapshot` captures the full control state so the async launch uses frozen settings. | | `AgentChatMessageList.tsx` | Virtualized message list (`@tanstack/react-virtual`). Renders transcript rows and turn dividers, and keeps sticky-bottom sessions pinned across streamed row growth and late virtual-height measurements. Plan-approval rows with non-empty body text render a scrollable markdown block (capped at `360px`) beneath the header so the user can review plan content inline. | | `AgentChatComposer.tsx` | Text input, attachments, model selector, permission controls, slash commands, pending-input answering, and parallel model-slot controls. | | `ChatSurfaceShell.tsx` | Floating chat header, body, footer layout. Backdrop-blur glass-morphism styling. | @@ -209,10 +209,19 @@ and a footer that contains the composer. ask the main process for a lane name before creating a new Work lane. The request includes a temporary `chat-YYYYMMDD-HHMMSS` fallback so prompt-derived fallback names remain unique when model naming is - unavailable. Foreground launches call `onSessionCreated` with - `{ activate: true, source: "draft-launch" }` so Work selects the new - lane/session; background launches pass `activate: false`, keep the - current Work focus, and show a dismissible notice with an Open action. + unavailable. Each launch creates a `DraftLaunchJob` that tracks + progress through `creating-lane` / `starting-session` / + `sending-prompt` / `ready` / `failed` states. The composer is cleared + optimistically when the job starts so the user can begin composing the + next prompt immediately; the `DraftLaunchSnapshot` freezes the model, + reasoning effort, execution mode, and native controls at capture time + so the async create/send flow uses the settings the user had when they + pressed Send. Foreground launches auto-open the result only if the job + is still the latest foreground job (tracked by + `latestForegroundDraftLaunchJobIdRef`); background launches keep the + current Work focus and render a dismissible job strip with an Open + action. Failed jobs offer a Restore button that merges the snapshot + back into the composer. Up to 8 concurrent jobs are tracked. - **Border beam.** On standard (non-grid-tile) layout the composer shell is wrapped in `BorderBeam` (`colorVariant="colorful"` at rest, @@ -523,6 +532,24 @@ These modules are pure and unit-testable: ## Fragile and tricky wiring +- **Draft launch job lifecycle.** `DraftLaunchJob` tracks multi-step + async launches. The composer is cleared immediately when the job + starts, not when it finishes. If the launch fails, the Restore action + merges the snapshot back via `restoreDraftLaunchSnapshot`, which + appends rather than replaces existing draft text and merges context + items by id. `latestForegroundDraftLaunchJobIdRef` prevents stale + foreground jobs from auto-opening when a newer foreground launch + superseded them. The `DraftLaunchSnapshot` captures the full + composer control state (model, reasoning, execution mode, native + controls) so `createSessionForLane` receives a `launchState` that + overrides the live composer state during the async gap. +- **Composer draft persistence.** `ComposerDraftStorageSnapshot` is + persisted to `localStorage` on every draft/model/attachment change + and restored on scope switch. `composerDraftHydratingRef` suppresses + the first write-back after hydration so the restore does not + immediately re-persist with a new timestamp. Normalization + (`normalizeStoredComposerDraft`) validates every field defensively + so corrupt stored data degrades gracefully instead of crashing. - **Session creation and first turn race.** When a new session is created from the composer, the pane awaits the `onSessionCreated` callback and the session-list refresh before sending the first agent diff --git a/docs/features/lanes/README.md b/docs/features/lanes/README.md index e76decfc6..622de95b9 100644 --- a/docs/features/lanes/README.md +++ b/docs/features/lanes/README.md @@ -88,7 +88,7 @@ Renderer components: | `renderer/components/lanes/LaneDiffPane.tsx` | Lane diff list + per-file stage/unstage/discard; file content uses shared `AdeDiffViewer` (commit comparisons read-only; working-tree file can be editable when unstaged) | | `renderer/components/lanes/LaneGitActionsPane.tsx` | Commit, stash, fetch, sync, push, recent commits. Stashing includes untracked files when the unstaged set contains untracked paths, and stash restore uses the ordinal `stash@{N}` ref returned by `git stash list`. After commit/stash operations it refreshes changes, lane git status, and git metadata while skipping snapshot decorations (`refreshLanes({ includeStatus: true, includeSnapshots: false })`). Seeds its `autoRebaseStatus` from the `autoRebaseStatusSnapshot` prop that `LanesPage` passes from the lane list (`laneSnapshot.autoRebaseStatus`), so opening a lane does not trigger a per-lane probe. A fallback `refreshAutoRebaseStatus` runs only when the snapshot is `undefined`, after a 3.5 s delay, and only while the document is visible. | | `renderer/components/lanes/LaneWorkPane.tsx` | Terminal/chat toggle work surface | -| `renderer/components/lanes/useLaneWorkSessions.ts` | Hook behind the lane Work pane's chat/session list. Tracks the latest lane id, project root, and scope key in refs so a refresh that was queued during a lane or project switch replays against the newest target and ignores stale rows from the old scope. `launchPtySession` accepts `WorkPtyLaunchArgs` (including `disposition` and `startupDelayMs`) and returns `WorkPtyLaunchResult`; background disposition skips `selectLane`/`focusSession`/`openSessionTab`. | +| `renderer/components/lanes/useLaneWorkSessions.ts` | Hook behind the lane Work pane's chat/session list. Tracks the latest lane id, project root, and scope key in refs so a refresh that was queued during a lane or project switch replays against the newest target and ignores stale rows from the old scope. `launchPtySession` accepts `WorkPtyLaunchArgs` (including `disposition` and `startupDelayMs`) and returns `WorkPtyLaunchResult`; background disposition skips `selectLane`/`focusSession`/`openSessionTab`. The launcher creates an optimistic `TerminalSessionSummary` snapshot from the `ptyCreate` result and upserts it into the session list immediately, then fires the forced session-list refresh as fire-and-forget so the tab and session card appear without waiting for the IPC round-trip. | | `renderer/components/lanes/LaneRebaseBanner.tsx` | Inline banner driven by `rebaseSuggestionService` | | `renderer/components/lanes/LaneEnvInitProgress.tsx` | Env init step progress inside create dialog | | `renderer/components/lanes/CreateLaneDialog.tsx`, `AttachLaneDialog.tsx`, `MultiAttachWorktreeDialog.tsx`, `LaneDialogShell.tsx` | Lane creation / attach dialogs and shared dialog chrome. `LaneDialogShell` is viewport-centered (`top-1/2 -translate-y-1/2`), capped at `min(92dvh, calc(100vh-1rem))`, and renders a sticky header strip plus a single scrollable body — every lane modal (create, attach, multi-attach, manage) inherits this layout so long content scrolls instead of overflowing the dialog. The "import existing branch" path inside `CreateLaneDialog` swaps the dialog body for `BranchPickerView` when the user opens the picker; the "Connect Linear issue" affordance in the always-open Advanced section swaps it for `LinearIssuePickerView`. The dialog title/description/icon switch in lockstep with the active sub-view, and connecting a Linear issue auto-flips the create mode out of `existing` (the import-branch tab is locked while an issue is attached). | diff --git a/docs/features/terminals-and-sessions/README.md b/docs/features/terminals-and-sessions/README.md index a7cebe2c2..0769b42d2 100644 --- a/docs/features/terminals-and-sessions/README.md +++ b/docs/features/terminals-and-sessions/README.md @@ -295,7 +295,7 @@ Renderer surfaces: and schedules a background refresh on window focus / `visibilitychange` and on chat events, so returning to Work after a tab switch always renders the current session set. Fresh PTY launches - are inserted as short-lived optimistic sessions before the forced + are inserted as optimistic sessions before the forced session-list refresh returns, which keeps the new terminal tab visible even when the runtime cache responds with a stale list. During project switches it hydrates the destination project's cached rows but marks them diff --git a/docs/features/terminals-and-sessions/ui-surfaces.md b/docs/features/terminals-and-sessions/ui-surfaces.md index 6c416949e..35705215d 100644 --- a/docs/features/terminals-and-sessions/ui-surfaces.md +++ b/docs/features/terminals-and-sessions/ui-surfaces.md @@ -660,7 +660,11 @@ for how the PTY service consumes the delay). wraps the same state but scopes to a single lane for the Lanes tab. Its `launchPtySession` also accepts `WorkPtyLaunchArgs` and returns `WorkPtyLaunchResult`, forwarding `startupDelayMs` and respecting -`disposition` the same way. +`disposition` the same way. The lane-scoped launcher builds an +optimistic `TerminalSessionSummary` from the `ptyCreate` result and +upserts it into the session list immediately, then fires the forced +session-list refresh as fire-and-forget so the tab opens without +blocking on the IPC round-trip. ## Session delta hook: `useSessionDelta.ts` From 0aa7b207d555b5843feb21e8feaa5343e957ded9 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 27 May 2026 14:56:00 -0400 Subject: [PATCH 2/4] Fix tool ownership claims and launch feedback --- apps/ade-cli/src/cli.test.ts | 150 ++++++++++++++++ apps/ade-cli/src/cli.ts | 115 ++++++++++-- .../builtInBrowser/desktopBridgeClient.ts | 2 + .../agent-skills/ade-app-control/SKILL.md | 3 +- .../agent-skills/ade-browser/SKILL.md | 3 +- .../agent-skills/ade-ios-simulator/SKILL.md | 4 +- .../main/services/adeActions/registry.test.ts | 2 +- .../src/main/services/adeActions/registry.ts | 6 +- .../appControl/appControlService.test.ts | 19 ++ .../services/appControl/appControlService.ts | 20 +++ .../builtInBrowserService.test.ts | 31 ++++ .../builtInBrowser/builtInBrowserService.ts | 58 +++++- .../builtInBrowser/desktopBridgeServer.ts | 1 + .../services/ios/iosSimulatorService.test.ts | 61 +++++++ .../main/services/ios/iosSimulatorService.ts | 29 ++- .../src/main/services/ipc/registerIpc.ts | 18 +- .../src/main/services/pty/ptyService.test.ts | 20 ++- .../src/main/services/pty/ptyService.ts | 20 ++- apps/desktop/src/renderer/browserMock.ts | 4 + .../chat/AgentChatComposer.test.tsx | 23 +++ .../components/chat/AgentChatComposer.tsx | 31 +++- .../components/chat/AgentChatPane.test.tsx | 170 +++++++++++++++++- .../components/chat/AgentChatPane.tsx | 130 ++++++++++---- .../chat/ChatIosSimulatorPanel.test.tsx | 1 + .../components/chat/ChatIosSimulatorPanel.tsx | 5 +- .../lanes/useLaneWorkSessions.test.ts | 20 +++ .../components/lanes/useLaneWorkSessions.ts | 109 +++++++++-- .../terminals/TerminalView.test.tsx | 84 +++++++++ .../components/terminals/TerminalView.tsx | 26 ++- .../components/terminals/WorkSidebar.test.tsx | 103 +++++++---- .../components/terminals/WorkSidebar.tsx | 55 +++--- apps/desktop/src/shared/types/appControl.ts | 5 + .../src/shared/types/builtInBrowser.ts | 16 +- apps/desktop/src/shared/types/iosSimulator.ts | 6 + 34 files changed, 1189 insertions(+), 161 deletions(-) diff --git a/apps/ade-cli/src/cli.test.ts b/apps/ade-cli/src/cli.test.ts index 3fafa5784..30f2a1340 100644 --- a/apps/ade-cli/src/cli.test.ts +++ b/apps/ade-cli/src/cli.test.ts @@ -2960,6 +2960,48 @@ describe("ADE CLI", () => { }); }); + it("ios-sim launch and claim carry the agent lane claim", () => { + const previousLane = process.env.ADE_LANE_ID; + const previousChat = process.env.ADE_CHAT_SESSION_ID; + try { + process.env.ADE_LANE_ID = "lane-env-1"; + process.env.ADE_CHAT_SESSION_ID = "chat-env-1"; + const launch = buildCliPlan(["ios-sim", "launch", "--target", "app"]); + expect(launch.kind).toBe("execute"); + if (launch.kind !== "execute") return; + expect(launch.steps[0]?.params).toMatchObject({ + arguments: { + domain: "ios_simulator", + action: "launch", + args: { + targetId: "app", + laneId: "lane-env-1", + chatSessionId: "chat-env-1", + }, + }, + }); + + const claim = buildCliPlan(["ios-sim", "claim", "--lane", "lane-explicit"]); + expect(claim.kind).toBe("execute"); + if (claim.kind !== "execute") return; + expect(claim.steps[0]?.params).toMatchObject({ + arguments: { + domain: "ios_simulator", + action: "claim", + args: { + laneId: "lane-explicit", + chatSessionId: "chat-env-1", + }, + }, + }); + } finally { + if (previousLane === undefined) delete process.env.ADE_LANE_ID; + else process.env.ADE_LANE_ID = previousLane; + if (previousChat === undefined) delete process.env.ADE_CHAT_SESSION_ID; + else process.env.ADE_CHAT_SESSION_ID = previousChat; + } + }); + it("ios-sim inspect requires both coordinates and forwards them", () => { expect(() => buildCliPlan(["ios-sim", "inspect"])).toThrow(/--x|--y/); const plan = buildCliPlan([ @@ -3317,6 +3359,63 @@ describe("ADE CLI", () => { }); }); + it("app-control launch, connect, and claim carry the agent lane claim", () => { + const previousLane = process.env.ADE_LANE_ID; + const previousChat = process.env.ADE_CHAT_SESSION_ID; + try { + process.env.ADE_LANE_ID = "lane-env-1"; + process.env.ADE_CHAT_SESSION_ID = "chat-env-1"; + const launch = buildCliPlan(["app-control", "launch", "--command", "npm run dev"]); + expect(launch.kind).toBe("execute"); + if (launch.kind !== "execute") return; + expect(launch.steps[0]?.params).toMatchObject({ + arguments: { + domain: "app_control", + action: "launch", + args: { + command: "npm run dev", + laneId: "lane-env-1", + chatSessionId: "chat-env-1", + }, + }, + }); + + const connect = buildCliPlan(["app-control", "connect", "--cdp-port", "9222"]); + expect(connect.kind).toBe("execute"); + if (connect.kind !== "execute") return; + expect(connect.steps[0]?.params).toMatchObject({ + arguments: { + domain: "app_control", + action: "connect", + args: { + cdpPort: 9222, + laneId: "lane-env-1", + chatSessionId: "chat-env-1", + }, + }, + }); + + const claim = buildCliPlan(["app-control", "claim", "--lane", "lane-explicit"]); + expect(claim.kind).toBe("execute"); + if (claim.kind !== "execute") return; + expect(claim.steps[0]?.params).toMatchObject({ + arguments: { + domain: "app_control", + action: "claim", + args: { + laneId: "lane-explicit", + chatSessionId: "chat-env-1", + }, + }, + }); + } finally { + if (previousLane === undefined) delete process.env.ADE_LANE_ID; + else process.env.ADE_LANE_ID = previousLane; + if (previousChat === undefined) delete process.env.ADE_CHAT_SESSION_ID; + else process.env.ADE_CHAT_SESSION_ID = previousChat; + } + }); + it("macos-vm lifecycle commands map to macos_vm actions", () => { const status = buildCliPlan(["macos-vm", "status", "--lane", "lane-1"]); expect(status.kind).toBe("execute"); @@ -3828,6 +3927,57 @@ describe("ADE CLI", () => { }); }); + it("browser open and claim commands carry the agent lane claim", () => { + const previousLane = process.env.ADE_LANE_ID; + const previousChat = process.env.ADE_CHAT_SESSION_ID; + try { + process.env.ADE_LANE_ID = "lane-env-1"; + process.env.ADE_CHAT_SESSION_ID = "chat-env-1"; + + const open = buildCliPlan(["browser", "open", "localhost:5173"]); + expect(open.kind).toBe("execute"); + if (open.kind !== "execute") return; + expect(open.steps[0]?.params).toMatchObject({ + arguments: { + domain: "built_in_browser", + action: "navigate", + args: { + url: "localhost:5173", + openPanel: true, + laneId: "lane-env-1", + chatSessionId: "chat-env-1", + }, + }, + }); + + const claim = buildCliPlan([ + "browser", + "claim", + "--lane", + "lane-explicit", + "--chat-session", + "chat-explicit", + ]); + expect(claim.kind).toBe("execute"); + if (claim.kind !== "execute") return; + expect(claim.steps[0]?.params).toMatchObject({ + arguments: { + domain: "built_in_browser", + action: "claim", + args: { + laneId: "lane-explicit", + chatSessionId: "chat-explicit", + }, + }, + }); + } finally { + if (previousLane === undefined) delete process.env.ADE_LANE_ID; + else process.env.ADE_LANE_ID = previousLane; + if (previousChat === undefined) delete process.env.ADE_CHAT_SESSION_ID; + else process.env.ADE_CHAT_SESSION_ID = previousChat; + } + }); + it("update commands map to auto-update actions", () => { const status = buildCliPlan(["update", "status"]); expect(status.kind).toBe("execute"); diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index 6df223d27..63041d914 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -1154,12 +1154,16 @@ const HELP_BY_COMMAND: Record = { If another session tries to launch with a different chatSessionId, the call fails with code IOS_SIMULATOR_OWNED_BY_OTHER_SESSION. Run "ios-sim shutdown" (or "shutdown --force") to release before re-launching from a different chat. + The visible Work tools pane is attributed by an explicit lane claim. ADE + reads ADE_LANE_ID/ADE_CHAT_SESSION_ID for agent CLI calls, and you can run + "ios-sim claim --lane " to re-attribute an existing drawer session. Discovery and lifecycle: $ ade ios-sim status --text Show Xcode/idb readiness (getStatus) $ ade ios-sim devices --text List installed/available simulators (listDevices) $ ade ios-sim apps --device --text List launchable apps (listLaunchTargets) $ ade --socket ios-sim launch --target Build/install/launch and update drawer state + $ ade --socket ios-sim claim --lane Attribute the drawer session to a lane $ ade --socket ios-sim launch --bundle-id com.example Launch installed app $ ade --socket ios-sim shutdown Tear down session, streams, helper processes (alias: stop) $ ade --socket ios-sim shutdown --force Force-release a session owned by another chat @@ -1279,6 +1283,7 @@ const HELP_BY_COMMAND: Record = { Discovery and lifecycle: $ ade app-control status --text Show active session and provider readiness + $ ade app-control claim --lane Attribute the active renderer to a lane $ ade app-control launch --command "npm run dev" --text $ ade app-control launch pnpm dev --text Launch via the visible chat terminal $ ade app-control launch --command "pnpm dev" --cwd apps/desktop --text @@ -1314,10 +1319,13 @@ const HELP_BY_COMMAND: Record = { Browser commands control ADE's global built-in browser pane. Use desktop socket mode so CLI calls, chat link clicks, terminal localhost links, and the Work sidebar all share the same browser tabs. The browser is global, not - lane-scoped. + lane-scoped, but its visible Work attribution comes from explicit CLI claims. + ADE reads ADE_LANE_ID/ADE_CHAT_SESSION_ID for agent CLI calls; use + "browser claim --lane " to claim an already-open browser for a lane. Tabs and navigation: $ ade --socket browser status --text Show active tab and tab list + $ ade --socket browser claim --lane Attribute the Browser panel to a lane $ ade --socket browser panel --text Open the Work sidebar Browser panel $ ade --socket browser open https://example.com --text $ ade --socket browser open localhost:5173 --new-tab --text @@ -1348,6 +1356,8 @@ const HELP_BY_COMMAND: Record = { --background Create a new tab without activating it. --no-panel Keep the Work sidebar panel hidden; alias: --hidden. --tab, --tab-id Target tab for switch/close/open. + --lane, --lane-id Claim lane for panel/open/new-tab/switch/claim. + --chat-session Claim chat/session for panel/open/new-tab/switch/claim. `, tests: `${ADE_BANNER} Tests @@ -1787,6 +1797,29 @@ function readLaneId(args: string[]): string | null { return readValue(args, ["--lane", "--lane-id"]) ?? null; } +type ToolClaimArgs = { + laneId?: string; + chatSessionId?: string; +}; + +function readToolClaimArgs(args: string[]): ToolClaimArgs { + const laneId = asString( + readValue(args, ["--lane", "--lane-id"]) ?? process.env.ADE_LANE_ID, + ); + const chatSessionId = asString( + readValue(args, [ + "--chat-session", + "--chat-session-id", + "--session", + "--session-id", + ]) ?? process.env.ADE_CHAT_SESSION_ID, + ); + return { + ...(laneId ? { laneId } : {}), + ...(chatSessionId ? { chatSessionId } : {}), + }; +} + function readPrId(args: string[]): string | null { return readValue(args, ["--pr", "--pr-id"]) ?? null; } @@ -5581,6 +5614,20 @@ function buildIosSimulatorPlan(args: string[]): CliPlan { ), ], }; + if (sub === "claim") { + return { + kind: "execute", + label: "iOS simulator claim", + steps: [ + actionStep( + "result", + "ios_simulator", + "claim", + collectGenericObjectArgs(args, readToolClaimArgs(args)), + ), + ], + }; + } if ( sub === "apps" || sub === "targets" || @@ -5604,6 +5651,7 @@ function buildIosSimulatorPlan(args: string[]): CliPlan { }; } if (sub === "launch" || sub === "open") { + const claimArgs = readToolClaimArgs(args); return { kind: "execute", label: "iOS simulator launch", @@ -5615,15 +5663,13 @@ function buildIosSimulatorPlan(args: string[]): CliPlan { collectGenericObjectArgs(args, { deviceUdid: readValue(args, ["--device", "--udid"]), projectRoot: readValue(args, ["--project-root", "--root"]), - laneId: readValue(args, ["--lane", "--lane-id"]), + laneId: claimArgs.laneId, targetId: readValue(args, ["--target", "--target-id"]), bundleId: readValue(args, ["--bundle-id", "--bundle"]), appBundlePath: readValue(args, ["--app-bundle", "--app"]), projectPath: readValue(args, ["--project", "--xcodeproj"]), scheme: readValue(args, ["--scheme"]), - chatSessionId: - readValue(args, ["--chat-session", "--session"]) ?? - process.env.ADE_CHAT_SESSION_ID, + chatSessionId: claimArgs.chatSessionId, build: !readFlag(args, ["--no-build"]), mode: readValue(args, ["--mode"]) ?? "live", keepSimulatorInBackground: !readFlag(args, ["--foreground"]), @@ -6068,6 +6114,20 @@ function buildAppControlPlan(args: string[]): CliPlan { ], }; } + if (sub === "claim") { + return { + kind: "execute", + label: "App Control claim", + steps: [ + actionStep( + "result", + "app_control", + "claim", + collectGenericObjectArgs(args, readToolClaimArgs(args)), + ), + ], + }; + } if (sub === "terminal") { const mode = firstPositional(args) ?? "read"; if (mode === "read" || mode === "logs" || mode === "tail") { @@ -6127,22 +6187,17 @@ function buildAppControlPlan(args: string[]): CliPlan { ); } if (sub === "launch" || sub === "open" || sub === "start") { + const claimArgs = readToolClaimArgs(args); const trailingCommand = readTrailingCommand(args); const command = readValue(args, ["--command", "--cmd"]) ?? trailingCommand; const appKind = readValue(args, ["--kind", "--app-kind"]) ?? "electron"; const projectRoot = readValue(args, ["--project-root", "--root"]); - const laneId = readValue(args, ["--lane", "--lane-id"]); + const laneId = claimArgs.laneId; const cwd = readValue(args, ["--cwd", "--working-directory"]); const debugPort = readNumberOption(args, ["--debug-port", "--port"]); const cdpPort = readNumberOption(args, ["--cdp-port"]); const label = readValue(args, ["--label", "--name"]); - const chatSessionId = - readValue(args, [ - "--chat-session", - "--chat-session-id", - "--session", - "--session-id", - ]) ?? process.env.ADE_CHAT_SESSION_ID; + const chatSessionId = claimArgs.chatSessionId; const force = readFlag(args, ["--force", "-f"]) ? true : undefined; const positionalCommand = args .filter((arg) => arg !== "--" && !arg.startsWith("-")) @@ -6179,6 +6234,7 @@ function buildAppControlPlan(args: string[]): CliPlan { }; } if (sub === "connect" || sub === "attach") { + const claimArgs = readToolClaimArgs(args); return { kind: "execute", label: "App Control connect", @@ -6190,14 +6246,12 @@ function buildAppControlPlan(args: string[]): CliPlan { collectGenericObjectArgs(args, { appKind: readValue(args, ["--kind", "--app-kind"]) ?? "electron", projectRoot: readValue(args, ["--project-root", "--root"]), - laneId: readValue(args, ["--lane", "--lane-id"]), + laneId: claimArgs.laneId, cdpPort: readNumberOption(args, ["--cdp-port", "--port"]) ?? Number(numericPositionals()[0]), label: readValue(args, ["--label", "--name"]), - chatSessionId: - readValue(args, ["--chat-session", "--session"]) ?? - process.env.ADE_CHAT_SESSION_ID, + chatSessionId: claimArgs.chatSessionId, force: readFlag(args, ["--force", "-f"]) ? true : undefined, }), ), @@ -6964,6 +7018,20 @@ function buildBrowserPlan(args: string[]): CliPlan { ], }; } + if (sub === "claim") { + return { + kind: "execute", + label: "browser claim", + steps: [ + actionStep( + "result", + "built_in_browser", + "claim", + collectGenericObjectArgs(args, readToolClaimArgs(args)), + ), + ], + }; + } if ( sub === "panel" || sub === "show" || @@ -6971,6 +7039,7 @@ function buildBrowserPlan(args: string[]): CliPlan { sub === "reveal" ) { const panelArgs: JsonObject = {}; + Object.assign(panelArgs, readToolClaimArgs(args)); maybePut(panelArgs, "url", readValue(args, ["--url"])); maybePut(panelArgs, "tabId", readValue(args, ["--tab", "--tab-id"])); return { @@ -6996,6 +7065,7 @@ function buildBrowserPlan(args: string[]): CliPlan { ]); const newTab = readFlag(args, ["--new-tab"]); const noPanel = readFlag(args, ["--no-panel", "--hidden"]); + const claimArgs = readToolClaimArgs(args); const genericArgs = collectGenericObjectArgs(args); const genericUrl = typeof genericArgs.url === "string" ? genericArgs.url : null; @@ -7010,6 +7080,7 @@ function buildBrowserPlan(args: string[]): CliPlan { tabId, newTab: newTab && !activeTab ? true : undefined, openPanel: !noPanel, + ...claimArgs, ...genericArgs, }), ], @@ -7019,6 +7090,7 @@ function buildBrowserPlan(args: string[]): CliPlan { const background = readFlag(args, ["--background"]); const noPanel = readFlag(args, ["--no-panel", "--hidden"]); const explicitUrl = readValue(args, ["--url"]); + const claimArgs = readToolClaimArgs(args); const genericArgs = collectGenericObjectArgs(args); const genericUrl = typeof genericArgs.url === "string" ? genericArgs.url : null; @@ -7032,6 +7104,7 @@ function buildBrowserPlan(args: string[]): CliPlan { url, activate: background ? false : undefined, openPanel: !noPanel, + ...claimArgs, ...genericArgs, }), ], @@ -7040,6 +7113,7 @@ function buildBrowserPlan(args: string[]): CliPlan { if (sub === "switch" || sub === "activate") { const noPanel = readFlag(args, ["--no-panel", "--hidden"]); const explicitTabId = readValue(args, ["--tab", "--tab-id"]); + const claimArgs = readToolClaimArgs(args); const genericArgs = collectGenericObjectArgs(args); const genericTabId = typeof genericArgs.tabId === "string" ? genericArgs.tabId : null; @@ -7053,6 +7127,7 @@ function buildBrowserPlan(args: string[]): CliPlan { "tabId", ), openPanel: !noPanel, + ...claimArgs, ...genericArgs, }), ], @@ -11809,8 +11884,10 @@ function formatIosSimStatus(value: unknown): string { : null, ], ["active app", activeSession.bundleId], + ["lane", activeSession.laneId], ["mode", activeSession.mode], ["chat session", activeSession.chatSessionId], + ["claimed", activeSession.claimedAt], ]), "", renderTable( @@ -12206,6 +12283,7 @@ function formatAppControlStatus(value: unknown): string { ["active app", session.label], ["session", session.id], ["status", session.status], + ["lane", session.laneId], ["cdp port", session.cdpPort], ["terminal", session.terminalSessionId], ["pty", session.terminalPtyId], @@ -12243,6 +12321,9 @@ function formatBrowserStatus(value: unknown): string { ["forward", status.canGoForward], ["inspecting", status.isInspecting ?? status.inspecting], ["selection", status.hasSelection], + ["owner lane", status.ownerLaneId], + ["owner chat", status.ownerChatSessionId], + ["owner claimed", status.ownerClaimedAt], ]), "", renderTable( diff --git a/apps/ade-cli/src/services/builtInBrowser/desktopBridgeClient.ts b/apps/ade-cli/src/services/builtInBrowser/desktopBridgeClient.ts index 68e67b898..4bec7e53c 100644 --- a/apps/ade-cli/src/services/builtInBrowser/desktopBridgeClient.ts +++ b/apps/ade-cli/src/services/builtInBrowser/desktopBridgeClient.ts @@ -41,6 +41,7 @@ async function raceWithTimeout( export type BuiltInBrowserDesktopBridgeClient = { getStatus: (args?: unknown) => Promise; + claim: (args?: unknown) => Promise; showPanel: (args?: unknown) => Promise; setBounds: (args: unknown) => Promise; navigate: (args: unknown) => Promise; @@ -144,6 +145,7 @@ export function createBuiltInBrowserDesktopBridgeClient(args: { return { getStatus: (args) => callBridge("getStatus", args), + claim: (args) => callBridge("claim", args), showPanel: (args) => callBridge("showPanel", args), setBounds: (args) => callBridge("setBounds", args), navigate: (args) => callBridge("navigate", args), diff --git a/apps/desktop/resources/agent-skills/ade-app-control/SKILL.md b/apps/desktop/resources/agent-skills/ade-app-control/SKILL.md index c2fb3c595..f154c3c02 100644 --- a/apps/desktop/resources/agent-skills/ade-app-control/SKILL.md +++ b/apps/desktop/resources/agent-skills/ade-app-control/SKILL.md @@ -12,11 +12,13 @@ App Control is a live desktop drawer service. Prefer socket-backed commands: ```bash ade help app-control ade --socket app-control status --text +ade --socket app-control claim --lane --text ade --socket app-control launch --command "npm run dev" --text ade --socket app-control connect --cdp-port --text ``` ADE sets `ADE_APP_CONTROL_CDP_PORT` and `ADE_APP_CONTROL_DEBUG_FLAGS` for launches. Custom Electron launchers should forward one of those values to `--remote-debugging-port`. +ADE-launched agents pass `ADE_LANE_ID` / `ADE_CHAT_SESSION_ID` through `launch`, `connect`, and `claim`; use `claim` when attaching to a renderer that is already running so the Work tools pane attributes it to the agent's lane instead of the visible chat. ## Inspect @@ -70,4 +72,3 @@ ade --socket app-control snapshot --text # forces the drawer to repaint ``` If `targets` shows a `/devtools/page/` entry with the dev URL (`http://localhost:5173/...`), CDP is healthy — the drawer banner is just lagging until the next snapshot. - diff --git a/apps/desktop/resources/agent-skills/ade-browser/SKILL.md b/apps/desktop/resources/agent-skills/ade-browser/SKILL.md index d0005a495..5870b4ba0 100644 --- a/apps/desktop/resources/agent-skills/ade-browser/SKILL.md +++ b/apps/desktop/resources/agent-skills/ade-browser/SKILL.md @@ -8,6 +8,7 @@ description: Use this skill when using ADE's built-in browser pane, shared brows ## Scope The ADE browser is global, not lane-scoped. Use socket mode so CLI calls and the Work sidebar share the same tabs. +The Work tools attribution is claim-based: `ade browser open`, `panel`, `new-tab`, and `switch` carry `ADE_LANE_ID` / `ADE_CHAT_SESSION_ID` automatically when ADE launched the agent. If you attach to an already-open tab, run `ade --socket browser claim --lane --text` first so the sidebar shows the right owner lane. ## How `ade browser` reaches the desktop @@ -24,6 +25,7 @@ Override the bridge socket path with `ADE_DESKTOP_BRIDGE_SOCKET_PATH` for dev la ```bash ade help browser ade --socket browser panel --text +ade --socket browser claim --lane --text ade --socket browser status --text ade --socket browser open --new-tab --text ade --socket browser tabs --text @@ -44,4 +46,3 @@ ade --socket browser clear-selection --text - Open localhost URLs and chat-output links in the ADE browser when the user expects them to show in the Work sidebar. - Because tabs are global, confirm the active tab before taking a screenshot or selecting context. - If there is no active browser panel/session, report the blocker rather than pretending to inspect the page. - diff --git a/apps/desktop/resources/agent-skills/ade-ios-simulator/SKILL.md b/apps/desktop/resources/agent-skills/ade-ios-simulator/SKILL.md index dbd16fac7..2f0203a50 100644 --- a/apps/desktop/resources/agent-skills/ade-ios-simulator/SKILL.md +++ b/apps/desktop/resources/agent-skills/ade-ios-simulator/SKILL.md @@ -11,6 +11,7 @@ Use socket mode so CLI actions and the desktop drawer share one simulator sessio ```bash ade --socket ios-sim status --text +ade --socket ios-sim claim --lane --text ade --socket ios-sim devices --text ade --socket ios-sim apps --text ade help ios-sim launch @@ -22,6 +23,8 @@ Launch with a target from `apps`: ade --socket ios-sim launch --target --text ``` +ADE-launched agents pass `ADE_LANE_ID` / `ADE_CHAT_SESSION_ID` through `launch` and `claim`; use `claim` when taking over an already-running simulator drawer so Work shows the lane that actually owns the visible simulator content. + ## Inspect and interact Capture current screen/context before acting: @@ -70,4 +73,3 @@ Add a preview only when no useful nearby preview already exists. Preview fixture - Do not create symlink projects, fake schemes, or repo-layout shims as the first fix for app detection. Re-run `ade --socket ios-sim apps --text` and report the selected project, scheme, and build output. - If no simulator/session/snapshot exists, report the exact blocker instead of guessing the screen. - When you own the simulator session and the task no longer needs it, run `ade --socket ios-sim shutdown --text`. - diff --git a/apps/desktop/src/main/services/adeActions/registry.test.ts b/apps/desktop/src/main/services/adeActions/registry.test.ts index c5f9ed2b3..390b5946b 100644 --- a/apps/desktop/src/main/services/adeActions/registry.test.ts +++ b/apps/desktop/src/main/services/adeActions/registry.test.ts @@ -232,7 +232,7 @@ describe("ADE_ACTION_ALLOWLIST shape", () => { it("exposes the browser panel and tab control surface", () => { const actions = ADE_ACTION_ALLOWLIST.built_in_browser ?? []; - for (const name of ["showPanel", "navigate", "createTab", "switchTab", "closeTab"]) { + for (const name of ["claim", "showPanel", "navigate", "createTab", "switchTab", "closeTab"]) { expect(actions).toContain(name); } }); diff --git a/apps/desktop/src/main/services/adeActions/registry.ts b/apps/desktop/src/main/services/adeActions/registry.ts index 4f5f3867e..403d733ef 100644 --- a/apps/desktop/src/main/services/adeActions/registry.ts +++ b/apps/desktop/src/main/services/adeActions/registry.ts @@ -655,9 +655,9 @@ export const ADE_ACTION_ALLOWLIST: Partial { expect(service.getStatus().activeSession?.cdpEndpoint).toBe(targetB.webSocketDebuggerUrl); }); + it("can claim an active renderer for a lane without relaunching it", async () => { + const targetA = target("a"); + mockState.httpResponses.push([targetA]); + + const service = createAppControlService({ + projectRoot: "/tmp/project", + logger: createLogger(), + }); + + await service.connect({ cdpPort: 12345, force: true }); + const claimed = service.claim({ laneId: "lane-1", chatSessionId: "chat-1" }); + + expect(claimed.activeSession).toMatchObject({ + laneId: "lane-1", + chatSessionId: "chat-1", + cdpPort: 12345, + }); + }); + it("dispatches clicks without a blocking mouseMoved prelude", async () => { const targetA = target("a"); mockState.httpResponses.push([targetA]); diff --git a/apps/desktop/src/main/services/appControl/appControlService.ts b/apps/desktop/src/main/services/appControl/appControlService.ts index 83b1b32b6..a528b8459 100644 --- a/apps/desktop/src/main/services/appControl/appControlService.ts +++ b/apps/desktop/src/main/services/appControl/appControlService.ts @@ -5,6 +5,7 @@ import net from "node:net"; import path from "node:path"; import { WebSocket, type RawData } from "ws"; import type { + AppControlClaimArgs, AppControlClickArgs, AppControlConnectArgs, AppControlContextItem, @@ -38,6 +39,10 @@ const SOURCE_FILE_EXTENSIONS = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ". const SOURCE_SKIP_DIRS = new Set([".git", ".ade", "node_modules", "dist", "build", "out", "coverage", ".next", ".vite"]); const SOURCE_FILE_CACHE_MAX = 200; +function cleanClaimId(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + type CreateAppControlServiceArgs = { projectRoot: string; logger: Logger; @@ -1431,6 +1436,20 @@ export function createAppControlService(args: CreateAppControlServiceArgs) { }; }; + const claim = (claimArgs: AppControlClaimArgs = {}): AppControlStatus => { + if (!activeSession) return getStatus(); + const laneId = cleanClaimId(claimArgs.laneId); + const chatSessionId = cleanClaimId(claimArgs.chatSessionId); + if (!laneId && !chatSessionId) return getStatus(); + activeSession = { + ...activeSession, + ...(laneId ? { laneId } : {}), + ...(chatSessionId ? { chatSessionId } : {}), + }; + emit({ type: "session-updated", session: activeSession }); + return getStatus(); + }; + const resolveLaunch = (launchArgs: AppControlLaunchArgs, debugPort: number): ResolvedLaunch => { const projectRoot = normalizeProjectRoot(launchArgs.projectRoot, args.projectRoot); const debugFlags = [ @@ -2358,6 +2377,7 @@ export function createAppControlService(args: CreateAppControlServiceArgs) { return { getStatus, + claim, launch, launchInTerminal: launch, connect, diff --git a/apps/desktop/src/main/services/builtInBrowser/builtInBrowserService.test.ts b/apps/desktop/src/main/services/builtInBrowser/builtInBrowserService.test.ts index f3c9974d4..3aba12cc8 100644 --- a/apps/desktop/src/main/services/builtInBrowser/builtInBrowserService.test.ts +++ b/apps/desktop/src/main/services/builtInBrowser/builtInBrowserService.test.ts @@ -473,6 +473,37 @@ describe("createBuiltInBrowserService — bounds and status dedupe", () => { }); }); + it("tracks explicit lane claims instead of inferring Browser ownership from the visible sidebar", async () => { + const service = createBuiltInBrowserService({ onEvent: collector.onEvent }); + + expect(service.getStatus()).toMatchObject({ + ownerLaneId: null, + ownerChatSessionId: null, + ownerClaimedAt: null, + }); + + await service.createTab({ + url: "https://example.test", + activate: true, + openPanel: true, + laneId: "lane-1", + chatSessionId: "chat-1", + }); + + const status = service.getStatus(); + expect(status.ownerLaneId).toBe("lane-1"); + expect(status.ownerChatSessionId).toBe("chat-1"); + expect(status.ownerClaimedAt).toEqual(expect.any(String)); + const openEvent = collector.events.findLast((event) => event.type === "open-request"); + expect(openEvent).toMatchObject({ + type: "open-request", + status: { + ownerLaneId: "lane-1", + ownerChatSessionId: "chat-1", + }, + }); + }); + it("showPanel can navigate to a URL before opening the panel", async () => { const service = createBuiltInBrowserService({ onEvent: collector.onEvent }); diff --git a/apps/desktop/src/main/services/builtInBrowser/builtInBrowserService.ts b/apps/desktop/src/main/services/builtInBrowser/builtInBrowserService.ts index 3b055aaae..b77396510 100644 --- a/apps/desktop/src/main/services/builtInBrowser/builtInBrowserService.ts +++ b/apps/desktop/src/main/services/builtInBrowser/builtInBrowserService.ts @@ -4,6 +4,7 @@ import { randomUUID } from "node:crypto"; import type { BuiltInBrowserBoundsArgs, BuiltInBrowserAttachWebviewArgs, + BuiltInBrowserClaimArgs, BuiltInBrowserContextItem, BuiltInBrowserCreateTabArgs, BuiltInBrowserEventPayload, @@ -109,6 +110,9 @@ export function createBuiltInBrowserService(args: { let handlingInspectNode = false; let browserSessionConfigured = false; let lastEmittedStatusKey: string | null = null; + let ownerLaneId: string | null = null; + let ownerChatSessionId: string | null = null; + let ownerClaimedAt: string | null = null; const configuredWebContents = new WeakSet(); const logger = (): Logger | null => { @@ -411,9 +415,34 @@ export function createBuiltInBrowserService(args: { canGoForward: wc?.canGoForward() ?? false, isInspecting: inspecting, hasSelection: lastSelectedItem !== null, + ownerLaneId, + ownerChatSessionId, + ownerClaimedAt, }; } + const claimOwnerFromInput = (input: BuiltInBrowserClaimArgs = {}): boolean => { + const laneId = stringOrNull(input.laneId); + const chatSessionId = stringOrNull(input.chatSessionId); + let changed = false; + if (laneId && laneId !== ownerLaneId) { + ownerLaneId = laneId; + changed = true; + } + if (chatSessionId && chatSessionId !== ownerChatSessionId) { + ownerChatSessionId = chatSessionId; + changed = true; + } + if (changed) ownerClaimedAt = new Date().toISOString(); + return changed; + }; + + function claim(input: BuiltInBrowserClaimArgs = {}): BuiltInBrowserStatus { + claimOwnerFromInput(input); + emitStatus(); + return getStatus(); + } + const requestOpenPanel = (input: BuiltInBrowserOpenPanelArgs = {}): BuiltInBrowserStatus => { const status = getStatus(); const tabId = stringOrNull(input.tabId) ?? status.activeTabId; @@ -429,13 +458,14 @@ export function createBuiltInBrowserService(args: { }; async function showPanel(input: BuiltInBrowserOpenPanelArgs = {}): Promise { + claimOwnerFromInput(input); const tabId = stringOrNull(input.tabId); const url = stringOrNull(input.url); if (url) { - return navigate({ url, tabId, openPanel: true }); + return navigate({ url, tabId, openPanel: true, laneId: input.laneId, chatSessionId: input.chatSessionId }); } if (tabId) { - return switchTab({ tabId, openPanel: true }); + return switchTab({ tabId, openPanel: true, laneId: input.laneId, chatSessionId: input.chatSessionId }); } return requestOpenPanel(input); } @@ -533,6 +563,7 @@ export function createBuiltInBrowserService(args: { existingTab = tabs.find((entry) => entry.id === input.tabId) ?? null; if (!existingTab) throw new Error(`Browser tab not found: ${input.tabId}`); } + claimOwnerFromInput(input); const switchingTabs = input.newTab || (input.tabId && input.tabId !== activeTabId); await stopInspectQuietly("built_in_browser.navigate_stop_inspect_failed"); if (switchingTabs) { @@ -552,7 +583,7 @@ export function createBuiltInBrowserService(args: { attachViewsToCurrentWindow(); await wc.loadURL(targetUrl); if (input.openPanel) { - requestOpenPanel({ url: targetUrl, tabId: tab.id }); + requestOpenPanel({ url: targetUrl, tabId: tab.id, laneId: input.laneId, chatSessionId: input.chatSessionId }); } emitStatus(); return getStatus(); @@ -564,6 +595,7 @@ export function createBuiltInBrowserService(args: { } // Normalize URL up front so we don't leave an orphan tab on invalid input. const normalizedUrl = input.url ? normalizeBrowserUrl(input.url) : null; + claimOwnerFromInput(input); const willActivate = input.activate !== false || !activeTabId; if (willActivate) { await stopInspectQuietly("built_in_browser.create_tab_stop_inspect_failed"); @@ -577,7 +609,7 @@ export function createBuiltInBrowserService(args: { await tab.webContents.loadURL(normalizedUrl); } if (input.openPanel) { - requestOpenPanel({ url: normalizedUrl, tabId: tab.id }); + requestOpenPanel({ url: normalizedUrl, tabId: tab.id, laneId: input.laneId, chatSessionId: input.chatSessionId }); } emitStatus(); return getStatus(); @@ -588,6 +620,7 @@ export function createBuiltInBrowserService(args: { if (!tabId) throw new Error("Browser tab id is required."); const tab = tabs.find((entry) => entry.id === tabId); if (!tab) throw new Error(`Browser tab not found: ${tabId}`); + claimOwnerFromInput(input); const wasDifferentTab = tab.id !== activeTabId; if (wasDifferentTab) { await stopInspectQuietly("built_in_browser.switch_tab_stop_inspect_failed"); @@ -598,7 +631,7 @@ export function createBuiltInBrowserService(args: { } attachViewsToCurrentWindow(); if (input.openPanel) { - requestOpenPanel({ tabId: tab.id }); + requestOpenPanel({ tabId: tab.id, laneId: input.laneId, chatSessionId: input.chatSessionId }); } emitStatus(); return getStatus(); @@ -633,6 +666,11 @@ export function createBuiltInBrowserService(args: { activeTabId = tabs[Math.max(0, index - 1)]?.id ?? tabs[0]?.id ?? null; clearSelectionInternal(); } + if (!tabs.length) { + ownerLaneId = null; + ownerChatSessionId = null; + ownerClaimedAt = null; + } attachViewsToCurrentWindow(); emitStatus(); return getStatus(); @@ -844,6 +882,9 @@ export function createBuiltInBrowserService(args: { win = null; tabs = []; activeTabId = null; + ownerLaneId = null; + ownerChatSessionId = null; + ownerClaimedAt = null; } const attachDebuggerListeners = (wc: WebContents): void => { @@ -971,7 +1012,11 @@ export function createBuiltInBrowserService(args: { sourceLine: null, frame: metadata.frame, pixelFrame: scaleFrame(metadata.frame, metadata.pixelRatio), - metadata: metadata.metadata, + metadata: { + ...metadata.metadata, + ownerLaneId, + ownerChatSessionId, + }, screenshotDataUrl, selectedAt: new Date().toISOString(), }); @@ -1077,6 +1122,7 @@ export function createBuiltInBrowserService(args: { return { attachToWindow, getStatus, + claim, showPanel, setBounds, attachWebview, diff --git a/apps/desktop/src/main/services/builtInBrowser/desktopBridgeServer.ts b/apps/desktop/src/main/services/builtInBrowser/desktopBridgeServer.ts index 42388c701..57acb3879 100644 --- a/apps/desktop/src/main/services/builtInBrowser/desktopBridgeServer.ts +++ b/apps/desktop/src/main/services/builtInBrowser/desktopBridgeServer.ts @@ -24,6 +24,7 @@ import type { BuiltInBrowserService } from "./builtInBrowserService"; const ALLOWED_METHODS = new Set([ "getStatus", + "claim", "showPanel", "setBounds", "navigate", diff --git a/apps/desktop/src/main/services/ios/iosSimulatorService.test.ts b/apps/desktop/src/main/services/ios/iosSimulatorService.test.ts index bb639a651..1086b4506 100644 --- a/apps/desktop/src/main/services/ios/iosSimulatorService.test.ts +++ b/apps/desktop/src/main/services/ios/iosSimulatorService.test.ts @@ -313,6 +313,7 @@ describe("iosSimulatorService single-owner lock contract", () => { mode: "snapshot" as const, bridgeUrl: null, startedAt: new Date().toISOString(), + claimedAt: null, }; const error = new IosSimulatorOwnedBySessionError(previousSession); expect(error.code).toBe(IOS_SIMULATOR_OWNED_BY_OTHER_SESSION_CODE); @@ -321,6 +322,66 @@ describe("iosSimulatorService single-owner lock contract", () => { expect(error.message).toContain(IOS_SIMULATOR_OWNED_BY_OTHER_SESSION_CODE); expect(error.message).toContain("chat-A"); }); + + it("can claim an active simulator drawer session for a lane without relaunching it", async () => { + const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("darwin"); + const events: IosSimulatorEventPayload[] = []; + const runMock = vi.fn(async (command: string, commandArgs: string[]) => { + if (command === "xcrun" && commandArgs.join(" ") === "simctl list devices available --json") { + return { stdout: simulatorDevicesJson, stderr: "" }; + } + if (command === "xcrun" && commandArgs[1] === "bootstatus") return { stdout: "", stderr: "" }; + if (command === "xcrun" && commandArgs[1] === "listapps") { + return { + stdout: `"com.example.app" = {\n CFBundleDisplayName = "Example";\n};\n`, + stderr: "", + }; + } + if (command === "xcrun" && commandArgs[1] === "launch") return { stdout: "com.example.app: 123\n", stderr: "" }; + return { stdout: "", stderr: "" }; + }); + const restoreHooks = __testSetIosSimulatorProcessHooks({ + run: runMock, + commandExists: () => true, + }); + const service = createIosSimulatorService({ + projectRoot: os.tmpdir(), + logger: noopLogger, + onEvent: (payload) => events.push(payload), + }); + + try { + await service.launch({ + bundleId: "com.example.app", + build: false, + laneId: "lane-old", + chatSessionId: "chat-old", + }); + + const claimed = await service.claim({ laneId: "lane-1", chatSessionId: "chat-1" }); + + expect(claimed.activeSession).toMatchObject({ + laneId: "lane-1", + chatSessionId: "chat-1", + bundleId: "com.example.app", + claimedAt: expect.any(String), + }); + expect(runMock.mock.calls.filter(([command, commandArgs]) => ( + command === "xcrun" && commandArgs[1] === "launch" + ))).toHaveLength(1); + expect(events.findLast((event) => event.type === "session-updated")).toMatchObject({ + session: { + laneId: "lane-1", + chatSessionId: "chat-1", + claimedAt: expect.any(String), + }, + }); + } finally { + service.dispose(); + restoreHooks(); + platformSpy.mockRestore(); + } + }); }); describe("iosSimulatorService shutdown contract", () => { diff --git a/apps/desktop/src/main/services/ios/iosSimulatorService.ts b/apps/desktop/src/main/services/ios/iosSimulatorService.ts index f085544b7..9abe37855 100644 --- a/apps/desktop/src/main/services/ios/iosSimulatorService.ts +++ b/apps/desktop/src/main/services/ios/iosSimulatorService.ts @@ -15,6 +15,7 @@ import type { IosScreenElement, IosScreenSnapshot, IosScreenSnapshotArgs, + IosSimulatorClaimArgs, IosSimulatorOpenPreviewWorkspaceArgs, IosSimulatorListPreviewsArgs, IosSimulatorPreviewCapability, @@ -1522,6 +1523,10 @@ function readString(record: Record, keys: string[]): string | n return null; } +function cleanClaimId(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + function readNumber(record: Record, keys: string[]): number | null { for (const key of keys) { const value = record[key]; @@ -3430,6 +3435,25 @@ export function createIosSimulatorService(args: CreateIosSimulatorServiceArgs) { return activeSession; }; + const claim = async (claimArgs: IosSimulatorClaimArgs = {}): Promise => { + if (!activeSession) return getStatus(); + const laneId = cleanClaimId(claimArgs.laneId); + const chatSessionId = cleanClaimId(claimArgs.chatSessionId); + if (!laneId && !chatSessionId) return getStatus(); + const nextLaneId = laneId || activeSession.laneId; + const nextChatSessionId = chatSessionId || activeSession.chatSessionId; + const changed = nextLaneId !== activeSession.laneId + || nextChatSessionId !== activeSession.chatSessionId; + activeSession = { + ...activeSession, + laneId: nextLaneId, + chatSessionId: nextChatSessionId, + claimedAt: changed ? nowIso() : activeSession.claimedAt, + }; + emit({ type: "session-updated", session: activeSession }); + return getStatus(); + }; + const launch = async (launchArgs: IosSimulatorLaunchArgs = {}): Promise => { if (process.platform !== "darwin") { throw new Error("iOS Simulator control is only available on macOS."); @@ -3529,6 +3553,7 @@ export function createIosSimulatorService(args: CreateIosSimulatorServiceArgs) { const launchEnvironment = normalizeLaunchEnvironment(launchArgs.environment); const launchArguments = normalizeLaunchArguments(launchArgs.arguments); + const startedAt = nowIso(); const session: IosSimulatorSession = { id: randomUUID(), deviceUdid: device.udid, @@ -3543,7 +3568,8 @@ export function createIosSimulatorService(args: CreateIosSimulatorServiceArgs) { mode: normalizeLaunchMode(launchArgs.mode), keepSimulatorInBackground: launchArgs.keepSimulatorInBackground ?? true, bridgeUrl: null, - startedAt: nowIso(), + startedAt, + claimedAt: launchArgs.laneId || launchArgs.chatSessionId ? startedAt : null, }; activeSession = session; const childEnv: NodeJS.ProcessEnv = { @@ -4773,6 +4799,7 @@ export function createIosSimulatorService(args: CreateIosSimulatorServiceArgs) { return { getStatus, + claim, listDevices, listLaunchTargets, launch, diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index 68ad35b46..999c0c02c 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -50,6 +50,7 @@ import type { AutomationSimulateRequest, AutomationSimulateResult, ReviewLaunchContext, + BuiltInBrowserClaimArgs, AppControlClickArgs, AppControlConnectArgs, AppControlCoordinateSpace, @@ -2043,7 +2044,7 @@ export function registerIpc({ const tabId = optionalBuiltInBrowserString(record, "tabId", channel, 128); const newTab = record.newTab === true ? true : undefined; const openPanel = optionalBoolean(record.openPanel); - return { url, tabId, newTab, openPanel }; + return { url, tabId, newTab, openPanel, ...parseBuiltInBrowserClaimArgs(record, channel) }; }; function optionalBuiltInBrowserString( @@ -2067,12 +2068,21 @@ export function registerIpc({ return undefined; } + const parseBuiltInBrowserClaimArgs = (record: Record, channel: string): BuiltInBrowserClaimArgs => { + const laneId = optionalBuiltInBrowserString(record, "laneId", channel, 128); + const chatSessionId = optionalBuiltInBrowserString(record, "chatSessionId", channel, 128); + return { + ...(laneId ? { laneId } : {}), + ...(chatSessionId ? { chatSessionId } : {}), + }; + }; + const parseBuiltInBrowserTabArgs = (value: unknown, channel: string): BuiltInBrowserTabArgs => { const record = builtInBrowserRecord(value, channel, true); const tabId = optionalBuiltInBrowserString(record, "tabId", channel, 128); if (!tabId) return invalidBuiltInBrowserArg(channel, "tabId must be a non-empty string"); const openPanel = optionalBoolean(record.openPanel); - return { tabId, openPanel }; + return { tabId, openPanel, ...parseBuiltInBrowserClaimArgs(record, channel) }; }; const parseBuiltInBrowserCreateTabArgs = (value: unknown, channel: string): BuiltInBrowserCreateTabArgs => { @@ -2080,14 +2090,14 @@ export function registerIpc({ const url = optionalBuiltInBrowserString(record, "url", channel, 4096); const activate = record.activate === false ? false : undefined; const openPanel = optionalBoolean(record.openPanel); - return { url, activate, openPanel }; + return { url, activate, openPanel, ...parseBuiltInBrowserClaimArgs(record, channel) }; }; const parseBuiltInBrowserOpenPanelArgs = (value: unknown, channel: string): BuiltInBrowserOpenPanelArgs => { const record = builtInBrowserRecord(value, channel, false); const url = optionalBuiltInBrowserString(record, "url", channel, 4096); const tabId = optionalBuiltInBrowserString(record, "tabId", channel, 128); - return { url, tabId }; + return { url, tabId, ...parseBuiltInBrowserClaimArgs(record, channel) }; }; const parseBuiltInBrowserSelectPointArgs = (value: unknown, channel: string): BuiltInBrowserSelectPointArgs => { diff --git a/apps/desktop/src/main/services/pty/ptyService.test.ts b/apps/desktop/src/main/services/pty/ptyService.test.ts index 4a57bd6ed..d55a53e22 100644 --- a/apps/desktop/src/main/services/pty/ptyService.test.ts +++ b/apps/desktop/src/main/services/pty/ptyService.test.ts @@ -837,7 +837,7 @@ describe("ptyService", () => { it("does not send Codex initialInput into the update prompt", async () => { vi.useFakeTimers(); try { - const { service, mockPty, logger } = createHarness(); + const { service, mockPty, logger, sessionService } = createHarness(); await service.create({ laneId: "lane-1", @@ -868,6 +868,14 @@ describe("ptyService", () => { "pty.initial_input_skipped_not_ready", expect.objectContaining({ provider: "codex" }), ); + expect(logger.warn).toHaveBeenCalledWith( + "pty.initial_input_launch_failed", + expect.objectContaining({ toolType: "codex" }), + ); + expect(sessionService.end).toHaveBeenCalledWith(expect.objectContaining({ + exitCode: 1, + status: "failed", + })); } finally { vi.useRealTimers(); } @@ -1040,7 +1048,7 @@ describe("ptyService", () => { it("skips Cursor initialInput when the workspace trust prompt never reaches a composer", async () => { vi.useFakeTimers(); try { - const { service, mockPty, logger } = createHarness(); + const { service, mockPty, logger, sessionService } = createHarness(); await service.create({ laneId: "lane-1", @@ -1071,6 +1079,14 @@ describe("ptyService", () => { "pty.initial_input_skipped_not_ready", expect.objectContaining({ provider: "cursor" }), ); + expect(logger.warn).toHaveBeenCalledWith( + "pty.initial_input_launch_failed", + expect.objectContaining({ toolType: "cursor-cli" }), + ); + expect(sessionService.end).toHaveBeenCalledWith(expect.objectContaining({ + exitCode: 1, + status: "failed", + })); } finally { vi.useRealTimers(); } diff --git a/apps/desktop/src/main/services/pty/ptyService.ts b/apps/desktop/src/main/services/pty/ptyService.ts index a654ad509..51f7eab2a 100644 --- a/apps/desktop/src/main/services/pty/ptyService.ts +++ b/apps/desktop/src/main/services/pty/ptyService.ts @@ -3444,6 +3444,22 @@ export function createPtyService({ throw err; } }; + const failInitialInputLaunch = (err: unknown): void => { + if (entry.disposed) return; + logger.warn("pty.initial_input_launch_failed", { + ptyId, + sessionId, + cwd, + toolType: toolTypeHint, + err: String(err), + }); + try { + terminatePtyProcessTree(entry, "SIGTERM", logger); + } catch { + // best effort + } + closeEntry(ptyId, 1); + }; const initialInputDelayMs = Math.max(0, Math.min(10_000, Math.floor(Number(args.initialInputDelayMs ?? 0) || 0))); if (args.awaitInitialInput) { try { @@ -3460,11 +3476,11 @@ export function createPtyService({ } } else if (initialInputDelayMs > 0) { entry.initialInputTimer = setTimeout(() => { - void writeInitialInput().catch(() => {}); + void writeInitialInput().catch(failInitialInputLaunch); }, initialInputDelayMs); entry.initialInputTimer.unref?.(); } else { - void writeInitialInput().catch(() => {}); + void writeInitialInput().catch(failInitialInputLaunch); } } diff --git a/apps/desktop/src/renderer/browserMock.ts b/apps/desktop/src/renderer/browserMock.ts index 6c2598717..e5c522d83 100644 --- a/apps/desktop/src/renderer/browserMock.ts +++ b/apps/desktop/src/renderer/browserMock.ts @@ -4576,7 +4576,11 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { canGoForward: false, isInspecting: false, hasSelection: false, + ownerLaneId: null, + ownerChatSessionId: null, + ownerClaimedAt: null, }), + claim: resolvedArg({} as any), showPanel: resolvedArg({} as any), setBounds: resolvedArg({} as any), attachWebview: resolvedArg({} as any), diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx index fab0c1409..88856a9b5 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx @@ -907,6 +907,29 @@ describe("AgentChatComposer", () => { expect(onStopOrchestratorChat).toHaveBeenCalledTimes(1); }); + it("keeps model controls visible for active worker orchestration sessions", () => { + renderComposer({ + orchestrationRole: "worker", + sessionId: "worker-session", + }); + + expect(screen.getByRole("button", { name: /Select model/i })).toBeTruthy(); + }); + + it("hides lead model controls only after the lead session exists", () => { + const props = buildComposerProps({ + orchestrationRole: "lead", + sessionId: null, + }); + const view = render(); + + expect(screen.getByRole("button", { name: /Select model/i })).toBeTruthy(); + + view.rerender(); + + expect(screen.queryByRole("button", { name: /Select model/i })).toBeNull(); + }); + it("renders the issue context menu outside the clipped composer shell", () => { const { container } = renderComposer({ draft: "", diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx index 03618e3b4..7098a521b 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx @@ -951,8 +951,8 @@ export function AgentChatComposer({ hideNativeControls?: boolean; /** * Orchestration role lock (see `goal.md` §10.10). - * - `"lead"`: hide permission picker AND model picker (lead's model is - * fixed at create-time). + * - `"lead"`: hide permission picker AND model picker once the lead + * session exists (lead's model is fixed at create-time). * - `"worker"` / `"validator"`: hide permission picker; show model + * fast + reasoning rows. * - `null` / undefined: default behaviour (regular chat composer). @@ -1533,7 +1533,12 @@ export function AgentChatComposer({ return; } if (!(node instanceof HTMLElement)) return; - if (node.dataset.iosContextId || node.dataset.appControlContextId || node.dataset.builtInBrowserContextId) { + if ( + node.dataset.iosContextId + || node.dataset.appControlContextId + || node.dataset.builtInBrowserContextId + || node.dataset.macosVmContextId + ) { parts.push(" "); return; } @@ -1579,7 +1584,15 @@ export function AgentChatComposer({ offset += node.textContent?.length ?? 0; return; } - if (node instanceof HTMLElement && (node.dataset.iosContextId || node.dataset.appControlContextId || node.dataset.builtInBrowserContextId)) return; + if ( + node instanceof HTMLElement + && ( + node.dataset.iosContextId + || node.dataset.appControlContextId + || node.dataset.builtInBrowserContextId + || node.dataset.macosVmContextId + ) + ) return; node.childNodes.forEach(visit); }; editor.childNodes.forEach(visit); @@ -2833,6 +2846,10 @@ export function AgentChatComposer({ && !composerInputLocked && !hasPendingImageAttachments && singleReady; + const normalizedBackgroundLaunchLabel = backgroundLaunchLabel.trim() || "Background"; + const backgroundLaunchActionLabel = normalizedBackgroundLaunchLabel === "Background" + ? "Launch in background" + : `${normalizedBackgroundLaunchLabel} in background`; function sendButtonTitle(): string { if (composerInputLocked) return composerInputLockMessage ?? "Resolve the pending request before sending."; @@ -3552,7 +3569,7 @@ export function AgentChatComposer({ /> ) : null} - {!parallelChatMode && orchestrationRole !== "lead" ? ( + {!parallelChatMode && (orchestrationRole !== "lead" || !sessionId) ? ( <> {onSubmitInBackground && !parallelChatMode && !cloudMode ? ( - + diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx index f78b129e6..2ddf8747e 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx @@ -314,6 +314,9 @@ function installAdeMocks(options?: { if (typeof args.provider === "string") overrides.provider = args.provider as AgentChatSession["provider"]; if (typeof args.model === "string") overrides.model = args.model; if (typeof args.modelId === "string") overrides.modelId = args.modelId; + if (typeof args.interactionMode === "string") { + overrides.interactionMode = args.interactionMode as AgentChatSession["interactionMode"]; + } return buildCreatedSession("created-session", overrides); }); const createLane = vi.fn().mockResolvedValue({ @@ -464,6 +467,9 @@ function installAdeMocks(options?: { getStatus: vi.fn().mockResolvedValue({ supported: true }), onEvent: vi.fn().mockImplementation(() => () => undefined), }, + orchestration: { + runCreate: vi.fn().mockResolvedValue({ runId: "run-1" }), + }, } as any; return { @@ -715,7 +721,7 @@ function renderAutoCreateDraftPane(args?: { session: AgentChatSession, options?: AgentChatSessionCreatedOptions, ) => void | Promise; - workDraftKind?: "chat" | "cli"; + workDraftKind?: "chat" | "cli" | "chat-orchestrator"; onLaunchCliSession?: React.ComponentProps["onLaunchCliSession"]; onLaneChange?: React.ComponentProps["onLaneChange"]; lanes?: any[]; @@ -2942,6 +2948,38 @@ describe("AgentChatPane submit recovery", () => { expect(await screen.findByText("Tools use current-lane until the lane is created.")).toBeTruthy(); }); + it("keeps orchestrator lead mode on the first Claude draft send", async () => { + const { send, create } = installAdeMocks({ sessions: [], includeClaudeModel: true }); + + renderAutoCreateDraftPane({ workDraftKind: "chat-orchestrator" }); + + const modelTrigger = await screen.findByRole("button", { name: /^Select model/ }); + const claudeLabel = getModelById("anthropic/claude-sonnet-4-6")?.displayName ?? "Claude Sonnet 4.6"; + fireEvent.pointerDown(modelTrigger, { button: 0 }); + fireEvent.click(modelTrigger); + fireEvent.click(await screen.findByRole("tab", { name: /^Anthropic$/i })); + await clickEnabledModelOption(new RegExp(escapeRegExp(claudeLabel), "i")); + + const textbox = await screen.findByRole("textbox"); + fireEvent.change(textbox, { target: { value: "Coordinate the release checklist." } }); + fireEvent.click(await screen.findByRole("button", { name: "Send" })); + + await waitFor(() => { + expect(create).toHaveBeenCalledWith(expect.objectContaining({ + interactionMode: "orchestrator-lead", + provider: "claude", + })); + expect(window.ade.orchestration.runCreate).toHaveBeenCalledWith({ + laneId: "lane-1", + leadSessionId: "created-session", + }); + expect(send).toHaveBeenCalledWith(expect.objectContaining({ + sessionId: "created-session", + interactionMode: "orchestrator-lead", + })); + }); + }); + it("background auto-create reports the new chat without stealing focus and shows a dismissible notice", async () => { const onSessionCreated = vi.fn(); const { createLane, suggestLaneName } = installAdeMocks({ sessions: [] }); @@ -3149,6 +3187,105 @@ describe("AgentChatPane submit recovery", () => { expect(screen.getByText("spec.md")).toBeTruthy(); }); + it("debounces persisted draft writes without storing screenshot data URLs", async () => { + installAdeMocks({ sessions: [] }); + const storageKey = composerDraftStorageKeyForTest({ + projectRoot: "/tmp/project-under-test", + companionStateKey: "draft:lane-1", + }); + window.localStorage.setItem(storageKey, JSON.stringify({ + version: 1, + text: "Persisted with visual context.", + modelId: "openai/gpt-5.4", + reasoningEffort: null, + codexFastMode: false, + executionMode: "focused", + controls: {}, + attachments: [], + contextAttachments: [], + iosContextItems: [{ + kind: "ios_element", + id: "ios-context-1", + componentId: "ContinueButton", + sourceFile: null, + sourceLine: null, + frame: { x: 1, y: 2, width: 3, height: 4 }, + metadata: {}, + accessibilityIdentifier: null, + screenshotDataUrl: "data:image/png;base64,ios", + selectedAt: "2026-05-27T00:00:00.000Z", + }], + appControlContextItems: [{ + kind: "app_control_element", + id: "app-context-1", + provider: "coordinate-fallback", + componentId: "SendButton", + sourceFile: null, + sourceLine: null, + frame: { x: 1, y: 2, width: 3, height: 4 }, + metadata: {}, + screenshotDataUrl: "data:image/png;base64,app", + selectedAt: "2026-05-27T00:00:00.000Z", + }], + builtInBrowserContextItems: [{ + kind: "built_in_browser_element", + id: "browser-context-1", + provider: "cdp", + componentId: "button.primary", + url: "https://example.com", + title: "Example", + sourceFile: null, + sourceLine: null, + frame: { x: 1, y: 2, width: 3, height: 4 }, + pixelFrame: { x: 1, y: 2, width: 3, height: 4 }, + metadata: {}, + screenshotDataUrl: "data:image/png;base64,browser", + selectedAt: "2026-05-27T00:00:00.000Z", + }], + macosVmContextItems: [{ + kind: "macos_vm_target", + id: "vm-context-1", + laneId: "lane-1", + laneName: "Lane 1", + vmName: "ADE VM", + provider: "lume", + state: "running", + hostLanePath: "/tmp/project-under-test", + guestLanePath: "/workspace", + runCommand: "npm test", + sshCommand: null, + vncUrl: null, + windowTitleQuery: "ADE", + screenshotDataUrl: "data:image/png;base64,vm", + selectedAt: "2026-05-27T00:00:00.000Z", + metadata: {}, + }], + draftLaunchTargetId: null, + updatedAt: "2026-05-27T00:00:00.000Z", + })); + + renderAutoCreateDraftPane(); + + const textbox = await screen.findByRole("textbox"); + await waitFor(() => { + expect(textbox.textContent).toContain("Persisted with visual context."); + }); + textbox.textContent = "Persisted with visual context and edits."; + fireEvent.input(textbox); + + await waitFor(() => { + const raw = window.localStorage.getItem(storageKey); + expect(raw).toBeTruthy(); + expect(raw).not.toContain("data:image/png;base64"); + const stored = JSON.parse(raw!); + expect(stored.text.trim()).toBe("Persisted with visual context and edits."); + expect(stored.iosContextItems[0]).not.toHaveProperty("screenshotDataUrl"); + expect(stored.appControlContextItems[0]).not.toHaveProperty("screenshotDataUrl"); + expect(stored.builtInBrowserContextItems[0].screenshotDataUrl).toBeNull(); + expect(stored.macosVmContextItems[0]).not.toHaveProperty("screenshotDataUrl"); + }); + }); + it("ignores malformed persisted draft attachment/context entries instead of crashing", async () => { installAdeMocks({ sessions: [] }); window.localStorage.setItem(composerDraftStorageKeyForTest({ @@ -3225,6 +3362,37 @@ describe("AgentChatPane submit recovery", () => { }); }); + it("keeps every in-flight background draft launch visible past the completed-notice cap", async () => { + const { suggestLaneName } = installAdeMocks({ sessions: [] }); + suggestLaneName.mockImplementation(() => new Promise(() => { + // keep the launch in-flight + })); + + renderAutoCreateDraftPane(); + + const modelTrigger = await screen.findByRole("button", { name: /^Select model/ }); + const codexLabel = getModelById("openai/gpt-5.4")?.displayName ?? "GPT-5.4"; + fireEvent.pointerDown(modelTrigger, { button: 0 }); + fireEvent.click(modelTrigger); + fireEvent.click(await screen.findByRole("tab", { name: /^OpenAI$/i })); + await clickEnabledModelOption(new RegExp(escapeRegExp(codexLabel), "i")); + + fireEvent.click(await screen.findByRole("button", { name: "Select lane" })); + fireEvent.click(await screen.findByRole("button", { name: /Auto-create lane/i })); + + const textbox = await screen.findByRole("textbox"); + for (let index = 1; index <= 9; index += 1) { + fireEvent.change(textbox, { target: { value: `Launch background chat ${index}.` } }); + fireEvent.click(await screen.findByRole("button", { name: "Launch in background" })); + await waitFor(() => { + expect(suggestLaneName).toHaveBeenCalledTimes(index); + }); + } + + expect(screen.getAllByTestId("draft-launch-job")).toHaveLength(9); + expect(screen.getAllByText(/Creating lane for chat/i)).toHaveLength(9); + }); + it("allows multiple background auto-create launches to stay pending at the same time", async () => { const { suggestLaneName, createLane, create, send } = installAdeMocks({ sessions: [] }); const suggestResolvers: Array<(value: string) => void> = []; diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index 449ef0b08..fa4c4328d 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -164,6 +164,7 @@ const LAST_MODEL_ID_KEY = "ade.chat.lastModelId"; const LAST_REASONING_KEY_PREFIX = "ade.chat.lastReasoningEffort"; const LAST_LAUNCH_CONFIG_KEY_PREFIX = "ade.chat.lastLaunchConfig.v1"; const COMPOSER_DRAFT_STORAGE_KEY_PREFIX = "ade.chat.composerDraft.v1"; +const COMPOSER_DRAFT_WRITE_DEBOUNCE_MS = 350; const SUBAGENT_AUTOOPEN_FIRED_KEY_PREFIX = "ade.chat.subagentAutoOpenFired"; const SUBAGENT_AUTOOPEN_FIRED_TTL_MS = 7 * 24 * 60 * 60 * 1000; const workCliStartupDelayMs = 180; @@ -246,6 +247,7 @@ const CHAT_HISTORY_READ_MAX_BYTES = 2_000_000; const MAX_RETAINED_CHAT_SESSION_HISTORIES = 6; const MAX_SELECTED_CHAT_SESSION_EVENTS = 20_000; const MAX_BACKGROUND_CHAT_SESSION_EVENTS = 1_000; +const MAX_DRAFT_LAUNCH_JOBS = 8; type DraftLaunchSnapshot = { text: string; @@ -312,6 +314,22 @@ type StartedDraftLaunch = { draftKind: DraftLaunchKind; }; +function isDraftLaunchJobTerminal(status: DraftLaunchJobStatus): boolean { + return status === "ready" || status === "failed"; +} + +function pruneDraftLaunchJobs(jobs: DraftLaunchJob[]): DraftLaunchJob[] { + const active = jobs.filter((job) => !isDraftLaunchJobTerminal(job.status)); + const terminal = jobs.filter((job) => isDraftLaunchJobTerminal(job.status)); + const remainingTerminalSlots = active.length > 0 + ? Math.max(MAX_DRAFT_LAUNCH_JOBS - active.length, 1) + : MAX_DRAFT_LAUNCH_JOBS; + return [ + ...active, + ...terminal.slice(0, remainingTerminalSlots), + ]; +} + function createTemporaryAutoLaneName(date = new Date()): string { const pad = (value: number) => String(value).padStart(2, "0"); return [ @@ -1788,12 +1806,30 @@ function readComposerDraftSnapshot( function writeComposerDraftSnapshot(storageKey: string, snapshot: ComposerDraftStorageSnapshot): void { try { - window.localStorage.setItem(storageKey, JSON.stringify(snapshot)); + window.localStorage.setItem(storageKey, JSON.stringify(stripComposerDraftScreenshots(snapshot))); } catch { // ignore } } +function stripComposerDraftScreenshots(snapshot: ComposerDraftStorageSnapshot): ComposerDraftStorageSnapshot { + return { + ...snapshot, + iosContextItems: snapshot.iosContextItems.map((item) => ( + item.screenshotDataUrl ? { ...item, screenshotDataUrl: undefined } : item + )), + appControlContextItems: snapshot.appControlContextItems.map((item) => ( + item.screenshotDataUrl ? { ...item, screenshotDataUrl: undefined } : item + )), + builtInBrowserContextItems: snapshot.builtInBrowserContextItems.map((item) => ( + item.screenshotDataUrl ? { ...item, screenshotDataUrl: null } : item + )), + macosVmContextItems: snapshot.macosVmContextItems.map((item) => ( + item.screenshotDataUrl ? { ...item, screenshotDataUrl: undefined } : item + )), + }; +} + function resolveCliRegistryModelId(provider: "codex" | "claude" | "cursor" | "droid", value: string | null | undefined): string | null { const normalized = (value ?? "").trim().toLowerCase(); if (!normalized.length) return null; @@ -2350,6 +2386,11 @@ export function AgentChatPane({ const [sendOnEnter, setSendOnEnter] = useState(true); const [draft, setDraft] = useState(""); const draftsPerSessionRef = useRef>(new Map()); + const composerDraftWriteTimerRef = useRef(null); + const pendingComposerDraftWriteRef = useRef<{ + storageKey: string; + snapshot: ComposerDraftStorageSnapshot; + } | null>(null); const [busy, setBusy] = useState(false); const [shellLaunchBusy, setShellLaunchBusy] = useState(false); const [loading, setLoading] = useState(false); @@ -5113,6 +5154,24 @@ export function AgentChatPane({ nativeControlsRef.current = currentNativeControls; }, [currentNativeControls]); + const flushPendingComposerDraftWrite = useCallback(() => { + if (composerDraftWriteTimerRef.current != null) { + window.clearTimeout(composerDraftWriteTimerRef.current); + composerDraftWriteTimerRef.current = null; + } + const pending = pendingComposerDraftWriteRef.current; + pendingComposerDraftWriteRef.current = null; + if (pending) { + writeComposerDraftSnapshot(pending.storageKey, pending.snapshot); + } + }, []); + + useEffect(() => { + return () => { + flushPendingComposerDraftWrite(); + }; + }, [composerDraftStorageKeyValue, flushPendingComposerDraftWrite]); + // Save/restore per-session (or per-lane draft) composer state when scope changes. const composerDraftTextRef = useRef(draft); useEffect(() => { @@ -5175,7 +5234,7 @@ export function AgentChatPane({ return; } draftsPerSessionRef.current.set(companionStateKey, draft); - writeComposerDraftSnapshot(composerDraftStorageKeyValue, { + const snapshot: ComposerDraftStorageSnapshot = { version: 1, text: draft, modelId, @@ -5194,7 +5253,22 @@ export function AgentChatPane({ macosVmContextItems, draftLaunchTargetId, updatedAt: new Date().toISOString(), - }); + }; + pendingComposerDraftWriteRef.current = { + storageKey: composerDraftStorageKeyValue, + snapshot, + }; + if (composerDraftWriteTimerRef.current != null) { + window.clearTimeout(composerDraftWriteTimerRef.current); + } + composerDraftWriteTimerRef.current = window.setTimeout(() => { + const pending = pendingComposerDraftWriteRef.current; + pendingComposerDraftWriteRef.current = null; + composerDraftWriteTimerRef.current = null; + if (pending) { + writeComposerDraftSnapshot(pending.storageKey, pending.snapshot); + } + }, COMPOSER_DRAFT_WRITE_DEBOUNCE_MS); }, [ appControlContextItems, attachments, @@ -5344,15 +5418,10 @@ export function AgentChatPane({ opencodePermissionMode: harnessPermissionMode, } : baseNativeControls; - const nativeControlPayload = harnessPermissionMode - ? { - ...summarizeNativeControls(provider, launchControls), - ...(provider === "cursor" ? { cursorConfigValues: launchControls.cursorConfigValues } : {}), - } - : { - ...summarizeNativeControls(provider, launchControls), - ...(provider === "cursor" ? { cursorConfigValues: launchControls.cursorConfigValues } : {}), - }; + const nativeControlPayload = { + ...summarizeNativeControls(provider, launchControls), + ...(provider === "cursor" ? { cursorConfigValues: launchControls.cursorConfigValues } : {}), + }; // Orchestrator-lead draft: force the interactionMode so the lead chat // boots with the orchestrator skill + tool gates (`goal.md` §10.1). const orchestratorOverrides: Partial[0]> = @@ -5424,18 +5493,6 @@ export function AgentChatPane({ draftSelectionLockedRef.current = false; setSelectedSessionId(created.id); } - // Only rebind the iOS simulator to a freshly created chat when the user - // has opened the simulator drawer for THIS chat. The eager-create path - // would otherwise steal ownership from a chat that is currently using - // the simulator (e.g. switching to a new lane creates a new session - // before any user gesture occurs). - if (options.select && iosSimulatorOpen && targetLaneId === laneId) { - try { - void window.ade.iosSimulator - ?.attachToChatSession?.({ chatSessionId: created.id }) - ?.catch(() => { /* attach is best-effort; sim may not be running or already owned */ }); - } catch { /* iosSimulator API may be unavailable in some environments */ } - } if (desc?.isCliWrapped && (desc.family === "anthropic" || desc.family === "cursor")) { window.ade.agentChat.warmupModel({ sessionId: created.id, @@ -5447,7 +5504,7 @@ export function AgentChatPane({ if (options.notify) notifySessionCreated(created, options.notifyOptions); if (targetLaneId === laneId) void refreshSessions().catch(() => {}); return created; - }, [codexFastMode, constrainedModelSelectionError, currentNativeControls, executionMode, initialNativeControls, iosSimulatorOpen, laneId, lastLaunchConfigStorageKey, modelId, notifySessionCreated, patchSessionSummary, reasoningEffort, refreshSessions, touchSession, workDraftKind]); + }, [codexFastMode, constrainedModelSelectionError, currentNativeControls, executionMode, initialNativeControls, laneId, lastLaunchConfigStorageKey, modelId, notifySessionCreated, patchSessionSummary, reasoningEffort, refreshSessions, touchSession, workDraftKind]); const createSession = useCallback(async (): Promise => { if (createSessionPromiseRef.current) { @@ -5601,9 +5658,9 @@ export function AgentChatPane({ }, [applyLaunchConfigToComposer, companionStateKey, draftLaunchConfigScopeKey]); const patchDraftLaunchJob = useCallback((jobId: string, patch: Partial) => { - setDraftLaunchJobs((current) => current.map((job) => ( + setDraftLaunchJobs((current) => pruneDraftLaunchJobs(current.map((job) => ( job.id === jobId ? { ...job, ...patch } : job - ))); + )))); }, []); const dismissDraftLaunchJob = useCallback((jobId: string) => { @@ -5717,6 +5774,9 @@ export function AgentChatPane({ try { createdSession = await createSessionForLane(targetLane.laneId, { select: false, launchState: prepared }); touchSession(createdSession.id); + const sendInteractionMode = createdSession.provider === "claude" + ? createdSession.interactionMode ?? prepared.interactionMode + : null; await window.ade.agentChat.send({ sessionId: createdSession.id, text: prepared.finalText, @@ -5725,7 +5785,7 @@ export function AgentChatPane({ contextAttachments: prepared.selectedContextAttachments, reasoningEffort: prepared.reasoningEffort, executionMode: prepared.executionMode, - interactionMode: createdSession.provider === "claude" ? prepared.interactionMode : null, + interactionMode: sendInteractionMode, ...(createdSession.provider === "cursor" ? { runtime: "local" as const } : {}), }); notifySessionCreated(createdSession, { @@ -5848,14 +5908,14 @@ export function AgentChatPane({ }; setPromptSuggestion(null); setError(null); - setDraftLaunchJobs((current) => [ + setDraftLaunchJobs((current) => pruneDraftLaunchJobs([ job, ...current.map((entry) => ( mode === "foreground" && entry.mode === "foreground" ? { ...entry, autoOpen: false } : entry )), - ].slice(0, 8)); + ])); clearDraftLaunchComposer(snapshot); let targetLane: DraftLaunchLaneTarget | null = null; @@ -6638,6 +6698,14 @@ export function AgentChatPane({ const sendMessageOrSteerIfBusy = async (retryOnStaleSteer = true) => { try { setOptimisticOutgoingMessageSynced({ sessionId, envelope: optimisticEnvelope(sessionId) }); + const sendInteractionMode: AgentChatInteractionMode | null = + sessionProvider === "claude" + ? ( + workDraftKind === "chat-orchestrator" || selectedSession?.interactionMode === "orchestrator-lead" + ? "orchestrator-lead" + : interactionMode + ) + : null; await window.ade.agentChat.send({ sessionId, text: finalText, @@ -6646,7 +6714,7 @@ export function AgentChatPane({ contextAttachments: selectedContextAttachments, reasoningEffort, executionMode: launchModeEditable ? executionMode : null, - interactionMode: sessionProvider === "claude" ? interactionMode : null, + interactionMode: sendInteractionMode, ...(sessionProvider === "cursor" ? { runtime: cursorRuntime } : {}), }); } catch (sendError) { diff --git a/apps/desktop/src/renderer/components/chat/ChatIosSimulatorPanel.test.tsx b/apps/desktop/src/renderer/components/chat/ChatIosSimulatorPanel.test.tsx index ce9a0c0f1..f77630573 100644 --- a/apps/desktop/src/renderer/components/chat/ChatIosSimulatorPanel.test.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatIosSimulatorPanel.test.tsx @@ -52,6 +52,7 @@ const activeStatus: IosSimulatorStatus = { mode: "live", bridgeUrl: null, startedAt: "2026-04-29T00:00:00.000Z", + claimedAt: "2026-04-29T00:00:01.000Z", }, }; diff --git a/apps/desktop/src/renderer/components/chat/ChatIosSimulatorPanel.tsx b/apps/desktop/src/renderer/components/chat/ChatIosSimulatorPanel.tsx index 25c7d05a2..6e47baac9 100644 --- a/apps/desktop/src/renderer/components/chat/ChatIosSimulatorPanel.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatIosSimulatorPanel.tsx @@ -65,6 +65,7 @@ type ChatIosSimulatorPanelProps = { laneId?: string | null; projectRoot: string | null; controlDisabledReason?: string | null; + ignoreChatOwnership?: boolean; onAddContext?: (item: IosElementContextItem) => void; onAddAttachment?: (attachment: AgentChatFileRef) => void; onInsertDraft?: (text: string) => void; @@ -889,6 +890,7 @@ export function ChatIosSimulatorPanel({ laneId = null, projectRoot, controlDisabledReason = null, + ignoreChatOwnership = false, onAddContext, onAddAttachment, onInsertDraft, @@ -1154,11 +1156,12 @@ export function ChatIosSimulatorPanel({ : "No preview target selected"; const otherChatSessionId = useMemo(() => { + if (ignoreChatOwnership) return null; const owner = activeSession?.chatSessionId ?? null; if (!owner) return null; if (!sessionId) return owner; return owner !== sessionId ? owner : null; - }, [activeSession?.chatSessionId, sessionId]); + }, [activeSession?.chatSessionId, ignoreChatOwnership, sessionId]); const ownedByOtherChat = otherChatSessionId !== null; const contextControlsBlocked = controlsDisabled; const simulatorMutationBlocked = ownedByOtherChat || controlsDisabled; diff --git a/apps/desktop/src/renderer/components/lanes/useLaneWorkSessions.test.ts b/apps/desktop/src/renderer/components/lanes/useLaneWorkSessions.test.ts index 9dcef5b40..b578f11a9 100644 --- a/apps/desktop/src/renderer/components/lanes/useLaneWorkSessions.test.ts +++ b/apps/desktop/src/renderer/components/lanes/useLaneWorkSessions.test.ts @@ -274,6 +274,26 @@ describe("useLaneWorkSessions — refresh-before-focus ordering", () => { expect(openTabIdx).toBeLessThan(refreshDoneIdx); }); + it("launchPtySession: keeps the optimistic terminal when the forced refresh is stale", async () => { + const { result } = renderHook(() => useLaneWorkSessions("lane-1")); + + await act(async () => { + await new Promise((r) => setTimeout(r, 10)); + }); + + listSessionsCachedMock.mockResolvedValue([]); + + await act(async () => { + await result.current.launchPtySession({ + laneId: "lane-1", + profile: "shell", + }); + await new Promise((r) => setTimeout(r, 0)); + }); + + expect(result.current.sessions.map((session) => session.id)).toContain("new-pty-session"); + }); + it("launchPtySession: opens immediately when another refresh is already running", async () => { const callOrder: string[] = []; let refreshCallCount = 0; diff --git a/apps/desktop/src/renderer/components/lanes/useLaneWorkSessions.ts b/apps/desktop/src/renderer/components/lanes/useLaneWorkSessions.ts index 216053fc6..7ddbad42b 100644 --- a/apps/desktop/src/renderer/components/lanes/useLaneWorkSessions.ts +++ b/apps/desktop/src/renderer/components/lanes/useLaneWorkSessions.ts @@ -36,6 +36,7 @@ const EMPTY_WORK_STATE: WorkProjectViewState = { }; const laneSessionsCacheByScope = new Map(); +const OPTIMISTIC_PTY_SESSION_TTL_MS = 2 * 60 * 1000; export function __clearLaneWorkSessionCacheForTests(): void { laneSessionsCacheByScope.clear(); @@ -51,6 +52,49 @@ type QueuedRefresh = { }; }; +type PendingOptimisticSession = { + session: TerminalSessionSummary; + createdAtMs: number; +}; + +function compareSessionsByStartedAtDesc(left: TerminalSessionSummary, right: TerminalSessionSummary): number { + return new Date(right.startedAt).getTime() - new Date(left.startedAt).getTime(); +} + +function upsertSessionByStartedAt( + sessions: readonly TerminalSessionSummary[], + session: TerminalSessionSummary, +): TerminalSessionSummary[] { + return [session, ...sessions.filter((entry) => entry.id !== session.id)].sort(compareSessionsByStartedAtDesc); +} + +function mergePendingOptimisticSession( + persisted: TerminalSessionSummary, + optimistic: TerminalSessionSummary, +): { session: TerminalSessionSummary; keepPending: boolean } { + const optimisticPtyId = optimistic.ptyId?.trim() || null; + if (!optimisticPtyId) return { session: persisted, keepPending: false }; + + if (persisted.status !== "running") { + return { session: persisted, keepPending: false }; + } + + const persistedPtyId = persisted.ptyId?.trim() || null; + if (persistedPtyId === optimisticPtyId) { + return { session: persisted, keepPending: false }; + } + + return { + session: { + ...persisted, + ptyId: optimisticPtyId, + toolType: persisted.toolType ?? optimistic.toolType, + runtimeState: persisted.runtimeState ?? optimistic.runtimeState, + }, + keepPending: true, + }; +} + function arraysEqual(a: string[], b: string[]): boolean { if (a.length !== b.length) return false; for (let i = 0; i < a.length; i += 1) { @@ -91,6 +135,7 @@ export function useLaneWorkSessions(laneId: string | null) { const laneIdRef = useRef(laneId); const projectRootRef = useRef(projectRoot); const scopeKeyRef = useRef(""); + const pendingOptimisticSessionsRef = useRef>(new Map()); const currentLane = useMemo( () => (laneId ? lanes.find((lane) => lane.id === laneId) ?? null : null), @@ -102,6 +147,13 @@ export function useLaneWorkSessions(laneId: string | null) { if (!normalizedProjectRoot || !laneId) return ""; return `${normalizedProjectRoot}::${laneId}`; }, [projectRoot, laneId]); + const pendingOptimisticScopeKeyRef = useRef(scopeKey); + + useEffect(() => { + if (pendingOptimisticScopeKeyRef.current === scopeKey) return; + pendingOptimisticSessionsRef.current.clear(); + pendingOptimisticScopeKeyRef.current = scopeKey; + }, [scopeKey]); useEffect(() => { laneIdRef.current = laneId; @@ -159,12 +211,41 @@ export function useLaneWorkSessions(laneId: string | null) { if (showLoading) setLoading(true); try { const requestedScopeKey = scopeKeyRef.current; - const rows = await listSessionsCached( - { laneId: targetLaneId, limit: 200 }, - { force: Boolean(options.force), projectRoot: targetProjectRoot }, - ); + const rows = ( + await listSessionsCached( + { laneId: targetLaneId, limit: 200 }, + { force: Boolean(options.force), projectRoot: targetProjectRoot }, + ) + ).filter((session) => !isRunOwnedSession(session)); if (scopeKeyRef.current !== requestedScopeKey) return; - const nextSessions = rows.filter((session) => !isRunOwnedSession(session)); + const pending = pendingOptimisticSessionsRef.current; + if (pending.size > 0) { + const now = Date.now(); + const rowIndexById = new Map(rows.map((session, index) => [session.id, index] as const)); + for (const [sessionId, entry] of [...pending.entries()]) { + const expired = now - entry.createdAtMs > OPTIMISTIC_PTY_SESSION_TTL_MS; + const existingIndex = rowIndexById.get(sessionId); + if (existingIndex != null) { + const persisted = rows[existingIndex]; + if (expired || !persisted) { + pending.delete(sessionId); + continue; + } + const merged = mergePendingOptimisticSession(persisted, entry.session); + rows[existingIndex] = merged.session; + if (!merged.keepPending) pending.delete(sessionId); + continue; + } + if (expired) { + pending.delete(sessionId); + continue; + } + rows.push(entry.session); + rowIndexById.set(sessionId, rows.length - 1); + } + rows.sort(compareSessionsByStartedAtDesc); + } + const nextSessions = rows; setSessions(nextSessions); if (requestedScopeKey) { laneSessionsCacheByScope.set(requestedScopeKey, nextSessions); @@ -204,12 +285,13 @@ export function useLaneWorkSessions(laneId: string | null) { laneName, }); optimistic.startedAt = new Date().toISOString(); + pendingOptimisticSessionsRef.current.set(optimistic.id, { + session: optimistic, + createdAtMs: Date.now(), + }); hasLoadedOnceRef.current = true; setSessions((prev) => { - const next = [optimistic, ...prev.filter((entry) => entry.id !== session.id)]; - next.sort((left, right) => ( - new Date(right.startedAt).getTime() - new Date(left.startedAt).getTime() - )); + const next = upsertSessionByStartedAt(prev, optimistic); if (scopeKey) { laneSessionsCacheByScope.set(scopeKey, next); } @@ -221,10 +303,7 @@ export function useLaneWorkSessions(laneId: string | null) { if (!laneId || session.laneId !== laneId) return; hasLoadedOnceRef.current = true; setSessions((prev) => { - const next = [session, ...prev.filter((entry) => entry.id !== session.id)]; - next.sort((left, right) => ( - new Date(right.startedAt).getTime() - new Date(left.startedAt).getTime() - )); + const next = upsertSessionByStartedAt(prev, session); if (scopeKey) { laneSessionsCacheByScope.set(scopeKey, next); } @@ -558,6 +637,10 @@ export function useLaneWorkSessions(laneId: string | null) { resumeMetadata: null, chatSessionId: null, }; + pendingOptimisticSessionsRef.current.set(result.sessionId, { + session: optimisticSession, + createdAtMs: Date.now(), + }); upsertSessionSnapshot(optimisticSession); // Invalidate all cache entries so other views (e.g. Work tab) pick up // the new session on their next refresh. diff --git a/apps/desktop/src/renderer/components/terminals/TerminalView.test.tsx b/apps/desktop/src/renderer/components/terminals/TerminalView.test.tsx index 33d896b95..5de6d9eca 100644 --- a/apps/desktop/src/renderer/components/terminals/TerminalView.test.tsx +++ b/apps/desktop/src/renderer/components/terminals/TerminalView.test.tsx @@ -82,11 +82,18 @@ vi.mock("@xterm/xterm", () => ({ blur = vi.fn(); write = vi.fn(); refresh = vi.fn(); + scrollLines = vi.fn(); resize = vi.fn((cols: number, rows: number) => { this.cols = cols; this.rows = rows; }); scrollToBottom = vi.fn(); + buffer = { + active: { + baseY: 0, + viewportY: 0, + }, + }; dispose = vi.fn(); clearTextureAtlas = vi.fn(); getSelection = vi.fn(() => ""); @@ -1256,6 +1263,83 @@ describe("TerminalView", () => { expect(window.ade.terminal.preview).not.toHaveBeenCalled(); }); + it("does not force live PTY output back to the bottom after the user scrolls up", async () => { + render(); + await flushAnimationFrame(); + + const terminal = mockState.terminalInstances.at(-1) as { + buffer: { active: { baseY: number; viewportY: number } }; + write: ReturnType; + refresh: ReturnType; + scrollToBottom: ReturnType; + } | undefined; + expect(terminal).toBeTruthy(); + + terminal!.buffer.active.baseY = 120; + terminal!.buffer.active.viewportY = 40; + terminal?.write.mockClear(); + terminal?.refresh.mockClear(); + terminal?.scrollToBottom.mockClear(); + + for (const listener of mockState.ptyDataListeners) { + listener({ ptyId: "pty-user-scrollback", sessionId: "session-user-scrollback", data: "background output\n" }); + } + await act(async () => { + await vi.advanceTimersByTimeAsync(16); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(16); + }); + + expect(terminal?.write).toHaveBeenCalledWith("background output\n"); + expect(terminal?.refresh).toHaveBeenCalled(); + expect(terminal?.scrollToBottom).not.toHaveBeenCalled(); + }); + + it("uses wheel gestures to scroll main-buffer history when mouse tracking is active", async () => { + render(); + await flushAllTimers(); + + const terminal = mockState.terminalInstances.at(-1) as { + element: HTMLElement | null; + buffer: { active: { baseY: number; viewportY: number } }; + scrollLines: ReturnType; + } | undefined; + expect(terminal?.element).toBeTruthy(); + + const viewport = document.createElement("div"); + viewport.className = "xterm-viewport"; + Object.defineProperty(viewport, "scrollHeight", { + configurable: true, + value: 720, + }); + Object.defineProperty(viewport, "clientHeight", { + configurable: true, + value: 360, + }); + terminal!.element!.appendChild(viewport); + terminal!.buffer.active.baseY = 200; + terminal!.buffer.active.viewportY = 180; + + for (const listener of mockState.ptyDataListeners) { + listener({ + ptyId: "pty-wheel-history", + sessionId: "session-wheel-history", + data: "\x1b[?1000h\x1b[?1002h\x1b[?1006h", + }); + } + + const event = new WheelEvent("wheel", { + bubbles: true, + cancelable: true, + deltaY: -96, + }); + viewport.dispatchEvent(event); + + expect(terminal?.scrollLines).toHaveBeenCalledWith(-3); + expect(event.defaultPrevented).toBe(true); + }); + it("does not replay transcript hydration over live PTY output that already painted", async () => { const previewMock = window.ade.terminal.preview as unknown as ReturnType; previewMock.mockResolvedValueOnce({ diff --git a/apps/desktop/src/renderer/components/terminals/TerminalView.tsx b/apps/desktop/src/renderer/components/terminals/TerminalView.tsx index 210db5e84..ed37ba84f 100644 --- a/apps/desktop/src/renderer/components/terminals/TerminalView.tsx +++ b/apps/desktop/src/renderer/components/terminals/TerminalView.tsx @@ -889,25 +889,37 @@ function flushFrameWriteChunksSync(runtime: CachedRuntime) { const merged = runtime.frameWriteChunks.join(""); runtime.frameWriteChunks.length = 0; runtime.frameWriteBytes = 0; + const followOutput = shouldFollowTerminalOutput(runtime); try { runtime.term.write(merged); if (hasRenderableTerminalText(merged)) { runtime.hasAppliedTerminalContent = true; } - scheduleVisibleFrameRefresh(runtime); + scheduleVisibleFrameRefresh(runtime, { scrollToBottom: followOutput }); } catch { // ignore write errors after disposal } } -function scheduleVisibleFrameRefresh(runtime: CachedRuntime) { +function shouldFollowTerminalOutput(runtime: CachedRuntime): boolean { + try { + const buffer = runtime.term.buffer.active; + return buffer.viewportY >= buffer.baseY - 1; + } catch { + return true; + } +} + +function scheduleVisibleFrameRefresh(runtime: CachedRuntime, options: { scrollToBottom: boolean }) { if (runtime.disposed || runtime.refs === 0 || !runtime.visible || !runtime.active) return; if (document.visibilityState !== "visible") return; requestAnimationFrame(() => { if (runtime.disposed || runtime.refs === 0 || !runtime.visible || !runtime.active) return; if (document.visibilityState !== "visible") return; try { - runtime.term.scrollToBottom(); + if (options.scrollToBottom) { + runtime.term.scrollToBottom(); + } runtime.term.refresh(0, Math.max(0, runtime.term.rows - 1)); } catch { // ignore refresh failures after disposal @@ -1784,17 +1796,19 @@ export function TerminalView({ if (!viewport) return; const viewportScrollable = viewport.scrollHeight > viewport.clientHeight + 1; const hasScrollback = runtime.term.buffer.active.baseY > 0; - if (viewportScrollable || !hasScrollback) return; + const mouseTrackingActive = isTerminalMouseTrackingActive(runtime); + if (!hasScrollback || (viewportScrollable && !mouseTrackingActive)) return; const direction = ev.deltaY > 0 ? 1 : -1; const magnitude = Math.max(1, Math.min(12, Math.round(Math.abs(ev.deltaY) / 32))); try { runtime.term.scrollLines(direction * magnitude); ev.preventDefault(); + ev.stopPropagation(); } catch { // ignore } }; - el.addEventListener("wheel", onWheel, { passive: false }); + el.addEventListener("wheel", onWheel, { passive: false, capture: true }); const intObs = new IntersectionObserver((entries) => { for (const entry of entries) { @@ -1901,7 +1915,7 @@ export function TerminalView({ window.removeEventListener("resize", onWindowResize); window.removeEventListener(WORK_SURFACE_REVEALED_EVENT, onWorkSurfaceRevealed); window.visualViewport?.removeEventListener("resize", onWindowResize); - el.removeEventListener("wheel", onWheel); + el.removeEventListener("wheel", onWheel, { capture: true }); if (runtime.host.parentElement === el) { flushPendingFrameWrites(runtime); diff --git a/apps/desktop/src/renderer/components/terminals/WorkSidebar.test.tsx b/apps/desktop/src/renderer/components/terminals/WorkSidebar.test.tsx index 5feefa3a6..28a9421e7 100644 --- a/apps/desktop/src/renderer/components/terminals/WorkSidebar.test.tsx +++ b/apps/desktop/src/renderer/components/terminals/WorkSidebar.test.tsx @@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { AppControlContextItem, AppControlSession, + BuiltInBrowserStatus, IosElementContextItem, IosSimulatorSession, LaneSummary, @@ -20,6 +21,7 @@ vi.mock("../chat/ChatIosSimulatorPanel", async () => { ChatIosSimulatorPanel: (props: { sessionId: string | null; controlDisabledReason?: string | null; + ignoreChatOwnership?: boolean; onAddAttachment?: (attachment: { path: string; type: "image" }) => void; onAddContext?: (item: IosElementContextItem) => void; onInsertDraft?: (text: string) => void; @@ -27,6 +29,7 @@ vi.mock("../chat/ChatIosSimulatorPanel", async () => { "data-testid": "ios-panel", "data-session-id": props.sessionId ?? "", "data-control-disabled": props.controlDisabledReason ?? "", + "data-ignore-chat-ownership": props.ignoreChatOwnership ? "true" : "false", }, [ React.createElement("button", { key: "context", @@ -223,6 +226,7 @@ const otherLaneIosSession: IosSimulatorSession = { mode: "live", bridgeUrl: null, startedAt: "2026-05-13T00:00:00.000Z", + claimedAt: "2026-05-13T00:00:01.000Z", }; const iosContextItem: IosElementContextItem = { @@ -252,9 +256,29 @@ const appControlContextItem: AppControlContextItem = { selectedAt: "2026-05-13T00:00:00.000Z", }; +const defaultBrowserStatus: BuiltInBrowserStatus = { + attached: false, + partition: "persist:ade-browser", + visible: false, + bounds: { x: 0, y: 0, width: 0, height: 0 }, + activeTabId: null, + tabs: [], + url: null, + title: null, + isLoading: false, + canGoBack: false, + canGoForward: false, + isInspecting: false, + hasSelection: false, + ownerLaneId: null, + ownerChatSessionId: null, + ownerClaimedAt: null, +}; + function installAdeMock(options: { appControlSession?: AppControlSession | null; iosSession?: IosSimulatorSession | null; + browserStatus?: BuiltInBrowserStatus | null; } = {}) { const terminalWrite = vi.fn().mockResolvedValue({ ok: true }); Object.defineProperty(window, "ade", { @@ -265,6 +289,8 @@ function installAdeMock(options: { onEvent: vi.fn(() => () => {}), }, builtInBrowser: { + getStatus: vi.fn().mockResolvedValue(options.browserStatus ?? defaultBrowserStatus), + onEvent: vi.fn(() => () => {}), stopInspect: vi.fn().mockResolvedValue(undefined), setBounds: vi.fn().mockResolvedValue(undefined), }, @@ -426,7 +452,7 @@ describe("WorkSidebar context targets", () => { expect(received[0]).not.toHaveProperty("sessionId"); }); - it("warns when App Control is attached to another lane without disabling context insertion", async () => { + it("warns when App Control is attached to another lane while keeping Work controls usable", async () => { const { terminalWrite } = installAdeMock({ appControlSession: otherLaneAppControlSession }); renderSidebar({ @@ -435,14 +461,14 @@ describe("WorkSidebar context targets", () => { lanes: [lane, laneTwo], }); - expect(await screen.findByText(/This App Control view is running from Lane 2 while your context target is Lane 1/)).toBeTruthy(); + expect(await screen.findByText(/This App Control view is claimed by Lane 2, not Lane 1/)).toBeTruthy(); expect(screen.getByTestId("app-control-panel").getAttribute("data-control-disabled")).toBe(""); expect((screen.getByText("Add App Control context") as HTMLButtonElement).disabled).toBe(false); fireEvent.click(screen.getByText("Add App Control context")); await waitFor(() => expect(terminalWrite).toHaveBeenCalledTimes(1)); }); - it("warns when the iOS Simulator is attached to another lane without disabling context insertion", async () => { + it("warns when the iOS Simulator is attached to another lane while keeping Work controls usable", async () => { const { terminalWrite } = installAdeMock({ iosSession: otherLaneIosSession }); renderSidebar({ @@ -451,48 +477,49 @@ describe("WorkSidebar context targets", () => { lanes: [lane, laneTwo], }); - expect(await screen.findByText(/This iOS Simulator view is running from Lane 2 while your context target is Lane 1/)).toBeTruthy(); + expect(await screen.findByText(/This iOS Simulator view is claimed by Lane 2, not Lane 1/)).toBeTruthy(); expect(screen.getByTestId("ios-panel").getAttribute("data-control-disabled")).toBe(""); + expect(screen.getByTestId("ios-panel").getAttribute("data-ignore-chat-ownership")).toBe("true"); expect((screen.getByText("Add iOS context") as HTMLButtonElement).disabled).toBe(false); fireEvent.click(screen.getByText("Add iOS context")); await waitFor(() => expect(terminalWrite).toHaveBeenCalledTimes(1)); }); - it("warns when the Browser view survives a lane switch", async () => { - const { rerender } = render( - - - , - ); - - rerender( - - - , - ); - - expect(await screen.findByText(/This Browser view is running from Lane 1 while your context target is Lane 2/)).toBeTruthy(); + it("does not assign Browser ownership from the currently visible lane", async () => { + installAdeMock(); + + renderSidebar({ + tab: "browser", + laneId: "lane-2", + lanes: [lane, laneTwo], + activeSession: { ...activeSession, laneId: "lane-2", laneName: "Lane 2" }, + contextTarget: { kind: "pty", sessionId: "term-2", ptyId: "pty-2", toolType: "claude" }, + }); + + await waitFor(() => expect(screen.queryByText(/This Browser view is claimed/)).toBeNull()); expect((screen.getByText("Add Browser context") as HTMLButtonElement).disabled).toBe(false); expect((screen.getByText("Add Browser attachment") as HTMLButtonElement).disabled).toBe(false); }); + + it("warns from the Browser service claim without blocking context insertion", async () => { + installAdeMock({ + browserStatus: { + ...defaultBrowserStatus, + ownerLaneId: "lane-1", + ownerChatSessionId: "session-1", + ownerClaimedAt: "2026-05-13T00:00:00.000Z", + }, + }); + + renderSidebar({ + tab: "browser", + laneId: "lane-2", + lanes: [lane, laneTwo], + activeSession: { ...activeSession, laneId: "lane-2", laneName: "Lane 2" }, + contextTarget: { kind: "pty", sessionId: "term-2", ptyId: "pty-2", toolType: "claude" }, + }); + + expect(await screen.findByText(/This Browser view is claimed by Lane 1, not Lane 2/)).toBeTruthy(); + expect((screen.getByText("Add Browser context") as HTMLButtonElement).disabled).toBe(false); + }); }); diff --git a/apps/desktop/src/renderer/components/terminals/WorkSidebar.tsx b/apps/desktop/src/renderer/components/terminals/WorkSidebar.tsx index b8133489f..e325fd354 100644 --- a/apps/desktop/src/renderer/components/terminals/WorkSidebar.tsx +++ b/apps/desktop/src/renderer/components/terminals/WorkSidebar.tsx @@ -13,6 +13,7 @@ import type { AgentChatFileRef, AppControlContextItem, AppControlSession, + BuiltInBrowserStatus, GitCommitSummary, IosElementContextItem, IosSimulatorSession, @@ -104,7 +105,7 @@ function laneMismatchMessage( ): string { const ownerLane = laneDisplayName(lanes, ownerLaneId); const activeLane = laneDisplayName(lanes, activeLaneId); - return `This ${toolName} view is running from ${ownerLane} while your context target is ${activeLane}. Controls affect the running ${toolName}; inserted context goes to the current chat, draft, or CLI.`; + return `This ${toolName} view is claimed by ${ownerLane}, not ${activeLane}. You can still view, inspect, and attach context here. Claim it from ${activeLane} to move ownership.`; } function dispatchAgentChatEvent( @@ -167,7 +168,6 @@ export function WorkSidebar({ active = true, laneId, lanes, - activeSession, tab, onTabChange, onClose, @@ -190,7 +190,7 @@ export function WorkSidebar({ const [selectedCommit, setSelectedCommit] = useState(null); const [appControlSession, setAppControlSession] = useState(null); const [iosSession, setIosSession] = useState(null); - const [browserViewLaneId, setBrowserViewLaneId] = useState(tab === "browser" ? laneId : null); + const [browserStatus, setBrowserStatus] = useState(null); const sidebarRef = useRef(null); const [compactTabs, setCompactTabs] = useState(false); @@ -230,15 +230,29 @@ export function WorkSidebar({ }; }, [active, tab]); - const previousTabForBrowserOwnerRef = useRef(tab); useEffect(() => { - if (!active) return; - const previousTab = previousTabForBrowserOwnerRef.current; - if (tab === "browser" && (previousTab !== "browser" || !browserViewLaneId)) { - setBrowserViewLaneId(laneId); - } - previousTabForBrowserOwnerRef.current = tab; - }, [active, browserViewLaneId, laneId, tab]); + if (!active) return undefined; + if (tab !== "browser") return undefined; + const browser = window.ade?.builtInBrowser; + if (!browser?.getStatus || !browser.onEvent) return undefined; + let cancelled = false; + void browser.getStatus() + .then((status) => { + if (!cancelled) setBrowserStatus(status); + }) + .catch(() => { + if (!cancelled) setBrowserStatus(null); + }); + const unsubscribe = browser.onEvent((event) => { + if (event.type === "status" || event.type === "open-request") { + setBrowserStatus(event.status); + } + }); + return () => { + cancelled = true; + unsubscribe(); + }; + }, [active, tab]); useEffect(() => { if (!active) return undefined; @@ -292,10 +306,10 @@ export function WorkSidebar({ }; }, [active, tab]); - function resolveLaneMismatchReason(): string | null { + function resolveToolAttributionReason(): string | null { if (!laneId) return null; - if (tab === "browser" && browserViewLaneId && browserViewLaneId !== laneId) { - return laneMismatchMessage("Browser", browserViewLaneId, laneId, lanes); + if (tab === "browser" && browserStatus?.ownerLaneId && browserStatus.ownerLaneId !== laneId) { + return laneMismatchMessage("Browser", browserStatus.ownerLaneId, laneId, lanes); } if (tab === "app-control" && appControlSession?.laneId && appControlSession.laneId !== laneId) { return laneMismatchMessage("App Control", appControlSession.laneId, laneId, lanes); @@ -305,8 +319,9 @@ export function WorkSidebar({ } return null; } - const laneMismatchReason = resolveLaneMismatchReason(); + const toolAttributionReason = resolveToolAttributionReason(); const contextDisabledReason = targetDisabledReason; + const warningReason = toolAttributionReason ?? contextDisabledReason; const canInsertContext = Boolean(contextTarget && !contextDisabledReason); const panelSessionId = contextTarget?.kind === "chat" ? contextTarget.sessionId : null; @@ -424,8 +439,7 @@ export function WorkSidebar({ if (tab === "browser") { return (
- {contextDisabledReason ? : null} - {laneMismatchReason ? : null} + {warningReason ? : null}
- {contextDisabledReason ? : null} - {laneMismatchReason ? : null} + {warningReason ? : null}
{panel}
); @@ -532,10 +546,9 @@ export function WorkSidebar({ addIosContext, panelSessionId, canInsertContext, - contextDisabledReason, insertDraft, laneId, - laneMismatchReason, + warningReason, laneRoot, navigate, selectedCommit, diff --git a/apps/desktop/src/shared/types/appControl.ts b/apps/desktop/src/shared/types/appControl.ts index 5eecc0f48..8e09084df 100644 --- a/apps/desktop/src/shared/types/appControl.ts +++ b/apps/desktop/src/shared/types/appControl.ts @@ -40,6 +40,11 @@ export type AppControlStatus = { }>; }; +export type AppControlClaimArgs = { + laneId?: string | null; + chatSessionId?: string | null; +}; + export type AppControlLaunchArgs = { appKind?: AppControlAppKind | null; projectRoot?: string | null; diff --git a/apps/desktop/src/shared/types/builtInBrowser.ts b/apps/desktop/src/shared/types/builtInBrowser.ts index 5880a287a..204748e19 100644 --- a/apps/desktop/src/shared/types/builtInBrowser.ts +++ b/apps/desktop/src/shared/types/builtInBrowser.ts @@ -16,7 +16,12 @@ export type BuiltInBrowserAttachWebviewArgs = { webContentsId: number; }; -export type BuiltInBrowserNavigateArgs = { +export type BuiltInBrowserClaimArgs = { + laneId?: string | null; + chatSessionId?: string | null; +}; + +export type BuiltInBrowserNavigateArgs = BuiltInBrowserClaimArgs & { url: string; tabId?: string | null; newTab?: boolean; @@ -46,20 +51,23 @@ export type BuiltInBrowserStatus = { canGoForward: boolean; isInspecting: boolean; hasSelection: boolean; + ownerLaneId: string | null; + ownerChatSessionId: string | null; + ownerClaimedAt: string | null; }; -export type BuiltInBrowserTabArgs = { +export type BuiltInBrowserTabArgs = BuiltInBrowserClaimArgs & { tabId: string; openPanel?: boolean; }; -export type BuiltInBrowserCreateTabArgs = { +export type BuiltInBrowserCreateTabArgs = BuiltInBrowserClaimArgs & { url?: string | null; activate?: boolean; openPanel?: boolean; }; -export type BuiltInBrowserOpenPanelArgs = { +export type BuiltInBrowserOpenPanelArgs = BuiltInBrowserClaimArgs & { url?: string | null; tabId?: string | null; }; diff --git a/apps/desktop/src/shared/types/iosSimulator.ts b/apps/desktop/src/shared/types/iosSimulator.ts index 6f627ae10..f4a4c4306 100644 --- a/apps/desktop/src/shared/types/iosSimulator.ts +++ b/apps/desktop/src/shared/types/iosSimulator.ts @@ -70,6 +70,11 @@ export type IosSimulatorListLaunchTargetsArgs = { projectRoot?: string | null; }; +export type IosSimulatorClaimArgs = { + laneId?: string | null; + chatSessionId?: string | null; +}; + export type IosSimulatorLaunchArgs = { deviceUdid?: string | null; projectRoot?: string | null; @@ -103,6 +108,7 @@ export type IosSimulatorSession = { keepSimulatorInBackground?: boolean | null; bridgeUrl: string | null; startedAt: string; + claimedAt: string | null; }; export type IosSimulatorScreenshot = { From e3978724368ef2019845efff8489b14ed923b983 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 27 May 2026 15:15:49 -0400 Subject: [PATCH 3/4] Address claim command review feedback --- apps/ade-cli/src/cli.test.ts | 23 +++++++++++++++++++ apps/ade-cli/src/cli.ts | 17 +++++++++++--- .../agent-skills/ade-browser/SKILL.md | 2 +- 3 files changed, 38 insertions(+), 4 deletions(-) diff --git a/apps/ade-cli/src/cli.test.ts b/apps/ade-cli/src/cli.test.ts index 30f2a1340..6b9c4c30b 100644 --- a/apps/ade-cli/src/cli.test.ts +++ b/apps/ade-cli/src/cli.test.ts @@ -3002,6 +3002,29 @@ describe("ADE CLI", () => { } }); + it("tool claim commands require an explicit or ADE-provided lane", () => { + const previousLane = process.env.ADE_LANE_ID; + const previousChat = process.env.ADE_CHAT_SESSION_ID; + try { + delete process.env.ADE_LANE_ID; + delete process.env.ADE_CHAT_SESSION_ID; + + expect(() => buildCliPlan(["ios-sim", "claim"])).toThrow(/requires --lane/); + expect(() => buildCliPlan(["app-control", "claim"])).toThrow(/requires --lane/); + expect(() => buildCliPlan(["browser", "claim"])).toThrow(/requires --lane/); + + process.env.ADE_LANE_ID = "lane-env-1"; + expect(buildCliPlan(["ios-sim", "claim"]).kind).toBe("execute"); + expect(buildCliPlan(["app-control", "claim"]).kind).toBe("execute"); + expect(buildCliPlan(["browser", "claim"]).kind).toBe("execute"); + } finally { + if (previousLane === undefined) delete process.env.ADE_LANE_ID; + else process.env.ADE_LANE_ID = previousLane; + if (previousChat === undefined) delete process.env.ADE_CHAT_SESSION_ID; + else process.env.ADE_CHAT_SESSION_ID = previousChat; + } + }); + it("ios-sim inspect requires both coordinates and forwards them", () => { expect(() => buildCliPlan(["ios-sim", "inspect"])).toThrow(/--x|--y/); const plan = buildCliPlan([ diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index 63041d914..bbc55cf30 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -1820,6 +1820,14 @@ function readToolClaimArgs(args: string[]): ToolClaimArgs { }; } +function readRequiredToolClaimArgs(args: string[], label: string): ToolClaimArgs { + const claimArgs = readToolClaimArgs(args); + if (!claimArgs.laneId) { + throw new CliUsageError(`${label} claim requires --lane or ADE_LANE_ID.`); + } + return claimArgs; +} + function readPrId(args: string[]): string | null { return readValue(args, ["--pr", "--pr-id"]) ?? null; } @@ -5615,6 +5623,7 @@ function buildIosSimulatorPlan(args: string[]): CliPlan { ], }; if (sub === "claim") { + const claimArgs = readRequiredToolClaimArgs(args, "iOS simulator"); return { kind: "execute", label: "iOS simulator claim", @@ -5623,7 +5632,7 @@ function buildIosSimulatorPlan(args: string[]): CliPlan { "result", "ios_simulator", "claim", - collectGenericObjectArgs(args, readToolClaimArgs(args)), + collectGenericObjectArgs(args, claimArgs), ), ], }; @@ -6115,6 +6124,7 @@ function buildAppControlPlan(args: string[]): CliPlan { }; } if (sub === "claim") { + const claimArgs = readRequiredToolClaimArgs(args, "App Control"); return { kind: "execute", label: "App Control claim", @@ -6123,7 +6133,7 @@ function buildAppControlPlan(args: string[]): CliPlan { "result", "app_control", "claim", - collectGenericObjectArgs(args, readToolClaimArgs(args)), + collectGenericObjectArgs(args, claimArgs), ), ], }; @@ -7019,6 +7029,7 @@ function buildBrowserPlan(args: string[]): CliPlan { }; } if (sub === "claim") { + const claimArgs = readRequiredToolClaimArgs(args, "browser"); return { kind: "execute", label: "browser claim", @@ -7027,7 +7038,7 @@ function buildBrowserPlan(args: string[]): CliPlan { "result", "built_in_browser", "claim", - collectGenericObjectArgs(args, readToolClaimArgs(args)), + collectGenericObjectArgs(args, claimArgs), ), ], }; diff --git a/apps/desktop/resources/agent-skills/ade-browser/SKILL.md b/apps/desktop/resources/agent-skills/ade-browser/SKILL.md index 5870b4ba0..41e5d2fea 100644 --- a/apps/desktop/resources/agent-skills/ade-browser/SKILL.md +++ b/apps/desktop/resources/agent-skills/ade-browser/SKILL.md @@ -14,7 +14,7 @@ The Work tools attribution is claim-based: `ade browser open`, `panel`, `new-tab The CLI does not own the browser pane. `BuiltInBrowserService` lives in Electron main because it owns a `WebContentsView`, so the runtime daemon (`ade serve`, which runs under `ELECTRON_RUN_AS_NODE=1` with no Electron APIs) can't host it directly. -Calls travel: CLI → runtime daemon (`~/.ade/sock/ade.sock`) → desktop bridge socket (`/sock/desktop-bridge.sock`) → real `BuiltInBrowserService` in Electron main → response back. The runtime registers a lazy JSON-RPC proxy whose allowlisted methods (`getStatus`, `showPanel`, `setBounds`, `navigate`, `createTab`, `switchTab`, `closeTab`, `reload`, `goBack`, `goForward`, `stop`, `startInspect`, `stopInspect`, `captureScreenshot`, `selectPoint`, `selectCurrent`, `clearSelection`) forward over the bridge. +Calls travel: CLI → runtime daemon (`~/.ade/sock/ade.sock`) → desktop bridge socket (`/sock/desktop-bridge.sock`) → real `BuiltInBrowserService` in Electron main → response back. The runtime registers a lazy JSON-RPC proxy whose allowlisted methods (`getStatus`, `showPanel`, `setBounds`, `navigate`, `createTab`, `switchTab`, `closeTab`, `reload`, `goBack`, `goForward`, `stop`, `startInspect`, `stopInspect`, `captureScreenshot`, `selectPoint`, `selectCurrent`, `clearSelection`, `claim`) forward over the bridge. Requirement: ADE Desktop must be running with a project open. Without it, calls fail with `Desktop browser bridge not running at . Open ADE Desktop with a project to enable \`ade browser\` commands.` — that's the headless case, not a bug. Other runtime domains keep working. From e8920a1a40e2433e7abea85bb3bb31ad904cd122 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 27 May 2026 15:22:32 -0400 Subject: [PATCH 4/4] Fix rebase follow-up validation issues --- .../components/chat/AgentChatPane.test.tsx | 16 ++++++++-------- .../renderer/components/chat/AgentChatPane.tsx | 6 ------ 2 files changed, 8 insertions(+), 14 deletions(-) diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx index 2ddf8747e..67610df25 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx @@ -3007,7 +3007,7 @@ describe("AgentChatPane submit recovery", () => { const textbox = await screen.findByRole("textbox"); fireEvent.change(textbox, { target: { value: "Launch this in the background." } }); - fireEvent.click(await screen.findByRole("button", { name: "Launch in background" })); + fireEvent.click(await screen.findByRole("button", { name: "Auto-create in background" })); await waitFor(() => { expect(onSessionCreated).toHaveBeenCalledWith( @@ -3339,13 +3339,13 @@ describe("AgentChatPane submit recovery", () => { const textbox = await screen.findByRole("textbox"); fireEvent.change(textbox, { target: { value: "Launch this and let me keep typing." } }); - fireEvent.click(await screen.findByRole("button", { name: "Launch in background" })); + fireEvent.click(await screen.findByRole("button", { name: "Auto-create in background" })); await waitFor(() => { expect(suggestLaneName).toHaveBeenCalled(); expect(screen.getByText(/Creating lane for chat/i)).toBeTruthy(); expect((screen.getByRole("button", { name: "Send" }) as HTMLButtonElement).disabled).toBe(true); - expect((screen.getByRole("button", { name: "Launch in background" }) as HTMLButtonElement).disabled).toBe(true); + expect((screen.getByRole("button", { name: "Auto-create in background" }) as HTMLButtonElement).disabled).toBe(true); }); expect((textbox as HTMLTextAreaElement).disabled).toBe(false); expect((textbox as HTMLTextAreaElement).value).toBe(""); @@ -3353,7 +3353,7 @@ describe("AgentChatPane submit recovery", () => { fireEvent.change(textbox, { target: { value: "Next thought while it launches." } }); expect((textbox as HTMLTextAreaElement).value).toBe("Next thought while it launches."); expect((screen.getByRole("button", { name: "Send" }) as HTMLButtonElement).disabled).toBe(false); - expect((screen.getByRole("button", { name: "Launch in background" }) as HTMLButtonElement).disabled).toBe(false); + expect((screen.getByRole("button", { name: "Auto-create in background" }) as HTMLButtonElement).disabled).toBe(false); resolveSuggestedName("still-editable-lane"); await waitFor(() => { @@ -3383,7 +3383,7 @@ describe("AgentChatPane submit recovery", () => { const textbox = await screen.findByRole("textbox"); for (let index = 1; index <= 9; index += 1) { fireEvent.change(textbox, { target: { value: `Launch background chat ${index}.` } }); - fireEvent.click(await screen.findByRole("button", { name: "Launch in background" })); + fireEvent.click(await screen.findByRole("button", { name: "Auto-create in background" })); await waitFor(() => { expect(suggestLaneName).toHaveBeenCalledTimes(index); }); @@ -3422,14 +3422,14 @@ describe("AgentChatPane submit recovery", () => { const textbox = await screen.findByRole("textbox"); fireEvent.change(textbox, { target: { value: "First auto lane." } }); - fireEvent.click(await screen.findByRole("button", { name: "Launch in background" })); + fireEvent.click(await screen.findByRole("button", { name: "Auto-create in background" })); await waitFor(() => { expect(suggestLaneName).toHaveBeenCalledTimes(1); expect((textbox as HTMLTextAreaElement).value).toBe(""); }); fireEvent.change(textbox, { target: { value: "Second auto lane." } }); - fireEvent.click(await screen.findByRole("button", { name: "Launch in background" })); + fireEvent.click(await screen.findByRole("button", { name: "Auto-create in background" })); await waitFor(() => { expect(suggestLaneName).toHaveBeenCalledTimes(2); expect(screen.getAllByText(/Creating lane for chat/i)).toHaveLength(2); @@ -3714,7 +3714,7 @@ describe("AgentChatPane submit recovery", () => { const textbox = await screen.findByRole("textbox"); fireEvent.change(textbox, { target: { value: "Launch this CLI session in the background." } }); - fireEvent.click(await screen.findByRole("button", { name: "Launch in background" })); + fireEvent.click(await screen.findByRole("button", { name: "Auto-create in background" })); await waitFor(() => { expect(onLaunchCliSession).toHaveBeenCalledWith(expect.objectContaining({ diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index fa4c4328d..042512f6f 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -2717,12 +2717,6 @@ export function AgentChatPane({ const lane = lanes.find((entry) => entry.id === scopedLaneId); return lane?.worktreePath ?? projectRoot; }, [laneId, lanes, projectRoot, selectedSession?.laneId]); - const activeLaneWorktreePath = useMemo(() => { - if (!laneId) return projectRoot; - const lane = lanes.find((entry) => entry.id === laneId); - return lane?.worktreePath ?? projectRoot; - }, [laneId, lanes, projectRoot]); - const selectedEvents = selectedSessionId ? eventsBySession[selectedSessionId] ?? EMPTY_CHAT_EVENTS : EMPTY_CHAT_EVENTS; const optimisticOutgoingMessageRef = useRef(null); const selectedEventsForDisplay = useMemo(() => {