diff --git a/mobile/src/session/mobile-existing-agent-launch.test.ts b/mobile/src/session/mobile-existing-agent-launch.test.ts index cc4bc26abd6..0e5d289ad51 100644 --- a/mobile/src/session/mobile-existing-agent-launch.test.ts +++ b/mobile/src/session/mobile-existing-agent-launch.test.ts @@ -250,10 +250,10 @@ describe('launchAgentInExistingWorkspace', () => { }) }) - it('says the launch was stopped when its tab was closed on the computer, in its own words', async () => { + it('reads a closed tab as the user stopping the launch, not as the launch failing', async () => { const { client } = scriptedClient(refused('agent_launch_tab_closed')) await expect(launch(client)).resolves.toEqual({ - kind: 'failed', + kind: 'tab-closed', message: AGENT_LAUNCH_TAB_CLOSED_MESSAGE }) }) diff --git a/mobile/src/session/mobile-existing-agent-launch.ts b/mobile/src/session/mobile-existing-agent-launch.ts index 688b36dca77..3b08bbc5432 100644 --- a/mobile/src/session/mobile-existing-agent-launch.ts +++ b/mobile/src/session/mobile-existing-agent-launch.ts @@ -44,8 +44,9 @@ export const AGENT_LAUNCH_UNCONFIRMED_MESSAGE = // The host started nothing, and the next tap reserves new ids. export const AGENT_LAUNCH_RESERVATION_TAKEN_MESSAGE = "Couldn't start the agent. Try again." +// Who closed it, and whether the agent had started, are unknown here: the phone or the computer. export const AGENT_LAUNCH_TAB_CLOSED_MESSAGE = - "The agent's tab was closed on the computer before it started, so it was stopped." + "The agent's tab was closed, so the agent was stopped." /** * The tab a launch will create, named by this device before it asks, so it can land there as soon @@ -82,6 +83,8 @@ export type MobileExistingAgentLaunch = | { kind: 'failed'; message: string } /** The agent may or may not be running; the caller must not launch again on its own. */ | { kind: 'unknown'; message: string } + /** A user closed the launch's tab, which stopped it; not a failure of the launch. */ + | { kind: 'tab-closed'; message: string } export function supportsMobileExistingAgentLaunch( hostCapabilities: readonly string[] | null | undefined @@ -169,7 +172,7 @@ function classifyLaunchRefusal( return { kind: 'failed', message: AGENT_LAUNCH_RESERVATION_TAKEN_MESSAGE } } if (error.code === AGENT_LAUNCH_TAB_CLOSED_CODE) { - return { kind: 'failed', message: AGENT_LAUNCH_TAB_CLOSED_MESSAGE } + return { kind: 'tab-closed', message: AGENT_LAUNCH_TAB_CLOSED_MESSAGE } } const message = error.message?.trim() return { kind: 'failed', message: message || "Couldn't start the agent." } diff --git a/mobile/src/session/new-tab-agent-host-launch.ts b/mobile/src/session/new-tab-agent-host-launch.ts index 290419d257b..6e3051ef3a3 100644 --- a/mobile/src/session/new-tab-agent-host-launch.ts +++ b/mobile/src/session/new-tab-agent-host-launch.ts @@ -99,6 +99,11 @@ export async function launchNewTabAgentThroughHost(args: { args.reportCreateFailure(launched.message) return true } + if (launched.kind === 'tab-closed') { + // Why silent: the tab this "+" opened is gone, which is the answer; the close was a user's own. + pendingSelectionRef.current = withoutUnansweredLaunch(pendingSelectionRef.current, args.lock) + return true + } if (launched.kind === 'unknown') { // Why: a listed tab proves the agent started, so only the prompt is in doubt; notes stay unsent. if (isLaunchedSurfaceListed(args.getSessionTabs(), reservation)) { diff --git a/mobile/src/session/pr-ai-triage-launch.ts b/mobile/src/session/pr-ai-triage-launch.ts index 61c160b224e..c425cb255cd 100644 --- a/mobile/src/session/pr-ai-triage-launch.ts +++ b/mobile/src/session/pr-ai-triage-launch.ts @@ -85,6 +85,7 @@ export async function launchAgentWithPrompt(args: { case 'unsupported': return { kind: 'not-started', message: AGENT_LAUNCH_UPDATE_REQUIRED_MESSAGE } case 'failed': + case 'tab-closed': return { kind: 'not-started', message: launched.message } case 'unknown': return { kind: 'unconfirmed', message: launched.message } diff --git a/mobile/src/session/use-mobile-session-terminal-create-agent-launch.test.ts b/mobile/src/session/use-mobile-session-terminal-create-agent-launch.test.ts index fc1f6c75545..3e0691c720a 100644 --- a/mobile/src/session/use-mobile-session-terminal-create-agent-launch.test.ts +++ b/mobile/src/session/use-mobile-session-terminal-create-agent-launch.test.ts @@ -26,6 +26,7 @@ import { releaseTerminalCreateLock } from './terminal-create-lock' import { useMobileSessionTerminalCreateActions } from './use-mobile-session-terminal-create-actions' vi.mock('../platform/haptics', () => ({ triggerSuccess: vi.fn(), triggerError: vi.fn() })) +const { triggerError } = await import('../platform/haptics') const LAUNCH_CAPABILITIES = [ 'agent.launch.v2', @@ -357,6 +358,19 @@ describe('the + menu', () => { expect(methods(sendRequest)).toEqual(['agent.launchReplay', 'session.tabs.createTerminal']) }) + it('says nothing when a user closed the tab the launch opened: the tab going is the answer', async () => { + const { client } = scriptedClient(refusal('agent_launch_tab_closed')) + const state = scope(client) + vi.mocked(triggerError).mockClear() + + await create_(state, 'claude') + + expect(state.showToast).not.toHaveBeenCalled() + expect(state.setCreateError).not.toHaveBeenCalledWith(expect.stringMatching(/./)) + expect(triggerError).not.toHaveBeenCalled() + expect(state.pendingSelectionRef.current).toBeNull() + }) + it("shows the host's refusal even when the session already has tabs", async () => { const { client, sendRequest } = scriptedClient({ id: 'x', diff --git a/mobile/src/transport/mobile-runtime-client-capabilities.ts b/mobile/src/transport/mobile-runtime-client-capabilities.ts index ecf01fcc9db..c3e3b5fc9c1 100644 --- a/mobile/src/transport/mobile-runtime-client-capabilities.ts +++ b/mobile/src/transport/mobile-runtime-client-capabilities.ts @@ -27,7 +27,7 @@ export const MOBILE_RUNTIME_CLIENT_CAPABILITIES = remoteRuntimeClientCapabilitie AGENT_LAUNCH_RUNTIME_CAPABILITY, // Reads a listed launch tab with no terminal yet as not started, so the host may show it early. AGENT_LAUNCH_UNSTARTED_TAB_CLIENT_CAPABILITY, - // Reads `agent_launch_tab_closed` (its tab was closed on the computer) as a definite answer. + // Reads `agent_launch_tab_closed` (a user closed its tab, which stopped it) as a definite answer. AGENT_LAUNCH_TAB_CLOSED_CLIENT_CAPABILITY ]) diff --git a/src/main/agent-launch/agent-launch-executor.test.ts b/src/main/agent-launch/agent-launch-executor.test.ts index da6ad5c0e4f..d4a6525105b 100644 --- a/src/main/agent-launch/agent-launch-executor.test.ts +++ b/src/main/agent-launch/agent-launch-executor.test.ts @@ -43,6 +43,7 @@ function harness(options: { calls.push(`createWorktree(startupAgent=${String(args.startupAgent)})`) return { worktreeId: 'wt-new', + connectionId: null, startupTerminalHandle: args.startupAgent ? 'term_agent_first' : undefined, ...carried(args.startupPrompt) } @@ -691,3 +692,22 @@ describe('the surface is published as the launch stands, before its prompt is de expect(result.prompt).toEqual({ delivery: 'submit', outcome: 'journaled', messageId: 'msg-1' }) }) }) + +describe('a new local worktree whose startup terminal did not come up', () => { + it('opens its agent in the view a local workspace allows, as an existing one would', async () => { + const h = harness({ + settings: { experimentalNativeChat: true, openAgentTabsInChatByDefault: true } + }) + h.createWorktree.mockImplementationOnce(async () => ({ + worktreeId: 'wt-new', + connectionId: null, + startupTerminalHandle: undefined + })) + + await h.run({ ...CREATE_INTENT, agent: 'opencode' }) + + expect(h.createTerminalAgent).toHaveBeenCalledWith( + expect.objectContaining({ viewMode: 'chat' }) + ) + }) +}) diff --git a/src/main/agent-launch/agent-launch-executor.ts b/src/main/agent-launch/agent-launch-executor.ts index cbb4ff43468..0878fc932aa 100644 --- a/src/main/agent-launch/agent-launch-executor.ts +++ b/src/main/agent-launch/agent-launch-executor.ts @@ -167,7 +167,7 @@ export async function executeAgentLaunch( execution.onStage?.('surface_create') let created: CreatedSurface try { - created = await createSurface(execution, placed.worktreeId, settled) + created = await createSurface(execution, placed, settled) } catch (error) { // The structured create path distinguishes a definitive pre-commit refusal from an unknown // outcome. Only the former is safe to replace with a terminal in the same workspace; retrying @@ -180,7 +180,7 @@ export async function executeAgentLaunch( throw error } settled = downgradeAgentLaunchModeForStructuredRefusal(settled, vocabulary) - created = await createTerminalSurface(execution, placed.worktreeId) + created = await createTerminalSurface(execution, placed) } // Both CAN be set, so neither may be dropped. The create warns precisely when it produced no // startup terminal — `didSpawnStartup` stays false when that spawn throws — and that is the same @@ -231,6 +231,7 @@ async function resolveWorkspace( preflight: AgentLaunchModeReceipt ): Promise<{ worktreeId: string + connectionId: string | null | undefined startupTerminalHandle: string | undefined startupTerminalPaneKey?: string warning?: string @@ -240,7 +241,8 @@ async function resolveWorkspace( const { intent } = execution if (intent.target.kind === 'existing') { // Nothing was created, so there is no create warning to carry. - return { worktreeId: intent.target.worktree, startupTerminalHandle: undefined } + const { worktree: worktreeId, connectionId } = intent.target + return { worktreeId, connectionId, startupTerminalHandle: undefined } } const workspaces = execution.workspaces if (!workspaces) { @@ -277,7 +279,7 @@ export type CreatedSurface = { async function createSurface( execution: AgentLaunchExecution, - worktreeId: string, + workspace: { worktreeId: string; connectionId: string | null | undefined }, settled: AgentLaunchModeReceipt ): Promise { const { intent, surfaces } = execution @@ -285,7 +287,7 @@ async function createSurface( // One reservation serves either route: the tab half of the reserved pane is the chat's tab. const reservedTabId = intent.paneKey ? parsePaneKey(intent.paneKey)?.tabId : undefined const session = await surfaces.createStructuredSession({ - worktreeId, + worktreeId: workspace.worktreeId, agent: intent.agent, ...(intent.sessionOptions ? { options: intent.sessionOptions } : {}), ...(intent.sessionId ? { sessionId: intent.sessionId } : {}), @@ -302,7 +304,7 @@ async function createSurface( ...ignoredStructuredAgentArgsWarning(intent) } } - return createTerminalSurface(execution, worktreeId) + return createTerminalSurface(execution, workspace) } /** @@ -347,12 +349,12 @@ function terminalLaunchInputs(intent: AgentLaunchIntent) { */ async function createTerminalSurface( execution: AgentLaunchExecution, - worktreeId: string + workspace: { worktreeId: string; connectionId: string | null | undefined } ): Promise { const { intent, surfaces } = execution const startupPrompt = argvLaunchPrompt(intent) const terminal = await surfaces.createTerminalAgent({ - worktreeId, + worktreeId: workspace.worktreeId, agent: intent.agent, ...(startupPrompt ? { startupPrompt } : {}), ...terminalLaunchInputs(intent), @@ -360,7 +362,7 @@ async function createTerminalSurface( settings: readAgentLaunchModeSettings(execution.runtime), agent: intent.agent, ...(intent.prompt ? { prompt: intent.prompt } : {}), - connectionId: intent.target.kind === 'existing' ? intent.target.connectionId : undefined + connectionId: workspace.connectionId }) }) return { diff --git a/src/main/agent-launch/agent-launch-opencode-model.test.ts b/src/main/agent-launch/agent-launch-opencode-model.test.ts index 39906f8c779..78446330fdd 100644 --- a/src/main/agent-launch/agent-launch-opencode-model.test.ts +++ b/src/main/agent-launch/agent-launch-opencode-model.test.ts @@ -13,6 +13,7 @@ function harness() { const deliverPrompt = vi.fn(async () => true) const createWorktree = vi.fn(async () => ({ worktreeId: 'wt_new', + connectionId: null, startupTerminalHandle: 'term_new' })) return { diff --git a/src/main/agent-launch/agent-launch-surface-factories.ts b/src/main/agent-launch/agent-launch-surface-factories.ts index ca35c3eb55b..765615e0d8f 100644 --- a/src/main/agent-launch/agent-launch-surface-factories.ts +++ b/src/main/agent-launch/agent-launch-surface-factories.ts @@ -119,6 +119,8 @@ export type AgentLaunchWorkspaceFactory = { options?: Readonly> }): Promise<{ worktreeId: string + /** The new workspace's SSH connection; `null` is local. Decides what its agent tab can show. */ + connectionId: string | null startupTerminalHandle: string | undefined /** The pane minted with the startup terminal, when the runtime reported one. */ startupTerminalPaneKey?: string diff --git a/src/main/runtime/rpc/methods/agent-launch-worktree-creation.ts b/src/main/runtime/rpc/methods/agent-launch-worktree-creation.ts index 734f7be401c..c39ee4636d8 100644 --- a/src/main/runtime/rpc/methods/agent-launch-worktree-creation.ts +++ b/src/main/runtime/rpc/methods/agent-launch-worktree-creation.ts @@ -107,6 +107,7 @@ export function agentLaunchWorkspaceFactory( finishAutomationWorkspaceProvenanceRequest(params.automationProvenanceRequest) return { worktreeId: result.worktree.id, + connectionId: repo.connectionId ?? null, startupTerminalHandle: result.startupTerminal?.handle, ...(promptRodeLaunchCommand ? { promptRodeLaunchCommand } : {}), ...(result.startupTerminal?.paneKey diff --git a/src/renderer/src/hooks/ipc-events/terminal-presentation-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/terminal-presentation-ipc-bridge.ts index d2ad5f91ff7..368bb66413e 100644 --- a/src/renderer/src/hooks/ipc-events/terminal-presentation-ipc-bridge.ts +++ b/src/renderer/src/hooks/ipc-events/terminal-presentation-ipc-bridge.ts @@ -62,7 +62,8 @@ export function registerTerminalPresentationIpcBridge(unsubs: (() => void)[]): v if (ptyId && tabId && leafId && wasAgentLaunchPaneClosedByUser(tabId, leafId)) { // The user closed the launch's tab or pane while it waited. That close wins: it stays // closed, and its agent stops, as closing any tab stops what runs in it. - void window.api.pty.kill(ptyId) + // The host stops the same agent; a second kill of a retired PTY may reject, harmlessly. + window.api.pty.kill(ptyId).catch(() => {}) throw new Error('agent_launch_tab_closed') } // Why: a split pane revealed from mobile is only bound in the persisted diff --git a/src/renderer/src/lib/agent-launch-pane-verdict-application.test.ts b/src/renderer/src/lib/agent-launch-pane-verdict-application.test.ts index 6b997b56960..5e65a5b0a00 100644 --- a/src/renderer/src/lib/agent-launch-pane-verdict-application.test.ts +++ b/src/renderer/src/lib/agent-launch-pane-verdict-application.test.ts @@ -77,6 +77,17 @@ describe("a launch pane's verdict in the window", () => { expect(agentLaunchPanePrompt(tabId)).toBe('fix the build') }) + it('final, for a launch pane the user already closed out of a split: nothing is kept', () => { + store.getState().setTabLayout(tabId, { + root: { type: 'leaf', leafId: '9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d' }, + activeLeafId: '9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d', + expandedLeafId: null + }) + apply({ kind: 'not-started', code: 'agent_launch_tab_closed' }) + expect(launchTab()?.agentLaunchPane).toBeUndefined() + expect(agentLaunchPanePrompt(tabId)).toBeNull() + }) + it('withdrawn: a tab with only the launch pane goes', () => { apply({ kind: 'withdrawn' }) expect(launchTab()).toBeUndefined() diff --git a/src/renderer/src/lib/agent-launch-pane-verdict-application.ts b/src/renderer/src/lib/agent-launch-pane-verdict-application.ts index a56f01c36a0..75b9ebafc7d 100644 --- a/src/renderer/src/lib/agent-launch-pane-verdict-application.ts +++ b/src/renderer/src/lib/agent-launch-pane-verdict-application.ts @@ -5,6 +5,7 @@ import type { AgentLaunchPaneVerdictEvent } from '../../../shared/agent-launch-pane-verdict' import { applyClosedTerminalLeafNotice } from '@/components/terminal-pane/closed-terminal-leaf-notice' +import { collectLeafIdsInOrder } from '@/components/terminal-pane/terminal-layout-leaf-ids' import { useAppStore } from '@/store' import { forgetAgentLaunchPanePrompt } from './agent-launch-pane-prompt' @@ -23,10 +24,18 @@ export function applyAgentLaunchPaneVerdict(event: AgentLaunchPaneVerdictEvent): state.setTabAgentLaunchPane(tabId, undefined) return case 'not-started': - case 'unconfirmed': + case 'unconfirmed': { + const root = state.terminalLayoutsByTabId[tabId]?.root + if (root && !collectLeafIdsInOrder(root).includes(leafId)) { + // The user closed that pane: nothing is left for the outcome to describe. + forgetAgentLaunchPanePrompt(tabId) + state.setTabAgentLaunchPane(tabId, undefined) + return + } // Final for this pane, for the tab's life: no later spawn needs the launch record. state.setTabAgentLaunchPane(tabId, { ...tab.agentLaunchPane, outcome: verdict }) return + } case 'withdrawn': { forgetAgentLaunchPanePrompt(tabId) // The pane alone when the user split the tab while it waited; their split stays.