diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index bd9320765b1d..fa351927f4e7 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -2743,6 +2743,10 @@ } }, "components": { + "agentSessionContinuation": { + "draftLoaded": "Session context loaded as a draft in the new {{agent}} session. Review it and press Enter to continue.", + "sent": "Session context sent to {{agent}} in a new session." + }, "native-chat": { "ask": { "asked": "Asked:", @@ -2756,6 +2760,7 @@ }, "failureWords": { "accountSwitchInProgress": "A Claude account switch is in progress. Try again after it finishes.", + "agentCommandNotRunnable": "{{agent}}'s Command in Settings → Agents must be a program path or name Orca can find, with no arguments or variables. Change it or reset it.", "answerUnconfirmed": "Your answer was recorded but the agent did not confirm it.", "argumentsMissingValue": "Saved Arguments need a value for {{option}}.", "argumentsMultipleValues": "Saved Arguments give {{option}} more than one value.", @@ -2788,37 +2793,36 @@ "couldNotStart": "{{agent}} couldn't start.", "editSavedArguments": "Edit them in Settings > Agents > Arguments.", "emptyMessage": "This message is empty, so it was not sent.", + "historyInOtherAccount": "This chat's history is in another Claude account. Switch back to that account to continue it.", "historyTooLarge": "This conversation's history is too large to restore here.", "hostFault": "Orca ran into a problem, so this didn't go through.", "hostFaultTryAgain": "Orca ran into a problem, so this didn't go through. Try again.", "hostRestarted": "Orca restarted before this message was sent.", "hostStopped": "{{agent}} never finished starting, so Orca stopped it.", + "launchFolderMissing": "The folder this chat ran in no longer exists. Restore it to continue this chat.", "managedAccountEnvOverride": "This Claude launch sets its own Anthropic sign-in variables. Remove them to use a managed Claude account.", "managedAccountUnsupported": "While a Claude account is added in WSL, Claude chats need a Windows Claude account.", - "launchFolderMissing": "The folder this chat ran in no longer exists. Restore it to continue this chat.", - "historyInOtherAccount": "This chat's history is in another Claude account. Switch back to that account to continue it.", - "agentCommandNotRunnable": "{{agent}}'s Command in Settings → Agents must be a program path or name Orca can find, with no arguments or variables. Change it or reset it.", "noTurnToStop": "{{agent}} had no turn running to stop.", "notDelivered": "This message was not delivered.", "notDeliveredSendAgain": "This message was not delivered. Send it again to continue.", "notSignedIn": "{{agent}} is not signed in for the selected account.", "previousExitUnverifiable": "Couldn't stop {{agent}} from before.", - "sessionNotRestored": "{{agent}} couldn't reopen its earlier session, so this chat continues in a new one. {{agent}} doesn't remember the earlier messages.", "providerExitedRejection": "{{agent}} stopped before this message was sent.", "providerExitedRow": "{{agent}} stopped while this response was in progress. You can continue in this conversation.", "providerRateLimited": "{{agent}} is rate-limited and retrying.", "providerRejected": "The provider did not accept this message.", "providerRejectedQuoted": "The provider did not accept this message: {{detail}}.", - "providerRetrying": "{{agent}} hit a temporary problem and is retrying.", - "providerRetryingQuoted": "{{agent}} is retrying: {{detail}}.", + "providerRetryLastError": "Last error: {{detail}}.", "providerRetryNumber": "Retry {{attempt}}.", "providerRetryNumberOf": "Retry {{attempt}} of {{maxRetries}}.", - "providerRetryLastError": "Last error: {{detail}}.", + "providerRetrying": "{{agent}} hit a temporary problem and is retrying.", + "providerRetryingQuoted": "{{agent}} is retrying: {{detail}}.", "providerStartFailed": "{{agent}} stopped before it finished starting.", "queueFull": "Too many messages were waiting for the agent, so this one was not sent.", "runCommandAgain": "Run /{{command}} again.", "sendAgainToTryOnceMore": "Send your message again to try once more.", "sendToTryAgain": "Send your message to try again.", + "sessionNotRestored": "{{agent}} couldn't reopen its earlier session, so this chat continues in a new one. {{agent}} doesn't remember the earlier messages.", "signInFirst": "Sign in first.", "signInThenRunCommand": "Sign in, then run /{{command}} again.", "signInThenSend": "Sign in, then send your message again.", @@ -2891,6 +2895,7 @@ "agentStarting": "The agent is still starting.", "answerFirst": "Answer the question or approval first.", "backgroundTasksRunning": "Background tasks are still running.", + "cannotRunHere": "Orca can't run this agent in a chat here.", "capacity": "Orca has received too many requests in the last day.", "chatNotFound": "Orca can't find this chat.", "chatSavedByNewerOrca": "This chat was saved by a newer Orca.", @@ -2905,7 +2910,6 @@ "messageNotSaved": "Couldn't save your message.", "messagesUnsettled": "A message you sent earlier isn't confirmed yet.", "notAvailable": "This isn't available in this chat.", - "cannotRunHere": "Orca can't run this agent in a chat here.", "notDoneAnswer": "Your answer was not sent.", "notDoneCommand": "The command didn't run.", "notDoneGoal": "The goal wasn't changed.", diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index be90a908af1d..28471bf3521c 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -18684,6 +18684,8 @@ "agentDisabled": "{{agent}} is disabled in Agent settings.", "agentUnavailable": "{{agent}} was not detected on this workspace host.", "sent": "Session context sent to {{agent}} in a new session.", + "openSession": "Open session", + "draftLoaded": "Session context loaded as a draft in the new {{agent}} session. Review it and press Enter to continue.", "deliveryFailed": "The new {{agent}} session started, but its context could not be sent.", "deliveryUnconfirmed": "Orca could not confirm {{agent}} received the session context. Check the new session, and paste it yourself if its input is empty.", "copyPrompt": "Copy prompt", diff --git a/src/renderer/src/lib/launch-agent-in-new-tab-structured-route.test.ts b/src/renderer/src/lib/launch-agent-in-new-tab-structured-route.test.ts new file mode 100644 index 000000000000..6f5cd76ed5e0 --- /dev/null +++ b/src/renderer/src/lib/launch-agent-in-new-tab-structured-route.test.ts @@ -0,0 +1,59 @@ +import { expect, it, vi } from 'vitest' +import type { Tab } from '../../../shared/tab-types' +import type { StructuredAgentLaunchHooks } from './structured-agent-launch-settlement' + +const mocks = vi.hoisted(() => { + const unifiedTabsByWorktree: Record = {} + return { + store: { unifiedTabsByWorktree }, + begin: vi.fn<(args: { hooks: StructuredAgentLaunchHooks }) => null>(() => null) + } +}) +vi.mock('@/store', () => ({ useAppStore: { getState: () => mocks.store } })) +vi.mock('@/lib/structured-agent-session-provisional-tab', () => ({ + structuredLaunchPairedOwner: () => ({ executionHostId: 'host-1' }), + beginStructuredAgentSessionProvisionalLaunch: mocks.begin +})) + +import { adoptAgentSessionLaunchVerdict } from './agent-session-launch-plan' +import { launchStructuredAgentFromNewTab } from './launch-agent-in-new-tab-structured-route' + +it('reports the admitted paired session tab, never a sibling or a missing session', () => { + const onCreatedTab = vi.fn() + launchStructuredAgentFromNewTab({ + plan: adoptAgentSessionLaunchVerdict({ + requestId: 'request-1', + route: 'structured-native-chat', + agent: 'codex', + worktreeId: 'wt-1', + prompt: 'continue', + promptDelivery: 'auto-submit' + }), + worktreeId: 'wt-1', + onCreatedTab, + openTerminal: () => null + }) + const hook = mocks.begin.mock.calls[0][0].hooks.onStructuredReady + const tab: Tab = { + id: 'exact-tab', + entityId: 'session-1', + contentType: 'agent-session', + worktreeId: 'wt-1', + groupId: 'group-1', + label: 'Codex', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 0 + } + mocks.store.unifiedTabsByWorktree = { + 'wt-1': [tab], + 'wt-other': [{ ...tab, id: 'sibling', worktreeId: 'wt-other' }] + } + hook?.('session-1') + expect(onCreatedTab).toHaveBeenCalledExactlyOnceWith('exact-tab') + hook?.('session-missing') + mocks.store.unifiedTabsByWorktree['wt-1'] = [] + hook?.('session-1') + expect(onCreatedTab).toHaveBeenCalledOnce() +}) diff --git a/src/renderer/src/lib/launch-agent-in-new-tab-structured-route.ts b/src/renderer/src/lib/launch-agent-in-new-tab-structured-route.ts index b5ca43f6f563..4597e2b1bcf2 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab-structured-route.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab-structured-route.ts @@ -1,3 +1,5 @@ +import { useAppStore } from '@/store' +import { findStructuredAgentSessionTab } from './structured-agent-session-tab-activation' import { adoptAgentSessionLaunchVerdict, type AgentSessionLaunchPlan @@ -27,6 +29,7 @@ export function launchStructuredAgentFromNewTab(args: { plan: AgentSessionLaunchPlan worktreeId: string groupId?: string + onCreatedTab?: (tabId: string) => void beforeSurfaceOpen?: LaunchAgentInNewTabArgs['beforeSurfaceOpen'] openTerminal: (terminalPlan: AgentSessionLaunchPlan) => { promptDeliveryResult?: Promise @@ -66,7 +69,17 @@ export function launchStructuredAgentFromNewTab(args: { } const launch = beginStructuredAgentSessionProvisionalLaunch({ plan, - hooks: {}, + hooks: { + onStructuredReady: (sessionId) => { + const tab = findStructuredAgentSessionTab(useAppStore.getState().unifiedTabsByWorktree, { + workspaceId: args.worktreeId, + sessionId + }) + if (tab) { + args.onCreatedTab?.(tab.id) + } + } + }, target: { worktreeId: args.worktreeId, executionHostId: paired.executionHostId }, ...(args.groupId ? { targetGroupId: args.groupId } : {}), onHostDeclined: () => { diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.ts b/src/renderer/src/lib/launch-agent-in-new-tab.ts index deabaa32a597..d1bc2e7c9d22 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.ts @@ -63,6 +63,7 @@ export type LaunchAgentInNewTabArgs = LaunchAgentInNewTabRequest & { launchPlatform?: NodeJS.Platform /** Called after the prompt is actually delivered to the agent input path. */ onPromptDelivered?: () => void + onCreatedTab?: (tabId: string) => void /** * Called before `onPromptDelivered` when the paste was written without ever observing the * agent's composer, so the launch cannot claim the prompt arrived. Fires only on the @@ -197,6 +198,7 @@ function launchAgentInNewTabInternal(args: LaunchAgentInNewTabArgs): LaunchAgent if (plan?.route === 'structured-native-chat') { const structured = launchStructuredAgentFromNewTab({ plan, + onCreatedTab: args.onCreatedTab, worktreeId, ...(groupId ? { groupId } : {}), ...(beforeSurfaceOpen ? { beforeSurfaceOpen } : {}), @@ -232,7 +234,8 @@ function launchAgentInNewTabInternal(args: LaunchAgentInNewTabArgs): LaunchAgent // Why: omission means terminal locally, but would let a paired host apply // its own default; send the client's resolved terminal choice explicitly. viewMode: initialViewModeProps.viewMode ?? 'terminal', - onPromptDelivered + onPromptDelivered, + onCreatedTab: args.onCreatedTab }) return { surface: { kind: 'host-published' }, @@ -256,6 +259,7 @@ function launchAgentInNewTabInternal(args: LaunchAgentInNewTabArgs): LaunchAgent ...(activate === false ? { activate: false } : {}), ...initialViewModeProps }) + args.onCreatedTab?.(tab.id) seedNativeChatAppliedSessionOptions(tab.id, agent, startupPlan.sessionOptions) if (initialCwd?.trim()) { // Why: queue before mount so local, WSL, and SSH continuations preserve their subdirectory. diff --git a/src/renderer/src/lib/launch-agent-session-continuation.test.ts b/src/renderer/src/lib/launch-agent-session-continuation.test.ts index 0634680cf520..21b2ac4355d3 100644 --- a/src/renderer/src/lib/launch-agent-session-continuation.test.ts +++ b/src/renderer/src/lib/launch-agent-session-continuation.test.ts @@ -1,17 +1,27 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +const activateWorkspaceTabPaletteResult = vi.hoisted(() => vi.fn()) const launchAgentInNewTab = vi.hoisted(() => vi.fn()) const writeClipboardText = vi.hoisted(() => vi.fn(async () => undefined)) const connectionId = vi.hoisted(() => ({ value: null as string | null })) const runtimeEnvironmentId = vi.hoisted(() => ({ value: null as string | null })) const toast = vi.hoisted(() => ({ error: vi.fn(), success: vi.fn(), warning: vi.fn() })) -const store = vi.hoisted(() => ({ - settings: { disabledTuiAgents: [] as string[] }, - ensureDetectedAgents: vi.fn(async () => ['claude', 'codex']), - ensureRemoteDetectedAgents: vi.fn(async () => ['claude', 'codex']), - ensureRuntimeDetectedAgents: vi.fn(async () => ['claude', 'codex']) -})) +const store = vi.hoisted(() => { + const unifiedTabsByWorktree: Record< + string, + { id: string; entityId: string; contentType: 'terminal'; groupId: string; worktreeId: string }[] + > = {} + return { + getKnownWorktreeById: vi.fn(() => ({ hostId: 'local' })), + unifiedTabsByWorktree, + settings: { disabledTuiAgents: [] as string[] }, + ensureDetectedAgents: vi.fn(async () => ['claude', 'codex']), + ensureRemoteDetectedAgents: vi.fn(async () => ['claude', 'codex']), + ensureRuntimeDetectedAgents: vi.fn(async () => ['claude', 'codex']) + } +}) +vi.mock('@/lib/workspace-tab-palette-activation', () => ({ activateWorkspaceTabPaletteResult })) vi.mock('@/store', () => ({ useAppStore: { getState: () => store } })) vi.mock('@/lib/launch-agent-in-new-tab', () => ({ launchAgentInNewTab })) vi.mock('@/lib/agent-catalog', () => ({ @@ -35,6 +45,8 @@ vi.mock('@/i18n/i18n', () => ({ describe('launchAgentSessionContinuation', () => { beforeEach(() => { vi.clearAllMocks() + store.unifiedTabsByWorktree = {} + store.getKnownWorktreeById.mockReturnValue({ hostId: 'local' }) connectionId.value = null runtimeEnvironmentId.value = null store.settings.disabledTuiAgents = [] @@ -80,6 +92,150 @@ describe('launchAgentSessionContinuation', () => { ) }) + it.each(['claude', 'codex'] as const)( + 'describes confirmed %s continuation delivery accurately', + async (agent) => { + const { launchAgentSessionContinuation } = await import('./launch-agent-session-continuation') + await launchAgentSessionContinuation({ + agent, + prompt: 'continue', + worktreeId: 'wt-1', + launchSource: 'sidebar' + }) + launchAgentInNewTab.mock.calls[0][0].onPromptDelivered() + await Promise.resolve() + expect(toast.success).toHaveBeenCalledWith( + agent === 'claude' + ? 'Session context loaded as a draft in the new Claude session. Review it and press Enter to continue.' + : 'Session context sent to Codex in a new session.', + expect.objectContaining({ action: expect.objectContaining({ label: 'Open session' }) }) + ) + } + ) + + it.each(['wt-1', 'folder:folder-1'])( + 'opens the exact new session in %s after a synchronous delivery callback', + async (worktreeId) => { + store.getKnownWorktreeById.mockReturnValue({ hostId: 'ssh-host' }) + launchAgentInNewTab.mockImplementation((args) => { + args.onCreatedTab('tab-new') + args.onPromptDelivered() + return { surface: { kind: 'local-terminal', tabId: 'tab-new' } } + }) + const { launchAgentSessionContinuation } = await import('./launch-agent-session-continuation') + await launchAgentSessionContinuation({ + agent: 'claude', + prompt: 'continue', + worktreeId, + launchSource: 'sidebar' + }) + store.unifiedTabsByWorktree[worktreeId] = [ + { + id: 'unified-new', + entityId: 'tab-new', + contentType: 'terminal', + groupId: 'moved-group', + worktreeId + } + ] + toast.success.mock.calls[0][1].action.onClick() + expect(activateWorkspaceTabPaletteResult).toHaveBeenCalledWith({ + id: 'unified-new', + tabId: 'unified-new', + entityId: 'tab-new', + contentType: 'terminal', + groupId: 'moved-group', + worktreeId, + executionHostId: 'ssh-host' + }) + store.unifiedTabsByWorktree[worktreeId] = [] + toast.success.mock.calls[0][1].action.onClick() + expect(activateWorkspaceTabPaletteResult).toHaveBeenCalledOnce() + } + ) + + it('uses the host-returned tab identity for a paired continuation', async () => { + launchAgentInNewTab.mockImplementation((args) => { + args.onCreatedTab('web-terminal-host-new') + args.onPromptDelivered() + return { surface: { kind: 'host-published' } } + }) + const { launchAgentSessionContinuation } = await import('./launch-agent-session-continuation') + await launchAgentSessionContinuation({ + agent: 'claude', + prompt: 'continue', + worktreeId: 'wt-1', + launchSource: 'sidebar' + }) + store.unifiedTabsByWorktree['wt-1'] = [ + { + id: 'web-terminal-host-new', + entityId: 'web-terminal-host-new', + contentType: 'terminal', + groupId: 'host-group', + worktreeId: 'wt-1' + } + ] + toast.success.mock.calls[0][1].action.onClick() + expect(activateWorkspaceTabPaletteResult).toHaveBeenCalledWith( + expect.objectContaining({ tabId: 'web-terminal-host-new', groupId: 'host-group' }) + ) + }) + + it('omits the navigation action when an older paired host supplies no tab identity', async () => { + launchAgentInNewTab.mockImplementation((args) => { + args.onPromptDelivered() + return { surface: { kind: 'host-published' } } + }) + const { launchAgentSessionContinuation } = await import('./launch-agent-session-continuation') + await launchAgentSessionContinuation({ + agent: 'claude', + prompt: 'continue', + worktreeId: 'wt-1', + launchSource: 'sidebar' + }) + expect(toast.success.mock.calls[0][1]).toBeUndefined() + }) + + it('does not show a success action when the launch fails after a synchronous callback', async () => { + launchAgentInNewTab.mockImplementation((args) => { + args.onPromptDelivered() + return null + }) + const { launchAgentSessionContinuation } = await import('./launch-agent-session-continuation') + await expect( + launchAgentSessionContinuation({ + agent: 'claude', + prompt: 'continue', + worktreeId: 'wt-1', + launchSource: 'sidebar' + }) + ).resolves.toBe(false) + expect(toast.success).not.toHaveBeenCalled() + expect(toast.error).toHaveBeenCalled() + }) + + it('does not report success or throw from a queued delivery when the launcher throws', async () => { + const queued: (() => void)[] = [] + vi.stubGlobal('queueMicrotask', (callback: () => void) => queued.push(callback)) + launchAgentInNewTab.mockImplementation((args) => { + args.onPromptDelivered() + throw new Error('launch failed after delivery callback') + }) + const { launchAgentSessionContinuation } = await import('./launch-agent-session-continuation') + await expect( + launchAgentSessionContinuation({ + agent: 'claude', + prompt: 'continue', + worktreeId: 'wt-1', + launchSource: 'sidebar' + }) + ).rejects.toThrow('launch failed after delivery callback') + expect(queued).toHaveLength(1) + expect(() => queued[0]()).not.toThrow() + expect(toast.success).not.toHaveBeenCalled() + }) + it('detects the target Agent on the SSH host that owns the workspace', async () => { connectionId.value = 'ssh-1' const { detectAgentSessionContinuationAgents } = @@ -192,7 +348,10 @@ describe('launchAgentSessionContinuation', () => { launchSource: 'sidebar' }) - expect(toast.success).toHaveBeenCalledWith('Session context sent to Codex in a new session.') + expect(toast.success).toHaveBeenCalledWith( + 'Session context sent to Codex in a new session.', + expect.objectContaining({ action: expect.objectContaining({ label: 'Open session' }) }) + ) expect(toast.warning).not.toHaveBeenCalled() }) }) diff --git a/src/renderer/src/lib/launch-agent-session-continuation.ts b/src/renderer/src/lib/launch-agent-session-continuation.ts index 6d6861411c21..012032cc173f 100644 --- a/src/renderer/src/lib/launch-agent-session-continuation.ts +++ b/src/renderer/src/lib/launch-agent-session-continuation.ts @@ -1,6 +1,7 @@ import { toast } from 'sonner' import { getAgentLabel } from '@/lib/agent-catalog' import { getConnectionIdFromState } from '@/lib/connection-context' +import { activateWorkspaceTabPaletteResult } from '@/lib/workspace-tab-palette-activation' import { launchAgentInNewTab } from '@/lib/launch-agent-in-new-tab' import { newAgentLaunchRequestId } from '@/lib/agent-launch-request-id' import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' @@ -83,16 +84,23 @@ export async function launchAgentSessionContinuation({ // Why: the paste helper writes blind when the agent's composer was never observed, so a // written prompt is not a delivered one. Claiming success there is how the whole handoff // could vanish silently (#22479). + const promptDelivery = agent === 'claude' ? 'draft' : 'submit-after-ready' let deliveryUnconfirmed = false - const result = launchAgentInNewTab({ + let createdTabId: string | undefined + const executionHostId = useAppStore.getState().getKnownWorktreeById(worktreeId)?.hostId + let result: ReturnType = null + result = launchAgentInNewTab({ requestId: newAgentLaunchRequestId(), agent, worktreeId, ...(groupId ? { groupId } : {}), prompt, - promptDelivery: agent === 'claude' ? 'draft' : 'submit-after-ready', + promptDelivery, launchSource, ...(initialCwd ? { initialCwd } : {}), + onCreatedTab: (tabId) => { + createdTabId = tabId + }, onPromptDeliveryUnconfirmed: () => { deliveryUnconfirmed = true }, @@ -101,13 +109,56 @@ export async function launchAgentSessionContinuation({ notifyDeliveryUnconfirmed(label, prompt) return } - toast.success( - translate( - 'components.agentSessionContinuation.sent', - 'Session context sent to {{agent}} in a new session.', - { agent: label } + // The launcher may report delivery before returning its tab identity. + queueMicrotask(() => { + if (!result) { + return + } + const tabId = + createdTabId ?? + (result.surface.kind !== 'host-published' ? result.surface.tabId : undefined) + toast.success( + translate( + promptDelivery === 'draft' + ? 'components.agentSessionContinuation.draftLoaded' + : 'components.agentSessionContinuation.sent', + promptDelivery === 'draft' + ? 'Session context loaded as a draft in the new {{agent}} session. Review it and press Enter to continue.' + : 'Session context sent to {{agent}} in a new session.', + { agent: label } + ), + tabId + ? { + action: { + label: translate( + 'components.agentSessionContinuation.openSession', + 'Open session' + ), + onClick: () => { + const tab = ( + useAppStore.getState().unifiedTabsByWorktree[worktreeId] ?? [] + ).find( + (candidate) => + candidate.id === tabId || + (candidate.contentType === 'terminal' && candidate.entityId === tabId) + ) + if ( + tab && + (tab.contentType === 'terminal' || tab.contentType === 'agent-session') + ) { + activateWorkspaceTabPaletteResult({ + ...tab, + tabId: tab.id, + contentType: tab.contentType, + executionHostId + }) + } + } + } + } + : undefined ) - ) + }) } }) if (!result) { diff --git a/src/renderer/src/lib/launch-agent-web-host-tab.ts b/src/renderer/src/lib/launch-agent-web-host-tab.ts index 36c2135729f9..6ca756b921e3 100644 --- a/src/renderer/src/lib/launch-agent-web-host-tab.ts +++ b/src/renderer/src/lib/launch-agent-web-host-tab.ts @@ -46,6 +46,7 @@ export function launchAgentInWebHostTab(args: { agentArgs?: string | null viewMode?: Tab['viewMode'] onPromptDelivered?: () => void + onCreatedTab?: (tabId: string) => void }): Promise<{ delivered: boolean; failureNotified: boolean }> { const { agent, @@ -72,6 +73,7 @@ export function launchAgentInWebHostTab(args: { environmentId, targetGroupId: groupId, activate: true, + ...(args.onCreatedTab ? { onCreatedTab: args.onCreatedTab } : {}), ...(cwd?.trim() ? { cwd } : {}), ...(viewMode ? { viewMode } : {}), agentSessionKind: 'fresh', diff --git a/src/renderer/src/lib/workspace-tab-palette-activation.test.ts b/src/renderer/src/lib/workspace-tab-palette-activation.test.ts index d86635032512..ec1a7c66b649 100644 --- a/src/renderer/src/lib/workspace-tab-palette-activation.test.ts +++ b/src/renderer/src/lib/workspace-tab-palette-activation.test.ts @@ -1,6 +1,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { WorkspaceTabPaletteSearchResult } from './workspace-tab-palette-search' +const activateStructuredAgentSessionTab = vi.hoisted(() => vi.fn(() => true)) +vi.mock('./structured-agent-session-tab-activation', () => ({ activateStructuredAgentSessionTab })) + const mocks = vi.hoisted(() => { type MockStore = { worktreesByRepo: Record @@ -174,6 +177,34 @@ describe('activateWorkspaceTabPaletteResult', () => { mocks.isWebRuntimeSessionActive.mockReturnValue(false) }) + it('reveals and activates structured sessions through the host-aware session activation path', () => { + mocks.store.unifiedTabsByWorktree['wt-1'][0] = { + ...mocks.store.unifiedTabsByWorktree['wt-1'][0], + contentType: 'agent-session', + entityId: 'session-1' + } + const target = { + worktreeId: 'wt-1', + groupId: 'group-1', + tabId: 'unified-terminal-1', + entityId: 'session-1', + contentType: 'agent-session' as const + } + expect(activateWorkspaceTabPaletteResult(target)).toEqual({ status: 'activated' }) + expect(mocks.activateAndRevealWorktree).toHaveBeenCalled() + expect(activateStructuredAgentSessionTab).toHaveBeenCalledExactlyOnceWith({ + worktreeId: 'wt-1', + tabId: 'unified-terminal-1' + }) + expect(mocks.store.setActiveFile).not.toHaveBeenCalled() + mocks.store.unifiedTabsByWorktree['wt-1'] = [] + expect(activateWorkspaceTabPaletteResult(target)).toEqual({ + status: 'failed', + reason: 'missing-tab' + }) + expect(activateStructuredAgentSessionTab).toHaveBeenCalledOnce() + }) + it('activates terminal tabs and focuses the terminal surface', () => { expect(activateWorkspaceTabPaletteResult(makeResult())).toEqual({ status: 'activated' }) diff --git a/src/renderer/src/lib/workspace-tab-palette-activation.ts b/src/renderer/src/lib/workspace-tab-palette-activation.ts index f208f80482bd..32c6ade33689 100644 --- a/src/renderer/src/lib/workspace-tab-palette-activation.ts +++ b/src/renderer/src/lib/workspace-tab-palette-activation.ts @@ -1,3 +1,4 @@ +import { activateStructuredAgentSessionTab } from './structured-agent-session-tab-activation' import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface' import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' import { @@ -30,8 +31,11 @@ export type WorkspaceTabPaletteActivationResult = // Why: callers outside Cmd+J hold only the identifiers, not a full search result. export type WorkspaceTabPaletteActivationTarget = Pick< WorkspaceTabPaletteSearchResult, - 'contentType' | 'entityId' | 'groupId' | 'tabId' | 'worktreeId' -> & { executionHostId?: ExecutionHostId } + 'entityId' | 'groupId' | 'tabId' | 'worktreeId' +> & { + contentType: WorkspaceTabPaletteSearchResult['contentType'] | 'agent-session' + executionHostId?: ExecutionHostId +} type WorkspaceTabPaletteActivationState = Pick< AppState, @@ -80,7 +84,7 @@ function validateTarget( if (tabs.length !== 1 || !tab) { return 'missing-tab' } - if (result.contentType !== 'terminal') { + if (result.contentType !== 'terminal' && result.contentType !== 'agent-session') { const files = state.openFiles.filter((file) => file.id === result.entityId) if (files.length !== 1 || files[0].worktreeId !== result.worktreeId) { return 'missing-file' @@ -121,6 +125,11 @@ export function activateWorkspaceTabPaletteResult( } const runtimeEnvironmentId = getRuntimeEnvironmentIdForWorktree(state, result.worktreeId) + if (result.contentType === 'agent-session') { + return activateStructuredAgentSessionTab({ worktreeId: result.worktreeId, tabId: result.tabId }) + ? { status: 'activated' } + : { status: 'failed', reason: 'missing-tab' } + } state.focusGroup(result.worktreeId, result.groupId) state.activateTab(result.tabId, { worktreeId: result.worktreeId }) diff --git a/src/renderer/src/runtime/web-runtime-session-terminal-host-authority.test.ts b/src/renderer/src/runtime/web-runtime-session-terminal-host-authority.test.ts index 3f6a5f374b61..5bf96998dba2 100644 --- a/src/renderer/src/runtime/web-runtime-session-terminal-host-authority.test.ts +++ b/src/renderer/src/runtime/web-runtime-session-terminal-host-authority.test.ts @@ -561,18 +561,21 @@ describe('createWebRuntimeSessionTerminal', () => { api: { runtimeEnvironments: { call: runtimeCall } } }) + const onCreatedTab = vi.fn() await expect( createWebRuntimeAgentSessionTerminal({ worktreeId: WORKTREE_ID, agentSessionKind: 'fresh', agent: 'claude', command: 'claude', + onCreatedTab, promptAfterReady: 'continue the unfinished task', submitPrompt: true, forcePromptPaste: true }) ).resolves.toEqual({ outcome: { status: 'created' }, promptDelivered: true }) + expect(onCreatedTab).toHaveBeenCalledExactlyOnceWith('web-terminal-host-tab-2') const createRequest = runtimeCall.mock.calls.find( ([request]) => request.method === 'terminal.createAgentSession' )?.[0] diff --git a/src/renderer/src/runtime/web-runtime-session-types.ts b/src/renderer/src/runtime/web-runtime-session-types.ts index 8664c9192ce9..23da3a5a88a4 100644 --- a/src/renderer/src/runtime/web-runtime-session-types.ts +++ b/src/renderer/src/runtime/web-runtime-session-types.ts @@ -40,6 +40,7 @@ export type CreateWebRuntimeSessionTerminalArgs = { viewMode?: 'terminal' | 'chat' activate?: boolean selectWorktree?: boolean + onCreatedTab?: (tabId: string) => void } export type CreatedWebRuntimeSessionTerminal = { diff --git a/src/renderer/src/runtime/web-runtime-terminal-creation.ts b/src/renderer/src/runtime/web-runtime-terminal-creation.ts index c448051a7c87..dcb9ce8c6b4b 100644 --- a/src/renderer/src/runtime/web-runtime-terminal-creation.ts +++ b/src/renderer/src/runtime/web-runtime-terminal-creation.ts @@ -13,7 +13,11 @@ import type { export async function createWebRuntimeSessionTerminal( args: CreateWebRuntimeSessionTerminalArgs ): Promise { - return (await createWebRuntimeSessionTerminalResult(args)).outcome + const created = await createWebRuntimeSessionTerminalResult(args) + if (created.outcome.status === 'created' && created.hostTabId) { + args.onCreatedTab?.(toWebTerminalSurfaceTabId(created.hostTabId)) + } + return created.outcome } export async function createWebRuntimeAgentSessionTerminal( @@ -32,6 +36,7 @@ export async function createWebRuntimeAgentSessionTerminal( return { outcome: created.outcome, promptDelivered: false } } + args.onCreatedTab?.(toWebTerminalSurfaceTabId(created.hostTabId)) const promptDelivered = await deliverLaunchPromptToAgentTab({ tabId: toWebTerminalSurfaceTabId(created.hostTabId), content: args.promptAfterReady, @@ -55,6 +60,7 @@ export async function createWebRuntimeAgentSessionTerminalWithLaunchDraft( ): Promise { const created = await createWebRuntimeSessionTerminalResult(args) if (created.outcome.status !== 'failed' && created.hostTabId) { + args.onCreatedTab?.(toWebTerminalSurfaceTabId(created.hostTabId)) seedNativeChatLaunchDraftForAgentTab({ tabId: toWebTerminalSurfaceTabId(created.hostTabId), agent: args.agent,