mirror of
https://github.com/stablyai/orca.git
synced 2026-10-17 00:03:08 +00:00
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.
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 } 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
|
||||
|
||||
@@ -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 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', () => {
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -12364,7 +12364,8 @@
|
||||
"title": "Allow {{value0}}?",
|
||||
"allow": "Allow",
|
||||
"deny": "Deny"
|
||||
}
|
||||
},
|
||||
"launchPromptNotDelivered": "Not delivered — check the terminal"
|
||||
},
|
||||
"tab": {
|
||||
"bar": {
|
||||
|
||||
@@ -12364,7 +12364,8 @@
|
||||
"title": "¿Permitir {{value0}}?",
|
||||
"allow": "Permitir",
|
||||
"deny": "Denegar"
|
||||
}
|
||||
},
|
||||
"launchPromptNotDelivered": "Not delivered — check the terminal"
|
||||
},
|
||||
"tab": {
|
||||
"bar": {
|
||||
|
||||
@@ -12364,7 +12364,8 @@
|
||||
"title": "{{value0}} を許可しますか?",
|
||||
"allow": "許可する",
|
||||
"deny": "拒否"
|
||||
}
|
||||
},
|
||||
"launchPromptNotDelivered": "Not delivered — check the terminal"
|
||||
},
|
||||
"tab": {
|
||||
"bar": {
|
||||
|
||||
@@ -12364,7 +12364,8 @@
|
||||
"title": "{{value0}}을(를) 허용하시겠습니까?",
|
||||
"allow": "허용하다",
|
||||
"deny": "부인하다"
|
||||
}
|
||||
},
|
||||
"launchPromptNotDelivered": "Not delivered — check the terminal"
|
||||
},
|
||||
"tab": {
|
||||
"bar": {
|
||||
|
||||
@@ -12364,7 +12364,8 @@
|
||||
"title": "允许 {{value0}}?",
|
||||
"allow": "允许",
|
||||
"deny": "拒绝"
|
||||
}
|
||||
},
|
||||
"launchPromptNotDelivered": "Not delivered — check the terminal"
|
||||
},
|
||||
"tab": {
|
||||
"bar": {
|
||||
|
||||
@@ -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 })
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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<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()
|
||||
})
|
||||
}
|
||||
|
||||
return pasteDraftWhenAgentReady({
|
||||
tabId,
|
||||
content,
|
||||
agent,
|
||||
submit,
|
||||
forcePaste,
|
||||
timeoutMs,
|
||||
onTimeout
|
||||
}).then((delivered) => {
|
||||
if (shouldSeed && !delivered) {
|
||||
useAppStore.getState().markNativeChatLaunchPromptFailed(tabId)
|
||||
}
|
||||
return delivered
|
||||
})
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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({})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 } : {}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { TuiAgent } from '../../../shared/types'
|
||||
|
||||
export type NativeChatLaunchPrompt = {
|
||||
tabId: string
|
||||
agent: TuiAgent
|
||||
text: string
|
||||
createdAt: number
|
||||
failed?: boolean
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 } : {})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<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,
|
||||
{
|
||||
@@ -599,6 +605,7 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
|
||||
pendingSetupSplitByTabId: {},
|
||||
pendingIssueCommandSplitByTabId: {},
|
||||
automaticAgentResumeClaimsByTabId: {},
|
||||
nativeChatLaunchPromptByTabId: {},
|
||||
tabBarOrderByWorktree: {},
|
||||
workspaceSessionReady: false,
|
||||
defaultTerminalTabsAppliedByWorktreeId: {},
|
||||
@@ -648,6 +655,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
|
||||
@@ -1052,6 +1094,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 }
|
||||
@@ -1129,6 +1173,7 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
|
||||
terminalLayoutsByTabId: nextLayouts,
|
||||
pendingStartupByTabId: nextPendingStartupByTabId,
|
||||
automaticAgentResumeClaimsByTabId: nextAutomaticAgentResumeClaimsByTabId,
|
||||
nativeChatLaunchPromptByTabId: nextNativeChatLaunchPromptByTabId,
|
||||
pendingInitialCwdByTabId: nextPendingInitialCwdByTabId,
|
||||
pendingSetupSplitByTabId: nextPendingSetupSplitByTabId,
|
||||
pendingIssueCommandSplitByTabId: nextPendingIssueCommandSplitByTabId,
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -1969,6 +1969,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),
|
||||
// Delete state
|
||||
deleteStateByWorktreeId: omitByWorktree(s.deleteStateByWorktreeId),
|
||||
baseStatusByWorktreeId: omitByWorktree(s.baseStatusByWorktreeId),
|
||||
@@ -3055,11 +3056,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]
|
||||
@@ -3205,6 +3208,7 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
|
||||
ptyIdsByTabId: nextPtyIdsByTabId,
|
||||
runtimePaneTitlesByTabId: nextRuntimePaneTitlesByTabId,
|
||||
automaticAgentResumeClaimsByTabId: nextAutomaticAgentResumeClaimsByTabId,
|
||||
nativeChatLaunchPromptByTabId: nextNativeChatLaunchPromptByTabId,
|
||||
terminalLayoutsByTabId: nextLayouts,
|
||||
deleteStateByWorktreeId: nextDeleteState,
|
||||
baseStatusByWorktreeId: (() => {
|
||||
|
||||
Reference in New Issue
Block a user