fix(native-chat): surface draft launch context in desktop and mobile chat composers (#9802)
* fix(native-chat): surface draft launch context in chat composers
Creating a workspace from a GitHub issue delivers the issue link only into
the agent TUI's input buffer (argv prefill or startup paste), so the chat
view showed no trace of it on desktop or mobile.
Desktop: draft launches now seed an in-memory launch draft keyed by tab id
(direct work-item launches, background GitHub work-item creates, quick-create
composer, and new-tab draft deliveries). The chat composer adopts the seed
once as its editable draft, declines permanently if the composer already has
text, and drops an untouched copy when any user turn lands (the one-line TUI
input means the prefill was submitted or deliberately cleared) or on its own
send, whose existing input pre-clear retires the TUI copy.
Mobile: the host publishes the draft as an optional launchDraft field on the
mobile terminal tab snapshot (additive, no protocol bump) and the mobile
composer adopts it with the same once-only/decline/resolve semantics. Mobile
chat sends now also pre-clear the TUI input line (Ctrl+U, desktop parity) so
a pending prefill cannot concatenate with the sent message.
Completion seeding resolves the launch tab from the synced store tabs when
the backend spawned the terminal and activation reports no primaryTabId.
Split the Windows shell-quoting tests into their own file to stay within the
max-lines budget.
* revert(mobile): drop incidental pnpm-lock churn from the launch-draft branch
The libc binding fields and the @typescript-eslint peer re-resolution came from
a local install, not from this change; mobile/package.json is untouched.
* fix(native-chat): resolve launch drafts without trusting cross-host clocks
The rule required a user turn stamped at or after the seed. Grok omits row
timestamps, so a Grok launch draft never resolved; and the seed time is a
renderer clock while the stamp comes from the executing host's JSONL, so a
remote workspace whose clock trailed never resolved either. Both left the
composer adopting an already-submitted prefill, which re-sends it as a
duplicate turn.
Resolve on any user turn that is not PROVABLY older than the seed (a launch
draft's session starts with zero user turns), with the existing cross-host
skew slack, plus a timestamp-free backstop for wider skew: a new tail user
turn since the draft was first observed. "Load earlier" prepends, so it
cannot move the tail and cannot over-resolve.
Split out of native-chat-pending.ts to stay under the max-lines ratchet.
* fix(worktrees): seed the launch draft on the agent's own tab, never on tabs[0]
Two defects in the completion seed:
- The tab was resolved by array position. buildStartupOpt returns undefined on
the backend-spawn path, so applyDefaultTerminalTabs stamps launchAgent on no
tab and the launchAgent guard was dead there. A repo with default terminal
tabs ("dev server", "logs", ...) got the draft on a tab that runs no agent,
and then published it to mobile as THAT tab's launchDraft. Correlate on the
backend startup tab, then on a launchAgent-stamped tab, then on primaryTabId
(which is the agent tab whenever the renderer owns startup); never tabs[0].
- Runtime-owned worktrees mirror their session tabs async, so tabsByWorktree
was empty at seed time and the seed was silently dropped for that whole host
class. Defer to the first mirrored tab via the existing delayed-delivery
queue, which now holds every pending delivery for a worktree instead of one
(setup/issue commands and the seed both wait on the same first tab).
* fix(store): evict nativeChatLaunchDraftByTabId on every teardown path
The new map was absent from all four paths its sibling
nativeChatLaunchPromptByTabId participates in: tab close, the orphan terminal
sweep, the bulk worktree purge, and the removeWorktree teardown. A stranded
entry is worse than a plain leak here because sync-runtime-graph keeps
publishing it to mobile as that tab's launchDraft.
* fix(native-chat): only seed single-line unsubmitted launch drafts
The unsubmitted-delivery branch seeded on every draft delivery, which also
caught the agent-session-fork path whose prompt is multi-line scraped context.
The chat send pre-clears the TUI with Ctrl+U (kill-to-start-of-LINE), so a
multi-line prefill cannot be fully cleared and its earlier lines would glue
onto the next message. The GitHub work-item draft this feature targets is a
bare issue URL, so narrowing costs it nothing.
Also assert the composer retires the seed after a send — deleting that call
previously failed no test.
* fix(mobile): stop the chat pre-clear from wiping a just-pasted image
The text write set clearInputFirst unconditionally. On the image path that
Ctrl+U lands AFTER pasteMobileNativeChatImagePaths already pasted the image,
so the agent receives the text alone while acceptSend still renders the
thumbnail on the sent bubble — silent image loss.
Desktop's image path clears exactly once, before the paste, and never again;
mobile now matches: pre-clear only when nothing was deliberately pasted first.
The image paste already leads with its own Ctrl+U, so a launch-draft prefill
parked on the input line still cannot glue onto the message.
Pinned at both levels: the controller test drives the real send hook and
asserts clearInputFirst per branch, and the send module asserts the wire text
carries no leading \x15. The image-attachments test injects its own baseSend,
so it structurally could not observe this.
* fix(mobile): hold the launch-draft prefill until the transcript settles
session.tabs delivers launchDraft before the transcript read resolves, so the
seed effect could run against an empty in-flight message list and miss the
user-turn decline. Launching from an issue, submitting the prefill in the TUI,
and never opening desktop chat (nothing else clears the host seed) then
prefilled the mobile composer with the already-sent issue link — a send tapped
before it retracted duplicated it to the agent.
Thread the session's loading state through and skip the seed while the read is
in flight. idle/waiting-session still seed: no session means no user turns.
* fix(runtime): publish a launch draft to mobile only for the tab's own agent
The publish had no agent check while the desktop consumer declines on
mismatch. The seed is keyed by tab id, which survives a pane's agent switch, so
mobile could adopt a draft desktop refuses — seed for claude, never open
desktop chat, switch the pane to Codex, and mobile prefills the Codex chat with
the Claude-era issue link. Align publish with the consumer.
* fix(native-chat): take the launch-draft baseline only after the transcript loads
The timestamp-free backstop snapshotted the transcript's user turns on first
observation of the draft, which can happen while the read is still in flight and
`messages` is []. A pane bound to a session that already had user turns then
backfilled above that zero baseline with a different tail id, so clause 2
resolved and silently dropped the seed — the launch context never appeared, and
the feature no-oped for exactly the panes it was meant to serve. Clause 1 was
already correct there (that history is provably older than the seed).
Gate baseline capture and resolution on the transcript read settling, the same
shape mobile's drafts hook uses. Clause 1 is unchanged; while loading the merged
list is empty anyway, and a pane with live appends is never reported 'loading'.
Also restore clause 1's short-circuit: it scans with .some() again and only
allocates the user-turn list when falling through to the backstop.
NativeChatView sat at exactly the 400-line cap, so the composer's two
launch-draft props are now spread from the hook result they already mirror.
* fix(native-chat): reject multi-line launch drafts inside the seed helper
The single-line guard lived in deliverLaunchPromptToAgentTab, so the two
other seeding entry points (worktree create, direct work-item launch)
bypassed it — and every Linear launch is multi-line by construction
("Linked Linear issue: STA-…" + url). The chat send pre-clears the TUI
with Ctrl+U, which kills to start of LINE, so those earlier lines stay
parked to glue onto the next message.
* fix(worktrees): keep the deferred agent seed off ambiguous mirrored tabs
The runtime-owned deferred path fell back to tabs[0], which the module's
own docstring forbids: with repo default tabs ("dev server", "logs") the
seed lands on a tab running no agent, where mobile withholds it and
desktop's agent check ignores it — the feature is silently dead for that
create and the entry leaks until tab close.
The queue entry is consumed before delivery, so there is no retry to fall
back on; accept the first mirrored tab only when it is the worktree's
only one and so unambiguously the agent's.
* fix(mobile): treat a launch-draft-only session-tab frame as a change
mobileSessionTabEqual's terminal branch never compared launchDraft, and
the route keeps `prev` when tabs compare equal — so a publish whose only
delta is the draft appearing or retracting was discarded and never
reached the composer. Live QA passed only because agentStatus happened to
change in the same frame.
MobileSessionTab's terminal variant did not declare the field either
(the controller read it through the structurally wider
MobileNativeChatTab), which is why TypeScript never flagged it.
* fix(mobile): judge a launch prefill only from its own settled transcript
Two ways the drafts hook was reading a transcript that was not the active
chat's:
- transcriptLoading came from `status`, a plain useState written by a
passive effect declared before the drafts hook. On the commit where the
tab identity changes it still holds the previous tab's value, so the
guard was off on exactly the render that seeds: first entry saw
status 'idle' with an empty list and seeded an already-submitted link,
and a tab switch declined the new tab's prefill from the old tab's
turns. The session hook now tracks the identity its messages describe
and reports transcriptLoading until they agree; the retire effect gates
on it too.
- Leaving chat view nulled launchDraft while draftKey stayed the same,
which the hook could not tell from a host retraction — it declined the
prefill permanently, so peeking at the terminal dropped the context.
The controller now passes the raw field plus an explicit chatActive
flag, and both effects hold their state when the tab is not on chat.
The controller wiring was previously unasserted: replacing both props
with constants left all 795 mobile session tests green.
* fix(native-chat): keep the launch-draft baseline across a transcript reload
baselineKey went null whenever the transcript was loading, and the null
branch DISCARDED an already-valid baseline taken from a settled read. It
was then re-taken from the fuller list, swallowing the very user turn
that resolves the draft — so a stale prefill gets re-adopted as a
duplicate turn. Key the baseline on draft identity alone and gate only
the capture.
session.status is also not a truthful read-in-flight signal: a live
'working' hook outranks 'loading', so the guard could be off over an
in-flight empty list. Expose the read phase itself and gate on that.
* test: cover the launch-draft reducers and the sync-key skip gate
Every consumer test injects the three launch-draft reducers as bare
vi.fn()s, so reducing markNativeChatLaunchDraftAdopted to a no-op left
2609 tests green — while in the app the composer would resurrect the
prefill after every manual clear.
canSkipRuntimeMobileSessionSyncKeyBuild had no launch-draft case either:
when it skips, the sync key is never even built, so the existing
getRuntimeMobileSessionSyncKey case cannot catch its removal.
* fix(native-chat): hold the launch-draft baseline in state, not a render-mutated ref
react-compiler rejects reading or writing a ref during render. Adjust the held
baseline with the sanctioned render-time setState instead, keeping the local
copy so the render that first sees a settled transcript resolves against it.
* fix(mobile): carry the transcript identity in the session read state
react-doctor flags the separate loadedIdentity state as an extra render for a
derivable value. Hold status alongside the identity it describes in one state
written by the subscription effect, so transcriptLoading derives from it.
* test(native-chat): assert the readPhase contract without the hook-status race
The test asserted status === 'working', which depends on liveStatusOverride
winning over ambient transcript state — green locally, red under CI load. The
contract is that readPhase stays 'loading' once live content unmasks status,
so assert exactly that; it still fails if readPhase derives from status.
* fix(mobile): derive pre-read chat status instead of writing it from the effect
react-doctor's no-derived-state-effect flags idle/waiting-session/loading being
set in the subscription effect: all three are pure functions of the props. Derive
them during render and keep state only for the genuinely async outcome, tagged
with the identity it describes.
The tag now gates `messages` too, so a just-switched tab never sees the previous
tab's transcript at all rather than seeing it behind a loading flag.
* fix(mobile): drop a settled chat read once its subscription is torn down
The settled outcome was only ever replaced by a newly arriving frame, so any
effect re-run that landed back on an already-settled identity resurfaced it over
a list the same effect had just cleared: 'ready' with no messages and
transcriptLoading false. Toggling out of chat view and back hit this every time
(the agent goes null, then returns), flashing the "start a chat" empty state over
a real conversation and opening the launch-draft seed's decline check on an empty
transcript. A reconnect did the same via the client dep.
Identity and client are the effect's only inputs, so tagging the read with both
and dropping it during render when either moves covers every re-run.
This commit is contained in:
parent
0388319a32
commit
b41e813cb5
|
|
@ -26,6 +26,8 @@ export type MobileSessionTab =
|
|||
/** Agent Orca launched in this terminal, if any. This makes chat eligible
|
||||
* before the first live agent-status update reaches the mobile client. */
|
||||
launchAgent?: TuiAgent
|
||||
/** Host-provided launch context still parked as an unsent TUI-input draft. */
|
||||
launchDraft?: string
|
||||
terminalTheme?: MobileTerminalTheme
|
||||
isActive: boolean
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,8 @@ export type MobileNativeChatTab = {
|
|||
type: string
|
||||
launchAgent?: string | null
|
||||
agentStatus?: AgentStatusEntry | null
|
||||
/** Host-provided launch context still parked as an unsent TUI-input draft. */
|
||||
launchDraft?: string
|
||||
}
|
||||
|
||||
/** Resolve a session tab to the transcript identity native chat needs, or
|
||||
|
|
|
|||
|
|
@ -169,6 +169,52 @@ describe('sendMobileNativeChatMessage', () => {
|
|||
).resolves.toBe('rejected')
|
||||
})
|
||||
|
||||
it('prepends the input-line clear byte when clearInputFirst is set', async () => {
|
||||
const client = clientWithResponse({
|
||||
id: 'request',
|
||||
ok: true,
|
||||
result: { send: { accepted: true } },
|
||||
_meta: { runtimeId: 'runtime' }
|
||||
})
|
||||
|
||||
await sendMobileNativeChatMessage({
|
||||
client,
|
||||
terminal: 'term',
|
||||
text: 'hello',
|
||||
clearInputFirst: true
|
||||
})
|
||||
expect(client.sendRequest).toHaveBeenCalledWith(
|
||||
'terminal.send',
|
||||
{
|
||||
terminal: 'term',
|
||||
text: '\x15hello',
|
||||
enter: true
|
||||
},
|
||||
{ timeoutMs: MOBILE_NATIVE_CHAT_SEND_TIMEOUT_MS, budgetSpansConnect: true }
|
||||
)
|
||||
})
|
||||
|
||||
it('sends the text verbatim when clearInputFirst is not set', async () => {
|
||||
// An image send pastes the image (behind its own leading Ctrl+U) before this
|
||||
// text write; a clear byte here would kill the pasted image off the input line.
|
||||
const client = clientWithResponse({
|
||||
id: 'request',
|
||||
ok: true,
|
||||
result: { send: { accepted: true } },
|
||||
_meta: { runtimeId: 'runtime' }
|
||||
})
|
||||
|
||||
await sendMobileNativeChatMessage({
|
||||
client,
|
||||
terminal: 'term',
|
||||
text: 'what is this',
|
||||
clearInputFirst: false
|
||||
})
|
||||
const sent = vi.mocked(client.sendRequest).mock.calls[0]?.[1] as { text: string }
|
||||
expect(sent.text).toBe('what is this')
|
||||
expect(sent.text.startsWith('\x15')).toBe(false)
|
||||
})
|
||||
|
||||
it('sends a single non-submitting Escape for prompt cancellation', async () => {
|
||||
const client = clientWithResponse({
|
||||
id: 'request',
|
||||
|
|
|
|||
|
|
@ -8,11 +8,17 @@ type MobileTerminalClient = {
|
|||
type: 'mobile'
|
||||
}
|
||||
|
||||
// Why: Ctrl+U kills the TUI's current input line (desktop native chat sends the
|
||||
// same byte before its body), so a launch-context prefill parked there cannot
|
||||
// concatenate with a mobile chat message. The host writes text bytes verbatim.
|
||||
const CLEAR_UNSUBMITTED_INPUT = '\x15'
|
||||
|
||||
type MobileNativeChatSendArgs = {
|
||||
client: RpcClient
|
||||
terminal: string
|
||||
text: string
|
||||
enter?: boolean
|
||||
clearInputFirst?: boolean
|
||||
mobileClient?: MobileTerminalClient
|
||||
/** Shared budget for a whole user action (heal → paste → text, or one selector's
|
||||
* keystroke sequence). Omit to give this write its own full budget. */
|
||||
|
|
@ -51,7 +57,7 @@ export async function sendMobileNativeChatMessageWithOutcome(
|
|||
'terminal.send',
|
||||
{
|
||||
terminal: args.terminal,
|
||||
text: args.text,
|
||||
text: args.clearInputFirst ? `${CLEAR_UNSUBMITTED_INPUT}${args.text}` : args.text,
|
||||
enter: args.enter ?? true,
|
||||
...(args.mobileClient ? { client: args.mobileClient } : {})
|
||||
},
|
||||
|
|
|
|||
|
|
@ -87,6 +87,29 @@ describe('mobile terminal records', () => {
|
|||
).toEqual([])
|
||||
})
|
||||
|
||||
it('treats a launch draft appearing or retracting as a session-tab change', () => {
|
||||
// The route keeps `prev` when these compare equal, so a frame whose only
|
||||
// delta is the draft would never reach the chat composer.
|
||||
const base: MobileTerminalSessionTab = {
|
||||
type: 'terminal',
|
||||
id: 'term-1::leaf-1',
|
||||
parentTabId: 'term-1',
|
||||
leafId: 'leaf-1',
|
||||
title: 'Claude',
|
||||
status: 'ready',
|
||||
terminal: 'pty-1',
|
||||
isActive: true
|
||||
}
|
||||
const seeded: MobileTerminalSessionTab = {
|
||||
...base,
|
||||
launchDraft: 'https://github.com/o/r/issues/12'
|
||||
}
|
||||
|
||||
expect(mobileSessionTabsEqual([base], [seeded])).toBe(false)
|
||||
expect(mobileSessionTabsEqual([seeded], [base])).toBe(false)
|
||||
expect(mobileSessionTabsEqual([seeded], [{ ...seeded }])).toBe(true)
|
||||
})
|
||||
|
||||
it('treats terminal agent-status changes as session-tab changes', () => {
|
||||
const base: MobileTerminalSessionTab = {
|
||||
type: 'terminal',
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ export type MobileTerminalSessionTab = {
|
|||
status?: 'pending-handle' | 'ready'
|
||||
terminal: string | null
|
||||
agentStatus?: AgentStatusEntry | null
|
||||
/** Host-provided launch context still parked as an unsent TUI-input draft. */
|
||||
launchDraft?: string
|
||||
terminalTheme?: MobileTerminalTheme
|
||||
isActive: boolean
|
||||
}
|
||||
|
|
@ -84,6 +86,9 @@ function mobileSessionTabEqual(
|
|||
a.leafId === b.leafId &&
|
||||
a.status === b.status &&
|
||||
a.terminal === b.terminal &&
|
||||
// A frame whose only delta is the launch draft appearing or retracting
|
||||
// still has to reach the chat composer.
|
||||
a.launchDraft === b.launchDraft &&
|
||||
JSON.stringify(a.agentStatus ?? null) === JSON.stringify(b.agentStatus ?? null) &&
|
||||
JSON.stringify(a.terminalTheme ?? null) === JSON.stringify(b.terminalTheme ?? null)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -10,25 +10,37 @@ const clearDraftForSend = vi.fn()
|
|||
const restoreRejectedDraft = vi.fn()
|
||||
const holdUnconfirmedSend = vi.fn()
|
||||
|
||||
// Mutable stand-ins so the launch-draft wiring below can drive chat resolution
|
||||
// and transcript state; defaults keep the send-seam tests unchanged.
|
||||
const viewMode = { isTabChatView: (_tabId: string) => true }
|
||||
const sessionState = { messages: [] as unknown[], status: 'ready', transcriptLoading: false }
|
||||
const draftsArgs: Record<string, unknown>[] = []
|
||||
|
||||
// The controller composes many session hooks; each is mocked to a minimal shape
|
||||
// so this test isolates the send seam (outcome -> drafts accounting).
|
||||
vi.mock('./use-mobile-session-view-mode', () => ({
|
||||
useMobileSessionViewMode: () => ({ isTabChatView: () => true, toggleTabChatView: vi.fn() })
|
||||
useMobileSessionViewMode: () => ({
|
||||
isTabChatView: (tabId: string) => viewMode.isTabChatView(tabId),
|
||||
toggleTabChatView: vi.fn()
|
||||
})
|
||||
}))
|
||||
vi.mock('./use-mobile-native-chat-session', () => ({
|
||||
useMobileNativeChatSession: () => ({ messages: [] })
|
||||
useMobileNativeChatSession: () => sessionState
|
||||
}))
|
||||
vi.mock('./use-mobile-native-chat-drafts', () => ({
|
||||
useMobileNativeChatDrafts: () => ({
|
||||
composerText: '',
|
||||
setComposerText: vi.fn(),
|
||||
pending: [],
|
||||
captureSendOrigin,
|
||||
clearDraftForSend,
|
||||
restoreRejectedDraft,
|
||||
acceptSend,
|
||||
holdUnconfirmedSend
|
||||
})
|
||||
useMobileNativeChatDrafts: (args: Record<string, unknown>) => {
|
||||
draftsArgs.push(args)
|
||||
return {
|
||||
composerText: '',
|
||||
setComposerText: vi.fn(),
|
||||
pending: [],
|
||||
captureSendOrigin,
|
||||
clearDraftForSend,
|
||||
restoreRejectedDraft,
|
||||
acceptSend,
|
||||
holdUnconfirmedSend
|
||||
}
|
||||
}
|
||||
}))
|
||||
vi.mock('./use-mobile-native-chat-prompts', () => ({
|
||||
useMobileNativeChatPrompts: () => ({ permission: null, question: null, ask: null })
|
||||
|
|
@ -213,6 +225,27 @@ describe('useMobileNativeChatController handleNativeChatSend', () => {
|
|||
expect(restoreRejectedDraft).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('pre-clears the input line for a text-only send but never for an image send', async () => {
|
||||
// The image path pastes the image behind its OWN leading Ctrl+U and then calls
|
||||
// this send; a second clear here wipes the image off the input line and the
|
||||
// agent receives text alone while the echo bubble still shows the thumbnail.
|
||||
sendWithOutcome.mockResolvedValue('accepted')
|
||||
|
||||
await act(async () => {
|
||||
await controller!.handleNativeChatSend('answer')
|
||||
})
|
||||
expect(sendWithOutcome).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ text: 'answer', clearInputFirst: true })
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await controller!.handleNativeChatSend('look', ['file:///a.jpg'])
|
||||
})
|
||||
expect(sendWithOutcome).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ text: 'look', clearInputFirst: false })
|
||||
)
|
||||
})
|
||||
|
||||
it('holds an unknown-outcome send without posting the optimistic echo', async () => {
|
||||
sendWithOutcome.mockResolvedValue('unknown')
|
||||
let accepted = false
|
||||
|
|
@ -282,3 +315,105 @@ describe('useMobileNativeChatController handleNativeChatSend', () => {
|
|||
expect(onSendError).toHaveBeenCalledWith('Message not sent')
|
||||
})
|
||||
})
|
||||
|
||||
describe('useMobileNativeChatController launch-draft wiring', () => {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
const clientStub = { sendRequest: vi.fn() }
|
||||
|
||||
const chatTab = {
|
||||
type: 'terminal',
|
||||
id: 'tab-1',
|
||||
title: 'Claude',
|
||||
terminal: 'term-1',
|
||||
launchAgent: 'claude',
|
||||
launchDraft: 'https://github.com/o/r/issues/12',
|
||||
isActive: true
|
||||
}
|
||||
|
||||
function Harness({ tab }: { tab: unknown }): null {
|
||||
useMobileNativeChatController({
|
||||
client: clientStub as unknown as RpcClient,
|
||||
connState: 'connected',
|
||||
hostId: 'h',
|
||||
worktreeId: 'w',
|
||||
activeSessionTab: tab as never,
|
||||
activeSessionTabId: 'tab-1',
|
||||
activeHandleRef: { current: 'term-1' },
|
||||
deviceTokenRef: { current: null },
|
||||
nativeChatTranscriptIsLocalReadable: true,
|
||||
nativeChatInputLeaseReady: true,
|
||||
onSendError: vi.fn(),
|
||||
onSendResolved: vi.fn()
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
function render(tab: unknown): void {
|
||||
const original = console.error
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation((...a) => {
|
||||
if (typeof a[0] === 'string' && a[0].includes('react-test-renderer is deprecated')) {
|
||||
return
|
||||
}
|
||||
original(...a)
|
||||
})
|
||||
try {
|
||||
act(() => {
|
||||
renderer = create(createElement(Harness, { tab }))
|
||||
})
|
||||
} finally {
|
||||
spy.mockRestore()
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
draftsArgs.length = 0
|
||||
viewMode.isTabChatView = () => true
|
||||
sessionState.messages = []
|
||||
sessionState.status = 'ready'
|
||||
sessionState.transcriptLoading = false
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
})
|
||||
|
||||
it('forwards the tab launch draft and chat-active flag for a chat-resolved tab', () => {
|
||||
render(chatTab)
|
||||
|
||||
expect(draftsArgs.at(-1)).toMatchObject({
|
||||
tabId: 'tab-1',
|
||||
launchDraft: 'https://github.com/o/r/issues/12',
|
||||
chatActive: true,
|
||||
transcriptLoading: false
|
||||
})
|
||||
})
|
||||
|
||||
it('forwards the raw draft with chatActive false when the tab shows the terminal', () => {
|
||||
// Nulling the draft off-chat is indistinguishable from a host retraction and
|
||||
// permanently declines the prefill; the flag is what keeps them apart.
|
||||
viewMode.isTabChatView = () => false
|
||||
render(chatTab)
|
||||
|
||||
expect(draftsArgs.at(-1)).toMatchObject({
|
||||
launchDraft: 'https://github.com/o/r/issues/12',
|
||||
chatActive: false
|
||||
})
|
||||
})
|
||||
|
||||
it('forwards the session hook’s transcriptLoading, not its status', () => {
|
||||
// 'working' masks 'loading' in status, so only the read-phase signal is honest.
|
||||
sessionState.status = 'working'
|
||||
sessionState.transcriptLoading = true
|
||||
render(chatTab)
|
||||
|
||||
expect(draftsArgs.at(-1)).toMatchObject({ transcriptLoading: true })
|
||||
})
|
||||
|
||||
it('forwards a null draft for a tab that publishes none', () => {
|
||||
render({ ...chatTab, launchDraft: undefined })
|
||||
|
||||
expect(draftsArgs.at(-1)).toMatchObject({ launchDraft: null, chatActive: true })
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -143,7 +143,13 @@ export function useMobileNativeChatController(args: {
|
|||
worktreeId,
|
||||
tabId: activeSessionTabId,
|
||||
sessionId: activeChatSessionId,
|
||||
messages: nativeChatSession.messages
|
||||
messages: nativeChatSession.messages,
|
||||
launchDraft: activeSessionTab?.launchDraft ?? null,
|
||||
// Why: pass the raw draft plus this flag rather than nulling it off-chat —
|
||||
// a null is indistinguishable from a host retraction, and peeking at the
|
||||
// terminal view would permanently decline the prefill.
|
||||
chatActive: showNativeChat,
|
||||
transcriptLoading: nativeChatSession.transcriptLoading
|
||||
})
|
||||
|
||||
const nativeChatStatus = activeChatResolution ? activeSessionTab?.agentStatus : null
|
||||
|
|
|
|||
|
|
@ -0,0 +1,310 @@
|
|||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
|
||||
import { useMobileNativeChatDrafts } from './use-mobile-native-chat-drafts'
|
||||
|
||||
type DraftState = ReturnType<typeof useMobileNativeChatDrafts>
|
||||
|
||||
function userTextMessage(id: string, text: string): NativeChatMessage {
|
||||
return {
|
||||
id,
|
||||
role: 'user',
|
||||
blocks: [{ type: 'text', text }],
|
||||
timestamp: null,
|
||||
source: 'transcript'
|
||||
}
|
||||
}
|
||||
|
||||
// Adoption and retirement of the host-published launch draft (the TUI-input
|
||||
// prefill mirrored into the chat composer). Split from the send/pending suite
|
||||
// so both stay under the per-file line cap.
|
||||
describe('useMobileNativeChatDrafts launch draft', () => {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
let state: DraftState | null = null
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
state = null
|
||||
})
|
||||
|
||||
function Harness({
|
||||
tabId,
|
||||
sessionId = `session-${tabId}`,
|
||||
messages = [],
|
||||
launchDraft = null,
|
||||
chatActive = true,
|
||||
transcriptLoading = false
|
||||
}: {
|
||||
tabId: string
|
||||
sessionId?: string | null
|
||||
messages?: NativeChatMessage[]
|
||||
launchDraft?: string | null
|
||||
chatActive?: boolean
|
||||
transcriptLoading?: boolean
|
||||
}): null {
|
||||
state = useMobileNativeChatDrafts({
|
||||
hostId: 'host',
|
||||
worktreeId: 'worktree',
|
||||
tabId,
|
||||
sessionId,
|
||||
messages,
|
||||
launchDraft,
|
||||
chatActive,
|
||||
transcriptLoading
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
async function mount(tabId: string): Promise<void> {
|
||||
const original = console.error
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
|
||||
if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) {
|
||||
return
|
||||
}
|
||||
original(...args)
|
||||
})
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(createElement(Harness, { tabId }))
|
||||
})
|
||||
} finally {
|
||||
consoleSpy.mockRestore()
|
||||
}
|
||||
}
|
||||
|
||||
it('prefills the composer from a host launch draft exactly once', async () => {
|
||||
await mount('a')
|
||||
await act(async () =>
|
||||
renderer?.update(
|
||||
createElement(Harness, { tabId: 'a', launchDraft: 'https://github.com/o/r/issues/12' })
|
||||
)
|
||||
)
|
||||
expect(state?.composerText).toBe('https://github.com/o/r/issues/12')
|
||||
|
||||
// A user clear must not see the prefill resurrected on the next render.
|
||||
act(() => state?.setComposerText(''))
|
||||
await act(async () =>
|
||||
renderer?.update(
|
||||
createElement(Harness, { tabId: 'a', launchDraft: 'https://github.com/o/r/issues/12' })
|
||||
)
|
||||
)
|
||||
expect(state?.composerText).toBe('')
|
||||
})
|
||||
|
||||
it('does not overwrite typed composer text with a launch draft', async () => {
|
||||
await mount('a')
|
||||
act(() => state?.setComposerText('typed first'))
|
||||
await act(async () =>
|
||||
renderer?.update(createElement(Harness, { tabId: 'a', launchDraft: 'issue link' }))
|
||||
)
|
||||
expect(state?.composerText).toBe('typed first')
|
||||
})
|
||||
|
||||
it('declines a launch draft when the transcript already has a user turn', async () => {
|
||||
await mount('a')
|
||||
await act(async () =>
|
||||
renderer?.update(
|
||||
createElement(Harness, {
|
||||
tabId: 'a',
|
||||
messages: [userTextMessage('m1', 'already sent')],
|
||||
launchDraft: 'issue link'
|
||||
})
|
||||
)
|
||||
)
|
||||
expect(state?.composerText).toBe('')
|
||||
})
|
||||
|
||||
it('clears an untouched prefill once a user turn lands, keeping user edits', async () => {
|
||||
await mount('a')
|
||||
await act(async () =>
|
||||
renderer?.update(createElement(Harness, { tabId: 'a', launchDraft: 'issue link' }))
|
||||
)
|
||||
expect(state?.composerText).toBe('issue link')
|
||||
|
||||
await act(async () =>
|
||||
renderer?.update(
|
||||
createElement(Harness, {
|
||||
tabId: 'a',
|
||||
messages: [userTextMessage('m1', 'sent from the TUI')],
|
||||
launchDraft: 'issue link'
|
||||
})
|
||||
)
|
||||
)
|
||||
expect(state?.composerText).toBe('')
|
||||
|
||||
// Edited prefill survives resolution on another tab's copy.
|
||||
await act(async () =>
|
||||
renderer?.update(createElement(Harness, { tabId: 'b', launchDraft: 'issue link' }))
|
||||
)
|
||||
act(() => state?.setComposerText('issue link plus my notes'))
|
||||
await act(async () =>
|
||||
renderer?.update(
|
||||
createElement(Harness, {
|
||||
tabId: 'b',
|
||||
messages: [userTextMessage('m2', 'sent from the TUI')],
|
||||
launchDraft: 'issue link'
|
||||
})
|
||||
)
|
||||
)
|
||||
expect(state?.composerText).toBe('issue link plus my notes')
|
||||
})
|
||||
|
||||
it('holds the launch draft until the transcript read settles', async () => {
|
||||
// `session.tabs` carries launchDraft before the transcript loads. Seeding on
|
||||
// the empty in-flight list would prefill an already-submitted issue link, and
|
||||
// a send tapped before it retracts duplicates it to the agent.
|
||||
await mount('a')
|
||||
await act(async () =>
|
||||
renderer?.update(
|
||||
createElement(Harness, { tabId: 'a', launchDraft: 'issue link', transcriptLoading: true })
|
||||
)
|
||||
)
|
||||
expect(state?.composerText).toBe('')
|
||||
|
||||
// The settled transcript already holds the submitted turn — decline for good.
|
||||
await act(async () =>
|
||||
renderer?.update(
|
||||
createElement(Harness, {
|
||||
tabId: 'a',
|
||||
launchDraft: 'issue link',
|
||||
messages: [userTextMessage('m1', 'already sent from the TUI')]
|
||||
})
|
||||
)
|
||||
)
|
||||
expect(state?.composerText).toBe('')
|
||||
})
|
||||
|
||||
it('seeds once the transcript settles empty', async () => {
|
||||
await mount('a')
|
||||
await act(async () =>
|
||||
renderer?.update(
|
||||
createElement(Harness, { tabId: 'a', launchDraft: 'issue link', transcriptLoading: true })
|
||||
)
|
||||
)
|
||||
expect(state?.composerText).toBe('')
|
||||
|
||||
await act(async () =>
|
||||
renderer?.update(createElement(Harness, { tabId: 'a', launchDraft: 'issue link' }))
|
||||
)
|
||||
expect(state?.composerText).toBe('issue link')
|
||||
})
|
||||
|
||||
it('holds the seed while the tab is not resolved to chat view', async () => {
|
||||
// Off chat the session hook is not subscribed, so `messages` is empty for a
|
||||
// reason that says nothing about the transcript — judging the seed from it
|
||||
// would prefill an issue link the agent already submitted in the TUI.
|
||||
await mount('a')
|
||||
await act(async () =>
|
||||
renderer?.update(
|
||||
createElement(Harness, { tabId: 'a', launchDraft: 'issue link', chatActive: false })
|
||||
)
|
||||
)
|
||||
expect(state?.composerText).toBe('')
|
||||
|
||||
await act(async () =>
|
||||
renderer?.update(
|
||||
createElement(Harness, {
|
||||
tabId: 'a',
|
||||
launchDraft: 'issue link',
|
||||
messages: [userTextMessage('m1', 'already sent from the TUI')]
|
||||
})
|
||||
)
|
||||
)
|
||||
expect(state?.composerText).toBe('')
|
||||
})
|
||||
|
||||
it('keeps an adopted prefill when the tab momentarily drops out of the snapshot', async () => {
|
||||
// A session-tabs frame can transiently omit the active tab: the draft then
|
||||
// reads as retracted and chat as inactive, but nothing was resolved.
|
||||
await mount('a')
|
||||
await act(async () =>
|
||||
renderer?.update(createElement(Harness, { tabId: 'a', launchDraft: 'issue link' }))
|
||||
)
|
||||
expect(state?.composerText).toBe('issue link')
|
||||
|
||||
await act(async () =>
|
||||
renderer?.update(createElement(Harness, { tabId: 'a', launchDraft: null, chatActive: false }))
|
||||
)
|
||||
expect(state?.composerText).toBe('issue link')
|
||||
|
||||
await act(async () =>
|
||||
renderer?.update(createElement(Harness, { tabId: 'a', launchDraft: 'issue link' }))
|
||||
)
|
||||
expect(state?.composerText).toBe('issue link')
|
||||
})
|
||||
|
||||
it('does not decline another tab’s prefill from the transcript it is still showing', async () => {
|
||||
// The session hook resets its list in an effect, so the commit that first
|
||||
// sees tab b still carries tab a's turns. Only transcriptLoading says so.
|
||||
const carriedOver = [userTextMessage('m1', 'sent on a')]
|
||||
await mount('a')
|
||||
await act(async () =>
|
||||
renderer?.update(createElement(Harness, { tabId: 'a', messages: carriedOver }))
|
||||
)
|
||||
|
||||
await act(async () =>
|
||||
renderer?.update(
|
||||
createElement(Harness, {
|
||||
tabId: 'b',
|
||||
messages: carriedOver,
|
||||
launchDraft: 'issue link',
|
||||
transcriptLoading: true
|
||||
})
|
||||
)
|
||||
)
|
||||
expect(state?.composerText).toBe('')
|
||||
|
||||
await act(async () =>
|
||||
renderer?.update(createElement(Harness, { tabId: 'b', launchDraft: 'issue link' }))
|
||||
)
|
||||
expect(state?.composerText).toBe('issue link')
|
||||
})
|
||||
|
||||
it('does not retire an adopted prefill from the transcript it is still showing', async () => {
|
||||
const carriedOver = [userTextMessage('m1', 'sent on a')]
|
||||
await mount('b')
|
||||
await act(async () =>
|
||||
renderer?.update(createElement(Harness, { tabId: 'b', launchDraft: 'issue link' }))
|
||||
)
|
||||
expect(state?.composerText).toBe('issue link')
|
||||
|
||||
await act(async () =>
|
||||
renderer?.update(createElement(Harness, { tabId: 'a', messages: carriedOver }))
|
||||
)
|
||||
await act(async () =>
|
||||
renderer?.update(
|
||||
createElement(Harness, {
|
||||
tabId: 'b',
|
||||
messages: carriedOver,
|
||||
launchDraft: 'issue link',
|
||||
transcriptLoading: true
|
||||
})
|
||||
)
|
||||
)
|
||||
expect(state?.composerText).toBe('issue link')
|
||||
|
||||
await act(async () =>
|
||||
renderer?.update(createElement(Harness, { tabId: 'b', launchDraft: 'issue link' }))
|
||||
)
|
||||
expect(state?.composerText).toBe('issue link')
|
||||
})
|
||||
|
||||
it('clears an untouched prefill when the host stops publishing the launch draft', async () => {
|
||||
await mount('a')
|
||||
await act(async () =>
|
||||
renderer?.update(createElement(Harness, { tabId: 'a', launchDraft: 'issue link' }))
|
||||
)
|
||||
expect(state?.composerText).toBe('issue link')
|
||||
|
||||
await act(async () =>
|
||||
renderer?.update(createElement(Harness, { tabId: 'a', launchDraft: null }))
|
||||
)
|
||||
expect(state?.composerText).toBe('')
|
||||
})
|
||||
})
|
||||
|
|
@ -43,18 +43,27 @@ describe('useMobileNativeChatDrafts', () => {
|
|||
function Harness({
|
||||
tabId,
|
||||
sessionId = `session-${tabId}`,
|
||||
messages = []
|
||||
messages = [],
|
||||
launchDraft = null,
|
||||
chatActive = true,
|
||||
transcriptLoading = false
|
||||
}: {
|
||||
tabId: string
|
||||
sessionId?: string | null
|
||||
messages?: NativeChatMessage[]
|
||||
launchDraft?: string | null
|
||||
chatActive?: boolean
|
||||
transcriptLoading?: boolean
|
||||
}): null {
|
||||
state = useMobileNativeChatDrafts({
|
||||
hostId: 'host',
|
||||
worktreeId: 'worktree',
|
||||
tabId,
|
||||
sessionId,
|
||||
messages
|
||||
messages,
|
||||
launchDraft,
|
||||
chatActive,
|
||||
transcriptLoading
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,6 +41,15 @@ export function useMobileNativeChatDrafts(args: {
|
|||
tabId: string | null
|
||||
sessionId: string | null
|
||||
messages: readonly NativeChatMessage[]
|
||||
/** Host-provided launch context still parked as an unsent TUI-input draft. */
|
||||
launchDraft?: string | null
|
||||
/** Whether the tab is currently resolved to the chat view. Off-chat the
|
||||
* launch-draft effects hold their state instead of acting on it. */
|
||||
chatActive?: boolean
|
||||
/** `messages` is not yet this session's real history (read in flight, or the
|
||||
* transcript still belongs to the previously active tab), so it cannot be
|
||||
* trusted to decline or retire the seed. */
|
||||
transcriptLoading?: boolean
|
||||
}): {
|
||||
composerText: string
|
||||
setComposerText: Dispatch<SetStateAction<string>>
|
||||
|
|
@ -57,7 +66,16 @@ export function useMobileNativeChatDrafts(args: {
|
|||
onUnconfirmed: () => void
|
||||
) => void
|
||||
} {
|
||||
const { hostId, worktreeId, tabId, sessionId, messages } = args
|
||||
const {
|
||||
hostId,
|
||||
worktreeId,
|
||||
tabId,
|
||||
sessionId,
|
||||
messages,
|
||||
launchDraft,
|
||||
chatActive = true,
|
||||
transcriptLoading
|
||||
} = args
|
||||
const draftKey = mobileNativeChatScopeKey(hostId, worktreeId, tabId)
|
||||
const pendingKey = draftKey && sessionId ? `${draftKey}\0${sessionId}` : null
|
||||
const [drafts, setDrafts] = useState<Record<string, string>>({})
|
||||
|
|
@ -73,6 +91,63 @@ export function useMobileNativeChatDrafts(args: {
|
|||
activePendingKeyRef.current = pendingKey
|
||||
const mountedRef = useRef(false)
|
||||
|
||||
// Seeded launch-context text per tab; '' marks a permanent decline so a
|
||||
// cleared composer never resurrects the prefill.
|
||||
const seededLaunchDraftByKeyRef = useRef(new Map<string, string>())
|
||||
|
||||
// Why: launch context delivered as a TUI-input prefill is invisible in chat;
|
||||
// adopt it once as the composer draft so mobile shows the same context.
|
||||
useEffect(() => {
|
||||
if (
|
||||
!draftKey ||
|
||||
!chatActive ||
|
||||
!launchDraft?.trim() ||
|
||||
seededLaunchDraftByKeyRef.current.has(draftKey)
|
||||
) {
|
||||
return
|
||||
}
|
||||
// Why: `session.tabs` carries launchDraft before the transcript read settles,
|
||||
// and an empty (or previous tab's) list would let the decline below misjudge
|
||||
// an already-submitted prefill — long enough for a send to duplicate it.
|
||||
if (transcriptLoading) {
|
||||
return
|
||||
}
|
||||
// A user turn already in the transcript means the one-line TUI prefill was
|
||||
// submitted or deliberately cleared; decline instead of resurrecting it.
|
||||
if (messages.some((message) => normalizedUserText(message) !== null)) {
|
||||
seededLaunchDraftByKeyRef.current.set(draftKey, '')
|
||||
return
|
||||
}
|
||||
seededLaunchDraftByKeyRef.current.set(draftKey, launchDraft)
|
||||
setDrafts((previous) =>
|
||||
(previous[draftKey] ?? '') === '' ? { ...previous, [draftKey]: launchDraft } : previous
|
||||
)
|
||||
}, [chatActive, draftKey, launchDraft, messages, transcriptLoading])
|
||||
|
||||
// Drop an untouched adopted copy once the prefill is resolved elsewhere — a
|
||||
// user turn landed (sent or cleared TUI-side) or the host stopped publishing
|
||||
// it (desktop sent or reconciled it). User edits are always kept.
|
||||
useEffect(() => {
|
||||
// Same gates as the seed: off-chat there is no retraction to read (the tab
|
||||
// publishes no draft to us), and an untrusted transcript would wipe an
|
||||
// untouched copy on the strength of another tab's user turns.
|
||||
if (!draftKey || !chatActive || transcriptLoading) {
|
||||
return
|
||||
}
|
||||
const seeded = seededLaunchDraftByKeyRef.current.get(draftKey)
|
||||
if (!seeded) {
|
||||
return
|
||||
}
|
||||
const hasUserTurn = messages.some((message) => normalizedUserText(message) !== null)
|
||||
if (!hasUserTurn && launchDraft?.trim()) {
|
||||
return
|
||||
}
|
||||
seededLaunchDraftByKeyRef.current.set(draftKey, '')
|
||||
setDrafts((previous) =>
|
||||
(previous[draftKey] ?? '') === seeded ? { ...previous, [draftKey]: '' } : previous
|
||||
)
|
||||
}, [chatActive, draftKey, launchDraft, messages, transcriptLoading])
|
||||
|
||||
const setComposerText: Dispatch<SetStateAction<string>> = useCallback(
|
||||
(value) => {
|
||||
if (!draftKey) {
|
||||
|
|
|
|||
|
|
@ -99,6 +99,13 @@ export function useMobileNativeChatMessageSend(args: {
|
|||
client,
|
||||
terminal: handle,
|
||||
text,
|
||||
// Why: pre-clear only when nothing was deliberately pasted first. The heal
|
||||
// above fires only for terminals a mobile image paste marked, so a desktop
|
||||
// launch-draft prefill parked on the input line would otherwise glue onto
|
||||
// this message. An image send already led its own paste with Ctrl+U, and a
|
||||
// second one here would wipe the image it just pasted (desktop's image path
|
||||
// likewise clears once, before the paste, and never again).
|
||||
clearInputFirst: !images?.length,
|
||||
deadline,
|
||||
...(deviceTokenRef.current
|
||||
? { mobileClient: { id: deviceTokenRef.current, type: 'mobile' } }
|
||||
|
|
|
|||
|
|
@ -218,3 +218,143 @@ describe('useMobileNativeChatSession', () => {
|
|||
expect(state?.messages.map((entry) => entry.id)).toEqual(['fresh-growing-tail'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('useMobileNativeChatSession transcriptLoading', () => {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
const renders: {
|
||||
sessionId: string | null
|
||||
transcriptLoading: boolean
|
||||
status: string
|
||||
ids: string[]
|
||||
}[] = []
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
renders.length = 0
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
})
|
||||
|
||||
function Harness({
|
||||
client,
|
||||
sessionId,
|
||||
agent = 'claude'
|
||||
}: {
|
||||
client: RpcClient | null
|
||||
sessionId: string | null
|
||||
agent?: string | null
|
||||
}): null {
|
||||
const session = useMobileNativeChatSession({
|
||||
client,
|
||||
agent,
|
||||
sessionId,
|
||||
transcriptPath: null
|
||||
})
|
||||
renders.push({
|
||||
sessionId,
|
||||
transcriptLoading: session.transcriptLoading,
|
||||
status: session.status,
|
||||
ids: session.messages.map((entry) => entry.id)
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
async function mountAt(client: RpcClient | null, sessionId: string | null): Promise<void> {
|
||||
const original = console.error
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
|
||||
if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) {
|
||||
return
|
||||
}
|
||||
original(...args)
|
||||
})
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(createElement(Harness, { client, sessionId }))
|
||||
})
|
||||
} finally {
|
||||
consoleSpy.mockRestore()
|
||||
}
|
||||
}
|
||||
|
||||
it('reports loading on the very first render, before the subscription effect runs', async () => {
|
||||
// `status` starts at 'idle', so on its own it would tell the launch-draft
|
||||
// seed that an empty transcript is this session's real history.
|
||||
const subscribe: RpcClient['subscribe'] = vi.fn(() => () => {})
|
||||
await mountAt({ subscribe } as unknown as RpcClient, 'session-a')
|
||||
|
||||
expect(renders[0]).toMatchObject({ transcriptLoading: true, ids: [] })
|
||||
})
|
||||
|
||||
it('re-reads instead of resurfacing a settled read when the same identity returns', async () => {
|
||||
// Leaving chat view nulls the agent, then returning restores the identity a
|
||||
// settled read already matched — but its list was cleared, so trusting it
|
||||
// would report 'ready' over an empty transcript.
|
||||
const subscribe: RpcClient['subscribe'] = vi.fn((_method, _params, onData) => {
|
||||
onData({ type: 'snapshot', messages: [message('a-1')], hasMore: false })
|
||||
return () => {}
|
||||
})
|
||||
const client = { subscribe } as unknown as RpcClient
|
||||
await mountAt(client, 'session-a')
|
||||
expect(renders.at(-1)).toMatchObject({ status: 'ready', transcriptLoading: false })
|
||||
|
||||
// Toggle out to the terminal view, then back.
|
||||
await act(async () =>
|
||||
renderer?.update(createElement(Harness, { client, sessionId: 'session-a', agent: null }))
|
||||
)
|
||||
renders.length = 0
|
||||
await act(async () =>
|
||||
renderer?.update(createElement(Harness, { client, sessionId: 'session-a', agent: 'claude' }))
|
||||
)
|
||||
|
||||
expect(renders[0]).toMatchObject({ status: 'loading', transcriptLoading: true, ids: [] })
|
||||
})
|
||||
|
||||
it('re-reads instead of resurfacing a settled read after a reconnect', async () => {
|
||||
// A reconnect swaps the client without moving the identity; the effect
|
||||
// re-subscribes and clears the list, so the old outcome must not stand.
|
||||
const subscribe: RpcClient['subscribe'] = vi.fn((_method, _params, onData) => {
|
||||
onData({ type: 'snapshot', messages: [message('a-1')], hasMore: false })
|
||||
return () => {}
|
||||
})
|
||||
const client = { subscribe } as unknown as RpcClient
|
||||
await mountAt(client, 'session-a')
|
||||
expect(renders.at(-1)).toMatchObject({ status: 'ready' })
|
||||
|
||||
const reconnected = { subscribe: vi.fn(() => () => {}) } as unknown as RpcClient
|
||||
renders.length = 0
|
||||
await act(async () =>
|
||||
renderer?.update(createElement(Harness, { client: reconnected, sessionId: 'session-a' }))
|
||||
)
|
||||
|
||||
expect(renders[0]).toMatchObject({ status: 'loading', transcriptLoading: true, ids: [] })
|
||||
})
|
||||
|
||||
it('never hands out the previous session’s messages under the new session id', async () => {
|
||||
const subscribe: RpcClient['subscribe'] = vi.fn((_method, params, onData) => {
|
||||
if ((params as { sessionId: string }).sessionId === 'session-a') {
|
||||
onData({ type: 'snapshot', messages: [message('a-1')], hasMore: false })
|
||||
}
|
||||
return () => {}
|
||||
})
|
||||
const client = { subscribe } as unknown as RpcClient
|
||||
await mountAt(client, 'session-a')
|
||||
await act(async () =>
|
||||
renderer?.update(createElement(Harness, { client, sessionId: 'session-b' }))
|
||||
)
|
||||
|
||||
// The effect that resets the list lands a commit later, so `messages` still
|
||||
// holds session-a's transcript here — it must never surface under b, and b
|
||||
// must read as loading until its own read settles.
|
||||
const leaked = renders.find(
|
||||
(entry) => entry.sessionId === 'session-b' && entry.ids.includes('a-1')
|
||||
)
|
||||
expect(leaked).toBeUndefined()
|
||||
expect(renders.find((entry) => entry.sessionId === 'session-b')).toMatchObject({
|
||||
transcriptLoading: true,
|
||||
ids: []
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -13,6 +13,12 @@ export type MobileNativeChatStatus = 'idle' | 'loading' | 'waiting-session' | 'r
|
|||
export type MobileNativeChatSession = {
|
||||
messages: NativeChatMessage[]
|
||||
status: MobileNativeChatStatus
|
||||
/** True while `messages` cannot be trusted as this session's real history:
|
||||
* the read is in flight, OR the subscription effect has not yet caught up to
|
||||
* a just-changed agent/session, so `messages`/`status` still describe the
|
||||
* previous tab. Consumers that decide something from an empty transcript
|
||||
* (the launch-draft seed) must wait for this to clear. */
|
||||
transcriptLoading: boolean
|
||||
error?: string
|
||||
/** True when an older page may exist (the last read filled the window). */
|
||||
hasMore: boolean
|
||||
|
|
@ -22,6 +28,9 @@ export type MobileNativeChatSession = {
|
|||
loadEarlier: () => void
|
||||
}
|
||||
|
||||
// Stable empty reference so a not-yet-current read doesn't churn consumers.
|
||||
const EMPTY_MESSAGES: NativeChatMessage[] = []
|
||||
|
||||
// Small first page for a fast first paint; grows by a page as the user scrolls.
|
||||
const INITIAL_LIMIT = 40
|
||||
const PAGE = 60
|
||||
|
|
@ -42,7 +51,33 @@ export function useMobileNativeChatSession(args: {
|
|||
}): MobileNativeChatSession {
|
||||
const { client, agent, sessionId, transcriptPath } = args
|
||||
const [messages, setMessages] = useState<NativeChatMessage[]>([])
|
||||
const [status, setStatus] = useState<MobileNativeChatStatus>('idle')
|
||||
const identity = `${agent ?? ''}\0${sessionId ?? ''}\0${transcriptPath ?? ''}`
|
||||
// Pre-read status is a pure function of the props, so derive it rather than
|
||||
// letting the effect write it a commit later.
|
||||
const initialStatus: MobileNativeChatStatus =
|
||||
!client || !agent ? 'idle' : !sessionId ? 'waiting-session' : 'loading'
|
||||
// Only the settled outcome is genuinely async, and it is tagged with the
|
||||
// identity it describes so a just-switched tab is never judged by the
|
||||
// previous tab's transcript — the effect that clears `messages` is passive
|
||||
// and lands a commit late.
|
||||
const [read, setRead] = useState<{
|
||||
client: RpcClient
|
||||
identity: string
|
||||
status: MobileNativeChatStatus
|
||||
} | null>(null)
|
||||
// Drop it the moment its subscription stops being the live one — identity and
|
||||
// client are the effect's only inputs, so together they catch every re-run.
|
||||
// Without this a toggle out of chat view and back (agent null, then the same
|
||||
// identity again) would resurface a settled 'ready' over an emptied list.
|
||||
let current = read
|
||||
if (current !== null && (current.identity !== identity || current.client !== client)) {
|
||||
current = null
|
||||
setRead(null)
|
||||
}
|
||||
// A settled read only counts while the props still call for one: losing the
|
||||
// client/agent/session means idle or waiting-session outranks it outright.
|
||||
const settled = initialStatus === 'loading' ? current : null
|
||||
const status = settled ? settled.status : initialStatus
|
||||
const [error, setError] = useState<string | undefined>(undefined)
|
||||
const [hasMore, setHasMore] = useState(false)
|
||||
const [loadingEarlier, setLoadingEarlier] = useState(false)
|
||||
|
|
@ -78,16 +113,12 @@ export function useMobileNativeChatSession(args: {
|
|||
setHasMore(false)
|
||||
beforeOffsetRef.current = null
|
||||
if (!client || !agent) {
|
||||
setStatus('idle')
|
||||
return
|
||||
}
|
||||
if (!sessionId) {
|
||||
setStatus('waiting-session')
|
||||
return
|
||||
}
|
||||
|
||||
setStatus('loading')
|
||||
|
||||
const unsubscribe = client.subscribe(
|
||||
'nativeChat.subscribe',
|
||||
{
|
||||
|
|
@ -121,7 +152,7 @@ export function useMobileNativeChatSession(args: {
|
|||
return
|
||||
}
|
||||
if (applied.kind === 'error') {
|
||||
setStatus('error')
|
||||
setRead({ client, identity, status: 'error' })
|
||||
setError(applied.error)
|
||||
return
|
||||
}
|
||||
|
|
@ -140,7 +171,7 @@ export function useMobileNativeChatSession(args: {
|
|||
setLoadingEarlier(false)
|
||||
beforeOffsetRef.current = null
|
||||
}
|
||||
setStatus('ready')
|
||||
setRead({ client, identity, status: 'ready' })
|
||||
}
|
||||
)
|
||||
|
||||
|
|
@ -148,7 +179,7 @@ export function useMobileNativeChatSession(args: {
|
|||
cancelled = true
|
||||
unsubscribe()
|
||||
}
|
||||
}, [client, agent, sessionId, transcriptPath, setList])
|
||||
}, [client, agent, sessionId, transcriptPath, identity, setList])
|
||||
|
||||
const loadEarlier = useCallback(() => {
|
||||
if (!client || !agent || !sessionId || loadingEarlierRef.current || !hasMore) {
|
||||
|
|
@ -215,5 +246,15 @@ export function useMobileNativeChatSession(args: {
|
|||
})()
|
||||
}, [client, agent, sessionId, transcriptPath, hasMore, setList])
|
||||
|
||||
return { messages, status, error, hasMore, loadingEarlier, loadEarlier }
|
||||
return {
|
||||
// Withheld until the settled read belongs to this identity: the effect that
|
||||
// clears the previous tab's list is passive, so `messages` lags a commit.
|
||||
messages: settled ? messages : EMPTY_MESSAGES,
|
||||
status,
|
||||
transcriptLoading: status === 'loading',
|
||||
error,
|
||||
hasMore,
|
||||
loadingEarlier,
|
||||
loadEarlier
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26786,6 +26786,7 @@ export class OrcaRuntimeService {
|
|||
...(tab.color != null ? { color: tab.color } : {}),
|
||||
...(tab.isPinned ? { isPinned: true } : {}),
|
||||
...(tab.viewMode ? { viewMode: tab.viewMode } : {}),
|
||||
...(tab.launchDraft ? { launchDraft: tab.launchDraft } : {}),
|
||||
isActive: tab.isActive,
|
||||
...(terminalHandle
|
||||
? { status: 'ready' as const, terminal: terminalHandle }
|
||||
|
|
|
|||
|
|
@ -32,14 +32,18 @@ const mocks = vi.hoisted(() => ({
|
|||
sendNativeChatMessageVerified: vi.fn(),
|
||||
trackPendingSend: vi.fn(),
|
||||
setDraft: vi.fn(),
|
||||
draftScopeKeys: [] as string[]
|
||||
draftScopeKeys: [] as string[],
|
||||
clearNativeChatLaunchDraft: vi.fn(),
|
||||
markNativeChatLaunchDraftAdopted: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../../store', () => {
|
||||
const state = {
|
||||
dictationState: 'idle',
|
||||
settings: { voice: { enabled: false }, nativeChatSessionOptions: {} },
|
||||
updateSettings: vi.fn()
|
||||
updateSettings: vi.fn(),
|
||||
clearNativeChatLaunchDraft: mocks.clearNativeChatLaunchDraft,
|
||||
markNativeChatLaunchDraftAdopted: mocks.markNativeChatLaunchDraftAdopted
|
||||
}
|
||||
const useAppStore = (selector: (value: typeof state) => unknown) => selector(state)
|
||||
useAppStore.getState = () => state
|
||||
|
|
@ -201,6 +205,22 @@ describe('NativeChatComposer', () => {
|
|||
expect(mocks.trackPendingSend).toHaveBeenCalledWith(mocks.sendHandle, 'pending-1')
|
||||
})
|
||||
|
||||
it('retires the launch-draft seed once a send clears the TUI input line', () => {
|
||||
render(
|
||||
<NativeChatComposer
|
||||
terminalTabId="tab-1"
|
||||
paneKey="tab-1:leaf-1"
|
||||
targetPtyId="pty-1"
|
||||
agent="codex"
|
||||
/>
|
||||
)
|
||||
expect(mocks.clearNativeChatLaunchDraft).not.toHaveBeenCalled()
|
||||
|
||||
act(() => mocks.fieldProps?.onSend?.())
|
||||
|
||||
expect(mocks.clearNativeChatLaunchDraft).toHaveBeenCalledWith('tab-1')
|
||||
})
|
||||
|
||||
it('keeps the draft scope anchored to the pane while the PTY reconnects', () => {
|
||||
const view = render(
|
||||
<NativeChatComposer
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import {
|
|||
} from './native-chat-composer-state'
|
||||
import { readNativeChatDraftCache } from './native-chat-draft-cache'
|
||||
import { useNativeChatDraft } from './use-native-chat-draft'
|
||||
import { useNativeChatLaunchDraftAdoption } from './use-native-chat-launch-draft-adoption'
|
||||
import { NativeChatComposerField } from './NativeChatComposerField'
|
||||
import {
|
||||
nativeChatComposerTargetIsRemote,
|
||||
|
|
@ -73,7 +74,9 @@ export const NativeChatComposer = forwardRef<NativeChatComposerHandle, NativeCha
|
|||
onOptimisticSendCanceled,
|
||||
onSlashCommand,
|
||||
onSwitchToTerminal,
|
||||
readTerminalScreen
|
||||
readTerminalScreen,
|
||||
launchDraft,
|
||||
launchDraftResolved = false
|
||||
},
|
||||
ref
|
||||
): React.JSX.Element {
|
||||
|
|
@ -84,6 +87,15 @@ export const NativeChatComposer = forwardRef<NativeChatComposerHandle, NativeCha
|
|||
const draftScopeKey = paneKey
|
||||
const { draft, setDraft } = useNativeChatDraft(draftScopeKey)
|
||||
const [caret, setCaret] = useState(draft.length)
|
||||
useNativeChatLaunchDraftAdoption({
|
||||
terminalTabId,
|
||||
agent,
|
||||
launchDraft,
|
||||
launchDraftResolved,
|
||||
draft,
|
||||
setDraft,
|
||||
setCaret
|
||||
})
|
||||
const [history, setHistory] = useState<HistoryState>(EMPTY_HISTORY)
|
||||
const [activeSuggestion, setActiveSuggestion] = useState(0)
|
||||
const [notice, setNotice] = useState<string | null>(null)
|
||||
|
|
@ -284,6 +296,9 @@ export const NativeChatComposer = forwardRef<NativeChatComposerHandle, NativeCha
|
|||
clearSkillOrigin()
|
||||
clearImageAttachments()
|
||||
setNotice(null)
|
||||
// Why: the send path pre-clears the TUI input line, so any launch-draft
|
||||
// prefill still parked there is gone — retire the composer seed with it.
|
||||
useAppStore.getState().clearNativeChatLaunchDraft(terminalTabId)
|
||||
}, [
|
||||
agent,
|
||||
classifySend,
|
||||
|
|
@ -297,6 +312,7 @@ export const NativeChatComposer = forwardRef<NativeChatComposerHandle, NativeCha
|
|||
onOptimisticSend,
|
||||
onSlashCommand,
|
||||
sessionOptionsSurface,
|
||||
terminalTabId,
|
||||
trackPendingSend,
|
||||
setDraft
|
||||
])
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { useAppStore } from '../../store'
|
||||
import { useNativeChatLaunchDraftSignal } from './use-native-chat-launch-draft-adoption'
|
||||
import type { NativeChatSession } from '../../../../shared/native-chat-types'
|
||||
import { useNativeChatLiveSession } from './use-native-chat-live-session'
|
||||
import { selectNativeChatViewState } from './native-chat-view-state'
|
||||
|
|
@ -140,6 +141,15 @@ function NativeChatResolvedView({
|
|||
const launchPrompt = useAppStore((s) => s.nativeChatLaunchPromptByTabId[terminalTabId] ?? null)
|
||||
const clearNativeChatLaunchPrompt = useAppStore((s) => s.clearNativeChatLaunchPrompt)
|
||||
const paneLaunchPrompt = launchPrompt?.agent === agent ? launchPrompt : null
|
||||
// Launch context prefilled into the TUI input as an unsent draft; the
|
||||
// composer adopts it so the GUI view shows the same context as the TUI.
|
||||
// Shape matches NativeChatComposer's two launch-draft props, so it spreads.
|
||||
const launchDraftSignal = useNativeChatLaunchDraftSignal({
|
||||
terminalTabId,
|
||||
agent,
|
||||
messages: session.messages,
|
||||
transcriptLoading: session.readPhase === 'loading'
|
||||
})
|
||||
// The live-session merge reconciles hooks with replayable transcript turn
|
||||
// boundaries; all working consumers must use that one lifecycle decision.
|
||||
const liveWorking = session.status === 'working'
|
||||
|
|
@ -442,6 +452,7 @@ function NativeChatResolvedView({
|
|||
onSlashCommand={onSlashCommand}
|
||||
onSwitchToTerminal={onSwitchToTerminal}
|
||||
readTerminalScreen={readTerminalScreen}
|
||||
{...launchDraftSignal}
|
||||
/>
|
||||
)}
|
||||
{contextMenu.menu}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { AgentType } from '../../../../shared/agent-status-types'
|
||||
import type { NativeChatLaunchDraft } from '@/lib/native-chat-launch-prompt'
|
||||
|
||||
export type NativeChatComposerProps = {
|
||||
/** Tab hosting the agent; used to resolve the live ptyId + runtime settings. */
|
||||
|
|
@ -24,6 +25,10 @@ export type NativeChatComposerProps = {
|
|||
onSwitchToTerminal?: () => void
|
||||
/** Reads the hosted TUI's current rendered screen when chat is entered. */
|
||||
readTerminalScreen?: () => string | null
|
||||
/** Launch context prefilled into the TUI input as an unsent draft; adopted as the composer draft. */
|
||||
launchDraft?: NativeChatLaunchDraft | null
|
||||
/** True once the transcript shows the TUI-side draft was submitted or cleared. */
|
||||
launchDraftResolved?: boolean
|
||||
}
|
||||
|
||||
export type NativeChatComposerHandle = {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,87 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import type { NativeChatMessage } from '../../../../shared/native-chat-types'
|
||||
import {
|
||||
launchDraftResolvedByTranscript,
|
||||
nativeChatLaunchDraftTurnBaseline
|
||||
} from './native-chat-launch-draft-resolution'
|
||||
|
||||
function userMessage(id: string, text: string): NativeChatMessage {
|
||||
return { id, role: 'user', blocks: [{ type: 'text', text }], timestamp: 1, source: 'transcript' }
|
||||
}
|
||||
|
||||
function assistantMessage(id: string, text: string): NativeChatMessage {
|
||||
return {
|
||||
id,
|
||||
role: 'assistant',
|
||||
blocks: [{ type: 'text', text }],
|
||||
timestamp: 2,
|
||||
source: 'transcript'
|
||||
}
|
||||
}
|
||||
|
||||
describe('launchDraftResolvedByTranscript', () => {
|
||||
const SEEDED_AT = 100_000
|
||||
|
||||
it('resolves on any user turn at or after the seed time, even with different text', () => {
|
||||
// The TUI input holds one line: a later user turn means the prefill was
|
||||
// submitted with that turn (possibly edited/concatenated) or cleared first.
|
||||
const submitted = { ...userMessage('u1', 'unrelated text'), timestamp: SEEDED_AT + 5_000 }
|
||||
expect(launchDraftResolvedByTranscript({ createdAt: SEEDED_AT }, [submitted])).toBe(true)
|
||||
})
|
||||
|
||||
it('ignores assistant turns and user turns from before the seed', () => {
|
||||
const early = { ...userMessage('u1', 'old turn'), timestamp: SEEDED_AT - 50_000 }
|
||||
const reply = { ...assistantMessage('a1', 'hello'), timestamp: SEEDED_AT + 5_000 }
|
||||
expect(launchDraftResolvedByTranscript({ createdAt: SEEDED_AT }, [early, reply])).toBe(false)
|
||||
expect(launchDraftResolvedByTranscript({ createdAt: SEEDED_AT }, [])).toBe(false)
|
||||
})
|
||||
|
||||
it('resolves on undated user turns (Grok omits row timestamps)', () => {
|
||||
const undated = { ...userMessage('u1', 'submitted in the TUI'), timestamp: null }
|
||||
expect(launchDraftResolvedByTranscript({ createdAt: SEEDED_AT }, [undated])).toBe(true)
|
||||
})
|
||||
|
||||
it('resolves when the executing host clock trails the renderer within the slack', () => {
|
||||
// SSH/remote workspaces stamp the JSONL on the other host's clock.
|
||||
const behind = { ...userMessage('u1', 'submitted'), timestamp: SEEDED_AT - 1_500 }
|
||||
expect(launchDraftResolvedByTranscript({ createdAt: SEEDED_AT }, [behind])).toBe(true)
|
||||
})
|
||||
|
||||
it('resolves past wider clock skew once a new tail user turn lands', () => {
|
||||
const stale = { ...userMessage('u1', 'earlier turn'), timestamp: SEEDED_AT - 600_000 }
|
||||
const baseline = { userTurnCount: 1, lastUserTurnId: 'u1' }
|
||||
expect(launchDraftResolvedByTranscript({ createdAt: SEEDED_AT }, [stale], baseline)).toBe(false)
|
||||
|
||||
const next = { ...userMessage('u2', 'submitted'), timestamp: SEEDED_AT - 600_000 + 10 }
|
||||
expect(launchDraftResolvedByTranscript({ createdAt: SEEDED_AT }, [stale, next], baseline)).toBe(
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
it('does not resolve when "load earlier" only prepends history', () => {
|
||||
const tail = { ...userMessage('u9', 'earlier turn'), timestamp: SEEDED_AT - 600_000 }
|
||||
const baseline = { userTurnCount: 1, lastUserTurnId: 'u9' }
|
||||
const paged = [
|
||||
{ ...userMessage('u7', 'older'), timestamp: SEEDED_AT - 900_000 },
|
||||
{ ...userMessage('u8', 'older'), timestamp: SEEDED_AT - 800_000 },
|
||||
tail
|
||||
]
|
||||
expect(launchDraftResolvedByTranscript({ createdAt: SEEDED_AT }, paged, baseline)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('nativeChatLaunchDraftTurnBaseline', () => {
|
||||
it('snapshots the user-turn count and tail id, ignoring assistant turns', () => {
|
||||
expect(
|
||||
nativeChatLaunchDraftTurnBaseline([
|
||||
userMessage('u1', 'one'),
|
||||
assistantMessage('a1', 'reply'),
|
||||
userMessage('u2', 'two')
|
||||
])
|
||||
).toEqual({ userTurnCount: 2, lastUserTurnId: 'u2' })
|
||||
expect(nativeChatLaunchDraftTurnBaseline([])).toEqual({
|
||||
userTurnCount: 0,
|
||||
lastUserTurnId: null
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
// When a launch-time draft (context prefilled into the agent's TUI input, never
|
||||
// submitted) stops being live. Pure so the rule stays unit-testable without React.
|
||||
|
||||
import type { NativeChatMessage } from '../../../../shared/native-chat-types'
|
||||
import type { NativeChatLaunchDraft } from '@/lib/native-chat-launch-prompt'
|
||||
import { LIFECYCLE_CLOCK_SKEW_SLACK_MS } from './native-chat-live-status'
|
||||
|
||||
/** The transcript's user turns as seen when a launch draft was first observed.
|
||||
* A new *tail* turn past this is the timestamp-free proof that the TUI prefill
|
||||
* was resolved; "load earlier" prepends, so it cannot move the tail. */
|
||||
export type NativeChatLaunchDraftTurnBaseline = {
|
||||
userTurnCount: number
|
||||
lastUserTurnId: string | null
|
||||
}
|
||||
|
||||
export function nativeChatLaunchDraftTurnBaseline(
|
||||
messages: NativeChatMessage[]
|
||||
): NativeChatLaunchDraftTurnBaseline {
|
||||
const userTurns = messages.filter((message) => message.role === 'user')
|
||||
return {
|
||||
userTurnCount: userTurns.length,
|
||||
lastUserTurnId: userTurns.at(-1)?.id ?? null
|
||||
}
|
||||
}
|
||||
|
||||
// Why: the TUI input holds one line — any user turn after the draft was seeded
|
||||
// means that prefill was either submitted with the turn or deliberately cleared
|
||||
// first, so the composer copy must not linger as stale context. Text matching
|
||||
// would miss TUI-edited submissions and concatenated remote sends.
|
||||
//
|
||||
// A launch draft is seeded at agent launch, so its session starts with zero user
|
||||
// turns. That makes "not provably older than the seed" the right test rather than
|
||||
// "stamped after the seed": Grok omits row timestamps entirely, and a remote
|
||||
// host's JSONL clock can trail the renderer's. Only a turn whose own timestamp
|
||||
// puts it before the seed by more than the cross-host slack is treated as
|
||||
// pre-existing history (a resumed session, or a `load earlier` page).
|
||||
export function launchDraftResolvedByTranscript(
|
||||
entry: Pick<NativeChatLaunchDraft, 'createdAt'>,
|
||||
messages: NativeChatMessage[],
|
||||
baseline?: NativeChatLaunchDraftTurnBaseline | null
|
||||
): boolean {
|
||||
const provablyOlder = (message: NativeChatMessage): boolean =>
|
||||
message.timestamp !== null &&
|
||||
message.timestamp + LIFECYCLE_CLOCK_SKEW_SLACK_MS < entry.createdAt
|
||||
if (messages.some((message) => message.role === 'user' && !provablyOlder(message))) {
|
||||
return true
|
||||
}
|
||||
// Backstop for host clock skew wider than the slack: the transcript grew a new
|
||||
// tail user turn since the draft was first observed.
|
||||
if (!baseline) {
|
||||
return false
|
||||
}
|
||||
const userTurns = messages.filter((message) => message.role === 'user')
|
||||
return (
|
||||
userTurns.length > baseline.userTurnCount &&
|
||||
(userTurns.at(-1)?.id ?? null) !== baseline.lastUserTurnId
|
||||
)
|
||||
}
|
||||
|
|
@ -77,7 +77,7 @@ export function mergeNativeChatLiveSession(input: NativeChatLiveMergeInput): Nat
|
|||
}
|
||||
|
||||
/** Slack for comparing transcript timestamps to hook receipt times across hosts. */
|
||||
const LIFECYCLE_CLOCK_SKEW_SLACK_MS = 2_000
|
||||
export const LIFECYCLE_CLOCK_SKEW_SLACK_MS = 2_000
|
||||
|
||||
function liveStatusOverride(
|
||||
hookState: AgentStatusState | null,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,257 @@
|
|||
// @vitest-environment happy-dom
|
||||
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { NativeChatLaunchDraft } from '@/lib/native-chat-launch-prompt'
|
||||
import type { NativeChatMessage } from '../../../../shared/native-chat-types'
|
||||
import {
|
||||
useNativeChatLaunchDraftAdoption,
|
||||
useNativeChatLaunchDraftSignal
|
||||
} from './use-native-chat-launch-draft-adoption'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
markNativeChatLaunchDraftAdopted: vi.fn(),
|
||||
clearNativeChatLaunchDraft: vi.fn(),
|
||||
storeState: { nativeChatLaunchDraftByTabId: {} as Record<string, NativeChatLaunchDraft> }
|
||||
}))
|
||||
|
||||
vi.mock('../../store', () => {
|
||||
const useAppStore = ((selector: (state: unknown) => unknown) =>
|
||||
selector(mocks.storeState)) as unknown as {
|
||||
(selector: (state: unknown) => unknown): unknown
|
||||
getState: () => unknown
|
||||
}
|
||||
useAppStore.getState = () => ({
|
||||
...mocks.storeState,
|
||||
markNativeChatLaunchDraftAdopted: mocks.markNativeChatLaunchDraftAdopted,
|
||||
clearNativeChatLaunchDraft: mocks.clearNativeChatLaunchDraft
|
||||
})
|
||||
return { useAppStore }
|
||||
})
|
||||
|
||||
function launchDraft(overrides: Partial<NativeChatLaunchDraft> = {}): NativeChatLaunchDraft {
|
||||
return {
|
||||
tabId: 'tab-1',
|
||||
agent: 'claude',
|
||||
text: 'https://github.com/o/r/issues/12',
|
||||
createdAt: 1000,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function setup(args: {
|
||||
launchDraft: NativeChatLaunchDraft | null
|
||||
launchDraftResolved?: boolean
|
||||
draft?: string
|
||||
agent?: string
|
||||
}): { setDraft: ReturnType<typeof vi.fn>; setCaret: ReturnType<typeof vi.fn> } {
|
||||
const setDraft = vi.fn()
|
||||
const setCaret = vi.fn()
|
||||
renderHook(() =>
|
||||
useNativeChatLaunchDraftAdoption({
|
||||
terminalTabId: 'tab-1',
|
||||
agent: args.agent ?? 'claude',
|
||||
launchDraft: args.launchDraft,
|
||||
launchDraftResolved: args.launchDraftResolved ?? false,
|
||||
draft: args.draft ?? '',
|
||||
setDraft,
|
||||
setCaret
|
||||
})
|
||||
)
|
||||
return { setDraft, setCaret }
|
||||
}
|
||||
|
||||
const SEEDED_AT = 1_000_000
|
||||
|
||||
function userTurn(id: string, timestamp: number | null): NativeChatMessage {
|
||||
return { id, role: 'user', blocks: [{ type: 'text', text: id }], timestamp, source: 'transcript' }
|
||||
}
|
||||
|
||||
type SignalProps = { messages: NativeChatMessage[]; transcriptLoading?: boolean }
|
||||
|
||||
function renderSignal(messages: NativeChatMessage[], transcriptLoading = false) {
|
||||
const initialProps: SignalProps = { messages, transcriptLoading }
|
||||
return renderHook(
|
||||
(props: SignalProps) =>
|
||||
useNativeChatLaunchDraftSignal({
|
||||
terminalTabId: 'tab-1',
|
||||
agent: 'claude',
|
||||
messages: props.messages,
|
||||
transcriptLoading: props.transcriptLoading === true
|
||||
}),
|
||||
{ initialProps }
|
||||
)
|
||||
}
|
||||
|
||||
describe('useNativeChatLaunchDraftSignal', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.storeState.nativeChatLaunchDraftByTabId = {
|
||||
'tab-1': launchDraft({ createdAt: SEEDED_AT })
|
||||
}
|
||||
})
|
||||
|
||||
it('resolves on an undated user turn (Grok omits row timestamps)', () => {
|
||||
const { result } = renderSignal([userTurn('u1', null)])
|
||||
|
||||
expect(result.current.launchDraftResolved).toBe(true)
|
||||
})
|
||||
|
||||
it('resolves a new turn even when the executing host clock runs far behind', () => {
|
||||
// SSH/remote workspaces stamp the JSONL on the other host's clock, so no
|
||||
// timestamp on either side proves the turn is new — the baseline does.
|
||||
const stale = userTurn('u1', SEEDED_AT - 600_000)
|
||||
const { result, rerender } = renderSignal([stale])
|
||||
expect(result.current.launchDraftResolved).toBe(false)
|
||||
|
||||
rerender({ messages: [stale, userTurn('u2', SEEDED_AT - 599_000)] })
|
||||
expect(result.current.launchDraftResolved).toBe(true)
|
||||
})
|
||||
|
||||
it('does not resolve when "load earlier" only prepends older history', () => {
|
||||
const tail = userTurn('u9', SEEDED_AT - 600_000)
|
||||
const { result, rerender } = renderSignal([tail])
|
||||
expect(result.current.launchDraftResolved).toBe(false)
|
||||
|
||||
rerender({ messages: [userTurn('u7', SEEDED_AT - 900_000), tail] })
|
||||
expect(result.current.launchDraftResolved).toBe(false)
|
||||
})
|
||||
|
||||
it('does not resolve when a loading transcript backfills older history', () => {
|
||||
// The baseline must not be snapshotted on the empty in-flight list: the
|
||||
// backfill itself would then push the count above it and silently drop a
|
||||
// seed the user never saw.
|
||||
const { result, rerender } = renderSignal([], true)
|
||||
expect(result.current.launchDraftResolved).toBe(false)
|
||||
|
||||
rerender({
|
||||
messages: [
|
||||
userTurn('u1', SEEDED_AT - 900_000),
|
||||
userTurn('u2', SEEDED_AT - 800_000),
|
||||
userTurn('u3', SEEDED_AT - 700_000)
|
||||
],
|
||||
transcriptLoading: false
|
||||
})
|
||||
|
||||
expect(result.current.launchDraftResolved).toBe(false)
|
||||
expect(result.current.launchDraft).not.toBeNull()
|
||||
})
|
||||
|
||||
it('still resolves a genuinely new turn after that backfilled history', () => {
|
||||
const history = [userTurn('u1', SEEDED_AT - 900_000), userTurn('u2', SEEDED_AT - 800_000)]
|
||||
const { result, rerender } = renderSignal([], true)
|
||||
|
||||
rerender({ messages: history, transcriptLoading: false })
|
||||
expect(result.current.launchDraftResolved).toBe(false)
|
||||
|
||||
rerender({
|
||||
messages: [...history, userTurn('u3', SEEDED_AT - 799_000)],
|
||||
transcriptLoading: false
|
||||
})
|
||||
expect(result.current.launchDraftResolved).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps a settled baseline across a later transcript reload', () => {
|
||||
// Discarding it and re-taking from the fuller list would swallow the very
|
||||
// user turn that resolves the draft, so a stale prefill gets re-adopted.
|
||||
const { result, rerender } = renderSignal([])
|
||||
expect(result.current.launchDraftResolved).toBe(false)
|
||||
|
||||
// Provably older than the seed, so only the baseline can resolve it.
|
||||
const turn = userTurn('u1', SEEDED_AT - 600_000)
|
||||
rerender({ messages: [turn], transcriptLoading: true })
|
||||
expect(result.current.launchDraftResolved).toBe(false)
|
||||
|
||||
rerender({ messages: [turn], transcriptLoading: false })
|
||||
expect(result.current.launchDraftResolved).toBe(true)
|
||||
})
|
||||
|
||||
it('does not resolve from an in-flight transcript that still shows recent turns', () => {
|
||||
// A read in flight can still be rendering the previous generation's tail;
|
||||
// those turns say nothing about this draft.
|
||||
const { result } = renderSignal([userTurn('u1', SEEDED_AT)], true)
|
||||
|
||||
expect(result.current.launchDraftResolved).toBe(false)
|
||||
expect(result.current.launchDraft).not.toBeNull()
|
||||
})
|
||||
|
||||
it('ignores a draft seeded for another agent', () => {
|
||||
mocks.storeState.nativeChatLaunchDraftByTabId = {
|
||||
'tab-1': launchDraft({ agent: 'codex', createdAt: SEEDED_AT })
|
||||
}
|
||||
const { result } = renderSignal([userTurn('u1', null)])
|
||||
|
||||
expect(result.current.launchDraft).toBeNull()
|
||||
expect(result.current.launchDraftResolved).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useNativeChatLaunchDraftAdoption', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('adopts an unadopted seed into an empty composer', () => {
|
||||
const entry = launchDraft()
|
||||
const { setDraft, setCaret } = setup({ launchDraft: entry })
|
||||
|
||||
expect(mocks.markNativeChatLaunchDraftAdopted).toHaveBeenCalledWith('tab-1')
|
||||
expect(setDraft).toHaveBeenCalledWith(entry.text)
|
||||
expect(setCaret).toHaveBeenCalledWith(entry.text.length)
|
||||
expect(mocks.clearNativeChatLaunchDraft).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('declines the seed permanently when the composer already holds text', () => {
|
||||
const { setDraft } = setup({ launchDraft: launchDraft(), draft: 'user typed first' })
|
||||
|
||||
expect(mocks.markNativeChatLaunchDraftAdopted).toHaveBeenCalledWith('tab-1')
|
||||
expect(setDraft).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does nothing for a different agent or a missing seed', () => {
|
||||
setup({ launchDraft: launchDraft({ agent: 'codex' }) })
|
||||
setup({ launchDraft: null })
|
||||
|
||||
expect(mocks.markNativeChatLaunchDraftAdopted).not.toHaveBeenCalled()
|
||||
expect(mocks.clearNativeChatLaunchDraft).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not re-adopt an already adopted seed after the user clears the composer', () => {
|
||||
const { setDraft } = setup({ launchDraft: launchDraft({ adopted: true }), draft: '' })
|
||||
|
||||
expect(setDraft).not.toHaveBeenCalled()
|
||||
expect(mocks.markNativeChatLaunchDraftAdopted).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('clears an untouched adopted copy once the transcript resolves the draft', () => {
|
||||
const entry = launchDraft({ adopted: true })
|
||||
const { setDraft, setCaret } = setup({
|
||||
launchDraft: entry,
|
||||
launchDraftResolved: true,
|
||||
draft: entry.text
|
||||
})
|
||||
|
||||
expect(setDraft).toHaveBeenCalledWith('')
|
||||
expect(setCaret).toHaveBeenCalledWith(0)
|
||||
expect(mocks.clearNativeChatLaunchDraft).toHaveBeenCalledWith('tab-1')
|
||||
})
|
||||
|
||||
it('keeps user edits when the transcript resolves the draft', () => {
|
||||
const { setDraft } = setup({
|
||||
launchDraft: launchDraft({ adopted: true }),
|
||||
launchDraftResolved: true,
|
||||
draft: 'edited context'
|
||||
})
|
||||
|
||||
expect(setDraft).not.toHaveBeenCalled()
|
||||
expect(mocks.clearNativeChatLaunchDraft).toHaveBeenCalledWith('tab-1')
|
||||
})
|
||||
|
||||
it('drops an unadopted seed once the transcript resolves it', () => {
|
||||
const { setDraft } = setup({ launchDraft: launchDraft(), launchDraftResolved: true })
|
||||
|
||||
expect(setDraft).not.toHaveBeenCalled()
|
||||
expect(mocks.markNativeChatLaunchDraftAdopted).not.toHaveBeenCalled()
|
||||
expect(mocks.clearNativeChatLaunchDraft).toHaveBeenCalledWith('tab-1')
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useAppStore } from '../../store'
|
||||
import type { NativeChatLaunchDraft } from '@/lib/native-chat-launch-prompt'
|
||||
import type { NativeChatMessage } from '../../../../shared/native-chat-types'
|
||||
import {
|
||||
launchDraftResolvedByTranscript,
|
||||
nativeChatLaunchDraftTurnBaseline,
|
||||
type NativeChatLaunchDraftTurnBaseline
|
||||
} from './native-chat-launch-draft-resolution'
|
||||
|
||||
/** Select the pane's launch draft and whether the transcript has resolved it. */
|
||||
export function useNativeChatLaunchDraftSignal(args: {
|
||||
terminalTabId: string
|
||||
agent: string
|
||||
messages: NativeChatMessage[]
|
||||
/** The transcript read is still in flight, so `messages` is not yet the
|
||||
* session's real history. Same gate mobile's drafts hook uses. */
|
||||
transcriptLoading?: boolean
|
||||
}): { launchDraft: NativeChatLaunchDraft | null; launchDraftResolved: boolean } {
|
||||
const launchDraft = useAppStore((s) => s.nativeChatLaunchDraftByTabId[args.terminalTabId] ?? null)
|
||||
const paneLaunchDraft = launchDraft?.agent === args.agent ? launchDraft : null
|
||||
const messages = args.messages
|
||||
const transcriptLoading = args.transcriptLoading === true
|
||||
// Timestamp-free backstop: snapshot the transcript's user turns the first time
|
||||
// this draft is seen, so a host clock skewed past the slack still resolves.
|
||||
// Deferred until the read settles — a baseline taken on the empty in-flight
|
||||
// list would be exceeded by the backfill itself, dropping a draft the user
|
||||
// never saw.
|
||||
const [heldBaseline, setHeldBaseline] = useState<{
|
||||
key: string
|
||||
baseline: NativeChatLaunchDraftTurnBaseline
|
||||
} | null>(null)
|
||||
// Keyed on draft identity alone: a reload mid-draft must not discard a
|
||||
// baseline already taken from a settled transcript — re-taking it from the
|
||||
// fuller list would swallow the very user turn that resolves the draft.
|
||||
const baselineKey = paneLaunchDraft
|
||||
? `${paneLaunchDraft.tabId} ${paneLaunchDraft.createdAt}`
|
||||
: null
|
||||
// Adjusted during render, not in an effect: the same render that first sees a
|
||||
// settled transcript must already resolve against the captured baseline.
|
||||
let held = heldBaseline
|
||||
if (baselineKey === null) {
|
||||
if (heldBaseline !== null) {
|
||||
setHeldBaseline(null)
|
||||
}
|
||||
held = null
|
||||
} else if (heldBaseline?.key !== baselineKey && !transcriptLoading) {
|
||||
held = { key: baselineKey, baseline: nativeChatLaunchDraftTurnBaseline(messages) }
|
||||
setHeldBaseline(held)
|
||||
}
|
||||
const baseline = held?.baseline ?? null
|
||||
const launchDraftResolved = useMemo(
|
||||
() =>
|
||||
paneLaunchDraft && !transcriptLoading
|
||||
? launchDraftResolvedByTranscript(paneLaunchDraft, messages, baseline)
|
||||
: false,
|
||||
[paneLaunchDraft, messages, baseline, transcriptLoading]
|
||||
)
|
||||
return { launchDraft: paneLaunchDraft, launchDraftResolved }
|
||||
}
|
||||
|
||||
/**
|
||||
* Adopt launch-time draft context (e.g. a linked issue URL prefilled into the
|
||||
* TUI input buffer) into the chat composer, and drop it again once the
|
||||
* transcript shows the TUI-side copy was resolved (submitted or cleared).
|
||||
*
|
||||
* State machine per seeded draft:
|
||||
* - unadopted + composer empty → copy text into the composer, mark adopted
|
||||
* - unadopted + composer in use → mark adopted without copying (never stomp)
|
||||
* - resolved by transcript → clear the seed; also clear the composer copy
|
||||
* only when it is still the untouched seed text
|
||||
*/
|
||||
export function useNativeChatLaunchDraftAdoption(args: {
|
||||
terminalTabId: string
|
||||
agent: string
|
||||
launchDraft: NativeChatLaunchDraft | null | undefined
|
||||
launchDraftResolved: boolean
|
||||
draft: string
|
||||
setDraft: (next: string) => void
|
||||
setCaret: (next: number) => void
|
||||
}): void {
|
||||
const { terminalTabId, agent, launchDraft, launchDraftResolved, draft, setDraft, setCaret } = args
|
||||
useEffect(() => {
|
||||
if (!launchDraft || launchDraft.agent !== agent) {
|
||||
return
|
||||
}
|
||||
if (launchDraftResolved) {
|
||||
if (launchDraft.adopted && draft === launchDraft.text) {
|
||||
setDraft('')
|
||||
setCaret(0)
|
||||
}
|
||||
useAppStore.getState().clearNativeChatLaunchDraft(terminalTabId)
|
||||
return
|
||||
}
|
||||
if (launchDraft.adopted) {
|
||||
return
|
||||
}
|
||||
// Mark adopted before copying so a composer that already holds user text
|
||||
// declines the seed permanently instead of resurrecting it on a later clear.
|
||||
useAppStore.getState().markNativeChatLaunchDraftAdopted(terminalTabId)
|
||||
if (draft === '') {
|
||||
setDraft(launchDraft.text)
|
||||
setCaret(launchDraft.text.length)
|
||||
}
|
||||
}, [agent, draft, launchDraft, launchDraftResolved, setCaret, setDraft, terminalTabId])
|
||||
}
|
||||
|
|
@ -865,6 +865,26 @@ describe('useNativeChatLiveSession — notFound retry (#8401)', () => {
|
|||
expect(latest?.messages.map((m) => m.id)).toContain('a-early')
|
||||
})
|
||||
|
||||
it('still reports the loading readPhase once live content unmasks the status', async () => {
|
||||
// `status` is not a truthful read-in-flight signal: content landing
|
||||
// mid-retry outranks the spinner, so status leaves 'loading' while the read
|
||||
// is still in flight. The launch-draft baseline must never be snapshotted
|
||||
// on that partial list, so consumers need the raw phase instead.
|
||||
vi.useFakeTimers()
|
||||
const transport = getMockTransport('env-1', { autoSnapshot: false })
|
||||
transport.readSession.mockResolvedValue({ error: 'No transcript found', notFound: true })
|
||||
|
||||
await render({ paneKey: PANE, agent: AGENT, sessionId: SESSION, runtimeEnvironmentId: 'env-1' })
|
||||
|
||||
// The user's own turn arrives on the watcher before the read settles.
|
||||
await act(async () => {
|
||||
transport.emit({ type: 'appended', messages: [user('u-live', 'hi')] })
|
||||
})
|
||||
|
||||
expect(latest?.status).not.toBe('loading')
|
||||
expect(latest?.readPhase).toBe('loading')
|
||||
})
|
||||
|
||||
it('renders live-appended content even when the initial read settled into a permanent error', async () => {
|
||||
const transport = getMockTransport('env-1', { autoSnapshot: false })
|
||||
transport.readSession.mockResolvedValueOnce({ error: 'unreadable transcript' })
|
||||
|
|
|
|||
|
|
@ -47,6 +47,10 @@ export type NativeChatLiveSession = NativeChatSession & {
|
|||
loadingEarlier: boolean
|
||||
/** Grow the read window to page in older history (scrolled-to-top trigger). */
|
||||
loadEarlier: () => void
|
||||
/** Raw initial-read phase. `status` is not a substitute: a live 'working' hook
|
||||
* outranks (and so hides) 'loading', which would let a consumer deciding from
|
||||
* an empty list treat an in-flight transcript as real history. */
|
||||
readPhase: ReadState['phase']
|
||||
}
|
||||
|
||||
// Stable empty-base reference so a non-ready read doesn't churn the base axis.
|
||||
|
|
@ -82,7 +86,7 @@ function notFoundRetryDelayMs(attempt: number): number {
|
|||
return NOTFOUND_RETRY_DELAYS_MS[attempt] ?? NOTFOUND_RETRY_FIXED_DELAY_MS
|
||||
}
|
||||
|
||||
type ReadState =
|
||||
export type ReadState =
|
||||
| { phase: 'loading' }
|
||||
| { phase: 'ready'; messages: NativeChatMessage[] }
|
||||
| { phase: 'error'; error: string }
|
||||
|
|
@ -357,7 +361,7 @@ export function useNativeChatLiveSession(
|
|||
loading: read.phase === 'loading' && appended.length === 0,
|
||||
...(read.phase === 'error' && appended.length === 0 ? { error: read.error } : {})
|
||||
})
|
||||
return { ...session, hasMore, loadingEarlier, loadEarlier }
|
||||
return { ...session, hasMore, loadingEarlier, loadEarlier, readPhase: read.phase }
|
||||
}, [
|
||||
surfacedMessages,
|
||||
read,
|
||||
|
|
|
|||
|
|
@ -3999,6 +3999,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
|||
note: trimmedNote,
|
||||
startupPlan,
|
||||
quickPrompt,
|
||||
...(quickDraftPrompt ? { launchDraftPrompt: quickDraftPrompt } : {}),
|
||||
quickTelemetry,
|
||||
...(createMultiple ? { suppressTerminalFocusOnCompletion: true } : {})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|||
const mocks = vi.hoisted(() => ({
|
||||
pasteDraftWhenAgentReady: vi.fn(),
|
||||
seedNativeChatLaunchPrompt: vi.fn(),
|
||||
seedNativeChatLaunchDraft: vi.fn(),
|
||||
markNativeChatLaunchPromptFailed: vi.fn()
|
||||
}))
|
||||
|
||||
|
|
@ -14,12 +15,50 @@ vi.mock('@/store', () => ({
|
|||
useAppStore: {
|
||||
getState: () => ({
|
||||
seedNativeChatLaunchPrompt: mocks.seedNativeChatLaunchPrompt,
|
||||
seedNativeChatLaunchDraft: mocks.seedNativeChatLaunchDraft,
|
||||
markNativeChatLaunchPromptFailed: mocks.markNativeChatLaunchPromptFailed
|
||||
})
|
||||
}
|
||||
}))
|
||||
|
||||
import { deliverLaunchPromptToAgentTab } from './agent-launch-prompt-delivery'
|
||||
import {
|
||||
deliverLaunchPromptToAgentTab,
|
||||
seedNativeChatLaunchDraftForAgentTab
|
||||
} from './agent-launch-prompt-delivery'
|
||||
|
||||
describe('seedNativeChatLaunchDraftForAgentTab', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('rejects multi-line text at the helper, not just at the delivery caller', () => {
|
||||
// Worktree-create and work-item launches seed through this helper directly
|
||||
// (a Linear draft is always `Linked Linear issue: …\n<url>\n`), so the
|
||||
// Ctrl+U kill-to-start-of-LINE constraint has to live here.
|
||||
seedNativeChatLaunchDraftForAgentTab({
|
||||
tabId: 'linear-tab',
|
||||
agent: 'codex',
|
||||
text: 'Linked Linear issue: STA-1234\nhttps://linear.app/o/issue/STA-1234\n'
|
||||
})
|
||||
|
||||
expect(mocks.seedNativeChatLaunchDraft).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('seeds single-line text', () => {
|
||||
seedNativeChatLaunchDraftForAgentTab({
|
||||
tabId: 'issue-tab',
|
||||
agent: 'codex',
|
||||
text: 'https://github.com/o/r/issues/12'
|
||||
})
|
||||
|
||||
expect(mocks.seedNativeChatLaunchDraft).toHaveBeenCalledWith({
|
||||
tabId: 'issue-tab',
|
||||
agent: 'codex',
|
||||
text: 'https://github.com/o/r/issues/12',
|
||||
createdAt: expect.any(Number)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('deliverLaunchPromptToAgentTab', () => {
|
||||
beforeEach(() => {
|
||||
|
|
@ -55,7 +94,7 @@ describe('deliverLaunchPromptToAgentTab', () => {
|
|||
})
|
||||
})
|
||||
|
||||
it('does not seed for drafts, unsupported agents, or empty content', async () => {
|
||||
it('does not seed a launch prompt for drafts, unsupported agents, or empty content', async () => {
|
||||
await deliverLaunchPromptToAgentTab({
|
||||
tabId: 'draft-tab',
|
||||
agent: 'codex',
|
||||
|
|
@ -81,6 +120,82 @@ describe('deliverLaunchPromptToAgentTab', () => {
|
|||
expect(mocks.seedNativeChatLaunchPrompt).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('seeds a native-chat launch draft for supported unsubmitted content', async () => {
|
||||
await deliverLaunchPromptToAgentTab({
|
||||
tabId: 'draft-tab',
|
||||
agent: 'codex',
|
||||
content: 'Review first',
|
||||
submit: false,
|
||||
forcePaste: false
|
||||
})
|
||||
|
||||
expect(mocks.seedNativeChatLaunchDraft).toHaveBeenCalledWith({
|
||||
tabId: 'draft-tab',
|
||||
agent: 'codex',
|
||||
text: 'Review first',
|
||||
createdAt: expect.any(Number)
|
||||
})
|
||||
expect(mocks.seedNativeChatLaunchPrompt).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not seed a launch draft for multi-line content', async () => {
|
||||
// The chat send pre-clears the TUI with Ctrl+U (kill-to-start-of-LINE), so a
|
||||
// multi-line prefill (e.g. scraped session-fork context) would leave earlier
|
||||
// lines behind to glue onto the next message.
|
||||
await deliverLaunchPromptToAgentTab({
|
||||
tabId: 'fork-tab',
|
||||
agent: 'codex',
|
||||
content: 'Forked from session\n\nhttps://example.test/context',
|
||||
submit: false,
|
||||
forcePaste: false
|
||||
})
|
||||
|
||||
expect(mocks.seedNativeChatLaunchDraft).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not seed a launch draft for submitted, unsupported, or empty content', async () => {
|
||||
await deliverLaunchPromptToAgentTab({
|
||||
tabId: 'submit-tab',
|
||||
agent: 'codex',
|
||||
content: 'Fix failing checks',
|
||||
submit: true,
|
||||
forcePaste: true
|
||||
})
|
||||
await deliverLaunchPromptToAgentTab({
|
||||
tabId: 'unsupported-tab',
|
||||
agent: 'gemini',
|
||||
content: 'Review first',
|
||||
submit: false,
|
||||
forcePaste: false
|
||||
})
|
||||
await deliverLaunchPromptToAgentTab({
|
||||
tabId: 'empty-tab',
|
||||
agent: 'claude',
|
||||
content: ' ',
|
||||
submit: false,
|
||||
forcePaste: false
|
||||
})
|
||||
|
||||
expect(mocks.seedNativeChatLaunchDraft).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps the seeded launch draft when paste delivery fails', async () => {
|
||||
// A paste timeout means the TUI never got the draft — the composer copy is
|
||||
// then the only copy, so it must not be flagged or cleared.
|
||||
mocks.pasteDraftWhenAgentReady.mockResolvedValue(false)
|
||||
|
||||
await deliverLaunchPromptToAgentTab({
|
||||
tabId: 'draft-tab',
|
||||
agent: 'codex',
|
||||
content: 'Review first',
|
||||
submit: false,
|
||||
forcePaste: false
|
||||
})
|
||||
|
||||
expect(mocks.seedNativeChatLaunchDraft).toHaveBeenCalled()
|
||||
expect(mocks.markNativeChatLaunchPromptFailed).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('marks a seeded launch prompt failed when paste delivery returns false', async () => {
|
||||
mocks.pasteDraftWhenAgentReady.mockResolvedValue(false)
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,32 @@ import { isNativeChatSupportedAgent } from '@/lib/native-chat-supported-agent'
|
|||
import { useAppStore } from '@/store'
|
||||
import type { TuiAgent } from '../../../shared/types'
|
||||
|
||||
/** Seed the chat-composer copy of launch context that reaches only the TUI
|
||||
* input (argv prefill or startup paste). No-op for empty text or agents
|
||||
* without a native-chat renderer. */
|
||||
export function seedNativeChatLaunchDraftForAgentTab(args: {
|
||||
tabId: string
|
||||
agent: TuiAgent
|
||||
text: string
|
||||
}): void {
|
||||
// Single-line only: the chat send pre-clears the TUI with Ctrl+U
|
||||
// (kill-to-start-of-LINE), so a multi-line prefill (e.g. a Linear issue block)
|
||||
// would leave earlier lines to glue onto the next message.
|
||||
if (
|
||||
args.text.trim().length === 0 ||
|
||||
args.text.includes('\n') ||
|
||||
!isNativeChatSupportedAgent(args.agent)
|
||||
) {
|
||||
return
|
||||
}
|
||||
useAppStore.getState().seedNativeChatLaunchDraft({
|
||||
tabId: args.tabId,
|
||||
agent: args.agent,
|
||||
text: args.text,
|
||||
createdAt: Date.now()
|
||||
})
|
||||
}
|
||||
|
||||
export function deliverLaunchPromptToAgentTab(args: {
|
||||
tabId: string
|
||||
agent: TuiAgent
|
||||
|
|
@ -24,6 +50,10 @@ export function deliverLaunchPromptToAgentTab(args: {
|
|||
text: content,
|
||||
createdAt: Date.now()
|
||||
})
|
||||
} else if (submit !== true) {
|
||||
// Why: an unsubmitted draft lives only in the TUI input buffer; seed the
|
||||
// chat-composer copy so the context isn't invisible in the GUI view.
|
||||
seedNativeChatLaunchDraftForAgentTab({ tabId, agent, text: content })
|
||||
}
|
||||
|
||||
// Why: native-prefill agents (claude/openclaude etc.) get the prompt at launch,
|
||||
|
|
|
|||
|
|
@ -241,12 +241,13 @@ export async function createGitHubWorkItemWorkspaceInBackground(
|
|||
if (!deps.hasPendingCreate(creationId)) {
|
||||
return { kind: 'background-started' }
|
||||
}
|
||||
const { startupPlan, quickPrompt, quickTelemetry } = buildGitHubWorkItemStartupPlan({
|
||||
agent,
|
||||
item: args.item,
|
||||
repo,
|
||||
store
|
||||
})
|
||||
const { startupPlan, quickPrompt, launchDraftPrompt, quickTelemetry } =
|
||||
buildGitHubWorkItemStartupPlan({
|
||||
agent,
|
||||
item: args.item,
|
||||
repo,
|
||||
store
|
||||
})
|
||||
if (agent && !startupPlan) {
|
||||
deps.toastError(agentLaunchCommandErrorMessage())
|
||||
abandonStagedCreate(creationId, restoreView, deps)
|
||||
|
|
@ -310,6 +311,7 @@ export async function createGitHubWorkItemWorkspaceInBackground(
|
|||
...(issueCommand ? { issueCommand } : {}),
|
||||
startupPlan,
|
||||
quickPrompt,
|
||||
...(launchDraftPrompt ? { launchDraftPrompt } : {}),
|
||||
quickTelemetry
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -174,11 +174,14 @@ export function buildGitHubWorkItemStartupPlan(args: {
|
|||
}): {
|
||||
startupPlan: AgentStartupPlan | null
|
||||
quickPrompt: string
|
||||
/** Draft context (issue link) that reaches only the TUI input; callers thread
|
||||
* it onto the creation request so completion can seed the chat composer. */
|
||||
launchDraftPrompt: string | null
|
||||
quickTelemetry: AgentStartedTelemetry | null
|
||||
} {
|
||||
const { agent, item, repo, store } = args
|
||||
if (!agent) {
|
||||
return { startupPlan: null, quickPrompt: '', quickTelemetry: null }
|
||||
return { startupPlan: null, quickPrompt: '', launchDraftPrompt: null, quickTelemetry: null }
|
||||
}
|
||||
const { prompt: quickPrompt, draftPrompt } = resolveGitHubWorkItemPrompt(item)
|
||||
// Why: runtime-owned repos launch on their owner host, not on the client
|
||||
|
|
@ -233,6 +236,7 @@ export function buildGitHubWorkItemStartupPlan(args: {
|
|||
return {
|
||||
startupPlan,
|
||||
quickPrompt,
|
||||
launchDraftPrompt: draftPrompt || null,
|
||||
quickTelemetry: {
|
||||
agent_kind: tuiAgentToAgentKind(agent),
|
||||
launch_source: 'new_workspace_composer',
|
||||
|
|
|
|||
|
|
@ -59,6 +59,24 @@ describe('queueHookCommandsForFirstWorktreeTab', () => {
|
|||
expect(deliver).toHaveBeenCalledWith(expect.anything(), 'tab-1')
|
||||
})
|
||||
|
||||
it('keeps every delivery queued for the same worktree', () => {
|
||||
// A runtime-owned create can have setup/issue commands AND an agent-tab seed
|
||||
// waiting on the same first mirrored tab; neither may evict the other.
|
||||
markWorktreeKnown('wt-1')
|
||||
setStorePartial({ tabsByWorktree: {} })
|
||||
const deliverFirst = vi.fn()
|
||||
const deliverSecond = vi.fn()
|
||||
|
||||
queueHookCommandsForFirstWorktreeTab({ worktreeId: 'wt-1', deliver: deliverFirst })
|
||||
queueHookCommandsForFirstWorktreeTab({ worktreeId: 'wt-1', deliver: deliverSecond })
|
||||
|
||||
setStorePartial({ tabsByWorktree: { 'wt-1': [{ id: 'mirror-tab-1' }] } })
|
||||
|
||||
expect(deliverFirst).toHaveBeenCalledTimes(1)
|
||||
expect(deliverSecond).toHaveBeenCalledTimes(1)
|
||||
expect(deliverSecond).toHaveBeenCalledWith(expect.anything(), 'mirror-tab-1')
|
||||
})
|
||||
|
||||
it('drops the pending delivery when the worktree is no longer known', () => {
|
||||
setStorePartial({
|
||||
tabsByWorktree: {},
|
||||
|
|
|
|||
|
|
@ -12,13 +12,21 @@ type PendingWorktreeHookCommandDelivery = {
|
|||
// the delivery until the first mirrored terminal tab lands instead of
|
||||
// dropping it. Mirrors agent-startup-delayed-delivery's lazy-subscription
|
||||
// shape: subscribed only while something is pending.
|
||||
const pendingHookCommandDeliveries = new Map<string, PendingWorktreeHookCommandDelivery>()
|
||||
// Queued per worktree rather than one-per-worktree: a runtime-owned create can
|
||||
// have both setup/issue commands and an agent-tab seed waiting on the same
|
||||
// first mirrored tab, and neither may evict the other.
|
||||
const pendingHookCommandDeliveries = new Map<string, PendingWorktreeHookCommandDelivery[]>()
|
||||
let unsubscribePendingHookCommandDeliveries: (() => void) | null = null
|
||||
|
||||
export function queueHookCommandsForFirstWorktreeTab(
|
||||
delivery: PendingWorktreeHookCommandDelivery
|
||||
): void {
|
||||
pendingHookCommandDeliveries.set(delivery.worktreeId, delivery)
|
||||
const queued = pendingHookCommandDeliveries.get(delivery.worktreeId)
|
||||
if (queued) {
|
||||
queued.push(delivery)
|
||||
} else {
|
||||
pendingHookCommandDeliveries.set(delivery.worktreeId, [delivery])
|
||||
}
|
||||
ensurePendingHookCommandSubscription()
|
||||
flushPendingHookCommandDeliveries()
|
||||
}
|
||||
|
|
@ -72,7 +80,7 @@ function stopPendingHookCommandSubscriptionIfIdle(): void {
|
|||
|
||||
function flushPendingHookCommandDeliveries(): void {
|
||||
const state = useAppStore.getState()
|
||||
for (const [worktreeId, delivery] of pendingHookCommandDeliveries) {
|
||||
for (const [worktreeId, deliveries] of pendingHookCommandDeliveries) {
|
||||
const firstTerminalTabId = state.tabsByWorktree[worktreeId]?.[0]?.id
|
||||
if (!firstTerminalTabId) {
|
||||
// Why: a worktree can be removed before its tabs ever mirror; drop the
|
||||
|
|
@ -83,9 +91,11 @@ function flushPendingHookCommandDeliveries(): void {
|
|||
continue
|
||||
}
|
||||
// Delete before delivering so store writes inside deliver cannot re-enter
|
||||
// this entry through the subscription.
|
||||
// these entries through the subscription.
|
||||
pendingHookCommandDeliveries.delete(worktreeId)
|
||||
delivery.deliver(state, firstTerminalTabId)
|
||||
for (const delivery of deliveries) {
|
||||
delivery.deliver(state, firstTerminalTabId)
|
||||
}
|
||||
}
|
||||
stopPendingHookCommandSubscriptionIfIdle()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,300 @@
|
|||
// Windows/WSL shell-quoting coverage for launchAgentInNewTab, split from
|
||||
// launch-agent-in-new-tab.test.ts to keep both files within the lines budget.
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mockCreateTab = vi.fn()
|
||||
const mockQueueTabStartupCommand = vi.fn()
|
||||
const mockPasteDraftWhenAgentReady = vi.fn()
|
||||
|
||||
const store = {
|
||||
activeRepoId: 'repo-1',
|
||||
activeWorktreeId: 'wt-1',
|
||||
settings: {
|
||||
agentCmdOverrides: {},
|
||||
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
|
||||
terminalWindowsShell?: string
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
id: 'repo-1',
|
||||
localWindowsRuntimePreference: { kind: 'inherit-global' as const }
|
||||
}
|
||||
] as {
|
||||
id: string
|
||||
localWindowsRuntimePreference:
|
||||
| { kind: 'inherit-global' }
|
||||
| { kind: 'windows-host' }
|
||||
| { kind: 'wsl'; distro: string | null }
|
||||
}[],
|
||||
repos: [{ id: 'repo-1', connectionId: null as string | null, path: '/repo' }],
|
||||
sshConnectionStates: new Map([['ssh-a', { status: 'connected' }]]),
|
||||
transientClearedAgentStatusConnectionIds: {} as Record<string, true>,
|
||||
worktreesByRepo: {
|
||||
'repo-1': [
|
||||
{
|
||||
id: 'wt-1',
|
||||
repoId: 'repo-1',
|
||||
projectId: 'repo-1',
|
||||
path: '/repo/worktree',
|
||||
displayName: 'main'
|
||||
}
|
||||
]
|
||||
},
|
||||
allWorktrees: vi.fn(() => store.worktreesByRepo['repo-1']),
|
||||
tabsByWorktree: {
|
||||
'wt-1': [{ id: 'tab-1' }]
|
||||
},
|
||||
openFiles: [] as { id: string; worktreeId: string }[],
|
||||
browserTabsByWorktree: {} as Record<string, { id: string }[]>,
|
||||
tabBarOrderByWorktree: {} as Record<string, string[]>,
|
||||
terminalLayoutsByTabId: {} as Record<
|
||||
string,
|
||||
{ activeLeafId: string | null; ptyIdsByLeafId?: Record<string, string> }
|
||||
>,
|
||||
ptyIdsByTabId: {} as Record<string, string[]>,
|
||||
createTab: mockCreateTab,
|
||||
closeTab: vi.fn(),
|
||||
queueTabStartupCommand: mockQueueTabStartupCommand,
|
||||
setActiveTabType: vi.fn(),
|
||||
setTabBarOrder: vi.fn(),
|
||||
setAgentStatus: vi.fn(),
|
||||
seedNativeChatLaunchPrompt: vi.fn(),
|
||||
seedNativeChatLaunchDraft: vi.fn(),
|
||||
markNativeChatLaunchPromptFailed: vi.fn()
|
||||
}
|
||||
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: {
|
||||
getState: () => store
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: { message: vi.fn(), error: vi.fn() }
|
||||
}))
|
||||
|
||||
vi.mock('@/components/tab-bar/reconcile-order', () => ({
|
||||
reconcileTabOrder: vi.fn(
|
||||
(_stored, termIds: string[], editorIds: string[], browserIds: string[]) => [
|
||||
...termIds,
|
||||
...editorIds,
|
||||
...browserIds
|
||||
]
|
||||
)
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/agent-paste-draft', () => ({
|
||||
pasteDraftWhenAgentReady: mockPasteDraftWhenAgentReady
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/telemetry', () => ({
|
||||
track: vi.fn(),
|
||||
tuiAgentToAgentKind: (agent: string) => agent
|
||||
}))
|
||||
|
||||
vi.mock('@/runtime/web-runtime-session', () => ({
|
||||
createWebRuntimeSessionTerminal: vi.fn(),
|
||||
isWebRuntimeSessionActive: vi.fn(() => false),
|
||||
isWebTerminalSurfaceTabId: vi.fn(() => false)
|
||||
}))
|
||||
|
||||
describe('launchAgentInNewTab Windows shell quoting', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
store.activeRepoId = 'repo-1'
|
||||
store.activeWorktreeId = 'wt-1'
|
||||
store.settings = {
|
||||
agentCmdOverrides: {},
|
||||
agentDefaultArgs: {},
|
||||
agentDefaultEnv: {},
|
||||
activeRuntimeEnvironmentId: null
|
||||
}
|
||||
store.projects = [
|
||||
{
|
||||
id: 'repo-1',
|
||||
localWindowsRuntimePreference: { kind: 'inherit-global' }
|
||||
}
|
||||
]
|
||||
store.repos = [{ id: 'repo-1', connectionId: null, path: '/repo' }]
|
||||
store.sshConnectionStates = new Map([['ssh-a', { status: 'connected' }]])
|
||||
store.transientClearedAgentStatusConnectionIds = {}
|
||||
store.worktreesByRepo = {
|
||||
'repo-1': [
|
||||
{
|
||||
id: 'wt-1',
|
||||
repoId: 'repo-1',
|
||||
projectId: 'repo-1',
|
||||
path: '/repo/worktree',
|
||||
displayName: 'main'
|
||||
}
|
||||
]
|
||||
}
|
||||
store.tabsByWorktree = { 'wt-1': [{ id: 'tab-1' }] }
|
||||
store.openFiles = []
|
||||
store.browserTabsByWorktree = {}
|
||||
store.tabBarOrderByWorktree = {}
|
||||
store.terminalLayoutsByTabId = {}
|
||||
store.ptyIdsByTabId = {}
|
||||
mockCreateTab.mockReturnValue({ id: 'tab-1' })
|
||||
mockPasteDraftWhenAgentReady.mockResolvedValue(true)
|
||||
})
|
||||
|
||||
it('uses the explicit startup shell platform when building draft launch commands', async () => {
|
||||
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
|
||||
|
||||
launchAgentInNewTab({
|
||||
agent: 'claude',
|
||||
worktreeId: 'wt-1',
|
||||
prompt: "review Bob's change",
|
||||
promptDelivery: 'draft',
|
||||
launchPlatform: 'win32'
|
||||
})
|
||||
|
||||
expect(mockQueueTabStartupCommand).toHaveBeenCalledWith(
|
||||
'tab-1',
|
||||
expect.objectContaining({
|
||||
command: "claude '--dangerously-skip-permissions' --prefill 'review Bob''s change'"
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('quotes local Windows default agent args for cmd.exe empty launches', async () => {
|
||||
store.settings.terminalWindowsShell = 'cmd.exe'
|
||||
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
|
||||
|
||||
launchAgentInNewTab({
|
||||
agent: 'claude',
|
||||
worktreeId: 'wt-1',
|
||||
launchPlatform: 'win32'
|
||||
})
|
||||
|
||||
expect(mockQueueTabStartupCommand).toHaveBeenCalledWith(
|
||||
'tab-1',
|
||||
expect.objectContaining({
|
||||
command: 'claude "--dangerously-skip-permissions"'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps PowerShell quoting for local Windows default agent args', async () => {
|
||||
store.settings.terminalWindowsShell = 'powershell.exe'
|
||||
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
|
||||
|
||||
launchAgentInNewTab({
|
||||
agent: 'claude',
|
||||
worktreeId: 'wt-1',
|
||||
launchPlatform: 'win32'
|
||||
})
|
||||
|
||||
expect(mockQueueTabStartupCommand).toHaveBeenCalledWith(
|
||||
'tab-1',
|
||||
expect.objectContaining({
|
||||
command: "claude '--dangerously-skip-permissions'"
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('quotes local Windows explicit agent args for cmd.exe prompt launches', async () => {
|
||||
store.settings.terminalWindowsShell = 'cmd.exe'
|
||||
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
|
||||
|
||||
launchAgentInNewTab({
|
||||
agent: 'codex',
|
||||
worktreeId: 'wt-1',
|
||||
prompt: 'fix the spinner',
|
||||
agentArgs: '--model gpt-5',
|
||||
launchPlatform: 'win32'
|
||||
})
|
||||
|
||||
expect(mockQueueTabStartupCommand).toHaveBeenCalledWith(
|
||||
'tab-1',
|
||||
expect.objectContaining({
|
||||
command: 'codex "--model" "gpt-5" "fix the spinner"',
|
||||
agentArgsOverride: '--model gpt-5'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('quotes local Windows draft launches for Git Bash', async () => {
|
||||
store.settings.terminalWindowsShell = 'git-bash'
|
||||
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
|
||||
|
||||
launchAgentInNewTab({
|
||||
agent: 'claude',
|
||||
worktreeId: 'wt-1',
|
||||
prompt: "review Bob's change",
|
||||
promptDelivery: 'draft',
|
||||
launchPlatform: 'win32'
|
||||
})
|
||||
|
||||
expect(mockQueueTabStartupCommand).toHaveBeenCalledWith(
|
||||
'tab-1',
|
||||
expect.objectContaining({
|
||||
command: "claude '--dangerously-skip-permissions' --prefill 'review Bob'\\''s change'"
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('does not use the local Windows shell setting for remote Windows launches', async () => {
|
||||
store.settings.terminalWindowsShell = 'cmd.exe'
|
||||
store.repos = [{ id: 'repo-1', connectionId: 'ssh-1', path: 'C:\\remote\\repo' }]
|
||||
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
|
||||
|
||||
launchAgentInNewTab({
|
||||
agent: 'claude',
|
||||
worktreeId: 'wt-1'
|
||||
})
|
||||
|
||||
expect(mockQueueTabStartupCommand).toHaveBeenCalledWith(
|
||||
'tab-1',
|
||||
expect.objectContaining({
|
||||
command: "claude '--dangerously-skip-permissions'"
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('uses WSL launch quoting by default for Windows-path projects forced to WSL', async () => {
|
||||
store.settings.terminalWindowsShell = 'cmd.exe'
|
||||
store.projects = [
|
||||
{
|
||||
id: 'repo-1',
|
||||
localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' }
|
||||
}
|
||||
]
|
||||
store.repos = [{ id: 'repo-1', connectionId: null, path: 'C:\\Users\\jinwo\\repo' }]
|
||||
store.worktreesByRepo = {
|
||||
'repo-1': [
|
||||
{
|
||||
id: 'wt-1',
|
||||
repoId: 'repo-1',
|
||||
projectId: 'repo-1',
|
||||
path: 'C:\\Users\\jinwo\\repo\\feature',
|
||||
displayName: 'feature'
|
||||
}
|
||||
]
|
||||
}
|
||||
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
|
||||
|
||||
launchAgentInNewTab({
|
||||
agent: 'claude',
|
||||
worktreeId: 'wt-1',
|
||||
prompt: "review Bob's change",
|
||||
promptDelivery: 'draft'
|
||||
})
|
||||
|
||||
expect(mockQueueTabStartupCommand).toHaveBeenCalledWith(
|
||||
'tab-1',
|
||||
expect.objectContaining({
|
||||
command: "claude '--dangerously-skip-permissions' --prefill 'review Bob'\\''s change'"
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -8,6 +8,7 @@ const mockSetTabBarOrder = vi.fn()
|
|||
const mockSetAgentStatus = vi.fn()
|
||||
const mockPasteDraftWhenAgentReady = vi.fn()
|
||||
const mockSeedNativeChatLaunchPrompt = vi.fn()
|
||||
const mockSeedNativeChatLaunchDraft = vi.fn()
|
||||
const mockMarkNativeChatLaunchPromptFailed = vi.fn()
|
||||
const mockTrack = vi.fn()
|
||||
const mockToastMessage = vi.fn()
|
||||
|
|
@ -80,6 +81,7 @@ const store = {
|
|||
setTabBarOrder: mockSetTabBarOrder,
|
||||
setAgentStatus: mockSetAgentStatus,
|
||||
seedNativeChatLaunchPrompt: mockSeedNativeChatLaunchPrompt,
|
||||
seedNativeChatLaunchDraft: mockSeedNativeChatLaunchDraft,
|
||||
markNativeChatLaunchPromptFailed: mockMarkNativeChatLaunchPromptFailed
|
||||
}
|
||||
|
||||
|
|
@ -441,157 +443,6 @@ describe('launchAgentInNewTab', () => {
|
|||
expect(mockTrack).not.toHaveBeenCalledWith('agent_prompt_sent', expect.anything())
|
||||
})
|
||||
|
||||
it('uses the explicit startup shell platform when building draft launch commands', async () => {
|
||||
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
|
||||
|
||||
launchAgentInNewTab({
|
||||
agent: 'claude',
|
||||
worktreeId: 'wt-1',
|
||||
prompt: "review Bob's change",
|
||||
promptDelivery: 'draft',
|
||||
launchPlatform: 'win32'
|
||||
})
|
||||
|
||||
expect(mockQueueTabStartupCommand).toHaveBeenCalledWith(
|
||||
'tab-1',
|
||||
expect.objectContaining({
|
||||
command: "claude '--dangerously-skip-permissions' --prefill 'review Bob''s change'"
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('quotes local Windows default agent args for cmd.exe empty launches', async () => {
|
||||
store.settings.terminalWindowsShell = 'cmd.exe'
|
||||
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
|
||||
|
||||
launchAgentInNewTab({
|
||||
agent: 'claude',
|
||||
worktreeId: 'wt-1',
|
||||
launchPlatform: 'win32'
|
||||
})
|
||||
|
||||
expect(mockQueueTabStartupCommand).toHaveBeenCalledWith(
|
||||
'tab-1',
|
||||
expect.objectContaining({
|
||||
command: 'claude "--dangerously-skip-permissions"'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps PowerShell quoting for local Windows default agent args', async () => {
|
||||
store.settings.terminalWindowsShell = 'powershell.exe'
|
||||
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
|
||||
|
||||
launchAgentInNewTab({
|
||||
agent: 'claude',
|
||||
worktreeId: 'wt-1',
|
||||
launchPlatform: 'win32'
|
||||
})
|
||||
|
||||
expect(mockQueueTabStartupCommand).toHaveBeenCalledWith(
|
||||
'tab-1',
|
||||
expect.objectContaining({
|
||||
command: "claude '--dangerously-skip-permissions'"
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('quotes local Windows explicit agent args for cmd.exe prompt launches', async () => {
|
||||
store.settings.terminalWindowsShell = 'cmd.exe'
|
||||
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
|
||||
|
||||
launchAgentInNewTab({
|
||||
agent: 'codex',
|
||||
worktreeId: 'wt-1',
|
||||
prompt: 'fix the spinner',
|
||||
agentArgs: '--model gpt-5',
|
||||
launchPlatform: 'win32'
|
||||
})
|
||||
|
||||
expect(mockQueueTabStartupCommand).toHaveBeenCalledWith(
|
||||
'tab-1',
|
||||
expect.objectContaining({
|
||||
command: 'codex "--model" "gpt-5" "fix the spinner"',
|
||||
agentArgsOverride: '--model gpt-5'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('quotes local Windows draft launches for Git Bash', async () => {
|
||||
store.settings.terminalWindowsShell = 'git-bash'
|
||||
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
|
||||
|
||||
launchAgentInNewTab({
|
||||
agent: 'claude',
|
||||
worktreeId: 'wt-1',
|
||||
prompt: "review Bob's change",
|
||||
promptDelivery: 'draft',
|
||||
launchPlatform: 'win32'
|
||||
})
|
||||
|
||||
expect(mockQueueTabStartupCommand).toHaveBeenCalledWith(
|
||||
'tab-1',
|
||||
expect.objectContaining({
|
||||
command: "claude '--dangerously-skip-permissions' --prefill 'review Bob'\\''s change'"
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('does not use the local Windows shell setting for remote Windows launches', async () => {
|
||||
store.settings.terminalWindowsShell = 'cmd.exe'
|
||||
store.repos = [{ id: 'repo-1', connectionId: 'ssh-1', path: 'C:\\remote\\repo' }]
|
||||
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
|
||||
|
||||
launchAgentInNewTab({
|
||||
agent: 'claude',
|
||||
worktreeId: 'wt-1'
|
||||
})
|
||||
|
||||
expect(mockQueueTabStartupCommand).toHaveBeenCalledWith(
|
||||
'tab-1',
|
||||
expect.objectContaining({
|
||||
command: "claude '--dangerously-skip-permissions'"
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('uses WSL launch quoting by default for Windows-path projects forced to WSL', async () => {
|
||||
store.settings.terminalWindowsShell = 'cmd.exe'
|
||||
store.projects = [
|
||||
{
|
||||
id: 'repo-1',
|
||||
localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' }
|
||||
}
|
||||
]
|
||||
store.repos = [{ id: 'repo-1', connectionId: null, path: 'C:\\Users\\jinwo\\repo' }]
|
||||
store.worktreesByRepo = {
|
||||
'repo-1': [
|
||||
{
|
||||
id: 'wt-1',
|
||||
repoId: 'repo-1',
|
||||
projectId: 'repo-1',
|
||||
path: 'C:\\Users\\jinwo\\repo\\feature',
|
||||
displayName: 'feature'
|
||||
}
|
||||
]
|
||||
}
|
||||
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
|
||||
|
||||
launchAgentInNewTab({
|
||||
agent: 'claude',
|
||||
worktreeId: 'wt-1',
|
||||
prompt: "review Bob's change",
|
||||
promptDelivery: 'draft'
|
||||
})
|
||||
|
||||
expect(mockQueueTabStartupCommand).toHaveBeenCalledWith(
|
||||
'tab-1',
|
||||
expect.objectContaining({
|
||||
command: "claude '--dangerously-skip-permissions' --prefill 'review Bob'\\''s change'"
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to post-ready draft paste when a Windows inline draft would be too large', async () => {
|
||||
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
|
||||
const prompt = 'x'.repeat(25_000)
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ const mocks = vi.hoisted(() => ({
|
|||
updateWorktreeMeta: vi.fn(),
|
||||
setSidebarOpen: vi.fn(),
|
||||
seedNativeChatLaunchPrompt: vi.fn(),
|
||||
seedNativeChatLaunchDraft: vi.fn(),
|
||||
markNativeChatLaunchPromptFailed: vi.fn(),
|
||||
activateAndRevealWorktree: vi.fn(),
|
||||
pasteDraftWhenAgentReady: vi.fn(),
|
||||
|
|
@ -24,6 +25,7 @@ const mocks = vi.hoisted(() => ({
|
|||
updateWorktreeMeta: ReturnType<typeof vi.fn>
|
||||
setSidebarOpen: ReturnType<typeof vi.fn>
|
||||
seedNativeChatLaunchPrompt: ReturnType<typeof vi.fn>
|
||||
seedNativeChatLaunchDraft: ReturnType<typeof vi.fn>
|
||||
markNativeChatLaunchPromptFailed: ReturnType<typeof vi.fn>
|
||||
}
|
||||
}))
|
||||
|
|
@ -191,6 +193,7 @@ describe('launchWorkItemDirect', () => {
|
|||
updateWorktreeMeta: mocks.updateWorktreeMeta,
|
||||
setSidebarOpen: mocks.setSidebarOpen,
|
||||
seedNativeChatLaunchPrompt: mocks.seedNativeChatLaunchPrompt,
|
||||
seedNativeChatLaunchDraft: mocks.seedNativeChatLaunchDraft,
|
||||
markNativeChatLaunchPromptFailed: mocks.markNativeChatLaunchPromptFailed
|
||||
} as typeof mocks.store
|
||||
// @ts-expect-error -- test shim
|
||||
|
|
@ -428,6 +431,62 @@ describe('launchWorkItemDirect', () => {
|
|||
expect(pasteDraftWhenAgentReady).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('seeds the chat-composer launch draft for a GitHub issue draft launch', async () => {
|
||||
mocks.ensureDetectedAgents.mockResolvedValue(['claude'])
|
||||
const { launchWorkItemDirect } = await import('./launch-work-item-direct')
|
||||
|
||||
await expect(
|
||||
launchWorkItemDirect({
|
||||
repoId: 'repo-1',
|
||||
launchSource: 'task_page',
|
||||
openModalFallback: vi.fn(),
|
||||
agentOverride: 'claude',
|
||||
item: {
|
||||
type: 'issue',
|
||||
number: 12,
|
||||
title: 'Fix crash on launch',
|
||||
url: 'https://github.com/acme/repo/issues/12'
|
||||
}
|
||||
})
|
||||
).resolves.toBe(true)
|
||||
|
||||
// The issue link prefills only the TUI input (argv `--prefill`); the seeded
|
||||
// draft is what makes the same context visible in the chat view.
|
||||
expect(mocks.seedNativeChatLaunchDraft).toHaveBeenCalledWith({
|
||||
tabId: 'tab-1',
|
||||
agent: 'claude',
|
||||
text: 'https://github.com/acme/repo/issues/12',
|
||||
createdAt: expect.any(Number)
|
||||
})
|
||||
expect(mocks.seedNativeChatLaunchPrompt).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('withholds the chat-composer launch draft for a multi-line Linear draft launch', async () => {
|
||||
// A Linear draft is always `Linked Linear issue: ENG-42\n<url>\n`. The chat
|
||||
// send pre-clears the TUI with Ctrl+U (kill-to-start-of-LINE), so seeding it
|
||||
// would leave the first line parked to glue onto the next message.
|
||||
mocks.ensureDetectedAgents.mockResolvedValue(['claude'])
|
||||
const { launchWorkItemDirect } = await import('./launch-work-item-direct')
|
||||
|
||||
await expect(
|
||||
launchWorkItemDirect({
|
||||
repoId: 'repo-1',
|
||||
launchSource: 'task_page',
|
||||
openModalFallback: vi.fn(),
|
||||
agentOverride: 'claude',
|
||||
item: {
|
||||
type: 'issue',
|
||||
number: null,
|
||||
title: 'Ship Linear parity',
|
||||
url: 'https://linear.app/acme/issue/ENG-42/ship-linear-parity',
|
||||
linearIdentifier: 'ENG-42'
|
||||
}
|
||||
})
|
||||
).resolves.toBe(true)
|
||||
|
||||
expect(mocks.seedNativeChatLaunchDraft).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('preserves explicit Linear paste content submit-after-ready behavior', async () => {
|
||||
mocks.ensureDetectedAgents.mockResolvedValue(['claude'])
|
||||
const { launchWorkItemDirect } = await import('./launch-work-item-direct')
|
||||
|
|
@ -472,6 +531,7 @@ describe('launchWorkItemDirect', () => {
|
|||
text: 'Use this explicit user prompt.',
|
||||
createdAt: expect.any(Number)
|
||||
})
|
||||
expect(mocks.seedNativeChatLaunchDraft).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses remote cursor-agent detection, trust preflight, and paste launch for SSH repos', async () => {
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
workspaceActivationErrorMessage
|
||||
} from '@/lib/launch-work-item-direct-messages'
|
||||
import { ensureHooksConfirmed } from '@/lib/ensure-hooks-confirmed'
|
||||
import { seedNativeChatLaunchDraftForAgentTab } from '@/lib/agent-launch-prompt-delivery'
|
||||
import { getConnectionId } from '@/lib/connection-context'
|
||||
import type { GitPushTarget, SetupDecision, TuiAgent } from '../../../shared/types'
|
||||
import { getLinearIssueWorkspaceName } from '../../../shared/workspace-name'
|
||||
|
|
@ -307,6 +308,17 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom
|
|||
return false
|
||||
}
|
||||
|
||||
// Why: draft delivery lands only in the TUI input buffer (argv prefill or
|
||||
// startup-owned paste); seed the chat-composer copy so the work-item context
|
||||
// isn't invisible in the GUI view.
|
||||
if (promptDelivery === 'draft' && primaryTabId && effectiveAgent) {
|
||||
seedNativeChatLaunchDraftForAgentTab({
|
||||
tabId: primaryTabId,
|
||||
agent: effectiveAgent,
|
||||
text: draftContent
|
||||
})
|
||||
}
|
||||
|
||||
// Why: at this point the workspace is live and the agent (if any) has
|
||||
// been queued on `primaryTabId`. The post-launch paste step below only
|
||||
// applies to agents that lacked a native prefill flag; for agents that
|
||||
|
|
|
|||
|
|
@ -7,3 +7,17 @@ export type NativeChatLaunchPrompt = {
|
|||
createdAt: number
|
||||
failed?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Launch-time context delivered as an UNSENT draft (e.g. a linked issue URL
|
||||
* prefilled into the agent TUI's input buffer). The chat composer adopts it as
|
||||
* its own draft so the context isn't invisible in the GUI view.
|
||||
*/
|
||||
export type NativeChatLaunchDraft = {
|
||||
tabId: string
|
||||
agent: TuiAgent
|
||||
text: string
|
||||
createdAt: number
|
||||
/** Set once a composer copied the text into its draft; blocks re-adoption after the user clears it. */
|
||||
adopted?: boolean
|
||||
}
|
||||
|
|
|
|||
|
|
@ -87,6 +87,9 @@ export type WorktreeCreationRequest = {
|
|||
* did not already spawn it. Null for blank-shell creates. */
|
||||
startupPlan: AgentStartupPlan | null
|
||||
quickPrompt: string
|
||||
/** Launch context delivered only as an unsent TUI-input draft (argv prefill or
|
||||
* startup paste); completion seeds the chat-composer copy from it. */
|
||||
launchDraftPrompt?: string
|
||||
quickTelemetry: AgentStartedTelemetry | null
|
||||
/** When the composer stays open for sequential creates, completion must not
|
||||
* steal focus from the next workspace name field. */
|
||||
|
|
|
|||
|
|
@ -0,0 +1,215 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useAppStore } from '@/store'
|
||||
import { resetHookCommandDelayedDeliveryForTests } from './hook-command-delayed-delivery'
|
||||
import { seedAgentTabStateAfterWorktreeCreate } from './worktree-creation-agent-seeds'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
seedNativeChatAppliedSessionOptions: vi.fn(),
|
||||
seedNativeChatLaunchDraftForAgentTab: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/components/native-chat/native-chat-session-option-cache', () => ({
|
||||
seedNativeChatAppliedSessionOptions: mocks.seedNativeChatAppliedSessionOptions
|
||||
}))
|
||||
vi.mock('@/lib/agent-launch-prompt-delivery', () => ({
|
||||
seedNativeChatLaunchDraftForAgentTab: mocks.seedNativeChatLaunchDraftForAgentTab
|
||||
}))
|
||||
|
||||
type AppState = ReturnType<typeof useAppStore.getState>
|
||||
|
||||
const initialTabsByWorktree = useAppStore.getState().tabsByWorktree
|
||||
const initialGetKnownWorktreeById = useAppStore.getState().getKnownWorktreeById
|
||||
|
||||
const DRAFT = 'https://github.com/o/r/issues/12'
|
||||
|
||||
const request = {
|
||||
agent: 'claude' as const,
|
||||
startupPlan: { agent: 'claude', launchCommand: 'claude' } as never,
|
||||
launchDraftPrompt: DRAFT
|
||||
}
|
||||
|
||||
function setTabs(tabs: { id: string; launchAgent?: string }[]): void {
|
||||
useAppStore.setState({
|
||||
tabsByWorktree: { 'wt-1': tabs },
|
||||
getKnownWorktreeById: ((id: string) =>
|
||||
id === 'wt-1' ? { id } : undefined) as unknown as AppState['getKnownWorktreeById']
|
||||
} as unknown as Partial<AppState>)
|
||||
}
|
||||
|
||||
function seededTabIds(): string[] {
|
||||
return mocks.seedNativeChatLaunchDraftForAgentTab.mock.calls.map((call) => call[0].tabId)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
resetHookCommandDelayedDeliveryForTests()
|
||||
useAppStore.setState({
|
||||
tabsByWorktree: initialTabsByWorktree,
|
||||
getKnownWorktreeById: initialGetKnownWorktreeById
|
||||
} as Partial<AppState>)
|
||||
})
|
||||
|
||||
describe('seedAgentTabStateAfterWorktreeCreate', () => {
|
||||
it('seeds the backend-spawned startup tab, not the first default terminal tab', () => {
|
||||
// Repo default tabs (dev server / logs / shell) run no agent; on the
|
||||
// backend-spawn path none of them carries launchAgent either.
|
||||
setTabs([{ id: 'dev-server' }, { id: 'logs' }, { id: 'agent-tab' }])
|
||||
|
||||
seedAgentTabStateAfterWorktreeCreate({
|
||||
request,
|
||||
worktreeId: 'wt-1',
|
||||
primaryTabId: 'dev-server',
|
||||
startupTerminalTabId: 'agent-tab',
|
||||
backendSpawned: true
|
||||
})
|
||||
|
||||
expect(seededTabIds()).toEqual(['agent-tab'])
|
||||
expect(mocks.seedNativeChatAppliedSessionOptions).toHaveBeenCalledWith(
|
||||
'agent-tab',
|
||||
'claude',
|
||||
undefined
|
||||
)
|
||||
})
|
||||
|
||||
it('seeds the launchAgent-stamped tab when the renderer owns startup', () => {
|
||||
setTabs([{ id: 'dev-server' }, { id: 'agent-tab', launchAgent: 'claude' }])
|
||||
|
||||
seedAgentTabStateAfterWorktreeCreate({
|
||||
request,
|
||||
worktreeId: 'wt-1',
|
||||
primaryTabId: 'dev-server',
|
||||
startupTerminalTabId: undefined,
|
||||
backendSpawned: false
|
||||
})
|
||||
|
||||
expect(seededTabIds()).toEqual(['agent-tab'])
|
||||
})
|
||||
|
||||
it('still seeds primaryTabId on the ordinary local path', () => {
|
||||
setTabs([{ id: 'tab-1' }])
|
||||
|
||||
seedAgentTabStateAfterWorktreeCreate({
|
||||
request,
|
||||
worktreeId: 'wt-1',
|
||||
primaryTabId: 'tab-1',
|
||||
startupTerminalTabId: undefined,
|
||||
backendSpawned: false
|
||||
})
|
||||
|
||||
expect(seededTabIds()).toEqual(['tab-1'])
|
||||
})
|
||||
|
||||
it('never falls back to an unrelated first tab', () => {
|
||||
setTabs([{ id: 'dev-server' }, { id: 'logs' }])
|
||||
|
||||
seedAgentTabStateAfterWorktreeCreate({
|
||||
request,
|
||||
worktreeId: 'wt-1',
|
||||
primaryTabId: null,
|
||||
startupTerminalTabId: undefined,
|
||||
backendSpawned: false
|
||||
})
|
||||
|
||||
expect(mocks.seedNativeChatLaunchDraftForAgentTab).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('defers the seed to the first mirrored tab on runtime-owned worktrees', () => {
|
||||
// Runtime-owned (web session) worktrees mirror their session tabs async, so
|
||||
// the worktree has no tab at all at seed time.
|
||||
setTabs([])
|
||||
|
||||
seedAgentTabStateAfterWorktreeCreate({
|
||||
request,
|
||||
worktreeId: 'wt-1',
|
||||
primaryTabId: null,
|
||||
startupTerminalTabId: undefined,
|
||||
backendSpawned: false
|
||||
})
|
||||
expect(mocks.seedNativeChatLaunchDraftForAgentTab).not.toHaveBeenCalled()
|
||||
|
||||
setTabs([{ id: 'mirror-tab-1' }])
|
||||
|
||||
expect(seededTabIds()).toEqual(['mirror-tab-1'])
|
||||
expect(mocks.seedNativeChatAppliedSessionOptions).toHaveBeenCalledWith(
|
||||
'mirror-tab-1',
|
||||
'claude',
|
||||
undefined
|
||||
)
|
||||
})
|
||||
|
||||
it('prefers the agent-stamped tab over the first mirrored tab when both land', () => {
|
||||
setTabs([])
|
||||
|
||||
seedAgentTabStateAfterWorktreeCreate({
|
||||
request,
|
||||
worktreeId: 'wt-1',
|
||||
primaryTabId: null,
|
||||
startupTerminalTabId: undefined,
|
||||
backendSpawned: false
|
||||
})
|
||||
|
||||
setTabs([{ id: 'mirror-shell' }, { id: 'mirror-agent', launchAgent: 'claude' }])
|
||||
|
||||
expect(seededTabIds()).toEqual(['mirror-agent'])
|
||||
})
|
||||
|
||||
it('drops the deferred seed when the mirrored tabs are ambiguous', () => {
|
||||
// Repo default tabs mirror together and none is stamped: `tabs[0]` here is
|
||||
// "dev server". Seeding it would be withheld from mobile by the agent check
|
||||
// and ignored on desktop — the same invariant the synchronous path holds.
|
||||
setTabs([])
|
||||
|
||||
seedAgentTabStateAfterWorktreeCreate({
|
||||
request,
|
||||
worktreeId: 'wt-1',
|
||||
primaryTabId: null,
|
||||
startupTerminalTabId: undefined,
|
||||
backendSpawned: false
|
||||
})
|
||||
|
||||
setTabs([{ id: 'mirror-dev-server' }, { id: 'mirror-logs' }])
|
||||
|
||||
expect(mocks.seedNativeChatLaunchDraftForAgentTab).not.toHaveBeenCalled()
|
||||
expect(mocks.seedNativeChatAppliedSessionOptions).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('seeds session options but no draft without launch draft context', () => {
|
||||
setTabs([{ id: 'tab-1' }])
|
||||
|
||||
seedAgentTabStateAfterWorktreeCreate({
|
||||
request: { agent: 'claude', startupPlan: request.startupPlan },
|
||||
worktreeId: 'wt-1',
|
||||
primaryTabId: 'tab-1',
|
||||
startupTerminalTabId: undefined,
|
||||
backendSpawned: false
|
||||
})
|
||||
|
||||
expect(mocks.seedNativeChatAppliedSessionOptions).toHaveBeenCalledOnce()
|
||||
expect(mocks.seedNativeChatLaunchDraftForAgentTab).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does nothing without an agent or a startup plan', () => {
|
||||
setTabs([{ id: 'tab-1' }])
|
||||
|
||||
seedAgentTabStateAfterWorktreeCreate({
|
||||
request: { agent: null, startupPlan: request.startupPlan, launchDraftPrompt: DRAFT },
|
||||
worktreeId: 'wt-1',
|
||||
primaryTabId: 'tab-1',
|
||||
startupTerminalTabId: undefined,
|
||||
backendSpawned: false
|
||||
})
|
||||
seedAgentTabStateAfterWorktreeCreate({
|
||||
request: { agent: 'claude', startupPlan: null, launchDraftPrompt: DRAFT },
|
||||
worktreeId: 'wt-1',
|
||||
primaryTabId: 'tab-1',
|
||||
startupTerminalTabId: undefined,
|
||||
backendSpawned: false
|
||||
})
|
||||
|
||||
expect(mocks.seedNativeChatAppliedSessionOptions).not.toHaveBeenCalled()
|
||||
expect(mocks.seedNativeChatLaunchDraftForAgentTab).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
import { useAppStore } from '@/store'
|
||||
import { seedNativeChatAppliedSessionOptions } from '@/components/native-chat/native-chat-session-option-cache'
|
||||
import { seedNativeChatLaunchDraftForAgentTab } from '@/lib/agent-launch-prompt-delivery'
|
||||
import { queueHookCommandsForFirstWorktreeTab } from '@/lib/hook-command-delayed-delivery'
|
||||
import type { WorktreeCreationRequest } from '@/lib/pending-worktree-creation'
|
||||
import type { TuiAgent } from '../../../shared/types'
|
||||
|
||||
type AppStoreSnapshot = ReturnType<typeof useAppStore.getState>
|
||||
|
||||
type SeedRequest = Pick<WorktreeCreationRequest, 'agent' | 'startupPlan' | 'launchDraftPrompt'>
|
||||
|
||||
/**
|
||||
* Resolve the tab the launch agent actually runs in.
|
||||
*
|
||||
* Deliberately never falls back to `tabs[0]`: with repo default terminal tabs
|
||||
* (e.g. "dev server", "logs", "shell") the worktree's first tab runs no agent,
|
||||
* and activation's `primaryTabId` is only the worktree's primary tab.
|
||||
*/
|
||||
function resolveLaunchAgentTabId(
|
||||
state: AppStoreSnapshot,
|
||||
args: {
|
||||
agent: TuiAgent
|
||||
worktreeId: string
|
||||
primaryTabId: string | null
|
||||
startupTerminalTabId: string | null | undefined
|
||||
backendSpawned: boolean
|
||||
}
|
||||
): string | null {
|
||||
// Main spawned the agent itself, so its startup tab is authoritative.
|
||||
if (args.backendSpawned && args.startupTerminalTabId) {
|
||||
return args.startupTerminalTabId
|
||||
}
|
||||
const worktreeTabs = state.tabsByWorktree[args.worktreeId] ?? []
|
||||
const stamped = worktreeTabs.find((tab) => tab.launchAgent === args.agent)?.id
|
||||
// Why: when the renderer owns startup, `ensureAgentStartupInTerminal` queues it
|
||||
// on primaryTabId, so that tab is the agent's tab by construction.
|
||||
return stamped ?? args.primaryTabId ?? args.startupTerminalTabId ?? null
|
||||
}
|
||||
|
||||
function applyAgentTabSeeds(request: SeedRequest, agent: TuiAgent, tabId: string): void {
|
||||
seedNativeChatAppliedSessionOptions(tabId, agent, request.startupPlan?.sessionOptions)
|
||||
// Why: draft launch context reaches only the TUI input; seed the
|
||||
// chat-composer copy so it isn't invisible in the chat view.
|
||||
if (request.launchDraftPrompt) {
|
||||
seedNativeChatLaunchDraftForAgentTab({ tabId, agent, text: request.launchDraftPrompt })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed per-tab agent state (applied session options + the chat-composer copy of
|
||||
* draft launch context) once a created worktree's launch tab is known.
|
||||
*/
|
||||
export function seedAgentTabStateAfterWorktreeCreate(args: {
|
||||
request: SeedRequest
|
||||
worktreeId: string
|
||||
primaryTabId: string | null
|
||||
startupTerminalTabId: string | null | undefined
|
||||
backendSpawned: boolean
|
||||
}): void {
|
||||
const { request, worktreeId } = args
|
||||
const agent = request.agent
|
||||
if (!request.startupPlan || !agent) {
|
||||
return
|
||||
}
|
||||
const state = useAppStore.getState()
|
||||
const tabId = resolveLaunchAgentTabId(state, { ...args, agent })
|
||||
if (tabId) {
|
||||
applyAgentTabSeeds(request, agent, tabId)
|
||||
return
|
||||
}
|
||||
if ((state.tabsByWorktree[worktreeId] ?? []).length > 0) {
|
||||
// Tabs exist but none is the agent's; seeding one anyway would publish the
|
||||
// draft on a tab that runs no agent.
|
||||
return
|
||||
}
|
||||
// Why: runtime-owned (web session) worktrees mirror their session tabs async,
|
||||
// so there is no tab to seed yet — hold the seed for the first mirrored tab
|
||||
// instead of dropping it and leaving the draft invisible on that host class.
|
||||
queueHookCommandsForFirstWorktreeTab({
|
||||
worktreeId,
|
||||
deliver: (state, firstTerminalTabId) => {
|
||||
const mirroredTabId = resolveLaunchAgentTabId(state, { ...args, agent })
|
||||
if (mirroredTabId) {
|
||||
applyAgentTabSeeds(request, agent, mirroredTabId)
|
||||
return
|
||||
}
|
||||
// Why: same invariant as the synchronous branch — never seed a tab that
|
||||
// runs no agent. The queue entry is consumed before delivery (no retry),
|
||||
// so accept the first mirrored tab only when it is the worktree's only
|
||||
// one and therefore unambiguously the agent's.
|
||||
if ((state.tabsByWorktree[worktreeId] ?? []).length === 1) {
|
||||
applyAgentTabSeeds(request, agent, firstTerminalTabId)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -39,7 +39,9 @@ const store = {
|
|||
setSidebarOpen: vi.fn(),
|
||||
createWorktree: vi.fn(() => new Promise(() => {})),
|
||||
setupProjectExistingFolder: vi.fn(),
|
||||
refreshRuntimeEnvironmentStatus: vi.fn()
|
||||
refreshRuntimeEnvironmentStatus: vi.fn(),
|
||||
seedNativeChatLaunchDraft: vi.fn(),
|
||||
tabsByWorktree: {} as Record<string, { id: string; launchAgent?: string }[]>
|
||||
}
|
||||
|
||||
vi.mock('@/store', () => ({
|
||||
|
|
@ -97,6 +99,7 @@ beforeEach(() => {
|
|||
store.repos = []
|
||||
store.pendingWorktreeCreations = { 'creation-1': makePendingCreation(makeRequest()) }
|
||||
store.createWorktree.mockImplementation(() => new Promise(() => {}))
|
||||
store.tabsByWorktree = {}
|
||||
vi.mocked(ensureWorktreeHasInitialTerminal).mockReturnValue('tab-1')
|
||||
})
|
||||
|
||||
|
|
@ -627,6 +630,99 @@ describe('staged background worktree creation', () => {
|
|||
expect(ensureWorktreeHasInitialTerminal).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('seeds the chat-composer launch draft on completion for draft launches', async () => {
|
||||
store.activeView = 'terminal'
|
||||
store.activePendingCreationId = 'creation-1'
|
||||
store.createWorktree.mockResolvedValueOnce({
|
||||
worktree: { id: 'wt-1', repoId: 'repo-1', path: '/repo/wt-1' }
|
||||
})
|
||||
vi.mocked(activateAndRevealWorktree).mockReturnValueOnce({ primaryTabId: 'tab-1' })
|
||||
|
||||
const started = continueBackgroundWorktreeCreation(
|
||||
'creation-1',
|
||||
makeRequest({
|
||||
agent: 'claude',
|
||||
startupPlan: {
|
||||
agent: 'claude',
|
||||
launchCommand: 'claude --prefill x',
|
||||
expectedProcess: 'claude',
|
||||
followupPrompt: null,
|
||||
launchConfig: { agentArgs: '', agentEnv: {} }
|
||||
},
|
||||
launchDraftPrompt: 'https://github.com/o/r/issues/12'
|
||||
})
|
||||
)
|
||||
|
||||
expect(started).toBe(true)
|
||||
await vi.waitFor(() =>
|
||||
expect(store.seedNativeChatLaunchDraft).toHaveBeenCalledWith({
|
||||
tabId: 'tab-1',
|
||||
agent: 'claude',
|
||||
text: 'https://github.com/o/r/issues/12',
|
||||
createdAt: expect.any(Number)
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('seeds the backend-spawned agent tab, not the worktree default terminal tab', async () => {
|
||||
// Repo default tabs ("dev server", "logs", …) make activation's primaryTabId
|
||||
// a tab that runs no agent; main's startup terminal is the agent's own tab.
|
||||
store.activeView = 'terminal'
|
||||
store.activePendingCreationId = 'creation-1'
|
||||
store.tabsByWorktree = { 'wt-1': [{ id: 'dev-server' }, { id: 'agent-tab' }] }
|
||||
store.createWorktree.mockResolvedValueOnce({
|
||||
worktree: { id: 'wt-1', repoId: 'repo-1', path: '/repo/wt-1' },
|
||||
startupTerminal: { tabId: 'agent-tab', spawned: true }
|
||||
})
|
||||
vi.mocked(activateAndRevealWorktree).mockReturnValueOnce({ primaryTabId: 'dev-server' })
|
||||
|
||||
continueBackgroundWorktreeCreation(
|
||||
'creation-1',
|
||||
makeRequest({
|
||||
agent: 'claude',
|
||||
startupPlan: {
|
||||
agent: 'claude',
|
||||
launchCommand: 'claude --prefill x',
|
||||
expectedProcess: 'claude',
|
||||
followupPrompt: null,
|
||||
launchConfig: { agentArgs: '', agentEnv: {} }
|
||||
},
|
||||
launchDraftPrompt: 'https://github.com/o/r/issues/12'
|
||||
})
|
||||
)
|
||||
|
||||
await vi.waitFor(() => expect(store.seedNativeChatLaunchDraft).toHaveBeenCalled())
|
||||
expect(store.seedNativeChatLaunchDraft).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ tabId: 'agent-tab' })
|
||||
)
|
||||
})
|
||||
|
||||
it('does not seed a launch draft without draft launch context', async () => {
|
||||
store.activeView = 'terminal'
|
||||
store.activePendingCreationId = 'creation-1'
|
||||
store.createWorktree.mockResolvedValueOnce({
|
||||
worktree: { id: 'wt-1', repoId: 'repo-1', path: '/repo/wt-1' }
|
||||
})
|
||||
vi.mocked(activateAndRevealWorktree).mockReturnValueOnce({ primaryTabId: 'tab-1' })
|
||||
|
||||
continueBackgroundWorktreeCreation(
|
||||
'creation-1',
|
||||
makeRequest({
|
||||
agent: 'claude',
|
||||
startupPlan: {
|
||||
agent: 'claude',
|
||||
launchCommand: 'claude',
|
||||
expectedProcess: 'claude',
|
||||
followupPrompt: null,
|
||||
launchConfig: { agentArgs: '', agentEnv: {} }
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
await vi.waitFor(() => expect(activateAndRevealWorktree).toHaveBeenCalled())
|
||||
expect(store.seedNativeChatLaunchDraft).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('toasts a staged create error after the user leaves the creation surface', async () => {
|
||||
store.activeView = 'tasks'
|
||||
store.createWorktree.mockRejectedValueOnce(new Error('create failed'))
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ import type {
|
|||
WorktreeCreationRequest
|
||||
} from '@/lib/pending-worktree-creation'
|
||||
import { createBrowserUuid } from '@/lib/browser-uuid'
|
||||
import { seedNativeChatAppliedSessionOptions } from '@/components/native-chat/native-chat-session-option-cache'
|
||||
import { seedAgentTabStateAfterWorktreeCreate } from '@/lib/worktree-creation-agent-seeds'
|
||||
|
||||
type ContinueBackgroundWorktreeCreationOptions = {
|
||||
revealCreationSurface?: boolean
|
||||
|
|
@ -255,16 +255,13 @@ async function executeWorktreeCreation(
|
|||
// Why: clearing synchronously right after activation lets React commit the
|
||||
// panel→terminal swap in one frame — no two-row flicker, no empty-terminal flash.
|
||||
useAppStore.getState().removePendingWorktreeCreation(creationId, { cleanupVm: false })
|
||||
if (preparedRequest.startupPlan && preparedRequest.agent) {
|
||||
const optionScopeKey = primaryTabId ?? result.startupTerminal?.tabId
|
||||
if (optionScopeKey) {
|
||||
seedNativeChatAppliedSessionOptions(
|
||||
optionScopeKey,
|
||||
preparedRequest.agent,
|
||||
preparedRequest.startupPlan.sessionOptions
|
||||
)
|
||||
}
|
||||
}
|
||||
seedAgentTabStateAfterWorktreeCreate({
|
||||
request: preparedRequest,
|
||||
worktreeId: worktree.id,
|
||||
primaryTabId,
|
||||
startupTerminalTabId: result.startupTerminal?.tabId,
|
||||
backendSpawned
|
||||
})
|
||||
if (preparedRequest.startupPlan && !backendSpawned) {
|
||||
void ensureAgentStartupInTerminal({
|
||||
worktreeId: worktree.id,
|
||||
|
|
|
|||
|
|
@ -386,6 +386,48 @@ describe('getRuntimeMobileSessionSyncKey', () => {
|
|||
expect(runtimeMobileSessionSyncKeysEqual(before, after)).toBe(false)
|
||||
})
|
||||
|
||||
it('changes when a native-chat launch draft is seeded or cleared', () => {
|
||||
const sharedOverrides = makeSharedOverrides()
|
||||
const launchDraft = {
|
||||
tabId: 'term-1',
|
||||
agent: 'claude' as const,
|
||||
text: 'https://github.com/o/r/issues/12',
|
||||
createdAt: 1
|
||||
}
|
||||
|
||||
const before = getRuntimeMobileSessionSyncKey(
|
||||
makeState({ ...sharedOverrides, nativeChatLaunchDraftByTabId: {} })
|
||||
)
|
||||
const after = getRuntimeMobileSessionSyncKey(
|
||||
makeState({
|
||||
...sharedOverrides,
|
||||
nativeChatLaunchDraftByTabId: { 'term-1': launchDraft }
|
||||
})
|
||||
)
|
||||
|
||||
expect(runtimeMobileSessionSyncKeysEqual(before, after)).toBe(false)
|
||||
})
|
||||
|
||||
it('does not skip the App subscriber gate when a launch draft is seeded', () => {
|
||||
// The key is never even built when this gate skips, so the draft-aware key
|
||||
// case above cannot catch a regression here.
|
||||
const sharedOverrides = makeSharedOverrides()
|
||||
const before = makeState({ ...sharedOverrides, nativeChatLaunchDraftByTabId: {} })
|
||||
const after = makeState({
|
||||
...sharedOverrides,
|
||||
nativeChatLaunchDraftByTabId: {
|
||||
'term-1': {
|
||||
tabId: 'term-1',
|
||||
agent: 'claude' as const,
|
||||
text: 'https://github.com/o/r/issues/12',
|
||||
createdAt: 1
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
expect(canSkipRuntimeMobileSessionSyncKeyBuild(after, before)).toBe(false)
|
||||
})
|
||||
|
||||
it('changes when explicit agent status epoch changes', () => {
|
||||
const sharedOverrides = makeSharedOverrides()
|
||||
const before = getRuntimeMobileSessionSyncKey(
|
||||
|
|
@ -669,6 +711,73 @@ describe('buildMobileSessionTabSnapshots', () => {
|
|||
)
|
||||
})
|
||||
|
||||
it('publishes the native-chat launch draft on terminal surface tabs', () => {
|
||||
const leafId = '11111111-1111-4111-8111-111111111111'
|
||||
const state = makeState({
|
||||
tabsByWorktree: {
|
||||
'wt-1': [{ id: 'term-1', title: 'Terminal 1', launchAgent: 'claude' }]
|
||||
} as unknown as AppState['tabsByWorktree'],
|
||||
terminalLayoutsByTabId: {
|
||||
'term-1': {
|
||||
root: { type: 'leaf', leafId },
|
||||
activeLeafId: leafId,
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: { [leafId]: 'pty-1' }
|
||||
}
|
||||
} as unknown as AppState['terminalLayoutsByTabId'],
|
||||
nativeChatLaunchDraftByTabId: {
|
||||
'term-1': {
|
||||
tabId: 'term-1',
|
||||
agent: 'claude',
|
||||
text: 'https://github.com/o/r/issues/12',
|
||||
createdAt: 1
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const snapshot = buildMobileSessionTabSnapshots(state)[0]
|
||||
|
||||
expect(snapshot?.tabs).toEqual([
|
||||
expect.objectContaining({
|
||||
type: 'terminal',
|
||||
parentTabId: 'term-1',
|
||||
launchDraft: 'https://github.com/o/r/issues/12'
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('withholds a launch draft seeded for a different agent than the tab runs', () => {
|
||||
// The seed is keyed by tab id, which survives an agent switch. Desktop's
|
||||
// consumer declines on mismatch; publishing anyway would prefill the new
|
||||
// agent's mobile chat with the previous agent's link.
|
||||
const leafId = '11111111-1111-4111-8111-111111111111'
|
||||
const state = makeState({
|
||||
tabsByWorktree: {
|
||||
'wt-1': [{ id: 'term-1', title: 'Terminal 1', launchAgent: 'codex' }]
|
||||
} as unknown as AppState['tabsByWorktree'],
|
||||
terminalLayoutsByTabId: {
|
||||
'term-1': {
|
||||
root: { type: 'leaf', leafId },
|
||||
activeLeafId: leafId,
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: { [leafId]: 'pty-1' }
|
||||
}
|
||||
} as unknown as AppState['terminalLayoutsByTabId'],
|
||||
nativeChatLaunchDraftByTabId: {
|
||||
'term-1': {
|
||||
tabId: 'term-1',
|
||||
agent: 'claude',
|
||||
text: 'https://github.com/o/r/issues/12',
|
||||
createdAt: 1
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const snapshot = buildMobileSessionTabSnapshots(state)[0]
|
||||
|
||||
expect(snapshot?.tabs[0]).not.toHaveProperty('launchDraft')
|
||||
})
|
||||
|
||||
it('preserves source-control diff metadata for mobile file tabs', () => {
|
||||
const diffId = 'wt-1::diff::unstaged::src/app.ts'
|
||||
const state = makeState({
|
||||
|
|
|
|||
|
|
@ -245,6 +245,7 @@ export type RuntimeMobileSessionSyncKey = {
|
|||
// Why: compared by reference; reallocation signals a real layout/title change, avoiding stringifying thousands of tabs. See docs/agent-working-pane-typing-lag.md.
|
||||
terminalLayoutsByTabId: AppState['terminalLayoutsByTabId']
|
||||
runtimePaneTitlesByTabId: AppState['runtimePaneTitlesByTabId']
|
||||
nativeChatLaunchDraftByTabId: AppState['nativeChatLaunchDraftByTabId']
|
||||
groupsByWorktree: AppState['groupsByWorktree']
|
||||
activeGroupIdByWorktree: AppState['activeGroupIdByWorktree']
|
||||
layoutByWorktree: AppState['layoutByWorktree']
|
||||
|
|
@ -300,6 +301,7 @@ export function canSkipRuntimeMobileSessionSyncKeyBuild(
|
|||
state.activeTabId === previousState.activeTabId &&
|
||||
state.terminalLayoutsByTabId === previousState.terminalLayoutsByTabId &&
|
||||
state.runtimePaneTitlesByTabId === previousState.runtimePaneTitlesByTabId &&
|
||||
state.nativeChatLaunchDraftByTabId === previousState.nativeChatLaunchDraftByTabId &&
|
||||
state.agentStatusEpoch === previousState.agentStatusEpoch &&
|
||||
state.agentStatusByPaneKey === previousState.agentStatusByPaneKey
|
||||
)
|
||||
|
|
@ -336,6 +338,7 @@ export function getRuntimeMobileSessionSyncKey(
|
|||
return {
|
||||
terminalLayoutsByTabId: state.terminalLayoutsByTabId,
|
||||
runtimePaneTitlesByTabId: state.runtimePaneTitlesByTabId,
|
||||
nativeChatLaunchDraftByTabId: state.nativeChatLaunchDraftByTabId,
|
||||
groupsByWorktree: state.groupsByWorktree,
|
||||
activeGroupIdByWorktree: state.activeGroupIdByWorktree,
|
||||
layoutByWorktree: state.layoutByWorktree ?? EMPTY_LAYOUT_BY_WORKTREE,
|
||||
|
|
@ -580,6 +583,7 @@ export function runtimeMobileSessionSyncKeysEqual(
|
|||
return (
|
||||
a.terminalLayoutsByTabId === b.terminalLayoutsByTabId &&
|
||||
a.runtimePaneTitlesByTabId === b.runtimePaneTitlesByTabId &&
|
||||
a.nativeChatLaunchDraftByTabId === b.nativeChatLaunchDraftByTabId &&
|
||||
a.groupsByWorktree === b.groupsByWorktree &&
|
||||
a.activeGroupIdByWorktree === b.activeGroupIdByWorktree &&
|
||||
a.layoutByWorktree === b.layoutByWorktree &&
|
||||
|
|
@ -1363,6 +1367,12 @@ function buildMobileTerminalSurfaceTabs(
|
|||
: undefined
|
||||
const savedPtyIdsByLeafId = sanitizedSavedLayout?.ptyIdsByLeafId ?? {}
|
||||
const terminalTheme = resolveMobileTerminalTheme(state, systemPrefersDark)
|
||||
// Agent-matched like the desktop consumer: a pane whose agent changed keeps its
|
||||
// tab id, so an unmatched seed would prefill the new agent's chat with stale text.
|
||||
const seededLaunchDraft = state.nativeChatLaunchDraftByTabId?.[terminal.id]
|
||||
const launchDraftEntry =
|
||||
seededLaunchDraft && seededLaunchDraft.agent === terminal.launchAgent ? seededLaunchDraft : null
|
||||
const launchDraftText = launchDraftEntry?.text.trim() ? launchDraftEntry.text : null
|
||||
const container = registered?.getContainer()
|
||||
const firstChild = container?.firstElementChild
|
||||
const liveLayoutRoot = serializePaneTree(
|
||||
|
|
@ -1423,6 +1433,9 @@ function buildMobileTerminalSurfaceTabs(
|
|||
...(terminalTheme ? { terminalTheme } : {}),
|
||||
...(agentStatus ? { agentStatus } : {}),
|
||||
...(terminal.launchAgent ? { launchAgent: terminal.launchAgent } : {}),
|
||||
// Launch context that exists only as an unsent TUI-input draft; mobile
|
||||
// prefills its chat composer from it (desktop keeps its own seed store).
|
||||
...(launchDraftText ? { launchDraft: launchDraftText } : {}),
|
||||
parentLayout,
|
||||
isActive: isDesktopTabActive && leafId === activeLeafId
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,139 @@
|
|||
/**
|
||||
* Teardown coverage for `nativeChatLaunchDraftByTabId`.
|
||||
*
|
||||
* A stranded entry is worse than a plain leak: sync-runtime-graph keeps
|
||||
* publishing it to mobile as that tab's `launchDraft`. It must evict wherever
|
||||
* its sibling `nativeChatLaunchPromptByTabId` does — tab close, orphan sweep,
|
||||
* the bulk worktree purge, and the single removeWorktree teardown.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import type * as AgentStatusModule from '@/lib/agent-status'
|
||||
import { buildOrphanTerminalCleanupPatch } from './terminal-orphan-helpers'
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: { info: vi.fn(), success: vi.fn(), error: vi.fn(), warning: vi.fn() }
|
||||
}))
|
||||
|
||||
vi.mock('@/components/terminal-pane/pty-dispatcher', () => ({
|
||||
restorePtyDataHandlersAfterFailedShutdown: vi.fn(),
|
||||
unregisterPtyDataHandlers: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/agent-status', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof AgentStatusModule>()
|
||||
return { ...actual, detectAgentStatusFromTitle: vi.fn().mockReturnValue(null) }
|
||||
})
|
||||
|
||||
const mockApi = {
|
||||
worktrees: {
|
||||
list: vi.fn().mockResolvedValue([]),
|
||||
remove: vi.fn().mockResolvedValue(undefined),
|
||||
forceDeletePreservedBranch: vi.fn().mockResolvedValue({ deleted: true }),
|
||||
updateMeta: vi.fn().mockResolvedValue({})
|
||||
},
|
||||
pty: { kill: vi.fn().mockResolvedValue(undefined) },
|
||||
runtimeEnvironments: { call: vi.fn().mockResolvedValue({ ok: true, result: {} }) }
|
||||
}
|
||||
|
||||
// @ts-expect-error -- minimal window.api stub for the store under test
|
||||
globalThis.window = { api: mockApi }
|
||||
|
||||
import { createTestStore, seedStore, makeWorktree, makeTab } from './store-test-helpers'
|
||||
|
||||
const WT1 = 'repo1::/path/wt1'
|
||||
const WT2 = 'repo1::/path/wt2'
|
||||
const TAB1 = 'tab-wt1'
|
||||
const TAB2 = 'tab-wt2'
|
||||
|
||||
function draft(tabId: string, text: string) {
|
||||
return { tabId, agent: 'claude' as const, text, createdAt: 1 }
|
||||
}
|
||||
|
||||
function seedDrafts(store: ReturnType<typeof createTestStore>): void {
|
||||
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 })]
|
||||
},
|
||||
nativeChatLaunchDraftByTabId: {
|
||||
[TAB1]: draft(TAB1, 'https://github.com/o/r/issues/1'),
|
||||
[TAB2]: draft(TAB2, 'https://github.com/o/r/issues/2')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('nativeChatLaunchDraftByTabId teardown', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockApi.worktrees.remove.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
it('closeTab drops the closed tab’s draft only', () => {
|
||||
const store = createTestStore()
|
||||
seedDrafts(store)
|
||||
|
||||
store.getState().closeTab(TAB1)
|
||||
|
||||
const s = store.getState()
|
||||
expect(s.nativeChatLaunchDraftByTabId[TAB1]).toBeUndefined()
|
||||
expect(s.nativeChatLaunchDraftByTabId[TAB2]).toBeDefined()
|
||||
})
|
||||
|
||||
it('bulk purgeWorktreeTerminalState drops drafts for the removed worktree only', () => {
|
||||
const store = createTestStore()
|
||||
seedDrafts(store)
|
||||
|
||||
store.getState().purgeWorktreeTerminalState([WT1])
|
||||
|
||||
const s = store.getState()
|
||||
expect(s.nativeChatLaunchDraftByTabId[TAB1]).toBeUndefined()
|
||||
expect(s.nativeChatLaunchDraftByTabId[TAB2]).toBeDefined()
|
||||
})
|
||||
|
||||
it('single removeWorktree drops drafts for the removed worktree only', async () => {
|
||||
const store = createTestStore()
|
||||
seedDrafts(store)
|
||||
|
||||
const result = await store.getState().removeWorktree(WT1)
|
||||
|
||||
expect(result).toEqual({ ok: true })
|
||||
const s = store.getState()
|
||||
expect(s.nativeChatLaunchDraftByTabId[TAB1]).toBeUndefined()
|
||||
expect(s.nativeChatLaunchDraftByTabId[TAB2]).toBeDefined()
|
||||
})
|
||||
|
||||
it('persists the adopted flag, idempotently, and clears the whole entry', () => {
|
||||
// Every consumer test injects these reducers as bare vi.fn()s. `adopted` is
|
||||
// what stops a manually cleared composer from resurrecting the prefill, so
|
||||
// assert the real reducers here.
|
||||
const store = createTestStore()
|
||||
store.getState().seedNativeChatLaunchDraft(draft(TAB1, 'https://github.com/o/r/issues/1'))
|
||||
expect(store.getState().nativeChatLaunchDraftByTabId[TAB1]?.adopted).toBeUndefined()
|
||||
|
||||
store.getState().markNativeChatLaunchDraftAdopted(TAB1)
|
||||
const adopted = store.getState().nativeChatLaunchDraftByTabId[TAB1]
|
||||
expect(adopted).toMatchObject({ tabId: TAB1, adopted: true })
|
||||
|
||||
store.getState().markNativeChatLaunchDraftAdopted(TAB1)
|
||||
expect(store.getState().nativeChatLaunchDraftByTabId[TAB1]).toBe(adopted)
|
||||
|
||||
store.getState().clearNativeChatLaunchDraft(TAB1)
|
||||
expect(TAB1 in store.getState().nativeChatLaunchDraftByTabId).toBe(false)
|
||||
})
|
||||
|
||||
it('the orphan terminal cleanup patch drops swept tabs’ drafts only', () => {
|
||||
const store = createTestStore()
|
||||
seedDrafts(store)
|
||||
|
||||
const patch = buildOrphanTerminalCleanupPatch(store.getState(), WT1, new Set([TAB1]))
|
||||
|
||||
expect(patch.nativeChatLaunchDraftByTabId[TAB1]).toBeUndefined()
|
||||
expect(patch.nativeChatLaunchDraftByTabId[TAB2]).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
|
@ -48,6 +48,7 @@ type OrphanTerminalCleanupState = Pick<
|
|||
| 'pendingIssueCommandSplitByTabId'
|
||||
| 'automaticAgentResumeClaimsByTabId'
|
||||
| 'nativeChatLaunchPromptByTabId'
|
||||
| 'nativeChatLaunchDraftByTabId'
|
||||
| 'tabBarOrderByWorktree'
|
||||
| 'cacheTimerByKey'
|
||||
| 'activeTabIdByWorktree'
|
||||
|
|
@ -99,6 +100,7 @@ export function buildOrphanTerminalCleanupPatch(
|
|||
| 'pendingIssueCommandSplitByTabId'
|
||||
| 'automaticAgentResumeClaimsByTabId'
|
||||
| 'nativeChatLaunchPromptByTabId'
|
||||
| 'nativeChatLaunchDraftByTabId'
|
||||
| 'tabBarOrderByWorktree'
|
||||
| 'cacheTimerByKey'
|
||||
| 'activeTabIdByWorktree'
|
||||
|
|
@ -118,6 +120,7 @@ export function buildOrphanTerminalCleanupPatch(
|
|||
pendingIssueCommandSplitByTabId: state.pendingIssueCommandSplitByTabId,
|
||||
automaticAgentResumeClaimsByTabId: state.automaticAgentResumeClaimsByTabId,
|
||||
nativeChatLaunchPromptByTabId: state.nativeChatLaunchPromptByTabId,
|
||||
nativeChatLaunchDraftByTabId: state.nativeChatLaunchDraftByTabId,
|
||||
tabBarOrderByWorktree: state.tabBarOrderByWorktree,
|
||||
cacheTimerByKey: state.cacheTimerByKey,
|
||||
activeTabIdByWorktree: state.activeTabIdByWorktree,
|
||||
|
|
@ -141,6 +144,7 @@ export function buildOrphanTerminalCleanupPatch(
|
|||
...state.automaticAgentResumeClaimsByTabId
|
||||
}
|
||||
const nextNativeChatLaunchPromptByTabId = { ...state.nativeChatLaunchPromptByTabId }
|
||||
const nextNativeChatLaunchDraftByTabId = { ...state.nativeChatLaunchDraftByTabId }
|
||||
const nextTabBarOrderByWorktree = {
|
||||
...state.tabBarOrderByWorktree,
|
||||
[worktreeId]: (state.tabBarOrderByWorktree[worktreeId] ?? []).filter(
|
||||
|
|
@ -165,6 +169,7 @@ export function buildOrphanTerminalCleanupPatch(
|
|||
delete nextPendingIssueCommandSplitByTabId[orphanTabId]
|
||||
delete nextAutomaticAgentResumeClaimsByTabId[orphanTabId]
|
||||
delete nextNativeChatLaunchPromptByTabId[orphanTabId]
|
||||
delete nextNativeChatLaunchDraftByTabId[orphanTabId]
|
||||
for (const key of Object.keys(nextCacheTimerByKey)) {
|
||||
if (key.startsWith(`${orphanTabId}:`)) {
|
||||
delete nextCacheTimerByKey[key]
|
||||
|
|
@ -195,6 +200,7 @@ export function buildOrphanTerminalCleanupPatch(
|
|||
pendingIssueCommandSplitByTabId: nextPendingIssueCommandSplitByTabId,
|
||||
automaticAgentResumeClaimsByTabId: nextAutomaticAgentResumeClaimsByTabId,
|
||||
nativeChatLaunchPromptByTabId: nextNativeChatLaunchPromptByTabId,
|
||||
nativeChatLaunchDraftByTabId: nextNativeChatLaunchDraftByTabId,
|
||||
tabBarOrderByWorktree: nextTabBarOrderByWorktree,
|
||||
cacheTimerByKey: nextCacheTimerByKey,
|
||||
activeTabIdByWorktree: nextActiveTabIdByWorktree,
|
||||
|
|
|
|||
|
|
@ -119,7 +119,7 @@ import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner
|
|||
import { resolveTerminalWorktreeRoute } from '@/lib/terminal-worktree-route'
|
||||
import { resolveWorktreeOperationRouteResult } from '@/lib/worktree-operation-route'
|
||||
import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context'
|
||||
import type { NativeChatLaunchPrompt } from '@/lib/native-chat-launch-prompt'
|
||||
import type { NativeChatLaunchDraft, NativeChatLaunchPrompt } from '@/lib/native-chat-launch-prompt'
|
||||
import {
|
||||
addAdditionalValidWorkspaceKeys,
|
||||
type WorkspaceSessionHydrationOptions
|
||||
|
|
@ -559,6 +559,11 @@ export type TerminalSlice = {
|
|||
seedNativeChatLaunchPrompt: (prompt: NativeChatLaunchPrompt) => void
|
||||
markNativeChatLaunchPromptFailed: (tabId: string) => void
|
||||
clearNativeChatLaunchPrompt: (tabId: string) => void
|
||||
/** Launch context prefilled into the TUI input as an unsent draft; the chat composer adopts it. In-memory only. */
|
||||
nativeChatLaunchDraftByTabId: Record<string, NativeChatLaunchDraft>
|
||||
seedNativeChatLaunchDraft: (draft: NativeChatLaunchDraft) => void
|
||||
markNativeChatLaunchDraftAdopted: (tabId: string) => void
|
||||
clearNativeChatLaunchDraft: (tabId: string) => void
|
||||
pendingStartupByTabId: Record<
|
||||
string,
|
||||
{
|
||||
|
|
@ -1001,6 +1006,7 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
|
|||
pendingIssueCommandSplitByTabId: {},
|
||||
automaticAgentResumeClaimsByTabId: {},
|
||||
nativeChatLaunchPromptByTabId: {},
|
||||
nativeChatLaunchDraftByTabId: {},
|
||||
tabBarOrderByWorktree: {},
|
||||
workspaceSessionReady: false,
|
||||
restoredRuntimeHostIdByWorkspaceSessionKey: {},
|
||||
|
|
@ -1086,6 +1092,41 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
|
|||
})
|
||||
},
|
||||
|
||||
seedNativeChatLaunchDraft: (draft) => {
|
||||
set((s) => ({
|
||||
nativeChatLaunchDraftByTabId: {
|
||||
...s.nativeChatLaunchDraftByTabId,
|
||||
[draft.tabId]: draft
|
||||
}
|
||||
}))
|
||||
},
|
||||
|
||||
markNativeChatLaunchDraftAdopted: (tabId) => {
|
||||
set((s) => {
|
||||
const current = s.nativeChatLaunchDraftByTabId[tabId]
|
||||
if (!current || current.adopted) {
|
||||
return {}
|
||||
}
|
||||
return {
|
||||
nativeChatLaunchDraftByTabId: {
|
||||
...s.nativeChatLaunchDraftByTabId,
|
||||
[tabId]: { ...current, adopted: true }
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
clearNativeChatLaunchDraft: (tabId) => {
|
||||
set((s) => {
|
||||
if (!s.nativeChatLaunchDraftByTabId[tabId]) {
|
||||
return {}
|
||||
}
|
||||
const next = { ...s.nativeChatLaunchDraftByTabId }
|
||||
delete next[tabId]
|
||||
return { nativeChatLaunchDraftByTabId: next }
|
||||
})
|
||||
},
|
||||
|
||||
recordTerminalInput: (paneKey, timestamp = Date.now()) => {
|
||||
if (!paneKey || !Number.isFinite(timestamp)) {
|
||||
return
|
||||
|
|
@ -1584,6 +1625,8 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
|
|||
delete nextAutomaticAgentResumeClaimsByTabId[tabId]
|
||||
const nextNativeChatLaunchPromptByTabId = { ...s.nativeChatLaunchPromptByTabId }
|
||||
delete nextNativeChatLaunchPromptByTabId[tabId]
|
||||
const nextNativeChatLaunchDraftByTabId = { ...s.nativeChatLaunchDraftByTabId }
|
||||
delete nextNativeChatLaunchDraftByTabId[tabId]
|
||||
const nextPendingInitialCwdByTabId = { ...s.pendingInitialCwdByTabId }
|
||||
delete nextPendingInitialCwdByTabId[tabId]
|
||||
const nextPendingSetupSplitByTabId = { ...s.pendingSetupSplitByTabId }
|
||||
|
|
@ -1668,6 +1711,7 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
|
|||
pendingStartupByTabId: nextPendingStartupByTabId,
|
||||
automaticAgentResumeClaimsByTabId: nextAutomaticAgentResumeClaimsByTabId,
|
||||
nativeChatLaunchPromptByTabId: nextNativeChatLaunchPromptByTabId,
|
||||
nativeChatLaunchDraftByTabId: nextNativeChatLaunchDraftByTabId,
|
||||
pendingInitialCwdByTabId: nextPendingInitialCwdByTabId,
|
||||
pendingSetupSplitByTabId: nextPendingSetupSplitByTabId,
|
||||
pendingIssueCommandSplitByTabId: nextPendingIssueCommandSplitByTabId,
|
||||
|
|
|
|||
|
|
@ -2479,6 +2479,7 @@ function buildWorktreePurgeState(s: AppState, worktreeIds: string[]): Partial<Ap
|
|||
runtimePaneTitlesByTabId: omitByTabId(s.runtimePaneTitlesByTabId),
|
||||
automaticAgentResumeClaimsByTabId: omitByTabId(s.automaticAgentResumeClaimsByTabId),
|
||||
nativeChatLaunchPromptByTabId: omitByTabId(s.nativeChatLaunchPromptByTabId),
|
||||
nativeChatLaunchDraftByTabId: omitByTabId(s.nativeChatLaunchDraftByTabId),
|
||||
// Why: bulk/hydration purge runs no terminal teardown, so it must drop the per-tab pane-expand flags itself.
|
||||
expandedPaneByTabId: omitByTabId(s.expandedPaneByTabId),
|
||||
canExpandPaneByTabId: omitByTabId(s.canExpandPaneByTabId),
|
||||
|
|
@ -3863,6 +3864,7 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
|
|||
...s.automaticAgentResumeClaimsByTabId
|
||||
}
|
||||
const nextNativeChatLaunchPromptByTabId = { ...s.nativeChatLaunchPromptByTabId }
|
||||
const nextNativeChatLaunchDraftByTabId = { ...s.nativeChatLaunchDraftByTabId }
|
||||
// Why: closeTab deletes these per-tab maps but removeWorktree missed them, leaking a split pane's expand flags.
|
||||
const nextExpandedPaneByTabId = { ...s.expandedPaneByTabId }
|
||||
const nextCanExpandPaneByTabId = { ...s.canExpandPaneByTabId }
|
||||
|
|
@ -3872,6 +3874,7 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
|
|||
delete nextRuntimePaneTitlesByTabId[tabId]
|
||||
delete nextAutomaticAgentResumeClaimsByTabId[tabId]
|
||||
delete nextNativeChatLaunchPromptByTabId[tabId]
|
||||
delete nextNativeChatLaunchDraftByTabId[tabId]
|
||||
delete nextExpandedPaneByTabId[tabId]
|
||||
delete nextCanExpandPaneByTabId[tabId]
|
||||
}
|
||||
|
|
@ -4003,6 +4006,7 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
|
|||
runtimePaneTitlesByTabId: nextRuntimePaneTitlesByTabId,
|
||||
automaticAgentResumeClaimsByTabId: nextAutomaticAgentResumeClaimsByTabId,
|
||||
nativeChatLaunchPromptByTabId: nextNativeChatLaunchPromptByTabId,
|
||||
nativeChatLaunchDraftByTabId: nextNativeChatLaunchDraftByTabId,
|
||||
terminalLayoutsByTabId: nextLayouts,
|
||||
expandedPaneByTabId: nextExpandedPaneByTabId,
|
||||
canExpandPaneByTabId: nextCanExpandPaneByTabId,
|
||||
|
|
|
|||
|
|
@ -171,6 +171,9 @@ export type RuntimeMobileSessionTerminalTab = {
|
|||
/** Per-tab view preference (terminal xterm vs native chat). Host-persisted so
|
||||
* paired clients converge; clients still win during the optimistic echo window. */
|
||||
viewMode?: 'terminal' | 'chat'
|
||||
/** Launch context delivered only into the TUI input as an unsent draft; the
|
||||
* mobile chat composer adopts it so the context isn't invisible in chat. */
|
||||
launchDraft?: string
|
||||
isActive: boolean
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue