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 1/3] 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 | 73 ++++++++-- .../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, 771 insertions(+), 77 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 b447a4287c..313845c203 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 ba8f877c43..9ab7c8e630 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 ef12785b0e..4bc1dcd98d 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 0b51051413..1553cdc913 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 c981fe806c..5083ab0c51 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 67bfb290ae..9e82f52b92 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 ddb4ac7fd4..1d48780832 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 c0ab880a9e..21742cf0e3 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 2fd3e185eb..511c344f97 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 d004befd43..7276f765be 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 04c946b7f2..77bb1e33f4 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 f55504f64f..88a7d4cf23 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 88a2b63808..897a993208 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 0000000000..5ea7aacda6 --- /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 0000000000..fc56f81d0f --- /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 4b3c18d968..73e72df1fa 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 41ded040d4..f815d450c9 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 c4f681fd89..6e259f9a2e 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 98913111af..e11e7414d8 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 496ff80b71..bc8d1553f2 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 tab creation props only when chat should be the initial mode', () => { + it('returns undefined for unsupported agents', () => { expect( - initialAgentTabViewModeProps({ + decideInitialAgentTabViewMode({ experimentalNativeChat: true, - openAgentTabsInChatByDefault: true + openAgentTabsInChatByDefault: true, + agent: 'grok' }) - ).toEqual({ viewMode: 'chat' }) + ).toBeUndefined() + }) + + it('returns undefined for draft delivery', () => { expect( - initialAgentTabViewModeProps({ - experimentalNativeChat: false, - openAgentTabsInChatByDefault: true + 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 + }, + { agent: 'claude' } + ) + ).toEqual({ viewMode: 'chat' }) + expect( + 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 8f83467a80..a79b60962e 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 0000000000..270a27f7a5 --- /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 0000000000..ec24cd3968 --- /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 a1937bbe8e..1fbe25359b 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 840361c651..924c52dd64 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 b6ffe73f90..cc5fb47be2 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 559e06106e..9af11f13a4 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 e603ae9474..fc00b3082a 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 2ddc8100ac..0eb99b91b5 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: (() => { From 287bcad89fe021f3977fb1d5136c699ce960e8c3 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sun, 5 Jul 2026 14:44:21 -0700 Subject: [PATCH 2/3] Support native-prefill prompt delivery and fix chat message sorting * Treat native draft pre-fills as successful deliveries instead of marking the seeded launch prompts as failed. * Group native chat messages into sorting tiers (real content, streaming preview, optimistic echoes) so optimistic bubbles don't sort past the streaming preview due to finite timestamps. --- .../native-chat-message-grouping.test.ts | 8 +++--- .../native-chat/native-chat-pending.ts | 3 ++ .../native-chat-session-assembler.ts | 28 +++++++++++++------ .../lib/agent-launch-prompt-delivery.test.ts | 19 +++++++++++++ .../src/lib/agent-launch-prompt-delivery.ts | 10 +++++-- .../src/lib/agent-native-draft-prefill.ts | 17 +++++++++++ src/renderer/src/lib/agent-paste-draft.ts | 5 ++-- 7 files changed, 74 insertions(+), 16 deletions(-) create mode 100644 src/renderer/src/lib/agent-native-draft-prefill.ts 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 1553cdc913..7df292d5ed 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 @@ -33,13 +33,13 @@ describe('orderNativeChatMessages', () => { expect(ordered.map((m) => m.id)).toEqual(['a', 'z']) }) - it('sorts the synthetic streaming message after timestamped content', () => { + it('sorts the streaming preview after real content but before optimistic echoes', () => { const ordered = orderNativeChatMessages([ + msg({ id: 'pending:abc', role: 'user', timestamp: 20, source: 'scrape' }), 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' }) + msg({ id: 'real-user', role: 'user', timestamp: 10 }) ]) - expect(ordered.map((m) => m.id)).toEqual(['real-user', 'pending-user', 'streaming']) + expect(ordered.map((m) => m.id)).toEqual(['real-user', 'streaming', 'pending:abc']) }) }) 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 9e82f52b92..e76349a9a6 100644 --- a/src/renderer/src/components/native-chat/native-chat-pending.ts +++ b/src/renderer/src/components/native-chat/native-chat-pending.ts @@ -153,6 +153,9 @@ export function isPendingMessageId(id: string): boolean { return id.startsWith('pending:') } +// Why: the seeded prompt has a synthetic id that never matches the real turn's, +// so dedup/prune match on normalized user-message text instead — this hides the +// optimistic bubble once the transcript's own copy of the turn catches up. export function launchPromptAsMessage( entry: NativeChatLaunchPrompt | null, existingMessages: NativeChatMessage[] = [] 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 1d48780832..53ebbd0084 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 @@ -8,6 +8,7 @@ import { } from '../../../../shared/native-chat-types' import { NATIVE_CHAT_STREAMING_ID } from '../../../../shared/native-chat-streaming' import { normalizeImageTranscriptMessages } from './native-chat-image-transcript-markers' +import { isLaunchPromptMessageId, isPendingMessageId } from './native-chat-pending' /** Messages grouped by source. Higher-priority sources (transcript > hook > * scrape) supersede lower ones when they describe the same turn. */ @@ -85,20 +86,31 @@ function supersedes(candidate: NativeChatMessage, existing: NativeChatMessage): return candidateRank > existingRank } -function messageSortTimestamp(message: NativeChatMessage): number { +// Why: the tail bubbles form fixed tiers that timestamps alone can't express. +// The streaming preview (null timestamp) must follow real content but sit ahead +// of the optimistic composer echoes, which carry finite `sentAt` timestamps that +// would otherwise sort past it. Rank first, then timestamp within a tier. +function messageSortRank(message: NativeChatMessage): number { if (message.id === NATIVE_CHAT_STREAMING_ID) { - return Number.POSITIVE_INFINITY + return 1 + } + if (isPendingMessageId(message.id) || isLaunchPromptMessageId(message.id)) { + return 2 } - return message.timestamp ?? Number.NEGATIVE_INFINITY + return 0 } // 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. 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. +// sort before any real timestamp within their tier so they don't jump to the +// end. Ties break on id for a stable, deterministic order. export function compareMessages(a: NativeChatMessage, b: NativeChatMessage): number { - const at = messageSortTimestamp(a) - const bt = messageSortTimestamp(b) + const ar = messageSortRank(a) + const br = messageSortRank(b) + if (ar !== br) { + return ar - br + } + const at = a.timestamp ?? Number.NEGATIVE_INFINITY + const bt = b.timestamp ?? Number.NEGATIVE_INFINITY if (at !== bt) { return at - bt } diff --git a/src/renderer/src/lib/agent-launch-prompt-delivery.test.ts b/src/renderer/src/lib/agent-launch-prompt-delivery.test.ts index 5ea7aacda6..577db1e534 100644 --- a/src/renderer/src/lib/agent-launch-prompt-delivery.test.ts +++ b/src/renderer/src/lib/agent-launch-prompt-delivery.test.ts @@ -97,6 +97,25 @@ describe('deliverLaunchPromptToAgentTab', () => { expect(mocks.markNativeChatLaunchPromptFailed).toHaveBeenCalledWith('tab-1') }) + it('treats native-prefill delivery as success without flagging the seeded prompt', async () => { + // claude delivers via `--prefill` at launch, so paste no-ops (returns false) + // when forcePaste is false — that is a native delivery, not a failure. + mocks.pasteDraftWhenAgentReady.mockResolvedValue(false) + + await expect( + deliverLaunchPromptToAgentTab({ + tabId: 'tab-1', + agent: 'claude', + content: 'Large generated prompt', + submit: true, + forcePaste: false + }) + ).resolves.toBe(true) + + expect(mocks.seedNativeChatLaunchPrompt).toHaveBeenCalled() + expect(mocks.markNativeChatLaunchPromptFailed).not.toHaveBeenCalled() + }) + it('does not mark unseeded launches failed', async () => { mocks.pasteDraftWhenAgentReady.mockResolvedValue(false) diff --git a/src/renderer/src/lib/agent-launch-prompt-delivery.ts b/src/renderer/src/lib/agent-launch-prompt-delivery.ts index fc56f81d0f..1569c5f1a9 100644 --- a/src/renderer/src/lib/agent-launch-prompt-delivery.ts +++ b/src/renderer/src/lib/agent-launch-prompt-delivery.ts @@ -1,3 +1,4 @@ +import { agentDeliversDraftViaNativePrefill } from '@/lib/agent-native-draft-prefill' import { pasteDraftWhenAgentReady } from '@/lib/agent-paste-draft' import { isNativeChatSupportedAgent } from '@/lib/native-chat-supported-agent' import { useAppStore } from '@/store' @@ -25,6 +26,11 @@ export function deliverLaunchPromptToAgentTab(args: { }) } + // Why: native-prefill agents (claude/openclaude etc.) get the prompt at launch, + // so pasteDraftWhenAgentReady returns false without pasting. That is a successful + // native delivery, not a failure — don't flag the seeded bubble in that case. + const deliversViaNativePrefill = agentDeliversDraftViaNativePrefill(agent, forcePaste) + return pasteDraftWhenAgentReady({ tabId, content, @@ -34,9 +40,9 @@ export function deliverLaunchPromptToAgentTab(args: { timeoutMs, onTimeout }).then((delivered) => { - if (shouldSeed && !delivered) { + if (shouldSeed && !delivered && !deliversViaNativePrefill) { useAppStore.getState().markNativeChatLaunchPromptFailed(tabId) } - return delivered + return delivered || deliversViaNativePrefill }) } diff --git a/src/renderer/src/lib/agent-native-draft-prefill.ts b/src/renderer/src/lib/agent-native-draft-prefill.ts new file mode 100644 index 0000000000..797b25e782 --- /dev/null +++ b/src/renderer/src/lib/agent-native-draft-prefill.ts @@ -0,0 +1,17 @@ +import type { TuiAgent } from '../../../shared/types' +import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config' + +// Why: agents with a native draft-prefill flag/env launch with the prompt +// already in their input box, so the paste helpers intentionally no-op (return +// false) unless `forcePaste` overrides. Callers use this to tell "delivered +// natively" apart from a real paste failure. +export function agentDeliversDraftViaNativePrefill( + agent: TuiAgent | undefined, + forcePaste: boolean | undefined +): boolean { + if (forcePaste) { + return false + } + const agentConfig = agent ? TUI_AGENT_CONFIG[agent] : null + return Boolean(agentConfig?.draftPromptFlag || agentConfig?.draftPromptEnvVar) +} diff --git a/src/renderer/src/lib/agent-paste-draft.ts b/src/renderer/src/lib/agent-paste-draft.ts index c840596de2..e924631088 100644 --- a/src/renderer/src/lib/agent-paste-draft.ts +++ b/src/renderer/src/lib/agent-paste-draft.ts @@ -14,6 +14,7 @@ import { waitForAgentReady } from './agent-ready-wait' import { getSettingsForWorktreeRuntimeOwner } from './worktree-runtime-owner' import type { GlobalSettings } from '../../../shared/types' import { sendAgentDraftPasteContent } from './agent-draft-paste-content' +import { agentDeliversDraftViaNativePrefill } from './agent-native-draft-prefill' import { waitForAgentDraftInputReady } from './agent-draft-readiness' import { isExpectedAgentProcess } from '../../../shared/agent-process-recognition' export { @@ -91,7 +92,7 @@ export async function pasteDraftWhenAgentReady(args: { // duplicate it. Callers should not invoke this helper for those agents; // the early return guards against accidental double-injection if a stale // call slips through. - if (!forcePaste && (agentConfig?.draftPromptFlag || agentConfig?.draftPromptEnvVar)) { + if (agentDeliversDraftViaNativePrefill(agent, forcePaste)) { return false } @@ -140,7 +141,7 @@ export async function pasteDraftToAgentPtyWhenReady(args: { const { tabId, ptyId, content, agent, submit, forcePaste, timeoutMs, onTimeout } = args const agentConfig = agent ? TUI_AGENT_CONFIG[agent] : null - if (!forcePaste && (agentConfig?.draftPromptFlag || agentConfig?.draftPromptEnvVar)) { + if (agentDeliversDraftViaNativePrefill(agent, forcePaste)) { return false } From 5ceb549cdbd56d1c9841d29dccdddf7d75a5216b Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sun, 5 Jul 2026 15:30:07 -0700 Subject: [PATCH 3/3] Avoid auto-opening native chat for draft prompt followups Followup paths paste the prompt as an unsubmitted draft. When prompt delivery is configured as 'auto-submit', this previously opened the native chat view with no actual submitted turn to render. By gating the initial view mode using 'draft' delivery on followup paths, we ensure the tab starts in terminal mode instead. --- .../native-chat/native-chat-availability.ts | 2 +- .../native-chat/native-chat-pending.ts | 3 + .../src/lib/launch-agent-in-new-tab.ts | 11 +++- .../src/lib/worktree-activation.test.ts | 55 +++++++++++++++++++ 4 files changed, 69 insertions(+), 2 deletions(-) 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 cccbb956f3..4e520af1b9 100644 --- a/src/renderer/src/components/native-chat/native-chat-availability.ts +++ b/src/renderer/src/components/native-chat/native-chat-availability.ts @@ -2,7 +2,7 @@ import type { Tab, TuiAgent } from '../../../../shared/types' import type { AgentType } from '../../../../shared/agent-status-types' import { isNativeChatSupportedAgent } from '@/lib/native-chat-supported-agent' -export { isNativeChatSupportedAgent } from '@/lib/native-chat-supported-agent' +export { isNativeChatSupportedAgent } /** 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-pending.ts b/src/renderer/src/components/native-chat/native-chat-pending.ts index e76349a9a6..3021dc7bba 100644 --- a/src/renderer/src/components/native-chat/native-chat-pending.ts +++ b/src/renderer/src/components/native-chat/native-chat-pending.ts @@ -176,6 +176,9 @@ export function launchPromptAsMessage( } } +// Why: prune only once an assistant turn has landed after the matching user +// text — keeping the optimistic bubble through the user-only phase avoids a +// first-turn flash before the transcript's own copy of the turn catches up. export function shouldPruneLaunchPrompt( entry: NativeChatLaunchPrompt, messages: NativeChatMessage[] 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 f815d450c9..42d577b4d1 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.ts @@ -268,10 +268,19 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI // lands after mount the agent binary never starts; the user sees a bare shell. // Since both calls happen synchronously in the same React batch, the queue // is in place by the time the pane commits. + // Why: the followup path pastes the prompt as an unsubmitted draft (submit + // stays false), so gate the initial chat view like a `draft` launch — + // otherwise a default `auto-submit` followup would open native chat with no + // submitted turn to render. + const viewModePromptDelivery = + hasPrompt && isFollowupPath && promptDelivery === 'auto-submit' ? 'draft' : promptDelivery const tab = store.createTab(worktreeId, groupId, undefined, { launchAgent: agent, quickCommandLabel, - ...initialAgentTabViewModeProps(store.settings, { agent, promptDelivery }) + ...initialAgentTabViewModeProps(store.settings, { + agent, + promptDelivery: viewModePromptDelivery + }) }) store.queueTabStartupCommand(tab.id, { command: startupPlan.launchCommand, diff --git a/src/renderer/src/lib/worktree-activation.test.ts b/src/renderer/src/lib/worktree-activation.test.ts index 107a573eee..53e6a5fe8e 100644 --- a/src/renderer/src/lib/worktree-activation.test.ts +++ b/src/renderer/src/lib/worktree-activation.test.ts @@ -495,6 +495,61 @@ describe('ensureWorktreeHasInitialTerminal', () => { }) }) + it('opens the startup default tab in native chat when configured', () => { + let createdIndex = 0 + const createTab = vi.fn(() => ({ id: `tab-${++createdIndex}` })) + const store = createMockStore({ + createTab, + settings: { + experimentalNativeChat: true, + openAgentTabsInChatByDefault: true + } + }) + + ensureWorktreeHasInitialTerminal( + store, + 'wt-1', + { command: 'claude', launchAgent: 'claude' }, + undefined, + undefined, + { runCommands: true, tabs: [{ title: 'Claude', command: 'claude' }] } + ) + + expect(createTab).toHaveBeenNthCalledWith(1, 'wt-1', undefined, undefined, { + pendingActivationSpawn: true, + recordInteraction: false, + launchAgent: 'claude', + viewMode: 'chat' + }) + }) + + it('keeps a draft startup default tab in terminal mode even when native chat is configured', () => { + let createdIndex = 0 + const createTab = vi.fn(() => ({ id: `tab-${++createdIndex}` })) + const store = createMockStore({ + createTab, + settings: { + experimentalNativeChat: true, + openAgentTabsInChatByDefault: true + } + }) + + ensureWorktreeHasInitialTerminal( + store, + 'wt-1', + { command: 'claude', launchAgent: 'claude', draftPrompt: 'Review before sending' }, + undefined, + undefined, + { runCommands: true, tabs: [{ title: 'Claude', command: 'claude' }] } + ) + + expect(createTab).toHaveBeenNthCalledWith(1, 'wt-1', undefined, undefined, { + pendingActivationSpawn: true, + recordInteraction: false, + launchAgent: 'claude' + }) + }) + it('gates startup behind setup completion when both are provided in new-tab mode', () => { setSetupScriptLaunchMode('new-tab') let createdIndex = 0