From 33f5eb184cec4f6dac4bdaadee51cfb9e2b479ef Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sun, 5 Jul 2026 11:31:05 -0700 Subject: [PATCH] Render launch prompts in native chat and refine initial view mode - Seed and render the agent launch prompt as a synthetic, pending user message in the native chat view until the transcript catches up. - Refine initial view mode logic so native chat does not auto-open for draft prompt delivery, unsupported agents, or when disabled. - Add delivery failure tracking for the launch prompt, rendering an error status in the message list if pasting into the terminal fails. - Implement corresponding store actions, selectors, cleanup routines, and extensive test coverage. --- .../native-chat/NativeChatMessageList.tsx | 17 ++- .../components/native-chat/NativeChatView.tsx | 40 +++++- .../native-chat/native-chat-availability.ts | 21 +-- .../native-chat-message-grouping.test.ts | 10 ++ .../native-chat/native-chat-pending.test.ts | 90 ++++++++++++ .../native-chat/native-chat-pending.ts | 32 +++++ .../native-chat-session-assembler.ts | 17 ++- src/renderer/src/hooks/useIpcEvents.ts | 8 +- src/renderer/src/i18n/locales/en.json | 3 +- src/renderer/src/i18n/locales/es.json | 3 +- src/renderer/src/i18n/locales/ja.json | 3 +- src/renderer/src/i18n/locales/ko.json | 3 +- src/renderer/src/i18n/locales/zh.json | 3 +- .../lib/agent-launch-prompt-delivery.test.ts | 131 ++++++++++++++++++ .../src/lib/agent-launch-prompt-delivery.ts | 42 ++++++ .../src/lib/launch-agent-in-new-tab.test.ts | 74 +++++++++- .../src/lib/launch-agent-in-new-tab.ts | 6 +- .../src/lib/launch-work-item-direct-agent.ts | 4 +- .../src/lib/launch-work-item-direct.test.ts | 26 +++- .../lib/native-chat-initial-view-mode.test.ts | 75 ++++++++-- .../src/lib/native-chat-initial-view-mode.ts | 54 +++++--- .../src/lib/native-chat-launch-prompt.ts | 9 ++ .../src/lib/native-chat-supported-agent.ts | 15 ++ .../src/lib/worktree-activation.test.ts | 26 ++++ src/renderer/src/lib/worktree-activation.ts | 16 ++- .../store/slices/terminal-orphan-helpers.ts | 6 + src/renderer/src/store/slices/terminals.ts | 45 ++++++ .../slices/worktree-removal-maps-leak.test.ts | 67 +++++++++ src/renderer/src/store/slices/worktrees.ts | 4 + 29 files changed, 772 insertions(+), 78 deletions(-) create mode 100644 src/renderer/src/lib/agent-launch-prompt-delivery.test.ts create mode 100644 src/renderer/src/lib/agent-launch-prompt-delivery.ts create mode 100644 src/renderer/src/lib/native-chat-launch-prompt.ts create mode 100644 src/renderer/src/lib/native-chat-supported-agent.ts diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx index b447a4287c2..313845c2030 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx @@ -127,7 +127,8 @@ function MessageRow({ expandSignal, onScrollMessageToTop, onLinkClick, - allowFileUriLinks = false + allowFileUriLinks = false, + deliveryFailed = false }: { message: NativeChatMessage expandSignal: boolean @@ -135,6 +136,7 @@ function MessageRow({ onScrollMessageToTop: (el: HTMLElement) => void onLinkClick?: CommentMarkdownLinkClickHandler allowFileUriLinks?: boolean + deliveryFailed?: boolean }): React.JSX.Element | null { const rowRef = useRef(null) const { prose, tools } = useMemo(() => splitNativeChatBlocks(message.blocks), [message.blocks]) @@ -181,6 +183,14 @@ function MessageRow({ )} + {deliveryFailed ? ( +
+ {translate( + 'components.native-chat.launchPromptNotDelivered', + 'Not delivered — check the terminal' + )} +
+ ) : null} ) } @@ -227,7 +237,8 @@ export function NativeChatMessageList({ expandSignal, fontScale, onLinkClick, - allowFileUriLinks = false + allowFileUriLinks = false, + failedDeliveryMessageIds }: { session: NativeChatLiveSession isWorking: boolean @@ -237,6 +248,7 @@ export function NativeChatMessageList({ fontScale: number onLinkClick?: CommentMarkdownLinkClickHandler allowFileUriLinks?: boolean + failedDeliveryMessageIds?: ReadonlySet }): React.JSX.Element { const scrollRef = useRef(null) const [stuckToBottom, setStuckToBottom] = useState(true) @@ -370,6 +382,7 @@ export function NativeChatMessageList({ onScrollMessageToTop={scrollMessageToTop} onLinkClick={onLinkClick} allowFileUriLinks={allowFileUriLinks} + deliveryFailed={failedDeliveryMessageIds?.has(message.id) === true} /> ))} {showTypingIndicator ? : null} diff --git a/src/renderer/src/components/native-chat/NativeChatView.tsx b/src/renderer/src/components/native-chat/NativeChatView.tsx index ba8f877c43a..9ab7c8e6305 100644 --- a/src/renderer/src/components/native-chat/NativeChatView.tsx +++ b/src/renderer/src/components/native-chat/NativeChatView.tsx @@ -24,10 +24,12 @@ import { appendPendingSendCache, commandMarkersAsMessages, appendCommandMarkerCache, + launchPromptAsMessage, pendingSendsAsMessages, prunePendingSends, readCommandMarkerCache, readPendingSendCache, + shouldPruneLaunchPrompt, writePendingSendCache, type NativeChatCommandMarker, type NativeChatPendingSend @@ -156,6 +158,9 @@ function NativeChatResolvedView({ contextMenuActions?: Omit }): React.JSX.Element { const session = useNativeChatLiveSession({ paneKey, agent, sessionId, transcriptPath }) + const launchPrompt = useAppStore((s) => s.nativeChatLaunchPromptByTabId[terminalTabId] ?? null) + const clearNativeChatLaunchPrompt = useAppStore((s) => s.clearNativeChatLaunchPrompt) + const paneLaunchPrompt = launchPrompt?.agent === agent ? launchPrompt : null // Live hook state for this pane, selected directly so the working indicator // flips the instant the agent reports 'working' — even when switching to chat // mid-turn before the transcript merge has caught up. @@ -270,6 +275,12 @@ function NativeChatResolvedView({ writePendingSendCache(pendingScope, prunePendingSends(prev, session.messages)) ) }, [session.messages, pendingScope]) + useEffect(() => { + if (!paneLaunchPrompt || !shouldPruneLaunchPrompt(paneLaunchPrompt, session.messages)) { + return + } + clearNativeChatLaunchPrompt(terminalTabId) + }, [clearNativeChatLaunchPrompt, paneLaunchPrompt, session.messages, terminalTabId]) const onOptimisticSend = useCallback( (text: string, imagePaths?: string[]) => { setWorkingInterrupted(false) @@ -291,10 +302,32 @@ function NativeChatResolvedView({ [commandMarkerScope] ) + const launchPromptMessage = useMemo( + () => launchPromptAsMessage(paneLaunchPrompt, session.messages), + [paneLaunchPrompt, session.messages] + ) + const sessionWithLaunchPrompt = useMemo(() => { + if (!launchPromptMessage) { + return session + } + return { ...session, messages: [...session.messages, launchPromptMessage] } + }, [launchPromptMessage, session]) + const sessionAfterCommandBoundaries = useMemo(() => { - const messages = applyCommandMarkerBoundaries(session.messages, commandMarkers) - return messages === session.messages ? session : { ...session, messages } - }, [session, commandMarkers]) + const messages = applyCommandMarkerBoundaries(sessionWithLaunchPrompt.messages, commandMarkers) + return messages === sessionWithLaunchPrompt.messages + ? sessionWithLaunchPrompt + : { ...sessionWithLaunchPrompt, messages } + }, [sessionWithLaunchPrompt, commandMarkers]) + const launchPromptVisible = + launchPromptMessage !== null && + sessionAfterCommandBoundaries.messages.some((message) => message.id === launchPromptMessage.id) + const failedLaunchPromptMessageIds = useMemo(() => { + if (!paneLaunchPrompt?.failed || !launchPromptVisible || !launchPromptMessage) { + return undefined + } + return new Set([launchPromptMessage.id]) + }, [paneLaunchPrompt?.failed, launchPromptMessage, launchPromptVisible]) // The streaming preview bubble (if any) sits after the transcript but before // the optimistic user echoes — same order mobile uses. @@ -422,6 +455,7 @@ function NativeChatResolvedView({ fontScale={fontScale.scale} onLinkClick={nativeChatFileLinkClick} allowFileUriLinks={fileLinkContext !== null} + failedDeliveryMessageIds={failedLaunchPromptMessageIds} /> )} diff --git a/src/renderer/src/components/native-chat/native-chat-availability.ts b/src/renderer/src/components/native-chat/native-chat-availability.ts index ef12785b0e0..4bc1dcd98d7 100644 --- a/src/renderer/src/components/native-chat/native-chat-availability.ts +++ b/src/renderer/src/components/native-chat/native-chat-availability.ts @@ -1,25 +1,8 @@ import type { Tab, TuiAgent } from '../../../../shared/types' import type { AgentType } from '../../../../shared/agent-status-types' +import { isNativeChatSupportedAgent } from '@/lib/native-chat-supported-agent' -/** Agents whose transcripts the native chat view can actually parse and render. - * Native chat depends on provider-specific transcript/streaming parsing, so the - * toggle must stay limited to the providers we support — currently Claude - * (including the OpenClaude variant) and Codex. Other agents (Grok, Gemini, …) - * run fine in the terminal but have no native-chat rendering, so they must not - * show the toggle. */ -const NATIVE_CHAT_SUPPORTED_AGENTS: ReadonlySet = new Set([ - 'claude', - 'openclaude', - 'codex' -]) - -/** Whether the given agent identity (from any signal: launch hint, live - * detection, or title resolution) is one native chat can render. */ -export function isNativeChatSupportedAgent( - agent: TuiAgent | AgentType | null | undefined -): boolean { - return agent != null && NATIVE_CHAT_SUPPORTED_AGENTS.has(agent) -} +export { isNativeChatSupportedAgent } from '@/lib/native-chat-supported-agent' /** Inputs that decide whether a tab may toggle into the native chat view. * Kept as a plain shape (not the live store) so the decision stays pure and diff --git a/src/renderer/src/components/native-chat/native-chat-message-grouping.test.ts b/src/renderer/src/components/native-chat/native-chat-message-grouping.test.ts index 0b510514135..1553cdc913f 100644 --- a/src/renderer/src/components/native-chat/native-chat-message-grouping.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-message-grouping.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from 'vitest' import type { NativeChatMessage } from '../../../../shared/native-chat-types' import { buildNativeChatRenderItems, orderNativeChatMessages } from './native-chat-message-grouping' +import { NATIVE_CHAT_STREAMING_ID } from '../../../../shared/native-chat-streaming' function msg( overrides: Partial & Pick @@ -31,6 +32,15 @@ describe('orderNativeChatMessages', () => { ]) expect(ordered.map((m) => m.id)).toEqual(['a', 'z']) }) + + it('sorts the synthetic streaming message after timestamped content', () => { + const ordered = orderNativeChatMessages([ + msg({ id: NATIVE_CHAT_STREAMING_ID, timestamp: null }), + msg({ id: 'real-user', role: 'user', timestamp: 10 }), + msg({ id: 'pending-user', role: 'user', timestamp: 20, source: 'scrape' }) + ]) + expect(ordered.map((m) => m.id)).toEqual(['real-user', 'pending-user', 'streaming']) + }) }) describe('buildNativeChatRenderItems', () => { diff --git a/src/renderer/src/components/native-chat/native-chat-pending.test.ts b/src/renderer/src/components/native-chat/native-chat-pending.test.ts index c981fe806c7..5083ab0c518 100644 --- a/src/renderer/src/components/native-chat/native-chat-pending.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-pending.test.ts @@ -8,11 +8,14 @@ import { clearPendingSendCacheForTests, commandMarkersAsMessages, isCommandMarkerId, + isLaunchPromptMessageId, isPendingMessageId, + launchPromptAsMessage, pendingSendsAsMessages, prunePendingSends, readCommandMarkerCache, readPendingSendCache, + shouldPruneLaunchPrompt, writePendingSendCache, type NativeChatPendingSend } from './native-chat-pending' @@ -135,6 +138,86 @@ describe('pendingSendsAsMessages', () => { }) }) +describe('launchPromptAsMessage', () => { + it('maps a launch prompt to a tab-keyed scrape-source user message', () => { + expect( + launchPromptAsMessage({ + tabId: 'tab-1', + agent: 'codex', + text: 'Fix failing checks', + createdAt: 42 + }) + ).toEqual({ + id: 'launch-pending:tab-1', + role: 'user', + blocks: [{ type: 'text', text: 'Fix failing checks' }], + timestamp: 42, + source: 'scrape' + }) + }) + + it('hides the launch prompt while its transcript user turn is visible', () => { + expect( + launchPromptAsMessage( + { + tabId: 'tab-1', + agent: 'codex', + text: 'Fix failing checks', + createdAt: 42 + }, + [userMessage('u1', 'Fix failing checks')] + ) + ).toBeNull() + }) + + it('uses pending-send normalization for large multiline generated prompts', () => { + const prompt = [ + '[Image #1] Resolve the failing checks:', + '', + 'Resolve the failing checks:', + '', + '- lint failed', + ' fix spacing' + ].join('\n') + const transcript = [ + userMessage( + 'u1', + 'Resolve the failing checks: Resolve the failing checks: - lint failed fix spacing' + ), + assistantMessage('a1', 'I will fix it') + ] + + expect( + shouldPruneLaunchPrompt( + { + tabId: 'tab-1', + agent: 'codex', + text: prompt, + createdAt: 42 + }, + transcript + ) + ).toBe(true) + }) + + it('keeps the launch prompt until the transcript advances past the user turn', () => { + const prompt = { + tabId: 'tab-1', + agent: 'claude' as const, + text: 'Fix failing checks', + createdAt: 42 + } + + expect(shouldPruneLaunchPrompt(prompt, [userMessage('u1', 'Fix failing checks')])).toBe(false) + expect( + shouldPruneLaunchPrompt(prompt, [ + userMessage('u1', 'Fix failing checks'), + assistantMessage('a1', 'working') + ]) + ).toBe(true) + }) +}) + describe('pending send cache', () => { it('persists optimistic sends for the same pane and agent', () => { clearPendingSendCacheForTests() @@ -165,6 +248,13 @@ describe('isPendingMessageId', () => { }) }) +describe('isLaunchPromptMessageId', () => { + it('recognizes the launch prompt id prefix', () => { + expect(isLaunchPromptMessageId('launch-pending:tab-1')).toBe(true) + expect(isLaunchPromptMessageId('pending:p1')).toBe(false) + }) +}) + describe('commandMarkersAsMessages', () => { it('renders a slash command as a system "Ran " message', () => { expect(commandMarkersAsMessages([{ id: 'c1', command: '/clear', sentAt: 7 }])).toEqual([ diff --git a/src/renderer/src/components/native-chat/native-chat-pending.ts b/src/renderer/src/components/native-chat/native-chat-pending.ts index 67bfb290aef..9e82f52b924 100644 --- a/src/renderer/src/components/native-chat/native-chat-pending.ts +++ b/src/renderer/src/components/native-chat/native-chat-pending.ts @@ -5,6 +5,7 @@ import { isTextBlock, type NativeChatMessage } from '../../../../shared/native-chat-types' import { stripImagePromptMarker } from './native-chat-image-transcript-markers' +import type { NativeChatLaunchPrompt } from '@/lib/native-chat-launch-prompt' /** An optimistic, not-yet-confirmed composer send. */ export type NativeChatPendingSend = { @@ -152,6 +153,37 @@ export function isPendingMessageId(id: string): boolean { return id.startsWith('pending:') } +export function launchPromptAsMessage( + entry: NativeChatLaunchPrompt | null, + existingMessages: NativeChatMessage[] = [] +): NativeChatMessage | null { + if (!entry) { + return null + } + const represented = matchingUserMessageTexts(existingMessages) + if (represented.has(normalize(entry.text))) { + return null + } + return { + id: `launch-pending:${entry.tabId}`, + role: 'user' as const, + blocks: entry.text.trim().length > 0 ? [{ type: 'text' as const, text: entry.text }] : [], + timestamp: entry.createdAt, + source: 'scrape' as const + } +} + +export function shouldPruneLaunchPrompt( + entry: NativeChatLaunchPrompt, + messages: NativeChatMessage[] +): boolean { + return advancedPastUserMessageTexts(messages).has(normalize(entry.text)) +} + +export function isLaunchPromptMessageId(id: string): boolean { + return id.startsWith('launch-pending:') +} + /** A locally-recorded slash command (e.g. `/clear`). Slash commands dispatch to * the agent's TUI and are not chat turns, so we surface a small system line as * feedback that the command ran rather than echoing a user bubble. */ diff --git a/src/renderer/src/components/native-chat/native-chat-session-assembler.ts b/src/renderer/src/components/native-chat/native-chat-session-assembler.ts index ddb4ac7fd40..1d48780832b 100644 --- a/src/renderer/src/components/native-chat/native-chat-session-assembler.ts +++ b/src/renderer/src/components/native-chat/native-chat-session-assembler.ts @@ -6,6 +6,7 @@ import { type NativeChatSession, type NativeChatSessionStatus } from '../../../../shared/native-chat-types' +import { NATIVE_CHAT_STREAMING_ID } from '../../../../shared/native-chat-streaming' import { normalizeImageTranscriptMessages } from './native-chat-image-transcript-markers' /** Messages grouped by source. Higher-priority sources (transcript > hook > @@ -84,12 +85,20 @@ function supersedes(candidate: NativeChatMessage, existing: NativeChatMessage): return candidateRank > existingRank } +function messageSortTimestamp(message: NativeChatMessage): number { + if (message.id === NATIVE_CHAT_STREAMING_ID) { + return Number.POSITIVE_INFINITY + } + return message.timestamp ?? Number.NEGATIVE_INFINITY +} + // Why: null timestamps (sources that can't supply one, e.g. scrape segments) -// sort before any real timestamp so they don't jump to the end. Ties break on -// id for a stable, deterministic order. +// sort before any real timestamp so they don't jump to the end. The synthetic +// streaming preview is the one exception: it is a tail bubble and sorts last. +// Ties break on id for a stable, deterministic order. export function compareMessages(a: NativeChatMessage, b: NativeChatMessage): number { - const at = a.timestamp ?? Number.NEGATIVE_INFINITY - const bt = b.timestamp ?? Number.NEGATIVE_INFINITY + const at = messageSortTimestamp(a) + const bt = messageSortTimestamp(b) if (at !== bt) { return at - bt } diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index c0ab880a9e8..21742cf0e3c 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -1423,7 +1423,9 @@ export function useIpcEvents(): void { ...(launchAgent ? { launchAgent, - ...initialAgentTabViewModeProps(store.settings) + ...initialAgentTabViewModeProps(store.settings, { + agent: launchAgent + }) } : {}), // Why: tabId hint comes from CLI-spawned PTYs whose env @@ -1602,7 +1604,9 @@ export function useIpcEvents(): void { ? { ...(shouldActivate ? {} : { activate: false, recordInteraction: false }), launchAgent: data.launchAgent, - ...initialAgentTabViewModeProps(store.settings) + ...initialAgentTabViewModeProps(store.settings, { + agent: data.launchAgent + }) } : shouldActivate ? undefined diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 2fd3e185eb4..511c344f97d 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -12364,7 +12364,8 @@ "title": "Allow {{value0}}?", "allow": "Allow", "deny": "Deny" - } + }, + "launchPromptNotDelivered": "Not delivered — check the terminal" }, "tab": { "bar": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index d004befd435..7276f765bec 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -12364,7 +12364,8 @@ "title": "¿Permitir {{value0}}?", "allow": "Permitir", "deny": "Denegar" - } + }, + "launchPromptNotDelivered": "Not delivered — check the terminal" }, "tab": { "bar": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 04c946b7f2b..77bb1e33f49 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -12364,7 +12364,8 @@ "title": "{{value0}} を許可しますか?", "allow": "許可する", "deny": "拒否" - } + }, + "launchPromptNotDelivered": "Not delivered — check the terminal" }, "tab": { "bar": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index f55504f64f3..88a7d4cf236 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -12364,7 +12364,8 @@ "title": "{{value0}}을(를) 허용하시겠습니까?", "allow": "허용하다", "deny": "부인하다" - } + }, + "launchPromptNotDelivered": "Not delivered — check the terminal" }, "tab": { "bar": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 88a2b63808d..897a993208a 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -12364,7 +12364,8 @@ "title": "允许 {{value0}}?", "allow": "允许", "deny": "拒绝" - } + }, + "launchPromptNotDelivered": "Not delivered — check the terminal" }, "tab": { "bar": { diff --git a/src/renderer/src/lib/agent-launch-prompt-delivery.test.ts b/src/renderer/src/lib/agent-launch-prompt-delivery.test.ts new file mode 100644 index 00000000000..5ea7aacda65 --- /dev/null +++ b/src/renderer/src/lib/agent-launch-prompt-delivery.test.ts @@ -0,0 +1,131 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + pasteDraftWhenAgentReady: vi.fn(), + seedNativeChatLaunchPrompt: vi.fn(), + markNativeChatLaunchPromptFailed: vi.fn() +})) + +vi.mock('@/lib/agent-paste-draft', () => ({ + pasteDraftWhenAgentReady: mocks.pasteDraftWhenAgentReady +})) + +vi.mock('@/store', () => ({ + useAppStore: { + getState: () => ({ + seedNativeChatLaunchPrompt: mocks.seedNativeChatLaunchPrompt, + markNativeChatLaunchPromptFailed: mocks.markNativeChatLaunchPromptFailed + }) + } +})) + +import { deliverLaunchPromptToAgentTab } from './agent-launch-prompt-delivery' + +describe('deliverLaunchPromptToAgentTab', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.pasteDraftWhenAgentReady.mockResolvedValue(true) + }) + + it('seeds a native-chat launch prompt for supported submitted content', async () => { + await expect( + deliverLaunchPromptToAgentTab({ + tabId: 'tab-1', + agent: 'codex', + content: 'Fix failing checks', + submit: true, + forcePaste: true + }) + ).resolves.toBe(true) + + expect(mocks.seedNativeChatLaunchPrompt).toHaveBeenCalledWith({ + tabId: 'tab-1', + agent: 'codex', + text: 'Fix failing checks', + createdAt: expect.any(Number) + }) + expect(mocks.pasteDraftWhenAgentReady).toHaveBeenCalledWith({ + tabId: 'tab-1', + agent: 'codex', + content: 'Fix failing checks', + submit: true, + forcePaste: true, + timeoutMs: undefined, + onTimeout: undefined + }) + }) + + it('does not seed for drafts, unsupported agents, or empty content', async () => { + await deliverLaunchPromptToAgentTab({ + tabId: 'draft-tab', + agent: 'codex', + content: 'Review first', + submit: false, + forcePaste: false + }) + await deliverLaunchPromptToAgentTab({ + tabId: 'unsupported-tab', + agent: 'grok', + content: 'Fix failing checks', + submit: true, + forcePaste: true + }) + await deliverLaunchPromptToAgentTab({ + tabId: 'empty-tab', + agent: 'claude', + content: ' ', + submit: true, + forcePaste: true + }) + + expect(mocks.seedNativeChatLaunchPrompt).not.toHaveBeenCalled() + }) + + it('marks a seeded launch prompt failed when paste delivery returns false', async () => { + mocks.pasteDraftWhenAgentReady.mockResolvedValue(false) + + await expect( + deliverLaunchPromptToAgentTab({ + tabId: 'tab-1', + agent: 'claude', + content: 'Large generated prompt', + submit: true, + forcePaste: true + }) + ).resolves.toBe(false) + + expect(mocks.markNativeChatLaunchPromptFailed).toHaveBeenCalledWith('tab-1') + }) + + it('does not mark unseeded launches failed', async () => { + mocks.pasteDraftWhenAgentReady.mockResolvedValue(false) + + await deliverLaunchPromptToAgentTab({ + tabId: 'tab-1', + agent: 'grok', + content: 'Large generated prompt', + submit: true, + forcePaste: true + }) + + expect(mocks.markNativeChatLaunchPromptFailed).not.toHaveBeenCalled() + }) + + it('passes timeout options through to the paste transport', async () => { + const onTimeout = vi.fn() + + await deliverLaunchPromptToAgentTab({ + tabId: 'tab-1', + agent: 'codex', + content: 'Fix failing checks', + submit: true, + forcePaste: true, + timeoutMs: 123, + onTimeout + }) + + expect(mocks.pasteDraftWhenAgentReady).toHaveBeenCalledWith( + expect.objectContaining({ timeoutMs: 123, onTimeout }) + ) + }) +}) diff --git a/src/renderer/src/lib/agent-launch-prompt-delivery.ts b/src/renderer/src/lib/agent-launch-prompt-delivery.ts new file mode 100644 index 00000000000..fc56f81d0fc --- /dev/null +++ b/src/renderer/src/lib/agent-launch-prompt-delivery.ts @@ -0,0 +1,42 @@ +import { pasteDraftWhenAgentReady } from '@/lib/agent-paste-draft' +import { isNativeChatSupportedAgent } from '@/lib/native-chat-supported-agent' +import { useAppStore } from '@/store' +import type { TuiAgent } from '../../../shared/types' + +export function deliverLaunchPromptToAgentTab(args: { + tabId: string + agent: TuiAgent + content: string + submit: boolean + forcePaste: boolean + timeoutMs?: number + onTimeout?: () => void +}): Promise { + const { tabId, agent, content, submit, forcePaste, timeoutMs, onTimeout } = args + const shouldSeed = + submit === true && content.trim().length > 0 && isNativeChatSupportedAgent(agent) + + if (shouldSeed) { + useAppStore.getState().seedNativeChatLaunchPrompt({ + tabId, + agent, + text: content, + createdAt: Date.now() + }) + } + + return pasteDraftWhenAgentReady({ + tabId, + content, + agent, + submit, + forcePaste, + timeoutMs, + onTimeout + }).then((delivered) => { + if (shouldSeed && !delivered) { + useAppStore.getState().markNativeChatLaunchPromptFailed(tabId) + } + return delivered + }) +} 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 4b3c18d968c..73e72df1fab 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 @@ -6,6 +6,8 @@ const mockSetActiveTabType = vi.fn() const mockSetTabBarOrder = vi.fn() const mockSetAgentStatus = vi.fn() const mockPasteDraftWhenAgentReady = vi.fn() +const mockSeedNativeChatLaunchPrompt = vi.fn() +const mockMarkNativeChatLaunchPromptFailed = vi.fn() const mockTrack = vi.fn() const LEAF_ID = '11111111-1111-4111-8111-111111111111' @@ -18,6 +20,13 @@ const store = { agentDefaultArgs: {} as Record, agentDefaultEnv: {} as Record>, activeRuntimeEnvironmentId: null as string | null + } as { + agentCmdOverrides: Record + agentDefaultArgs: Record + agentDefaultEnv: Record> + activeRuntimeEnvironmentId: string | null + experimentalNativeChat?: boolean + openAgentTabsInChatByDefault?: boolean }, projects: [ { @@ -56,7 +65,9 @@ const store = { queueTabStartupCommand: mockQueueTabStartupCommand, setActiveTabType: mockSetActiveTabType, setTabBarOrder: mockSetTabBarOrder, - setAgentStatus: mockSetAgentStatus + setAgentStatus: mockSetAgentStatus, + seedNativeChatLaunchPrompt: mockSeedNativeChatLaunchPrompt, + markNativeChatLaunchPromptFailed: mockMarkNativeChatLaunchPromptFailed } vi.mock('@/store', () => ({ @@ -152,6 +163,66 @@ describe('launchAgentInNewTab', () => { }) }) + it('opens supported submit-after-ready launches in chat and seeds a launch prompt echo', async () => { + store.settings = { + agentCmdOverrides: {}, + agentDefaultArgs: {}, + agentDefaultEnv: {}, + activeRuntimeEnvironmentId: null, + experimentalNativeChat: true, + openAgentTabsInChatByDefault: true + } + const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') + + launchAgentInNewTab({ + agent: 'codex', + worktreeId: 'wt-1', + prompt: 'large generated prompt', + promptDelivery: 'submit-after-ready' + }) + + expect(mockCreateTab).toHaveBeenCalledWith('wt-1', undefined, undefined, { + launchAgent: 'codex', + viewMode: 'chat' + }) + expect(mockQueueTabStartupCommand).toHaveBeenCalledWith( + 'tab-1', + expect.objectContaining({ + command: expect.not.stringContaining('large generated prompt') + }) + ) + expect(mockSeedNativeChatLaunchPrompt).toHaveBeenCalledWith({ + tabId: 'tab-1', + agent: 'codex', + text: 'large generated prompt', + createdAt: expect.any(Number) + }) + }) + + it('keeps unsupported submit-after-ready launches in terminal mode and does not seed chat', async () => { + store.settings = { + agentCmdOverrides: {}, + agentDefaultArgs: {}, + agentDefaultEnv: {}, + activeRuntimeEnvironmentId: null, + experimentalNativeChat: true, + openAgentTabsInChatByDefault: true + } + const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') + + launchAgentInNewTab({ + agent: 'grok', + worktreeId: 'wt-1', + prompt: 'large generated prompt', + promptDelivery: 'submit-after-ready' + }) + + expect(mockCreateTab).toHaveBeenCalledWith('wt-1', undefined, undefined, { + launchAgent: 'grok' + }) + expect(mockSeedNativeChatLaunchPrompt).not.toHaveBeenCalled() + }) + it('passes quick command labels only to locally-created agent tabs', async () => { const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') @@ -414,6 +485,7 @@ describe('launchAgentInNewTab', () => { }) store.terminalLayoutsByTabId = { 'tab-1': { activeLeafId: LEAF_ID } } await Promise.resolve() + await Promise.resolve() expect(mockQueueTabStartupCommand).toHaveBeenCalledWith( 'tab-1', 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 41ded040d48..f815d450c9d 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.ts @@ -9,7 +9,7 @@ import { CLIENT_PLATFORM } from '@/lib/new-workspace' import { getAgentLaunchPlatformForRepo } from '@/lib/agent-launch-platform' import { reconcileTabOrder } from '@/components/tab-bar/reconcile-order' import { track, tuiAgentToAgentKind } from '@/lib/telemetry' -import { pasteDraftWhenAgentReady } from '@/lib/agent-paste-draft' +import { deliverLaunchPromptToAgentTab } from '@/lib/agent-launch-prompt-delivery' import { initialAgentTabViewModeProps } from '@/lib/native-chat-initial-view-mode' import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' @@ -271,7 +271,7 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI const tab = store.createTab(worktreeId, groupId, undefined, { launchAgent: agent, quickCommandLabel, - ...initialAgentTabViewModeProps(store.settings) + ...initialAgentTabViewModeProps(store.settings, { agent, promptDelivery }) }) store.queueTabStartupCommand(tab.id, { command: startupPlan.launchCommand, @@ -302,7 +302,7 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI // don't fire for user-initiated cancellation (mirrors the 5s launch // watchdog in QuickLaunchButton). const tabId = tab.id - void pasteDraftWhenAgentReady({ + void deliverLaunchPromptToAgentTab({ tabId, content: pasteDraftAfterLaunch, agent, diff --git a/src/renderer/src/lib/launch-work-item-direct-agent.ts b/src/renderer/src/lib/launch-work-item-direct-agent.ts index c4f681fd89d..6e259f9a2e1 100644 --- a/src/renderer/src/lib/launch-work-item-direct-agent.ts +++ b/src/renderer/src/lib/launch-work-item-direct-agent.ts @@ -1,5 +1,5 @@ import { toast } from 'sonner' -import { pasteDraftWhenAgentReady } from '@/lib/agent-paste-draft' +import { deliverLaunchPromptToAgentTab } from '@/lib/agent-launch-prompt-delivery' import { track, tuiAgentToAgentKind } from '@/lib/telemetry' import { buildAgentDraftLaunchPlan, @@ -144,7 +144,7 @@ export async function pasteDirectWorkItemDraftWhenAgentReady(args: { forcePaste?: boolean }): Promise { const { primaryTabId, startupPlan, content, submit = false, forcePaste = false } = args - await pasteDraftWhenAgentReady({ + await deliverLaunchPromptToAgentTab({ tabId: primaryTabId, content, agent: startupPlan.agent, diff --git a/src/renderer/src/lib/launch-work-item-direct.test.ts b/src/renderer/src/lib/launch-work-item-direct.test.ts index 98913111af8..e11e7414d8d 100644 --- a/src/renderer/src/lib/launch-work-item-direct.test.ts +++ b/src/renderer/src/lib/launch-work-item-direct.test.ts @@ -10,6 +10,8 @@ const mocks = vi.hoisted(() => ({ ensureRemoteDetectedAgents: vi.fn(), updateWorktreeMeta: vi.fn(), setSidebarOpen: vi.fn(), + seedNativeChatLaunchPrompt: vi.fn(), + markNativeChatLaunchPromptFailed: vi.fn(), activateAndRevealWorktree: vi.fn(), pasteDraftWhenAgentReady: vi.fn(), openModalFallback: vi.fn(), @@ -21,6 +23,8 @@ const mocks = vi.hoisted(() => ({ createWorktree: ReturnType updateWorktreeMeta: ReturnType setSidebarOpen: ReturnType + seedNativeChatLaunchPrompt: ReturnType + markNativeChatLaunchPromptFailed: ReturnType } })) @@ -185,7 +189,9 @@ describe('launchWorkItemDirect', () => { ensureRemoteDetectedAgents: mocks.ensureRemoteDetectedAgents, createWorktree: mocks.createWorktree, updateWorktreeMeta: mocks.updateWorktreeMeta, - setSidebarOpen: mocks.setSidebarOpen + setSidebarOpen: mocks.setSidebarOpen, + seedNativeChatLaunchPrompt: mocks.seedNativeChatLaunchPrompt, + markNativeChatLaunchPromptFailed: mocks.markNativeChatLaunchPromptFailed } as typeof mocks.store // @ts-expect-error -- test shim globalThis.window = { api: mockApi } @@ -414,13 +420,21 @@ describe('launchWorkItemDirect', () => { ).resolves.toBe(true) expect(buildAgentDraftLaunchPlan).not.toHaveBeenCalled() - expect(pasteDraftWhenAgentReady).toHaveBeenCalledWith({ + expect(pasteDraftWhenAgentReady).toHaveBeenCalledWith( + expect.objectContaining({ + tabId: 'tab-1', + content: 'Use this explicit user prompt.', + agent: 'claude', + submit: true, + forcePaste: true, + onTimeout: expect.any(Function) + }) + ) + expect(mocks.seedNativeChatLaunchPrompt).toHaveBeenCalledWith({ tabId: 'tab-1', - content: 'Use this explicit user prompt.', agent: 'claude', - submit: true, - forcePaste: true, - onTimeout: expect.any(Function) + text: 'Use this explicit user prompt.', + createdAt: expect.any(Number) }) }) diff --git a/src/renderer/src/lib/native-chat-initial-view-mode.test.ts b/src/renderer/src/lib/native-chat-initial-view-mode.test.ts index 496ff80b713..bc8d1553f2b 100644 --- a/src/renderer/src/lib/native-chat-initial-view-mode.test.ts +++ b/src/renderer/src/lib/native-chat-initial-view-mode.test.ts @@ -6,33 +6,84 @@ import { describe('decideInitialAgentTabViewMode', () => { it("returns 'chat' when native chat and the opt-in default setting are on", () => { - expect(decideInitialAgentTabViewMode(true, true)).toBe('chat') + expect( + decideInitialAgentTabViewMode({ + experimentalNativeChat: true, + openAgentTabsInChatByDefault: true, + agent: 'codex' + }) + ).toBe('chat') }) it('returns undefined when native chat is disabled', () => { - expect(decideInitialAgentTabViewMode(false, true)).toBeUndefined() + expect( + decideInitialAgentTabViewMode({ + experimentalNativeChat: false, + openAgentTabsInChatByDefault: true, + agent: 'codex' + }) + ).toBeUndefined() }) it('returns undefined when the default-chat setting is off', () => { - expect(decideInitialAgentTabViewMode(true, false)).toBeUndefined() + expect( + decideInitialAgentTabViewMode({ + experimentalNativeChat: true, + openAgentTabsInChatByDefault: false, + agent: 'codex' + }) + ).toBeUndefined() }) it('returns undefined when the setting is missing (legacy settings)', () => { - expect(decideInitialAgentTabViewMode(true, undefined)).toBeUndefined() + expect( + decideInitialAgentTabViewMode({ + experimentalNativeChat: true, + openAgentTabsInChatByDefault: undefined, + agent: 'codex' + }) + ).toBeUndefined() + }) + + it('returns undefined for unsupported agents', () => { + expect( + decideInitialAgentTabViewMode({ + experimentalNativeChat: true, + openAgentTabsInChatByDefault: true, + agent: 'grok' + }) + ).toBeUndefined() + }) + + it('returns undefined for draft delivery', () => { + expect( + decideInitialAgentTabViewMode({ + experimentalNativeChat: true, + openAgentTabsInChatByDefault: true, + agent: 'claude', + promptDelivery: 'draft' + }) + ).toBeUndefined() }) it('returns tab creation props only when chat should be the initial mode', () => { expect( - initialAgentTabViewModeProps({ - experimentalNativeChat: true, - openAgentTabsInChatByDefault: true - }) + initialAgentTabViewModeProps( + { + experimentalNativeChat: true, + openAgentTabsInChatByDefault: true + }, + { agent: 'claude' } + ) ).toEqual({ viewMode: 'chat' }) expect( - initialAgentTabViewModeProps({ - experimentalNativeChat: false, - openAgentTabsInChatByDefault: true - }) + initialAgentTabViewModeProps( + { + experimentalNativeChat: false, + openAgentTabsInChatByDefault: true + }, + { agent: 'claude' } + ) ).toEqual({}) }) }) diff --git a/src/renderer/src/lib/native-chat-initial-view-mode.ts b/src/renderer/src/lib/native-chat-initial-view-mode.ts index 8f83467a808..a79b60962e9 100644 --- a/src/renderer/src/lib/native-chat-initial-view-mode.ts +++ b/src/renderer/src/lib/native-chat-initial-view-mode.ts @@ -1,29 +1,49 @@ -import type { GlobalSettings, Tab } from '../../../shared/types' +import type { GlobalSettings, Tab, TuiAgent } from '../../../shared/types' +import { isNativeChatSupportedAgent } from '@/lib/native-chat-supported-agent' + +export type NativeChatLaunchPromptDelivery = 'auto-submit' | 'draft' | 'submit-after-ready' /** * Decide the initial `viewMode` for a newly launched agent tab from the * opt-in `openAgentTabsInChatByDefault` setting. * - * Returns `'chat'` only when the setting is explicitly on; otherwise returns - * `undefined` so the tab keeps the implicit default (`'terminal'`) and stays - * backward-compatible with tabs persisted before the setting existed. A pure - * function so the decision can be unit-tested without the store or launch path. + * Returns `'chat'` only when the setting is explicitly on and the launched + * agent has a native-chat renderer. Draft launches stay in the terminal because + * their prompt exists only in the TUI input buffer. */ -export function decideInitialAgentTabViewMode( - experimentalNativeChat: boolean | undefined, - openAgentTabsInChatByDefault: boolean | undefined -): Tab['viewMode'] { - return experimentalNativeChat === true && openAgentTabsInChatByDefault === true - ? 'chat' - : undefined +export function decideInitialAgentTabViewMode(args: { + experimentalNativeChat?: boolean + openAgentTabsInChatByDefault?: boolean + agent?: TuiAgent | null + promptDelivery?: NativeChatLaunchPromptDelivery +}): Tab['viewMode'] { + if (args.experimentalNativeChat !== true || args.openAgentTabsInChatByDefault !== true) { + return undefined + } + if (!isNativeChatSupportedAgent(args.agent)) { + return undefined + } + if (args.promptDelivery === 'draft') { + return undefined + } + return 'chat' } export function initialAgentTabViewModeProps( - settings: Pick | null + settings: + | Pick + | null + | undefined, + options: { + agent?: TuiAgent | null + promptDelivery?: NativeChatLaunchPromptDelivery + } = {} ): { viewMode?: Tab['viewMode'] } { - const viewMode = decideInitialAgentTabViewMode( - settings?.experimentalNativeChat, - settings?.openAgentTabsInChatByDefault - ) + const viewMode = decideInitialAgentTabViewMode({ + experimentalNativeChat: settings?.experimentalNativeChat, + openAgentTabsInChatByDefault: settings?.openAgentTabsInChatByDefault, + agent: options.agent, + promptDelivery: options.promptDelivery + }) return viewMode ? { viewMode } : {} } diff --git a/src/renderer/src/lib/native-chat-launch-prompt.ts b/src/renderer/src/lib/native-chat-launch-prompt.ts new file mode 100644 index 00000000000..270a27f7a5b --- /dev/null +++ b/src/renderer/src/lib/native-chat-launch-prompt.ts @@ -0,0 +1,9 @@ +import type { TuiAgent } from '../../../shared/types' + +export type NativeChatLaunchPrompt = { + tabId: string + agent: TuiAgent + text: string + createdAt: number + failed?: boolean +} diff --git a/src/renderer/src/lib/native-chat-supported-agent.ts b/src/renderer/src/lib/native-chat-supported-agent.ts new file mode 100644 index 00000000000..ec24cd3968d --- /dev/null +++ b/src/renderer/src/lib/native-chat-supported-agent.ts @@ -0,0 +1,15 @@ +import type { AgentType } from '../../../shared/agent-status-types' +import type { TuiAgent } from '../../../shared/types' + +/** Agents whose transcripts the native chat view can parse and render. */ +export const NATIVE_CHAT_SUPPORTED_AGENTS: ReadonlySet = new Set([ + 'claude', + 'openclaude', + 'codex' +]) + +export function isNativeChatSupportedAgent( + agent: TuiAgent | AgentType | null | undefined +): boolean { + return agent != null && NATIVE_CHAT_SUPPORTED_AGENTS.has(agent) +} diff --git a/src/renderer/src/lib/worktree-activation.test.ts b/src/renderer/src/lib/worktree-activation.test.ts index a1937bbe8e2..1fbe25359bb 100644 --- a/src/renderer/src/lib/worktree-activation.test.ts +++ b/src/renderer/src/lib/worktree-activation.test.ts @@ -421,6 +421,32 @@ describe('ensureWorktreeHasInitialTerminal', () => { }) }) + it('keeps draft startup payloads in terminal mode even when native chat is configured', () => { + const store = createMockStore({ + settings: { + experimentalNativeChat: true, + openAgentTabsInChatByDefault: true + } + }) + + ensureWorktreeHasInitialTerminal( + store, + 'wt-1', + { + command: 'claude', + launchAgent: 'claude', + draftPrompt: 'Review before sending' + }, + undefined, + undefined + ) + + expect(store.createTab).toHaveBeenCalledWith('wt-1', undefined, undefined, { + pendingActivationSpawn: true, + launchAgent: 'claude' + }) + }) + it('gates startup behind setup completion when both are provided in new-tab mode', () => { setSetupScriptLaunchMode('new-tab') let createdIndex = 0 diff --git a/src/renderer/src/lib/worktree-activation.ts b/src/renderer/src/lib/worktree-activation.ts index 840361c651b..924c52dd640 100644 --- a/src/renderer/src/lib/worktree-activation.ts +++ b/src/renderer/src/lib/worktree-activation.ts @@ -559,7 +559,13 @@ export function ensureWorktreeHasInitialTerminal( const terminalTab = store.createTab(worktreeId, undefined, undefined, { pendingActivationSpawn: true, ...(launchAgent - ? { launchAgent, ...initialAgentTabViewModeProps(store.settings ?? null) } + ? { + launchAgent, + ...initialAgentTabViewModeProps(store.settings ?? null, { + agent: launchAgent, + promptDelivery: sequencedStartup?.draftPrompt != null ? 'draft' : undefined + }) + } : {}), ...(opts?.activateCreatedTabs === false ? { activate: false } : {}) }) @@ -618,7 +624,13 @@ function applyDefaultTerminalTabs( pendingActivationSpawn: true, recordInteraction: false, ...(launchAgent - ? { launchAgent, ...initialAgentTabViewModeProps(store.settings ?? null) } + ? { + launchAgent, + ...initialAgentTabViewModeProps(store.settings ?? null, { + agent: launchAgent, + promptDelivery: isStartupTab && startup?.draftPrompt != null ? 'draft' : undefined + }) + } : {}), ...(opts?.activateCreatedTabs === false ? { activate: false } : {}) }) diff --git a/src/renderer/src/store/slices/terminal-orphan-helpers.ts b/src/renderer/src/store/slices/terminal-orphan-helpers.ts index b6ffe73f902..cc5fb47be28 100644 --- a/src/renderer/src/store/slices/terminal-orphan-helpers.ts +++ b/src/renderer/src/store/slices/terminal-orphan-helpers.ts @@ -18,6 +18,7 @@ type OrphanTerminalCleanupState = Pick< | 'pendingSetupSplitByTabId' | 'pendingIssueCommandSplitByTabId' | 'automaticAgentResumeClaimsByTabId' + | 'nativeChatLaunchPromptByTabId' | 'tabBarOrderByWorktree' | 'cacheTimerByKey' | 'activeTabIdByWorktree' @@ -65,6 +66,7 @@ export function buildOrphanTerminalCleanupPatch( | 'pendingSetupSplitByTabId' | 'pendingIssueCommandSplitByTabId' | 'automaticAgentResumeClaimsByTabId' + | 'nativeChatLaunchPromptByTabId' | 'tabBarOrderByWorktree' | 'cacheTimerByKey' | 'activeTabIdByWorktree' @@ -83,6 +85,7 @@ export function buildOrphanTerminalCleanupPatch( pendingSetupSplitByTabId: state.pendingSetupSplitByTabId, pendingIssueCommandSplitByTabId: state.pendingIssueCommandSplitByTabId, automaticAgentResumeClaimsByTabId: state.automaticAgentResumeClaimsByTabId, + nativeChatLaunchPromptByTabId: state.nativeChatLaunchPromptByTabId, tabBarOrderByWorktree: state.tabBarOrderByWorktree, cacheTimerByKey: state.cacheTimerByKey, activeTabIdByWorktree: state.activeTabIdByWorktree, @@ -105,6 +108,7 @@ export function buildOrphanTerminalCleanupPatch( const nextAutomaticAgentResumeClaimsByTabId = { ...state.automaticAgentResumeClaimsByTabId } + const nextNativeChatLaunchPromptByTabId = { ...state.nativeChatLaunchPromptByTabId } const nextTabBarOrderByWorktree = { ...state.tabBarOrderByWorktree, [worktreeId]: (state.tabBarOrderByWorktree[worktreeId] ?? []).filter( @@ -128,6 +132,7 @@ export function buildOrphanTerminalCleanupPatch( delete nextPendingSetupSplitByTabId[orphanTabId] delete nextPendingIssueCommandSplitByTabId[orphanTabId] delete nextAutomaticAgentResumeClaimsByTabId[orphanTabId] + delete nextNativeChatLaunchPromptByTabId[orphanTabId] for (const key of Object.keys(nextCacheTimerByKey)) { if (key.startsWith(`${orphanTabId}:`)) { delete nextCacheTimerByKey[key] @@ -157,6 +162,7 @@ export function buildOrphanTerminalCleanupPatch( pendingSetupSplitByTabId: nextPendingSetupSplitByTabId, pendingIssueCommandSplitByTabId: nextPendingIssueCommandSplitByTabId, automaticAgentResumeClaimsByTabId: nextAutomaticAgentResumeClaimsByTabId, + nativeChatLaunchPromptByTabId: nextNativeChatLaunchPromptByTabId, tabBarOrderByWorktree: nextTabBarOrderByWorktree, cacheTimerByKey: nextCacheTimerByKey, activeTabIdByWorktree: nextActiveTabIdByWorktree, diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index 559e06106e5..9af11f13a46 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -64,6 +64,7 @@ import { sanitizeTerminalLayoutPaneTitles } from '@/lib/terminal-pane-title-sani import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface' import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' +import type { NativeChatLaunchPrompt } from '@/lib/native-chat-launch-prompt' import { collectHibernatedCompletionEvidenceForWorktree, collectSleepingAgentSessionRecordsForWorktree, @@ -340,6 +341,11 @@ export type TerminalSlice = { * bridges the gap after startup payload consumption and before hooks go live. */ automaticAgentResumeClaimsByTabId: Record claimAutomaticAgentResume: (tabId: string, claim: AutomaticAgentResumeClaim) => void + /** Launch-time native-chat prompt echo, keyed by terminal tab. In-memory only. */ + nativeChatLaunchPromptByTabId: Record + seedNativeChatLaunchPrompt: (prompt: NativeChatLaunchPrompt) => void + markNativeChatLaunchPromptFailed: (tabId: string) => void + clearNativeChatLaunchPrompt: (tabId: string) => void pendingStartupByTabId: Record< string, { @@ -599,6 +605,7 @@ export const createTerminalSlice: StateCreator pendingSetupSplitByTabId: {}, pendingIssueCommandSplitByTabId: {}, automaticAgentResumeClaimsByTabId: {}, + nativeChatLaunchPromptByTabId: {}, tabBarOrderByWorktree: {}, workspaceSessionReady: false, defaultTerminalTabsAppliedByWorktreeId: {}, @@ -648,6 +655,41 @@ export const createTerminalSlice: StateCreator })) }, + seedNativeChatLaunchPrompt: (prompt) => { + set((s) => ({ + nativeChatLaunchPromptByTabId: { + ...s.nativeChatLaunchPromptByTabId, + [prompt.tabId]: prompt + } + })) + }, + + markNativeChatLaunchPromptFailed: (tabId) => { + set((s) => { + const current = s.nativeChatLaunchPromptByTabId[tabId] + if (!current || current.failed) { + return {} + } + return { + nativeChatLaunchPromptByTabId: { + ...s.nativeChatLaunchPromptByTabId, + [tabId]: { ...current, failed: true } + } + } + }) + }, + + clearNativeChatLaunchPrompt: (tabId) => { + set((s) => { + if (!s.nativeChatLaunchPromptByTabId[tabId]) { + return {} + } + const next = { ...s.nativeChatLaunchPromptByTabId } + delete next[tabId] + return { nativeChatLaunchPromptByTabId: next } + }) + }, + recordTerminalInput: (paneKey, timestamp = Date.now()) => { if (!paneKey || !Number.isFinite(timestamp)) { return @@ -1052,6 +1094,8 @@ export const createTerminalSlice: StateCreator delete nextPendingStartupByTabId[tabId] const nextAutomaticAgentResumeClaimsByTabId = { ...s.automaticAgentResumeClaimsByTabId } delete nextAutomaticAgentResumeClaimsByTabId[tabId] + const nextNativeChatLaunchPromptByTabId = { ...s.nativeChatLaunchPromptByTabId } + delete nextNativeChatLaunchPromptByTabId[tabId] const nextPendingInitialCwdByTabId = { ...s.pendingInitialCwdByTabId } delete nextPendingInitialCwdByTabId[tabId] const nextPendingSetupSplitByTabId = { ...s.pendingSetupSplitByTabId } @@ -1129,6 +1173,7 @@ export const createTerminalSlice: StateCreator terminalLayoutsByTabId: nextLayouts, pendingStartupByTabId: nextPendingStartupByTabId, automaticAgentResumeClaimsByTabId: nextAutomaticAgentResumeClaimsByTabId, + nativeChatLaunchPromptByTabId: nextNativeChatLaunchPromptByTabId, pendingInitialCwdByTabId: nextPendingInitialCwdByTabId, pendingSetupSplitByTabId: nextPendingSetupSplitByTabId, pendingIssueCommandSplitByTabId: nextPendingIssueCommandSplitByTabId, diff --git a/src/renderer/src/store/slices/worktree-removal-maps-leak.test.ts b/src/renderer/src/store/slices/worktree-removal-maps-leak.test.ts index e603ae94740..fc00b3082ac 100644 --- a/src/renderer/src/store/slices/worktree-removal-maps-leak.test.ts +++ b/src/renderer/src/store/slices/worktree-removal-maps-leak.test.ts @@ -188,6 +188,73 @@ describe('worktree removal evicts the per-worktree + per-page maps it previously expect(getAgentHibernationPaneOutputEpoch(survivingPaneKey)).toBe(1) }) + it('bulk purgeWorktreeTerminalState drops native-chat launch prompts for removed tabs only', () => { + const store = createTestStore() + const TAB1 = 'tab-wt1' + const TAB2 = 'tab-wt2' + seedStore(store, { + worktreesByRepo: { + repo1: [ + makeWorktree({ id: WT1, repoId: 'repo1', path: '/path/wt1' }), + makeWorktree({ id: WT2, repoId: 'repo1', path: '/path/wt2' }) + ] + }, + tabsByWorktree: { + [WT1]: [makeTab({ id: TAB1, worktreeId: WT1 })], + [WT2]: [makeTab({ id: TAB2, worktreeId: WT2 })] + }, + nativeChatLaunchPromptByTabId: { + [TAB1]: { tabId: TAB1, agent: 'codex', text: 'fix wt1', createdAt: 1 }, + [TAB2]: { tabId: TAB2, agent: 'codex', text: 'fix wt2', createdAt: 2 } + } + }) + + store.getState().purgeWorktreeTerminalState([WT1]) + + const s = store.getState() + expect(s.nativeChatLaunchPromptByTabId[TAB1]).toBeUndefined() + expect(s.nativeChatLaunchPromptByTabId[TAB2]).toEqual({ + tabId: TAB2, + agent: 'codex', + text: 'fix wt2', + createdAt: 2 + }) + }) + + it('single removeWorktree drops native-chat launch prompts for removed tabs only', async () => { + const store = createTestStore() + const TAB1 = 'tab-wt1' + const TAB2 = 'tab-wt2' + seedStore(store, { + worktreesByRepo: { + repo1: [ + makeWorktree({ id: WT1, repoId: 'repo1', path: '/path/wt1' }), + makeWorktree({ id: WT2, repoId: 'repo1', path: '/path/wt2' }) + ] + }, + tabsByWorktree: { + [WT1]: [makeTab({ id: TAB1, worktreeId: WT1 })], + [WT2]: [makeTab({ id: TAB2, worktreeId: WT2 })] + }, + nativeChatLaunchPromptByTabId: { + [TAB1]: { tabId: TAB1, agent: 'claude', text: 'fix wt1', createdAt: 1 }, + [TAB2]: { tabId: TAB2, agent: 'claude', text: 'fix wt2', createdAt: 2 } + } + }) + + const result = await store.getState().removeWorktree(WT1) + + expect(result).toEqual({ ok: true }) + const s = store.getState() + expect(s.nativeChatLaunchPromptByTabId[TAB1]).toBeUndefined() + expect(s.nativeChatLaunchPromptByTabId[TAB2]).toEqual({ + tabId: TAB2, + agent: 'claude', + text: 'fix wt2', + createdAt: 2 + }) + }) + it('bulk purgeWorktreeTerminalState drops page/workspace-keyed browser maps for the removed worktree only', () => { const store = createTestStore() const WS1 = 'ws-1' diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index 2ddc8100aca..0eb99b91b55 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -1969,6 +1969,7 @@ function buildWorktreePurgeState(s: AppState, worktreeIds: string[]): Partial const nextAutomaticAgentResumeClaimsByTabId = { ...s.automaticAgentResumeClaimsByTabId } + const nextNativeChatLaunchPromptByTabId = { ...s.nativeChatLaunchPromptByTabId } for (const tabId of tabIds) { delete nextLayouts[tabId] delete nextPtyIdsByTabId[tabId] delete nextRuntimePaneTitlesByTabId[tabId] delete nextAutomaticAgentResumeClaimsByTabId[tabId] + delete nextNativeChatLaunchPromptByTabId[tabId] } const nextDeleteState = { ...s.deleteStateByWorktreeId } delete nextDeleteState[worktreeId] @@ -3205,6 +3208,7 @@ export const createWorktreeSlice: StateCreator ptyIdsByTabId: nextPtyIdsByTabId, runtimePaneTitlesByTabId: nextRuntimePaneTitlesByTabId, automaticAgentResumeClaimsByTabId: nextAutomaticAgentResumeClaimsByTabId, + nativeChatLaunchPromptByTabId: nextNativeChatLaunchPromptByTabId, terminalLayoutsByTabId: nextLayouts, deleteStateByWorktreeId: nextDeleteState, baseStatusByWorktreeId: (() => {