From 0952d790e2418d7d14f7d45a78a4074f8b7c34cf Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Mon, 25 May 2026 23:29:11 -0400 Subject: [PATCH] =?UTF-8?q?ship:=20orchestrator=20smoke=20cleanup=20?= =?UTF-8?q?=E2=80=94=20simplify=20code,=20update=20docs,=20add=20work=20se?= =?UTF-8?q?ssion=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Simplify nested ternaries, inline one-use helpers, consolidate duplicated guards, restore precise types, and update feature docs after the large missions-removal and Cursor SDK cleanup merges. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/ade-cli/src/adeRpcServer.ts | 46 +-- apps/ade-cli/src/bootstrap.ts | 2 +- apps/ade-cli/src/cli.ts | 6 +- apps/ade-cli/src/headlessLinearServices.ts | 4 +- apps/ade-cli/src/multiProjectRpcServer.ts | 26 +- apps/ade-cli/src/tuiClient/connection.ts | 22 +- .../services/config/projectConfigService.ts | 23 +- .../components/chat/AgentChatComposer.tsx | 4 +- .../chat/AgentChatPane.submit.test.tsx | 131 ++++++- .../components/chat/AgentChatPane.tsx | 360 +++++++++++------- .../components/chat/chatSurfaceTheme.ts | 2 +- .../components/history/EventDetailPanel.tsx | 2 +- .../lanes/useLaneWorkSessions.test.ts | 36 ++ .../components/lanes/useLaneWorkSessions.ts | 23 +- .../shared/ModelPicker/ModelPicker.tsx | 13 +- .../terminals/WorkStartSurface.test.tsx | 2 +- .../components/terminals/WorkStartSurface.tsx | 14 +- .../terminals/WorkViewArea.test.tsx | 93 +++-- .../components/terminals/WorkViewArea.tsx | 14 +- .../components/terminals/cliLaunch.ts | 20 + .../terminals/useWorkSessions.test.ts | 65 +++- .../components/terminals/useWorkSessions.ts | 29 +- apps/desktop/src/shared/laneLinearIssue.ts | 7 +- .../src/shared/linearWorkflowPresets.ts | 90 +++-- docs/features/chat/README.md | 29 +- docs/features/lanes/README.md | 2 +- .../features/terminals-and-sessions/README.md | 34 +- .../terminals-and-sessions/ui-surfaces.md | 61 +-- 28 files changed, 759 insertions(+), 401 deletions(-) diff --git a/apps/ade-cli/src/adeRpcServer.ts b/apps/ade-cli/src/adeRpcServer.ts index 1169aaa32..1f21f40cf 100644 --- a/apps/ade-cli/src/adeRpcServer.ts +++ b/apps/ade-cli/src/adeRpcServer.ts @@ -2410,9 +2410,7 @@ function buildAdeInlineGuidanceForLane(laneWorktreePath: string | null | undefin return buildAdeCliInlineGuidance(getAdeAgentSkillRootsForPrompt({ cwd: laneWorktreePath ?? undefined })); } -function resolveRunContextLaneId(runtime: AdeRuntime, callerCtx: CallerContext): string | null { - void runtime; - void callerCtx; +function resolveRunContextLaneId(_runtime: AdeRuntime, _callerCtx: CallerContext): string | null { return null; } @@ -2864,12 +2862,8 @@ async function resolveEffectiveCallerContext( return callerCtx; } -function isStandaloneChatCaller(callerCtx: CallerContext): boolean { - return callerCtx.standaloneChatSession; -} - function isToolHiddenForStandaloneChat(name: string, callerCtx: CallerContext): boolean { - return isStandaloneChatCaller(callerCtx) && STANDALONE_CHAT_HIDDEN_TOOL_NAMES.has(name); + return callerCtx.standaloneChatSession && STANDALONE_CHAT_HIDDEN_TOOL_NAMES.has(name); } function isLocalComputerUseAllowed(callerCtx: CallerContext): boolean { @@ -2922,8 +2916,7 @@ async function listToolSpecsForSession(runtime: AdeRuntime, session: SessionStat return allVisibleTools.filter((tool) => !isToolHiddenForStandaloneChat(tool.name, callerCtx)); } -function parseInitializeIdentity(runtime: AdeRuntime, params: unknown): SessionIdentity { - void runtime; +function parseInitializeIdentity(_runtime: AdeRuntime, params: unknown): SessionIdentity { const data = safeObject(params); const identity = safeObject(data.identity); const envContext = resolveEnvCallerContext(); @@ -3094,7 +3087,6 @@ async function buildLaneStatus(runtime: AdeRuntime, laneId: string): Promise Promise).apply(service, argsList) - : hasScalarArg - ? await (callable as (arg: unknown) => Promise).call(service, toolArgs.arg) - : await (callable as (args?: Record) => Promise).call( - service, - Object.keys(rawObjectArgs).length > 0 ? rawObjectArgs : undefined - ); + let result: unknown; + if (argsList) { + result = await (callable as (...params: unknown[]) => Promise).apply(service, argsList); + } else if (hasScalarArg) { + result = await (callable as (arg: unknown) => Promise).call(service, toolArgs.arg); + } else { + result = await (callable as (args?: Record) => Promise).call( + service, + Object.keys(rawObjectArgs).length > 0 ? rawObjectArgs : undefined, + ); + } const record = isRecord(result) ? result : null; const statusHints = { operationId: typeof record?.operationId === "string" ? record.operationId : null, @@ -4076,11 +4069,10 @@ async function runTool(args: { }); } const answered = result.decision !== "decline" && result.decision !== "cancel"; - const outcome = result.decision === "decline" - ? "declined" - : result.decision === "cancel" - ? "cancelled" - : "answered"; + let outcome: "answered" | "declined" | "cancelled"; + if (result.decision === "decline") outcome = "declined"; + else if (result.decision === "cancel") outcome = "cancelled"; + else outcome = "answered"; return buildAskUserResult({ awaitingUserResponse: false, blocking: false, diff --git a/apps/ade-cli/src/bootstrap.ts b/apps/ade-cli/src/bootstrap.ts index f78db40e1..5be8ffd9e 100644 --- a/apps/ade-cli/src/bootstrap.ts +++ b/apps/ade-cli/src/bootstrap.ts @@ -718,7 +718,7 @@ export async function createAdeRuntime(args: { projectRoot, logger, onEvent: (event) => pushEvent("runtime", { type: "computer_use_event", event }), - } as Parameters[0]); + }); const iosSimulatorService = chatOnlyRuntime ? null : createIosSimulatorService({ diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index 836d8f2ae..87134a46d 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -10127,10 +10127,10 @@ function readMachineRuntimeInfo(value: unknown): MachineRuntimeInfo { } const pid = value.runtimeInfo.pid; return { - version: asString(value.runtimeInfo.version)?.trim() || null, - buildHash: asString(value.runtimeInfo.buildHash)?.trim() || null, + version: asString(value.runtimeInfo.version), + buildHash: asString(value.runtimeInfo.buildHash), defaultRole: normalizeAdeRuntimeRole(value.runtimeInfo.defaultRole), - projectRoot: asString(value.runtimeInfo.projectRoot)?.trim() || null, + projectRoot: asString(value.runtimeInfo.projectRoot), pid: typeof pid === "number" && Number.isFinite(pid) && pid > 0 ? Math.floor(pid) diff --git a/apps/ade-cli/src/headlessLinearServices.ts b/apps/ade-cli/src/headlessLinearServices.ts index bc406e2c6..142ca9a01 100644 --- a/apps/ade-cli/src/headlessLinearServices.ts +++ b/apps/ade-cli/src/headlessLinearServices.ts @@ -1647,7 +1647,7 @@ export function createHeadlessLinearServices( outboundService, prService, computerUseArtifactBrokerService: args.computerUseArtifactBrokerService, - } as Parameters[0]); + }); const dispatcherService = createLinearDispatcherServiceImpl({ db: args.db, projectId: args.projectId, @@ -1662,7 +1662,7 @@ export function createHeadlessLinearServices( workerTaskSessionService, prService, onEvent: args.onLinearWorkflowEvent ?? (() => {}), - } as Parameters[0]); + }); const syncService = createLinearSyncServiceImpl({ db: args.db, logger: args.logger, diff --git a/apps/ade-cli/src/multiProjectRpcServer.ts b/apps/ade-cli/src/multiProjectRpcServer.ts index de57bab40..c0002f8c9 100644 --- a/apps/ade-cli/src/multiProjectRpcServer.ts +++ b/apps/ade-cli/src/multiProjectRpcServer.ts @@ -378,19 +378,19 @@ export function createMultiProjectRpcRequestHandler( return syncService; }; - const resolveRuntimeEnvInfo = () => ({ - buildHash: - typeof process.env.ADE_RUNTIME_BUILD_HASH === "string" && - process.env.ADE_RUNTIME_BUILD_HASH.trim() - ? process.env.ADE_RUNTIME_BUILD_HASH.trim() - : null, - defaultRole: normalizeAdeRuntimeRole(process.env.ADE_DEFAULT_ROLE), - projectRoot: - typeof process.env.ADE_PROJECT_ROOT === "string" && - process.env.ADE_PROJECT_ROOT.trim() - ? path.resolve(process.env.ADE_PROJECT_ROOT.trim()) - : null, - }); + const trimmedEnvOrNull = (key: string): string | null => { + const value = process.env[key]; + return typeof value === "string" && value.trim() ? value.trim() : null; + }; + + const resolveRuntimeEnvInfo = () => { + const projectRoot = trimmedEnvOrNull("ADE_PROJECT_ROOT"); + return { + buildHash: trimmedEnvOrNull("ADE_RUNTIME_BUILD_HASH"), + defaultRole: normalizeAdeRuntimeRole(process.env.ADE_DEFAULT_ROLE), + projectRoot: projectRoot ? path.resolve(projectRoot) : null, + }; + }; const handler = (async (request: JsonRpcRequest): Promise => { const method = typeof request.method === "string" ? request.method : ""; diff --git a/apps/ade-cli/src/tuiClient/connection.ts b/apps/ade-cli/src/tuiClient/connection.ts index 8ef1285d7..7c9318597 100644 --- a/apps/ade-cli/src/tuiClient/connection.ts +++ b/apps/ade-cli/src/tuiClient/connection.ts @@ -384,22 +384,20 @@ class StaleAdeSocketError extends Error { } } +function trimmedStringOrNull(value: unknown): string | null { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + return trimmed || null; +} + function readAttachedRuntimeInfo(result: InitializeResult): AttachedRuntimeInfo { const runtimeInfo = result.runtimeInfo; const pid = runtimeInfo?.pid; + const projectRoot = trimmedStringOrNull(runtimeInfo?.projectRoot); return { - buildHash: - typeof runtimeInfo?.buildHash === "string" && runtimeInfo.buildHash.trim() - ? runtimeInfo.buildHash.trim() - : null, - defaultRole: - typeof runtimeInfo?.defaultRole === "string" && runtimeInfo.defaultRole.trim() - ? runtimeInfo.defaultRole.trim() - : null, - projectRoot: - typeof runtimeInfo?.projectRoot === "string" && runtimeInfo.projectRoot.trim() - ? path.resolve(runtimeInfo.projectRoot.trim()) - : null, + buildHash: trimmedStringOrNull(runtimeInfo?.buildHash), + defaultRole: trimmedStringOrNull(runtimeInfo?.defaultRole), + projectRoot: projectRoot ? path.resolve(projectRoot) : null, pid: typeof pid === "number" && Number.isFinite(pid) && pid > 0 ? Math.floor(pid) diff --git a/apps/desktop/src/main/services/config/projectConfigService.ts b/apps/desktop/src/main/services/config/projectConfigService.ts index 3dd78c308..c2d92bee3 100644 --- a/apps/desktop/src/main/services/config/projectConfigService.ts +++ b/apps/desktop/src/main/services/config/projectConfigService.ts @@ -594,21 +594,18 @@ function coerceAutomationExecution(value: unknown): AutomationExecution | undefi if (kind === "agent-session") { const legacyTitle = kindRaw === "mission" ? legacyMissionPrompt(value) : undefined; - const session = isRecord(value.session) + const sessionTitle = isRecord(value.session) + ? firstNonEmptyString(value.session.title, legacyTitle) + : legacyTitle; + const sessionReasoningEffort = isRecord(value.session) ? asString(value.session.reasoningEffort)?.trim() : undefined; + const sessionCodexFastMode = isRecord(value.session) ? asBool(value.session.codexFastMode) : undefined; + const session = sessionTitle || sessionReasoningEffort || sessionCodexFastMode != null ? { - ...(asString(value.session.title)?.trim() - ? { title: asString(value.session.title)!.trim() } - : legacyTitle - ? { title: legacyTitle } - : {}), - ...(asString(value.session.reasoningEffort)?.trim() - ? { reasoningEffort: asString(value.session.reasoningEffort)!.trim() } - : {}), - ...(asBool(value.session.codexFastMode) != null ? { codexFastMode: asBool(value.session.codexFastMode) } : {}), + ...(sessionTitle ? { title: sessionTitle } : {}), + ...(sessionReasoningEffort ? { reasoningEffort: sessionReasoningEffort } : {}), + ...(sessionCodexFastMode != null ? { codexFastMode: sessionCodexFastMode } : {}), } - : legacyTitle - ? { title: legacyTitle } - : undefined; + : undefined; return { kind, ...sharedLaneFields, diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx index 5bb56c022..36d4f3dc9 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx @@ -2608,9 +2608,7 @@ export function AgentChatComposer({ captureRichSelection(); }, [captureRichSelection, getRichCursorTextOffset, onDraftChange, serializeRichEditor]); - const singleModelBlockedMessage = (modelUnavailableMessage?.trim() ?? "").length > 0 - ? modelUnavailableMessage - : null; + const singleModelBlockedMessage = modelUnavailableMessage?.trim() ? modelUnavailableMessage : null; const singleModelReady = Boolean(modelId) && !singleModelBlockedMessage; const submitComposerDraft = useCallback(() => { diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.submit.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.submit.test.tsx index 7d4512a19..30cebc1c6 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.submit.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.submit.test.tsx @@ -476,7 +476,25 @@ const originalAde = globalThis.window.ade; const originalNavigatorPlatform = window.navigator.platform; let iosEventListener: ((event: { type: string; chatSessionId?: string; laneId?: string; mode?: string }) => void) | null = null; +function installMatchMediaMock(): void { + if (typeof window.matchMedia === "function") return; + Object.defineProperty(window, "matchMedia", { + writable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); +} + beforeEach(() => { + installMatchMediaMock(); invalidateAiDiscoveryCache(); resetModelPickerRuntimeCatalogForTests(); window.localStorage.clear(); @@ -632,6 +650,8 @@ function renderAutoCreateDraftPane(args?: { session: AgentChatSession, options?: AgentChatSessionCreatedOptions, ) => void | Promise; + workDraftKind?: "chat" | "cli"; + onLaunchCliSession?: React.ComponentProps["onLaunchCliSession"]; }) { const lanes = [ { @@ -667,9 +687,11 @@ function renderAutoCreateDraftPane(args?: { laneId="lane-1" forceDraftMode embeddedWorkLayout + workDraftKind={args?.workDraftKind} availableLanes={lanes} onLaneChange={vi.fn()} onSessionCreated={args?.onSessionCreated} + onLaunchCliSession={args?.onLaunchCliSession} /> @@ -2708,7 +2730,7 @@ describe("AgentChatPane submit recovery", () => { { activate: false, source: "draft-launch" }, ); expect(screen.getByText("Launched in background-lane")).toBeTruthy(); - expect(screen.getByRole("button", { name: "Dismiss launched chat notice" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "Dismiss launch notice" })).toBeTruthy(); }); expect(screen.getByTestId("location").textContent).toBe("/work"); @@ -2876,6 +2898,7 @@ describe("AgentChatPane submit recovery", () => { title: "Run the unified CLI launch", startupDelayMs: 180, tracked: true, + disposition: "foreground", })); }); const launchArgs = onLaunchCliSession.mock.calls[0]?.[0]; @@ -2887,6 +2910,112 @@ describe("AgentChatPane submit recovery", () => { expect(send).not.toHaveBeenCalled(); }); + it("auto-creates a lane for a foreground CLI session draft", async () => { + const { send, create, createLane, suggestLaneName } = installAdeMocks({ sessions: [] }); + const onLaunchCliSession = vi.fn().mockResolvedValue({ sessionId: "terminal-created", ptyId: "pty-created" }); + suggestLaneName.mockResolvedValue("cli-auto-lane"); + createLane.mockResolvedValue({ + id: "lane-created", + name: "cli-auto-lane", + laneType: "worktree", + branchRef: "refs/heads/cli-auto-lane", + worktreePath: "/tmp/project-under-test/cli-auto-lane", + parentLaneId: "lane-primary", + }); + + renderAutoCreateDraftPane({ workDraftKind: "cli", onLaunchCliSession }); + + 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: "Launch a CLI agent on a new lane." } }); + fireEvent.click(await screen.findByRole("button", { name: "Send" })); + + await waitFor(() => { + expect(suggestLaneName).toHaveBeenCalledWith(expect.objectContaining({ + laneId: "lane-primary", + prompt: "Launch a CLI agent on a new lane.", + modelId: "openai/gpt-5.4", + })); + expect(createLane).toHaveBeenCalledWith({ + name: "cli-auto-lane", + parentLaneId: "lane-primary", + }); + expect(onLaunchCliSession).toHaveBeenCalledWith(expect.objectContaining({ + laneId: "lane-created", + profile: "codex", + title: "Launch a CLI agent on a new lane", + startupDelayMs: 180, + tracked: true, + disposition: "foreground", + })); + }); + const launchArgs = onLaunchCliSession.mock.calls[0]?.[0]; + expect(launchArgs.startupCommand).toContain("Launch a CLI agent on a new lane."); + expect(create).not.toHaveBeenCalled(); + expect(send).not.toHaveBeenCalled(); + }); + + it("launches a CLI session draft in the background without stealing focus", async () => { + const { send, create, createLane, suggestLaneName } = installAdeMocks({ sessions: [] }); + const onLaunchCliSession = vi.fn().mockResolvedValue({ sessionId: "terminal-created", ptyId: "pty-created" }); + suggestLaneName.mockResolvedValue("background-cli-lane"); + createLane.mockResolvedValue({ + id: "lane-created", + name: "background-cli-lane", + laneType: "worktree", + branchRef: "refs/heads/background-cli-lane", + worktreePath: "/tmp/project-under-test/background-cli-lane", + parentLaneId: "lane-primary", + }); + + renderAutoCreateDraftPane({ workDraftKind: "cli", onLaunchCliSession }); + + 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: "Launch this CLI session in the background." } }); + fireEvent.click(await screen.findByRole("button", { name: "Launch in background" })); + + await waitFor(() => { + expect(onLaunchCliSession).toHaveBeenCalledWith(expect.objectContaining({ + laneId: "lane-created", + profile: "codex", + startupDelayMs: 180, + tracked: true, + disposition: "background", + })); + expect(screen.getByText("Launched in background-cli-lane")).toBeTruthy(); + expect(screen.getByRole("button", { name: "Dismiss launch notice" })).toBeTruthy(); + }); + const launchArgs = onLaunchCliSession.mock.calls[0]?.[0]; + expect(launchArgs.startupCommand).toContain("Launch this CLI session in the background."); + expect(create).not.toHaveBeenCalled(); + expect(send).not.toHaveBeenCalled(); + expect(screen.getByTestId("location").textContent).toBe("/work"); + + fireEvent.click(screen.getByRole("button", { name: "Open" })); + await waitFor(() => { + expect(screen.getByTestId("location").textContent).toBe("/work?laneId=lane-created&sessionId=terminal-created"); + }); + }); + it("keeps immediate agent events for a freshly created chat before session refresh catches up", async () => { const { create, emitChatEvent } = installAdeMocks({ sessions: [] }); const send = vi.fn().mockImplementation(async ({ sessionId }: { sessionId: string }) => { diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index f99f6d743..f5cce5929 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -117,7 +117,8 @@ import { buildTrackedCliLaunchCommand, LAUNCH_PROFILE_TITLE, type CliProvider, - type LaunchProfile, + type WorkPtyLaunchArgs, + type WorkPtyLaunchResult, } from "../terminals/cliLaunch"; import { ClaudeCacheTtlBadge } from "../shared/ClaudeCacheTtlBadge"; import { shouldShowClaudeCacheTtl } from "../../lib/claudeCacheTtl"; @@ -259,6 +260,22 @@ type BackgroundLaunchNotice = { laneId: string; laneName: string; sessionId: string; + draftKind: "chat" | "cli"; +}; + +type DraftLaunchMode = "foreground" | "background"; +type DraftLaunchKind = BackgroundLaunchNotice["draftKind"]; + +type DraftLaunchLaneTarget = { + laneId: string; + laneName: string; + worktreePath: string | null; + autoCreated: boolean; +}; + +type StartedDraftLaunch = { + sessionId: string; + draftKind: DraftLaunchKind; }; function createTemporaryAutoLaneName(date = new Date()): string { @@ -1789,17 +1806,7 @@ export function AgentChatPane({ onInitialLinearIssueContextConsumed?: () => void; onSessionCreated?: (session: AgentChatSession, options?: AgentChatSessionCreatedOptions) => void | Promise; workDraftKind?: "chat" | "cli"; - onLaunchCliSession?: (args: { - laneId: string; - profile: LaunchProfile; - title?: string; - startupCommand?: string; - startupDelayMs?: number; - command?: string; - args?: string[]; - env?: Record; - tracked?: boolean; - }) => Promise; + onLaunchCliSession?: (args: WorkPtyLaunchArgs) => Promise; onOpenShellSession?: (laneId: string) => void | Promise; /** Available lanes for the lane selector in empty state (full `LaneSummary` includes `branchRef` for branch sublines in the menu). */ availableLanes?: Array<{ id: string; name: string; color?: string | null; branchRef?: string | null; laneType?: string | null }>; @@ -2266,8 +2273,7 @@ export function AgentChatPane({ const baseEvents = shouldRenderOptimistic ? [...selectedEvents, optimisticOutgoingMessage.envelope] : selectedEvents; - const renderableEvents = baseEvents.filter((envelope) => !envelope.event.type.startsWith("subagent.")); - const displayEvents = renderableEvents; + const displayEvents = baseEvents.filter((envelope) => !envelope.event.type.startsWith("subagent.")); const promotedTurnId = selectedSession?.cursorPromotedTurnId; const cloudAgentId = selectedSession?.cursorCloudAgentId; if (!promotedTurnId || !cloudAgentId) return displayEvents; @@ -4935,7 +4941,7 @@ export function AgentChatPane({ setMacosVmContextItems((current) => (current.length ? current : snapshot.macosVmContextItems)); }, []); - const openLaunchedDraftChat = useCallback((launch: BackgroundLaunchNotice) => { + const openLaunchedDraftSession = useCallback((launch: BackgroundLaunchNotice) => { setBackgroundLaunchNotice(null); if (projectRoot) { setWorkViewState(projectRoot, (prev) => ({ @@ -4945,7 +4951,7 @@ export function AgentChatPane({ : [...prev.openItemIds, launch.sessionId], activeItemId: launch.sessionId, selectedItemId: launch.sessionId, - draftKind: "chat", + draftKind: launch.draftKind, viewMode: "tabs", })); setLaneWorkViewState(projectRoot, launch.laneId, (prev) => ({ @@ -4955,7 +4961,7 @@ export function AgentChatPane({ : [...prev.openItemIds, launch.sessionId], activeItemId: launch.sessionId, selectedItemId: launch.sessionId, - draftKind: "chat", + draftKind: launch.draftKind, viewMode: "tabs", })); } @@ -4966,7 +4972,7 @@ export function AgentChatPane({ navigate(`/lanes?laneId=${encodeURIComponent(launch.laneId)}&sessionId=${encodeURIComponent(launch.sessionId)}&focus=single`); }, [embeddedWorkLayout, navigate, projectRoot, setLaneWorkViewState, setWorkViewState]); - const resolveDraftLaunchLane = useCallback(async (snapshot: DraftLaunchSnapshot): Promise<{ laneId: string; laneName: string }> => { + const resolveDraftLaunchLane = useCallback(async (snapshot: DraftLaunchSnapshot): 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") @@ -4981,34 +4987,164 @@ export function AgentChatPane({ }); const createdLane = await window.ade.lanes.create({ name: laneName, parentLaneId: primaryLane.id }); await refreshLanesStore().catch((refreshError: unknown) => { - console.warn("draft chat launch lane refresh failed", refreshError); + console.warn("draft launch lane refresh failed", refreshError); + }); + return { + laneId: createdLane.id, + laneName: createdLane.name, + worktreePath: createdLane.worktreePath ?? null, + autoCreated: true, + }; + } + if (!laneId) throw new Error("Select a lane before launching."); + const launchLane = lanes.find((lane) => lane.id === laneId); + const laneName = availableLanes?.find((lane) => lane.id === laneId)?.name ?? launchLane?.name ?? laneDisplayLabel ?? laneId; + return { + laneId, + laneName, + worktreePath: launchLane?.worktreePath ?? projectRoot ?? null, + autoCreated: false, + }; + }, [availableLanes, draftLaunchTargetIsAutoCreate, laneDisplayLabel, laneId, lanes, modelId, projectRoot, refreshLanesStore]); + + const clearDraftLaunchComposer = useCallback((snapshot: DraftLaunchSnapshot) => { + setDraft((current) => (current === snapshot.draft ? "" : current)); + setAttachments([]); + setContextAttachments([]); + setIosElementContextItems([]); + setAppControlContextItems([]); + setBuiltInBrowserContextItems([]); + setMacosVmContextItems([]); + }, []); + + const cleanupDraftChatSession = useCallback(async ( + session: AgentChatSession, + targetLane: DraftLaunchLaneTarget, + ) => { + await window.ade.agentChat.delete({ sessionId: session.id }).catch((cleanupError: unknown) => { + console.warn("draft chat launch session cleanup failed", cleanupError); + }); + loadedHistoryRef.current.delete(session.id); + localTouchBySessionRef.current.delete(session.id); + optimisticSessionIdsRef.current.delete(session.id); + knownSessionIdsRef.current.delete(session.id); + invalidateSessionListCache(); + if (targetLane.laneId === laneId) { + await refreshSessions().catch(() => undefined); + } + }, [laneId, refreshSessions]); + + const startDraftChatLaunch = useCallback(async ( + prepared: PreparedDraftLaunch, + targetLane: DraftLaunchLaneTarget, + ): Promise => { + let createdSession: AgentChatSession | null = null; + try { + createdSession = await createSessionForLane(targetLane.laneId, { select: false }); + touchSession(createdSession.id); + await window.ade.agentChat.send({ + sessionId: createdSession.id, + text: prepared.finalText, + displayText: prepared.finalDisplayText || "Selected visual app context", + attachments: prepared.selectedAttachments, + contextAttachments: prepared.selectedContextAttachments, + reasoningEffort, + executionMode, + interactionMode: createdSession.provider === "claude" ? interactionMode : null, + ...(createdSession.provider === "cursor" ? { runtime: "local" as const } : {}), + }); + notifySessionCreated(createdSession, { + activate: false, + source: "draft-launch", }); - return { laneId: createdLane.id, laneName: createdLane.name }; + return { + sessionId: createdSession.id, + draftKind: "chat", + }; + } catch (launchError) { + if (createdSession) { + await cleanupDraftChatSession(createdSession, targetLane); + } + throw launchError; } - if (!laneId) throw new Error("Select a lane before launching chat."); - const laneName = availableLanes?.find((lane) => lane.id === laneId)?.name ?? laneDisplayLabel ?? laneId; - return { laneId, laneName }; - }, [availableLanes, draftLaunchTargetIsAutoCreate, laneDisplayLabel, laneId, modelId, refreshLanesStore]); + }, [ + cleanupDraftChatSession, + createSessionForLane, + executionMode, + interactionMode, + notifySessionCreated, + reasoningEffort, + touchSession, + ]); - const launchDraftChat = useCallback(async (mode: "foreground" | "background") => { + const startDraftCliLaunch = useCallback(async ( + prepared: PreparedDraftLaunch, + targetLane: DraftLaunchLaneTarget, + mode: DraftLaunchMode, + ): Promise => { + if (!onLaunchCliSession) throw new Error("CLI sessions are not available from this surface."); + if (!modelId) throw new Error("Select a model before launching a CLI session."); + const desc = getModelById(modelId); + if (!desc) throw new Error("Select a model before launching a CLI session."); + const provider = resolveCliProviderForModel(desc) ?? "opencode"; + const runtimeModel = getRuntimeModelRefForDescriptor(desc, provider); + const permissionMode = cliPermissionModeFromNativeControls(provider, currentNativeControls); + const cliPrompt = buildWorkCliInitialPrompt({ + text: prepared.finalText, + attachments: prepared.selectedAttachments, + contextAttachments: prepared.selectedContextAttachments, + }); + if (!cliPrompt.trim().length) throw new Error("Enter a prompt or attach context before launching a CLI session."); + const cliSessionId = provider === "claude" ? createClaudeSessionIdForCliLaunch() : undefined; + const launch = buildTrackedCliLaunchCommand({ + provider, + permissionMode, + ...(cliSessionId ? { sessionId: cliSessionId } : {}), + model: runtimeModel, + reasoningEffort, + initialPrompt: cliPrompt, + laneWorktreePath: targetLane.worktreePath ?? projectRoot, + }); + const result = await onLaunchCliSession({ + laneId: targetLane.laneId, + profile: provider, + title: workCliTitleFromPrompt(prepared.text || prepared.finalDisplayText || prepared.finalText, LAUNCH_PROFILE_TITLE[provider]), + startupCommand: launch.startupCommand, + startupDelayMs: workCliStartupDelayMs, + ...(launch.env ? { env: launch.env } : {}), + tracked: true, + disposition: mode, + }); + return { + sessionId: result.sessionId, + draftKind: "cli", + }; + }, [ + currentNativeControls, + modelId, + onLaunchCliSession, + projectRoot, + reasoningEffort, + ]); + + const launchDraftSession = useCallback(async (kind: DraftLaunchKind, mode: DraftLaunchMode) => { if (submitInFlightRef.current || busy || backgroundLaunchBusy || parallelLaunchBusy) { if (submitInFlightRef.current) { setError("Still sending the previous message. Wait a moment and try again."); } return; } - if (selectedSessionId || workDraftKind !== "chat") return; - if (constrainedModelSelectionError) { - setError(constrainedModelSelectionError); - return; - } + if (kind === "chat" && (selectedSessionId || workDraftKind !== "chat")) return; + if (kind === "cli" && (!isWorkCliLaunchDraft || !onLaunchCliSession)) return; if (!modelId) { setError("Select a model first"); return; } const snapshot = buildDraftLaunchSnapshotForCurrentState(); if (!snapshot) { - setError("Add a message before sending."); + setError(kind === "cli" + ? "Enter a prompt or attach context before launching a CLI session." + : "Add a message before sending."); return; } @@ -5020,68 +5156,35 @@ export function AgentChatPane({ setBackgroundLaunchNotice(null); draftSelectionLockedRef.current = mode === "background"; - let targetLane: { laneId: string; laneName: string } | null = null; - let createdSession: AgentChatSession | null = null; + let targetLane: DraftLaunchLaneTarget | null = null; try { targetLane = await resolveDraftLaunchLane(snapshot); const prepared = await prepareDraftLaunchForSend(snapshot, targetLane.laneId); - createdSession = await createSessionForLane(targetLane.laneId, { select: false }); - setDraft((current) => (current === snapshot.draft ? "" : current)); - setAttachments([]); - setContextAttachments([]); - setIosElementContextItems([]); - setAppControlContextItems([]); - setBuiltInBrowserContextItems([]); - setMacosVmContextItems([]); - touchSession(createdSession.id); - await window.ade.agentChat.send({ - sessionId: createdSession.id, - text: prepared.finalText, - displayText: prepared.finalDisplayText || "Selected visual app context", - attachments: prepared.selectedAttachments, - contextAttachments: prepared.selectedContextAttachments, - reasoningEffort, - executionMode, - interactionMode: createdSession.provider === "claude" ? interactionMode : null, - ...(createdSession.provider === "cursor" ? { runtime: "local" as const } : {}), - }); - notifySessionCreated(createdSession, { - activate: false, - source: "draft-launch", - }); + clearDraftLaunchComposer(snapshot); + const launched = kind === "chat" + ? await startDraftChatLaunch(prepared, targetLane) + : await startDraftCliLaunch(prepared, targetLane, mode); invalidateSessionListCache(); - if (targetLane.laneId === laneId) { + if (launched.draftKind === "chat" && targetLane.laneId === laneId) { void refreshSessions().catch(() => {}); } const launch = { laneId: targetLane.laneId, laneName: targetLane.laneName, - sessionId: createdSession.id, + sessionId: launched.sessionId, + draftKind: launched.draftKind, }; - if (mode === "foreground") { - openLaunchedDraftChat(launch); - } else { + if (mode === "foreground" && launched.draftKind === "chat") { + openLaunchedDraftSession(launch); + } else if (mode === "background") { setSelectedSessionId(null); setBackgroundLaunchNotice(launch); } } catch (launchError) { - if (createdSession) { - await window.ade.agentChat.delete({ sessionId: createdSession.id }).catch((cleanupError: unknown) => { - console.warn("draft chat launch session cleanup failed", cleanupError); - }); - loadedHistoryRef.current.delete(createdSession.id); - localTouchBySessionRef.current.delete(createdSession.id); - optimisticSessionIdsRef.current.delete(createdSession.id); - knownSessionIdsRef.current.delete(createdSession.id); - invalidateSessionListCache(); - if (targetLane?.laneId === laneId) { - await refreshSessions().catch(() => undefined); - } - } - if (draftLaunchTargetIsAutoCreate && targetLane) { + if (targetLane?.autoCreated) { await window.ade.lanes.delete({ laneId: targetLane.laneId, force: true }).catch((cleanupError: unknown) => { - console.warn("draft chat launch lane cleanup failed", cleanupError); + console.warn(`draft ${kind} launch lane cleanup failed`, cleanupError); }); await refreshLanesStore().catch(() => undefined); } @@ -5097,26 +5200,32 @@ export function AgentChatPane({ backgroundLaunchBusy, buildDraftLaunchSnapshotForCurrentState, busy, + clearDraftLaunchComposer, constrainedModelSelectionError, createSessionForLane, draftLaunchTargetIsAutoCreate, executionMode, interactionMode, + isWorkCliLaunchDraft, laneId, modelId, - openLaunchedDraftChat, + onLaunchCliSession, + openLaunchedDraftSession, parallelLaunchBusy, prepareDraftLaunchForSend, - reasoningEffort, refreshLanesStore, refreshSessions, resolveDraftLaunchLane, restoreDraftLaunchSnapshot, selectedSessionId, - touchSession, + startDraftChatLaunch, + startDraftCliLaunch, workDraftKind, ]); + const launchDraftChat = useCallback((mode: DraftLaunchMode) => launchDraftSession("chat", mode), [launchDraftSession]); + const launchDraftCliSession = useCallback((mode: DraftLaunchMode) => launchDraftSession("cli", mode), [launchDraftSession]); + const handoffSession = useCallback(async (mode: "brief" | "fork" = "brief") => { if (!canShowHandoff || !selectedSessionId || !handoffModelId || handoffBlocked) return; setError(null); @@ -5498,15 +5607,21 @@ export function AgentChatPane({ return; } + if (isWorkCliLaunchDraft) { + await launchDraftCliSession("foreground"); + return; + } + + if (constrainedModelSelectionError) { + setError(constrainedModelSelectionError); + return; + } + if (!modelId) { + setError("Select a model first"); + return; + } + if (draftLaunchTargetIsAutoCreate && selectedSessionId == null && workDraftKind === "chat") { - if (constrainedModelSelectionError) { - setError(constrainedModelSelectionError); - return; - } - if (!modelId) { - setError("Select a model first"); - return; - } await launchDraftChat("foreground"); return; } @@ -5519,26 +5634,9 @@ export function AgentChatPane({ && !lockSessionId && !draftLaunchTargetIsAutoCreate ) { - if (constrainedModelSelectionError) { - setError(constrainedModelSelectionError); - return; - } - if (!modelId) { - setError("Select a model first"); - return; - } await launchDraftChat("foreground"); return; } - - if (constrainedModelSelectionError) { - setError(constrainedModelSelectionError); - return; - } - if (!modelId) { - setError("Select a model first"); - return; - } const text = draft.trim(); const iosContextSnapshot = [...iosElementContextItems]; const appControlContextSnapshot = [...appControlContextItems]; @@ -5714,44 +5812,6 @@ export function AgentChatPane({ }, }); - if (isWorkCliLaunchDraft && onLaunchCliSession) { - const desc = getModelById(modelId); - if (!desc) throw new Error("Select a model before launching a CLI session."); - const provider = resolveCliProviderForModel(desc) ?? "opencode"; - const runtimeModel = getRuntimeModelRefForDescriptor(desc, provider); - const permissionMode = cliPermissionModeFromNativeControls(provider, currentNativeControls); - const cliPrompt = buildWorkCliInitialPrompt({ - text: finalText, - attachments: selectedAttachments, - contextAttachments: selectedContextAttachments, - }); - if (!cliPrompt.trim().length) throw new Error("Enter a prompt or attach context before launching a CLI session."); - const sessionId = provider === "claude" ? createClaudeSessionIdForCliLaunch() : undefined; - const launch = buildTrackedCliLaunchCommand({ - provider, - permissionMode, - ...(sessionId ? { sessionId } : {}), - model: runtimeModel, - reasoningEffort, - initialPrompt: cliPrompt, - laneWorktreePath: activeLaneWorktreePath, - }); - await onLaunchCliSession({ - laneId, - profile: provider, - title: workCliTitleFromPrompt(text || finalDisplayText || finalText, LAUNCH_PROFILE_TITLE[provider]), - startupCommand: launch.startupCommand, - startupDelayMs: workCliStartupDelayMs, - ...(launch.env ? { env: launch.env } : {}), - tracked: true, - }); - setIosElementContextItems([]); - setAppControlContextItems([]); - setBuiltInBrowserContextItems([]); - setMacosVmContextItems([]); - return; - } - if (sessionId && !turnActive && ( selectedModelChanged || selectedCodexFastModeChanged @@ -5896,8 +5956,10 @@ export function AgentChatPane({ executionMode, hasComputerUseSelectionChanged, interactionMode, + isWorkCliLaunchDraft, laneId, launchDraftChat, + launchDraftCliSession, launchModeEditable, modelSelectionConstrained, modelId, @@ -5924,9 +5986,7 @@ export function AgentChatPane({ embeddedWorkLayout, lastLaunchConfigStorageKey, projectRoot, - activeLaneWorktreePath, navigate, - onLaunchCliSession, buildNativeControlPayloadForSlot, refreshLanesStore, persistParallelLaunchState, @@ -6270,7 +6330,7 @@ export function AgentChatPane({ && selectedSessionId == null && !lockSessionId && !initialSessionId - && workDraftKind === "chat"; + && (workDraftKind === "chat" || isWorkCliLaunchDraft); const draftLaneSelectorLanes = useMemo( () => showDraftLaunchControls && availableLanes ? [AUTO_CREATE_LANE_OPTION, ...availableLanes] @@ -7218,6 +7278,10 @@ export function AgentChatPane({ }} onSubmitBlocked={(message) => setError(message)} onSubmitInBackground={showDraftLaunchControls ? () => { + if (workDraftKind === "cli") { + void launchDraftCliSession("background"); + return; + } void launchDraftChat("background"); } : undefined} backgroundLaunchBusy={backgroundLaunchBusy} @@ -7470,13 +7534,13 @@ export function AgentChatPane({