Render launch prompts in native chat and refine initial view (#7444)

* 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.

* 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.

* 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.
This commit is contained in:
Jinjing 2026-07-05 15:30:24 -07:00 committed by GitHub
parent 8aecc0bf93
commit 7953240135
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
31 changed files with 897 additions and 78 deletions

View File

@ -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<HTMLDivElement | null>(null)
const { prose, tools } = useMemo(() => splitNativeChatBlocks(message.blocks), [message.blocks])
@ -181,6 +183,14 @@ function MessageRow({
<ImageAttachmentRefs blocks={prose} />
)}
</div>
{deliveryFailed ? (
<div className="max-w-[85%] text-[11px] text-destructive/80">
{translate(
'components.native-chat.launchPromptNotDelivered',
'Not delivered — check the terminal'
)}
</div>
) : null}
</div>
)
}
@ -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<string>
}): React.JSX.Element {
const scrollRef = useRef<HTMLDivElement | null>(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 ? <TypingIndicatorRow /> : null}

View File

@ -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<NativeChatContextMenuActions, 'onPaste'>
}): 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<typeof session>(() => {
if (!launchPromptMessage) {
return session
}
return { ...session, messages: [...session.messages, launchPromptMessage] }
}, [launchPromptMessage, session])
const sessionAfterCommandBoundaries = useMemo<typeof session>(() => {
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}
/>
)}
</div>

View File

@ -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<string> = new Set<string>([
'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 }
/** 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

View File

@ -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<NativeChatMessage> & Pick<NativeChatMessage, 'id'>
@ -31,6 +32,15 @@ describe('orderNativeChatMessages', () => {
])
expect(ordered.map((m) => m.id)).toEqual(['a', 'z'])
})
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 })
])
expect(ordered.map((m) => m.id)).toEqual(['real-user', 'streaming', 'pending:abc'])
})
})
describe('buildNativeChatRenderItems', () => {

View File

@ -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 <cmd>" message', () => {
expect(commandMarkersAsMessages([{ id: 'c1', command: '/clear', sentAt: 7 }])).toEqual([

View File

@ -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,43 @@ 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[] = []
): 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
}
}
// 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[]
): 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. */

View File

@ -6,7 +6,9 @@ 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'
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. */
@ -84,10 +86,29 @@ function supersedes(candidate: NativeChatMessage, existing: NativeChatMessage):
return candidateRank > existingRank
}
// 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 1
}
if (isPendingMessageId(message.id) || isLaunchPromptMessageId(message.id)) {
return 2
}
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. 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 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) {

View File

@ -1424,7 +1424,9 @@ export function useIpcEvents(): void {
...(launchAgent
? {
launchAgent,
...initialAgentTabViewModeProps(store.settings)
...initialAgentTabViewModeProps(store.settings, {
agent: launchAgent
})
}
: {}),
...(cwd ? { startupCwd: cwd } : {}),
@ -1612,7 +1614,9 @@ export function useIpcEvents(): void {
? {
...(shouldActivate ? {} : { activate: false, recordInteraction: false }),
launchAgent: data.launchAgent,
...initialAgentTabViewModeProps(store.settings),
...initialAgentTabViewModeProps(store.settings, {
agent: data.launchAgent
}),
...(data.cwd ? { startupCwd: data.cwd } : {})
}
: shouldActivate

View File

@ -12448,7 +12448,8 @@
"title": "Allow {{value0}}?",
"allow": "Allow",
"deny": "Deny"
}
},
"launchPromptNotDelivered": "Not delivered — check the terminal"
},
"tab": {
"bar": {

View File

@ -12448,7 +12448,8 @@
"title": "¿Permitir {{value0}}?",
"allow": "Permitir",
"deny": "Denegar"
}
},
"launchPromptNotDelivered": "Not delivered — check the terminal"
},
"tab": {
"bar": {

View File

@ -12448,7 +12448,8 @@
"title": "{{value0}} を許可しますか?",
"allow": "許可する",
"deny": "拒否"
}
},
"launchPromptNotDelivered": "Not delivered — check the terminal"
},
"tab": {
"bar": {

View File

@ -12448,7 +12448,8 @@
"title": "{{value0}}을(를) 허용하시겠습니까?",
"allow": "허용하다",
"deny": "부인하다"
}
},
"launchPromptNotDelivered": "Not delivered — check the terminal"
},
"tab": {
"bar": {

View File

@ -12448,7 +12448,8 @@
"title": "允许 {{value0}}",
"allow": "允许",
"deny": "拒绝"
}
},
"launchPromptNotDelivered": "Not delivered — check the terminal"
},
"tab": {
"bar": {

View File

@ -0,0 +1,150 @@
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('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)
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 })
)
})
})

View File

@ -0,0 +1,48 @@
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'
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<boolean> {
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()
})
}
// 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,
agent,
submit,
forcePaste,
timeoutMs,
onTimeout
}).then((delivered) => {
if (shouldSeed && !delivered && !deliversViaNativePrefill) {
useAppStore.getState().markNativeChatLaunchPromptFailed(tabId)
}
return delivered || deliversViaNativePrefill
})
}

View File

@ -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)
}

View File

@ -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
}

View File

@ -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<string, string>,
agentDefaultEnv: {} as Record<string, Record<string, string>>,
activeRuntimeEnvironmentId: null as string | null
} as {
agentCmdOverrides: Record<string, string>
agentDefaultArgs: Record<string, string>
agentDefaultEnv: Record<string, Record<string, string>>
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',

View File

@ -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'
@ -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)
...initialAgentTabViewModeProps(store.settings, {
agent,
promptDelivery: viewModePromptDelivery
})
})
store.queueTabStartupCommand(tab.id, {
command: startupPlan.launchCommand,
@ -302,7 +311,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,

View File

@ -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<void> {
const { primaryTabId, startupPlan, content, submit = false, forcePaste = false } = args
await pasteDraftWhenAgentReady({
await deliverLaunchPromptToAgentTab({
tabId: primaryTabId,
content,
agent: startupPlan.agent,

View File

@ -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<typeof vi.fn>
updateWorktreeMeta: ReturnType<typeof vi.fn>
setSidebarOpen: ReturnType<typeof vi.fn>
seedNativeChatLaunchPrompt: ReturnType<typeof vi.fn>
markNativeChatLaunchPromptFailed: ReturnType<typeof vi.fn>
}
}))
@ -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 }
@ -449,13 +455,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)
})
})

View File

@ -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({})
})
})

View File

@ -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<GlobalSettings, 'experimentalNativeChat' | 'openAgentTabsInChatByDefault'> | null
settings:
| Pick<GlobalSettings, 'experimentalNativeChat' | 'openAgentTabsInChatByDefault'>
| 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 } : {}
}

View File

@ -0,0 +1,9 @@
import type { TuiAgent } from '../../../shared/types'
export type NativeChatLaunchPrompt = {
tabId: string
agent: TuiAgent
text: string
createdAt: number
failed?: boolean
}

View File

@ -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<string> = new Set<string>([
'claude',
'openclaude',
'codex'
])
export function isNativeChatSupportedAgent(
agent: TuiAgent | AgentType | null | undefined
): boolean {
return agent != null && NATIVE_CHAT_SUPPORTED_AGENTS.has(agent)
}

View File

@ -469,6 +469,87 @@ 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('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

View File

@ -578,7 +578,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 } : {})
})
@ -637,7 +643,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 } : {})
})

View File

@ -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,

View File

@ -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,
@ -370,6 +371,11 @@ export type TerminalSlice = {
* bridges the gap after startup payload consumption and before hooks go live. */
automaticAgentResumeClaimsByTabId: Record<string, AutomaticAgentResumeClaim>
claimAutomaticAgentResume: (tabId: string, claim: AutomaticAgentResumeClaim) => void
/** Launch-time native-chat prompt echo, keyed by terminal tab. In-memory only. */
nativeChatLaunchPromptByTabId: Record<string, NativeChatLaunchPrompt>
seedNativeChatLaunchPrompt: (prompt: NativeChatLaunchPrompt) => void
markNativeChatLaunchPromptFailed: (tabId: string) => void
clearNativeChatLaunchPrompt: (tabId: string) => void
pendingStartupByTabId: Record<
string,
{
@ -636,6 +642,7 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
pendingSetupSplitByTabId: {},
pendingIssueCommandSplitByTabId: {},
automaticAgentResumeClaimsByTabId: {},
nativeChatLaunchPromptByTabId: {},
tabBarOrderByWorktree: {},
workspaceSessionReady: false,
defaultTerminalTabsAppliedByWorktreeId: {},
@ -685,6 +692,41 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
}))
},
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
@ -1091,6 +1133,8 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
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 }
@ -1168,6 +1212,7 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
terminalLayoutsByTabId: nextLayouts,
pendingStartupByTabId: nextPendingStartupByTabId,
automaticAgentResumeClaimsByTabId: nextAutomaticAgentResumeClaimsByTabId,
nativeChatLaunchPromptByTabId: nextNativeChatLaunchPromptByTabId,
pendingInitialCwdByTabId: nextPendingInitialCwdByTabId,
pendingSetupSplitByTabId: nextPendingSetupSplitByTabId,
pendingIssueCommandSplitByTabId: nextPendingIssueCommandSplitByTabId,

View File

@ -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'

View File

@ -1991,6 +1991,7 @@ function buildWorktreePurgeState(s: AppState, worktreeIds: string[]): Partial<Ap
ptyIdsByTabId: omitByTabId(s.ptyIdsByTabId),
runtimePaneTitlesByTabId: omitByTabId(s.runtimePaneTitlesByTabId),
automaticAgentResumeClaimsByTabId: omitByTabId(s.automaticAgentResumeClaimsByTabId),
nativeChatLaunchPromptByTabId: omitByTabId(s.nativeChatLaunchPromptByTabId),
// Why: these tab/pane-scoped agent-status, unread, and input maps are only
// cleared on the single removeWorktree path (via shutdownWorktreeTerminals /
// dropAgentStatusByWorktree / clearPaneForegroundAgentByWorktree, which read
@ -3096,11 +3097,13 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
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]
@ -3246,6 +3249,7 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
ptyIdsByTabId: nextPtyIdsByTabId,
runtimePaneTitlesByTabId: nextRuntimePaneTitlesByTabId,
automaticAgentResumeClaimsByTabId: nextAutomaticAgentResumeClaimsByTabId,
nativeChatLaunchPromptByTabId: nextNativeChatLaunchPromptByTabId,
terminalLayoutsByTabId: nextLayouts,
deleteStateByWorktreeId: nextDeleteState,
baseStatusByWorktreeId: (() => {