From bacd4f8e8d5445b7406f29e19baaa22ce4e9c25f Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Tue, 2 Jun 2026 04:18:51 -0400 Subject: [PATCH 1/8] Fix draft launch status persistence --- .../src/renderer/components/app/App.tsx | 1 + .../components/app/App.workKeepAlive.test.tsx | 23 ++ .../chat/AgentChatComposer.test.tsx | 13 ++ .../components/chat/AgentChatComposer.tsx | 3 + .../components/chat/AgentChatPane.test.tsx | 127 ++++++++++ .../components/chat/AgentChatPane.tsx | 217 +++++++++--------- .../components/settings/AppearanceSection.tsx | 32 +++ .../src/renderer/lib/draftLaunchJobs.ts | 100 ++++++++ .../lib/launchedLanesHighlight.test.ts | 34 +++ .../renderer/lib/launchedLanesHighlight.ts | 11 +- .../src/renderer/state/appStore.test.ts | 11 +- apps/desktop/src/renderer/state/appStore.ts | 40 ++++ 12 files changed, 495 insertions(+), 117 deletions(-) create mode 100644 apps/desktop/src/renderer/lib/draftLaunchJobs.ts create mode 100644 apps/desktop/src/renderer/lib/launchedLanesHighlight.test.ts diff --git a/apps/desktop/src/renderer/components/app/App.tsx b/apps/desktop/src/renderer/components/app/App.tsx index 45482ca2c..d4f9f1172 100644 --- a/apps/desktop/src/renderer/components/app/App.tsx +++ b/apps/desktop/src/renderer/components/app/App.tsx @@ -589,6 +589,7 @@ function ProjectTabHost() { onboardingEnabled: s.onboardingEnabled, didYouKnowEnabled: s.didYouKnowEnabled, launchPromptClipboardEnabled: s.launchPromptClipboardEnabled, + launchPromptClipboardNoticeEnabled: s.launchPromptClipboardNoticeEnabled, }))); const storesRef = React.useRef(new Map()); const lruRef = React.useRef([]); diff --git a/apps/desktop/src/renderer/components/app/App.workKeepAlive.test.tsx b/apps/desktop/src/renderer/components/app/App.workKeepAlive.test.tsx index 53698a27c..a315c2b7a 100644 --- a/apps/desktop/src/renderer/components/app/App.workKeepAlive.test.tsx +++ b/apps/desktop/src/renderer/components/app/App.workKeepAlive.test.tsx @@ -22,6 +22,8 @@ const appStoreState = vi.hoisted(() => ({ project: { rootPath: "/fake/project" }, projectTransition: null as { kind: "opening" | "switching" | "closing"; rootPath: string | null; startedAtMs: number } | null, theme: "dark", + launchPromptClipboardEnabled: true, + launchPromptClipboardNoticeEnabled: true, openProjectTabRoots: [] as string[], projectInfoByRoot: {} as Record, })); @@ -186,6 +188,8 @@ describe("App Work route keep-alive", () => { appStoreState.project = { rootPath: "/fake/project" }; appStoreState.projectTransition = null; appStoreState.theme = "dark"; + appStoreState.launchPromptClipboardEnabled = true; + appStoreState.launchPromptClipboardNoticeEnabled = true; appStoreState.openProjectTabRoots = []; appStoreState.projectInfoByRoot = {}; window.localStorage.clear(); @@ -228,6 +232,25 @@ describe("App Work route keep-alive", () => { expect(workLifecycle.unmounts).toBe(0); }); + it("hydrates project stores with launch clipboard reminder preferences", async () => { + appStoreState.launchPromptClipboardNoticeEnabled = false; + const { hydrateProjectAppStore } = await import("../../state/appStore"); + const { App } = await import("./App"); + + render(); + + await screen.findByTestId("work-page"); + await waitFor(() => { + expect(hydrateProjectAppStore).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + launchPromptClipboardEnabled: true, + launchPromptClipboardNoticeEnabled: false, + }), + ); + }); + }); + it("does not mount the Work page when hydration has not found a project yet", async () => { appStoreState.projectHydrated = false; appStoreState.project = { rootPath: "" }; diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx index ceef5df1d..82eb18b81 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx @@ -1423,6 +1423,19 @@ describe("AgentChatComposer", () => { expect(screen.queryByText("After submission your prompt will auto copy to clipboard.")).toBeNull(); }); + it("hides the launch clipboard helper when the reminder setting is disabled", () => { + renderComposer({ + draft: "Copy silently", + turnActive: false, + launchPromptClipboardEnabled: true, + launchPromptClipboardNoticeEnabled: false, + }); + + fireEvent.focus(screen.getByRole("textbox")); + + expect(screen.queryByText(/Prompt will be copied to clipboard after Send\./)).toBeNull(); + }); + it("focuses the grid composer when the tile becomes active", () => { const props = buildComposerProps({ layoutVariant: "grid-tile", diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx index 074f764da..360d94544 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx @@ -814,6 +814,7 @@ export function AgentChatComposer({ onOpenAiSettings, onOpenLinearSettings, launchPromptClipboardEnabled = false, + launchPromptClipboardNoticeEnabled = true, onOpenLaunchPromptClipboardSettings, onStartOrchestratorChat, onStopOrchestratorChat, @@ -965,6 +966,7 @@ export function AgentChatComposer({ onOpenAiSettings?: () => void; onOpenLinearSettings?: () => void; launchPromptClipboardEnabled?: boolean; + launchPromptClipboardNoticeEnabled?: boolean; onOpenLaunchPromptClipboardSettings?: () => void; /** * Open the "New orchestrator chat" flow from the visible composer mode @@ -1099,6 +1101,7 @@ export function AgentChatComposer({ const orchestratorModeButtonDisabled = composerInputLocked || busy || turnActive; const showLaunchClipboardHelper = launchPromptClipboardEnabled + && launchPromptClipboardNoticeEnabled && composerFocused && !composerInputLocked && draft.trim().length > 0; diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx index 8e4965ca1..0a31f1d1d 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx @@ -645,8 +645,10 @@ function resetChatTestStore() { projectTransition: null, laneInspectorTabs: {}, launchPromptClipboardEnabled: true, + launchPromptClipboardNoticeEnabled: true, workViewByProject: {}, laneWorkViewByScope: {}, + draftLaunchJobsByScope: {}, }); } @@ -3026,6 +3028,36 @@ describe("AgentChatPane submit recovery", () => { }); }); + it("copies submitted prompts when the launch clipboard reminder is disabled", async () => { + useAppStore.setState({ launchPromptClipboardNoticeEnabled: false }); + const { send, writeClipboardText } = installAdeMocks({ sessions: [] }); + + render( + + + , + ); + + const trigger = await screen.findByRole("button", { name: /^Select model/ }); + const codexLabel = getModelById("openai/gpt-5.4")?.displayName ?? "GPT-5.4"; + fireEvent.pointerDown(trigger, { button: 0 }); + fireEvent.click(trigger); + fireEvent.click(await screen.findByRole("tab", { name: /^OpenAI$/i })); + await clickEnabledModelOption(new RegExp(escapeRegExp(codexLabel), "i")); + + const textbox = await screen.findByRole("textbox"); + fireEvent.change(textbox, { target: { value: "Copy quietly." } }); + fireEvent.click(await screen.findByRole("button", { name: "Send" })); + + await waitFor(() => { + expect(send).toHaveBeenCalledWith(expect.objectContaining({ text: "Copy quietly." })); + expect(writeClipboardText).toHaveBeenCalledWith("Copy quietly."); + }); + }); + it("does not copy submitted prompts when the launch clipboard setting is disabled", async () => { useAppStore.setState({ launchPromptClipboardEnabled: false }); const { send, writeClipboardText } = installAdeMocks({ sessions: [] }); @@ -3693,6 +3725,101 @@ describe("AgentChatPane submit recovery", () => { }); }); + it("keeps a pending auto-create launch visible after the new chat pane remounts", async () => { + const { createLane, suggestLaneName } = installAdeMocks({ sessions: [] }); + let resolveSuggestedName!: (value: string) => void; + suggestLaneName.mockImplementation(() => new Promise((resolve) => { + resolveSuggestedName = 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", + })); + + const rendered = 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: "Keep this launch visible." } }); + fireEvent.click(await screen.findByRole("button", { name: "Auto-create in background" })); + + await waitFor(() => { + expect(suggestLaneName).toHaveBeenCalledTimes(1); + expect(screen.getByText(/Creating lane for chat/i)).toBeTruthy(); + }); + expect(screen.queryByRole("button", { name: "Dismiss launch status" })).toBeNull(); + + rendered.unmount(); + renderAutoCreateDraftPane(); + + expect(await screen.findByText(/Creating lane for chat/i)).toBeTruthy(); + expect(screen.queryByRole("button", { name: "Dismiss launch status" })).toBeNull(); + + await act(async () => { + resolveSuggestedName("remounted-lane"); + }); + + await waitFor(() => { + expect(screen.getByText(/Launched chat in remounted-lane/i)).toBeTruthy(); + expect(screen.getByRole("button", { name: "Dismiss launch status" })).toBeTruthy(); + }); + }); + + it("keeps an auto-create failure visible when the launch fails after remount", async () => { + const { send, suggestLaneName } = installAdeMocks({ sessions: [] }); + let rejectSend!: (error: Error) => void; + suggestLaneName.mockResolvedValue("fails-after-remount"); + send.mockImplementation(() => new Promise((_resolve, reject) => { + rejectSend = reject; + })); + + const rendered = 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: "Surface the failure after remount." } }); + fireEvent.click(await screen.findByRole("button", { name: "Auto-create in background" })); + + await waitFor(() => { + expect(send).toHaveBeenCalled(); + }); + + rendered.unmount(); + renderAutoCreateDraftPane(); + + await act(async () => { + rejectSend(new Error("send failed after remount")); + }); + + await waitFor(() => { + expect(screen.getByText(/Launch failed: send failed after remount/i)).toBeTruthy(); + expect(screen.getByRole("button", { name: "Restore" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "Dismiss failed launch" })).toBeTruthy(); + }); + }); + it("ignores duplicate auto-create submits for the same draft while lane naming is pending", async () => { const { suggestLaneName } = installAdeMocks({ sessions: [] }); suggestLaneName.mockImplementation(() => new Promise(() => { diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index ab2482a67..94966f433 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -138,6 +138,17 @@ import { WorkSurfaceHeader } from "../work/WorkSurfaceHeader"; import { shouldShowClaudeCacheTtl } from "../../lib/claudeCacheTtl"; import { getAgentChatModelsCached, getAiStatusCached, invalidateAiDiscoveryCache, peekAiStatusCached } from "../../lib/aiDiscoveryCache"; import { invalidateSessionListCache } from "../../lib/sessionListCache"; +import { + isDraftLaunchJobTerminal, + pruneDraftLaunchJobs, + type BackgroundLaunchNotice, + type DraftLaunchJob, + type DraftLaunchKind, + type DraftLaunchMode, + type DraftLaunchSnapshot, + type NativeControlState, + type PreparedDraftLaunch, +} from "../../lib/draftLaunchJobs"; import { buildAutomaticMacosVmContextForPrompt, createAppControlContextInstanceId, @@ -249,60 +260,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; - draft: string; - modelId: string; - reasoningEffort: string | null; - codexFastMode: boolean; - executionMode: AgentChatExecutionMode; - interactionMode: AgentChatInteractionMode; - nativeControls: NativeControlState; - attachments: AgentChatFileRef[]; - contextAttachments: AgentChatContextAttachment[]; - iosContextItems: IosElementContextItem[]; - appControlContextItems: AppControlContextItem[]; - builtInBrowserContextItems: BuiltInBrowserContextItem[]; - macosVmContextItems: MacosVmContextItem[]; - visualContextPrefix: string; - visualContextDisplayChips: string; - isLiteralSlashCommand: boolean; -}; - -type PreparedDraftLaunch = DraftLaunchSnapshot & { - finalText: string; - finalDisplayText: string; - selectedAttachments: AgentChatFileRef[]; - selectedContextAttachments: AgentChatContextAttachment[]; -}; - -type BackgroundLaunchNotice = { - laneId: string; - laneName: string; - sessionId: string; - draftKind: "chat" | "cli"; -}; - -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; -}; +const EMPTY_DRAFT_LAUNCH_JOBS: DraftLaunchJob[] = []; type DraftLaunchLaneTarget = { laneId: string; @@ -316,22 +274,6 @@ 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 draftLaunchRequestKey(args: { kind: DraftLaunchKind; mode: DraftLaunchMode; @@ -395,15 +337,23 @@ function draftLaunchKindLabel(kind: DraftLaunchKind): string { return kind === "cli" ? "CLI session" : "chat"; } +function draftLaunchJobLabel(job: DraftLaunchJob): string { + if (job.status === "naming-lane" || job.status === "creating-lane") return "Auto-create lane"; + if (job.status === "failed") return "Launch failed"; + if (job.status === "ready") return job.mode === "background" ? "Background launch" : "Ready"; + return job.draftKind === "cli" ? "CLI launch" : "Chat launch"; +} + function draftLaunchJobMessage(job: DraftLaunchJob): string { const laneSuffix = job.laneName ? ` in ${job.laneName}` : ""; + if (job.status === "naming-lane") return `Creating lane for ${draftLaunchKindLabel(job.draftKind)}... Choosing a branch name.`; 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}.`; + : `Ready to open ${draftLaunchKindLabel(job.draftKind)}${laneSuffix}.`; } type AiStatusSnapshot = AiSettingsStatus & { @@ -640,18 +590,6 @@ function deleteAgentChatSessionViewCache(sessionId: string): void { agentChatSessionViewCacheBySessionId.delete(sessionId); } -type NativeControlState = { - interactionMode: AgentChatInteractionMode; - claudePermissionMode: AgentChatClaudePermissionMode; - codexApprovalPolicy: AgentChatCodexApprovalPolicy; - codexSandbox: AgentChatCodexSandbox; - codexConfigSource: AgentChatCodexConfigSource; - opencodePermissionMode: AgentChatOpenCodePermissionMode; - droidPermissionMode: AgentChatDroidPermissionMode; - cursorModeId: string | null; - cursorConfigValues: Record; -}; - type LastLaunchConfig = { version: 1; modelId: string; @@ -2389,6 +2327,7 @@ export function AgentChatPane({ const chatChromeTint = useAppStore((s) => s.chatChromeTint); const chatShellGeometry = useAppStore((s) => s.chatShellGeometry); const launchPromptClipboardEnabled = useAppStore((s) => s.launchPromptClipboardEnabled); + const launchPromptClipboardNoticeEnabled = useAppStore((s) => s.launchPromptClipboardNoticeEnabled); const chatAppearanceRootStyle = useMemo( () => buildChatAppearanceRootStyle({ chatFontSizePx, transcriptDensity: chatTranscriptDensity }), [chatFontSizePx, chatTranscriptDensity], @@ -2439,12 +2378,27 @@ export function AgentChatPane({ () => `${projectRoot ?? "project"}:${laneId ?? "no-lane"}:${surfaceProfile}:${workDraftStorageKind}`, [laneId, projectRoot, surfaceProfile, workDraftStorageKind], ); + const draftLaunchJobsScopeKey = useMemo( + () => [ + "draft-launch-jobs", + projectRoot?.trim() || "project", + surfaceProfile, + workDraftKind, + ].map(encodeURIComponent).join(":"), + [projectRoot, surfaceProfile, workDraftKind], + ); + const draftLaunchJobs = useAppStore((s) => s.draftLaunchJobsByScope[draftLaunchJobsScopeKey] ?? EMPTY_DRAFT_LAUNCH_JOBS); + const setDraftLaunchJobsInStore = useAppStore((s) => s.setDraftLaunchJobs); + const setDraftLaunchJobs = useCallback(( + next: DraftLaunchJob[] | ((prev: DraftLaunchJob[]) => DraftLaunchJob[]), + ) => { + setDraftLaunchJobsInStore(draftLaunchJobsScopeKey, next); + }, [draftLaunchJobsScopeKey, setDraftLaunchJobsInStore]); const initialCompanionStateKey = lockSessionId ?? initialSessionId ?? (laneId ? `draft:${laneId}` : "draft"); const [sessions, setSessions] = useState([]); const [archivedSessions, setArchivedSessions] = useState([]); const [selectedSessionId, setSelectedSessionId] = useState(lockSessionId ?? initialSessionId ?? null); const [draftLaunchTargetId, setDraftLaunchTargetId] = useState(null); - const [draftLaunchJobs, setDraftLaunchJobs] = useState([]); const isWorkCliLaunchDraft = !lockSessionId && !initialSessionId @@ -2713,6 +2667,7 @@ export function AgentChatPane({ const draftLaunchConfigHydratedRef = useRef(null); const draftLaunchConfigTouchedKeyRef = useRef(null); const recoveredParallelLaunchKeyRef = useRef(null); + const paneMountedRef = useRef(true); const selectedSession = useMemo( () => (selectedSessionId ? sessions.find((session) => session.sessionId === selectedSessionId) ?? null : null), [sessions, selectedSessionId] @@ -3421,6 +3376,10 @@ export function AgentChatPane({ sessionsRef.current = sessions; }, [sessions]); + useEffect(() => () => { + paneMountedRef.current = false; + }, []); + const modelSelectionDiffersFromSession = Boolean(selectedSession && selectedSessionModelId && selectedSessionModelId !== modelId); const sessionProvider = useMemo(() => { @@ -5822,11 +5781,11 @@ export function AgentChatPane({ setDraftLaunchJobs((current) => pruneDraftLaunchJobs(current.map((job) => ( job.id === jobId ? { ...job, ...patch } : job )))); - }, []); + }, [setDraftLaunchJobs]); const dismissDraftLaunchJob = useCallback((jobId: string) => { setDraftLaunchJobs((current) => current.filter((job) => job.id !== jobId)); - }, []); + }, [setDraftLaunchJobs]); const openLaunchedDraftSession = useCallback((launch: BackgroundLaunchNotice & { jobId?: string }) => { if (launch.jobId) { @@ -5862,9 +5821,20 @@ export function AgentChatPane({ return; } navigate(`/lanes?laneId=${encodeURIComponent(launch.laneId)}&sessionId=${encodeURIComponent(launch.sessionId)}&focus=single`); - }, [embeddedWorkLayout, navigate, projectRoot, setLaneWorkViewState, setWorkViewState, suppressDraftLaunchNavigation]); + }, [ + embeddedWorkLayout, + navigate, + projectRoot, + setDraftLaunchJobs, + setLaneWorkViewState, + setWorkViewState, + suppressDraftLaunchNavigation, + ]); - const resolveDraftLaunchLane = useCallback(async (snapshot: DraftLaunchSnapshot): Promise => { + const resolveDraftLaunchLane = useCallback(async ( + snapshot: DraftLaunchSnapshot, + onAutoCreateNameResolved?: () => void, + ): Promise => { if (draftLaunchTargetIsAutoCreate) { if (!laneId) throw new Error("Select a lane before auto-creating a new lane."); const primaryLane = availableLanes?.find((candidate) => candidate.laneType === "primary") @@ -5877,6 +5847,7 @@ export function AgentChatPane({ modelId: snapshot.modelId, fallbackName: createTemporaryAutoLaneName(), }); + onAutoCreateNameResolved?.(); const createdLane = await window.ade.lanes.create({ name: laneName, parentLaneId: primaryLane.id }); await refreshLanesStore().catch((refreshError: unknown) => { console.warn("draft launch lane refresh failed", refreshError); @@ -6077,7 +6048,7 @@ export function AgentChatPane({ id: jobId, mode, draftKind: kind, - status: draftLaunchTargetIsAutoCreate ? "creating-lane" : "starting-session", + status: draftLaunchTargetIsAutoCreate ? "naming-lane" : "starting-session", title: buildDraftLaunchJobTitle(kind, snapshot), laneId: null, laneName: null, @@ -6102,7 +6073,9 @@ export function AgentChatPane({ let targetLane: DraftLaunchLaneTarget | null = null; try { - targetLane = await resolveDraftLaunchLane(snapshot); + targetLane = await resolveDraftLaunchLane(snapshot, () => { + patchDraftLaunchJob(jobId, { status: "creating-lane" }); + }); patchDraftLaunchJob(jobId, { status: "starting-session", laneId: targetLane.laneId, @@ -6136,9 +6109,9 @@ export function AgentChatPane({ draftKind: launch.draftKind, autoOpen: false, }); - if (shouldAutoOpen) { + if (shouldAutoOpen && paneMountedRef.current) { openLaunchedDraftSession({ ...launch, jobId }); - } else if (mode === "background") { + } else if (mode === "background" && paneMountedRef.current) { setSelectedSessionId(null); } } catch (launchError) { @@ -6156,7 +6129,9 @@ export function AgentChatPane({ error: message, autoOpen: false, }); - setError(message); + if (paneMountedRef.current) { + setError(message); + } } finally { draftLaunchInFlightKeysRef.current.delete(requestKey); } @@ -6178,6 +6153,7 @@ export function AgentChatPane({ refreshSessions, resolveDraftLaunchLane, selectedSessionId, + setDraftLaunchJobs, startDraftChatLaunch, startDraftCliLaunch, workDraftKind, @@ -8166,6 +8142,7 @@ export function AgentChatPane({ onOpenAiSettings={openAiProvidersSettings} onOpenLinearSettings={openLinearSettings} launchPromptClipboardEnabled={launchPromptClipboardEnabled} + launchPromptClipboardNoticeEnabled={launchPromptClipboardNoticeEnabled} onOpenLaunchPromptClipboardSettings={openLaunchPromptClipboardSettings} onStartOrchestratorChat={() => { // Switch the lane to a fresh orchestrator-lead draft. The @@ -8538,24 +8515,38 @@ export function AgentChatPane({ const canOpen = job.status === "ready" && job.laneId && job.laneName && job.sessionId; const isFailed = job.status === "failed"; const isReady = job.status === "ready"; + const isActiveJob = !isDraftLaunchJobTerminal(job.status); return (
-
+
{!isReady && !isFailed ? ( - + ) : null} -
-
{job.title}
-
{draftLaunchJobMessage(job)}
+
+
+ + {draftLaunchJobLabel(job)} + + {job.title} +
+
{draftLaunchJobMessage(job)}
@@ -8586,19 +8577,21 @@ export function AgentChatPane({ Open ) : null} - + {!isActiveJob ? ( + + ) : null}
); diff --git a/apps/desktop/src/renderer/components/settings/AppearanceSection.tsx b/apps/desktop/src/renderer/components/settings/AppearanceSection.tsx index 78e2b57d5..34e520231 100644 --- a/apps/desktop/src/renderer/components/settings/AppearanceSection.tsx +++ b/apps/desktop/src/renderer/components/settings/AppearanceSection.tsx @@ -194,6 +194,7 @@ export function AppearanceSection() { const volumeSliderId = useId(); const quietToggleId = useId(); const launchPromptClipboardToggleId = useId(); + const launchPromptClipboardNoticeToggleId = useId(); const terminalFieldId = useId(); const theme = useAppStore((s) => s.theme); @@ -212,6 +213,8 @@ export function AppearanceSection() { const setChatUserMinimapEnabled = useAppStore((s) => s.setChatUserMinimapEnabled); const launchPromptClipboardEnabled = useAppStore((s) => s.launchPromptClipboardEnabled); const setLaunchPromptClipboardEnabled = useAppStore((s) => s.setLaunchPromptClipboardEnabled); + const launchPromptClipboardNoticeEnabled = useAppStore((s) => s.launchPromptClipboardNoticeEnabled); + const setLaunchPromptClipboardNoticeEnabled = useAppStore((s) => s.setLaunchPromptClipboardNoticeEnabled); const codeBlockCopyButtonPosition = useAppStore((s) => s.codeBlockCopyButtonPosition); const setCodeBlockCopyButtonPosition = useAppStore((s) => s.setCodeBlockCopyButtonPosition); @@ -456,6 +459,35 @@ export function AppearanceSection() { + {launchPromptClipboardEnabled ? ( + + ) : null}
diff --git a/apps/desktop/src/renderer/lib/draftLaunchJobs.ts b/apps/desktop/src/renderer/lib/draftLaunchJobs.ts new file mode 100644 index 000000000..0b30e5416 --- /dev/null +++ b/apps/desktop/src/renderer/lib/draftLaunchJobs.ts @@ -0,0 +1,100 @@ +import type { + AgentChatCodexApprovalPolicy, + AgentChatCodexConfigSource, + AgentChatCodexSandbox, + AgentChatDroidPermissionMode, + AgentChatExecutionMode, + AgentChatFileRef, + AgentChatContextAttachment, + AgentChatInteractionMode, + AgentChatClaudePermissionMode, + AgentChatCursorConfigValue, + AgentChatOpenCodePermissionMode, + AppControlContextItem, + BuiltInBrowserContextItem, + IosElementContextItem, + MacosVmContextItem, +} from "../../shared/types"; + +export const MAX_DRAFT_LAUNCH_JOBS = 8; + +export type NativeControlState = { + interactionMode: AgentChatInteractionMode; + claudePermissionMode: AgentChatClaudePermissionMode; + codexApprovalPolicy: AgentChatCodexApprovalPolicy; + codexSandbox: AgentChatCodexSandbox; + codexConfigSource: AgentChatCodexConfigSource; + opencodePermissionMode: AgentChatOpenCodePermissionMode; + droidPermissionMode: AgentChatDroidPermissionMode; + cursorModeId: string | null; + cursorConfigValues: Record; +}; + +export 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[]; + appControlContextItems: AppControlContextItem[]; + builtInBrowserContextItems: BuiltInBrowserContextItem[]; + macosVmContextItems: MacosVmContextItem[]; + visualContextPrefix: string; + visualContextDisplayChips: string; + isLiteralSlashCommand: boolean; +}; + +export type PreparedDraftLaunch = DraftLaunchSnapshot & { + finalText: string; + finalDisplayText: string; + selectedAttachments: AgentChatFileRef[]; + selectedContextAttachments: AgentChatContextAttachment[]; +}; + +export type BackgroundLaunchNotice = { + laneId: string; + laneName: string; + sessionId: string; + draftKind: "chat" | "cli"; +}; + +export type DraftLaunchMode = "foreground" | "background"; +export type DraftLaunchKind = BackgroundLaunchNotice["draftKind"]; +export type DraftLaunchJobStatus = "naming-lane" | "creating-lane" | "starting-session" | "sending-prompt" | "ready" | "failed"; + +export 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; +}; + +export function isDraftLaunchJobTerminal(status: DraftLaunchJobStatus): boolean { + return status === "ready" || status === "failed"; +} + +export 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), + ]; +} diff --git a/apps/desktop/src/renderer/lib/launchedLanesHighlight.test.ts b/apps/desktop/src/renderer/lib/launchedLanesHighlight.test.ts new file mode 100644 index 000000000..7446922c4 --- /dev/null +++ b/apps/desktop/src/renderer/lib/launchedLanesHighlight.test.ts @@ -0,0 +1,34 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + consumeLaunchedLanesHighlight, + rememberLaunchedLanes, + subscribeLaunchedLanesHighlight, +} from "./launchedLanesHighlight"; + +describe("rememberLaunchedLanes", () => { + afterEach(() => { + consumeLaunchedLanesHighlight(); + }); + + it("does not publish lane-only creates into the agent-loading highlight path", () => { + const listener = vi.fn(); + const unsubscribe = subscribeLaunchedLanesHighlight(listener); + try { + rememberLaunchedLanes({ laneIds: ["lane-only"], sessionIds: [] }); + + expect(listener).not.toHaveBeenCalled(); + expect(consumeLaunchedLanesHighlight()).toBeNull(); + } finally { + unsubscribe(); + } + }); + + it("keeps lane ids when launched agent sessions are expected", () => { + rememberLaunchedLanes({ laneIds: ["lane-agent"], sessionIds: ["session-agent"] }); + + expect(consumeLaunchedLanesHighlight()).toMatchObject({ + laneIds: ["lane-agent"], + sessionIds: ["session-agent"], + }); + }); +}); diff --git a/apps/desktop/src/renderer/lib/launchedLanesHighlight.ts b/apps/desktop/src/renderer/lib/launchedLanesHighlight.ts index 7934f054a..82b0e8885 100644 --- a/apps/desktop/src/renderer/lib/launchedLanesHighlight.ts +++ b/apps/desktop/src/renderer/lib/launchedLanesHighlight.ts @@ -21,10 +21,15 @@ function isExpired(highlight: LaunchedLanesHighlight): boolean { } export function rememberLaunchedLanes(args: { laneIds: string[]; sessionIds: string[] }): void { - if (!args.laneIds.length && !args.sessionIds.length) return; + const sessionIds = [...args.sessionIds]; + // Lane ids drive the Lanes tab's "creating" overlay while a launched agent + // session materializes. Lane-only creates have no session to wait for, so do + // not publish their lane ids into that loading path. + const laneIds = sessionIds.length > 0 ? [...args.laneIds] : []; + if (!laneIds.length && !sessionIds.length) return; pending = { - laneIds: [...args.laneIds], - sessionIds: [...args.sessionIds], + laneIds, + sessionIds, requestedAt: Date.now(), }; for (const listener of listeners) listener(pending); diff --git a/apps/desktop/src/renderer/state/appStore.test.ts b/apps/desktop/src/renderer/state/appStore.test.ts index 4c0426517..5ac8a1619 100644 --- a/apps/desktop/src/renderer/state/appStore.test.ts +++ b/apps/desktop/src/renderer/state/appStore.test.ts @@ -80,6 +80,7 @@ function resetStore() { onboardingEnabled: true, didYouKnowEnabled: true, launchPromptClipboardEnabled: true, + launchPromptClipboardNoticeEnabled: true, laneInspectorTabs: {}, workViewByProject: {}, laneWorkViewByScope: {}, @@ -238,16 +239,22 @@ describe("appStore", () => { expect(useAppStore.getState().chatUserMinimapEnabled).toBe(true); }); - it("persists the launch prompt clipboard toggle", () => { + it("persists the launch prompt clipboard toggles", () => { expect(useAppStore.getState().launchPromptClipboardEnabled).toBe(true); + expect(useAppStore.getState().launchPromptClipboardNoticeEnabled).toBe(true); useAppStore.getState().setLaunchPromptClipboardEnabled(false); + useAppStore.getState().setLaunchPromptClipboardNoticeEnabled(false); expect(useAppStore.getState().launchPromptClipboardEnabled).toBe(false); + expect(useAppStore.getState().launchPromptClipboardNoticeEnabled).toBe(false); const calls = mockLocalStorage.setItem.mock.calls.filter( ([key]) => key === "ade.userPreferences.v1", ); const latest = calls[calls.length - 1]; expect(latest).toBeTruthy(); - expect(JSON.parse(latest![1])).toMatchObject({ launchPromptClipboardEnabled: false }); + expect(JSON.parse(latest![1])).toMatchObject({ + launchPromptClipboardEnabled: false, + launchPromptClipboardNoticeEnabled: false, + }); }); it("persists transcript density and shell geometry prefs", () => { diff --git a/apps/desktop/src/renderer/state/appStore.ts b/apps/desktop/src/renderer/state/appStore.ts index 59e5c8ad5..a3b6ea61a 100644 --- a/apps/desktop/src/renderer/state/appStore.ts +++ b/apps/desktop/src/renderer/state/appStore.ts @@ -9,6 +9,7 @@ import { getAiStatusCached, invalidateAiDiscoveryCache } from "../lib/aiDiscover import { hasConfiguredAiProvider } from "../lib/aiProviderStatus"; import { getKeybindingsCoalesced, listLaneSnapshotsCoalesced, listLanesCoalesced } from "../lib/laneReadCache"; import { getProjectConfigCached, invalidateProjectConfigCache } from "../lib/projectConfigCache"; +import type { DraftLaunchJob } from "../lib/draftLaunchJobs"; export type ThemeId = "dark" | "light"; export const THEME_IDS: ThemeId[] = ["dark", "light"]; @@ -431,6 +432,7 @@ type PersistedUserPreferences = { onboardingEnabled: boolean; didYouKnowEnabled: boolean; launchPromptClipboardEnabled: boolean; + launchPromptClipboardNoticeEnabled: boolean; codeBlockCopyButtonPosition: CodeBlockCopyButtonPosition; agentTurnCompletionSound: AgentTurnCompletionSound; agentTurnCompletionSoundVolume: number; @@ -461,6 +463,7 @@ function readUnifiedUserPreferences(): PersistedUserPreferences | null { onboardingEnabled: parsed.onboardingEnabled !== false, didYouKnowEnabled: parsed.didYouKnowEnabled !== false, launchPromptClipboardEnabled: parsed.launchPromptClipboardEnabled !== false, + launchPromptClipboardNoticeEnabled: parsed.launchPromptClipboardNoticeEnabled !== false, codeBlockCopyButtonPosition: normalizeCodeBlockCopyButtonPosition(parsed.codeBlockCopyButtonPosition), agentTurnCompletionSound: normalizeAgentTurnCompletionSound(parsed.agentTurnCompletionSound), agentTurnCompletionSoundVolume: normalizeAgentTurnCompletionSoundVolume(parsed.agentTurnCompletionSoundVolume), @@ -503,6 +506,7 @@ function readLegacyUserPreferences(): PersistedUserPreferences { onboardingEnabled: true, didYouKnowEnabled: true, launchPromptClipboardEnabled: true, + launchPromptClipboardNoticeEnabled: true, codeBlockCopyButtonPosition: "top", agentTurnCompletionSound: "off", agentTurnCompletionSoundVolume: DEFAULT_AGENT_TURN_COMPLETION_SOUND_VOLUME, @@ -531,6 +535,7 @@ function persistUserPreferencesFrom(state: { onboardingEnabled: boolean; didYouKnowEnabled: boolean; launchPromptClipboardEnabled: boolean; + launchPromptClipboardNoticeEnabled: boolean; codeBlockCopyButtonPosition: CodeBlockCopyButtonPosition; agentTurnCompletionSound: AgentTurnCompletionSound; agentTurnCompletionSoundVolume: number; @@ -548,6 +553,7 @@ function persistUserPreferencesFrom(state: { onboardingEnabled: state.onboardingEnabled, didYouKnowEnabled: state.didYouKnowEnabled, launchPromptClipboardEnabled: state.launchPromptClipboardEnabled, + launchPromptClipboardNoticeEnabled: state.launchPromptClipboardNoticeEnabled, codeBlockCopyButtonPosition: state.codeBlockCopyButtonPosition, agentTurnCompletionSound: state.agentTurnCompletionSound, agentTurnCompletionSoundVolume: state.agentTurnCompletionSoundVolume, @@ -655,8 +661,10 @@ export type AppState = { onboardingEnabled: boolean; didYouKnowEnabled: boolean; launchPromptClipboardEnabled: boolean; + launchPromptClipboardNoticeEnabled: boolean; workViewByProject: Record; laneWorkViewByScope: Record; + draftLaunchJobsByScope: Record; /** * Per-project lane / chat selection. Switching projects stashes the current * selection here keyed by project root so switching BACK restores the same @@ -728,6 +736,7 @@ export type AppState = { setOnboardingEnabled: (enabled: boolean) => void; setDidYouKnowEnabled: (enabled: boolean) => void; setLaunchPromptClipboardEnabled: (enabled: boolean) => void; + setLaunchPromptClipboardNoticeEnabled: (enabled: boolean) => void; getWorkViewState: (projectRoot: string | null | undefined) => WorkProjectViewState; setWorkViewState: ( projectRoot: string | null | undefined, @@ -743,6 +752,12 @@ export type AppState = { | Partial | ((prev: WorkProjectViewState) => WorkProjectViewState) ) => void; + setDraftLaunchJobs: ( + scopeKey: string | null | undefined, + next: + | DraftLaunchJob[] + | ((prev: DraftLaunchJob[]) => DraftLaunchJob[]) + ) => void; refreshProviderMode: () => Promise; refreshKeybindings: () => Promise; dismissMissingAiBanner: (projectRoot: string) => void; @@ -898,8 +913,10 @@ const createAppState: StateCreator = (set, get) => { onboardingEnabled: initialUserPreferences.onboardingEnabled, didYouKnowEnabled: initialUserPreferences.didYouKnowEnabled, launchPromptClipboardEnabled: initialUserPreferences.launchPromptClipboardEnabled, + launchPromptClipboardNoticeEnabled: initialUserPreferences.launchPromptClipboardNoticeEnabled, workViewByProject: initialPersistedWorkViews.workViewByProject, laneWorkViewByScope: initialPersistedWorkViews.laneWorkViewByScope, + draftLaunchJobsByScope: {}, laneSelectionByProject: {}, laneCacheByProject: {}, sessionsCacheByProject: {}, @@ -1127,6 +1144,11 @@ const createAppState: StateCreator = (set, get) => { persistUserPreferencesFrom({ ...prev, launchPromptClipboardEnabled: enabled }); return { launchPromptClipboardEnabled: enabled }; }), + setLaunchPromptClipboardNoticeEnabled: (enabled) => + set((prev) => { + persistUserPreferencesFrom({ ...prev, launchPromptClipboardNoticeEnabled: enabled }); + return { launchPromptClipboardNoticeEnabled: enabled }; + }), openNewTab: () => set({ isNewTabOpen: true, showWelcome: true }), cancelNewTab: () => { const hasProject = get().project != null; @@ -1192,6 +1214,22 @@ const createAppState: StateCreator = (set, get) => { }; }); }, + setDraftLaunchJobs: (scopeKey, next) => { + const key = typeof scopeKey === "string" ? scopeKey.trim() : ""; + if (!key) return; + set((prev) => { + const current = prev.draftLaunchJobsByScope[key] ?? []; + const updated = typeof next === "function" ? next(current) : next; + const nextJobs = Array.isArray(updated) ? updated : []; + const nextByScope = { ...prev.draftLaunchJobsByScope }; + if (nextJobs.length > 0) { + nextByScope[key] = nextJobs; + } else { + delete nextByScope[key]; + } + return { draftLaunchJobsByScope: nextByScope }; + }); + }, refreshProject: async () => { const project = await window.ade.app.getProject(); @@ -1757,6 +1795,7 @@ export function createProjectAppStore(project: ProjectInfo): AppStoreApi { onboardingEnabled: rootState.onboardingEnabled, didYouKnowEnabled: rootState.didYouKnowEnabled, launchPromptClipboardEnabled: rootState.launchPromptClipboardEnabled, + launchPromptClipboardNoticeEnabled: rootState.launchPromptClipboardNoticeEnabled, setTheme: rootState.setTheme, setTerminalPreferences: rootState.setTerminalPreferences, setCodeBlockCopyButtonPosition: rootState.setCodeBlockCopyButtonPosition, @@ -1773,6 +1812,7 @@ export function createProjectAppStore(project: ProjectInfo): AppStoreApi { setOnboardingEnabled: rootState.setOnboardingEnabled, setDidYouKnowEnabled: rootState.setDidYouKnowEnabled, setLaunchPromptClipboardEnabled: rootState.setLaunchPromptClipboardEnabled, + setLaunchPromptClipboardNoticeEnabled: rootState.setLaunchPromptClipboardNoticeEnabled, }); return store; } From 9728bb7c0e62c12d17995b63c3a107ce72c1d0f7 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Tue, 2 Jun 2026 04:23:16 -0400 Subject: [PATCH 2/8] docs: update draft launch state docs --- docs/features/chat/README.md | 29 ++++++---- docs/features/chat/composer-and-ui.md | 57 +++++++++++-------- docs/features/lanes/README.md | 1 + docs/features/linear-integration/README.md | 7 ++- .../onboarding-and-settings/README.md | 8 ++- 5 files changed, 66 insertions(+), 36 deletions(-) diff --git a/docs/features/chat/README.md b/docs/features/chat/README.md index 776d53a87..a545f3788 100644 --- a/docs/features/chat/README.md +++ b/docs/features/chat/README.md @@ -45,7 +45,9 @@ 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), Codex goal/token-usage DTOs, typed Codex goal control args, permission modes, pending input, completion reports, `PARALLEL_CHAT_MAX_ATTACHMENTS`, and parallel launch state DTOs. `AgentChatSessionSummary.linearIssueLinks?: SessionLinearIssueLink[]` carries the Linear issues attached to the session (chat or CLI), populated from `session_linear_issues` independent of any lane link. | -| `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/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 (`naming-lane` -> `creating-lane` -> `starting-session` -> `sending-prompt` -> `ready` | `failed`) and stores it in `appStore.draftLaunchJobsByScope` keyed by project, surface profile, and Work draft kind so loading/error strips survive pane remounts. The composer is cleared optimistically when the job starts rather than after it finishes; active jobs remain visible while terminal rows are pruned by scope. The pane renders status strips with Open/Restore for ready/failed jobs and Dismiss for terminal jobs. 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/lib/draftLaunchJobs.ts` | Shared renderer helper for Work draft-launch job DTOs and pruning. Owns `NativeControlState`, `DraftLaunchSnapshot`, `PreparedDraftLaunch`, `DraftLaunchJobStatus`, `DraftLaunchJob`, `isDraftLaunchJobTerminal`, and `pruneDraftLaunchJobs`; active jobs are kept ahead of terminal rows, with terminal rows filling the remaining retained slots and at least one terminal row retained alongside active jobs. | +| `apps/desktop/src/renderer/state/appStore.ts` | Shared renderer state store. Besides project/lane/work selection, it persists user preferences such as `launchPromptClipboardEnabled` and `launchPromptClipboardNoticeEnabled`, mirrors them into per-project stores, and owns `draftLaunchJobsByScope` for Work draft launch status strips. | | `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. Codex goal lifecycle events render as compact user-facing rows (`Goal set`, `Goal paused`, `Goal cleared`) instead of raw JSON-RPC/status wording. | | `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. | @@ -54,7 +56,7 @@ machinery layered on top. | `apps/desktop/src/renderer/components/chat/ChatBuiltInBrowserPanel.tsx` | Renderer panel for the in-app browser. Renders the address bar, tabs strip, navigation controls, an inspect/select toolbar, and a `BuiltInBrowserStatus`-derived empty/error state, then asks the main process to position the underlying `WebContentsView` over the panel's bounding rect through `ade.builtInBrowser.setBounds`. Mounted by `WorkSidebar` under the `browser` tab and (indirectly) by any renderer code that calls `openUrlInAdeBrowser()` — the helper opens the sidebar Browser tab and dispatches the URL into a fresh tab. Selections committed through inspect-mode hit-testing fan out via the `onAddContext` callback as `BuiltInBrowserContextItem` payloads. | | `apps/desktop/src/renderer/components/work/WorkSurfaceHeader.tsx` | Shared Work surface header chrome for chat and CLI surfaces: title, lane chip, Claude cache badge, git toolbar, and caller-provided trailing actions. | | `apps/desktop/src/renderer/lib/openExternal.ts` | Renderer-side router for outbound URLs. Defines the `ADE_OPEN_BUILT_IN_BROWSER_EVENT` window event plus `openUrlInAdeBrowser(url)` and `openExternalUrl(url)`. `openUrlInAdeBrowser` dispatches the event (so any open `WorkSidebar` can flip to its Browser tab), then calls `window.ade.builtInBrowser.navigate({ url, newTab: true })`. Anything that is not a normal `http`/`https`/`about:blank` URL falls through to `window.ade.app.openExternal` (system browser). All in-renderer URL clicks (markdown links, lane-runtime open buttons, etc.) go through this helper so the user stays inside ADE. | -| `apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx` | Composer UI: single-session prompt entry, attachments, model/permission controls, slash commands, pending input answering, and parallel launch slot configuration. Pasted/dropped image attachments show pending thumbnails while temp files save, and native Electron clipboard images use `ade.app.saveClipboardImageAttachment` before falling back to `ade.agentChat.saveTempAttachment`. | +| `apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx` | Composer UI: single-session prompt entry, attachments, model/permission controls, slash commands, pending input answering, and parallel launch slot configuration. Pasted/dropped image attachments show pending thumbnails while temp files save, and native Electron clipboard images use `ade.app.saveClipboardImageAttachment` before falling back to `ade.agentChat.saveTempAttachment`. The launch-prompt clipboard helper is gated separately from prompt copying: `launchPromptClipboardEnabled` controls copying and `launchPromptClipboardNoticeEnabled` controls whether the focused-composer reminder is shown. | | `apps/desktop/src/renderer/components/chat/ChatCursorCloudPanel.tsx` | Side panel for Cursor Cloud (background agents): lists existing cloud agents and runs for the lane, lets the user open an existing cloud chat in ADE, archive/unarchive/cancel, and stream run output. Backed by `ade.ai.cursorCloud.*` IPC. | | `apps/desktop/src/renderer/components/chat/CursorCloudInlineLaunch.tsx` | Inline composer affordance for "Send to Cursor Cloud": picks repo + branch + Cursor Cloud-eligible model, optionally targeting a detected PR, and dispatches the prompt to a fresh cloud agent. | | `apps/desktop/src/renderer/components/chat/ChatSurfaceShell.tsx` | Shell that wraps every chat surface (desktop pane, mobile lane, CTO chat) with a unified header/footer slot and `--chat-accent` CSS variable. Supports a `layoutVariant="mobile"` mode that the iOS companion mirrors. | @@ -163,14 +165,21 @@ render them, but neither one *runs* them. `DraftLaunchLaneTarget` (resolved lane + worktree + auto-created 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 + a `DraftLaunchJob` with status states `naming-lane` -> + `creating-lane` -> `starting-session` -> `sending-prompt` -> + `ready` | `failed`; auto-created lanes enter `naming-lane` while the + prompt-derived branch name is being resolved, then `creating-lane` + while the lane row/worktree is created. Jobs live in + `appStore.draftLaunchJobsByScope`, scoped by project, surface + profile, and Work draft kind, so a new chat pane or remount does not + drop loading/error state. The composer is cleared optimistically at + job creation rather than after the async flow completes, so users can + begin composing the next prompt immediately. Active jobs remain + visible; terminal rows are pruned per scope while keeping at least one + terminal row alongside active jobs. The pane renders a status strip + per 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 for terminal jobs. The `DraftLaunchSnapshot` now captures the full composer state including `modelId`, `reasoningEffort`, `codexFastMode`, `executionMode`, `interactionMode`, and `nativeControls` so that diff --git a/docs/features/chat/composer-and-ui.md b/docs/features/chat/composer-and-ui.md index 01eb70e33..3569799dd 100644 --- a/docs/features/chat/composer-and-ui.md +++ b/docs/features/chat/composer-and-ui.md @@ -11,9 +11,10 @@ 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. 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. | +| `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 store-backed `DraftLaunchJob` state machines with multi-step progress (`naming-lane` -> `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. | +| `apps/desktop/src/renderer/lib/draftLaunchJobs.ts` | Pure helper for Work draft-launch job DTOs, terminal-state detection, and pruning. The list keeps active rows ahead of terminal rows, fills remaining retained slots with terminal rows, and keeps at least one terminal row alongside active jobs. | | `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. Codex goal lifecycle rows use user-facing text such as `Goal set`, `Goal paused`, and `Goal cleared`. | -| `AgentChatComposer.tsx` | Text input, attachments, model selector, permission controls, slash commands, pending-input answering, and parallel model-slot controls. | +| `AgentChatComposer.tsx` | Text input, attachments, model selector, permission controls, slash commands, pending-input answering, and parallel model-slot controls. The launch-prompt clipboard reminder is controlled by `launchPromptClipboardNoticeEnabled`, separate from the `launchPromptClipboardEnabled` copy behavior. | | `ChatSurfaceShell.tsx` | Floating chat header, body, footer layout. Backdrop-blur glass-morphism styling. | | `ChatComposerShell.tsx` | Input container chrome reused by the composer. | | `ChatAttachmentTray.tsx` | Inline file/image attachment tray inside the composer. Image attachments render an inline thumbnail, open a full-size lightbox on click, and expose a copy-to-clipboard button that ships the image bytes via `window.ade.app.writeClipboardImage` so the user can paste them into another app. Pasted images can pass a seeded preview URL from the composer while the temp file is being saved; tray-only image refs fall back to `window.ade.app.getImageDataUrl`. Non-image attachments fall back to the file glyph. | @@ -217,18 +218,24 @@ and a footer that contains the composer. The request includes a temporary `chat-YYYYMMDD-HHMMSS` fallback so prompt-derived fallback names remain unique when model naming is 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 + progress through `naming-lane` / `creating-lane` / + `starting-session` / `sending-prompt` / `ready` / `failed` states. + The lane-name suggestion phase is visible as `naming-lane`; once the + name is resolved the job advances to `creating-lane`. 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. Jobs are stored in + `appStore.draftLaunchJobsByScope`, scoped by project, surface + profile, and Work draft kind, so loading/error strips survive a new + chat pane or remount. 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. + current Work focus and render a job strip with an Open action once + ready. Failed jobs offer a Restore button that merges the snapshot + back into the composer. Active jobs remain visible; terminal rows are + pruned per scope. - **Border beam.** On standard (non-grid-tile) layout the composer shell is wrapped in `BorderBeam` (`colorVariant="colorful"` at rest, @@ -546,16 +553,20 @@ 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. + async launches and is stored in `appStore.draftLaunchJobsByScope` + rather than local pane state. The composer is cleared immediately when + the job starts, not when it finishes. Auto-created lanes begin at + `naming-lane`, switch to `creating-lane` after the suggested branch + name resolves, then move through session start and prompt send. 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 diff --git a/docs/features/lanes/README.md b/docs/features/lanes/README.md index abfd288bf..657e5d2ac 100644 --- a/docs/features/lanes/README.md +++ b/docs/features/lanes/README.md @@ -97,6 +97,7 @@ Renderer components: | `renderer/components/lanes/linearBrand.tsx` | Linear brand tokens (`LINEAR_BRAND` colour palette) plus the icon family used everywhere ADE references Linear: `LinearMark`, `LinearStateIcon`, `LinearPriorityIcon`. | | `renderer/components/lanes/laneAgents.ts` | Pure model + hook for the per-lane agent dashboard. `LaneAgent` is a unified row over a lane's ADE chat sessions and CLI agent sessions (plain shells / run-shells and child terminals of a chat are excluded), each with a glanceable `activity` (`working` / `awaiting-input` / `idle` / `ended`), provider/model label, and last-activity hint. `buildLaneAgents(chatSessions, cliSessions)` merges and sorts (live first, ended last; most-recent within a bucket). `useLaneAgents(laneIds)` returns the merged list keyed by laneId, refreshing on agent-chat + terminal-session change events (debounced 350ms). | | `renderer/components/lanes/LaneAgentList.tsx` | Inline per-lane agent dashboard built on `laneAgents.ts`. `LaneAgentList` renders one `LaneAgentRow` per agent (dead ones dimmed) with a live `ActivityPulse` (spinner while working/awaiting, static dot otherwise) and a click-to-open handler. Shared by the Lanes stack drawer (`LaneStackPane`), the graph lane cards (`LaneNode`), and the lane list rows; `highlightedSessionIds` pulses the agents that a batch launch just created (fed from `launchedLanesHighlight`). | +| `renderer/lib/launchedLanesHighlight.ts` | One-shot renderer signal used when another surface creates agent sessions and then routes into Lanes. It only publishes lane ids when session ids are present, so lane-only creates do not enter the Lanes tab's agent-loading overlay path. | | `renderer/components/lanes/ManageLaneDialog.tsx` | Unified manage dialog covering stack position, appearance, adopt-attached, archive, and delete in both single-lane and batch (multi-select) modes. Single-lane mode opens with a "What each section does" info panel and a hero lane-info strip; batch mode swaps in a callout explaining that only archive/delete apply to multiple lanes (stack, color, and adopt are single-lane only). The `StackPositionSection` is single-lane and non-primary only: it shows a parent-lane select (filtered to exclude the lane itself and its descendants), an optional base-branch override input, and an inline "Runs git rebase" disclosure. Apply calls `lanes.reparent({ laneId, newParentLaneId, stackBaseBranchRef })`; the button is disabled while the lane is dirty or has a rebase in progress and while nothing has actually changed, and a parent-callback (`onStackReorganized`) refreshes the lane list. Delete still supports the three scopes (`worktree`, `local_branch`, `remote_branch`), the typed confirmation phrase, remote-branch name input, dirty-state warnings, and the live multi-step progress strip wired to `lanes.delete.event` (`git_status` when a worktree exists, then `cancel_auto_rebase` / `stop_processes` / `stop_ptys` / `stop_watchers` / `cleanup_env` / `git_worktree_remove` / `git_branch_delete` / `git_remote_branch_delete` / `pack_dir_remove` / `database_cleanup`). Optional branch cleanup steps can finish as warnings, allowing lane-owned worktree/database cleanup to complete while still showing the branch cleanup error inline. The dialog calls `lanes.getDeleteRisk` on open to surface dirty state, unpushed commits, running processes / PTYs / watchers, and remote-branch existence before the user confirms; running deletes are shown as non-cancellable because teardown runs to completion once started. | | `renderer/components/lanes/MonacoDiffView.tsx` | Monaco diff editor used for editable working-tree views (invoked from `AdeDiffViewer`) | | `renderer/components/run/LaneRuntimeBar.tsx` | Compact lane runtime status bar (health, preview, port, proxy, oauth) | diff --git a/docs/features/linear-integration/README.md b/docs/features/linear-integration/README.md index fdb780ca9..f4f3de32b 100644 --- a/docs/features/linear-integration/README.md +++ b/docs/features/linear-integration/README.md @@ -356,7 +356,9 @@ Renderer wiring: one-shot signal (`rememberLaunchedLanes` / `consumeLaunchedLanesHighlight` / `subscribeLaunchedLanesHighlight`, 30s TTL) so after a batch launch reroutes from the quick view, the Lanes tab opens its stack drawer and - pulses the newly launched agents. + pulses the newly launched agents. Lane ids are published only when the + launch also produced session ids, so lane-only batch creates do not trigger + the Lanes tab's agent-loading overlay. - `apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx` — the composer's Linear attach affordance opens a `LinearIssueContextDialog` that hosts the same @@ -586,7 +588,8 @@ On submit the orchestrator in `linearBatchLaunch.ts` runs `runBatchLaunch`: `creating-lane` → `launching-agent` → `done` / `failed`) with retry and jump-to-lane affordances. After the run, `rememberLaunchedLanes` (`launchedLanesHighlight.ts`) signals the Lanes tab to open its stack drawer -and pulse the newly launched agents (one-shot, 30s TTL). +and pulse the newly launched agents (one-shot, 30s TTL). Lane-only creates +intentionally skip this signal because there is no agent session to wait for. ## Session-scoped issue attachment and CLI context injection diff --git a/docs/features/onboarding-and-settings/README.md b/docs/features/onboarding-and-settings/README.md index 4ce506aa4..8cf891fdd 100644 --- a/docs/features/onboarding-and-settings/README.md +++ b/docs/features/onboarding-and-settings/README.md @@ -190,6 +190,12 @@ Renderer — settings: project health / repair surface. Setup status comes from `ade.onboarding.getStatus`; runtime daemon health lives in the `AboutSection`, and AI provider controls live in AI Connections. +- `apps/desktop/src/renderer/components/settings/AppearanceSection.tsx` + — theme and chat appearance preferences. Renders `ChatAppearancePreview` + and writes local user preferences through `appStore`, including + `launchPromptClipboardEnabled` (whether Work draft launches copy the + submitted prompt) and `launchPromptClipboardNoticeEnabled` (whether the + focused-composer reminder is shown before the copy). - `apps/desktop/src/renderer/components/settings/ProjectSection.tsx` — project `.ade` structure snapshot, shared/local/secret config paths, health warnings, structure repair, and integrity-check controls. @@ -413,7 +419,7 @@ changing rather than which service backs it: | Tab | Section file | What lives here | |---|---|---| | General | `GeneralSection.tsx` (embeds `AdeCliSection` in compact form) | AI mode, task routing, terminal preferences (font size, line height, scrollback), keybindings link, and the `ade` CLI install / status surface. The CLI card reports whether the bundled `ade-` binary is on `PATH`, the resolved install target, and exposes one-click Install / Repair backed by the platform install-path helper. Receives the legacy `?tab=onboarding`, `?tab=help`, `?tab=tours`, and `?tab=keybindings` deep links via `TAB_ALIASES`. | -| Appearance | `AppearanceSection.tsx` (renders `ChatAppearancePreview`) | Theme, code-block copy-button position, agent-turn completion sound + volume + quiet-when-focused, chat font size (`chatFontSizePx`), chat transcript density (`chatTranscriptDensity` — `compact` / `comfortable` / `spacious`), chat chrome tint (`chatChromeTint` — `colored` default vs `neutral` for monochrome chrome; the legacy `chatLaneAccentEmphasis` preset slug is still read so older user-pref blobs migrate cleanly), chat shell geometry (`chatShellGeometry` — `soft` / `default` / `sharp` corners), and the user-message minimap toggle (`chatUserMinimapEnabled` — drives the inline `ChatUserMinimap`). Persisted to `localStorage` under `ade.userPreferences.v1`. | +| Appearance | `AppearanceSection.tsx` (renders `ChatAppearancePreview`) | Theme, code-block copy-button position, agent-turn completion sound + volume + quiet-when-focused, chat font size (`chatFontSizePx`), chat transcript density (`chatTranscriptDensity` — `compact` / `comfortable` / `spacious`), chat chrome tint (`chatChromeTint` — `colored` default vs `neutral` for monochrome chrome; the legacy `chatLaneAccentEmphasis` preset slug is still read so older user-pref blobs migrate cleanly), chat shell geometry (`chatShellGeometry` — `soft` / `default` / `sharp` corners), the user-message minimap toggle (`chatUserMinimapEnabled` — drives the inline `ChatUserMinimap`), and the Work launch-prompt clipboard preferences (`launchPromptClipboardEnabled` for copying submitted prompts, `launchPromptClipboardNoticeEnabled` for the composer reminder). Persisted to `localStorage` under `ade.userPreferences.v1`. | | Workspace | `WorkspaceSettingsSection.tsx`, `ProjectSection.tsx` | Project identity, paths, skill files. (`SyncDevicesSection.tsx` — multi-device sync, host transfer, peer status, pairing PIN, Tailscale discovery — is mounted from the top bar's Sync popover, not as a Settings tab.) | | AI | `AiSettingsSection.tsx`, `AiFeaturesSection.tsx`, `ProvidersSection.tsx` | Provider CLIs, models, API-key status, provider readiness, OpenCode runtime diagnostics, and AI feature flags. The same status surface is exposed through ADE actions for `ade code` model setup. | | Mobile Push | `MobilePushPanel.tsx` | APNs registration, paired-device push tokens, per-category preferences | From 7e4ea748da6dbecac6053420fbb2c9d8e2ea95f5 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Tue, 2 Jun 2026 04:24:56 -0400 Subject: [PATCH 3/8] Respect launch reminder setting in composer footer --- .../src/renderer/components/chat/AgentChatComposer.test.tsx | 1 + .../src/renderer/components/chat/AgentChatComposer.tsx | 2 +- docs/features/chat/README.md | 2 +- docs/features/chat/composer-and-ui.md | 2 +- docs/features/onboarding-and-settings/README.md | 4 ++-- 5 files changed, 6 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx index 82eb18b81..af0a6ff88 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx @@ -1434,6 +1434,7 @@ describe("AgentChatComposer", () => { fireEvent.focus(screen.getByRole("textbox")); expect(screen.queryByText(/Prompt will be copied to clipboard after Send\./)).toBeNull(); + expect(screen.queryByText("After submission your prompt will auto copy to clipboard.")).toBeNull(); }); it("focuses the grid composer when the tile becomes active", () => { diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx index 360d94544..1c9e6ccd4 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx @@ -3361,7 +3361,7 @@ export function AgentChatComposer({ } footer={
- {launchPromptClipboardEnabled && draft.trim().length > 0 && !composerInputLocked ? ( + {launchPromptClipboardEnabled && launchPromptClipboardNoticeEnabled && draft.trim().length > 0 && !composerInputLocked ? (
After submission your prompt will auto copy to clipboard.
diff --git a/docs/features/chat/README.md b/docs/features/chat/README.md index a545f3788..9f63ccaa3 100644 --- a/docs/features/chat/README.md +++ b/docs/features/chat/README.md @@ -56,7 +56,7 @@ machinery layered on top. | `apps/desktop/src/renderer/components/chat/ChatBuiltInBrowserPanel.tsx` | Renderer panel for the in-app browser. Renders the address bar, tabs strip, navigation controls, an inspect/select toolbar, and a `BuiltInBrowserStatus`-derived empty/error state, then asks the main process to position the underlying `WebContentsView` over the panel's bounding rect through `ade.builtInBrowser.setBounds`. Mounted by `WorkSidebar` under the `browser` tab and (indirectly) by any renderer code that calls `openUrlInAdeBrowser()` — the helper opens the sidebar Browser tab and dispatches the URL into a fresh tab. Selections committed through inspect-mode hit-testing fan out via the `onAddContext` callback as `BuiltInBrowserContextItem` payloads. | | `apps/desktop/src/renderer/components/work/WorkSurfaceHeader.tsx` | Shared Work surface header chrome for chat and CLI surfaces: title, lane chip, Claude cache badge, git toolbar, and caller-provided trailing actions. | | `apps/desktop/src/renderer/lib/openExternal.ts` | Renderer-side router for outbound URLs. Defines the `ADE_OPEN_BUILT_IN_BROWSER_EVENT` window event plus `openUrlInAdeBrowser(url)` and `openExternalUrl(url)`. `openUrlInAdeBrowser` dispatches the event (so any open `WorkSidebar` can flip to its Browser tab), then calls `window.ade.builtInBrowser.navigate({ url, newTab: true })`. Anything that is not a normal `http`/`https`/`about:blank` URL falls through to `window.ade.app.openExternal` (system browser). All in-renderer URL clicks (markdown links, lane-runtime open buttons, etc.) go through this helper so the user stays inside ADE. | -| `apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx` | Composer UI: single-session prompt entry, attachments, model/permission controls, slash commands, pending input answering, and parallel launch slot configuration. Pasted/dropped image attachments show pending thumbnails while temp files save, and native Electron clipboard images use `ade.app.saveClipboardImageAttachment` before falling back to `ade.agentChat.saveTempAttachment`. The launch-prompt clipboard helper is gated separately from prompt copying: `launchPromptClipboardEnabled` controls copying and `launchPromptClipboardNoticeEnabled` controls whether the focused-composer reminder is shown. | +| `apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx` | Composer UI: single-session prompt entry, attachments, model/permission controls, slash commands, pending input answering, and parallel launch slot configuration. Pasted/dropped image attachments show pending thumbnails while temp files save, and native Electron clipboard images use `ade.app.saveClipboardImageAttachment` before falling back to `ade.agentChat.saveTempAttachment`. The launch-prompt clipboard helper is gated separately from prompt copying: `launchPromptClipboardEnabled` controls copying and `launchPromptClipboardNoticeEnabled` controls whether composer reminder text is shown. | | `apps/desktop/src/renderer/components/chat/ChatCursorCloudPanel.tsx` | Side panel for Cursor Cloud (background agents): lists existing cloud agents and runs for the lane, lets the user open an existing cloud chat in ADE, archive/unarchive/cancel, and stream run output. Backed by `ade.ai.cursorCloud.*` IPC. | | `apps/desktop/src/renderer/components/chat/CursorCloudInlineLaunch.tsx` | Inline composer affordance for "Send to Cursor Cloud": picks repo + branch + Cursor Cloud-eligible model, optionally targeting a detected PR, and dispatches the prompt to a fresh cloud agent. | | `apps/desktop/src/renderer/components/chat/ChatSurfaceShell.tsx` | Shell that wraps every chat surface (desktop pane, mobile lane, CTO chat) with a unified header/footer slot and `--chat-accent` CSS variable. Supports a `layoutVariant="mobile"` mode that the iOS companion mirrors. | diff --git a/docs/features/chat/composer-and-ui.md b/docs/features/chat/composer-and-ui.md index 3569799dd..f32c4d7b3 100644 --- a/docs/features/chat/composer-and-ui.md +++ b/docs/features/chat/composer-and-ui.md @@ -14,7 +14,7 @@ stream plus session metadata. | `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 store-backed `DraftLaunchJob` state machines with multi-step progress (`naming-lane` -> `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. | | `apps/desktop/src/renderer/lib/draftLaunchJobs.ts` | Pure helper for Work draft-launch job DTOs, terminal-state detection, and pruning. The list keeps active rows ahead of terminal rows, fills remaining retained slots with terminal rows, and keeps at least one terminal row alongside active jobs. | | `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. Codex goal lifecycle rows use user-facing text such as `Goal set`, `Goal paused`, and `Goal cleared`. | -| `AgentChatComposer.tsx` | Text input, attachments, model selector, permission controls, slash commands, pending-input answering, and parallel model-slot controls. The launch-prompt clipboard reminder is controlled by `launchPromptClipboardNoticeEnabled`, separate from the `launchPromptClipboardEnabled` copy behavior. | +| `AgentChatComposer.tsx` | Text input, attachments, model selector, permission controls, slash commands, pending-input answering, and parallel model-slot controls. Launch-prompt clipboard reminder text is controlled by `launchPromptClipboardNoticeEnabled`, separate from the `launchPromptClipboardEnabled` copy behavior. | | `ChatSurfaceShell.tsx` | Floating chat header, body, footer layout. Backdrop-blur glass-morphism styling. | | `ChatComposerShell.tsx` | Input container chrome reused by the composer. | | `ChatAttachmentTray.tsx` | Inline file/image attachment tray inside the composer. Image attachments render an inline thumbnail, open a full-size lightbox on click, and expose a copy-to-clipboard button that ships the image bytes via `window.ade.app.writeClipboardImage` so the user can paste them into another app. Pasted images can pass a seeded preview URL from the composer while the temp file is being saved; tray-only image refs fall back to `window.ade.app.getImageDataUrl`. Non-image attachments fall back to the file glyph. | diff --git a/docs/features/onboarding-and-settings/README.md b/docs/features/onboarding-and-settings/README.md index 8cf891fdd..1303e20df 100644 --- a/docs/features/onboarding-and-settings/README.md +++ b/docs/features/onboarding-and-settings/README.md @@ -194,8 +194,8 @@ Renderer — settings: — theme and chat appearance preferences. Renders `ChatAppearancePreview` and writes local user preferences through `appStore`, including `launchPromptClipboardEnabled` (whether Work draft launches copy the - submitted prompt) and `launchPromptClipboardNoticeEnabled` (whether the - focused-composer reminder is shown before the copy). + submitted prompt) and `launchPromptClipboardNoticeEnabled` (whether + composer reminder text is shown before the copy). - `apps/desktop/src/renderer/components/settings/ProjectSection.tsx` — project `.ade` structure snapshot, shared/local/secret config paths, health warnings, structure repair, and integrity-check controls. From 5038939e3a1f10da33f63d2a3e12e1882edfd3d7 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Tue, 2 Jun 2026 04:28:15 -0400 Subject: [PATCH 4/8] Simplify draft launch notice rendering --- .../renderer/components/chat/AgentChatComposer.tsx | 6 +++--- .../src/renderer/components/chat/AgentChatPane.tsx | 12 ++++++------ 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx index 1c9e6ccd4..e90e5ca92 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx @@ -1099,12 +1099,12 @@ export function AgentChatComposer({ const canAttachIssueContext = !composerInputLocked && typeof onAddContextAttachment === "function"; const showOrchestratorModeButton = Boolean(onStartOrchestratorChat && !sessionId && !parallelChatMode); const orchestratorModeButtonDisabled = composerInputLocked || busy || turnActive; - const showLaunchClipboardHelper = + const showLaunchClipboardNotice = launchPromptClipboardEnabled && launchPromptClipboardNoticeEnabled - && composerFocused && !composerInputLocked && draft.trim().length > 0; + const showLaunchClipboardHelper = showLaunchClipboardNotice && composerFocused; const resizeTextarea = useCallback(() => { if (useRichComposer) return; @@ -3361,7 +3361,7 @@ export function AgentChatComposer({ } footer={
- {launchPromptClipboardEnabled && launchPromptClipboardNoticeEnabled && draft.trim().length > 0 && !composerInputLocked ? ( + {showLaunchClipboardNotice ? (
After submission your prompt will auto copy to clipboard.
diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index 94966f433..c9af2cf49 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -5789,7 +5789,7 @@ export function AgentChatPane({ const openLaunchedDraftSession = useCallback((launch: BackgroundLaunchNotice & { jobId?: string }) => { if (launch.jobId) { - setDraftLaunchJobs((current) => current.filter((job) => job.id !== launch.jobId)); + dismissDraftLaunchJob(launch.jobId); } if (projectRoot) { setWorkViewState(projectRoot, (prev) => ({ @@ -5822,10 +5822,10 @@ export function AgentChatPane({ } navigate(`/lanes?laneId=${encodeURIComponent(launch.laneId)}&sessionId=${encodeURIComponent(launch.sessionId)}&focus=single`); }, [ + dismissDraftLaunchJob, embeddedWorkLayout, navigate, projectRoot, - setDraftLaunchJobs, setLaneWorkViewState, setWorkViewState, suppressDraftLaunchNavigation, @@ -8512,10 +8512,10 @@ export function AgentChatPane({ className={cn(compactShell ? "min-w-0 w-full" : undefined, "space-y-2")} > {draftLaunchJobs.map((job) => { - const canOpen = job.status === "ready" && job.laneId && job.laneName && job.sessionId; const isFailed = job.status === "failed"; const isReady = job.status === "ready"; const isActiveJob = !isDraftLaunchJobTerminal(job.status); + const canOpen = isReady && Boolean(job.laneId && job.laneName && job.sessionId); return (
- {!isReady && !isFailed ? ( + {isActiveJob ? ( ) : null}
From 6be7c010fee5212f6c99a318ca1a8aec4f44209b Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Tue, 2 Jun 2026 04:58:47 -0400 Subject: [PATCH 5/8] Fix draft launch job lane scope --- .../components/chat/AgentChatPane.test.tsx | 83 +++++++++++++++++++ .../components/chat/AgentChatPane.tsx | 5 +- docs/features/chat/README.md | 9 +- docs/features/chat/composer-and-ui.md | 7 +- 4 files changed, 95 insertions(+), 9 deletions(-) diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx index 0a31f1d1d..6da995f28 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx @@ -3852,6 +3852,89 @@ describe("AgentChatPane submit recovery", () => { }); }); + it("keeps draft launch rows scoped to the lane pane that launched them", async () => { + const { suggestLaneName } = installAdeMocks({ sessions: [] }); + suggestLaneName.mockImplementation(() => new Promise(() => { + // Keep the launch in-flight so the status row remains visible. + })); + const lanes = [ + { + id: "lane-primary", + name: "Primary", + laneType: "primary", + branchRef: "refs/heads/main", + worktreePath: "/tmp/project-under-test", + }, + { + id: "lane-1", + name: "Lane one", + laneType: "worktree", + branchRef: "refs/heads/lane-one", + worktreePath: "/tmp/project-under-test/lane-one", + parentLaneId: "lane-primary", + }, + { + id: "lane-2", + name: "Lane two", + laneType: "worktree", + branchRef: "refs/heads/lane-two", + worktreePath: "/tmp/project-under-test/lane-two", + parentLaneId: "lane-primary", + }, + ] as any[]; + useAppStore.setState({ + project: { rootPath: "/tmp/project-under-test" } as any, + lanes, + selectedLaneId: "lane-1", + }); + + render( + +
+ +
+
+ +
+
, + ); + + const paneOne = screen.getByTestId("lane-one-pane"); + const paneTwo = screen.getByTestId("lane-two-pane"); + const modelTrigger = await within(paneOne).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 within(paneOne).findByRole("button", { name: "Select lane" })); + fireEvent.click(await screen.findByRole("button", { name: /Auto-create lane/i })); + + const textbox = await within(paneOne).findByRole("textbox"); + fireEvent.change(textbox, { target: { value: "Only lane one should show this launch." } }); + fireEvent.click(await within(paneOne).findByRole("button", { name: "Auto-create in background" })); + + await waitFor(() => { + expect(suggestLaneName).toHaveBeenCalledTimes(1); + expect(within(paneOne).getByText(/Creating lane for chat/i)).toBeTruthy(); + }); + expect(within(paneTwo).queryByText(/Creating lane for chat/i)).toBeNull(); + expect(within(paneTwo).queryByTestId("draft-launch-job")).toBeNull(); + }); + it("keeps every in-flight background draft launch visible past the completed-notice cap", async () => { const { suggestLaneName } = installAdeMocks({ sessions: [] }); suggestLaneName.mockImplementation(() => new Promise(() => { diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index c9af2cf49..252dcedd5 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -2382,10 +2382,11 @@ export function AgentChatPane({ () => [ "draft-launch-jobs", projectRoot?.trim() || "project", + laneId ?? "no-lane", surfaceProfile, - workDraftKind, + workDraftStorageKind, ].map(encodeURIComponent).join(":"), - [projectRoot, surfaceProfile, workDraftKind], + [laneId, projectRoot, surfaceProfile, workDraftStorageKind], ); const draftLaunchJobs = useAppStore((s) => s.draftLaunchJobsByScope[draftLaunchJobsScopeKey] ?? EMPTY_DRAFT_LAUNCH_JOBS); const setDraftLaunchJobsInStore = useAppStore((s) => s.setDraftLaunchJobs); diff --git a/docs/features/chat/README.md b/docs/features/chat/README.md index 9f63ccaa3..fc7dfb280 100644 --- a/docs/features/chat/README.md +++ b/docs/features/chat/README.md @@ -45,7 +45,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), Codex goal/token-usage DTOs, typed Codex goal control args, permission modes, pending input, completion reports, `PARALLEL_CHAT_MAX_ATTACHMENTS`, and parallel launch state DTOs. `AgentChatSessionSummary.linearIssueLinks?: SessionLinearIssueLink[]` carries the Linear issues attached to the session (chat or CLI), populated from `session_linear_issues` independent of any lane link. | -| `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 (`naming-lane` -> `creating-lane` -> `starting-session` -> `sending-prompt` -> `ready` | `failed`) and stores it in `appStore.draftLaunchJobsByScope` keyed by project, surface profile, and Work draft kind so loading/error strips survive pane remounts. The composer is cleared optimistically when the job starts rather than after it finishes; active jobs remain visible while terminal rows are pruned by scope. The pane renders status strips with Open/Restore for ready/failed jobs and Dismiss for terminal jobs. 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/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 (`naming-lane` -> `creating-lane` -> `starting-session` -> `sending-prompt` -> `ready` | `failed`) and stores it in `appStore.draftLaunchJobsByScope` keyed by project, lane, surface profile, and Work draft kind so loading/error strips survive pane remounts without leaking into another lane pane. The composer is cleared optimistically when the job starts rather than after it finishes; active jobs remain visible while terminal rows are pruned by scope. The pane renders status strips with Open/Restore for ready/failed jobs and Dismiss for terminal jobs. 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/lib/draftLaunchJobs.ts` | Shared renderer helper for Work draft-launch job DTOs and pruning. Owns `NativeControlState`, `DraftLaunchSnapshot`, `PreparedDraftLaunch`, `DraftLaunchJobStatus`, `DraftLaunchJob`, `isDraftLaunchJobTerminal`, and `pruneDraftLaunchJobs`; active jobs are kept ahead of terminal rows, with terminal rows filling the remaining retained slots and at least one terminal row retained alongside active jobs. | | `apps/desktop/src/renderer/state/appStore.ts` | Shared renderer state store. Besides project/lane/work selection, it persists user preferences such as `launchPromptClipboardEnabled` and `launchPromptClipboardNoticeEnabled`, mirrors them into per-project stores, and owns `draftLaunchJobsByScope` for Work draft launch status strips. | | `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. Codex goal lifecycle events render as compact user-facing rows (`Goal set`, `Goal paused`, `Goal cleared`) instead of raw JSON-RPC/status wording. | @@ -170,10 +170,11 @@ render them, but neither one *runs* them. `ready` | `failed`; auto-created lanes enter `naming-lane` while the prompt-derived branch name is being resolved, then `creating-lane` while the lane row/worktree is created. Jobs live in - `appStore.draftLaunchJobsByScope`, scoped by project, surface + `appStore.draftLaunchJobsByScope`, scoped by project, lane, surface profile, and Work draft kind, so a new chat pane or remount does not - drop loading/error state. The composer is cleared optimistically at - job creation rather than after the async flow completes, so users can + drop loading/error state and another lane pane does not inherit the + strip. The composer is cleared optimistically at job creation rather + than after the async flow completes, so users can begin composing the next prompt immediately. Active jobs remain visible; terminal rows are pruned per scope while keeping at least one terminal row alongside active jobs. The pane renders a status strip diff --git a/docs/features/chat/composer-and-ui.md b/docs/features/chat/composer-and-ui.md index f32c4d7b3..2c9846959 100644 --- a/docs/features/chat/composer-and-ui.md +++ b/docs/features/chat/composer-and-ui.md @@ -227,10 +227,11 @@ and a footer that contains the composer. 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. Jobs are stored in - `appStore.draftLaunchJobsByScope`, scoped by project, surface + `appStore.draftLaunchJobsByScope`, scoped by project, lane, surface profile, and Work draft kind, so loading/error strips survive a new - chat pane or remount. Foreground launches auto-open the result only if - the job is still the latest foreground job (tracked by + chat pane or remount without leaking into another lane pane. + 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 job strip with an Open action once ready. Failed jobs offer a Restore button that merges the snapshot From b70c337988caf8ea09c9456ab509feeaf5139862 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Tue, 2 Jun 2026 05:30:41 -0400 Subject: [PATCH 6/8] Allow stale draft launches to be hidden --- .../components/chat/AgentChatPane.test.tsx | 68 +++++++++++++++++++ .../components/chat/AgentChatPane.tsx | 34 ++++++++-- .../src/renderer/lib/draftLaunchJobs.ts | 6 ++ docs/features/chat/README.md | 7 +- docs/features/chat/composer-and-ui.md | 11 +-- 5 files changed, 115 insertions(+), 11 deletions(-) diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx index 6da995f28..a80babdff 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx @@ -17,6 +17,7 @@ import type { } from "../../../shared/types"; import { createDynamicCursorCliModelDescriptor, getModelById } from "../../../shared/modelRegistry"; import { invalidateAiDiscoveryCache } from "../../lib/aiDiscoveryCache"; +import { DRAFT_LAUNCH_JOB_STALE_AFTER_MS } from "../../lib/draftLaunchJobs"; import { useAppStore } from "../../state/appStore"; import { rememberRuntimeCatalog, @@ -931,6 +932,20 @@ function composerDraftStorageKeyForTest(args: { ].map(encodeURIComponent).join(":"); } +function draftLaunchJobsScopeKeyForTest(args: { + projectRoot: string; + laneId: string; + workDraftKind?: "work-start" | "chat-orchestrator"; +}) { + return [ + "draft-launch-jobs", + args.projectRoot, + args.laneId, + "standard", + args.workDraftKind ?? "work-start", + ].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"); @@ -3778,6 +3793,59 @@ describe("AgentChatPane submit recovery", () => { }); }); + it("allows stale active draft launch rows to be hidden", async () => { + installAdeMocks({ sessions: [] }); + const scopeKey = draftLaunchJobsScopeKeyForTest({ + projectRoot: "/tmp/project-under-test", + laneId: "lane-1", + }); + useAppStore.setState({ + draftLaunchJobsByScope: { + [scopeKey]: [{ + id: "stale-draft-launch", + mode: "background", + draftKind: "chat", + status: "naming-lane", + title: "Stale background launch", + laneId: null, + laneName: null, + sessionId: null, + error: null, + autoOpen: false, + createdAtMs: Date.now() - DRAFT_LAUNCH_JOB_STALE_AFTER_MS - 1, + snapshot: { + text: "Recover from a stuck launch.", + draft: "Recover from a stuck launch.", + modelId: "openai/gpt-5.4", + reasoningEffort: null, + codexFastMode: false, + executionMode: "focused", + interactionMode: "native", + nativeControls: {}, + attachments: [], + contextAttachments: [], + iosContextItems: [], + appControlContextItems: [], + builtInBrowserContextItems: [], + macosVmContextItems: [], + visualContextPrefix: "", + visualContextDisplayChips: "", + isLiteralSlashCommand: false, + }, + } as any], + }, + }); + + renderAutoCreateDraftPane(); + + expect(await screen.findByText(/Still working\. You can hide this status/i)).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "Hide stale launch status" })); + + await waitFor(() => { + expect(screen.queryByTestId("draft-launch-job")).toBeNull(); + }); + }); + it("keeps an auto-create failure visible when the launch fails after remount", async () => { const { send, suggestLaneName } = installAdeMocks({ sessions: [] }); let rejectSend!: (error: Error) => void; diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index 252dcedd5..54be1a4c7 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -139,6 +139,7 @@ import { shouldShowClaudeCacheTtl } from "../../lib/claudeCacheTtl"; import { getAgentChatModelsCached, getAiStatusCached, invalidateAiDiscoveryCache, peekAiStatusCached } from "../../lib/aiDiscoveryCache"; import { invalidateSessionListCache } from "../../lib/sessionListCache"; import { + isDraftLaunchJobStale, isDraftLaunchJobTerminal, pruneDraftLaunchJobs, type BackgroundLaunchNotice, @@ -356,6 +357,10 @@ function draftLaunchJobMessage(job: DraftLaunchJob): string { : `Ready to open ${draftLaunchKindLabel(job.draftKind)}${laneSuffix}.`; } +function staleDraftLaunchJobMessage(job: DraftLaunchJob): string { + return `${draftLaunchJobMessage(job)} Still working. You can hide this status while ADE continues in the background.`; +} + type AiStatusSnapshot = AiSettingsStatus & { runtimeConnections?: Record; }; @@ -2395,6 +2400,19 @@ export function AgentChatPane({ ) => { setDraftLaunchJobsInStore(draftLaunchJobsScopeKey, next); }, [draftLaunchJobsScopeKey, setDraftLaunchJobsInStore]); + const hasActiveDraftLaunchJobs = useMemo( + () => draftLaunchJobs.some((job) => !isDraftLaunchJobTerminal(job.status)), + [draftLaunchJobs], + ); + const [draftLaunchJobNowMs, setDraftLaunchJobNowMs] = useState(() => Date.now()); + useEffect(() => { + if (!hasActiveDraftLaunchJobs) return; + setDraftLaunchJobNowMs(Date.now()); + const intervalId = window.setInterval(() => { + setDraftLaunchJobNowMs(Date.now()); + }, 15 * 1000); + return () => window.clearInterval(intervalId); + }, [hasActiveDraftLaunchJobs]); const initialCompanionStateKey = lockSessionId ?? initialSessionId ?? (laneId ? `draft:${laneId}` : "draft"); const [sessions, setSessions] = useState([]); const [archivedSessions, setArchivedSessions] = useState([]); @@ -8516,6 +8534,7 @@ export function AgentChatPane({ const isFailed = job.status === "failed"; const isReady = job.status === "ready"; const isActiveJob = !isDraftLaunchJobTerminal(job.status); + const isStaleActiveJob = isDraftLaunchJobStale(job, draftLaunchJobNowMs); const canOpen = isReady && Boolean(job.laneId && job.laneName && job.sessionId); return (
{job.title}
-
{draftLaunchJobMessage(job)}
+
+ {isStaleActiveJob ? staleDraftLaunchJobMessage(job) : draftLaunchJobMessage(job)} +
@@ -8578,15 +8602,17 @@ export function AgentChatPane({ Open ) : null} - {!isActiveJob ? ( + {(!isActiveJob || isStaleActiveJob) ? (