diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx index 3fb745919cb5..bfcc34d990aa 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx @@ -295,7 +295,7 @@ describe('NativeChatComposer', () => { subscribe: () => () => {} } satisfies SessionOptionsSurface const optionSnapshot = [{ id: 'model' }] as SessionOptionDescriptor[] - render( + const composer = (sessionPending = false) => ( { optionsSurface, optionSnapshot, onError: vi.fn(), - runtime: 'local', + runtime: 'remote', sessionId: 'session-test', - runtimeEnvironmentId: null + runtimeEnvironmentId: 'paired-host', + sessionPending }} /> ) + const { rerender } = render(composer()) expect(mocks.fieldProps?.sessionOptionsSurface).toBe(optionsSurface) expect(mocks.fieldProps?.sessionOptionsSnapshot).toBe(optionSnapshot) expect(mocks.fieldProps?.attachDisabled).toBe(false) + rerender(composer(true)) + expect(mocks.fieldProps?.attachDisabled).toBe(true) + rerender(composer()) + expect(mocks.fieldProps?.attachDisabled).toBe(false) await act(async () => mocks.fieldProps?.onSend?.()) expect(dispatchCommand).toHaveBeenCalledWith('hello') diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.tsx index 5d254f2d6963..9d8a32a4bbcc 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.tsx @@ -375,7 +375,10 @@ const NativeChatComposerPane = forwardRef

{ tab.focus() act(() => requestNativeChatComposerFocus('chat-tab')) + expect(container.querySelector('textarea')).toBe(document.activeElement) drainFrames() expect(container.querySelector('textarea')).toBe(document.activeElement) diff --git a/src/renderer/src/components/native-chat/native-chat-composer-types.ts b/src/renderer/src/components/native-chat/native-chat-composer-types.ts index a4be879cfcf1..ab7f78c5f537 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-types.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-types.ts @@ -65,6 +65,9 @@ export type NativeChatStructuredComposerTransport = { sessionId: string /** Owning runtime for that report; null is the local runtime. */ runtimeEnvironmentId: string | null + /** The host has not confirmed this chat: session uploads wait for publication. */ + sessionPending?: boolean + sessionPendingNotice?: string } export type NativeChatOptimisticSendOutcome = { diff --git a/src/renderer/src/components/native-chat/native-chat-pending-attachment-cache.ts b/src/renderer/src/components/native-chat/native-chat-pending-attachment-cache.ts index f2260bf631e1..764430cb3d0a 100644 --- a/src/renderer/src/components/native-chat/native-chat-pending-attachment-cache.ts +++ b/src/renderer/src/components/native-chat/native-chat-pending-attachment-cache.ts @@ -31,6 +31,16 @@ export function nativeChatPendingAttachmentSnapshot( return pendingCache.get(scopeKey) ?? EMPTY } +/** Pending chips are user content before their file paths settle into the draft. */ +export function nativeChatComposerHasUserContent(scopeKey: string): boolean { + const draft = readNativeChatComposerDraft(scopeKey) + return ( + draft.text.trim() !== '' || + draft.images.length > 0 || + nativeChatPendingAttachmentSnapshot(scopeKey).length > 0 + ) +} + function writePending(scopeKey: string, next: readonly NativeChatComposerImageAttachment[]): void { if (next.length === 0) { pendingCache.delete(scopeKey) diff --git a/src/renderer/src/components/native-chat/native-chat-structured-session-notices.ts b/src/renderer/src/components/native-chat/native-chat-structured-session-notices.ts index 1e64ebded269..c69c72747262 100644 --- a/src/renderer/src/components/native-chat/native-chat-structured-session-notices.ts +++ b/src/renderer/src/components/native-chat/native-chat-structured-session-notices.ts @@ -46,7 +46,7 @@ function nativeChatLaunchNotice({ ) const cause = lifecycle === 'failed' && failure - ? (failure.authStartupMessage ?? + ? (failure.causeText ?? agentSessionWriteNoticeText( agentSessionRefusalCauseParts(failure, agentLabel ? { agentName: agentLabel } : {}) )) @@ -100,8 +100,9 @@ export function structuredSessionNotices({ /** Why this chat's host may not take a turn, from `useNativeChatHostPermissionGate`. */ hostPermission?: NativeChatComposerNotice | null }): NativeChatComposerNotice[] { + // A host held for its permission mode already says why nothing starts; Retry returns with it. const launchNotice = nativeChatLaunchNotice({ - lifecycle: launch.lifecycle, + lifecycle: hostPermission ? null : launch.lifecycle, failure: launch.failure, agentLabel, onRetry: launch.retry, diff --git a/src/renderer/src/components/native-chat/structured-agent-session-status-bridge-unconfirmed-start.test.tsx b/src/renderer/src/components/native-chat/structured-agent-session-status-bridge-unconfirmed-start.test.tsx index 784039a0e457..ad011173bb25 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-status-bridge-unconfirmed-start.test.tsx +++ b/src/renderer/src/components/native-chat/structured-agent-session-status-bridge-unconfirmed-start.test.tsx @@ -328,7 +328,8 @@ describe('a chat whose start was never confirmed', () => { }) }), expect.any(Function), - true + true, + expect.any(Function) ) expect(lifecycle()).toBeNull() expect(rows()).toEqual([]) diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-paste.ts b/src/renderer/src/components/native-chat/use-native-chat-composer-paste.ts index 2e15da31583b..5f2c1bf94376 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-paste.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-paste.ts @@ -205,7 +205,7 @@ export function useNativeChatComposerPaste({ return } if (owner.kind === 'not-ready') { - setNotice(nativeChatWorktreeNotReadyNotice()) + setNotice(owner.notice ?? nativeChatWorktreeNotReadyNotice()) return } // Why: snapshot the caret before the async temp-file round-trip — `caret` @@ -285,7 +285,7 @@ export function useNativeChatComposerPaste({ setNotice( owner.kind === 'remote' ? nativeChatLocalAttachmentUnsupportedNotice() - : nativeChatWorktreeNotReadyNotice() + : (owner.notice ?? nativeChatWorktreeNotReadyNotice()) ) } return diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-reveal-focus.ts b/src/renderer/src/components/native-chat/use-native-chat-composer-reveal-focus.ts index 189214ee492c..0eb328a4d696 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-reveal-focus.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-reveal-focus.ts @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from 'react' +import { useEffect, useLayoutEffect, useRef, useState } from 'react' import type { RefObject } from 'react' import { shouldPreserveEditableFocus } from '@/components/terminal-pane/pane-helpers' import { scheduleNextFrame } from '@/components/terminal-pane/terminal-ime-input-context-refresh' @@ -40,7 +40,7 @@ export function captureNativeChatComposerFocusIntent(): NativeChatComposerFocusI } } const pending: { tabId: string | null; cancel: () => void } = { tabId: null, cancel } - let timeout: ReturnType | undefined + const timeout = setTimeout(cancel, PENDING_FOCUS_MS) pendingFocusRequest = pending document.addEventListener('pointerdown', cancel, true) document.addEventListener('keydown', cancelForNavigation, true) @@ -50,8 +50,6 @@ export function captureNativeChatComposerFocusIntent(): NativeChatComposerFocusI return } pending.tabId = tabId - clearTimeout(timeout) - timeout = setTimeout(cancel, PENDING_FOCUS_MS) const reclaim = revealedComposerReclaims.get(tabId) if (reclaim) { cancel() @@ -101,34 +99,43 @@ export function useNativeChatComposerRevealFocus({ const explicitRef = useRef(false) const [reclaimCount, setReclaimCount] = useState(0) - useEffect(() => { + useLayoutEffect(() => { mountedComposers.add(composerRef) return () => { mountedComposers.delete(composerRef) } }, [composerRef]) - useEffect(() => { + useLayoutEffect(() => { if (!revealed || !composerReady) { return } const reclaim = (): void => { claimedRef.current = false explicitRef.current = true + const active = rootRef.current?.ownerDocument.activeElement ?? null + const siblingComposer = [...mountedComposers].some( + (ownerRef) => ownerRef !== composerRef && ownerRef.current?.contains(active) + ) + if ( + (!shouldPreserveEditableFocus(active) || siblingComposer) && + composerRef.current?.focus() + ) { + explicitRef.current = false + } setReclaimCount((count) => count + 1) } revealedComposerReclaims.set(tabId, reclaim) if (pendingFocusRequest?.tabId === tabId) { pendingFocusRequest.cancel() - claimedRef.current = false - explicitRef.current = true + reclaim() } return () => { if (revealedComposerReclaims.get(tabId) === reclaim) { revealedComposerReclaims.delete(tabId) } } - }, [composerReady, revealed, tabId]) + }, [composerReady, composerRef, revealed, rootRef, tabId]) useEffect(() => { if (!revealed || !composerReady) { diff --git a/src/renderer/src/components/native-chat/use-native-chat-external-attachments.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-external-attachments.test.tsx index 8ee0bba3d62a..a04881875e67 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-external-attachments.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-external-attachments.test.tsx @@ -36,6 +36,7 @@ import { useNativeChatExternalAttachments } from './use-native-chat-external-att import type { NativeChatPendingAttachmentChips } from './native-chat-session-attachment-drop' import type { AgentSessionAttachmentPathUploadResult } from '../../../../shared/agent-session-attachments' import { replaceRuntimeEnvironmentRevisions } from '@/runtime/runtime-environment-revision' +import type { NativeChatStructuredAttachmentSession } from './native-chat-attachment-upload' type HookApi = ReturnType @@ -68,7 +69,7 @@ function Probe({ }: { disabled: boolean structuredWorktreeId?: string - structuredSession?: { sessionId: string; runtimeEnvironmentId: string | null } + structuredSession?: NativeChatStructuredAttachmentSession attachResolvedPaths: (paths: string[]) => void pendingChips: NativeChatPendingAttachmentChips setNotice: (notice: string | null) => void @@ -96,7 +97,7 @@ let root: Root | null = null async function renderProbe(args: { disabled?: boolean structuredWorktreeId?: string - structuredSession?: { sessionId: string; runtimeEnvironmentId: string | null } + structuredSession?: NativeChatStructuredAttachmentSession attachResolvedPaths: (paths: string[]) => void pendingChips?: NativeChatPendingAttachmentChips setNotice?: (notice: string | null) => void @@ -160,6 +161,30 @@ afterEach(() => { }) describe('useNativeChatExternalAttachments', () => { + it('keeps a failed paired chat from issuing an upload and says how to retry', async () => { + const attachResolvedPaths = vi.fn() + const setNotice = vi.fn() + const notice = 'Codex chat could not open. Use Retry in the chat, then attach the files again.' + const probe = await renderProbe({ + structuredWorktreeId: 'folder-1', + structuredSession: { + sessionId: 'codex-1', + runtimeEnvironmentId: 'server-1', + sessionPending: true, + sessionPendingNotice: notice + }, + attachResolvedPaths, + setNotice + }) + await act(async () => { + await probe.latest().captureExternalDrop()(['/local/a.txt']) + }) + expect(setNotice).toHaveBeenCalledWith(notice) + expect(mocks.prepareNativeChatSessionAttachmentUpload).not.toHaveBeenCalled() + expect(mocks.uploadNativeChatSessionAttachmentPaths).not.toHaveBeenCalled() + expect(attachResolvedPaths).not.toHaveBeenCalled() + }) + it('attaches local worktree paths unchanged', async () => { mocks.resolveNativeChatAttachmentOwner.mockReturnValue({ kind: 'local' }) const attachResolvedPaths = vi.fn() diff --git a/src/renderer/src/components/native-chat/use-native-chat-external-attachments.ts b/src/renderer/src/components/native-chat/use-native-chat-external-attachments.ts index 8523fca63f31..60256de4deec 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-external-attachments.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-external-attachments.ts @@ -97,20 +97,14 @@ export function useNativeChatExternalAttachments({ // The post-await gate asks which workspace this composer serves now, so it // reads the pane through a ref. Resolving through the render closure would // re-ask the workspace the upload started in — a comparison with itself. - const sessionId = structuredSession?.sessionId - const runtimeEnvironmentId = structuredSession?.runtimeEnvironmentId ?? null const workspaceRef = useRef({ structuredWorktreeId, terminalTabId, structuredSession }) useLayoutEffect(() => { - workspaceRef.current = { - structuredWorktreeId, - terminalTabId, - structuredSession: sessionId ? { sessionId, runtimeEnvironmentId } : undefined - } - }, [runtimeEnvironmentId, sessionId, structuredWorktreeId, terminalTabId]) + workspaceRef.current = { structuredWorktreeId, terminalTabId, structuredSession } + }, [structuredSession, structuredWorktreeId, terminalTabId]) const pendingChipsRef = useRef(pendingChips) useLayoutEffect(() => { pendingChipsRef.current = pendingChips @@ -119,6 +113,9 @@ export function useNativeChatExternalAttachments({ const resolveAttachmentOwner = useCallback((): NativeChatAttachmentOwner => { const { structuredWorktreeId, structuredSession, terminalTabId } = workspaceRef.current if (structuredWorktreeId && structuredSession?.runtimeEnvironmentId) { + if (structuredSession.sessionPending) { + return { kind: 'not-ready', notice: structuredSession.sessionPendingNotice } + } return resolveNativeChatRuntimeSessionAttachmentOwner({ sessionId: structuredSession.sessionId, runtimeEnvironmentId: structuredSession.runtimeEnvironmentId @@ -159,7 +156,7 @@ export function useNativeChatExternalAttachments({ return nativeChatAttachmentOwnerChangedNotice() } if (owner.kind === 'not-ready') { - return nativeChatWorktreeNotReadyNotice() + return owner.notice ?? nativeChatWorktreeNotReadyNotice() } if (owner.kind === 'remote') { return nativeChatLocalAttachmentUnsupportedNotice() diff --git a/src/renderer/src/components/native-chat/use-native-chat-host-permission-gate.ts b/src/renderer/src/components/native-chat/use-native-chat-host-permission-gate.ts index e6f60dfc787c..68db3bb56827 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-host-permission-gate.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-host-permission-gate.ts @@ -38,6 +38,19 @@ export function useNativeChatHostPermissionGate( const text = hostPermissionRefusedText(agentLabel, verdict.kind, requested) return { sendBlockedReason: text, - notice: { key: `host-permission:${verdict.kind}`, kind: 'host', tone: 'warning', text } + notice: { + key: `host-permission:${verdict.kind}`, + kind: 'host', + tone: 'warning', + text, + ...(verdict.kind === 'unverified' + ? { + action: { + label: translate('components.native-chat.hostPermission.checkAgain', 'Check again'), + onClick: verdict.checkAgain + } + } + : {}) + } } } diff --git a/src/renderer/src/components/native-chat/use-native-chat-provisional-launch.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-provisional-launch.test.tsx index 8712f0877fa8..b12c135c8638 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-provisional-launch.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-provisional-launch.test.tsx @@ -7,7 +7,13 @@ import type * as LaunchIntentModule from '@/lib/launch-structured-agent-session' const mocks = vi.hoisted(() => ({ call: vi.fn<(target: unknown, method: string, params?: unknown) => Promise>(), - launch: vi.fn<(intent: { sessionId: string }) => Promise<{ sessionId: string; fence: number }>>(), + launch: + vi.fn< + ( + intent: { sessionId: string }, + onSeed?: LaunchIntentModule.StructuredLaunchHostSeedListener + ) => Promise<{ sessionId: string; fence: number }> + >(), toastError: vi.fn() })) @@ -30,6 +36,9 @@ vi.mock('@/lib/launch-structured-agent-session', async () => { vi.mock('@/runtime/local-structured-session-tabs-sync/inventory-refresh', () => ({ refreshLocalStructuredSessionTabs: () => Promise.resolve(publishedTabs) })) +vi.mock('@/runtime/structured-session-tab-inventory', () => ({ + readStructuredSessionTabInventory: () => Promise.resolve(publishedTabs) +})) vi.mock('./use-structured-agent-session-hold', () => ({ useStructuredAgentSessionHold: () => undefined @@ -49,7 +58,12 @@ vi.mock('./native-chat-session-option-settings-write', () => ({ import { getDefaultSettings } from '../../../../shared/constants' import { useAppStore } from '../../store' -import { startStructuredAgentLaunch } from '@/lib/structured-agent-session-launch' +import { + startStructuredAgentLaunch, + retryStructuredAgentSessionLaunch +} from '@/lib/structured-agent-session-launch' +import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target' +import { dispatchStructuredAgentSessionComposerCommand } from '../../../../shared/structured-agent-session-composer' import { resetStructuredAgentLaunchPersistenceForTests } from '@/lib/structured-agent-session-launch-persistence' import { resetStructuredAgentLaunchRegistryForTests } from '@/lib/structured-agent-session-launch-registry' import { useNativeChatProvisionalLaunch } from './use-native-chat-provisional-launch' @@ -118,13 +132,14 @@ function publishedSnapshot(worktreeId: string, sessionId: string) { function renderLaunchedChat( worktreeId: string, sessionId: string, - agent: 'codex' | 'claude' = 'codex' + agent: 'codex' | 'claude' = 'codex', + target: RuntimeClientTarget = LOCAL_TARGET ) { return renderHook((selectedSessionId: string = sessionId) => { const provisional = useNativeChatProvisionalLaunch(worktreeId, selectedSessionId) const chat = useStructuredAgentSession({ sessionId: selectedSessionId, - target: LOCAL_TARGET, + target, agent, providerStarting: true, isVisible: true, @@ -157,6 +172,70 @@ describe('a chat pane over its own launch', () => { afterEach(() => resetStructuredAgentSessionSendsForTests()) + it.each([undefined, { model: 'gpt-5.5' }])( + 'holds paired picks through reload and Retry until the host answers (%s)', + async (seed) => { + mocks.call.mockImplementation((_target, method) => + method === 'agentSession.modelCatalog' + ? Promise.resolve({ + ...SAVED_LIST, + listingNamesConfiguredModel: true, + models: SAVED_LIST.models.map((model) => ({ + ...model, + isDefault: model.id === 'gpt-5.5', + efforts: [ + { value: 'low', label: 'Low' }, + { value: 'high', label: 'High' } + ] + })) + }) + : new Promise(() => {}) + ) + const worktreeId = 'paired-pending' + const target = { kind: 'environment', environmentId: 'server-1' } as const + const launch = startStructuredAgentLaunch(worktreeId, 'codex', { + requestId: 'paired-reload', + executionHostId: 'runtime:server-1' + }) + const fresh = renderLaunchedChat(worktreeId, launch.sessionId, 'codex', target) + expect(fresh.result.current.launchView?.seedPending).toBe(true) + fresh.unmount() + resetStructuredAgentLaunchRegistryForTests() + resetStructuredAgentLaunchPersistenceForTests() + const restored = renderLaunchedChat(worktreeId, launch.sessionId, 'codex', target) + const command = (text: string) => + dispatchStructuredAgentSessionComposerCommand(text, { + snapshot: restored.result.current.optionSnapshot, + invokeAction: async () => false, + setOption: restored.result.current.setStructuredOption + }) + await waitFor(() => + expect( + restored.result.current.optionSnapshot.find((entry) => entry.id === 'model') + ).toMatchObject({ + settable: false, + kind: { choices: expect.arrayContaining([expect.objectContaining({ value: 'gpt-5.5' })]) } + }) + ) + await expect(command('/model gpt-5.5')).resolves.toMatchObject({ accepted: false }) + act(() => { + retryStructuredAgentSessionLaunch(worktreeId, launch.sessionId) + }) + await waitFor(() => expect(mocks.launch).toHaveBeenCalledTimes(2)) + await expect(command('/effort high')).resolves.toMatchObject({ accepted: false }) + act(() => { + mocks.launch.mock.lastCall?.[1]?.(seed) + }) + expect( + restored.result.current.optionSnapshot.find((entry) => entry.id === 'model')?.settable + ).toBe(true) + await act(async () => { + await expect(command('/model gpt-5.5')).resolves.toMatchObject({ accepted: true }) + await expect(command('/effort high')).resolves.toMatchObject({ accepted: true }) + }) + } + ) + it.each([false, true])( 'keeps launch intent after retirement and on first send (accepted picks: %s)', async (picked) => { diff --git a/src/renderer/src/components/native-chat/use-native-chat-provisional-launch.ts b/src/renderer/src/components/native-chat/use-native-chat-provisional-launch.ts index 276968920557..be375c94d79b 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-provisional-launch.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-provisional-launch.ts @@ -20,6 +20,8 @@ export type StructuredAgentSessionLaunchView = { heldOptions: Readonly> /** Where the chat runs; its own config may replace the listed default. */ worktree?: string + /** Its paired server has not said what create seeds yet; picks wait for it. */ + seedPending?: true } const NO_HELD_OPTIONS: Readonly> = {} @@ -36,7 +38,7 @@ function useLaunchView( if (current) { snapshot = current } else if (snapshot && snapshot.held !== NO_HELD_OPTIONS) { - snapshot = { seed: snapshot.seed, held: NO_HELD_OPTIONS } + snapshot = { seed: snapshot.seed, held: NO_HELD_OPTIONS, seedPending: true } } return snapshot } @@ -69,17 +71,19 @@ function useLaunchView( }) } const held = selection?.held ?? NO_HELD_OPTIONS + const seedPending = launching && (!selection || selection.seedPending === true) return useMemo( () => intent.kind ? { kind: intent.kind, ...(selection?.seed ? { seedOptions: selection.seed } : {}), + ...(seedPending ? { seedPending: true as const } : {}), heldOptions: held, ...(worktreeId ? { worktree: toRuntimeWorktreeSelector(worktreeId) } : {}) } : undefined, - [held, intent.kind, selection?.seed, worktreeId] + [held, intent.kind, selection?.seed, seedPending, worktreeId] ) } diff --git a/src/renderer/src/components/native-chat/use-native-chat-structured-composer-transport.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-structured-composer-transport.test.tsx index a8cb8be50a83..d203fc98d56e 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-structured-composer-transport.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-structured-composer-transport.test.tsx @@ -37,6 +37,7 @@ function composerAcceptsImages(reported: boolean | undefined): boolean | undefin controller, sendThroughLaunch: () => false, starting: false, + transportEnabled: true, worktreeId: undefined, optionPickerRequest: null, setOptionPickerRequest: () => {}, diff --git a/src/renderer/src/components/native-chat/use-native-chat-structured-composer-transport.ts b/src/renderer/src/components/native-chat/use-native-chat-structured-composer-transport.ts index c1e5ca5aebd9..52f41f49f61c 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-structured-composer-transport.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-structured-composer-transport.ts @@ -7,6 +7,25 @@ import type { } from './native-chat-composer-types' import type { NativeChatStructuredViewProps } from './native-chat-view-types' import type { useStructuredAgentSession } from './use-structured-agent-session' +import type { AgentType } from '../../../../shared/agent-status-types' +import { translate } from '@/i18n/i18n' +import { structuredAgentLabel } from '@/lib/structured-agent-session-launch-label' + +/** Why a paired chat's files can't upload yet: its host has not created the chat. */ +function nativeChatSessionAttachmentNotReadyNotice(agent: AgentType, starting: boolean): string { + const label = structuredAgentLabel(agent) + return starting + ? translate( + 'components.native-chat.composer.sessionAttachmentsStarting', + '{{agent}} chat is still opening. Wait for it to open, then attach the files again.', + { agent: label } + ) + : translate( + 'components.native-chat.composer.sessionAttachmentsFailed', + '{{agent}} chat could not open. Use Retry in the chat, then attach the files again.', + { agent: label } + ) +} /** What the composer of a structured chat sends, picks and runs through, from the chat's controller. */ export function useNativeChatStructuredComposerTransport(args: { @@ -20,6 +39,7 @@ export function useNativeChatStructuredComposerTransport(args: { ) => boolean | 'queued' /** The chat is starting: it takes no send yet. */ starting: boolean + transportEnabled: boolean worktreeId: string | undefined optionPickerRequest: NativeChatOptionPickerRequest | null setOptionPickerRequest: Dispatch> @@ -30,6 +50,7 @@ export function useNativeChatStructuredComposerTransport(args: { }): NativeChatStructuredComposerTransport { const { props, controller, sendThroughLaunch, starting, worktreeId, optionPickerRequest } = args const { setOptionPickerRequest, onError, onSubmitted } = args + const { transportEnabled } = args const sendBlockedReason = args.sendBlockedReason ?? null const acceptsImages = useStructuredAgentAcceptsImages( props.target, @@ -87,7 +108,13 @@ export function useNativeChatStructuredComposerTransport(args: { runtime: props.target.kind === 'local' ? 'local' : 'remote', sessionId: props.sessionId, runtimeEnvironmentId: - props.target.kind === 'local' ? null : (props.target.environmentId ?? null) + props.target.kind === 'local' ? null : (props.target.environmentId ?? null), + ...(!transportEnabled + ? { + sessionPending: true, + sessionPendingNotice: nativeChatSessionAttachmentNotReadyNotice(props.agent, starting) + } + : {}) } }, [ acceptsImages, @@ -102,6 +129,7 @@ export function useNativeChatStructuredComposerTransport(args: { sendBlockedReason, sendThroughLaunch, setOptionPickerRequest, - starting + starting, + transportEnabled ]) } diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts index e140cf714932..86a611c6102a 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts @@ -65,7 +65,7 @@ export function useStructuredAgentSessionOptions(args: { const launchSeedOptions = launch?.seedOptions const held = launch?.heldOptions ?? NO_HELD_OPTIONS // Published but not attached: the launch no longer holds picks and there is no fence to send one. - const acceptsPicks = !transportEnabled || fence !== null + const acceptsPicks = (!transportEnabled || fence !== null) && launch?.seedPending !== true const optionCatalog = useMemo(() => structuredAgentSessionSeedCatalog(agent), [agent]) const { optionView, diff --git a/src/renderer/src/components/native-chat/use-structured-native-chat-submit-reveal.test.ts b/src/renderer/src/components/native-chat/use-structured-native-chat-submit-reveal.test.ts index 673c91cdc708..2390979b6f32 100644 --- a/src/renderer/src/components/native-chat/use-structured-native-chat-submit-reveal.test.ts +++ b/src/renderer/src/components/native-chat/use-structured-native-chat-submit-reveal.test.ts @@ -1,8 +1,17 @@ // @vitest-environment happy-dom import { act, renderHook } from '@testing-library/react' import { expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' +import { useNativeChatHostPermissionGate } from './use-native-chat-host-permission-gate' +import { structuredSessionNotices } from './native-chat-structured-session-notices' import { useStructuredNativeChatSubmitReveal } from './use-structured-native-chat-submit-reveal' +const permission = vi.hoisted(() => ({ capability: vi.fn(), settings: vi.fn() })) +vi.mock('@/runtime/runtime-rpc-client', () => ({ + runtimeEnvironmentSupportsCapability: permission.capability, + callRuntimeRpc: permission.settings +})) + it('reveals before launch Retry, but waits for a successful queue Resume', async () => { const order: string[] = [] const controller = { @@ -61,10 +70,46 @@ it('sends no queued card and retries no start on a host held for its permission } const controller = { respond: vi.fn(async () => null), queuedMessages } const retryLaunch = vi.fn() - const { result } = renderHook(() => - useStructuredNativeChatSubmitReveal(controller, retryLaunch, true) - ) + useAppStore.setState({ + settings: { ...useAppStore.getState().settings!, nativeChatPermissionMode: 'ask' } + }) + permission.capability.mockResolvedValue(false) + permission.settings.mockRejectedValue(new Error('unreachable')) + const { result } = renderHook(() => { + const gate = useNativeChatHostPermissionGate( + { kind: 'environment', environmentId: 'server' }, + 'claude', + 'Claude' + ) + const reveal = useStructuredNativeChatSubmitReveal( + controller, + retryLaunch, + gate.sendBlockedReason !== null + ) + return { + ...reveal, + publishedNotices: structuredSessionNotices({ + launch: { lifecycle: 'published', failure: null, retry: reveal.retryLaunch }, + agentLabel: 'Claude', + sessionError: null, + composerError: null, + hostPermission: gate.notice + }), + notices: structuredSessionNotices({ + launch: { lifecycle: 'failed', failure: null, retry: reveal.retryLaunch }, + agentLabel: 'Claude', + sessionError: null, + composerError: null, + hostPermission: gate.notice + }) + } + }) + await vi.waitFor(() => expect(result.current.notices[0]?.action?.label).toBe('Check again')) + expect(result.current.publishedNotices[0]).toMatchObject({ + text: "Orca couldn't confirm the host's permissions for Claude.", + action: { label: 'Check again' } + }) await act(() => result.current.queuedMessages.resume()) await act(() => result.current.queuedMessages.steer('m1')) expect(result.current.queuedMessages.steerNewest()).toBe(false) @@ -73,4 +118,17 @@ it('sends no queued card and retries no start on a host held for its permission expect(queuedMessages.steerNewest).not.toHaveBeenCalled() act(() => result.current.retryLaunch()) expect(retryLaunch).not.toHaveBeenCalled() + let allow!: (allowed: boolean) => void + permission.capability.mockReturnValue( + new Promise((resolve) => { + allow = resolve + }) + ) + act(() => result.current.notices[0]?.action?.onClick()) + expect(result.current.notices[0]?.action?.label).toBe('Check again') + expect(retryLaunch).not.toHaveBeenCalled() + await act(async () => allow(true)) + expect(result.current.notices[0]?.action?.label).toBe('Retry') + act(() => result.current.notices[0]?.action?.onClick()) + expect(retryLaunch).toHaveBeenCalledOnce() }) diff --git a/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx b/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx index 72f361428bb9..bebb9ae8b638 100644 --- a/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx +++ b/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx @@ -66,8 +66,13 @@ export function useTabAgentMenuLaunch({ onPromptDelivered, onPromptHandedOff, disabled, - onFocusHostTerminal -}: QuickLaunchAgentMenuItemsProps & { onFocusHostTerminal?: () => void }) { + onFocusHostTerminal, + onFocusChat +}: QuickLaunchAgentMenuItemsProps & { + onFocusHostTerminal?: () => void + /** A New Chat opened at once: its composer takes the focus the menu gives back. */ + onFocusChat?: (tabId: string) => void +}) { const runLaunch = useCallback( (row: NewTabAgentMenuRow) => { if (disabled || row.kind === 'chat-unavailable') { @@ -125,6 +130,10 @@ export function useTabAgentMenuLaunch({ } }) } + if (result.surface.kind === 'local-chat') { + onFocusChat?.(result.surface.tabId) + return + } if (result.surface.kind !== 'local-terminal') { onFocusHostTerminal?.() return @@ -141,7 +150,8 @@ export function useTabAgentMenuLaunch({ onPromptDelivered, onPromptHandedOff, disabled, - onFocusHostTerminal + onFocusHostTerminal, + onFocusChat ] ) diff --git a/src/renderer/src/components/tab-bar/use-tab-bar-create-menu-controller.ts b/src/renderer/src/components/tab-bar/use-tab-bar-create-menu-controller.ts index 2d65e6782e99..6fcb2b14006a 100644 --- a/src/renderer/src/components/tab-bar/use-tab-bar-create-menu-controller.ts +++ b/src/renderer/src/components/tab-bar/use-tab-bar-create-menu-controller.ts @@ -4,6 +4,7 @@ import type { WindowsTerminalCapabilities } from '@/lib/windows-terminal-capabil import { useAppStore } from '../../store' import type { TabAgentLaunchOption } from './tab-agent-launch-options' import { useTabAgentMenuLaunch } from './QuickLaunchButton' +import { requestNativeChatComposerFocus } from '@/components/native-chat/use-native-chat-composer-reveal-focus' import { useNewTabAgentMenuRows, type NewTabAgentMenuModel } from './use-new-tab-agent-menu-rows' import type { AgentDetectionTarget } from '@/hooks/useDetectedAgents' import { buildTabAgentLaunchOptionsFromRows } from './tab-agent-launch-options' @@ -95,22 +96,20 @@ export function useTabBarCreateMenuController({ agentMenu.state === 'settled' ? agentMenu.rows : [], commandOverrides ) - const pendingNewTabMenuFocusRef = useRef<(() => void) | null>(null) + const pendingNewTabMenuFocusRef = useRef<{ focus: () => void; synchronous?: true } | null>(null) const pendingNewTabMenuFocusAnimationRef = useRef(null) const pendingNewTabMenuFocusRetryRef = useRef(null) const clearPendingNewTabMenuFocusAnimation = (): void => { - if (pendingNewTabMenuFocusAnimationRef.current === null) { - return + if (pendingNewTabMenuFocusAnimationRef.current !== null) { + cancelAnimationFrame(pendingNewTabMenuFocusAnimationRef.current) + pendingNewTabMenuFocusAnimationRef.current = null } - cancelAnimationFrame(pendingNewTabMenuFocusAnimationRef.current) - pendingNewTabMenuFocusAnimationRef.current = null } const clearPendingNewTabMenuFocusRetry = (): void => { - if (pendingNewTabMenuFocusRetryRef.current === null) { - return + if (pendingNewTabMenuFocusRetryRef.current !== null) { + window.clearTimeout(pendingNewTabMenuFocusRetryRef.current) + pendingNewTabMenuFocusRetryRef.current = null } - window.clearTimeout(pendingNewTabMenuFocusRetryRef.current) - pendingNewTabMenuFocusRetryRef.current = null } const focusNewActiveTerminalWhenReady = ( previousActiveTabId: string | null, @@ -136,20 +135,21 @@ export function useTabBarCreateMenuController({ } const queueNewActiveTerminalFocusAfterNewTabMenuClose = (): void => { const previousActiveTabId = useAppStore.getState().activeTabId - pendingNewTabMenuFocusRef.current = () => { + queueFocusAfterNewTabMenuClose(() => { // Why: paired web/SSH tab creation is async; await the host snapshot's new terminal instead of the pre-existing active tab. focusNewActiveTerminalWhenReady( previousActiveTabId, Date.now() + NEW_TAB_MENU_TERMINAL_FOCUS_TIMEOUT_MS, Date.now() ) - } - } - const queueTerminalTabFocusAfterNewTabMenuClose = (tabId: string): void => { - pendingNewTabMenuFocusRef.current = () => focusTerminalTabSurface(tabId) + }) } - const queueFocusAfterNewTabMenuClose = (focus: () => void): void => { - pendingNewTabMenuFocusRef.current = focus + const queueTerminalTabFocusAfterNewTabMenuClose = (tabId: string): void => + queueFocusAfterNewTabMenuClose(() => focusTerminalTabSurface(tabId)) + const queueFocusAfterNewTabMenuClose = (focus: () => void, synchronous?: true): void => { + clearPendingNewTabMenuFocusAnimation() + clearPendingNewTabMenuFocusRetry() + pendingNewTabMenuFocusRef.current = { focus, synchronous } } const windowsShellEntries = useMemo(() => { return buildWindowsShellMenuEntries({ @@ -233,7 +233,9 @@ export function useTabBarCreateMenuController({ groupId: resolvedGroupId, launchSource: 'tab_bar_quick_launch', onFocusTerminal: queueTerminalTabFocusAfterNewTabMenuClose, - onFocusHostTerminal: queueNewActiveTerminalFocusAfterNewTabMenuClose + onFocusHostTerminal: queueNewActiveTerminalFocusAfterNewTabMenuClose, + onFocusChat: (tabId) => + queueFocusAfterNewTabMenuClose(() => requestNativeChatComposerFocus(tabId), true) }) const launchAgentFromNewTabEntry = (option: TabAgentLaunchOption): void => { if (option.row) { @@ -245,27 +247,27 @@ export function useTabBarCreateMenuController({ pendingNewTabMenuFocusRef.current = null clearPendingNewTabMenuFocusAnimation() clearPendingNewTabMenuFocusRetry() - if (pendingFocus) { + if (pendingFocus?.synchronous) { + pendingFocus.focus() + } else if (pendingFocus) { pendingNewTabMenuFocusAnimationRef.current = requestAnimationFrame(() => { pendingNewTabMenuFocusAnimationRef.current = null - pendingFocus() + pendingFocus.focus() }) } } - const clearPendingNewTabMenuFocusOnUnmountRef = useRef< - ((node: HTMLDivElement | null) => void) | null - >(null) - if (clearPendingNewTabMenuFocusOnUnmountRef.current === null) { - clearPendingNewTabMenuFocusOnUnmountRef.current = (node: HTMLDivElement | null): void => { + const menuFocusUnmountRef = useRef<((node: HTMLDivElement | null) => void) | null>(null) + if (menuFocusUnmountRef.current === null) { + menuFocusUnmountRef.current = (node: HTMLDivElement | null): void => { if (node !== null) { return } // Why: cancel the delayed focus handoff via this root ref cleanup, avoiding an otherwise cleanup-only React Effect. + pendingNewTabMenuFocusRef.current = null clearPendingNewTabMenuFocusAnimation() clearPendingNewTabMenuFocusRetry() } } - const clearPendingNewTabMenuFocusOnUnmount = clearPendingNewTabMenuFocusOnUnmountRef.current useEffect(() => { if (!newTabMenuOpen) { return @@ -291,7 +293,7 @@ export function useTabBarCreateMenuController({ handleSelectCreateMenuOption, launchAgentFromNewTabEntry, runPendingNewTabMenuFocusAfterClose, - clearPendingNewTabMenuFocusOnUnmount, + clearPendingNewTabMenuFocusOnUnmount: menuFocusUnmountRef.current, queueNewActiveTerminalFocusAfterNewTabMenuClose, queueTerminalTabFocusAfterNewTabMenuClose, queueFocusAfterNewTabMenuClose, diff --git a/src/renderer/src/components/ui/context-menu.tsx b/src/renderer/src/components/ui/context-menu.tsx index c57948cde042..f475bb0cb6e5 100644 --- a/src/renderer/src/components/ui/context-menu.tsx +++ b/src/renderer/src/components/ui/context-menu.tsx @@ -4,6 +4,7 @@ import * as ContextMenuPrimitive from 'radix-ui/context-menu' import { cn } from '@/lib/utils' import { handleImeOverlayEscape } from '@/lib/ime-overlay-escape' +import { keepFocusWhileMenuCloses } from './menu-item-closing-focus' function ContextMenu({ ...props }: React.ComponentProps) { return @@ -99,6 +100,8 @@ function ContextMenuItem({ className, inset, variant = 'default', + onPointerMove, + onPointerLeave, ...props }: React.ComponentProps & { inset?: boolean @@ -113,6 +116,8 @@ function ContextMenuItem({ "relative flex cursor-default items-center gap-2 rounded-md px-2 py-[4px] text-[12px] leading-[17px] font-[450] outline-hidden select-none focus:bg-black/8 dark:focus:bg-white/14 focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5 [&_svg:not([class*='text-'])]:text-muted-foreground data-[variant=destructive]:*:[svg]:text-destructive!", className )} + onPointerMove={keepFocusWhileMenuCloses(onPointerMove)} + onPointerLeave={keepFocusWhileMenuCloses(onPointerLeave)} {...props} /> ) diff --git a/src/renderer/src/components/ui/dropdown-menu.tsx b/src/renderer/src/components/ui/dropdown-menu.tsx index 4e9dbe0846e4..06a422c01bb8 100644 --- a/src/renderer/src/components/ui/dropdown-menu.tsx +++ b/src/renderer/src/components/ui/dropdown-menu.tsx @@ -4,6 +4,7 @@ import * as DropdownMenuPrimitive from 'radix-ui/dropdown-menu' import { cn } from '@/lib/utils' import { handleImeOverlayEscape } from '@/lib/ime-overlay-escape' +import { keepFocusWhileMenuCloses } from './menu-item-closing-focus' function DropdownMenu({ ...props }: React.ComponentProps) { return @@ -51,6 +52,8 @@ function DropdownMenuItem({ className, inset, variant = 'default', + onPointerMove, + onPointerLeave, ...props }: React.ComponentProps & { inset?: boolean @@ -66,6 +69,8 @@ function DropdownMenuItem({ "relative flex cursor-default items-center gap-2 rounded-md px-2 py-[4px] text-[12px] leading-[17px] font-[450] outline-hidden select-none focus:bg-black/8 dark:focus:bg-white/14 focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5 [&_svg:not([class*='text-'])]:text-muted-foreground data-[variant=destructive]:*:[svg]:text-destructive! data-[variant=launch]:rounded-[7px] data-[variant=launch]:py-1.5 data-[variant=launch]:leading-5 data-[variant=launch]:font-medium", className )} + onPointerMove={keepFocusWhileMenuCloses(onPointerMove)} + onPointerLeave={keepFocusWhileMenuCloses(onPointerLeave)} {...props} /> ) diff --git a/src/renderer/src/components/ui/menu-item-closing-focus.test.ts b/src/renderer/src/components/ui/menu-item-closing-focus.test.ts new file mode 100644 index 000000000000..20031f2f3024 --- /dev/null +++ b/src/renderer/src/components/ui/menu-item-closing-focus.test.ts @@ -0,0 +1,29 @@ +// @vitest-environment happy-dom +import { describe, expect, it, vi } from 'vitest' +import type * as React from 'react' +import { keepFocusWhileMenuCloses } from './menu-item-closing-focus' + +function pointerEventOnItem(menuState: 'open' | 'closed'): React.PointerEvent { + const menu = document.createElement('div') + menu.setAttribute('role', 'menu') + menu.setAttribute('data-state', menuState) + const item = menu.appendChild(document.createElement('div')) + const event = { currentTarget: item, preventDefault: vi.fn() } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the handler reads only currentTarget and preventDefault. + return event as unknown as React.PointerEvent +} + +describe('keepFocusWhileMenuCloses', () => { + // A New Chat chosen from the menu lost every key after the first: the closing menu took focus back. + it('cancels the library pointer focus only while the menu is closing, after the caller runs', () => { + const caller = vi.fn() + const closing = pointerEventOnItem('closed') + keepFocusWhileMenuCloses(caller)(closing) + expect(caller).toHaveBeenCalledWith(closing) + expect(closing.preventDefault).toHaveBeenCalled() + + const open = pointerEventOnItem('open') + keepFocusWhileMenuCloses(undefined)(open) + expect(open.preventDefault).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/ui/menu-item-closing-focus.ts b/src/renderer/src/components/ui/menu-item-closing-focus.ts new file mode 100644 index 000000000000..a93a4922c20e --- /dev/null +++ b/src/renderer/src/components/ui/menu-item-closing-focus.ts @@ -0,0 +1,14 @@ +import type * as React from 'react' + +/** A dismissed menu stays mounted while it animates out; pointer handlers on its items must not + * pull focus back from what the chosen item opened. Cancelling skips Radix's own focus handling. */ +export function keepFocusWhileMenuCloses( + handler: React.PointerEventHandler | undefined +): React.PointerEventHandler { + return (event) => { + handler?.(event) + if (event.currentTarget.closest('[role="menu"]')?.getAttribute('data-state') === 'closed') { + event.preventDefault() + } + } +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 47de0ad1a7f5..34786861f369 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -18896,6 +18896,8 @@ "imagePasteFailed": "Image paste failed.", "imageSaving": "Saving pasted image…", "worktreeNotReady": "Worktree not ready — try again in a moment.", + "sessionAttachmentsStarting": "{{agent}} chat is still opening. Wait for it to open, then attach the files again.", + "sessionAttachmentsFailed": "{{agent}} chat could not open. Use Retry in the chat, then attach the files again.", "pasteUnavailable": "Can't paste — this chat isn't accepting input right now.", "model": "Model", "effort": "Effort", @@ -19353,7 +19355,8 @@ "explicitChatSsh": "Chat is not available over SSH. Open the agent in a terminal instead.", "explicitChatUnavailable": "This workspace cannot run the chat. Check the agent installation or open it in a terminal.", "structuredSessionHostDeclined": "Opened {{value0}} in a terminal", - "structuredSessionHostDeclinedDescription": "This server can't run a {{value0}} chat in this workspace.", + "structuredSessionTerminalNotVisible": "Orca opened {{value0}} in a terminal, but couldn't show it here when the launch finished. Your chat draft was kept.", + "structuredSessionHostDeclinedDescription": "This workspace can't run a {{value0}} chat.", "structuredSessionHostUnreachable": "Could not reach {{value0}}", "structuredSessionHostUnreachableDescription": "Orca did not open a {{value0}} chat. Check the connection to the server and try again.", "hostOutage": { @@ -19383,10 +19386,11 @@ "replace": "Replace" }, "hostPermission": { - "checking": "Checking this host's chat permissions…", + "checkAgain": "Check again", "launchRefused": "Orca did not start a {{value0}} chat", + "checking": "Checking this host's chat permissions…", "outdatedHost": "This host runs an older Orca that ignores chat permissions, so {{value0}} would run here without asking. Update Orca on this host to use {{value1}}.", - "unverified": "Orca couldn't confirm this host applies chat permissions. Reconnect to it to send." + "unverified": "Orca couldn't confirm the host's permissions for {{value0}}." } }, "tab": { 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 30574b1fca1c..6705d5372e8b 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 @@ -28,7 +28,12 @@ export function launchStructuredAgentFromNewTab(args: { focusIntent?: NativeChatComposerFocusIntent beforeSurfaceOpen?: LaunchAgentInNewTabArgs['beforeSurfaceOpen'] onHostDeclined?: () => StructuredLaunchTerminal - openTerminal: (terminalPlan: AgentSessionLaunchPlan) => { + openTerminal: ( + terminalPlan: AgentSessionLaunchPlan, + activate?: false + ) => { + surface: AgentLaunchSurface + terminalCompletion?: StructuredLaunchTerminal['completion'] promptDeliveryResult?: Promise } | null }): StructuredFromNewTab | null { @@ -42,7 +47,7 @@ export function launchStructuredAgentFromNewTab(args: { ...(args.focusIntent ? { focusIntent: args.focusIntent } : {}), target: { worktreeId: args.worktreeId }, ...(args.groupId ? { targetGroupId: args.groupId } : {}), - onHostDeclined: () => { + onHostDeclined: (_target, activate) => { if (args.onHostDeclined) { return args.onHostDeclined() } @@ -52,10 +57,13 @@ export function launchStructuredAgentFromNewTab(args: { requestId: plan.requestId, agent: plan.agent, worktreeId: args.worktreeId - }) + }), + activate ) return { opened: terminal !== null, + ...(terminal?.surface.kind === 'local-terminal' ? { tabId: terminal.surface.tabId } : {}), + ...(terminal?.terminalCompletion ? { completion: terminal.terminalCompletion } : {}), ...(terminal?.promptDeliveryResult ? { promptDeliveryResult: terminal.promptDeliveryResult } : {}) @@ -64,7 +72,9 @@ export function launchStructuredAgentFromNewTab(args: { }) return ( launch && { - surface: { kind: 'host-published' }, + surface: launch.tab + ? { kind: 'local-chat', tabId: launch.tab.id } + : { kind: 'host-published' }, pasteDraftAfterLaunch: false, structuredSettlement: launch.settlement, ...(launch.promptDeliveryResult ? { promptDeliveryResult: launch.promptDeliveryResult } : {}) diff --git a/src/renderer/src/lib/launch-agent-in-new-tab-web-runtime.test.ts b/src/renderer/src/lib/launch-agent-in-new-tab-web-runtime.test.ts index 0f5f48959077..a4681a3e4c6f 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab-web-runtime.test.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab-web-runtime.test.ts @@ -4,7 +4,8 @@ const mocks = vi.hoisted(() => ({ createTab: vi.fn(), closeTab: vi.fn(), createWebRuntimeSessionTerminal: vi.fn(), - setActiveTabType: vi.fn() + setActiveTabType: vi.fn(), + toastError: vi.fn() })) const store = { @@ -54,22 +55,19 @@ const store = { } vi.mock('@/store', () => ({ useAppStore: { getState: () => store } })) -vi.mock('sonner', () => ({ toast: { message: vi.fn(), error: vi.fn() } })) +vi.mock('sonner', () => ({ toast: { message: vi.fn(), error: mocks.toastError } })) vi.mock('@/components/tab-bar/reconcile-order', () => ({ reconcileTabOrder: vi.fn(() => []) })) vi.mock('@/lib/agent-paste-draft', () => ({ pasteDraftWhenAgentReady: vi.fn() })) vi.mock('@/lib/telemetry', () => ({ track: vi.fn(), tuiAgentToAgentKind: (agent: string) => agent })) -vi.mock('@/runtime/web-runtime-terminal-creation', () => ({ - createWebRuntimeSessionTerminal: mocks.createWebRuntimeSessionTerminal +vi.mock('@/runtime/web-runtime-terminal-create-operation', () => ({ + createWebRuntimeSessionTerminalResult: mocks.createWebRuntimeSessionTerminal })) vi.mock('@/runtime/web-runtime-session-environment', () => ({ isWebRuntimeSessionActive: vi.fn(() => true) })) -vi.mock('../../../shared/terminal-surface-id', () => ({ - isWebTerminalSurfaceTabId: vi.fn(() => false) -})) describe('launchAgentInNewTab paired web runtime', () => { beforeEach(() => { @@ -81,15 +79,28 @@ describe('launchAgentInNewTab paired web runtime', () => { activeRuntimeEnvironmentId: 'web-runtime' } store.tabsByWorktree = { 'wt-1': [{ id: 'tab-1' }] } - mocks.createWebRuntimeSessionTerminal.mockResolvedValue({ status: 'created' }) + mocks.createWebRuntimeSessionTerminal.mockResolvedValue({ + outcome: { status: 'created' }, + hostTabId: 'host-tab::leaf-1' + }) }) - it('delegates agent quick launch to the host runtime', async () => { + it.each([true, false, 'failed'] as const)('delegates agent quick launch (%s)', async (mode) => { + const activate = mode === true + const status = mode === 'failed' ? 'failed' : 'created' + mocks.createWebRuntimeSessionTerminal.mockResolvedValue({ + outcome: { + status, + ...(status === 'failed' ? { message: 'The host refused the launch.' } : {}) + }, + hostTabId: 'host-tab::leaf-1' + }) store.tabsByWorktree['wt-1'].push({ id: 'local-agent-tab', launchAgent: 'claude' }) const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') const result = launchAgentInNewTab({ requestId: 'request-1', + activate, agent: 'claude', worktreeId: 'wt-1', groupId: 'group-1' @@ -101,22 +112,26 @@ describe('launchAgentInNewTab paired web runtime', () => { pasteDraftAfterLaunch: false }) ) - expect(mocks.createWebRuntimeSessionTerminal).toHaveBeenCalledWith( - { - worktreeId: 'wt-1', - environmentId: 'web-runtime', - targetGroupId: 'group-1', - activate: true, - agentSessionKind: 'fresh', - agent: 'claude', - viewMode: 'terminal' - }, - { notifyFailure: false } - ) + expect(mocks.createWebRuntimeSessionTerminal).toHaveBeenCalledWith({ + worktreeId: 'wt-1', + environmentId: 'web-runtime', + targetGroupId: 'group-1', + activate, + ...(activate ? {} : { selectWorktree: false }), + agentSessionKind: 'fresh', + agent: 'claude', + viewMode: 'terminal' + }) expect(mocks.createTab).not.toHaveBeenCalled() - await Promise.resolve() - // Why: host creation is async, so the user may be viewing another worktree by the time it lands. - expect(mocks.setActiveTabType).toHaveBeenCalledExactlyOnceWith('terminal', 'wt-1') + await expect(result?.terminalCompletion).resolves.toMatchObject({ + outcome: { status }, + ...(status === 'created' ? { tabId: 'web-terminal-host-tab' } : {}) + }) + expect(mocks.setActiveTabType).toHaveBeenCalledTimes(activate && status === 'created' ? 1 : 0) + expect(mocks.toastError).toHaveBeenCalledTimes(status === 'failed' ? 1 : 0) + if (status === 'failed') { + expect(mocks.toastError).toHaveBeenCalledExactlyOnceWith('The host refused the launch.') + } // A local tab closes locally (the host close no-ops), so a launch leaves it alone. expect(mocks.closeTab).not.toHaveBeenCalled() }) @@ -140,28 +155,25 @@ describe('launchAgentInNewTab paired web runtime', () => { pasteDraftAfterLaunch: false }) ) - expect(mocks.createWebRuntimeSessionTerminal).toHaveBeenCalledWith( - { - worktreeId: 'wt-1', - environmentId: 'web-runtime', - targetGroupId: 'group-1', - activate: true, - agentSessionKind: 'fresh', - launchAgent: 'codex', - command: "codex '--model' 'gpt-5' '--reasoning-effort' 'high' 'fix the spinner'", - env: { CODEX_PROFILE: 'captured' }, - launchConfig: { - agentCommand: "codex '--model' 'gpt-5' '--reasoning-effort' 'high'", - agentArgs: '--model gpt-5 --reasoning-effort high', - agentEnv: { CODEX_PROFILE: 'captured' } - }, - startupCommandDelivery: 'shell-ready', - prompt: 'fix the spinner', - promptDelivery: 'auto-submit', - viewMode: 'terminal' + expect(mocks.createWebRuntimeSessionTerminal).toHaveBeenCalledWith({ + worktreeId: 'wt-1', + environmentId: 'web-runtime', + targetGroupId: 'group-1', + activate: true, + agentSessionKind: 'fresh', + launchAgent: 'codex', + command: "codex '--model' 'gpt-5' '--reasoning-effort' 'high' 'fix the spinner'", + env: { CODEX_PROFILE: 'captured' }, + launchConfig: { + agentCommand: "codex '--model' 'gpt-5' '--reasoning-effort' 'high'", + agentArgs: '--model gpt-5 --reasoning-effort high', + agentEnv: { CODEX_PROFILE: 'captured' } }, - { notifyFailure: false } - ) + startupCommandDelivery: 'shell-ready', + prompt: 'fix the spinner', + promptDelivery: 'auto-submit', + viewMode: 'terminal' + }) expect(mocks.createTab).not.toHaveBeenCalled() }) }) diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.test.ts b/src/renderer/src/lib/launch-agent-in-new-tab.test.ts index 219e82188beb..bab3ce449730 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.test.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { setLocalRuntimeCapabilitiesForTests } from '@/runtime/local-runtime-capabilities' import { toAppSshPtyId } from '../../../shared/ssh-pty-id' +import type { Project } from '../../../shared/project-types' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' const mockCreateTab = vi.fn() @@ -22,6 +23,10 @@ const mockWaitForAgentReady = vi.fn() const LEAF_ID = '11111111-1111-4111-8111-111111111111' +const initialProjects: Pick[] = [ + { id: 'repo-1', localWindowsRuntimePreference: { kind: 'inherit-global' } } +] + const store = { activeRepoId: 'repo-1', activeWorktreeId: 'wt-1', @@ -42,18 +47,7 @@ const store = { { model?: string; valuesByModel?: Record> } > }, - projects: [ - { - id: 'repo-1', - localWindowsRuntimePreference: { kind: 'inherit-global' as const } - } - ] as { - id: string - localWindowsRuntimePreference: - | { kind: 'inherit-global' } - | { kind: 'windows-host' } - | { kind: 'wsl'; distro: string | null } - }[], + projects: initialProjects, repos: [{ id: 'repo-1', connectionId: null as string | null, path: '/repo' }], sshConnectionStates: new Map([ ['ssh-a', { status: 'connected', remotePlatform: 'linux' }], @@ -142,6 +136,11 @@ const mockCreateWebRuntimeSessionTerminal = vi.fn() const mockCreateWebRuntimeAgentSessionTerminalWithLaunchDraft = vi.fn() const mockIsWebRuntimeSessionActive = vi.fn(() => false) +vi.mock('@/runtime/web-runtime-terminal-create-operation', () => ({ + createWebRuntimeSessionTerminalResult: async (args: unknown) => ({ + outcome: await mockCreateWebRuntimeSessionTerminal(args) + }) +})) vi.mock('@/runtime/web-runtime-terminal-creation', () => ({ createWebRuntimeSessionTerminal: mockCreateWebRuntimeSessionTerminal, createWebRuntimeAgentSessionTerminalWithLaunchDraft: @@ -593,9 +592,8 @@ describe('launchAgentInNewTab', () => { } const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') - launchAgentInNewTab({ requestId: 'request-14', agent: 'claude', worktreeId: 'wt-1' }) - - await Promise.resolve() + await launchAgentInNewTab({ requestId: 'request-14', agent: 'claude', worktreeId: 'wt-1' }) + ?.terminalCompletion expect(mockToastError).toHaveBeenCalledWith( 'Upgrade the remote Orca host before starting or resuming agent sessions.' ) 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 f51fe6a7d25b..8e2a5ad6b0fa 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.ts @@ -89,9 +89,10 @@ export type LaunchAgentInNewTabArgs = LaunchAgentInNewTabRequest & { onStructuredHostDeclined?: () => StructuredLaunchTerminal } -/** `host-published`: the surface opens once its host answers, a structured chat's included. */ +/** `host-published`: the surface opens once its host answers; `local-chat` opened before it did. */ export type AgentLaunchSurface = | { kind: 'local-terminal'; tabId: string } + | { kind: 'local-chat'; tabId: string } | { kind: 'host-published' } export type LaunchAgentInNewTabResult = { @@ -100,6 +101,7 @@ export type LaunchAgentInNewTabResult = { pasteDraftAfterLaunch: boolean promptDeliveryResult?: Promise<{ delivered: boolean; failureNotified: boolean }> /** Structured route only: what the launch did once it settled. The call stays synchronous. */ + terminalCompletion?: StructuredLaunchTerminal['completion'] structuredSettlement?: Promise } | null @@ -144,7 +146,6 @@ function launchAgentInNewTabInternal(args: LaunchAgentInNewTabArgs): LaunchAgent return null } const { resolvedLaunchPlatform, isRemote, queuedShell } = executionContext - const cmdOverrides = store.settings?.agentCmdOverrides ?? {} const effectiveAgentArgs = agentArgs !== undefined ? agentArgs @@ -153,13 +154,12 @@ function launchAgentInNewTabInternal(args: LaunchAgentInNewTabArgs): LaunchAgent const trimmedPrompt = prompt?.trim() ?? '' const hasPrompt = trimmedPrompt.length > 0 const isFollowupPath = TUI_AGENT_CONFIG[agent].promptInjectionMode === 'stdin-after-start' - const workspaceKind = workspaceKindForWorktreeId(worktreeId) // Why: a followup-path agent gets its prompt pasted unsubmitted after start, so route it as a draft. const routePromptDelivery = hasPrompt && isFollowupPath && promptDelivery === 'auto-submit' ? 'draft' : promptDelivery const startupPlanBase = { agent, - cmdOverrides, + cmdOverrides: store.settings?.agentCmdOverrides ?? {}, platform: resolvedLaunchPlatform, shell: queuedShell, isRemote, @@ -185,7 +185,7 @@ function launchAgentInNewTabInternal(args: LaunchAgentInNewTabArgs): LaunchAgent agent, requestedSurface: args.requestedSurface, selectedExecutionHostId: args.selectedExecutionHostId, - workspace: { kind: workspaceKind, worktreeId }, + workspace: { kind: workspaceKindForWorktreeId(worktreeId), worktreeId }, prompt: trimmedPrompt, promptDelivery: routePromptDelivery, tuiCustomization: { cwd: initialCwd }, @@ -205,10 +205,11 @@ function launchAgentInNewTabInternal(args: LaunchAgentInNewTabArgs): LaunchAgent ...(beforeSurfaceOpen ? { beforeSurfaceOpen } : {}), ...(args.onStructuredHostDeclined ? { onHostDeclined: args.onStructuredHostDeclined } : {}), // The host's "no" opens this same launch as a terminal, with the caller's arguments. - openTerminal: (terminalPlan) => + openTerminal: (terminalPlan, terminalActivate) => launchAgentInNewTabInternal({ ...args, beforeSurfaceOpen: undefined, + ...(terminalActivate === false ? { prompt: undefined, activate: false } : {}), requestId: undefined, agentSessionLaunchPlan: terminalPlan }) @@ -241,10 +242,12 @@ 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: 'terminal', + ...(activate === false ? { activate } : {}), onPromptDelivered }) return { surface: { kind: 'host-published' }, + terminalCompletion: webHostDelivery, startupPlan, pasteDraftAfterLaunch: pasteDraftAfterLaunch !== null, ...(pasteDraftAfterLaunch !== null && promptDelivery === 'submit-after-ready' @@ -341,8 +344,10 @@ function launchAgentInNewTabInternal(args: LaunchAgentInNewTabArgs): LaunchAgent } // Why: without setActiveTabType('terminal') an activated launch can stay hidden behind an editor. - // Scoped to the launch's worktree so a floating or background launch leaves the main window's tab alone. - store.setActiveTabType('terminal', worktreeId) + // Scoped to the launch's worktree so a floating launch leaves the main window's tab alone. + if (activate !== false) { + store.setActiveTabType('terminal', worktreeId) + } // Why: persist tab-bar order so reconcileTabOrder doesn't fall back to terminals-first and jump the new tab to index 0. persistAgentLaunchTabOrder(worktreeId, tab.id) @@ -355,6 +360,4 @@ function launchAgentInNewTabInternal(args: LaunchAgentInNewTabArgs): LaunchAgent } } -export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentInNewTabResult { - return launchAgentInNewTabInternal(args) -} +export { launchAgentInNewTabInternal as launchAgentInNewTab } diff --git a/src/renderer/src/lib/launch-agent-structured-chat-guard.test.ts b/src/renderer/src/lib/launch-agent-structured-chat-guard.test.ts index 840744fa15a2..10983d53a643 100644 --- a/src/renderer/src/lib/launch-agent-structured-chat-guard.test.ts +++ b/src/renderer/src/lib/launch-agent-structured-chat-guard.test.ts @@ -296,10 +296,13 @@ describe('structured chat adoption guard on the launch path', () => { 'codex', 'local', undefined, + undefined, undefined ) expect(mockLaunchStructuredCodexSession).toHaveBeenCalledWith( expect.objectContaining({ worktreeId: 'wt-1' }), + expect.any(Function), + false, expect.any(Function) ) expect(mockCreateTab).not.toHaveBeenCalled() @@ -329,6 +332,7 @@ describe('structured chat adoption guard on the launch path', () => { 'codex', 'local', undefined, + undefined, undefined ) expect(mockCreateTab).not.toHaveBeenCalled() @@ -350,6 +354,7 @@ describe('structured chat adoption guard on the launch path', () => { 'claude', 'local', undefined, + undefined, undefined ) expect(mockCreateTab).not.toHaveBeenCalled() 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 352bc88f79f0..f4ee71b9ca65 100644 --- a/src/renderer/src/lib/launch-agent-web-host-tab.ts +++ b/src/renderer/src/lib/launch-agent-web-host-tab.ts @@ -1,15 +1,19 @@ +import { createWebRuntimeSessionTerminalResult } from '@/runtime/web-runtime-terminal-create-operation' +import { webTerminalPlacementParentTabId } from '@/runtime/web-session-terminal-placement' import { toast } from 'sonner' import { useAppStore } from '@/store' import { createWebRuntimeAgentSessionTerminal, createWebRuntimeAgentSessionTerminalWithLaunchDraft, - createWebRuntimeSessionTerminal + type createWebRuntimeSessionTerminal } from '@/runtime/web-runtime-terminal-creation' import type { AgentStartupPlan } from '@/lib/tui-agent-startup' import type { Tab } from '../../../shared/tab-types' import type { TuiAgent } from '../../../shared/tui-agent' import type { AgentPromptDelivery } from '../../../shared/agent-session-host-authority' import { translate } from '@/i18n/i18n' +import { toWebTerminalSurfaceTabId } from '../../../shared/terminal-surface-id' +import type { AgentTerminalLaunchCompletion } from './structured-agent-session-launch-admission' import { toAgentLaunchPreferences } from '../../../shared/agent-launch-preferences' /** @@ -31,8 +35,10 @@ export function launchAgentInWebHostTab(args: { submitPastedPrompt: boolean agentArgs?: string | null viewMode?: Tab['viewMode'] + /** False opens the host's tab without selecting it. */ + activate?: boolean onPromptDelivered?: () => void -}): Promise<{ delivered: boolean; failureNotified: boolean }> { +}): Promise<{ delivered: boolean; failureNotified: boolean } & AgentTerminalLaunchCompletion> { const { agent, worktreeId, @@ -56,7 +62,8 @@ export function launchAgentInWebHostTab(args: { worktreeId, environmentId, targetGroupId: groupId, - activate: true, + activate: args.activate !== false, + ...(!hasPrompt && args.activate === false ? { selectWorktree: false } : {}), ...(cwd?.trim() ? { cwd } : {}), ...(viewMode ? { viewMode } : {}), agentSessionKind: 'fresh', @@ -81,11 +88,13 @@ export function launchAgentInWebHostTab(args: { const handleCreation = ({ outcome, - promptDelivered + promptDelivered, + hostTabId }: { outcome: Awaited> promptDelivered: boolean - }): { delivered: boolean; failureNotified: boolean } => { + hostTabId?: string + }): { delivered: boolean; failureNotified: boolean } & AgentTerminalLaunchCompletion => { if (outcome.status === 'failed') { toast.error( outcome.message || @@ -95,13 +104,22 @@ export function launchAgentInWebHostTab(args: { { value0: agent } ) ) - return { delivered: false, failureNotified: true } + return { delivered: false, failureNotified: true, outcome } + } + if (args.activate !== false) { + useAppStore.getState().setActiveTabType('terminal', worktreeId) } - useAppStore.getState().setActiveTabType('terminal', worktreeId) if (hasPrompt && promptDelivered) { onPromptDelivered?.() } - return { delivered: promptDelivered, failureNotified: false } + return { + delivered: promptDelivered, + failureNotified: false, + outcome, + ...(hostTabId + ? { tabId: toWebTerminalSurfaceTabId(webTerminalPlacementParentTabId(hostTabId)) } + : {}) + } } if (pastePromptAfterReady !== null) { @@ -122,7 +140,10 @@ export function launchAgentInWebHostTab(args: { launchDraft: prompt }).then((outcome) => handleCreation({ outcome, promptDelivered: outcome.status === 'created' })) } - return createWebRuntimeSessionTerminal(launch, { notifyFailure: false }).then((outcome) => - handleCreation({ outcome, promptDelivered: outcome.status === 'created' && hasPrompt }) + return createWebRuntimeSessionTerminalResult(launch).then((created) => + handleCreation({ + ...created, + promptDelivered: created.outcome.status === 'created' && hasPrompt + }) ) } diff --git a/src/renderer/src/lib/launch-structured-agent-session.test.ts b/src/renderer/src/lib/launch-structured-agent-session.test.ts index 0301e84876d7..84aa2af01a1d 100644 --- a/src/renderer/src/lib/launch-structured-agent-session.test.ts +++ b/src/renderer/src/lib/launch-structured-agent-session.test.ts @@ -12,16 +12,75 @@ import { StructuredAgentSessionOwnerUnresolvedError } from './launch-structured-agent-session' import { admitStructuredLaunchOnHost } from './structured-agent-session-host-admission' +import { + clearWebSessionFocusIntent, + peekWebSessionFocusIntent +} from '@/runtime/web-session-focus-intent' vi.mock('@/runtime/structured-agent-session-client', () => ({ callStructuredAgentSession: vi.fn() })) +const hostPermission = vi.hoisted(() => + vi.fn<() => Promise<{ kind: string; hostMode?: null }>>(async () => ({ kind: 'allowed' })) +) +vi.mock('@/runtime/structured-chat-host-permission', () => ({ + readStructuredChatHostPermissionVerdict: hostPermission +})) describe('structured agent session launch', () => { beforeEach(() => { vi.mocked(callStructuredAgentSession).mockReset() }) + it('only a fresh foreground launch grants publication permission to select its chat', () => { + const worktreeId = 'focus-lifecycle' + const owner = { environmentId: 'server-1' } + const intent = createStructuredAgentSessionLaunchIntent(worktreeId, 'codex', 'runtime:server-1') + expect(peekWebSessionFocusIntent(owner, worktreeId)?.hostTabId).toBe( + `agent-session:${intent.sessionId}` + ) + clearWebSessionFocusIntent(owner, worktreeId) + retryStructuredAgentSessionLaunchIntent(intent) + restoreStructuredAgentSessionLaunchIntent({ + worktreeId, + executionHostId: intent.executionHostId, + agent: intent.agent, + ...intent.params.envelope + }) + createStructuredAgentSessionLaunchIntent( + worktreeId, + 'codex', + 'runtime:server-1', + undefined, + undefined, + false + ) + expect(peekWebSessionFocusIntent(owner, worktreeId)).toBeNull() + }) + + // The chat opens before its host answers, so the create is the last thing that can say no. + it('issues no create to a host held for its access, or after its chat closed', async () => { + vi.mocked(callStructuredAgentSession).mockResolvedValue({ supported: true }) + const held = createStructuredAgentSessionLaunchIntent( + 'workspace-1', + 'claude', + 'runtime:server-1' + ) + hostPermission.mockResolvedValueOnce({ kind: 'refused', hostMode: null }) + await expect(launchStructuredAgentSession(held)).rejects.toMatchObject({ + code: 'structured_agent_session_host_permission' + }) + const closed = createStructuredAgentSessionLaunchIntent('workspace-1', 'claude') + await expect( + launchStructuredAgentSession(closed, undefined, false, () => true) + ).rejects.toThrow('retired before create') + expect( + vi + .mocked(callStructuredAgentSession) + .mock.calls.some(([, method]) => method === 'agentSession.create') + ).toBe(false) + }) + it('creates a native session with a host-verifiable launch intent', async () => { vi.mocked(callStructuredAgentSession).mockImplementation(async (_target, method, params) => method === 'agentSession.createSupport' diff --git a/src/renderer/src/lib/launch-structured-agent-session.ts b/src/renderer/src/lib/launch-structured-agent-session.ts index da3809b559a2..c0794c5beefa 100644 --- a/src/renderer/src/lib/launch-structured-agent-session.ts +++ b/src/renderer/src/lib/launch-structured-agent-session.ts @@ -89,13 +89,23 @@ export function createStructuredAgentSessionLaunchIntent( agent: TuiAgent, executionHostId?: ExecutionHostId, resumeFrom?: StructuredAgentSessionResumeSource, - hostSeedOptions?: LaunchSeed + hostSeedOptions?: LaunchSeed, + activate = true ): StructuredAgentSessionLaunchIntent { const owner = structuredAgentSessionOwnerTarget( worktreeId, executionHostId ?? resolveStructuredAgentSessionOwner(useAppStore.getState(), worktreeId) ) const sessionId = createStructuredAgentSessionId(agent, createBrowserUuid) + if (activate) { + recordWebSessionFocusIntent( + structuredAgentSessionFocusOwner(owner.target), + worktreeId, + `agent-session:${sessionId}`, + undefined, + resolveWebSessionVisibleTabId(useAppStore.getState(), worktreeId) + ) + } return buildStructuredAgentSessionLaunchIntent( worktreeId, owner, @@ -115,13 +125,6 @@ function buildStructuredAgentSessionLaunchIntent( hostSeedOptions: LaunchSeed ): StructuredAgentSessionLaunchIntent { const state = useAppStore.getState() - recordWebSessionFocusIntent( - structuredAgentSessionFocusOwner(owner.target), - worktreeId, - `agent-session:${sessionId}`, - undefined, - resolveWebSessionVisibleTabId(state, worktreeId) - ) return { sessionId, worktreeId, @@ -171,13 +174,6 @@ export function restoreStructuredAgentSessionLaunchIntent(args: { }): StructuredAgentSessionLaunchIntent { const state = useAppStore.getState() const { target } = structuredAgentSessionOwnerTarget(args.worktreeId, args.executionHostId) - recordWebSessionFocusIntent( - structuredAgentSessionFocusOwner(target), - args.worktreeId, - `agent-session:${args.sessionId}`, - undefined, - resolveWebSessionVisibleTabId(state, args.worktreeId) - ) return { sessionId: args.sessionId, worktreeId: args.worktreeId, diff --git a/src/renderer/src/lib/structured-agent-launch-settlement.ts b/src/renderer/src/lib/structured-agent-launch-settlement.ts index b828802ae764..4ce6bbf4d117 100644 --- a/src/renderer/src/lib/structured-agent-launch-settlement.ts +++ b/src/renderer/src/lib/structured-agent-launch-settlement.ts @@ -27,6 +27,7 @@ export type StructuredAgentLaunchSettlement = | { kind: 'terminal' } export type StructuredAgentLaunchHooks = { + activate?: boolean onStructuredReady?: (sessionId: string) => void /** Abort the moment the caller abandons the launch. The loop cancels on the event, not only by * polling after awaits, so a staged prompt is discarded before it can reach the provider. */ @@ -110,6 +111,7 @@ export function beginStructuredAgentLaunchSettlement( ): StructuredAgentLaunchHandle { const launch = startStructuredAgentLaunch(worktreeId, agent, { ...options, + ...(hooks.activate !== undefined ? { activate: hooks.activate } : {}), ...(hooks.beforeCreate ? { beforeCreate: hooks.beforeCreate } : {}) }) // Joining transfers no source ownership to the already independent destination. diff --git a/src/renderer/src/lib/structured-agent-session-action-launch.ts b/src/renderer/src/lib/structured-agent-session-action-launch.ts new file mode 100644 index 000000000000..c04772222007 --- /dev/null +++ b/src/renderer/src/lib/structured-agent-session-action-launch.ts @@ -0,0 +1,169 @@ +import { structuredAgentLabel } from './structured-agent-session-launch-label' +import { translate } from '@/i18n/i18n' +import { + getStructuredLaunchStateBySessionId, + notifyStructuredLaunchListeners +} from './structured-agent-session-launch-registry' +import { focusTerminalTabSurface } from './focus-terminal-tab-surface' +import type { Tab } from '../../../shared/tab-types' +import type { StructuredAgentLaunchSettlement } from '@/lib/structured-agent-launch-settlement' +import type { ProvisionalLaunchArgs } from './structured-agent-session-provisional-tab' +import { insertUnifiedTabAfterAnchor } from './unified-tab-anchor-insertion' +import { useAppStore } from '@/store' +import { + notifyHostDeclined, + openDeclinedStructuredLaunchTerminal, + type StructuredLaunchTerminal +} from '@/lib/structured-agent-session-launch-admission' +import type { RuntimeClientTarget } from '../../../shared/runtime-client-target' +import { structuredAgentSessionDraftScopeKey } from '@/components/native-chat/native-chat-composer-draft-store' +import { nativeChatComposerHasUserContent } from '@/components/native-chat/native-chat-pending-attachment-cache' +import { StructuredAgentSessionCreateRefusalError } from '@/lib/structured-agent-session-launch-errors' + +export function openDeclinedTerminal( + args: ProvisionalLaunchArgs, + worktreeId: string, + target: RuntimeClientTarget, + activate?: false +): Promise | StructuredLaunchTerminal { + if (args.onHostDeclined) { + return args.onHostDeclined(target, activate) + } + const terminal = { ...args.declinedTerminal, ...(activate === false ? { activate } : {}) } + return openDeclinedStructuredLaunchTerminal({ + plan: args.plan, + worktreeId, + ...(args.targetGroupId ? { targetGroupId: args.targetGroupId } : {}), + terminal + }) +} + +/** Only a foreground action without a prompt to deliver may open before admission. */ +export function opensAtAction(args: ProvisionalLaunchArgs): boolean { + const { plan } = args + return ( + args.focusIntent !== undefined && + args.activate !== false && + !plan.resumeFrom && + (!plan.prompt?.trim() || plan.promptDelivery === 'draft') + ) +} + +/** Prepare the replacement before relinquishing the source and its current composer content. */ +export async function openTerminalForDeclinedChat( + args: ProvisionalLaunchArgs, + worktreeId: string, + target: RuntimeClientTarget, + tab: Tab, + settlement: StructuredAgentLaunchSettlement +): Promise { + if ( + args.plan.requestedSurface === 'chat' || + settlement.kind !== 'failed' || + !(settlement.error instanceof StructuredAgentSessionCreateRefusalError) || + settlement.error.code !== 'structured_agent_session_unsupported' + ) { + return settlement + } + const source = () => + (useAppStore.getState().unifiedTabsByWorktree[worktreeId] ?? []).find( + (candidate) => candidate.id === tab.id && candidate.entityId === tab.entityId + ) + const launchState = getStructuredLaunchStateBySessionId(tab.entityId) + if (!source() || launchState?.cancelled || args.hooks.signal?.aborted) { + return settlement + } + const scopeKey = structuredAgentSessionDraftScopeKey(tab.entityId) + const typed = nativeChatComposerHasUserContent(scopeKey) + const attempt = launchState?.callers + const terminal = await openDeclinedTerminal(args, worktreeId, target, false) + const completed = terminal.completion + ? await terminal.completion + : { + outcome: { status: terminal.opened ? 'created' : 'failed' }, + tabId: terminal.tabId + } + if (completed.outcome.status !== 'created') { + return settlement + } + const state = useAppStore.getState() + const current = source() + const replacement = (state.unifiedTabsByWorktree[worktreeId] ?? []).find( + (candidate) => candidate.id === completed.tabId + ) + const ownsFailure = + launchState && + getStructuredLaunchStateBySessionId(tab.entityId) === launchState && + launchState.callers === attempt && + attempt?.outcome === 'failed' && + !launchState.cancelled + if (current && ownsFailure) { + launchState.failure = { + ...(launchState.failure?.code === 'structured_agent_session_unsupported' + ? launchState.failure + : {}), + kind: 'refused', + code: 'structured_agent_session_unsupported', + causeText: replacement + ? translate( + 'components.native-chat.structuredSessionHostDeclinedDescription', + "This workspace can't run a {{value0}} chat.", + { value0: structuredAgentLabel(args.plan.agent) } + ) + : translate( + 'components.native-chat.structuredSessionTerminalNotVisible', + "Orca opened {{value0}} in a terminal, but couldn't show it here when the launch finished. Your chat draft was kept.", + { value0: structuredAgentLabel(args.plan.agent) } + ) + } + notifyStructuredLaunchListeners() + } + if (!replacement) { + return settlement + } + if (!current || args.hooks.signal?.aborted || (launchState && !ownsFailure)) { + notifyHostDeclined(args.plan.agent) + return settlement + } + state.moveUnifiedTabToGroup(replacement.id, current.groupId, { + activate: false, + recordInteraction: false + }) + if (Boolean(current.isPinned) !== Boolean(replacement.isPinned)) { + const setPinned = current.isPinned ? state.pinTab : state.unpinTab + setPinned(replacement.id, { recordInteraction: false }) + } + insertUnifiedTabAfterAnchor(worktreeId, replacement.id, current.id) + const placed = useAppStore.getState() + const group = (placed.groupsByWorktree[worktreeId] ?? []).find((g) => g.id === current.groupId) + if ( + !group || + group.tabOrder.indexOf(replacement.id) !== group.tabOrder.indexOf(current.id) + 1 || + typed || + nativeChatComposerHasUserContent(scopeKey) + ) { + notifyHostDeclined(args.plan.agent, false) + return settlement + } + const foreground = + placed.activeWorktreeId === worktreeId && + placed.activeGroupIdByWorktree[worktreeId] === current.groupId && + group.activeTabId === current.id + if (current.customLabel !== null) { + placed.setTabCustomTitle(replacement.entityId, current.customLabel, { + recordInteraction: false + }) + } + if (current.color !== null) { + placed.setTabColor(replacement.entityId, current.color, { recordInteraction: false }) + } + placed.closeUnifiedTab(tab.id, { recordInteraction: false }) + if (foreground) { + useAppStore.getState().setActiveTab(replacement.entityId) + useAppStore.getState().activateTab(replacement.id) + useAppStore.getState().setActiveTabType('terminal', worktreeId) + focusTerminalTabSurface(replacement.id) + } + notifyHostDeclined(args.plan.agent) + return { kind: 'terminal' } +} diff --git a/src/renderer/src/lib/structured-agent-session-launch-admission.test.ts b/src/renderer/src/lib/structured-agent-session-launch-admission.test.ts index 248a7ed18a03..be8c2303cc4d 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-admission.test.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-admission.test.ts @@ -105,7 +105,9 @@ describe('a structured chat launch on a paired server', () => { failureNotified: false }) expect(onHostDeclined).toHaveBeenCalledOnce() - expect(mocks.toastInfo).toHaveBeenCalledOnce() + expect(mocks.toastInfo).toHaveBeenCalledWith(expect.any(String), { + description: expect.stringContaining("can't run") + }) expect(useAppStore.getState().activeWorktreeId).toBe(WORKTREE) expectNoChatCommitted() }) @@ -215,7 +217,11 @@ describe('a structured chat launch on a paired server', () => { 'claude' ) expect(settlement).toEqual(expect.objectContaining({ kind: 'failed', notified: true })) - expect(mocks.toastError).toHaveBeenCalledTimes(1) + expect(mocks.toastError).toHaveBeenCalledWith(expect.stringContaining('Claude'), { + description: expect.stringMatching( + kind === 'unverified' ? /confirm the host's permissions for Claude/ : /older Orca/ + ) + }) expect(mocks.launchAgentInNewTab).not.toHaveBeenCalled() expectNoChatCommitted() } @@ -265,7 +271,7 @@ describe('a structured chat launch on this machine', () => { delivered: true, failureNotified: false }) - expect(onHostDeclined).toHaveBeenCalledExactlyOnceWith({ kind: 'local' }) + expect(onHostDeclined).toHaveBeenCalledExactlyOnceWith({ kind: 'local' }, undefined) expect(mocks.toastInfo).not.toHaveBeenCalled() expect(mocks.toastError).not.toHaveBeenCalled() expectNoChatCommitted() diff --git a/src/renderer/src/lib/structured-agent-session-launch-admission.ts b/src/renderer/src/lib/structured-agent-session-launch-admission.ts index 1be3604a8718..03d7a3411af1 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-admission.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-admission.ts @@ -25,10 +25,19 @@ import type { RuntimeClientTarget } from '../../../shared/runtime-client-target' import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector' import { readStructuredChatHostPermissionVerdict } from '@/runtime/structured-chat-host-permission' import { notifyHostPermissionRefused } from '@/lib/structured-agent-session-launch-permission-notice' +import { structuredChatUnavailableText } from '@/lib/structured-agent-session-launch-failure' +import type { WebRuntimeTerminalCreateOutcome } from '@/runtime/web-runtime-session-types' + +export type AgentTerminalLaunchCompletion = { + outcome: WebRuntimeTerminalCreateOutcome + tabId?: string +} /** What a host's "no" opens instead: the terminal the launch would have opened had it known. */ export type StructuredLaunchTerminal = { opened: boolean + tabId?: string + completion?: Promise promptDeliveryResult?: Promise } @@ -62,27 +71,11 @@ function notifyExplicitChatFailure(agent: TuiAgent, reason?: string): void { "Couldn't start a {{value0}} chat.", { value0: structuredAgentLabel(agent) } ), - { - description: - reason === 'wsl' - ? translate( - 'components.native-chat.explicitChatWsl', - 'Chat is not available in WSL. Open the agent in a terminal instead.' - ) - : reason === 'remote' - ? translate( - 'components.native-chat.explicitChatSsh', - 'Chat is not available over SSH. Open the agent in a terminal instead.' - ) - : translate( - 'components.native-chat.explicitChatUnavailable', - 'This workspace cannot run the chat. Check the agent installation or open it in a terminal.' - ) - } + { description: structuredChatUnavailableText(reason) } ) } -function notifyHostDeclined(agent: TuiAgent): void { +export function notifyHostDeclined(agent: TuiAgent, explain = true): void { const agentLabel = structuredAgentLabel(agent) toast.info( translate( @@ -90,13 +83,15 @@ function notifyHostDeclined(agent: TuiAgent): void { 'Opened {{value0}} in a terminal', { value0: agentLabel } ), - { - description: translate( - 'components.native-chat.structuredSessionHostDeclinedDescription', - "This server can't run a {{value0}} chat in this workspace.", - { value0: agentLabel } - ) - } + explain + ? { + description: translate( + 'components.native-chat.structuredSessionHostDeclinedDescription', + "This workspace can't run a {{value0}} chat.", + { value0: agentLabel } + ) + } + : undefined ) } @@ -123,7 +118,7 @@ function notifyHostUnreachable(agent: TuiAgent, executionHostId: ExecutionHostId /** What the caller's own terminal launch would have carried beyond the plan's agent and prompt. */ export type DeclinedStructuredLaunchTerminalOptions = Pick< LaunchAgentInNewTabArgs, - 'agentArgs' | 'launchPlatform' | 'launchSource' | 'initialCwd' + 'agentArgs' | 'launchPlatform' | 'launchSource' | 'initialCwd' | 'activate' > /** A caller with no terminal path of its own gets the one a new agent tab would open. */ @@ -140,7 +135,7 @@ export async function openDeclinedStructuredLaunchTerminal(args: { agent: args.plan.agent, worktreeId: args.worktreeId, ...(args.targetGroupId ? { groupId: args.targetGroupId } : {}), - ...(args.plan.prompt ? { prompt: args.plan.prompt } : {}), + ...(args.terminal?.activate !== false && args.plan.prompt ? { prompt: args.plan.prompt } : {}), ...(args.plan.promptDelivery ? { promptDelivery: args.plan.promptDelivery } : {}), ...(args.plan.onPromptDelivered ? { onPromptDelivered: args.plan.onPromptDelivered } : {}), ...(args.plan.promptKeptByCaller ? { promptKeptByCaller: true as const } : {}), @@ -153,6 +148,8 @@ export async function openDeclinedStructuredLaunchTerminal(args: { }) return { opened: result !== null, + ...(result?.surface.kind === 'local-terminal' ? { tabId: result.surface.tabId } : {}), + ...(result?.terminalCompletion ? { completion: result.terminalCompletion } : {}), ...(result?.promptDeliveryResult ? { promptDeliveryResult: result.promptDeliveryResult } : {}) } } @@ -190,6 +187,8 @@ export function beginHostAdmittedStructuredLaunch(args: { resolveDelivery = resolve }) : undefined + // Reached by an explicit chat that carries a prompt; one without opens before its host answers, + // and its chat says why instead. const failBeforeSurface = (error: unknown, reason?: string): StructuredAgentLaunchSettlement => { if (plan.requestedSurface === 'chat' && !admitted) { notifyExplicitChatFailure(plan.agent, reason) diff --git a/src/renderer/src/lib/structured-agent-session-launch-callers.ts b/src/renderer/src/lib/structured-agent-session-launch-callers.ts index ba21ae8df316..a18daf5fe04f 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-callers.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-callers.ts @@ -13,6 +13,8 @@ import type { AgentLaunchRequestId } from './agent-launch-request-id' export type StructuredAgentLaunchOptions = { /** The user action this start serves; only a re-delivery of it joins its chat. */ requestId: AgentLaunchRequestId + /** Background launches never ask host publication to select their tab. */ + activate?: boolean prompt?: string promptDelivery?: 'auto-submit' | 'submit-after-ready' | 'draft' onPromptDelivered?: () => void diff --git a/src/renderer/src/lib/structured-agent-session-launch-create-call.ts b/src/renderer/src/lib/structured-agent-session-launch-create-call.ts index 7852bfb520d4..bb4e3d4d986e 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-create-call.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-create-call.ts @@ -15,6 +15,7 @@ import { } from '@/runtime/structured-agent-session-client' import { publishStructuredAgentSessionCreateHydration } from './structured-agent-session-create-hydration' import { askHostCreateSupport } from './structured-agent-session-host-admission' +import { readStructuredChatHostPermissionVerdict } from '@/runtime/structured-chat-host-permission' import { abandonStructuredAgentSessionLaunchIntent, type StructuredAgentSessionLaunchIntent @@ -70,12 +71,39 @@ async function requireHostCreateSupport( abandonStructuredAgentSessionLaunchIntent(intent) throw new StructuredAgentSessionCreateRefusalError( 'structured_agent_session_unsupported', - 'structured_agent_session_unsupported' + 'structured_agent_session_unsupported', + undefined, + support.kind === 'declined' ? support.reason : undefined ) } return support.seedOptions } +/** A paired host that would run the chat with more access than asked for gets nothing to start. + * The chat's own permission gate says why, so this refusal carries no words of its own. */ +const STRUCTURED_LAUNCH_HOST_PERMISSION_REFUSAL = 'structured_agent_session_host_permission' + +async function requireHostPermission(intent: StructuredAgentSessionLaunchIntent): Promise { + const permission = + intent.target.kind === 'local' + ? { kind: 'allowed' as const } + : await readStructuredChatHostPermissionVerdict(intent.target, intent.agent) + if (permission.kind === 'allowed') { + return + } + if (permission.kind === 'unverified') { + throw new StructuredAgentSessionCreateUnknownOutcomeError( + 'The host could not say what access the chat would run with.', + 'runtime_unavailable' + ) + } + abandonStructuredAgentSessionLaunchIntent(intent) + throw new StructuredAgentSessionCreateRefusalError( + STRUCTURED_LAUNCH_HOST_PERMISSION_REFUSAL, + STRUCTURED_LAUNCH_HOST_PERMISSION_REFUSAL + ) +} + /** Told the seed a paired server says this create will use, which may differ from an earlier * attempt's; a local launch reads its own settings instead. */ export type StructuredLaunchHostSeedListener = (seedOptions: LaunchSeed) => void @@ -83,12 +111,15 @@ export type StructuredLaunchHostSeedListener = (seedOptions: LaunchSeed) => void export async function launchStructuredAgentSession( intent: StructuredAgentSessionLaunchIntent, onHostSeed?: StructuredLaunchHostSeedListener, - priorCreateOutcomeUnknown = false + priorCreateOutcomeUnknown = false, + /** A chat closed while its host was being asked issues no create. */ + isCancelled: () => boolean = () => false ): Promise { const hostSeed = await requireHostCreateSupport(intent) if (intent.target.kind !== 'local') { onHostSeed?.(hostSeed) } + await requireHostPermission(intent) if (!intent.createPrepared) { let supported = false try { @@ -103,7 +134,8 @@ export async function launchStructuredAgentSession( } } let result: AgentSessionMutationResult - if (intent.beforeCreate?.() === false) { + // Nothing is created for a chat closed while its host was asked, or whose source retired. + if (isCancelled() || intent.beforeCreate?.() === false) { abandonStructuredAgentSessionLaunchIntent(intent) throw new StructuredAgentSessionCreateAbandonedError( 'Conversation source retired before create.' diff --git a/src/renderer/src/lib/structured-agent-session-launch-create-message.test.ts b/src/renderer/src/lib/structured-agent-session-launch-create-message.test.ts index eed479f7922b..f62cfde4da3b 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-create-message.test.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-create-message.test.ts @@ -50,6 +50,9 @@ vi.mock('@/store', () => ({ } })) vi.mock('sonner', () => ({ toast: { error: vi.fn() } })) +vi.mock('@/runtime/structured-chat-host-permission', () => ({ + readStructuredChatHostPermissionVerdict: async () => ({ kind: 'allowed' }) +})) import { startStructuredAgentLaunch, diff --git a/src/renderer/src/lib/structured-agent-session-launch-errors.ts b/src/renderer/src/lib/structured-agent-session-launch-errors.ts index d60aaf523177..5a8b468e580c 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-errors.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-errors.ts @@ -17,7 +17,9 @@ export class StructuredAgentSessionCreateRefusalError extends StructuredAgentSes constructor( message: string, code: string = 'structured_agent_session_unsupported', - refusal?: AgentSessionRefusalReference + refusal?: AgentSessionRefusalReference, + /** The host's own word for why it can't run the chat here (`wsl`, `remote`), when it gave one. */ + readonly hostReason?: string ) { super(message, code, refusal) this.name = 'StructuredAgentSessionCreateRefusalError' diff --git a/src/renderer/src/lib/structured-agent-session-launch-failure.ts b/src/renderer/src/lib/structured-agent-session-launch-failure.ts index 30acb7d58e10..f67ae163824d 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-failure.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-failure.ts @@ -10,14 +10,43 @@ import { readAgentSessionErrorRefusal, type AgentSessionWriteRefusal } from '../../../shared/agent-session-write-failure' +import { translate } from '@/i18n/i18n' +import { StructuredAgentSessionCreateRefusalError } from './structured-agent-session-launch-errors' export type StructuredLaunchFailure = AgentSessionWriteRefusal & { - authStartupMessage?: string + /** Words that replace the refusal's generic cause: the host's sign-in guidance, or why this + * workspace can't run the chat. */ + causeText?: string owner?: AgentSessionConversationOwner } +/** Why a host said it can't run a chat in this workspace, and what to do instead. */ +export function structuredChatUnavailableText(reason?: string): string { + return reason === 'wsl' + ? translate( + 'components.native-chat.explicitChatWsl', + 'Chat is not available in WSL. Open the agent in a terminal instead.' + ) + : reason === 'remote' + ? translate( + 'components.native-chat.explicitChatSsh', + 'Chat is not available over SSH. Open the agent in a terminal instead.' + ) + : translate( + 'components.native-chat.explicitChatUnavailable', + 'This workspace cannot run the chat. Check the agent installation or open it in a terminal.' + ) +} + /** The host's refusal behind a failed launch; undefined when the failure carried none. */ export function structuredLaunchFailure(error: unknown): StructuredLaunchFailure | undefined { + if (error instanceof StructuredAgentSessionCreateRefusalError && error.hostReason) { + return { + kind: 'refused', + code: 'structured_agent_session_unsupported', + causeText: structuredChatUnavailableText(error.hostReason) + } + } const refusal = launchRefusal(error) if (!refusal) { return undefined @@ -35,7 +64,7 @@ export function structuredLaunchFailure(error: unknown): StructuredLaunchFailure failure.details?.reason === 'notSignedIn' && message && message !== failure.code - ? { ...failure, authStartupMessage: message } + ? { ...failure, causeText: message } : failure } diff --git a/src/renderer/src/lib/structured-agent-session-launch-options.ts b/src/renderer/src/lib/structured-agent-session-launch-options.ts index f6edaf01440f..6142cbb42b9f 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-options.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-options.ts @@ -27,6 +27,7 @@ import { type AgentSessionWriteFailure } from '../../../shared/agent-session-write-failure' import type { RuntimeClientTarget } from '../../../shared/runtime-client-target' +import type { StructuredAgentSessionLaunchIntent } from './launch-structured-agent-session' import { structuredAgentSessionModelOffers, type StructuredAgentSessionOptionState @@ -38,6 +39,18 @@ export type StructuredLaunchSelection = { seed?: Readonly> /** Encoded picks made before publish; the launch applies them before it publishes. */ held: Readonly> + /** A paired server has not yet said what create seeds, so nothing can be picked against it. */ + seedPending?: true +} + +export function initialStructuredLaunchSelection( + intent: StructuredAgentSessionLaunchIntent +): StructuredLaunchSelection { + return { + seed: intent.seedOptions, + held: {}, + ...(intent.target.kind !== 'local' && !intent.seedOptions ? { seedPending: true } : {}) + } } export type StructuredLaunchOptionOutcome = @@ -73,6 +86,9 @@ export function holdStructuredAgentSessionLaunchOption( if (!state || state.cancelled || state.callers.outcome === 'published') { return null } + if (state.selection.seedPending) { + return null + } const replies = repliesFor(state) const held: Record = {} // Each held value is the chat's own: a pick supersedes an earlier pick of the same option. diff --git a/src/renderer/src/lib/structured-agent-session-launch-permission-notice.ts b/src/renderer/src/lib/structured-agent-session-launch-permission-notice.ts index 696130409780..bc007c5f7fc9 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-permission-notice.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-permission-notice.ts @@ -18,7 +18,8 @@ export function hostPermissionRefusedText( return kind === 'unverified' ? translate( 'components.native-chat.hostPermission.unverified', - "Orca couldn't confirm this host applies chat permissions. Reconnect to it to send." + "Orca couldn't confirm the host's permissions for {{value0}}.", + { value0: agentLabel } ) : translate( 'components.native-chat.hostPermission.outdatedHost', diff --git a/src/renderer/src/lib/structured-agent-session-launch-probe-outage.test.ts b/src/renderer/src/lib/structured-agent-session-launch-probe-outage.test.ts index ede3de6ef3bb..509fd5a942dd 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-probe-outage.test.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-probe-outage.test.ts @@ -29,6 +29,9 @@ vi.mock('@/runtime/structured-agent-session-client', () => ({ throw offline() }) })) +vi.mock('@/runtime/structured-chat-host-permission', () => ({ + readStructuredChatHostPermissionVerdict: async () => ({ kind: 'allowed' }) +})) vi.mock('@/runtime/structured-session-tab-inventory', () => ({ readStructuredSessionTabInventory: vi.fn(async () => { calls.log.push('inventory') diff --git a/src/renderer/src/lib/structured-agent-session-launch-recovery.ts b/src/renderer/src/lib/structured-agent-session-launch-recovery.ts index 8a376210bc0c..938762366996 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-recovery.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-recovery.ts @@ -78,7 +78,7 @@ export async function recoverPublishedSessionReceipt( ) : undefined if (state.intent.params.firstMessage && !firstMessage) { - return launchStructuredAgentSession(state.intent, state.onHostSeed, true) + return launchStructuredAgentSession(state.intent, state.onHostSeed, true, () => state.cancelled) } if (state.intent.createMessageSupport === true) { publishStructuredAgentSessionCreateHydration(state.intent, history.page, fence) @@ -98,7 +98,12 @@ async function retrySameIntent( ): Promise { throwIfLaunchCancelled(state) try { - const receipt = await launchStructuredAgentSession(state.intent, state.onHostSeed, true) + const receipt = await launchStructuredAgentSession( + state.intent, + state.onHostSeed, + true, + () => state.cancelled + ) throwIfLaunchCancelled(state) if (state.intent.createMessageSupport !== true) { await verifyPublishedSession(state) @@ -130,7 +135,12 @@ export async function launchAndReconcile( throwIfLaunchCancelled(state) let receipt: StructuredAgentLaunchReceipt try { - receipt = await launchStructuredAgentSession(state.intent, state.onHostSeed) + receipt = await launchStructuredAgentSession( + state.intent, + state.onHostSeed, + false, + () => state.cancelled + ) } catch (error) { if (state.cancelled) { throw new StructuredAgentSessionLaunchCancelledError() diff --git a/src/renderer/src/lib/structured-agent-session-launch-reload.test.ts b/src/renderer/src/lib/structured-agent-session-launch-reload.test.ts index 4ab7ee061d67..e4cd6fd2caf5 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-reload.test.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-reload.test.ts @@ -179,7 +179,8 @@ describe('structured agent launch reload recovery', () => { }) }), expect.any(Function), - true + true, + expect.any(Function) ) }) }) diff --git a/src/renderer/src/lib/structured-agent-session-launch-reload.ts b/src/renderer/src/lib/structured-agent-session-launch-reload.ts index 870dbfe05a1f..0b76320a60b8 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-reload.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-reload.ts @@ -13,6 +13,7 @@ import { structuredLaunchIdentity, type StructuredLaunchState } from './structured-agent-session-launch-registry' +import { initialStructuredLaunchSelection } from './structured-agent-session-launch-options' export function restorePersistedStructuredLaunchState( worktreeId: string, @@ -61,7 +62,7 @@ export function restorePersistedStructuredLaunchState( cancelled: false, onVisibilityChanged: undefined, callers, - selection: { seed: intent.seedOptions, held: {} } + selection: initialStructuredLaunchSelection(intent) } callers.outcome = record.lifecycle === 'failed' ? 'failed' : 'unknown' callers.failedAt = record.failedAt diff --git a/src/renderer/src/lib/structured-agent-session-launch.test.ts b/src/renderer/src/lib/structured-agent-session-launch.test.ts index ba0fdc029505..3f65a1e0a837 100644 --- a/src/renderer/src/lib/structured-agent-session-launch.test.ts +++ b/src/renderer/src/lib/structured-agent-session-launch.test.ts @@ -341,7 +341,12 @@ describe('startStructuredAgentLaunch', () => { await flushLaunchSettlement() expect(mocks.launch).toHaveBeenCalledOnce() - expect(mocks.launch).toHaveBeenCalledWith(intent, expect.any(Function)) + expect(mocks.launch).toHaveBeenCalledWith( + intent, + expect.any(Function), + false, + expect.any(Function) + ) expect(toast.message).not.toHaveBeenCalled() expect(toast.error).not.toHaveBeenCalled() }) @@ -367,6 +372,7 @@ describe('startStructuredAgentLaunch', () => { 'claude', undefined, undefined, + undefined, undefined ) expect(mocks.createIntent).toHaveBeenNthCalledWith( @@ -375,6 +381,7 @@ describe('startStructuredAgentLaunch', () => { 'codex', undefined, undefined, + undefined, undefined ) expect(mocks.launch).toHaveBeenCalledTimes(2) diff --git a/src/renderer/src/lib/structured-agent-session-launch.ts b/src/renderer/src/lib/structured-agent-session-launch.ts index 73f7bef79516..f8f04eab17d3 100644 --- a/src/renderer/src/lib/structured-agent-session-launch.ts +++ b/src/renderer/src/lib/structured-agent-session-launch.ts @@ -49,6 +49,7 @@ import { trackStructuredLaunchConfirmationDeadline } from './structured-agent-session-launch-prompt-outcome' import { repeatedStructuredLaunchAttempt } from './structured-agent-session-launch-request' +import { initialStructuredLaunchSelection } from './structured-agent-session-launch-options' import { releaseStructuredLaunchSource } from './structured-agent-session-launch-source-handoff' export type { StructuredAgentLaunchOptions, StructuredAgentLaunchReceipt } @@ -114,7 +115,8 @@ function adoptPairedHostSeed( state: StructuredLaunchState, seedOptions: StructuredLaunchState['selection']['seed'] ): void { - if (JSON.stringify(seedOptions) === JSON.stringify(state.intent.seedOptions)) { + const { seedPending, ...selection } = state.selection + if (!seedPending && JSON.stringify(seedOptions) === JSON.stringify(state.intent.seedOptions)) { return } if (seedOptions) { @@ -122,7 +124,7 @@ function adoptPairedHostSeed( } else { delete state.intent.seedOptions } - state.selection = { ...state.selection, seed: seedOptions } + state.selection = { ...selection, seed: seedOptions } notifyStructuredLaunchListeners() } @@ -143,7 +145,10 @@ function restartStructuredLaunchState(state: StructuredLaunchState): void { state.callers.outcome = 'pending' // A new create seeds from the settings of now (a paired server's arrive with its probe); picks // held through the failure still apply. - state.selection = { ...state.selection, seed: state.intent.seedOptions } + state.selection = { + ...initialStructuredLaunchSelection(state.intent), + held: state.selection.held + } state.onHostSeed = (seedOptions) => adoptPairedHostSeed(state, seedOptions) state.promise = publishWithHeldOptions( state, @@ -211,7 +216,8 @@ function structuredAgentLaunchState( agent, options.executionHostId, options.resumeFrom, - options.hostSeedOptions + options.hostSeedOptions, + options.activate ) intent.beforeCreate = options.beforeCreate intent.surfaceAddress = options.surfaceAddress @@ -234,7 +240,7 @@ function structuredAgentLaunchState( cancelled: false, onVisibilityChanged: notifyStructuredLaunchListeners, callers, - selection: { seed: intent.seedOptions, held: {} } + selection: initialStructuredLaunchSelection(intent) } state.onHostSeed = (seedOptions) => adoptPairedHostSeed(state, seedOptions) callers.onSettled = () => maybeCleanupLaunchState(state) diff --git a/src/renderer/src/lib/structured-agent-session-provisional-tab.test.ts b/src/renderer/src/lib/structured-agent-session-provisional-tab.test.ts index 9acc430be544..5fa9802c1c1a 100644 --- a/src/renderer/src/lib/structured-agent-session-provisional-tab.test.ts +++ b/src/renderer/src/lib/structured-agent-session-provisional-tab.test.ts @@ -1,77 +1,270 @@ +// @vitest-environment happy-dom import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { Tab } from '../../../shared/tab-types' +import { useAppStore } from '@/store' +import type * as launchModule from './launch-structured-agent-session' import type { AgentSessionLaunchPlan } from './agent-session-launch-plan' -import type { StructuredAgentLaunchSettlement } from './structured-agent-launch-settlement' - -const mocks = vi.hoisted(() => ({ - createUnifiedTab: vi.fn(), - createSupport: vi.fn(), - requestFocus: vi.fn(), - cancelFocus: vi.fn() -})) +import { adoptAgentSessionLaunchVerdict } from './agent-session-launch-plan' +import { beginStructuredAgentSessionProvisionalLaunch } from './structured-agent-session-provisional-tab' +import { StructuredAgentSessionCreateRefusalError } from './structured-agent-session-launch-errors' +import { act, renderHook } from '@testing-library/react' +import { useNativeChatProvisionalLaunch } from '@/components/native-chat/use-native-chat-provisional-launch' +import { + getStructuredAgentSessionLaunchFailure, + resetStructuredAgentLaunchRegistryForTests +} from './structured-agent-session-launch-registry' +import { structuredSessionNotices } from '@/components/native-chat/native-chat-structured-session-notices' +import type { AgentTerminalLaunchCompletion } from './structured-agent-session-launch-admission' +import { + addNativeChatPendingAttachment, + clearNativeChatPendingAttachmentsForTests +} from '@/components/native-chat/native-chat-pending-attachment-cache' -vi.mock('@/store', () => ({ - useAppStore: { - getState: () => ({ - unifiedTabsByWorktree: {}, - activeGroupIdByWorktree: {}, - createUnifiedTab: mocks.createUnifiedTab, - setActiveTabType: vi.fn() - }) - } +const mocks = vi.hoisted(() => ({ text: '', focus: vi.fn(), toast: vi.fn(), launch: vi.fn() })) +vi.mock('./launch-structured-agent-session', async (original) => ({ + ...(await original()), + launchStructuredAgentSession: mocks.launch })) -vi.mock('@/runtime/structured-agent-session-client', () => ({ - callStructuredAgentSession: (_target: unknown, method: string) => - method === 'agentSession.createSupport' ? mocks.createSupport() : new Promise(() => undefined) +vi.mock('sonner', () => ({ toast: { info: mocks.toast, error: vi.fn() } })) +vi.mock('./focus-terminal-tab-surface', () => ({ focusTerminalTabSurface: vi.fn() })) +vi.mock('@/components/native-chat/native-chat-composer-draft-store', () => ({ + readNativeChatComposerDraft: () => ({ text: mocks.text, images: [] }), + resolveNativeChatComposerDraftOwner: () => undefined, + structuredAgentSessionDraftScopeKey: (id: string) => `agent-session:${id}` })) -import { beginStructuredAgentSessionProvisionalLaunch } from './structured-agent-session-provisional-tab' - -function planSettlingAs(settlement: StructuredAgentLaunchSettlement): AgentSessionLaunchPlan { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: begin() is the only member this path calls. - return { +function planSettlingAs(error: StructuredAgentSessionCreateRefusalError): AgentSessionLaunchPlan { + mocks.launch.mockRejectedValue(error) + return adoptAgentSessionLaunchVerdict({ + requestId: 'action', route: 'structured-native-chat', agent: 'claude', worktreeId: 'wt-1', - executionHostId: 'local', - begin: () => ({ - sessionId: 'claude_1', - executionHostId: 'local', - settlement: Promise.resolve(settlement), - cancel: vi.fn() - }), - launch: vi.fn() - } as unknown as AgentSessionLaunchPlan + executionHostId: 'local' + }) } beforeEach(() => { vi.clearAllMocks() - mocks.createUnifiedTab.mockImplementation( - (worktreeId: string, _type: string, tab: Partial) => ({ ...tab, worktreeId }) - ) - mocks.createSupport.mockResolvedValue({ supported: true }) + mocks.text = '' + resetStructuredAgentLaunchRegistryForTests() + clearNativeChatPendingAttachmentsForTests() + useAppStore.setState({ + activeWorktreeId: 'wt-1', + unifiedTabsByWorktree: {}, + groupsByWorktree: {}, + activeGroupIdByWorktree: {}, + tabsByWorktree: {}, + tabBarOrderByWorktree: {} + }) }) +function openChat(requestedSurface?: 'chat') { + let complete!: (value: AgentTerminalLaunchCompletion) => void + const completion = new Promise((resolve) => { + complete = resolve + }) + const fallback = vi.fn(() => ({ opened: true, completion })) + const launch = beginStructuredAgentSessionProvisionalLaunch({ + plan: { + ...planSettlingAs(new StructuredAgentSessionCreateRefusalError('no')), + requestedSurface + }, + hooks: {}, + focusIntent: { request: mocks.focus, cancel: vi.fn() }, + onHostDeclined: fallback + }) + const chat = launch?.tab + if (!chat) { + throw new Error('No immediate chat') + } + return { launch, chat, fallback, complete } +} + describe('a provisional structured chat tab', () => { - it('opens once this machine admits the chat, in the caller group, on the host it was sent to', async () => { - const launch = beginStructuredAgentSessionProvisionalLaunch({ - plan: planSettlingAs({ kind: 'structured', sessionId: 'claude_1' }), - hooks: {}, - focusIntent: { request: mocks.requestFocus, cancel: mocks.cancelFocus }, - targetGroupId: 'group-2' + it.each([ + 'empty', + 'typed-before', + 'typed-during', + 'pending-chip', + 'failed', + 'unreconciled', + 'closed', + 'explicit', + 'away-before', + 'away-during', + 'closed-during' + ])('owns the destination through terminal completion: %s', async (scenario) => { + if (scenario === 'typed-before') { + mocks.text = 'keep this' + } + const { launch, chat, fallback, complete } = openChat( + scenario === 'explicit' ? 'chat' : undefined + ) + expect(mocks.focus).toHaveBeenCalledWith(chat.id) + const view = renderHook(() => { + const current = useNativeChatProvisionalLaunch('wt-1', chat.entityId) + return structuredSessionNotices({ + launch: current, + agentLabel: 'Claude', + sessionError: null, + composerError: null + }) }) - - expect(launch?.tab).toBeNull() - expect(mocks.createUnifiedTab).not.toHaveBeenCalled() - expect(mocks.requestFocus).not.toHaveBeenCalled() - await expect(launch?.settlement).resolves.toEqual({ kind: 'structured', sessionId: 'claude_1' }) - expect(mocks.createSupport).toHaveBeenCalledOnce() - expect(mocks.createUnifiedTab).toHaveBeenCalledWith( - 'wt-1', - 'agent-session', - expect.objectContaining({ executionHostId: 'local', targetGroupId: 'group-2' }) + const away = () => { + useAppStore.getState().createUnifiedTab('wt-2', 'agent-session', { entityId: 'other' }) + useAppStore.setState({ activeWorktreeId: 'wt-2', activeTabType: 'agent-session' }) + } + if (scenario === 'away-before') { + away() + } + if (scenario === 'closed') { + useAppStore.getState().closeUnifiedTab(chat.id) + } + await vi.waitFor(() => + expect( + scenario === 'closed' || scenario === 'explicit' || fallback.mock.calls.length + ).toBeTruthy() + ) + if (scenario === 'closed' || scenario === 'explicit') { + await launch?.settlement + expect(fallback).not.toHaveBeenCalled() + view.unmount() + return + } + expect(useAppStore.getState().unifiedTabsByWorktree['wt-1']).toContainEqual(chat) + // Move the source while its host is preparing the terminal, then invest in the composer. + const sibling = useAppStore.getState().createUnifiedTab('wt-1', 'terminal', { + entityId: 'sibling', + split: { sourceGroupId: chat.groupId, splitDirection: 'right', activate: true } + }) + const groupId = sibling.groupId + const pinnedSource = scenario === 'empty' || scenario === 'typed-during' + if (pinnedSource) { + useAppStore.getState().pinTab(chat.id) + useAppStore.getState().pinTab(sibling.id) + useAppStore.getState().setTabCustomLabel(chat.id, 'My work') + useAppStore.getState().setUnifiedTabColor(chat.id, '#ff0000') + } + useAppStore.getState().moveUnifiedTabToGroup(chat.id, groupId) + useAppStore.getState().reorderUnifiedTabs(groupId, [chat.id, sibling.id]) + if (scenario === 'typed-during') { + mocks.text = 'new thought' + } + if (scenario === 'pending-chip') { + addNativeChatPendingAttachment(`agent-session:${chat.entityId}`, { + id: 'paste', + path: '', + pending: true + }) + } + if (scenario === 'away-during') { + away() + } + if (scenario === 'closed-during') { + useAppStore.getState().closeUnifiedTab(chat.id) + } + if (pinnedSource) { + useAppStore.getState().activateTab(chat.id) + useAppStore.getState().setActiveTabType('agent-session', 'wt-1') + } + const priorFailure = getStructuredAgentSessionLaunchFailure('wt-1', chat.entityId) + const selected = useAppStore.getState().getActiveTab('wt-2') + const terminal = + scenario === 'failed' || scenario === 'unreconciled' + ? null + : useAppStore + .getState() + .createUnifiedTab('wt-1', 'terminal', { id: 'replacement', activate: false }) + if (terminal) { + useAppStore.setState({ + tabsByWorktree: { + 'wt-1': [ + { + id: terminal.entityId, + ptyId: null, + worktreeId: 'wt-1', + title: 'Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + } + }) + if (scenario === 'typed-before') { + useAppStore.getState().pinTab(terminal.id) + } + } + const interaction = vi.fn() + useAppStore.setState({ recordFeatureInteraction: interaction }) + await act(async () => { + complete({ + outcome: + scenario === 'failed' ? { status: 'failed', message: 'offline' } : { status: 'created' }, + tabId: terminal?.id ?? 'missing' + }) + await launch?.settlement + }) + expect(interaction).not.toHaveBeenCalled() + const state = useAppStore.getState() + const tabs = state.unifiedTabsByWorktree['wt-1'] ?? [] + expect(tabs.some((t) => t.id === chat.id)).toBe( + !['empty', 'away-before', 'away-during', 'closed-during'].includes(scenario) ) - expect(mocks.requestFocus).toHaveBeenCalledExactlyOnceWith('structured-agent-session-claude_1') - expect(mocks.cancelFocus).not.toHaveBeenCalled() + if (terminal) { + const order = state.groupsByWorktree['wt-1']?.find((g) => g.id === groupId)?.tabOrder + expect(order).toEqual( + scenario === 'closed-during' + ? [sibling.id, terminal.id] + : ['empty', 'away-before', 'away-during'].includes(scenario) + ? [terminal.id, sibling.id] + : [chat.id, terminal.id, sibling.id] + ) + expect(mocks.toast).toHaveBeenCalledOnce() + expect(state.getTab(terminal.id)?.isPinned ?? false).toBe(pinnedSource) + expect(state.tabsByWorktree['wt-1'][0].isPinned ?? false).toBe(pinnedSource) + if (pinnedSource) { + const replaced = scenario === 'empty' + expect(state.getTab(terminal.id)).toMatchObject({ + customLabel: replaced ? 'My work' : null, + color: replaced ? '#ff0000' : null + }) + expect(state.tabsByWorktree['wt-1'][0]).toMatchObject({ + customTitle: replaced ? 'My work' : null, + color: replaced ? '#ff0000' : null + }) + expect(state.getTab(chat.id)?.customLabel ?? null).toBe(replaced ? null : 'My work') + expect(state.getTab(chat.id)?.color ?? null).toBe(replaced ? null : '#ff0000') + expect(state.activeGroupIdByWorktree['wt-1']).toBe(groupId) + expect(state.groupsByWorktree['wt-1'].find((g) => g.id === groupId)?.activeTabId).toBe( + replaced ? terminal.id : chat.id + ) + expect(state.activeTabType).toBe(replaced ? 'terminal' : 'agent-session') + } + } + expect(view.result.current[0]?.text ?? '').not.toMatch(/open the agent|open it in/i) + if (scenario === 'unreconciled') { + expect(getStructuredAgentSessionLaunchFailure('wt-1', chat.entityId)).not.toBe(priorFailure) + const notice = view.result.current[0]?.text + expect(notice).toContain("couldn't show it here when the launch finished") + act(() => { + useAppStore.getState().createUnifiedTab('wt-1', 'terminal', { + id: 'missing', + activate: false + }) + }) + expect(useAppStore.getState().getTab(chat.id)).not.toBeNull() + expect(fallback).toHaveBeenCalledOnce() + expect(mocks.launch).toHaveBeenCalledOnce() + expect(view.result.current[0]?.text).toBe(notice) + expect(mocks.toast).not.toHaveBeenCalled() + } + if (scenario.startsWith('away-')) { + expect(state.activeWorktreeId).toBe('wt-2') + expect(state.activeTabType).toBe('agent-session') + expect(state.getActiveTab('wt-2')).toEqual(selected) + } + view.unmount() }) }) diff --git a/src/renderer/src/lib/structured-agent-session-provisional-tab.ts b/src/renderer/src/lib/structured-agent-session-provisional-tab.ts index d57338c3abca..cdf6389ebeb3 100644 --- a/src/renderer/src/lib/structured-agent-session-provisional-tab.ts +++ b/src/renderer/src/lib/structured-agent-session-provisional-tab.ts @@ -1,3 +1,4 @@ +import type { NativeChatComposerFocusIntent } from '@/components/native-chat/use-native-chat-composer-reveal-focus' import type { Tab } from '../../../shared/tab-types' import type { AgentSessionLaunchPlan, @@ -11,7 +12,6 @@ import { useAppStore } from '@/store' import type { ExecutionHostId } from '../../../shared/execution-host' import { beginHostAdmittedStructuredLaunch, - openDeclinedStructuredLaunchTerminal, type DeclinedStructuredLaunchTerminalOptions, type HostAdmittedStructuredLaunch, type StructuredLaunchTerminal @@ -21,7 +21,11 @@ import { structuredAgentSessionTargetForHost } from '@/runtime/structured-agent-session-owner' import type { RuntimeClientTarget } from '../../../shared/runtime-client-target' -import type { NativeChatComposerFocusIntent } from '@/components/native-chat/use-native-chat-composer-reveal-focus' +import { + openTerminalForDeclinedChat, + openDeclinedTerminal, + opensAtAction +} from './structured-agent-session-action-launch' import { holdEmptyWorkspaceDefaultSurfaceForLaunch } from './empty-workspace-default-surface-claims' import { captureStructuredLaunchSurfaceAddress, @@ -54,7 +58,12 @@ export function structuredLaunchOwner( return executionHostId && hostTarget ? { executionHostId, target: hostTarget } : null } -type ProvisionalLaunchArgs = { +/** Only a launch that takes the user to its chat may ask host publication to select it. */ +function launchHooks(args: ProvisionalLaunchArgs): StructuredAgentLaunchHooks { + return args.activate === false ? { ...args.hooks, activate: false } : args.hooks +} + +export type ProvisionalLaunchArgs = { plan: AgentSessionLaunchPlan hooks: StructuredAgentLaunchHooks focusIntent?: NativeChatComposerFocusIntent @@ -70,13 +79,15 @@ type ProvisionalLaunchArgs = { beforeOpen?: (sessionId?: string) => boolean | void /** The terminal the host's "no" opens; a caller without one gets a new agent tab's. */ onHostDeclined?: ( - target: RuntimeClientTarget + target: RuntimeClientTarget, + activate?: false ) => Promise | StructuredLaunchTerminal /** What that default terminal carries from the caller, e.g. a recipe's saved CLI arguments. */ declinedTerminal?: DeclinedStructuredLaunchTerminalOptions } -/** Binds the launch to a chat tab once its host, this machine included, admits the chat. */ +/** Binds the launch to a chat tab: at once for a New Chat the user asked for, otherwise once its + * host, this machine included, admits the chat. */ export function beginStructuredAgentSessionProvisionalLaunch( args: ProvisionalLaunchArgs ): StructuredAgentSessionProvisionalLaunch | null { @@ -114,6 +125,26 @@ export function beginStructuredAgentSessionProvisionalLaunch( // No host to ask: the launch path refuses it with its own message. return open(beginLocalProvisionalLaunch(args)) } + if (opensAtAction(args)) { + const launch = open( + beginLocalProvisionalLaunch({ + ...args, + hooks: { ...args.hooks, activate: false }, + target: { ...args.target, worktreeId, executionHostId: owner.executionHostId }, + beforeOpen: undefined + }) + ) + if (!launch || launch.tab === null) { + return launch + } + const tab = launch.tab + return { + ...launch, + settlement: launch.settlement.then((settlement) => + openTerminalForDeclinedChat(args, worktreeId, owner.target, tab, settlement) + ) + } + } if (args.deferPublication && !args.target?.surfaceAddress) { args = { ...args, @@ -142,15 +173,7 @@ export function beginStructuredAgentSessionProvisionalLaunch( beforeOpen: undefined }) ), - onHostDeclined: - args.onHostDeclined ?? - (() => - openDeclinedStructuredLaunchTerminal({ - plan: args.plan, - worktreeId, - ...(args.targetGroupId ? { targetGroupId: args.targetGroupId } : {}), - ...(args.declinedTerminal ? { terminal: args.declinedTerminal } : {}) - })) + onHostDeclined: (target) => openDeclinedTerminal(args, worktreeId, target) }) void launch.settlement.then(cancelIfNoTab, cancelIfNoTab) return launch @@ -166,7 +189,7 @@ function beginLocalProvisionalLaunch( if (args.deferPublication) { return beginDeferredPublication(args) } - const handle = args.plan.begin(args.hooks, args.target) + const handle = args.plan.begin(launchHooks(args), args.target) if (!handle) { return null } @@ -206,7 +229,7 @@ function beginDeferredPublication( const releaseHold = holdEmptyWorkspaceDefaultSurfaceForLaunch(worktreeId) let handle: StructuredAgentLaunchHandle | null try { - handle = args.plan.begin(args.hooks, args.target) + handle = args.plan.begin(launchHooks(args), args.target) } catch (error) { releaseHold() throw error diff --git a/src/renderer/src/runtime/structured-chat-host-permission.test.ts b/src/renderer/src/runtime/structured-chat-host-permission.test.ts index 8993ecfcca20..600d8dff9727 100644 --- a/src/renderer/src/runtime/structured-chat-host-permission.test.ts +++ b/src/renderer/src/runtime/structured-chat-host-permission.test.ts @@ -135,13 +135,13 @@ describe('the permission verdict a chat holds', () => { mocks.contactListeners.length = 0 mocks.supportsCapability.mockResolvedValue(true) const { result } = renderHook(() => useStructuredChatHostPermissionVerdict(PAIRED, 'claude')) - expect(result.current).toEqual({ kind: 'checking' }) - await vi.waitFor(() => expect(result.current).toEqual({ kind: 'allowed' })) + expect(result.current).toMatchObject({ kind: 'checking' }) + await vi.waitFor(() => expect(result.current).toMatchObject({ kind: 'allowed' })) // The host came back as an older build: its answer is still on the way. mocks.supportsCapability.mockReturnValue(new Promise(() => undefined)) act(() => mocks.contactListeners.forEach((listener) => listener())) - expect(result.current).toEqual({ kind: 'checking' }) + expect(result.current).toMatchObject({ kind: 'checking' }) }) }) @@ -160,7 +160,7 @@ describe('a held chat', () => { await act(async () => { await vi.advanceTimersByTimeAsync(30_000) }) - expect(result.current).toEqual({ kind: 'allowed' }) + expect(result.current).toMatchObject({ kind: 'allowed' }) } finally { vi.useRealTimers() } diff --git a/src/renderer/src/runtime/structured-chat-host-permission.ts b/src/renderer/src/runtime/structured-chat-host-permission.ts index 70f93c1694bb..871e720cb27d 100644 --- a/src/renderer/src/runtime/structured-chat-host-permission.ts +++ b/src/renderer/src/runtime/structured-chat-host-permission.ts @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react' +import { useCallback, useEffect, useState } from 'react' import { agentChatPermissionModes, newChatPermissionModeForSettings @@ -70,7 +70,7 @@ export async function readStructuredChatHostPermissionVerdict( export function useStructuredChatHostPermissionVerdict( target: RuntimeClientTarget, agent: string -): StructuredChatHostPermissionVerdict | { kind: 'checking' } { +): (StructuredChatHostPermissionVerdict | { kind: 'checking' }) & { checkAgain: () => void } { const gated = target.kind === 'environment' && agentChatPermissionModes(agent) !== null const environmentId = target.kind === 'environment' ? target.environmentId : null const requested = useAppStore((state) => newChatPermissionModeForSettings(agent, state.settings)) @@ -84,6 +84,7 @@ export function useStructuredChatHostPermissionVerdict( } | null>(null) // Not in the key: a held chat stays held while it asks again. const [recheck, setRecheck] = useState(0) + const checkAgain = useCallback(() => setRecheck((n) => n + 1), []) const held = answer?.key === key && answer.verdict.kind !== 'allowed' useEffect(() => { if (!held) { @@ -117,7 +118,7 @@ export function useStructuredChatHostPermissionVerdict( } }, [agent, contact, environmentId, gated, key, recheck, requested]) if (!gated) { - return { kind: 'allowed' } + return { kind: 'allowed', checkAgain } } - return answer?.key === key ? answer.verdict : { kind: 'checking' } + return { ...(answer?.key === key ? answer.verdict : { kind: 'checking' as const }), checkAgain } } diff --git a/src/renderer/src/runtime/web-runtime-session-terminal-workspace-routing.test.ts b/src/renderer/src/runtime/web-runtime-session-terminal-workspace-routing.test.ts index 5c646f56f11e..ac951fbe9b0c 100644 --- a/src/renderer/src/runtime/web-runtime-session-terminal-workspace-routing.test.ts +++ b/src/renderer/src/runtime/web-runtime-session-terminal-workspace-routing.test.ts @@ -261,7 +261,7 @@ describe('web-runtime-session terminal workspace routing', () => { it('does not touch the host selection when the caller opted out of selecting', async () => { const selection: MutableSelection = { - activeWorktreeId: WORKTREE_ID, + activeWorktreeId: 'repo::/other', activeWorkspaceExecutionHostId: 'local' } stubSelectableWorkspaceStore(selection) @@ -276,6 +276,7 @@ describe('web-runtime-session terminal workspace routing', () => { }) expect(mocks.setActiveWorktree).not.toHaveBeenCalled() + expect(selection.activeWorktreeId).toBe('repo::/other') expect(selection.activeWorkspaceExecutionHostId).toBe('local') }) }) diff --git a/src/renderer/src/store/slices/tabs-label-and-pin-state.test.ts b/src/renderer/src/store/slices/tabs-label-and-pin-state.test.ts index f868306b2a2c..a711b61de2f1 100644 --- a/src/renderer/src/store/slices/tabs-label-and-pin-state.test.ts +++ b/src/renderer/src/store/slices/tabs-label-and-pin-state.test.ts @@ -144,11 +144,19 @@ vi.mock('@/lib/agent-status', async (importOriginal) => { } })) - store.getState().pinTab(tab.id) + const interaction = vi.fn() + store.setState({ recordFeatureInteraction: interaction }) + store.getState().pinTab(tab.id, { recordInteraction: false }) expect(store.getState().tabsByWorktree[WT][0].isPinned).toBe(true) - - store.getState().unpinTab(tab.id) + store.getState().unpinTab(tab.id, { recordInteraction: false }) expect(store.getState().tabsByWorktree[WT][0].isPinned).toBe(false) + store.getState().setTabColor(tab.entityId, '#ff0000', { recordInteraction: false }) + expect(store.getState().tabsByWorktree[WT][0].color).toBe('#ff0000') + expect(interaction).not.toHaveBeenCalled() + store.getState().pinTab(tab.id) + store.getState().unpinTab(tab.id) + store.getState().setTabColor(tab.entityId, null) + expect(interaction).toHaveBeenCalledTimes(3) }) }) }) diff --git a/src/renderer/src/store/slices/tabs/tabs-label-actions.ts b/src/renderer/src/store/slices/tabs/tabs-label-actions.ts index 9f6b1a0889c8..876bea533d67 100644 --- a/src/renderer/src/store/slices/tabs/tabs-label-actions.ts +++ b/src/renderer/src/store/slices/tabs/tabs-label-actions.ts @@ -206,15 +206,15 @@ export function createTabsLabelActions( } }, - setUnifiedTabColor: (tabId, color) => { + setUnifiedTabColor: (tabId, color, opts) => { const exists = get().getTab(tabId) !== null set((state) => patchTab(state.unifiedTabsByWorktree, tabId, { color }) ?? state) - if (exists) { + if (exists && opts?.recordInteraction !== false) { get().recordFeatureInteraction?.('terminal-tabs') } }, - pinTab: (tabId) => { + pinTab: (tabId, opts) => { const exists = get().getTab(tabId) !== null set((state) => { const found = findTabAndWorktree(state.unifiedTabsByWorktree, tabId) @@ -247,12 +247,12 @@ export function createTabsLabelActions( } }) mirrorTabPinnedToHost(get(), tabId, true) - if (exists) { + if (exists && opts?.recordInteraction !== false) { get().recordFeatureInteraction?.('terminal-tabs') } }, - unpinTab: (tabId) => { + unpinTab: (tabId, opts) => { const exists = get().getTab(tabId) !== null set((state) => { const found = findTabAndWorktree(state.unifiedTabsByWorktree, tabId) @@ -284,7 +284,7 @@ export function createTabsLabelActions( } }) mirrorTabPinnedToHost(get(), tabId, false) - if (exists) { + if (exists && opts?.recordInteraction !== false) { get().recordFeatureInteraction?.('terminal-tabs') } } diff --git a/src/renderer/src/store/slices/tabs/tabs-slice-contract.ts b/src/renderer/src/store/slices/tabs/tabs-slice-contract.ts index c2e175124826..d31107acf579 100644 --- a/src/renderer/src/store/slices/tabs/tabs-slice-contract.ts +++ b/src/renderer/src/store/slices/tabs/tabs-slice-contract.ts @@ -125,9 +125,13 @@ export type TabsSlice = { label: string | null, opts?: { recordInteraction?: boolean } ) => void - setUnifiedTabColor: (tabId: string, color: string | null) => void - pinTab: (tabId: string) => void - unpinTab: (tabId: string) => void + setUnifiedTabColor: ( + tabId: string, + color: string | null, + opts?: { recordInteraction?: boolean } + ) => void + pinTab: (tabId: string, opts?: { recordInteraction?: boolean }) => void + unpinTab: (tabId: string, opts?: { recordInteraction?: boolean }) => void closeOtherTabs: (tabId: string) => string[] closeTabsToRight: (tabId: string) => string[] closeTabsToLeft: (tabId: string) => string[] diff --git a/src/renderer/src/store/terminals/terminal-actions.ts b/src/renderer/src/store/terminals/terminal-actions.ts index 43c63c9883a6..c2dc4b685f85 100644 --- a/src/renderer/src/store/terminals/terminal-actions.ts +++ b/src/renderer/src/store/terminals/terminal-actions.ts @@ -123,7 +123,7 @@ export type TerminalActions = { recordInteraction?: boolean } ) => void - setTabColor: (tabId: string, color: string | null) => void + setTabColor: (tabId: string, color: string | null, opts?: { recordInteraction?: boolean }) => void /** What the tab keeps about the launch that laid out one of its panes; undefined clears it. * `remount` remounts the tab's panes, so one whose spawn was refused spawns again. */ setTabAgentLaunchPane: ( diff --git a/src/renderer/src/store/terminals/terminal-tab-appearance.ts b/src/renderer/src/store/terminals/terminal-tab-appearance.ts index 0962e65591d3..2a68c81ed99c 100644 --- a/src/renderer/src/store/terminals/terminal-tab-appearance.ts +++ b/src/renderer/src/store/terminals/terminal-tab-appearance.ts @@ -27,7 +27,7 @@ export function createTerminalTabAppearanceActions( get().setTabCustomLabel(item.id, title, opts) } }, - setTabColor: (tabId, color) => { + setTabColor: (tabId, color, opts) => { set((s) => { const next = { ...s.tabsByWorktree } for (const wId of Object.keys(next)) { @@ -37,7 +37,7 @@ export function createTerminalTabAppearanceActions( }) const item = findRenamableUnifiedTab(get().unifiedTabsByWorktree, tabId) if (item) { - get().setUnifiedTabColor(item.id, color) + get().setUnifiedTabColor(item.id, color, opts) // Why: tab color is host-authoritative for remote-server tabs; mirror it so it persists instead of reverting on the next snapshot. const state = get() const owningWorktreeId = Object.keys(state.unifiedTabsByWorktree).find((wId) => diff --git a/src/renderer/src/store/terminals/terminal-tab-layout-owner-actions.ts b/src/renderer/src/store/terminals/terminal-tab-layout-owner-actions.ts index 4c0e0a734bf3..68b19e0e080d 100644 --- a/src/renderer/src/store/terminals/terminal-tab-layout-owner-actions.ts +++ b/src/renderer/src/store/terminals/terminal-tab-layout-owner-actions.ts @@ -125,11 +125,11 @@ export function routeTerminalTabChangesThroughLayoutOwner( (id) => ({ type: 'renameTab', tabId: id, kind: 'custom', title }), () => actions.setTabCustomTitle(tabId, title, opts) ), - setTabColor: (tabId, color) => + setTabColor: (tabId, color, opts) => route( tabId, (id) => ({ type: 'setTabProps', tabId: id, color }), - () => actions.setTabColor(tabId, color) + () => actions.setTabColor(tabId, color, opts) ), setAiVaultTabTitle: (tabId, title) => { const current = terminalTab(get(), tabId)?.aiVaultTitle ?? null