diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index ef5e416cc..f701e4ebf 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -11632,6 +11632,53 @@ describe("createAgentChatService", () => { expect(persisted.reasoningEffort).toBe("xhigh"); }); + it("applies fresh Codex thread effective sandbox when it differs from requested flags", async () => { + vi.mocked(mapPermissionToCodex).mockImplementation((mode) => { + if (mode === "default") return { approvalPolicy: "on-request", sandbox: "workspace-write" }; + return { approvalPolicy: "on-request", sandbox: "read-only" }; + }); + mockState.codexResponseOverrides.set("thread/start", () => ({ + thread: { id: "thread-effective-start-readonly" }, + approvalPolicy: "onRequest", + sandbox: "read-only", + })); + + const { service } = createService(); + const session = await service.createSession({ + laneId: "lane-1", + provider: "codex", + model: "gpt-5.4", + permissionMode: "default", + }); + + await service.sendMessage({ + sessionId: session.id, + text: "Inspect the repo.", + }); + + await vi.waitFor(() => { + expect(mockState.codexRequestPayloads.some((payload) => payload.method === "turn/start")).toBe(true); + }); + + const threadStartRequest = mockState.codexRequestPayloads.find((payload) => payload.method === "thread/start"); + const threadStartParams = threadStartRequest?.params as { approvalPolicy?: unknown; sandbox?: unknown } | undefined; + expect(threadStartParams?.approvalPolicy).toBe("on-request"); + expect(threadStartParams?.sandbox).toBe("workspace-write"); + + const turnStartRequest = mockState.codexRequestPayloads.find((payload) => payload.method === "turn/start"); + const turnStartParams = turnStartRequest?.params as { + approvalPolicy?: unknown; + sandboxPolicy?: { type?: unknown }; + } | undefined; + expect(turnStartParams?.approvalPolicy).toBe("on-request"); + expect(turnStartParams?.sandboxPolicy?.type).toBe("readOnly"); + + const summary = await service.getSessionSummary(session.id); + expect(summary?.codexApprovalPolicy).toBe("on-request"); + expect(summary?.codexSandbox).toBe("read-only"); + expect(summary?.permissionMode).toBe("plan"); + }); + it("re-resumes Codex threads when permission mode changes mid-session", async () => { vi.mocked(mapPermissionToCodex).mockImplementation((mode) => { if (mode === "full-auto") { @@ -11675,6 +11722,11 @@ describe("createAgentChatService", () => { }); mockState.codexRequestPayloads = []; + mockState.codexResponseOverrides.set("thread/resume", () => ({ + thread: { id: "thread-after-mode-switch" }, + approvalPolicy: "onRequest", + sandbox: "read-only", + })); await service.updateSession({ sessionId: session.id, @@ -12141,7 +12193,7 @@ describe("createAgentChatService", () => { expect(sessionService.reopen).toHaveBeenCalledWith(session.id); }); - it("keeps requested Codex reasoning effort while applying effective policy on resume", async () => { + it("keeps requested Codex policy and reasoning effort across resume", async () => { mockState.codexResponseOverrides.set("thread/resume", () => ({ thread: { id: "thread-effective-resume" }, approvalPolicy: "onFailure", @@ -12175,15 +12227,15 @@ describe("createAgentChatService", () => { expect(resumeParams?.effort).toBe("xhigh"); expect(resumeParams?.reasoningEffort).toBeUndefined(); expect(resumeParams?.reasoning_effort).toBeUndefined(); - expect(resumed.codexApprovalPolicy).toBe("on-failure"); - expect(resumed.codexSandbox).toBe("workspace-write"); - expect(resumed.permissionMode).toBe("default"); + expect(resumed.codexApprovalPolicy).toBe("never"); + expect(resumed.codexSandbox).toBe("danger-full-access"); + expect(resumed.permissionMode).toBe("full-auto"); expect(resumed.reasoningEffort).toBe("xhigh"); const persistedAfter = readPersistedChatState(session.id); expect(persistedAfter.threadId).toBe("thread-effective-resume"); - expect(persistedAfter.codexApprovalPolicy).toBe("on-failure"); - expect(persistedAfter.codexSandbox).toBe("workspace-write"); + expect(persistedAfter.codexApprovalPolicy).toBe("never"); + expect(persistedAfter.codexSandbox).toBe("danger-full-access"); expect(persistedAfter.reasoningEffort).toBe("xhigh"); }); diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index 4b522b061..7aae46338 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -3465,10 +3465,26 @@ function normalizeCodexRuntimeSandbox(value: unknown): AgentChatCodexSandbox | u return typeof type === "string" ? CODEX_SANDBOX_CAMEL_CASE_ALIASES[type] : undefined; } +function shouldPreserveRequestedCodexPolicy( + requested: { approvalPolicy: AgentChatCodexApprovalPolicy; sandbox: AgentChatCodexSandbox } | null, + runtime: { approvalPolicy?: AgentChatCodexApprovalPolicy; sandbox?: AgentChatCodexSandbox }, +): boolean { + if (!requested) return false; + // Resume responses can echo stale thread policy from before ADE re-sent the + // picker/config flags. Fresh starts still adopt the runtime-reported policy. + if (runtime.sandbox && runtime.sandbox !== requested.sandbox) return true; + if (!runtime.approvalPolicy || runtime.approvalPolicy === requested.approvalPolicy) return false; + return requested.approvalPolicy === "never" || requested.approvalPolicy === "untrusted"; +} + function applyCodexEffectiveThreadState( managed: ManagedChatSession, response: CodexThreadLifecycleResponse | null | undefined, options: { + requestedCodexPolicy?: { + approvalPolicy: AgentChatCodexApprovalPolicy; + sandbox: AgentChatCodexSandbox; + } | null; requestedReasoningEffort?: string | null; onReasoningMismatch?: (mismatch: { requestedReasoningEffort: string; @@ -3478,16 +3494,25 @@ function applyCodexEffectiveThreadState( ): void { if (!response) return; + const requestedCodexPolicy = options.requestedCodexPolicy ?? null; const approvalPolicy = normalizePersistedCodexApprovalPolicy(response.approvalPolicy); - if (approvalPolicy) { - managed.session.codexApprovalPolicy = approvalPolicy; - } - const sandbox = normalizeCodexRuntimeSandbox(response.sandbox); - if (sandbox) { - managed.session.codexSandbox = sandbox; + const runtime = { + ...(approvalPolicy ? { approvalPolicy } : {}), + ...(sandbox ? { sandbox } : {}), + }; + const preserveRequestedPolicy = shouldPreserveRequestedCodexPolicy(requestedCodexPolicy, runtime); + + if (preserveRequestedPolicy && requestedCodexPolicy) { + managed.session.codexApprovalPolicy = requestedCodexPolicy.approvalPolicy; + managed.session.codexSandbox = requestedCodexPolicy.sandbox; + } else { + if (approvalPolicy) managed.session.codexApprovalPolicy = approvalPolicy; + if (sandbox) managed.session.codexSandbox = sandbox; } + managed.session.permissionMode = syncLegacyPermissionMode(managed.session) ?? managed.session.permissionMode; + const reasoningEffort = validateReasoningEffort( "codex", normalizeReasoningEffort( @@ -19258,6 +19283,7 @@ export function createAgentChatService(args: { persistExtendedHistory: true }); applyCodexEffectiveThreadState(managed, resumeResponse, { + requestedCodexPolicy: codexPolicy, requestedReasoningEffort: resumeReasoningEffort, onReasoningMismatch: (mismatch) => logger.warn("agent_chat.codex_reasoning_runtime_mismatch", { sessionId: managed.session.id, @@ -20246,6 +20272,7 @@ export function createAgentChatService(args: { persistExtendedHistory: true }); applyCodexEffectiveThreadState(managed, resumeResponse, { + requestedCodexPolicy: codexPolicy, requestedReasoningEffort: managed.session.reasoningEffort, onReasoningMismatch: (mismatch) => logger.warn("agent_chat.codex_reasoning_runtime_mismatch", { sessionId: managed.session.id, diff --git a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx index 985b3e51c..c127d5bac 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx @@ -1515,6 +1515,84 @@ describe("AgentChatMessageList transcript rendering", () => { // "renders structured question blocks" tests removed: tested specific // CSS classes and rendering details that change with UI iterations. + it("renders completed Codex plan markdown without requiring expansion", () => { + renderMessageList([ + { + sessionId: "session-1", + timestamp: "2026-03-17T10:00:00.000Z", + event: { + type: "plan", + itemId: "plan-1", + turnId: "turn-1", + state: "complete", + steps: [], + streamingText: [ + "# Plan", + "", + "- Inspect the app-server wiring.", + "- Patch the native plan handoff.", + ].join("\n"), + }, + }, + ]); + + expect(screen.getByText("Plan")).toBeTruthy(); + expect(screen.getByText("Inspect the app-server wiring.")).toBeTruthy(); + expect(screen.getByText("Patch the native plan handoff.")).toBeTruthy(); + }); + + it("does not duplicate completed Codex plan markdown when structured steps exist", () => { + renderMessageList([ + { + sessionId: "session-1", + timestamp: "2026-03-17T10:00:00.000Z", + event: { + type: "plan", + itemId: "plan-1", + turnId: "turn-1", + state: "complete", + steps: [{ text: "Inspect once", status: "pending" }], + streamingText: "- Inspect once", + }, + }, + ]); + + expect(screen.getAllByText("Inspect once")).toHaveLength(1); + }); + + it("renders plan approval request bodies in the transcript", () => { + renderMessageList([ + { + sessionId: "session-1", + timestamp: "2026-03-17T10:00:00.000Z", + event: { + type: "approval_request", + itemId: "approval-plan", + kind: "tool_call", + description: "Plan ready for approval", + turnId: "turn-1", + detail: { + request: { + requestId: "approval-plan", + itemId: "approval-plan", + source: "codex", + kind: "plan_approval", + title: "Plan Ready for Review", + description: "# Plan\n\n- Show the plan body.", + questions: [], + allowsFreeform: true, + blocking: true, + canProceedWithoutAnswer: false, + }, + }, + }, + }, + ]); + + expect(screen.getByText("Presenting plan for approval")).toBeTruthy(); + expect(screen.getByText("Show the plan body.")).toBeTruthy(); + }); + it("renders structured ask-user requests inline and submits option answers", () => { const onApproval = vi.fn(); renderMessageList([ diff --git a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx index 241f63f4b..01449f50d 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx @@ -2817,6 +2817,11 @@ function renderEvent( )} + {isPlanApproval && bodyText.trim().length > 0 ? ( +
+ +
+ ) : null} ); } diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.submit.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.submit.test.tsx index 63d64bfbb..116f84e20 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.submit.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.submit.test.tsx @@ -708,8 +708,10 @@ function renderAutoCreateDraftPane(args?: { ) => void | Promise; workDraftKind?: "chat" | "cli"; onLaunchCliSession?: React.ComponentProps["onLaunchCliSession"]; + onLaneChange?: React.ComponentProps["onLaneChange"]; + lanes?: any[]; }) { - const lanes = [ + const lanes = args?.lanes ?? [ { id: "lane-primary", name: "Primary", @@ -745,7 +747,7 @@ function renderAutoCreateDraftPane(args?: { embeddedWorkLayout workDraftKind={args?.workDraftKind} availableLanes={lanes} - onLaneChange={vi.fn()} + onLaneChange={args?.onLaneChange ?? vi.fn()} onSessionCreated={args?.onSessionCreated} onLaunchCliSession={args?.onLaunchCliSession} /> @@ -1484,6 +1486,9 @@ describe("AgentChatPane submit recovery", () => { renderPane(session); expect(await screen.findByRole("button", { name: "Implement" })).toBeTruthy(); + expect((await screen.findAllByText("Inspect")).length).toBeGreaterThan(0); + expect(screen.getAllByText("Patch").length).toBeGreaterThan(0); + expect(screen.getAllByText("Verify").length).toBeGreaterThan(0); const textbox = screen.getByRole("textbox") as HTMLTextAreaElement; expect(textbox.disabled).toBe(false); @@ -2843,6 +2848,77 @@ describe("AgentChatPane submit recovery", () => { }); }); + it("routes Work sidebar draft insertions into the visible draft composer", async () => { + installAdeMocks({ sessions: [] }); + + render( + + + , + ); + + const textbox = await screen.findByRole("textbox") as HTMLTextAreaElement; + window.dispatchEvent(new CustomEvent("ade:agent-chat:insert-draft", { + detail: { + draftTargetId: "work:draft:lane-other:chat", + text: "wrong draft", + }, + })); + expect(textbox.value).toBe(""); + + window.dispatchEvent(new CustomEvent("ade:agent-chat:insert-draft", { + detail: { + draftTargetId: "work:draft:lane-1:chat", + text: "Use the selected browser context.", + }, + })); + + await waitFor(() => { + expect(textbox.value).toBe("Use the selected browser context."); + }); + }); + + it("keeps Auto-create selected while reporting Primary as the Work tools lane", async () => { + installAdeMocks({ sessions: [] }); + const onLaneChange = vi.fn(); + renderAutoCreateDraftPane({ onLaneChange }); + + fireEvent.click(await screen.findByRole("button", { name: "Select lane" })); + fireEvent.click(await screen.findByRole("button", { name: /Auto-create lane/i })); + + expect(onLaneChange).toHaveBeenCalledWith("lane-primary"); + expect(await screen.findByText("Auto-create lane")).toBeTruthy(); + expect(await screen.findByText("Tools use Primary until the lane is created.")).toBeTruthy(); + }); + + it("falls back to the first available Work tools lane when Auto-create has no Primary lane", async () => { + installAdeMocks({ sessions: [] }); + const onLaneChange = vi.fn(); + renderAutoCreateDraftPane({ + onLaneChange, + lanes: [ + { + id: "lane-worktree", + name: "current-lane", + laneType: "worktree", + branchRef: "refs/heads/current-lane", + worktreePath: "/tmp/project-under-test/current-lane", + }, + ], + }); + + fireEvent.click(await screen.findByRole("button", { name: "Select lane" })); + fireEvent.click(await screen.findByRole("button", { name: /Auto-create lane/i })); + + expect(onLaneChange).toHaveBeenCalledWith("lane-worktree"); + expect(await screen.findByText("Tools use current-lane until the lane is created.")).toBeTruthy(); + }); + 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: [] }); diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index ff33ac447..e6e62972e 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -1839,6 +1839,7 @@ export function AgentChatPane({ isTileActive = true, isTileVisible = isTileActive, shouldAutofocusComposer = false, + draftContextTargetId = null, initialLinearIssueContext = null, initialLinearIssueContextSource = "lane_link", initialModelId = null, @@ -1873,6 +1874,8 @@ export function AgentChatPane({ /** Visible grid tiles hydrate transcripts even when they are not the focused tile. */ isTileVisible?: boolean; shouldAutofocusComposer?: boolean; + /** Stable Work-sidebar target id for an unsaved draft composer. */ + draftContextTargetId?: string | null; initialLinearIssueContext?: LaneLinearIssue | null; initialLinearIssueContextSource?: "manual" | "lane_link"; initialModelId?: string | null; @@ -4621,37 +4624,52 @@ export function AgentChatPane({ const matchesThisChat = (sessionId: unknown): boolean => ( typeof sessionId === "string" && sessionId === selectedSessionIdRef.current ); + const matchesThisDraft = (targetId: unknown): boolean => ( + selectedSessionIdRef.current == null + && forceDraft + && typeof targetId === "string" + && targetId === draftContextTargetId + ); + const matchesThisComposer = (detail: { sessionId?: unknown; draftTargetId?: unknown } | undefined): boolean => ( + matchesThisChat(detail?.sessionId) || matchesThisDraft(detail?.draftTargetId) + ); + + type ComposerEventDetail = { sessionId?: unknown; draftTargetId?: unknown; [key: string]: unknown }; + const composerDetail = (event: Event): ComposerEventDetail | undefined => { + const detail = (event as CustomEvent).detail; + return matchesThisComposer(detail) ? detail : undefined; + }; const onAddAttachment = (event: Event) => { - const detail = (event as CustomEvent<{ sessionId?: unknown; attachment?: unknown }>).detail; - if (!matchesThisChat(detail?.sessionId)) return; + const detail = composerDetail(event); + if (!detail) return; const attachment = detail.attachment as AgentChatFileRef | undefined; if (!attachment?.path) return; addAttachment(attachment); }; const onInsertDraft = (event: Event) => { - const detail = (event as CustomEvent<{ sessionId?: unknown; text?: unknown }>).detail; - if (!matchesThisChat(detail?.sessionId) || typeof detail.text !== "string") return; + const detail = composerDetail(event); + if (!detail || typeof detail.text !== "string") return; insertComposerDraft(detail.text); }; const onAddIosContext = (event: Event) => { - const detail = (event as CustomEvent<{ sessionId?: unknown; item?: unknown }>).detail; - if (!matchesThisChat(detail?.sessionId) || !detail.item) return; + const detail = composerDetail(event); + if (!detail?.item) return; addIosElementContext(detail.item as IosElementContextItem); }; const onAddAppControlContext = (event: Event) => { - const detail = (event as CustomEvent<{ sessionId?: unknown; item?: unknown }>).detail; - if (!matchesThisChat(detail?.sessionId) || !detail.item) return; + const detail = composerDetail(event); + if (!detail?.item) return; addAppControlContext(detail.item as AppControlContextItem); }; const onAddBuiltInBrowserContext = (event: Event) => { - const detail = (event as CustomEvent<{ sessionId?: unknown; item?: unknown }>).detail; - if (!matchesThisChat(detail?.sessionId) || !detail.item) return; + const detail = composerDetail(event); + if (!detail?.item) return; void addBuiltInBrowserContext(detail.item); }; const onAddMacosVmContext = (event: Event) => { - const detail = (event as CustomEvent<{ sessionId?: unknown; item?: unknown }>).detail; - if (!matchesThisChat(detail?.sessionId) || !detail.item) return; + const detail = composerDetail(event); + if (!detail?.item) return; addMacosVmContext(detail.item as MacosVmContextItem); }; // Plan-panel annotation events (goal.md §10.7). The popover composes an @@ -4685,7 +4703,16 @@ export function AgentChatPane({ window.removeEventListener("ade:agent-chat:add-macos-vm-context", onAddMacosVmContext); window.removeEventListener("ade:agent-chat:add-plan-annotation", onAddPlanAnnotation); }; - }, [addAppControlContext, addAttachment, addBuiltInBrowserContext, addIosElementContext, addMacosVmContext, insertComposerDraft]); + }, [ + addAppControlContext, + addAttachment, + addBuiltInBrowserContext, + addIosElementContext, + addMacosVmContext, + draftContextTargetId, + forceDraft, + insertComposerDraft, + ]); const removeAttachment = useCallback((attachmentPath: string) => { linkedIosAttachmentPathsRef.current.delete(attachmentPath); @@ -6527,15 +6554,22 @@ export function AgentChatPane({ : (availableLanes ?? []), [availableLanes, showDraftLaunchControls], ); + const primaryDraftLane = useMemo(() => ( + availableLanes?.find((candidate) => candidate.laneType === "primary") + ?? availableLanes?.find((candidate) => candidate.name.trim().toLowerCase() === "primary") + ?? null + ), [availableLanes]); + const autoCreateToolsLane = primaryDraftLane ?? availableLanes?.[0] ?? null; const draftLaneSelectorValue = draftLaunchTargetIsAutoCreate ? AUTO_CREATE_LANE_OPTION_ID : (laneId ?? ""); const handleDraftLaneSelectionChange = useCallback((nextLaneId: string) => { if (nextLaneId === AUTO_CREATE_LANE_OPTION_ID) { setDraftLaunchTargetId(AUTO_CREATE_LANE_OPTION_ID); + if (autoCreateToolsLane) onLaneChange?.(autoCreateToolsLane.id); return; } setDraftLaunchTargetId(null); onLaneChange?.(nextLaneId); - }, [onLaneChange]); + }, [autoCreateToolsLane, onLaneChange]); useEffect(() => { if (!showDraftLaunchControls && draftLaunchTargetId) { @@ -8135,31 +8169,38 @@ export function AgentChatPane({ className="flex justify-center" exit={{ opacity: 0, transition: { duration: 0.15 } }} > -
- - {onOpenShellSession ? ( - - - + + + ) : null} +
+ {draftLaunchTargetIsAutoCreate && autoCreateToolsLane ? ( +
+ Tools use {autoCreateToolsLane.name} until the lane is created. +
) : null} diff --git a/apps/desktop/src/renderer/components/chat/ChatIosSimulatorPanel.test.tsx b/apps/desktop/src/renderer/components/chat/ChatIosSimulatorPanel.test.tsx index b1277d4db..ce9a0c0f1 100644 --- a/apps/desktop/src/renderer/components/chat/ChatIosSimulatorPanel.test.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatIosSimulatorPanel.test.tsx @@ -285,10 +285,10 @@ function installIosSimulatorApi(options: { error: null, }), openPreviewWorkspace: vi.fn(), - tap: vi.fn(), - typeText: vi.fn(), - drag: vi.fn(), - swipe: vi.fn(), + tap: vi.fn().mockResolvedValue(undefined), + typeText: vi.fn().mockResolvedValue(undefined), + drag: vi.fn().mockResolvedValue(undefined), + swipe: vi.fn().mockResolvedValue(undefined), selectPoint: vi.fn().mockResolvedValue({ item: { kind: "ios_simulator_target", @@ -653,12 +653,71 @@ describe("ChatIosSimulatorPanel", () => { fireEvent.pointerDown(liveSurface, { clientX: 50, clientY: 40, pointerId: 1 }); fireEvent.pointerUp(liveSurface, { clientX: 50, clientY: 40, pointerId: 1 }); - expect(await screen.findByText("Another chat is already connected to the simulator. Use Take over to claim it.")).toBeTruthy(); + expect(await screen.findByText("Another chat is using the simulator")).toBeTruthy(); expect(api.typeText).not.toHaveBeenCalled(); expect(api.tap).not.toHaveBeenCalled(); expect(api.drag).not.toHaveBeenCalled(); }); + it("warns without renderer-blocking inspected context attachment when another chat owns the simulator", async () => { + vi.stubGlobal("PointerEvent", MouseEvent); + vi.stubGlobal("Image", class { + onload: (() => void) | null = null; + onerror: (() => void) | null = null; + set src(_value: string) { + queueMicrotask(() => this.onerror?.()); + } + }); + const onAddContext = vi.fn(); + const { api } = installIosSimulatorApi({ + status: { + ...activeStatus, + activeSession: { + ...activeStatus.activeSession!, + chatSessionId: "chat-2", + }, + }, + screenElements: [inspectElement], + }); + + render( + , + ); + + expect(await screen.findByText("Another chat is using the simulator")).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "Inspect" })); + const image = await screen.findByAltText("iOS Simulator snapshot") as HTMLImageElement; + const imageRect = { + x: 0, + y: 0, + left: 0, + top: 0, + right: 393, + bottom: 852, + width: 393, + height: 852, + toJSON: () => ({}), + } as DOMRect; + image.getBoundingClientRect = () => imageRect; + if (image.parentElement) { + image.parentElement.getBoundingClientRect = () => imageRect; + } + + fireEvent.pointerDown(image, { clientX: 50, clientY: 40 }); + + await waitFor(() => { + expect(api.selectPoint).toHaveBeenCalled(); + expect(onAddContext).toHaveBeenCalledWith(expect.objectContaining({ + label: "Continue", + source: "ade-inspector", + })); + }); + }); + it("selects an inspected simulator element and opens Preview Lab for its matching target", async () => { vi.stubGlobal("PointerEvent", MouseEvent); vi.stubGlobal("Image", class { diff --git a/apps/desktop/src/renderer/components/chat/ChatIosSimulatorPanel.tsx b/apps/desktop/src/renderer/components/chat/ChatIosSimulatorPanel.tsx index 52d2ce5b4..25c7d05a2 100644 --- a/apps/desktop/src/renderer/components/chat/ChatIosSimulatorPanel.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatIosSimulatorPanel.tsx @@ -1160,20 +1160,21 @@ export function ChatIosSimulatorPanel({ return owner !== sessionId ? owner : null; }, [activeSession?.chatSessionId, sessionId]); const ownedByOtherChat = otherChatSessionId !== null; - const controlsOwnedElsewhere = ownedByOtherChat || controlsDisabled; + const contextControlsBlocked = controlsDisabled; + const simulatorMutationBlocked = ownedByOtherChat || controlsDisabled; const inputBlockedMessage = ownedByOtherChat ? "Another chat is already connected to the simulator. Use Take over to claim it." : controlsDisabledMessage; const toggleSimulatorWindowMode = useCallback(() => { - if (!activeSession || controlsOwnedElsewhere) return; + if (!activeSession || contextControlsBlocked) return; const enable = simulatorWindowSessionId !== activeSession.id; liveDeviceStreamFailureTimestampsRef.current = []; setSimulatorWindowSessionId(enable ? activeSession.id : null); setMessage(enable ? "Opening Simulator.app and switching to window streaming..." : "Switching back to the headless simulator stream..."); - }, [activeSession, controlsOwnedElsewhere, simulatorWindowSessionId]); + }, [activeSession, contextControlsBlocked, simulatorWindowSessionId]); const changeMediaZoom = useCallback((delta: number) => { setMediaZoom((current) => { @@ -1487,7 +1488,7 @@ export function ChatIosSimulatorPanel({ }, [refreshStatus, startWindowCaptureVisual]); const retryAdeStream = useCallback(() => { - if (!activeDevice || !activeSession || activeSession.deviceUdid !== activeDevice.udid || controlsOwnedElsewhere) return; + if (!activeDevice || !activeSession || activeSession.deviceUdid !== activeDevice.udid || contextControlsBlocked) return; liveDeviceStreamFailureTimestampsRef.current = []; setSimulatorWindowSessionId(null); setMessage("Retrying ADE simulator stream..."); @@ -1504,7 +1505,7 @@ export function ChatIosSimulatorPanel({ setMessage(`ADE simulator stream failed. ${message}`); } })(); - }, [activeDevice, activeSession, controlsOwnedElsewhere, startDeviceBackedLiveVisual, stopRendererLiveVisual]); + }, [activeDevice, activeSession, contextControlsBlocked, startDeviceBackedLiveVisual, stopRendererLiveVisual]); useEffect(() => { const video = videoRef.current; @@ -2116,8 +2117,8 @@ export function ChatIosSimulatorPanel({ ]); const launch = useCallback(async (options: { previewTarget?: IosSimulatorPreviewTarget | null } = {}) => { - if (controlsDisabled) { - setMessage(controlsDisabledMessage); + if (simulatorMutationBlocked) { + setMessage(inputBlockedMessage); return; } const previewTarget = options.previewTarget ?? null; @@ -2156,7 +2157,7 @@ export function ChatIosSimulatorPanel({ setLaunchBusy(false); setBusy(false); } - }, [activeTarget?.id, controlsDisabled, controlsDisabledMessage, laneId, projectRoot, refreshSnapshot, refreshStatus, selectedDeviceUdid, selectedTargetId, sessionId]); + }, [activeTarget?.id, inputBlockedMessage, laneId, projectRoot, refreshSnapshot, refreshStatus, selectedDeviceUdid, selectedTargetId, sessionId, simulatorMutationBlocked]); useEffect(() => { launchRef.current = launch; @@ -2224,7 +2225,7 @@ export function ChatIosSimulatorPanel({ const sendTypedText = useCallback(async () => { const text = typedText; if (!text.trim()) return; - if (controlsOwnedElsewhere) { + if (simulatorMutationBlocked) { setMessage(inputBlockedMessage); return; } @@ -2235,7 +2236,7 @@ export function ChatIosSimulatorPanel({ } catch (error) { setMessage(error instanceof Error ? error.message : String(error)); } - }, [armWindowCaptureRecoveryAfterInput, controlsOwnedElsewhere, inputBlockedMessage, projectRoot, selectedDeviceUdid, typedText]); + }, [armWindowCaptureRecoveryAfterInput, inputBlockedMessage, projectRoot, selectedDeviceUdid, simulatorMutationBlocked, typedText]); const updateInspectBounds = useCallback(() => { const image = imageRef.current; @@ -2756,7 +2757,7 @@ export function ChatIosSimulatorPanel({ }, [liveVisualKind, mapLivePointToSimulatorPixel, mediaHeight, mediaWidth]); const handleSnapshotInteractPointerDown = useCallback((event: PointerEvent) => { - if (controlsOwnedElsewhere) { + if (simulatorMutationBlocked) { setMessage(inputBlockedMessage); return; } @@ -2769,13 +2770,13 @@ export function ChatIosSimulatorPanel({ clientY: event.clientY, }; event.currentTarget.setPointerCapture(event.pointerId); - }, [controlsOwnedElsewhere, inputBlockedMessage, liveSimulatorPointFromPointer]); + }, [inputBlockedMessage, liveSimulatorPointFromPointer, simulatorMutationBlocked]); const handleSnapshotInteractPointerUp = useCallback((event: PointerEvent) => { const start = dragStartRef.current; dragStartRef.current = null; if (!start) return; - if (controlsOwnedElsewhere) { + if (simulatorMutationBlocked) { setMessage(inputBlockedMessage); return; } @@ -2805,11 +2806,11 @@ export function ChatIosSimulatorPanel({ setMessage(error instanceof Error ? error.message : String(error)); } })(); - }, [armWindowCaptureRecoveryAfterInput, controlsOwnedElsewhere, inputBlockedMessage, liveSimulatorPointFromPointer, projectRoot, selectedDeviceUdid, snapshot?.screen.scale]); + }, [armWindowCaptureRecoveryAfterInput, inputBlockedMessage, liveSimulatorPointFromPointer, projectRoot, selectedDeviceUdid, simulatorMutationBlocked, snapshot?.screen.scale]); const handleVideoKeyDown = useCallback((event: KeyboardEvent) => { if (event.metaKey || event.ctrlKey || event.altKey) return; - if (controlsOwnedElsewhere) { + if (simulatorMutationBlocked) { setMessage(inputBlockedMessage); return; } @@ -2828,7 +2829,7 @@ export function ChatIosSimulatorPanel({ setMessage(error instanceof Error ? error.message : String(error)); }); } - }, [armWindowCaptureRecoveryAfterInput, controlsOwnedElsewhere, inputBlockedMessage, projectRoot, selectedDeviceUdid]); + }, [armWindowCaptureRecoveryAfterInput, inputBlockedMessage, projectRoot, selectedDeviceUdid, simulatorMutationBlocked]); const providerSummary = snapshot?.providers.map((provider) => { if (provider.source === "screenshot") return "screenshot"; @@ -2927,11 +2928,11 @@ export function ChatIosSimulatorPanel({ && (liveVisual.status !== "error" || (liveVisual.kind === "mjpeg" && Boolean(liveVisual.url))); const canShowSnapshot = mode === "inspect" && Boolean(snapshotImage); const hasActiveSession = Boolean(activeSession); - const interactionDisabled = controlsOwnedElsewhere || showSetupChecklist; + const interactionDisabled = simulatorMutationBlocked || showSetupChecklist; const canRetryDeviceBackedStream = mode === "interact" && liveVisualKind === "mjpeg" && hasActiveSession - && !controlsOwnedElsewhere + && !contextControlsBlocked && Boolean(activeDevice); const showStreamRecoveryActions = canRetryDeviceBackedStream && (liveVisual?.status === "reconnecting" || liveVisual?.status === "error"); @@ -2980,7 +2981,7 @@ export function ChatIosSimulatorPanel({ event.stopPropagation(); if (!simulatorWindowModeEnabled) toggleSimulatorWindowMode(); }} - disabled={!activeSession || controlsOwnedElsewhere || simulatorWindowModeEnabled} + disabled={!activeSession || contextControlsBlocked || simulatorWindowModeEnabled} > iOS window @@ -3080,7 +3081,7 @@ export function ChatIosSimulatorPanel({
{activeSurface === "simulator" ? "Simulator mode" : "Preview mode"}
- {activeSurface === "simulator" && hasActiveSession && !controlsOwnedElsewhere ? ( + {activeSurface === "simulator" && hasActiveSession && !contextControlsBlocked ? (
- {hasActiveSession && !controlsOwnedElsewhere ? ( + {hasActiveSession && !simulatorMutationBlocked ? ( - {hasActiveSession && !controlsOwnedElsewhere ? ( + {hasActiveSession && !simulatorMutationBlocked ? (
{!mediaExpanded ?
- {mode === "interact" && !controlsOwnedElsewhere && !showSetupChecklist ? ( + {mode === "interact" && !simulatorMutationBlocked && !showSetupChecklist ? (
) : null} - {mode === "interact" && !controlAvailable && !showSetupChecklist && !controlsOwnedElsewhere ? ( + {mode === "interact" && !controlAvailable && !showSetupChecklist && !simulatorMutationBlocked ? (
Install a supported full Xcode for native touch input, or idb + idb_companion for fallback tap, drag, and text.
diff --git a/apps/desktop/src/renderer/components/chat/ChatProposedPlanCard.tsx b/apps/desktop/src/renderer/components/chat/ChatProposedPlanCard.tsx index c0660c253..0ab27abb3 100644 --- a/apps/desktop/src/renderer/components/chat/ChatProposedPlanCard.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatProposedPlanCard.tsx @@ -1,6 +1,7 @@ import React, { useCallback, useState } from "react"; import { ListChecks, CopySimple } from "@phosphor-icons/react"; import { cn } from "../ui/cn"; +import { ChatMarkdown } from "./chatMarkdown"; import { ChatStatusGlyph } from "./chatStatusVisuals"; /* ── Types ── */ @@ -25,13 +26,14 @@ const ChatProposedPlanCard = React.memo(function ChatProposedPlanCard({ onReject, }: ChatProposedPlanCardProps) { const [copied, setCopied] = useState(false); - const bodyText = description ?? question ?? "The agent has prepared a plan."; + const bodyText = description?.trim() || question?.trim() || "The agent has prepared a plan."; const handleCopy = useCallback(() => { + if (!navigator.clipboard) return; void navigator.clipboard.writeText(bodyText).then(() => { setCopied(true); setTimeout(() => setCopied(false), 1500); - }); + }).catch(() => {}); }, [bodyText]); return ( @@ -48,8 +50,8 @@ const ChatProposedPlanCard = React.memo(function ChatProposedPlanCard({
-
- Review the plan in the right pane, then choose to start or keep refining. +
+ {bodyText}
diff --git a/apps/desktop/src/renderer/components/chat/codex/CodexPlanCard.tsx b/apps/desktop/src/renderer/components/chat/codex/CodexPlanCard.tsx index be633ed9d..50bdab005 100644 --- a/apps/desktop/src/renderer/components/chat/codex/CodexPlanCard.tsx +++ b/apps/desktop/src/renderer/components/chat/codex/CodexPlanCard.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import { motion } from "motion/react"; import type { AgentChatEvent, AgentChatPlanStep } from "../../../../shared/types"; import { cn } from "../../ui/cn"; +import { ChatMarkdown } from "../chatMarkdown"; type PlanEvent = Extract; @@ -11,6 +12,14 @@ type CodexPlanCardProps = { const VIOLET = "#A78BFA"; +function PlanMarkdown({ markdown }: { markdown: string }) { + return ( +
+ {markdown} +
+ ); +} + function PlanGlyph({ status }: { status: AgentChatPlanStep["status"] }) { if (status === "in_progress") { return ( @@ -36,13 +45,18 @@ function PlanGlyph({ status }: { status: AgentChatPlanStep["status"] }) { export function CodexPlanCard({ event }: CodexPlanCardProps) { const [liveOpen, setLiveOpen] = useState(false); + const steps = Array.isArray(event.steps) ? event.steps : []; const hasStreaming = Boolean(event.streamingText && event.streamingText.trim().length); - const stateLabel = event.state === "complete" - ? "Plan ready" - : event.state === "active" || event.state === "delta" - ? "Planning" - : "Plan"; + let stateLabel: string; + switch (event.state) { + case "complete": stateLabel = "Plan ready"; break; + case "active": + case "delta": stateLabel = "Planning"; break; + default: stateLabel = "Plan"; + } const streamingTrimmed = (event.streamingText ?? "").trim(); + const showCompletedMarkdownInline = hasStreaming && event.state === "complete" && !steps.length; + const showMarkdownToggle = hasStreaming && !showCompletedMarkdownInline; return ( {stateLabel} - {event.steps.length ? ( + {steps.length ? ( - {event.steps.filter((s) => s.status === "completed").length} + {steps.filter((s) => s.status === "completed").length} / - {event.steps.length} + {steps.length} ) : null}
@@ -71,9 +85,9 @@ export function CodexPlanCard({ event }: CodexPlanCardProps) {

) : null} - {event.steps.length ? ( + {steps.length ? (
    - {event.steps.map((step, idx) => { + {steps.map((step, idx) => { const isActive = step.status === "in_progress"; const isComplete = step.status === "completed"; return ( @@ -98,7 +112,13 @@ export function CodexPlanCard({ event }: CodexPlanCardProps) {
) : null} - {hasStreaming ? ( + {showCompletedMarkdownInline ? ( +
+ +
+ ) : null} + + {showMarkdownToggle ? (
) : null} - {hasStreaming && liveOpen ? ( + {showMarkdownToggle && liveOpen ? (
- {streamingTrimmed} +
) : null} diff --git a/apps/desktop/src/renderer/components/terminals/OrchestratorComposerEntry.test.tsx b/apps/desktop/src/renderer/components/terminals/OrchestratorComposerEntry.test.tsx deleted file mode 100644 index 64b855785..000000000 --- a/apps/desktop/src/renderer/components/terminals/OrchestratorComposerEntry.test.tsx +++ /dev/null @@ -1,28 +0,0 @@ -/* @vitest-environment jsdom */ - -/** - * Composer entry point for new orchestrator chats. The user picks - * "Orchestrator" from the AgentChatComposer prompt-box mode button. - * - * That flow resolves to `onShowDraftKind("chat-orchestrator")`. The - * subsequent draft submit calls `agentChat.create({ interactionMode: - * "orchestrator-lead", ... })` and `orchestration.runCreate({ laneId, - * leadSessionId })` — see `goal.md` §10.1 + §17 step 6. - */ - -import { describe, expect, it, vi } from "vitest"; - -/** - * Verifies the contract from the composer side: the prompt-box button asks - * TerminalsPage to switch the draft kind through this event. - */ -describe("composer New orchestrator chat entry contract", () => { - it("dispatches `ade:work:start-orchestrator-chat` when invoked", () => { - const onShowDraftKind = vi.fn(); - const handler = () => onShowDraftKind("chat-orchestrator"); - window.addEventListener("ade:work:start-orchestrator-chat", handler); - window.dispatchEvent(new CustomEvent("ade:work:start-orchestrator-chat")); - expect(onShowDraftKind).toHaveBeenCalledWith("chat-orchestrator"); - window.removeEventListener("ade:work:start-orchestrator-chat", handler); - }); -}); diff --git a/apps/desktop/src/renderer/components/terminals/TerminalsPage.test.tsx b/apps/desktop/src/renderer/components/terminals/TerminalsPage.test.tsx index 701f5813b..fec5f3013 100644 --- a/apps/desktop/src/renderer/components/terminals/TerminalsPage.test.tsx +++ b/apps/desktop/src/renderer/components/terminals/TerminalsPage.test.tsx @@ -3,7 +3,7 @@ import React from "react"; import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; -import type { AgentChatSession, LaneSummary } from "../../../shared/types"; +import type { AgentChatSession, LaneSummary, TerminalSessionSummary, TerminalToolType } from "../../../shared/types"; import type { AgentChatSessionCreatedOptions } from "../chat/AgentChatPane"; import { TerminalsPage } from "./TerminalsPage"; @@ -21,6 +21,34 @@ const workMocks = vi.hoisted(() => { createdAt: "2026-05-14T18:00:00.000Z", lastActivityAt: "2026-05-14T18:00:00.000Z", }); + const makeTerminalSession = ( + id: string, + laneId: string, + toolType: TerminalToolType, + overrides: Partial = {}, + ): TerminalSessionSummary => ({ + id, + laneId, + laneName: laneId === "lane-primary" ? "Primary" : "Background lane", + ptyId: toolType === "codex-chat" ? null : `pty-${id}`, + tracked: true, + pinned: false, + goal: null, + toolType, + title: id, + status: "running", + startedAt: "2026-05-14T18:00:00.000Z", + endedAt: null, + exitCode: null, + transcriptPath: "/tmp/transcript", + headShaStart: null, + headShaEnd: null, + lastOutputPreview: null, + summary: null, + runtimeState: "running", + resumeCommand: null, + ...overrides, + }); const laneStatus = { dirty: false, ahead: 0, behind: 0, remoteBehind: 0, rebaseInProgress: false }; const makeLane = (id: string, name: string, laneType: LaneSummary["laneType"] = "worktree"): LaneSummary => ({ id, @@ -117,11 +145,21 @@ const workMocks = vi.hoisted(() => { return { backgroundSession: makeChatSession("chat-background", "lane-background"), foregroundSession: makeChatSession("chat-foreground", "lane-primary"), - currentWork: baseWork, + baseWork, + currentWork: baseWork as any, fns, + makeTerminalSession, }; }); +const sidebarProps = vi.hoisted(() => ({ + latest: null as null | { + laneId: string | null; + contextTarget: unknown; + contextDisabledReason: string | null; + }, +})); + vi.mock("../../state/appStore", () => ({ useAppStore: (selector: (state: { selectedLaneId: string }) => T): T => selector({ selectedLaneId: "lane-primary" }), @@ -146,7 +184,14 @@ vi.mock("./SessionListPane", () => ({ })); vi.mock("./WorkSidebar", () => ({ - WorkSidebar: () =>
, + WorkSidebar: (props: { + laneId: string | null; + contextTarget: unknown; + contextDisabledReason: string | null; + }) => { + sidebarProps.latest = props; + return
; + }, })); vi.mock("./SessionContextMenu", () => ({ @@ -184,6 +229,8 @@ vi.mock("./WorkViewArea", () => ({ describe("TerminalsPage chat session activation", () => { afterEach(() => { cleanup(); + workMocks.currentWork = { ...workMocks.baseWork, closingPtyIds: new Set() }; + sidebarProps.latest = null; vi.clearAllMocks(); }); @@ -223,4 +270,74 @@ describe("TerminalsPage chat session activation", () => { expect(workMocks.fns.openSessionTab).toHaveBeenCalledWith("chat-foreground"); }); }); + + it("targets the visible Work draft when no saved session is active", async () => { + Object.defineProperty(window, "ade", { + configurable: true, + value: { builtInBrowser: { onEvent: vi.fn(() => vi.fn()) } }, + }); + workMocks.currentWork = { + ...workMocks.baseWork, + workSidebarOpen: true, + workSidebarTab: "browser", + draftLaneId: "lane-background", + draftKind: "chat", + closingPtyIds: new Set(), + }; + + render(); + + expect(await screen.findByTestId("work-sidebar")).toBeTruthy(); + expect(sidebarProps.latest).toEqual(expect.objectContaining({ + laneId: "lane-background", + contextDisabledReason: null, + contextTarget: { + kind: "draft", + draftTargetId: "work:draft:lane-background:chat", + laneId: "lane-background", + draftKind: "chat", + }, + })); + }); + + it("targets active chat sessions and running agent CLI sessions", async () => { + Object.defineProperty(window, "ade", { + configurable: true, + value: { builtInBrowser: { onEvent: vi.fn(() => vi.fn()) } }, + }); + const chatSession = workMocks.makeTerminalSession("chat-1", "lane-primary", "codex-chat"); + workMocks.currentWork = { + ...workMocks.baseWork, + sessions: [chatSession], + visibleSessions: [chatSession], + activeItemId: "chat-1", + workSidebarOpen: true, + closingPtyIds: new Set(), + }; + + const { rerender } = render(); + + expect(await screen.findByTestId("work-sidebar")).toBeTruthy(); + expect(sidebarProps.latest?.contextTarget).toEqual({ kind: "chat", sessionId: "chat-1" }); + + const cliSession = workMocks.makeTerminalSession("term-codex", "lane-primary", "codex"); + workMocks.currentWork = { + ...workMocks.baseWork, + sessions: [cliSession], + visibleSessions: [cliSession], + activeItemId: "term-codex", + workSidebarOpen: true, + closingPtyIds: new Set(), + }; + + rerender(); + + expect(sidebarProps.latest?.contextTarget).toEqual({ + kind: "pty", + sessionId: "term-codex", + ptyId: "pty-term-codex", + toolType: "codex", + }); + expect(sidebarProps.latest?.contextDisabledReason).toBeNull(); + }); }); diff --git a/apps/desktop/src/renderer/components/terminals/TerminalsPage.tsx b/apps/desktop/src/renderer/components/terminals/TerminalsPage.tsx index 1e62b6231..489f5e45c 100644 --- a/apps/desktop/src/renderer/components/terminals/TerminalsPage.tsx +++ b/apps/desktop/src/renderer/components/terminals/TerminalsPage.tsx @@ -11,7 +11,7 @@ import type { AgentChatSessionCreatedOptions } from "../chat/AgentChatPane"; import { formatToolTypeLabel, isChatToolType } from "../../lib/sessions"; import { sortLanesForTabs } from "../lanes/laneUtils"; import { invalidateSessionListCache } from "../../lib/sessionListCache"; -import { useAppStore } from "../../state/appStore"; +import { useAppStore, type WorkDraftKind } from "../../state/appStore"; import { ADE_OPEN_BUILT_IN_BROWSER_EVENT } from "../../lib/openExternal"; import { ADE_WORK_SIDEBAR_BROWSER_RESIZE_END_EVENT, @@ -50,7 +50,15 @@ function dispatchWorkSidebarBrowserResizeEvent(type: "start" | "end"): void { } function isPtyContextInsertableToolType(toolType: TerminalSessionSummary["toolType"]): boolean { - return toolType === "claude"; + return toolType === "claude" + || toolType === "codex" + || toolType === "cursor-cli" + || toolType === "droid" + || toolType === "opencode"; +} + +function buildDraftContextTargetId(laneId: string, draftKind: WorkDraftKind): string { + return `work:draft:${laneId}:${draftKind}`; } async function allSettledWithConcurrency( @@ -419,12 +427,29 @@ export function TerminalsPage({ active = true }: { active?: boolean }) { const activeLaneId = useMemo(() => { if (activeWorkSession?.laneId) return activeWorkSession.laneId; + if (work.draftLaneId && sortedLanes.some((lane) => lane.id === work.draftLaneId)) return work.draftLaneId; if (selectedLaneId && sortedLanes.some((lane) => lane.id === selectedLaneId)) return selectedLaneId; return sortedLanes.find((lane) => lane.laneType === "primary")?.id ?? sortedLanes[0]?.id ?? null; - }, [activeWorkSession?.laneId, selectedLaneId, sortedLanes]); + }, [activeWorkSession?.laneId, selectedLaneId, sortedLanes, work.draftLaneId]); + + const draftContextTargetId = useMemo(() => ( + !activeWorkSession && activeLaneId + ? buildDraftContextTargetId(activeLaneId, work.draftKind) + : null + ), [activeLaneId, activeWorkSession, work.draftKind]); const contextTarget = useMemo(() => { - if (!activeWorkSession || activeWorkSession.laneId !== activeLaneId) return null; + if (!activeWorkSession) { + return draftContextTargetId && activeLaneId + ? { + kind: "draft", + draftTargetId: draftContextTargetId, + laneId: activeLaneId, + draftKind: work.draftKind, + } + : null; + } + if (activeWorkSession.laneId !== activeLaneId) return null; if (isChatToolType(activeWorkSession.toolType)) { return { kind: "chat", sessionId: activeWorkSession.id }; } @@ -441,11 +466,14 @@ export function TerminalsPage({ active = true }: { active?: boolean }) { }; } return null; - }, [activeLaneId, activeWorkSession]); + }, [activeLaneId, activeWorkSession, draftContextTargetId, work.draftKind]); let contextDisabledReason: string | null; - if (!activeWorkSession) { - contextDisabledReason = "Open a chat or agent CLI session in this lane to insert tool context."; + if (!activeWorkSession && contextTarget) { + // Draft context target is available -- no session needed. + contextDisabledReason = null; + } else if (!activeWorkSession) { + contextDisabledReason = "Select a lane before inserting tool context."; } else if (activeWorkSession.laneId !== activeLaneId) { contextDisabledReason = "Open a Work session in the active lane to insert tool context."; } else if (activeWorkSession.toolType === "shell" || activeWorkSession.toolType === "run-shell") { @@ -588,6 +616,7 @@ export function TerminalsPage({ active = true }: { active?: boolean }) { viewMode={work.viewMode} draftKind={work.draftKind} draftLaneId={work.draftLaneId} + draftContextTargetId={draftContextTargetId} setViewMode={work.setViewMode} onSelectItem={work.setActiveItemId} onCloseItem={work.closeTab} @@ -634,6 +663,7 @@ export function TerminalsPage({ active = true }: { active?: boolean }) { work.viewMode, work.draftKind, work.draftLaneId, + draftContextTargetId, work.setDraftLaneId, work.showDraftKind, work.setViewMode, diff --git a/apps/desktop/src/renderer/components/terminals/WorkSidebar.test.tsx b/apps/desktop/src/renderer/components/terminals/WorkSidebar.test.tsx index da5795461..5feefa3a6 100644 --- a/apps/desktop/src/renderer/components/terminals/WorkSidebar.test.tsx +++ b/apps/desktop/src/renderer/components/terminals/WorkSidebar.test.tsx @@ -78,6 +78,7 @@ vi.mock("../chat/ChatBuiltInBrowserPanel", async () => { sessionId: string | null; onAddAttachment?: (attachment: { path: string; type: "image" }) => void; onAddContext?: (item: unknown) => void; + onInsertDraft?: (text: string) => void; }) => React.createElement("div", { "data-testid": "browser-panel", "data-session-id": props.sessionId ?? "" }, [ React.createElement("button", { key: "context", @@ -100,6 +101,12 @@ vi.mock("../chat/ChatBuiltInBrowserPanel", async () => { disabled: !props.onAddAttachment, onClick: () => props.onAddAttachment?.({ path: ".ade/artifacts/browser.png", type: "image" }), }, "Add Browser attachment"), + React.createElement("button", { + key: "draft", + type: "button", + disabled: !props.onInsertDraft, + onClick: () => props.onInsertDraft?.("inspect this browser state"), + }, "Insert Browser draft"), ]), }; }); @@ -390,8 +397,37 @@ describe("WorkSidebar context targets", () => { expect((screen.getByText("Add iOS attachment") as HTMLButtonElement).disabled).toBe(true); }); - it("warns when App Control is attached to another lane and disables context insertion", async () => { - installAdeMock({ appControlSession: otherLaneAppControlSession }); + it("dispatches draft target events without faking a chat session", () => { + const received: unknown[] = []; + window.addEventListener("ade:agent-chat:add-ios-context", (event) => { + received.push((event as CustomEvent).detail); + }); + + renderSidebar({ + tab: "ios", + contextTarget: { + kind: "draft", + draftTargetId: "work:draft:lane-1:chat", + laneId: "lane-1", + draftKind: "chat", + }, + }); + + expect(screen.getByTestId("ios-panel").getAttribute("data-session-id")).toBe(""); + expect((screen.getByText("Add iOS context") as HTMLButtonElement).disabled).toBe(false); + fireEvent.click(screen.getByText("Add iOS context")); + + expect(received).toEqual([expect.objectContaining({ + draftTargetId: "work:draft:lane-1:chat", + laneId: "lane-1", + draftKind: "chat", + item: expect.objectContaining({ id: "ios-context-1" }), + })]); + expect(received[0]).not.toHaveProperty("sessionId"); + }); + + it("warns when App Control is attached to another lane without disabling context insertion", async () => { + const { terminalWrite } = installAdeMock({ appControlSession: otherLaneAppControlSession }); renderSidebar({ tab: "app-control", @@ -399,13 +435,15 @@ describe("WorkSidebar context targets", () => { lanes: [lane, laneTwo], }); - expect(await screen.findByText(/This App Control view is attached to Lane 2, not Lane 1/)).toBeTruthy(); - expect(screen.getByTestId("app-control-panel").getAttribute("data-control-disabled")).toMatch(/Lane 2/); - expect((screen.getByText("Add App Control context") as HTMLButtonElement).disabled).toBe(true); + expect(await screen.findByText(/This App Control view is running from Lane 2 while your context target is 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 and disables context insertion", async () => { - installAdeMock({ iosSession: otherLaneIosSession }); + it("warns when the iOS Simulator is attached to another lane without disabling context insertion", async () => { + const { terminalWrite } = installAdeMock({ iosSession: otherLaneIosSession }); renderSidebar({ tab: "ios", @@ -413,9 +451,11 @@ describe("WorkSidebar context targets", () => { lanes: [lane, laneTwo], }); - expect(await screen.findByText(/This iOS Simulator view is attached to Lane 2, not Lane 1/)).toBeTruthy(); - expect(screen.getByTestId("ios-panel").getAttribute("data-control-disabled")).toMatch(/Lane 2/); - expect((screen.getByText("Add iOS context") as HTMLButtonElement).disabled).toBe(true); + expect(await screen.findByText(/This iOS Simulator view is running from Lane 2 while your context target is Lane 1/)).toBeTruthy(); + expect(screen.getByTestId("ios-panel").getAttribute("data-control-disabled")).toBe(""); + 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 () => { @@ -451,7 +491,8 @@ describe("WorkSidebar context targets", () => { , ); - expect(await screen.findByText(/This Browser view is attached to Lane 1, not Lane 2/)).toBeTruthy(); - expect((screen.getByText("Add Browser context") as HTMLButtonElement).disabled).toBe(true); + expect(await screen.findByText(/This Browser view is running from Lane 1 while your context target is Lane 2/)).toBeTruthy(); + expect((screen.getByText("Add Browser context") as HTMLButtonElement).disabled).toBe(false); + expect((screen.getByText("Add Browser attachment") 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 fbb11e6d8..b8133489f 100644 --- a/apps/desktop/src/renderer/components/terminals/WorkSidebar.tsx +++ b/apps/desktop/src/renderer/components/terminals/WorkSidebar.tsx @@ -20,7 +20,7 @@ import type { TerminalSessionSummary, TerminalToolType, } from "../../../shared/types"; -import type { WorkSidebarTab } from "../../state/appStore"; +import type { WorkDraftKind, WorkSidebarTab } from "../../state/appStore"; import { formatAppControlContextForPrompt, formatBuiltInBrowserContextForPrompt, @@ -80,9 +80,10 @@ const WORK_SIDEBAR_TABS: Array> = [ export type WorkSidebarContextTarget = | { kind: "chat"; sessionId: string } + | { kind: "draft"; draftTargetId: string; laneId: string; draftKind: WorkDraftKind } | { kind: "pty"; sessionId: string; ptyId: string; toolType: TerminalToolType | null }; -const NO_CONTEXT_TARGET_ERROR = "Open a chat or agent CLI session in this lane before inserting tool context."; +const NO_CONTEXT_TARGET_ERROR = "Open a chat, draft, or agent CLI session in this lane before inserting tool context."; const BRACKETED_PASTE_START = "\x1b[200~"; const BRACKETED_PASTE_END = "\x1b[201~"; @@ -103,13 +104,25 @@ function laneMismatchMessage( ): string { const ownerLane = laneDisplayName(lanes, ownerLaneId); const activeLane = laneDisplayName(lanes, activeLaneId); - return `This ${toolName} view is attached to ${ownerLane}, not ${activeLane}. Quit this view, then restart it from a chat or Claude Code session in ${activeLane} before inserting context.`; + 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.`; } -function dispatchAgentChatEvent(eventName: string, sessionId: string, key: string, value: T): void { +function dispatchAgentChatEvent( + eventName: string, + target: Extract, + key: string, + value: T, +): void { + const targetDetail = target.kind === "chat" + ? { sessionId: target.sessionId } + : { + draftTargetId: target.draftTargetId, + laneId: target.laneId, + draftKind: target.draftKind, + }; window.dispatchEvent(new CustomEvent(eventName, { detail: { - sessionId, + ...targetDetail, [key]: value, }, })); @@ -293,7 +306,7 @@ export function WorkSidebar({ return null; } const laneMismatchReason = resolveLaneMismatchReason(); - const contextDisabledReason = laneMismatchReason ?? targetDisabledReason; + const contextDisabledReason = targetDisabledReason; const canInsertContext = Boolean(contextTarget && !contextDisabledReason); const panelSessionId = contextTarget?.kind === "chat" ? contextTarget.sessionId : null; @@ -348,8 +361,8 @@ export function WorkSidebar({ formatForPty: (value: T) => string | null, ) => { withContextTarget(NO_CONTEXT_TARGET_ERROR, (target) => { - if (target.kind === "chat") { - dispatchAgentChatEvent(eventName, target.sessionId, key, value); + if (target.kind === "chat" || target.kind === "draft") { + dispatchAgentChatEvent(eventName, target, key, value); return; } const text = formatForPty(value); @@ -397,9 +410,9 @@ export function WorkSidebar({ ); }, [insertContext]); const insertDraft = useCallback((text: string) => { - withContextTarget("Open a chat or agent CLI session in this lane before inserting draft text.", (target) => { - if (target.kind === "chat") { - dispatchAgentChatEvent("ade:agent-chat:insert-draft", target.sessionId, "text", text); + withContextTarget("Open a chat, draft, or agent CLI session in this lane before inserting draft text.", (target) => { + if (target.kind === "chat" || target.kind === "draft") { + dispatchAgentChatEvent("ade:agent-chat:insert-draft", target, "text", text); return; } insertIntoPty(target, text, "draft"); @@ -412,6 +425,7 @@ export function WorkSidebar({ return (
{contextDisabledReason ? : null} + {laneMismatchReason ? : null}
{contextDisabledReason ? : null} + {laneMismatchReason ? : null}
{panel}
); diff --git a/apps/desktop/src/renderer/components/terminals/WorkStartSurface.test.tsx b/apps/desktop/src/renderer/components/terminals/WorkStartSurface.test.tsx index 7936f4ea7..fed998ed2 100644 --- a/apps/desktop/src/renderer/components/terminals/WorkStartSurface.test.tsx +++ b/apps/desktop/src/renderer/components/terminals/WorkStartSurface.test.tsx @@ -9,7 +9,8 @@ const selectLane = vi.fn(); const agentChatPaneProps = vi.hoisted(() => ({ latest: null as null | { laneId: string | null; - workDraftKind?: "chat" | "cli"; + workDraftKind?: "chat" | "cli" | "chat-orchestrator"; + draftContextTargetId?: string | null; onOpenShellSession?: (laneId: string) => void | Promise; onLaunchCliSession?: unknown; }, @@ -23,7 +24,8 @@ vi.mock("../../state/appStore", () => ({ vi.mock("../chat/AgentChatPane", () => ({ AgentChatPane: (props: { laneId: string | null; - workDraftKind?: "chat" | "cli"; + workDraftKind?: "chat" | "cli" | "chat-orchestrator"; + draftContextTargetId?: string | null; onOpenShellSession?: (laneId: string) => void | Promise; onLaunchCliSession?: unknown; }) => { @@ -112,6 +114,7 @@ describe("WorkStartSurface", () => { { ); expect(await screen.findByTestId("agent-chat-pane")).toBeTruthy(); + expect(agentChatPaneProps.latest?.draftContextTargetId).toBe("work:draft:lane-local:chat"); expect(screen.queryByTestId("work-vm-banner")).toBeNull(); expect(screen.queryByTestId("work-vm-not-ready")).toBeNull(); }); diff --git a/apps/desktop/src/renderer/components/terminals/WorkStartSurface.tsx b/apps/desktop/src/renderer/components/terminals/WorkStartSurface.tsx index 7fd33ffe4..368b03975 100644 --- a/apps/desktop/src/renderer/components/terminals/WorkStartSurface.tsx +++ b/apps/desktop/src/renderer/components/terminals/WorkStartSurface.tsx @@ -13,6 +13,7 @@ import type { WorkPtyLaunchArgs, WorkPtyLaunchResult } from "./cliLaunch"; type WorkStartSurfaceProps = { draftKind: WorkDraftKind; draftLaneId?: string | null; + draftContextTargetId?: string | null; lanes: LaneSummary[]; onOpenChatSession: (session: AgentChatSession, options?: AgentChatSessionCreatedOptions) => void | Promise; onLaunchPtySession: (args: WorkPtyLaunchArgs) => Promise; @@ -26,6 +27,7 @@ type WorkStartSurfaceProps = { export function WorkStartSurface({ draftKind, draftLaneId = null, + draftContextTargetId = null, lanes, onOpenChatSession, onLaunchPtySession, @@ -125,6 +127,7 @@ export function WorkStartSurface({ hideSessionTabs hideLaneToolDrawers forceDraftMode + draftContextTargetId={draftContextTargetId} embeddedWorkLayout workDraftKind={draftKind} initialLinearIssueContext={initialLinearIssueContext} diff --git a/apps/desktop/src/renderer/components/terminals/WorkViewArea.tsx b/apps/desktop/src/renderer/components/terminals/WorkViewArea.tsx index 63fb7194a..028382a6c 100644 --- a/apps/desktop/src/renderer/components/terminals/WorkViewArea.tsx +++ b/apps/desktop/src/renderer/components/terminals/WorkViewArea.tsx @@ -1163,6 +1163,7 @@ export function WorkViewArea({ viewMode, draftKind, draftLaneId = null, + draftContextTargetId = null, onContinueCliSession, setViewMode, onSelectItem, @@ -1203,6 +1204,7 @@ export function WorkViewArea({ viewMode: WorkViewMode; draftKind: WorkDraftKind; draftLaneId?: string | null; + draftContextTargetId?: string | null; setViewMode: (mode: WorkViewMode) => void; onSelectItem: (sessionId: string) => void; onCloseItem: (sessionId: string) => void; @@ -1479,6 +1481,7 @@ export function WorkViewArea({ `) 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 when the sidebar's `attachChatSessionId` matches. 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`, 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/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. | diff --git a/docs/features/chat/agent-routing.md b/docs/features/chat/agent-routing.md index ba5ee0bf7..abf0e450d 100644 --- a/docs/features/chat/agent-routing.md +++ b/docs/features/chat/agent-routing.md @@ -159,10 +159,15 @@ persistExtendedHistory: true }`. The return envelope is consumed by `reasoningEffort`. That snapshot becomes the session state, so the picker chips always show what the runtime actually applied. On resume, the persisted chat state is re-written after normalization instead of -being re-copied from the on-disk file — the server's reading of -`.codex/config.toml` wins over a stale persisted pair. Turns use the -Codex-native `effort` key (`turn/start({ threadId, input, effort?, -serviceTier? })`) instead of the lifecycle `reasoningEffort` name. +being re-copied from the on-disk file. For ADE-controlled flag modes, +the explicit policy sent with `thread/resume` remains authoritative when +the lifecycle response echoes an older thread policy; this keeps a +manual picker switch from snapping back to Plan before the next +`turn/start`. When `codexConfigSource` is `config-toml`, ADE sends no +policy override and the server's reading of `.codex/config.toml` wins +over a stale persisted pair. Turns use the Codex-native `effort` key +(`turn/start({ threadId, input, effort?, serviceTier? })`) instead of +the lifecycle `reasoningEffort` name. #### Codex service tiers (Fast Mode) diff --git a/docs/features/chat/composer-and-ui.md b/docs/features/chat/composer-and-ui.md index 1e540e8bf..a40a139a2 100644 --- a/docs/features/chat/composer-and-ui.md +++ b/docs/features/chat/composer-and-ui.md @@ -11,8 +11,8 @@ 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. | -| `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. | +| `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. | +| `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. | | `ChatComposerShell.tsx` | Input container chrome reused by the composer. | @@ -24,11 +24,12 @@ stream plus session metadata. | `ChatSubagentsPanel.tsx`, `ChatSubagentStrip.tsx` | Claude background subagent panels. | | `ChatComputerUsePanel.tsx` | Computer-use backend status. | | `ChatAppControlPanel.tsx` | App Control panel for Electron apps. Two mount points: under the chat composer (chat-scoped, `sessionId` set) and inside the Work right-edge sidebar (lane-scoped, `sessionId={null}`). Two modes: **Control** (live screencast frames + launch/connect form + click/type input + quick `terminal write` / `terminal signal` actions) and **Inspect** (hit-test crosshair on the screenshot; commits selections as `AppControlContextItem`s with screenshot, DOM packet, and source-file candidates). Persists panel state under `sessionStorage["ade.chat.appControlPanel."]`, where the key is `chat:` for the chat mount and `lane::` for the sidebar mount. Connect/launch calls forward `laneId` so the resulting `AppControlSession` records its launching lane. See [App Control](../computer-use/app-control.md). | -| `ChatIosSimulatorPanel.tsx` | macOS-only iOS Simulator drawer. Two mount points: under the chat composer and inside the Work right-edge sidebar. Tool-readiness checklist, device + target pickers, three-backend live preview, `interact` vs `inspect` mode, hit-test overlay, and selection emission as `IosElementContextItem`. Accepts an optional `laneId` prop, forwarded into `iosSimulator.launch` so the resulting `IosSimulatorSession` records its launching lane. See [iOS Simulator feature](../ios-simulator/README.md). | +| `ChatIosSimulatorPanel.tsx` | macOS-only iOS Simulator drawer. Two mount points: under the chat composer and inside the Work right-edge sidebar. Tool-readiness checklist, device + target pickers, three-backend live preview, `interact` vs `inspect` mode, hit-test overlay, and selection emission as `IosElementContextItem`. Accepts an optional `laneId` prop, forwarded into `iosSimulator.launch` so the resulting `IosSimulatorSession` records its launching lane. Simulator controls are not blocked when another chat session owns the simulator — ownership only affects which session receives context insertions, not whether the user can interact with the device. See [iOS Simulator feature](../ios-simulator/README.md). | | `ChatBuiltInBrowserPanel.tsx` | In-app browser panel mounted under the Work right-edge sidebar's `browser` tab. Renders the address bar, navigation/tab strip, inspect toolbar, screenshot capture, and an empty/error state derived from `BuiltInBrowserStatus`; the actual page content is painted by a main-process `WebContentsView` whose bounds the panel reports back to the broker via `ade.builtInBrowser.setBounds`. Inspect-mode hit-tests emit `BuiltInBrowserContextItem` payloads through `onAddContext`; the sidebar then dispatches `ade:agent-chat:add-builtin-browser-context` to the active chat. The panel does not run inside `AgentChatPane` directly — instead, anywhere in the renderer that wants to open a URL calls `openUrlInAdeBrowser()` (in `apps/desktop/src/renderer/lib/openExternal.ts`), which fires `ADE_OPEN_BUILT_IN_BROWSER_EVENT` and asks the broker to open a new tab. | | `ChatTerminalDrawer.tsx` | Collapsible terminal drawer at the bottom of the chat. | | `ChatGitToolbar.tsx` | Git status and quick-action toolbar above the composer. The PR action opens a linked PR when one exists, otherwise opens the PR creation handoff for the current lane targeting the primary branch. | -| `ChatProposedPlanCard.tsx` | Plan approval card inline in the transcript. | +| `ChatProposedPlanCard.tsx` | Plan approval card inline in the transcript. Renders the plan description or question text as rich markdown (`ChatMarkdown`) inside a scrollable container (capped at `min(34vh, 360px)`). | +| `codex/CodexPlanCard.tsx` | Codex plan card rendered inline in the transcript for `plan` events. Shows plan state (Planning / Plan ready), step progress with status glyphs, and streaming plan text as rich markdown via `ChatMarkdown`. Completed plans with no discrete steps render the full markdown body inline; plans with steps offer a toggle to expand the raw markdown details (labelled "details" when complete, "live" while streaming). Handles missing `steps` arrays gracefully. | | `ChatWorkLogBlock.tsx` | Collapsible work-log group (see `chatTranscriptRows.ts`). Accepts `animate` so completed groups render a static glyph while in-flight ones pulse; prefers `waiting` over `working` when any entry is `interrupted`. Also renders a `LocalhostServersStrip` above the panels when any work-log entry produced a `localhost`/`127.0.0.1`/`0.0.0.0`/`[::1]` URL: a sky-toned chip per detected URL routes through `openUrlInAdeBrowser()` (so the click opens the Work sidebar Browser tab in a new tab), and a sibling Logs button either reveals the chat's currently active terminal (via `onRevealChatTerminal`) or — when no terminal exists — drafts a "please move this server into the ADE chat terminal" prompt for the agent through `onInsertDraft`. | | `AgentQuestionModal.tsx` | Pending input modal for question-type requests. | | `CodeHighlighter.tsx`, `chatStatusVisuals.tsx`, `chatSurfaceTheme.ts`, `chatToolAppearance.tsx` | Supporting visuals. `chatStatusVisuals.ChatStatusGlyph` takes an `animate` prop so non-active rows skip the ping/spin animation; `AgentChatMessageList.ActivityIndicator` mirrors this and switches to a dimmed static tone plus a non-looping Brain lottie for `thinking` once the turn ends. | @@ -332,8 +333,10 @@ rendering. Key rules: shadow. - System notices render compact inline (no pill badges). - Turn dividers (`ChatTurnDivider`) separate turns. -- Plan approval cards cap at `max-h-72` with pre-wrapped text so long - plans scroll. +- Plan approval cards display the plan body as rich markdown inside a + scrollable container (capped at `360px`). When a plan-approval event + carries non-empty body text, it is rendered as a `MarkdownBlock` + beneath the header. Row derivation uses `chatTranscriptRows.ts` (see [transcript-and-turns](transcript-and-turns.md)). diff --git a/docs/features/terminals-and-sessions/README.md b/docs/features/terminals-and-sessions/README.md index bf931791c..a7cebe2c2 100644 --- a/docs/features/terminals-and-sessions/README.md +++ b/docs/features/terminals-and-sessions/README.md @@ -189,10 +189,13 @@ Renderer surfaces: across the app — but it still flows selections to the active chat through the same dispatch path as the other tool tabs. The active Work session picks the sidebar's insertion target - (`WorkSidebarContextTarget`): chat sessions get the legacy + (`WorkSidebarContextTarget`): chat sessions (`kind: "chat"`) and + draft composers (`kind: "draft"`, carrying `draftTargetId`, `laneId`, + and `draftKind`) receive `ade:agent-chat:add-attachment` / `add-ios-context` / `add-app-control-context` / `add-builtin-browser-context` / - `insert-draft` events, while tracked agent + `insert-draft` events (draft targets include `draftTargetId` instead + of `sessionId` in the event detail), while tracked agent CLI PTYs (Claude / Codex / Cursor / OpenCode / Droid) receive the same iOS / App Control / browser / attachment / draft payloads formatted into prompt text by @@ -202,12 +205,14 @@ Renderer surfaces: dispatches `ADE_WORK_PTY_CONTEXT_INSERTED_EVENT` (`apps/desktop/src/renderer/lib/workPtyContextEvents.ts`) so the matching `TerminalView` can briefly highlight the new content. When - no chat or tracked agent CLI session is open, attachment is disabled - with a banner; lane mismatches between the Work lane and an existing - App Control / iOS Simulator session also disable attachment with a - warning. The tab strip must stay reachable when the Work pane is - narrow: labels collapse to accessible icon buttons while preserving - stable hit targets and tooltips. + no chat, draft, or tracked agent CLI session is open, attachment is + disabled with a banner. Lane mismatches between the Work lane and an + existing App Control / iOS Simulator session are shown as an + informational warning banner but no longer block context insertion — + controls affect the running tool while inserted context goes to the + current chat, draft, or CLI target. The tab strip must stay reachable + when the Work pane is narrow: labels collapse to accessible icon + buttons while preserving stable hit targets and tooltips. - `apps/desktop/src/renderer/components/vm/MacVmPage.tsx` — dedicated `/vm` route for the lane-tied macOS VM. It shows host and Lume readiness, the single-VM lane reservation, stale attachment @@ -260,11 +265,13 @@ Renderer surfaces: read consistently. - `apps/desktop/src/renderer/components/terminals/WorkStartSurface.tsx` — empty-state "start new chat / terminal" surface. It mounts - `AgentChatPane` in embedded draft mode and forwards - `onSessionCreated(session, options)` so foreground draft launches can - open in Work while background launches stay quiet and surface their - dismissible launch notice inside the pane. The `onLaunchPtySession` - prop is typed as `(args: WorkPtyLaunchArgs) => Promise`. + `AgentChatPane` in embedded draft mode, passes `draftContextTargetId` + so the Work sidebar can insert context into the draft composer before + a session exists, and forwards `onSessionCreated(session, options)` so + foreground draft launches can open in Work while background launches + stay quiet and surface their dismissible launch notice inside the pane. + The `onLaunchPtySession` prop is typed as + `(args: WorkPtyLaunchArgs) => Promise`. - `apps/desktop/src/renderer/components/terminals/TerminalView.tsx` — xterm.js wrapper; WebGL renderer with DOM fallback, fit retries, health counters. diff --git a/docs/features/terminals-and-sessions/ui-surfaces.md b/docs/features/terminals-and-sessions/ui-surfaces.md index 544be510c..6c416949e 100644 --- a/docs/features/terminals-and-sessions/ui-surfaces.md +++ b/docs/features/terminals-and-sessions/ui-surfaces.md @@ -24,7 +24,17 @@ globally-positioned overlays: The page handles session navigation (selection, tab open, "go to lane") and invalidates the shared session list cache before pushing a -freshly-opened chat into the Work tab. +freshly-opened chat into the Work tab. It also computes a +`draftContextTargetId` (formatted as `work:draft::`) +and a `contextTarget` that includes a `"draft"` kind when no active +Work session is selected but a draft composer is mounted, so the Work +sidebar can insert context (attachments, iOS/App Control/browser +selections) into the draft composer before a chat session exists. +Context insertion is enabled for draft targets — the "no session open" +disabled message no longer appears when a draft is active. The page +also determines PTY context insertability through +`isPtyContextInsertableToolType`, which covers all tracked agent CLI +tool types: `claude`, `codex`, `cursor-cli`, `droid`, and `opencode`. It also owns the sidebar's multi-select state: @@ -312,15 +322,17 @@ Tabs: The sidebar picks a single insertion target per active Work session via `WorkSidebarContextTarget`: a chat (`kind: "chat"`) when the focused -Work session is chat-typed, otherwise a tracked agent CLI PTY +Work session is chat-typed, a draft composer (`kind: "draft"`, carrying +`draftTargetId`, `laneId`, and `draftKind`) when no session is active +but a draft composer is mounted, or a tracked agent CLI PTY (`kind: "pty"`, carrying `sessionId`, `ptyId`, and `toolType`) when the focused Work session is Claude / Codex / Cursor / OpenCode / Droid. -Chat targets receive selections through window events +Chat and draft targets receive selections through window events (`ade:agent-chat:add-attachment`, `add-ios-context`, `add-app-control-context`, `add-builtin-browser-context`, -`insert-draft`); the matching `AgentChatPane` -listens for its session id and feeds the payload into the same -handlers that the in-pane drawers use. PTY targets get the same +`insert-draft`); draft events carry `draftTargetId` instead of +`sessionId` so the matching `AgentChatPane` can identify the correct +draft composer. PTY targets get the same selections formatted into prompt text by `apps/desktop/src/renderer/lib/visualContextFormatting.ts` (`formatIosElementContextForPrompt`, @@ -331,14 +343,15 @@ bracketed-paste payload (`\x1b[200~…\x1b[201~`) through `ADE_WORK_PTY_CONTEXT_INSERTED_EVENT` (`apps/desktop/src/renderer/lib/workPtyContextEvents.ts`) so the active `TerminalView` can show a brief "context inserted" affordance. When no -chat or tracked agent CLI session is open in the active Work lane, -attachment is disabled with the banner "Open a chat or agent CLI -session in this lane before inserting tool context." The sidebar also -owns its own `AppControlSession` / `IosSimulatorSession` subscriptions -so it can detect lane mismatches (e.g. App Control was launched from a -different lane) and disable attachment with a warning banner; the -existing tool session can still be controlled, but it will not feed -context into the mismatched lane until the user re-launches. +chat, draft, or tracked agent CLI session is open in the active Work +lane, attachment is disabled with the banner "Open a chat, draft, or +agent CLI session in this lane before inserting tool context." The +sidebar also owns its own `AppControlSession` / `IosSimulatorSession` +subscriptions so it can detect lane mismatches (e.g. App Control was +launched from a different lane); lane mismatches are surfaced as an +informational warning banner but no longer block context insertion — +controls affect the running tool while inserted context goes to the +current chat, draft, or CLI target. Toggling and tab selection go through `useWorkSessions` setters (`setWorkSidebarOpen`, `setWorkSidebarTab`, `setWorkSidebarWidthPct`). @@ -456,7 +469,10 @@ Font stack defaults: `ui-monospace`, `SFMono-Regular`, `Menlo`, ## Empty state: `WorkStartSurface.tsx` -Rendered when the Work view has no open sessions. Contains: +Rendered when the Work view has no open sessions. Accepts a +`draftContextTargetId` prop that is forwarded to the embedded +`AgentChatPane` so the Work sidebar can target the draft composer +for context insertions. Contains: - A three-mode liquid-glass pill (`ModeSwitcherPills` in `WorkViewArea.tsx`) toggling `draftKind` between **Chat** (compose a