fix(native-chat): retire mobile-consumed launch drafts

This commit is contained in:
Brennan Benson
2026-07-29 21:26:35 -07:00
parent 6370a823e5
commit 62cb76a906
37 changed files with 720 additions and 42 deletions
@@ -28,6 +28,7 @@ export type MobileSessionTab =
launchAgent?: TuiAgent
/** Host-provided launch context still parked as an unsent TUI-input draft. */
launchDraft?: string
launchDraftCreatedAt?: number
terminalTheme?: MobileTerminalTheme
isActive: boolean
}
@@ -27,6 +27,7 @@ export type MobileNativeChatTab = {
agentStatus?: AgentStatusEntry | null
/** Host-provided launch context still parked as an unsent TUI-input draft. */
launchDraft?: string
launchDraftCreatedAt?: number
}
/** Resolve a session tab to the transcript identity native chat needs, or
@@ -31,6 +31,7 @@ describe('sendMobileNativeChatMessage', () => {
client,
terminal: 'term',
text: 'hello',
resolvedLaunchDraft: { text: 'seed', createdAt: 7 },
mobileClient: { id: 'device', type: 'mobile' }
})
).resolves.toBe(true)
@@ -40,6 +41,7 @@ describe('sendMobileNativeChatMessage', () => {
terminal: 'term',
text: 'hello',
enter: true,
resolvedLaunchDraft: { text: 'seed', createdAt: 7 },
client: { id: 'device', type: 'mobile' }
},
{ timeoutMs: MOBILE_NATIVE_CHAT_SEND_TIMEOUT_MS, budgetSpansConnect: true }
@@ -24,6 +24,8 @@ type MobileNativeChatSendArgs = {
text: string
enter?: boolean
clearInputFirst?: boolean
/** Exact host launch draft this submitting write resolves when accepted. */
resolvedLaunchDraft?: { text: string; createdAt: number }
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. */
@@ -64,6 +66,7 @@ export async function sendMobileNativeChatMessageWithOutcome(
terminal: args.terminal,
text: args.clearInputFirst ? `${CLEAR_UNSUBMITTED_INPUT}${args.text}` : args.text,
enter: args.enter ?? true,
...(args.resolvedLaunchDraft ? { resolvedLaunchDraft: args.resolvedLaunchDraft } : {}),
...(args.mobileClient ? { client: args.mobileClient } : {})
},
// The budget covers this whole write, reconnect wait included — a chat send
@@ -102,12 +102,14 @@ describe('mobile terminal records', () => {
}
const seeded: MobileTerminalSessionTab = {
...base,
launchDraft: 'https://github.com/o/r/issues/12'
launchDraft: 'https://github.com/o/r/issues/12',
launchDraftCreatedAt: 1
}
expect(mobileSessionTabsEqual([base], [seeded])).toBe(false)
expect(mobileSessionTabsEqual([seeded], [base])).toBe(false)
expect(mobileSessionTabsEqual([seeded], [{ ...seeded }])).toBe(true)
expect(mobileSessionTabsEqual([seeded], [{ ...seeded, launchDraftCreatedAt: 2 }])).toBe(false)
})
it('treats terminal agent-status changes as session-tab changes', () => {
@@ -19,6 +19,7 @@ export type MobileTerminalSessionTab = {
agentStatus?: AgentStatusEntry | null
/** Host-provided launch context still parked as an unsent TUI-input draft. */
launchDraft?: string
launchDraftCreatedAt?: number
terminalTheme?: MobileTerminalTheme
isActive: boolean
}
@@ -89,6 +90,7 @@ function mobileSessionTabEqual(
// A frame whose only delta is the launch draft appearing or retracting
// still has to reach the chat composer.
a.launchDraft === b.launchDraft &&
a.launchDraftCreatedAt === b.launchDraftCreatedAt &&
JSON.stringify(a.agentStatus ?? null) === JSON.stringify(b.agentStatus ?? null) &&
JSON.stringify(a.terminalTheme ?? null) === JSON.stringify(b.terminalTheme ?? null)
)
@@ -36,6 +36,7 @@ vi.mock('./use-mobile-native-chat-drafts', () => ({
pending: [],
captureSendOrigin,
readSeededLaunchDraft: () => null,
readSeededLaunchDraftSeed: () => null,
clearDraftForSend,
restoreRejectedDraft,
acceptSend,
@@ -328,6 +329,7 @@ describe('useMobileNativeChatController launch-draft wiring', () => {
terminal: 'term-1',
launchAgent: 'claude',
launchDraft: 'https://github.com/o/r/issues/12',
launchDraftCreatedAt: 7,
isActive: true
}
@@ -386,6 +388,7 @@ describe('useMobileNativeChatController launch-draft wiring', () => {
expect(draftsArgs.at(-1)).toMatchObject({
tabId: 'tab-1',
launchDraft: 'https://github.com/o/r/issues/12',
launchDraftCreatedAt: 7,
chatActive: true,
transcriptLoading: false
})
@@ -138,6 +138,7 @@ export function useMobileNativeChatController(args: {
pending: chatPending,
captureSendOrigin,
readSeededLaunchDraft,
readSeededLaunchDraftSeed,
clearDraftForSend,
restoreRejectedDraft,
acceptSend,
@@ -149,6 +150,7 @@ export function useMobileNativeChatController(args: {
sessionId: activeChatSessionId,
messages: nativeChatSession.messages,
launchDraft: activeSessionTab?.launchDraft ?? null,
launchDraftCreatedAt: activeSessionTab?.launchDraftCreatedAt ?? 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.
@@ -247,7 +249,7 @@ export function useMobileNativeChatController(args: {
handleRef: activeHandleRef,
deviceTokenRef,
captureSendOrigin,
readSeededLaunchDraft,
readSeededLaunchDraftSeed,
clearDraftForSend,
restoreRejectedDraft,
acceptSend,
@@ -38,6 +38,7 @@ describe('useMobileNativeChatDrafts launch draft', () => {
sessionId = `session-${tabId}`,
messages = [],
launchDraft = null,
launchDraftCreatedAt = null,
chatActive = true,
transcriptLoading = false
}: {
@@ -45,6 +46,7 @@ describe('useMobileNativeChatDrafts launch draft', () => {
sessionId?: string | null
messages?: NativeChatMessage[]
launchDraft?: string | null
launchDraftCreatedAt?: number | null
chatActive?: boolean
transcriptLoading?: boolean
}): null {
@@ -55,6 +57,7 @@ describe('useMobileNativeChatDrafts launch draft', () => {
sessionId,
messages,
launchDraft,
launchDraftCreatedAt,
chatActive,
transcriptLoading
})
@@ -97,6 +100,21 @@ describe('useMobileNativeChatDrafts launch draft', () => {
expect(state?.composerText).toBe('')
})
it('captures the generation paired with the adopted text', async () => {
await mount('a')
await act(async () =>
renderer?.update(
createElement(Harness, {
tabId: 'a',
launchDraft: 'issue link',
launchDraftCreatedAt: 7
})
)
)
expect(state?.readSeededLaunchDraftSeed()).toEqual({ text: 'issue link', createdAt: 7 })
})
it('does not overwrite typed composer text with a launch draft', async () => {
await mount('a')
act(() => state?.setComposerText('typed first'))
@@ -9,6 +9,7 @@ import {
} from './mobile-native-chat-draft-reconcile'
import { mobileNativeChatScopeKey } from './mobile-native-chat-scope-key'
import { useMobileNativeChatLaunchDraftSeed } from './use-mobile-native-chat-launch-draft-seed'
import type { MobileNativeChatLaunchDraftSeed } from './use-mobile-native-chat-launch-draft-seed'
export type MobileNativeChatPendingMessage = {
id: string
@@ -44,6 +45,7 @@ export function useMobileNativeChatDrafts(args: {
messages: readonly NativeChatMessage[]
/** Host-provided launch context still parked as an unsent TUI-input draft. */
launchDraft?: string | null
launchDraftCreatedAt?: number | 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
@@ -60,6 +62,7 @@ export function useMobileNativeChatDrafts(args: {
* line, or null once it has been declined or retired. Send paths size their
* pre-clear from it, since one Ctrl+U clears only one logical line. */
readSeededLaunchDraft: () => string | null
readSeededLaunchDraftSeed: () => MobileNativeChatLaunchDraftSeed | null
/** Clear the composer at send time, before the RPC settles. */
clearDraftForSend: (origin: MobileNativeChatSendOrigin, text: string) => void
/** Put the text back after a definite rejection, unless newer edits exist. */
@@ -78,6 +81,7 @@ export function useMobileNativeChatDrafts(args: {
sessionId,
messages,
launchDraft,
launchDraftCreatedAt,
chatActive = true,
transcriptLoading
} = args
@@ -96,10 +100,11 @@ export function useMobileNativeChatDrafts(args: {
activePendingKeyRef.current = pendingKey
const mountedRef = useRef(false)
const { readSeededLaunchDraft } = useMobileNativeChatLaunchDraftSeed({
const { readSeededLaunchDraft, readSeededLaunchDraftSeed } = useMobileNativeChatLaunchDraftSeed({
draftKey,
messages,
launchDraft,
launchDraftCreatedAt,
chatActive,
transcriptLoading,
setDrafts
@@ -316,6 +321,7 @@ export function useMobileNativeChatDrafts(args: {
pending,
captureSendOrigin,
readSeededLaunchDraft,
readSeededLaunchDraftSeed,
clearDraftForSend,
restoreRejectedDraft,
acceptSend,
@@ -2,6 +2,11 @@ import { useCallback, useEffect, useRef, type Dispatch, type SetStateAction } fr
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
import { normalizedUserText } from './mobile-native-chat-draft-reconcile'
export type MobileNativeChatLaunchDraftSeed = {
text: string
createdAt: number | null
}
/**
* Adopting the host's launch-context prefill as the mobile composer draft, and
* retiring it again once it is resolved elsewhere. Split out of the drafts hook
@@ -12,6 +17,7 @@ export function useMobileNativeChatLaunchDraftSeed(args: {
messages: readonly NativeChatMessage[]
/** Host-provided launch context still parked as an unsent TUI-input draft. */
launchDraft?: string | null
launchDraftCreatedAt?: number | null
chatActive: boolean
transcriptLoading?: boolean
setDrafts: Dispatch<SetStateAction<Record<string, string>>>
@@ -20,12 +26,23 @@ export function useMobileNativeChatLaunchDraftSeed(args: {
* once declined or retired. Send paths size their pre-clear from it, since
* one Ctrl+U clears only one logical line. */
readSeededLaunchDraft: () => string | null
readSeededLaunchDraftSeed: () => MobileNativeChatLaunchDraftSeed | null
} {
const { draftKey, messages, launchDraft, chatActive, transcriptLoading, setDrafts } = args
const {
draftKey,
messages,
launchDraft,
launchDraftCreatedAt,
chatActive,
transcriptLoading,
setDrafts
} = args
// Seeded launch-context text per tab; '' marks a permanent decline so a
// Seeded launch-context text per tab; null marks a permanent decline so a
// cleared composer never resurrects the prefill.
const seededLaunchDraftByKeyRef = useRef(new Map<string, string>())
const seededLaunchDraftByKeyRef = useRef(
new Map<string, MobileNativeChatLaunchDraftSeed | null>()
)
// 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.
@@ -47,14 +64,25 @@ export function useMobileNativeChatLaunchDraftSeed(args: {
// A user turn already in the transcript means the TUI prefill was submitted
// or deliberately cleared; decline instead of resurrecting it.
if (messages.some((message) => normalizedUserText(message) !== null)) {
seededLaunchDraftByKeyRef.current.set(draftKey, '')
seededLaunchDraftByKeyRef.current.set(draftKey, null)
return
}
seededLaunchDraftByKeyRef.current.set(draftKey, launchDraft)
seededLaunchDraftByKeyRef.current.set(draftKey, {
text: launchDraft,
createdAt: launchDraftCreatedAt ?? null
})
setDrafts((previous) =>
(previous[draftKey] ?? '') === '' ? { ...previous, [draftKey]: launchDraft } : previous
)
}, [chatActive, draftKey, launchDraft, messages, setDrafts, transcriptLoading])
}, [
chatActive,
draftKey,
launchDraft,
launchDraftCreatedAt,
messages,
setDrafts,
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
@@ -74,18 +102,21 @@ export function useMobileNativeChatLaunchDraftSeed(args: {
if (!hasUserTurn && launchDraft?.trim()) {
return
}
seededLaunchDraftByKeyRef.current.set(draftKey, '')
seededLaunchDraftByKeyRef.current.set(draftKey, null)
setDrafts((previous) =>
(previous[draftKey] ?? '') === seeded ? { ...previous, [draftKey]: '' } : previous
(previous[draftKey] ?? '') === seeded.text ? { ...previous, [draftKey]: '' } : previous
)
}, [chatActive, draftKey, launchDraft, messages, setDrafts, transcriptLoading])
// '' in the map is a permanent decline, not a parked draft — both mean there is
// nothing of ours on the TUI line, so both read as null.
// A missing or declined entry means there is nothing of ours on the TUI line.
const readSeededLaunchDraft = useCallback(
() => (draftKey ? seededLaunchDraftByKeyRef.current.get(draftKey) || null : null),
() => (draftKey ? (seededLaunchDraftByKeyRef.current.get(draftKey)?.text ?? null) : null),
[draftKey]
)
const readSeededLaunchDraftSeed = useCallback(
() => (draftKey ? (seededLaunchDraftByKeyRef.current.get(draftKey) ?? null) : null),
[draftKey]
)
return { readSeededLaunchDraft }
return { readSeededLaunchDraft, readSeededLaunchDraftSeed }
}
@@ -29,7 +29,9 @@ describe('useMobileNativeChatMessageSend', () => {
let renderer: ReactTestRenderer | null = null
let api: Send | null = null
const mount = (readSeededLaunchDraft: () => string | null): void => {
const mount = (
readSeededLaunchDraftSeed: () => { text: string; createdAt: number | null } | null
): void => {
function Probe(): null {
api = useMobileNativeChatMessageSend({
client: { sendRequest: vi.fn() } as never,
@@ -37,7 +39,7 @@ describe('useMobileNativeChatMessageSend', () => {
handleRef: { current: 'term' },
deviceTokenRef: { current: 'device' },
captureSendOrigin: () => ({ draftKey: 'k', pendingKey: 'p' }) as never,
readSeededLaunchDraft,
readSeededLaunchDraftSeed,
clearDraftForSend: () => {},
restoreRejectedDraft: () => {},
acceptSend: () => {},
@@ -51,8 +53,14 @@ describe('useMobileNativeChatMessageSend', () => {
})
}
const sentArgs = (): { clearInputFirst?: boolean } =>
sendWithOutcome.mock.calls[0]![0] as { clearInputFirst?: boolean }
const sentArgs = (): {
clearInputFirst?: boolean
resolvedLaunchDraft?: { text: string; createdAt: number }
} =>
sendWithOutcome.mock.calls[0]![0] as {
clearInputFirst?: boolean
resolvedLaunchDraft?: { text: string; createdAt: number }
}
const clearArgs = (): { clearInput?: string } =>
(clearInputWrite.mock.calls[0]?.[0] ?? {}) as { clearInput?: string }
@@ -72,7 +80,7 @@ describe('useMobileNativeChatMessageSend', () => {
})
it('sizes the pre-clear to every line of a parked launch draft', async () => {
mount(() => DRAFT)
mount(() => ({ text: DRAFT, createdAt: 1 }))
await act(async () => {
await api!.send('hello')
})
@@ -81,7 +89,7 @@ describe('useMobileNativeChatMessageSend', () => {
it('issues the burst as its OWN write, before the body', async () => {
// Bundled into the body write it arrived as literal Ctrl+U text.
mount(() => DRAFT)
mount(() => ({ text: DRAFT, createdAt: 1 }))
await act(async () => {
await api!.send('hello')
})
@@ -95,7 +103,7 @@ describe('useMobileNativeChatMessageSend', () => {
it('aborts without sending the body when the clear is rejected', async () => {
// Sending on top of an uncleared line is exactly the concatenation bug.
clearInputWrite.mockResolvedValue(false)
mount(() => DRAFT)
mount(() => ({ text: DRAFT, createdAt: 1 }))
let result: boolean | undefined
await act(async () => {
result = await api!.send('hello')
@@ -107,11 +115,12 @@ describe('useMobileNativeChatMessageSend', () => {
it('drops the body write\u2019s own Ctrl+U prefix once the dedicated clear ran', async () => {
// A Ctrl+U written immediately before body text in the SAME write arrives as
// a literal control character, so it would head the received message.
mount(() => DRAFT)
mount(() => ({ text: DRAFT, createdAt: 1 }))
await act(async () => {
await api!.send('hello')
})
expect(sentArgs().clearInputFirst).toBe(false)
expect(sentArgs().resolvedLaunchDraft).toEqual({ text: DRAFT, createdAt: 1 })
})
it('keeps the single-Ctrl+U prefix when no dedicated clear ran', async () => {
@@ -120,6 +129,7 @@ describe('useMobileNativeChatMessageSend', () => {
await api!.send('hello')
})
expect(sentArgs().clearInputFirst).toBe(true)
expect(sentArgs().resolvedLaunchDraft).toBeUndefined()
})
it('writes no clear at all when nothing is parked on the line', async () => {
@@ -131,7 +141,7 @@ describe('useMobileNativeChatMessageSend', () => {
})
it('reads the draft at send time, so a retired seed stops widening the clear', async () => {
let parked: string | null = DRAFT
let parked: { text: string; createdAt: number } | null = { text: DRAFT, createdAt: 1 }
mount(() => parked)
await act(async () => {
await api!.send('first')
@@ -147,11 +157,20 @@ describe('useMobileNativeChatMessageSend', () => {
it('does not clear an image send after the image was pasted', async () => {
// A second clear here would wipe the image that was just pasted.
mount(() => DRAFT)
mount(() => ({ text: DRAFT, createdAt: 1 }))
await act(async () => {
await api!.send('caption', ['file:///a.png'])
})
expect(clearInputWrite).not.toHaveBeenCalled()
expect(sentArgs().clearInputFirst).toBe(false)
expect(sentArgs().resolvedLaunchDraft).toEqual({ text: DRAFT, createdAt: 1 })
})
it('does not resolve a composer seed from a question-card answer', async () => {
mount(() => ({ text: DRAFT, createdAt: 1 }))
await act(async () => {
await api!.answerQuestion('1')
})
expect(sentArgs().resolvedLaunchDraft).toBeUndefined()
})
})
@@ -8,6 +8,7 @@ import {
} from './mobile-native-chat-send'
import { healMobileNativeChatStaleInput } from './mobile-native-chat-stale-input'
import type { MobileNativeChatSendOrigin } from './use-mobile-native-chat-drafts'
import type { MobileNativeChatLaunchDraftSeed } from './use-mobile-native-chat-launch-draft-seed'
import { buildAgentTuiClearInputForText } from '../../../src/shared/agent-tui-input-clear'
export type MobileNativeChatMessageSend = {
@@ -36,7 +37,7 @@ export function useMobileNativeChatMessageSend(args: {
captureSendOrigin: (text: string) => MobileNativeChatSendOrigin | null
/** Launch-context text Orca parked on the agent's TUI input line, or null. Read
* at send time so the pre-clear can be sized to every line it occupies. */
readSeededLaunchDraft: () => string | null
readSeededLaunchDraftSeed: () => MobileNativeChatLaunchDraftSeed | null
clearDraftForSend: (origin: MobileNativeChatSendOrigin, text: string) => void
restoreRejectedDraft: (origin: MobileNativeChatSendOrigin, text: string) => void
acceptSend: (origin: MobileNativeChatSendOrigin, text: string, images?: string[]) => void
@@ -53,7 +54,7 @@ export function useMobileNativeChatMessageSend(args: {
handleRef,
deviceTokenRef,
captureSendOrigin,
readSeededLaunchDraft,
readSeededLaunchDraftSeed,
clearDraftForSend,
restoreRejectedDraft,
acceptSend,
@@ -112,12 +113,12 @@ export function useMobileNativeChatMessageSend(args: {
// arrived as literal Ctrl+U text and the draft concatenated (see
// clearMobileNativeChatInput). A rejected clear aborts the send rather
// than pasting on top of an uncleared line.
const seededLaunchDraft = readSeededLaunchDraft()
const seededLaunchDraft = readSeededLaunchDraftSeed()
if (seededLaunchDraft && !images?.length) {
const cleared = await clearMobileNativeChatInput({
client,
terminal: handle,
clearInput: buildAgentTuiClearInputForText(seededLaunchDraft),
clearInput: buildAgentTuiClearInputForText(seededLaunchDraft.text),
deadline,
...(deviceTokenRef.current
? { mobileClient: { id: deviceTokenRef.current, type: 'mobile' } }
@@ -147,6 +148,14 @@ export function useMobileNativeChatMessageSend(args: {
// write reaches the agent as a literal control character rather than a
// keypress (observed live as a stray \x15 heading the received message).
clearInputFirst: !images?.length && !seededLaunchDraft,
...(syncComposer && typeof seededLaunchDraft?.createdAt === 'number'
? {
resolvedLaunchDraft: {
text: seededLaunchDraft.text,
createdAt: seededLaunchDraft.createdAt
}
}
: {}),
deadline,
...(deviceTokenRef.current
? { mobileClient: { id: deviceTokenRef.current, type: 'mobile' } }
@@ -182,7 +191,7 @@ export function useMobileNativeChatMessageSend(args: {
handleRef,
holdUnconfirmedSend,
onSendError,
readSeededLaunchDraft,
readSeededLaunchDraftSeed,
restoreRejectedDraft
]
)
+125
View File
@@ -73,6 +73,7 @@ import { HeadlessEmulator } from '../daemon/headless-emulator'
import {
HEADLESS_RUNTIME_WINDOW_ID,
type RuntimeMobileSessionTabsResult,
type RuntimeSyncWindowGraph,
type RuntimeTerminalCreate
} from '../../shared/runtime-types'
import type { TerminalSideEffectBatch } from '../../shared/terminal-side-effect-facts'
@@ -2514,6 +2515,130 @@ describe('OrcaRuntimeService', () => {
})
})
it('routes a launch-draft resolution to the handle-owning local and remote renderers', async () => {
const runtime = new OrcaRuntimeService(store)
const nativeChatLaunchDraftResolved = vi.fn()
const events: RuntimeClientEvent[] = []
runtime.setNotifier({ nativeChatLaunchDraftResolved } as never)
runtime.onClientEvent((event) => events.push(event))
runtime.attachWindow(1)
const graph: RuntimeSyncWindowGraph = {
tabs: [
{
tabId: 'tab-1',
worktreeId: 'repo-1::/tmp/worktree-a',
title: 'Claude',
activeLeafId: 'pane:1',
layout: null
}
],
leaves: [
{
tabId: 'tab-1',
worktreeId: 'repo-1::/tmp/worktree-a',
leafId: 'pane:1',
paneRuntimeId: 1,
ptyId: 'pty-1'
}
],
mobileSessionTabs: [
{
worktree: 'repo-1::/tmp/worktree-a',
publicationEpoch: 'launch-draft-epoch',
snapshotVersion: 1,
activeGroupId: 'group-1',
activeTabId: 'tab-1::pane:1',
activeTabType: 'terminal',
tabs: [
{
type: 'terminal',
id: 'tab-1::pane:1',
parentTabId: 'tab-1',
leafId: 'pane:1',
title: 'Claude',
launchDraft: 'seed',
launchDraftCreatedAt: 7,
isActive: true
}
]
}
]
}
runtime.syncWindowGraph(1, graph)
const listed = await runtime.listMobileSessionTabs('branch:feature/foo')
const mobileTab = listed.tabs.find((tab) => tab.type === 'terminal')
if (!mobileTab?.terminal) {
throw new Error('expected mobile terminal handle')
}
expect(mobileTab).toMatchObject({ launchDraft: 'seed', launchDraftCreatedAt: 7 })
runtime.notifyNativeChatLaunchDraftResolved(mobileTab.terminal, {
text: 'seed',
createdAt: 7
})
expect(nativeChatLaunchDraftResolved).toHaveBeenCalledWith('tab-1', {
text: 'seed',
createdAt: 7
})
expect(events).toContainEqual({
type: 'nativeChatLaunchDraftResolved',
tabId: 'tab-1',
text: 'seed',
createdAt: 7
})
expect(runtime.getNativeChatLaunchDraftResolutionClientEventSnapshot()).toContainEqual({
type: 'nativeChatLaunchDraftResolved',
tabId: 'tab-1',
text: 'seed',
createdAt: 7
})
const retired = (await runtime.listMobileSessionTabs('branch:feature/foo')).tabs.find(
(tab) => tab.type === 'terminal'
)
expect(retired).not.toHaveProperty('launchDraft')
expect(retired).not.toHaveProperty('launchDraftCreatedAt')
runtime.markRendererReloading(1)
const replay = runtime.syncWindowGraph(1, {
...graph,
mobileSessionTabs: graph.mobileSessionTabs?.map((snapshot) => ({
...snapshot,
publicationEpoch: 'launch-draft-reload',
snapshotVersion: 2
}))
})
expect(replay.nativeChatLaunchDraftResolutions).toEqual([
{ tabId: 'tab-1', text: 'seed', createdAt: 7 }
])
expect(
(await runtime.listMobileSessionTabs('branch:feature/foo')).tabs.find(
(tab) => tab.type === 'terminal'
)
).not.toHaveProperty('launchDraft')
const reconciled = runtime.syncWindowGraph(1, {
...graph,
mobileSessionTabs: graph.mobileSessionTabs?.map((snapshot) => ({
...snapshot,
publicationEpoch: 'launch-draft-reload',
snapshotVersion: 3,
tabs: snapshot.tabs.map((tab) => {
if (tab.type !== 'terminal') {
return tab
}
return { ...tab, launchDraftCreatedAt: 8 }
})
}))
})
expect(reconciled.nativeChatLaunchDraftResolutions).toBeUndefined()
expect(
(await runtime.listMobileSessionTabs('branch:feature/foo')).tabs.find(
(tab) => tab.type === 'terminal'
)
).toMatchObject({ launchDraft: 'seed', launchDraftCreatedAt: 8 })
})
it('surfaces stale terminal handles for stranded panes and recovers after same-pane wake', async () => {
const runtime = new OrcaRuntimeService(store)
const tabId = 'tab-1'
+161 -2
View File
@@ -324,6 +324,7 @@ import {
type RuntimeMobileSessionTabsRemovedResult,
type RuntimeMobileSessionTabsResult,
type RuntimeMobileSessionTabsSnapshot,
type RuntimeNativeChatLaunchDraftResolution,
type RuntimeSessionTabCloseReason,
type RuntimeBrowserDriverState,
type RuntimeTerminalDriverState,
@@ -1777,6 +1778,10 @@ type RuntimeNotifier = {
// and so a future write coordinator can use the same signal as scheduling
// input. See docs/mobile-presence-lock.md.
terminalDriverChanged(ptyId: string, driver: DriverState): void
nativeChatLaunchDraftResolved?(
tabId: string,
resolution: { text: string; createdAt: number }
): void
browserDriverChanged?(browserPageId: string, driver: RuntimeBrowserDriverState): void
}
@@ -2582,6 +2587,12 @@ type LayoutQueueEntry = {
}[]
}
type NativeChatLaunchDraftResolutionTombstone = RuntimeNativeChatLaunchDraftResolution & {
worktreeId: string
}
const MAX_NATIVE_CHAT_LAUNCH_DRAFT_RESOLUTION_TOMBSTONES = 200
async function hasLocalWorktreeBaseRef(
repoPath: string,
baseRef: string,
@@ -2702,6 +2713,10 @@ export class OrcaRuntimeService {
private ptyController: RuntimePtyController | null = null
private notifier: RuntimeNotifier | null = null
private clientEventListeners = new Set<(event: RuntimeClientEvent) => void>()
private nativeChatLaunchDraftResolutionByTabId = new Map<
string,
NativeChatLaunchDraftResolutionTombstone
>()
private worktreeLifecycleListeners = new Set<(event: RuntimeWorktreeLifecycleEvent) => void>()
private forkBackfillStarted = false
private agentBrowserBridge: AgentBrowserBridge | null = null
@@ -4705,12 +4720,146 @@ export class OrcaRuntimeService {
return events
}
getNativeChatLaunchDraftResolutionClientEventSnapshot(): Extract<
RuntimeClientEvent,
{ type: 'nativeChatLaunchDraftResolved' }
>[] {
return [...this.nativeChatLaunchDraftResolutionByTabId.values()]
.sort((a, b) => a.tabId.localeCompare(b.tabId))
.map(({ tabId, text, createdAt }) => ({
type: 'nativeChatLaunchDraftResolved',
tabId,
text,
createdAt
}))
}
private emitClientEvent(event: RuntimeClientEvent): void {
// Why: a throwing subscriber here once escaped acquireWorktreeTerminalSpawn after it took the
// per-worktree terminal mutation, leaking it and wedging that worktree's sleep until restart.
notifyRuntimeListeners(this.clientEventListeners, (listener) => listener(event), 'client-event')
}
notifyNativeChatLaunchDraftResolved(
handle: string,
resolution: { text: string; createdAt: number }
): void {
const owner = this.resolveNativeChatLaunchDraftOwner(handle)
if (!owner) {
return
}
const tombstone = { ...owner, ...resolution }
this.nativeChatLaunchDraftResolutionByTabId.delete(owner.tabId)
this.nativeChatLaunchDraftResolutionByTabId.set(owner.tabId, tombstone)
while (
this.nativeChatLaunchDraftResolutionByTabId.size >
MAX_NATIVE_CHAT_LAUNCH_DRAFT_RESOLUTION_TOMBSTONES
) {
const oldestTabId = this.nativeChatLaunchDraftResolutionByTabId.keys().next().value
if (typeof oldestTabId !== 'string') {
break
}
this.nativeChatLaunchDraftResolutionByTabId.delete(oldestTabId)
}
this.retireResolvedNativeChatLaunchDraftFromMobileSnapshot(tombstone)
this.notifier?.nativeChatLaunchDraftResolved?.(owner.tabId, resolution)
this.emitClientEvent({
type: 'nativeChatLaunchDraftResolved',
tabId: owner.tabId,
...resolution
})
}
private resolveNativeChatLaunchDraftOwner(
handle: string
): { tabId: string; worktreeId: string } | null {
const record = this.handles.get(handle)
if (!record) {
return null
}
if (!record.tabId.startsWith('pty:')) {
return { tabId: record.tabId, worktreeId: record.worktreeId }
}
const pty = record.ptyId ? this.ptysById.get(record.ptyId) : null
const tabId =
pty?.tabId && !pty.tabId.startsWith('pty:')
? pty.tabId
: parsePaneKey(pty?.paneKey ?? '')?.tabId
if (!pty || !tabId || tabId.startsWith('pty:')) {
return null
}
return { tabId, worktreeId: pty.worktreeId }
}
private retireResolvedNativeChatLaunchDraftFromMobileSnapshot(
resolution: NativeChatLaunchDraftResolutionTombstone
): void {
for (const [worktreeId, snapshot] of this.mobileSessionTabsByWorktree) {
if (!runtimeWorktreeIdsEqual(worktreeId, resolution.worktreeId)) {
continue
}
const next = this.applyNativeChatLaunchDraftResolutionFence(snapshot)
if (next === snapshot) {
return
}
this.mobileSessionTabsByWorktree.set(worktreeId, {
...next,
snapshotVersion: snapshot.snapshotVersion + 1
})
this.mobileSessionTabsNotifyCoalescer.schedule(worktreeId)
return
}
}
private applyNativeChatLaunchDraftResolutionFence(
snapshot: RuntimeMobileSessionTabsSnapshot
): RuntimeMobileSessionTabsSnapshot {
let changed = false
const tabs = snapshot.tabs.map((tab) => {
if (tab.type !== 'terminal') {
return tab
}
const resolution = this.nativeChatLaunchDraftResolutionByTabId.get(tab.parentTabId)
if (
!resolution ||
!runtimeWorktreeIdsEqual(snapshot.worktree, resolution.worktreeId) ||
tab.launchDraft !== resolution.text ||
tab.launchDraftCreatedAt !== resolution.createdAt
) {
return tab
}
changed = true
const next = { ...tab }
delete next.launchDraft
delete next.launchDraftCreatedAt
return next
})
return changed ? { ...snapshot, tabs } : snapshot
}
private reconcileNativeChatLaunchDraftResolutionTombstones(
snapshot: RuntimeMobileSessionTabsSnapshot
): void {
for (const [tabId, resolution] of this.nativeChatLaunchDraftResolutionByTabId) {
if (!runtimeWorktreeIdsEqual(snapshot.worktree, resolution.worktreeId)) {
continue
}
const surfaces = snapshot.tabs.filter(
(tab): tab is RuntimeMobileSessionTerminalTab =>
tab.type === 'terminal' && tab.parentTabId === tabId
)
if (
surfaces.length === 0 ||
!surfaces.some(
(tab) =>
tab.launchDraft === resolution.text && tab.launchDraftCreatedAt === resolution.createdAt
)
) {
this.nativeChatLaunchDraftResolutionByTabId.delete(tabId)
}
}
}
private notifyWorktreesChanged(repoId: string): void {
this.notifier?.worktreesChanged(repoId)
this.emitClientEvent({ type: 'worktreesChanged', repoId })
@@ -5174,9 +5323,14 @@ export class OrcaRuntimeService {
}
const agentOrchestrationByPaneKey = this.buildAgentOrchestrationByPaneKey()
const nativeChatLaunchDraftResolutions =
this.getNativeChatLaunchDraftResolutionClientEventSnapshot().map(
({ tabId, text, createdAt }) => ({ tabId, text, createdAt })
)
return {
...this.getStatus(),
...(agentOrchestrationByPaneKey ? { agentOrchestrationByPaneKey } : {})
...(agentOrchestrationByPaneKey ? { agentOrchestrationByPaneKey } : {}),
...(nativeChatLaunchDraftResolutions.length > 0 ? { nativeChatLaunchDraftResolutions } : {})
}
}
@@ -27790,7 +27944,9 @@ export class OrcaRuntimeService {
) {
continue
}
const fencedSnapshot = this.applyMobileSessionRetirementFences(snapshot)
this.reconcileNativeChatLaunchDraftResolutionTombstones(snapshot)
const launchDraftFencedSnapshot = this.applyNativeChatLaunchDraftResolutionFence(snapshot)
const fencedSnapshot = this.applyMobileSessionRetirementFences(launchDraftFencedSnapshot)
const nextSnapshot = this.mergePreservedHeadlessMobileSessionTabs(fencedSnapshot, existing)
// Why: clients drop same-epoch frames whose version isn't strictly newer,
// and main-local touches may already have emitted a higher version than
@@ -28405,6 +28561,9 @@ export class OrcaRuntimeService {
...(tab.isPinned ? { isPinned: true } : {}),
...(tab.viewMode ? { viewMode: tab.viewMode } : {}),
...(tab.launchDraft ? { launchDraft: tab.launchDraft } : {}),
...(tab.launchDraftCreatedAt !== undefined
? { launchDraftCreatedAt: tab.launchDraftCreatedAt }
: {}),
isActive: tab.isActive,
...(terminalHandle
? { status: 'ready' as const, terminal: terminalHandle }
@@ -332,6 +332,14 @@ describe('remote runtime request connection integration', () => {
const worktreeId = 'repo-1::C:\\repo\\feature'
const ptyId = `${worktreeId}@@pty-1`
let sleepSnapshot: RuntimeClientEvent[] = []
const launchDraftResolutionSnapshot: RuntimeClientEvent[] = [
{
type: 'nativeChatLaunchDraftResolved',
tabId: 'tab-1',
text: 'seed',
createdAt: 7
}
]
const emit = (event: RuntimeClientEvent): void => {
for (const listener of clientEventListeners) {
listener(event)
@@ -362,6 +370,7 @@ describe('remote runtime request connection integration', () => {
return () => clientEventListeners.delete(listener)
},
getTerminalSleepClientEventSnapshot: () => sleepSnapshot,
getNativeChatLaunchDraftResolutionClientEventSnapshot: () => launchDraftResolutionSnapshot,
sleepTerminalsForWorktree: async () => {
emit({
type: 'worktreeTerminalSleepState',
@@ -440,6 +449,9 @@ describe('remote runtime request connection integration', () => {
await waitFor(() =>
clientEvents.every((events) => events.some((e) => e.type === 'ready'))
)
for (const events of clientEvents) {
expect(events).toContainEqual(launchDraftResolutionSnapshot[0])
}
await expect(
requester.request(
'terminal.sleep',
@@ -487,6 +499,7 @@ describe('remote runtime request connection integration', () => {
.filter((event) => event.type === 'worktreeTerminalSleepState')
.map((event) => event.phase)
).toEqual(['committed'])
expect(reconnectedEvents).toContainEqual(launchDraftResolutionSnapshot[0])
sleepSnapshot = []
emit({
@@ -38,6 +38,10 @@ export const CLIENT_EVENT_METHODS: readonly RpcAnyMethod[] = [
for (const event of runtime.getTerminalSleepClientEventSnapshot?.() ?? []) {
emit(event)
}
for (const event of runtime.getNativeChatLaunchDraftResolutionClientEventSnapshot?.() ??
[]) {
emit(event)
}
const sshStates = listRegisteredSshTargets().flatMap((target) => {
const state = getPublicSshState(getRegisteredSshState(target.id) ?? null)
return state ? [{ targetId: target.id, state }] : []
+15
View File
@@ -863,6 +863,12 @@ const TerminalSend = TerminalHandle.extend({
text: OptionalString,
enter: z.unknown().optional(),
interrupt: z.unknown().optional(),
resolvedLaunchDraft: z
.object({
text: z.string(),
createdAt: z.number().finite()
})
.optional(),
requireAgentStatus: z.enum(['sendable']).optional(),
// Why: terminal-generated replies are valid input but must not transfer the shared terminal floor.
inputKind: z.enum(['query-reply']).optional(),
@@ -1183,6 +1189,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
params: TerminalSend,
handler: async (params, { runtime, clientId }) => {
await assertTerminalSendTextWithinLimit(params.text)
await assertTerminalSendTextWithinLimit(params.resolvedLaunchDraft?.text)
const queryReplyClientId = clientId ?? params.client?.id
if (
params.inputKind === 'query-reply' &&
@@ -1361,6 +1368,14 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
if (result.accepted !== true) {
mobileFloorClaim.current?.rollback()
}
if (
result.accepted === true &&
params.enter === true &&
params.client?.type === 'mobile' &&
params.resolvedLaunchDraft
) {
runtime.notifyNativeChatLaunchDraftResolved(params.terminal, params.resolvedLaunchDraft)
}
// Why: deliberate mobile input takes the floor (drives `* → mobile{clientId}`); clientless sends fall back to the current mobile driver.
return { send: result }
}
@@ -0,0 +1,67 @@
import { describe, expect, it, vi } from 'vitest'
import type { OrcaRuntimeService } from '../orca-runtime'
import type { RpcRequest } from './core'
import { RpcDispatcher } from './dispatcher'
import { TERMINAL_METHODS } from './methods/terminal'
const RESOLUTION = { text: 'seed', createdAt: 1 }
function makeRequest(params: unknown): RpcRequest {
return { id: 'request', authToken: 'token', method: 'terminal.send', params }
}
function makeRuntime(accepted: boolean): OrcaRuntimeService {
return {
getRuntimeId: () => 'runtime',
resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
getDriver: vi.fn().mockReturnValue({ kind: 'idle' }),
beginMobileInputFloor: vi.fn().mockReturnValue({ commit: vi.fn(), rollback: vi.fn() }),
sendTerminal: vi.fn().mockResolvedValue({
handle: 'terminal-1',
accepted,
bytesWritten: accepted ? 1 : 0
}),
notifyNativeChatLaunchDraftResolved: vi.fn()
} as unknown as OrcaRuntimeService
}
async function send(
runtime: OrcaRuntimeService,
options: { enter: boolean; clientType: 'mobile' | 'desktop' }
): Promise<void> {
const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS })
await dispatcher.dispatch(
makeRequest({
terminal: 'terminal-1',
text: 'hello',
enter: options.enter,
resolvedLaunchDraft: RESOLUTION,
client: { id: 'client-1', type: options.clientType }
})
)
}
describe('terminal.send launch-draft resolution', () => {
it('notifies after an accepted mobile submit', async () => {
const runtime = makeRuntime(true)
await send(runtime, { enter: true, clientType: 'mobile' })
expect(runtime.notifyNativeChatLaunchDraftResolved).toHaveBeenCalledWith(
'terminal-1',
RESOLUTION
)
})
it.each([
['rejected submit', false, true, 'mobile'],
['clear-only write', true, false, 'mobile'],
['desktop submit', true, true, 'desktop']
] as const)('does not notify after a %s', async (_case, accepted, enter, clientType) => {
const runtime = makeRuntime(accepted)
await send(runtime, { enter, clientType })
expect(runtime.notifyNativeChatLaunchDraftResolved).not.toHaveBeenCalled()
})
})
@@ -472,6 +472,8 @@ function registerRuntimeWindowLifecycle(
send('runtime:terminalFitOverrideChanged', { ptyId, mode, cols, rows }),
terminalDriverChanged: (ptyId, driver) =>
send('runtime:terminalDriverChanged', { ptyId, driver }),
nativeChatLaunchDraftResolved: (tabId, resolution) =>
send('runtime:nativeChatLaunchDraftResolved', { tabId, ...resolution }),
browserDriverChanged: (browserPageId, driver) =>
send('runtime:browserDriverChanged', { browserPageId, driver })
})
+3
View File
@@ -3304,6 +3304,9 @@ export type PreloadApi = {
onTerminalDriverChanged: (
callback: (event: { ptyId: string; driver: RuntimeTerminalDriverState }) => void
) => () => void
onNativeChatLaunchDraftResolved?: (
callback: (event: { tabId: string; text: string; createdAt: number }) => void
) => () => void
onBrowserDriverChanged: (
callback: (event: { browserPageId: string; driver: RuntimeBrowserDriverState }) => void
) => () => void
+10
View File
@@ -4250,6 +4250,16 @@ const api = {
ipcRenderer.on('runtime:terminalDriverChanged', listener)
return () => ipcRenderer.removeListener('runtime:terminalDriverChanged', listener)
},
onNativeChatLaunchDraftResolved: (
callback: (event: { tabId: string; text: string; createdAt: number }) => void
): (() => void) => {
const listener = (
_event: Electron.IpcRendererEvent,
data: { tabId: string; text: string; createdAt: number }
) => callback(data)
ipcRenderer.on('runtime:nativeChatLaunchDraftResolved', listener)
return () => ipcRenderer.removeListener('runtime:nativeChatLaunchDraftResolved', listener)
},
onBrowserDriverChanged: (
callback: (event: { browserPageId: string; driver: RuntimeBrowserDriverState }) => void
): (() => void) => {
@@ -175,6 +175,17 @@ describe('useNativeChatLaunchDraftSignal', () => {
expect(result.current.launchDraft).not.toBeNull()
})
it('resolves immediately from an accepted mobile submission', () => {
mocks.storeState.nativeChatLaunchDraftByTabId = {
'tab-1': launchDraft({ adopted: true, resolved: true, createdAt: SEEDED_AT })
}
const { result } = renderSignal([], true)
expect(result.current.launchDraftResolved).toBe(true)
expect(result.current.launchDraft?.resolved).toBe(true)
})
it('ignores a draft seeded for another agent', () => {
mocks.storeState.nativeChatLaunchDraftByTabId = {
'tab-1': launchDraft({ agent: 'codex', createdAt: SEEDED_AT })
@@ -51,9 +51,10 @@ export function useNativeChatLaunchDraftSignal(args: {
const baseline = held?.baseline ?? null
const launchDraftResolved = useMemo(
() =>
paneLaunchDraft && !transcriptLoading
paneLaunchDraft?.resolved === true ||
(paneLaunchDraft && !transcriptLoading
? launchDraftResolvedByTranscript(paneLaunchDraft, messages, baseline)
: false,
: false),
[paneLaunchDraft, messages, baseline, transcriptLoading]
)
return { launchDraft: paneLaunchDraft, launchDraftResolved }
+17
View File
@@ -88,6 +88,7 @@ import { attachMobileMarkdownBridge } from '@/runtime/mobile-markdown-bridge'
import { closeMobileSessionTabInStore } from '@/runtime/mobile-session-tab-close'
import { createWorktreeChangeRefreshQueue } from './worktree-change-refresh-queue'
import { subscribeRuntimeClientEvents } from '@/runtime/runtime-client-events'
import { applyNativeChatLaunchDraftResolved } from '@/runtime/native-chat-launch-draft-runtime-resolution'
import { toRemoteRuntimePtyId } from '@/runtime/runtime-terminal-stream'
import { dispatchTerminalSideEffectBatch } from '@/components/terminal-pane/terminal-side-effect-facts-handler'
import { subscribeToUnpairedDeviceAuthNotification } from './unpaired-device-auth-notification'
@@ -936,6 +937,10 @@ export function useIpcEvents(): void {
})
return
}
if (event.type === 'nativeChatLaunchDraftResolved') {
applyNativeChatLaunchDraftResolved(useAppStore.getState(), event)
return
}
if (event.type === 'reposChanged') {
runtimeProjectRefreshScheduler.request(environmentId)
return
@@ -3472,6 +3477,18 @@ export function useIpcEvents(): void {
})
)
const unsubscribeLaunchDraftResolution = window.api.runtime.onNativeChatLaunchDraftResolved?.(
(event) => {
applyNativeChatLaunchDraftResolved(useAppStore.getState(), {
type: 'nativeChatLaunchDraftResolved',
...event
})
}
)
if (unsubscribeLaunchDraftResolution) {
unsubs.push(unsubscribeLaunchDraftResolution)
}
unsubs.push(
window.api.runtime.onBrowserDriverChanged((event) => {
if (isRuntimeEnvironmentActive()) {
@@ -20,4 +20,6 @@ export type NativeChatLaunchDraft = {
createdAt: number
/** Set once a composer copied the text into its draft; blocks re-adoption after the user clears it. */
adopted?: boolean
/** Accepted mobile submission consumed the TUI-side copy. */
resolved?: boolean
}
@@ -0,0 +1,18 @@
import { describe, expect, it, vi } from 'vitest'
import { applyNativeChatLaunchDraftResolved } from './native-chat-launch-draft-runtime-resolution'
describe('applyNativeChatLaunchDraftResolved', () => {
it('routes the exact generation to the store action', () => {
const resolveNativeChatLaunchDraft = vi.fn()
applyNativeChatLaunchDraftResolved(
{ resolveNativeChatLaunchDraft },
{ type: 'nativeChatLaunchDraftResolved', tabId: 'tab-1', text: 'seed', createdAt: 7 }
)
expect(resolveNativeChatLaunchDraft).toHaveBeenCalledWith('tab-1', {
text: 'seed',
createdAt: 7
})
})
})
@@ -0,0 +1,19 @@
import type { AppState } from '@/store'
import type { RuntimeClientEvent } from '../../../shared/runtime-client-events'
type LaunchDraftResolvedEvent = Extract<
RuntimeClientEvent,
{ type: 'nativeChatLaunchDraftResolved' }
>
type LaunchDraftResolutionState = Pick<AppState, 'resolveNativeChatLaunchDraft'>
export function applyNativeChatLaunchDraftResolved(
state: LaunchDraftResolutionState,
event: LaunchDraftResolvedEvent
): void {
state.resolveNativeChatLaunchDraft(event.tabId, {
text: event.text,
createdAt: event.createdAt
})
}
@@ -54,17 +54,32 @@ describe('subscribeRuntimeClientEvents', () => {
batch: { ptyId: 'pty-1', seq: 7, facts: [{ kind: 'bell' }] }
}
})
capturedOnResponse({
ok: true,
result: {
type: 'nativeChatLaunchDraftResolved',
tabId: 'tab-1',
text: 'seed',
createdAt: 7
}
})
capturedOnResponse({
ok: false,
error: { code: 'method_not_found', message: 'missing' }
})
expect(onEvent).toHaveBeenCalledTimes(2)
expect(onEvent).toHaveBeenCalledTimes(3)
expect(onEvent).toHaveBeenCalledWith({ type: 'worktreesChanged', repoId: 'repo-1' })
expect(onEvent).toHaveBeenCalledWith({
type: 'terminalSideEffects',
batch: { ptyId: 'pty-1', seq: 7, facts: [{ kind: 'bell' }] }
})
expect(onEvent).toHaveBeenCalledWith({
type: 'nativeChatLaunchDraftResolved',
tabId: 'tab-1',
text: 'seed',
createdAt: 7
})
expect(onError).toHaveBeenCalledWith({ code: 'method_not_found', message: 'missing' })
subscription.unsubscribe()
@@ -86,6 +86,7 @@ function isRuntimeClientEvent(
return (
message.type === 'reposChanged' ||
message.type === 'worktreesChanged' ||
message.type === 'nativeChatLaunchDraftResolved' ||
message.type === 'terminalSideEffects' ||
message.type === 'sshStateChanged' ||
message.type === 'linearLinkedIssueUpdated' ||
@@ -741,11 +741,40 @@ describe('buildMobileSessionTabSnapshots', () => {
expect.objectContaining({
type: 'terminal',
parentTabId: 'term-1',
launchDraft: 'https://github.com/o/r/issues/12'
launchDraft: 'https://github.com/o/r/issues/12',
launchDraftCreatedAt: 1
})
])
})
it('retracts a launch draft as soon as mobile resolves it', () => {
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: 'issue link',
createdAt: 1,
resolved: true
}
}
})
expect(buildMobileSessionTabSnapshots(state)[0]?.tabs[0]).not.toHaveProperty('launchDraft')
})
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
+23 -6
View File
@@ -40,6 +40,7 @@ import {
} from '../components/tab-bar/group-tab-order'
import { resolveTerminalLayoutRoot } from './remote-terminal-layout-resolution'
import { parseRemoteRuntimePtyId } from './runtime-terminal-stream'
import { applyNativeChatLaunchDraftResolved } from './native-chat-launch-draft-runtime-resolution'
type RegisteredTerminalTab = {
tabId: string
@@ -734,9 +735,16 @@ async function syncRuntimeGraph(): Promise<void> {
try {
const result = await window.api.runtime.syncWindowGraph(graph)
getStoreState()?.setRuntimeAgentOrchestrationByPaneKey?.(
result?.agentOrchestrationByPaneKey ?? {}
)
const currentState = getStoreState()
currentState?.setRuntimeAgentOrchestrationByPaneKey?.(result?.agentOrchestrationByPaneKey ?? {})
for (const resolution of result?.nativeChatLaunchDraftResolutions ?? []) {
if (currentState) {
applyNativeChatLaunchDraftResolved(currentState, {
type: 'nativeChatLaunchDraftResolved',
...resolution
})
}
}
} catch (error) {
console.error('[runtime] Failed to sync renderer graph:', error)
}
@@ -1371,8 +1379,12 @@ function buildMobileTerminalSurfaceTabs(
// 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
seededLaunchDraft &&
!seededLaunchDraft.resolved &&
seededLaunchDraft.agent === terminal.launchAgent
? seededLaunchDraft
: null
const publishedLaunchDraft = launchDraftEntry?.text.trim() ? launchDraftEntry : null
const container = registered?.getContainer()
const firstChild = container?.firstElementChild
const liveLayoutRoot = serializePaneTree(
@@ -1435,7 +1447,12 @@ function buildMobileTerminalSurfaceTabs(
...(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 } : {}),
...(publishedLaunchDraft
? {
launchDraft: publishedLaunchDraft.text,
launchDraftCreatedAt: publishedLaunchDraft.createdAt
}
: {}),
parentLayout,
isActive: isDesktopTabActive && leafId === activeLeafId
}
@@ -127,6 +127,21 @@ describe('nativeChatLaunchDraftByTabId teardown', () => {
expect(TAB1 in store.getState().nativeChatLaunchDraftByTabId).toBe(false)
})
it('resolves only the exact draft generation', () => {
const store = createTestStore()
const entry = draft(TAB1, 'same text')
store.getState().seedNativeChatLaunchDraft(entry)
store.getState().resolveNativeChatLaunchDraft(TAB1, { text: entry.text, createdAt: 0 })
expect(store.getState().nativeChatLaunchDraftByTabId[TAB1]?.resolved).toBeUndefined()
store.getState().resolveNativeChatLaunchDraft(TAB1, {
text: entry.text,
createdAt: entry.createdAt
})
expect(store.getState().nativeChatLaunchDraftByTabId[TAB1]?.resolved).toBe(true)
})
it('the orphan terminal cleanup patch drops swept tabs’ drafts only', () => {
const store = createTestStore()
seedDrafts(store)
@@ -569,6 +569,10 @@ export type TerminalSlice = {
nativeChatLaunchDraftByTabId: Record<string, NativeChatLaunchDraft>
seedNativeChatLaunchDraft: (draft: NativeChatLaunchDraft) => void
markNativeChatLaunchDraftAdopted: (tabId: string) => void
resolveNativeChatLaunchDraft: (
tabId: string,
resolution: Pick<NativeChatLaunchDraft, 'createdAt' | 'text'>
) => void
clearNativeChatLaunchDraft: (tabId: string) => void
pendingStartupByTabId: Record<
string,
@@ -1127,6 +1131,26 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
})
},
resolveNativeChatLaunchDraft: (tabId, resolution) => {
set((s) => {
const current = s.nativeChatLaunchDraftByTabId[tabId]
if (
!current ||
current.resolved ||
current.createdAt !== resolution.createdAt ||
current.text !== resolution.text
) {
return {}
}
return {
nativeChatLaunchDraftByTabId: {
...s.nativeChatLaunchDraftByTabId,
[tabId]: { ...current, resolved: true }
}
}
})
},
clearNativeChatLaunchDraft: (tabId) => {
set((s) => {
if (!s.nativeChatLaunchDraftByTabId[tabId]) {
+1
View File
@@ -1339,6 +1339,7 @@ function createRuntimeApi(): NonNullable<Partial<PreloadApi>['runtime']> {
reclaimBrowserForDesktop: () => Promise.resolve({ reclaimed: false }),
onTerminalFitOverrideChanged: () => noopUnsubscribe,
onTerminalDriverChanged: () => noopUnsubscribe,
onNativeChatLaunchDraftResolved: () => noopUnsubscribe,
onBrowserDriverChanged: () => noopUnsubscribe
}
}
+2
View File
@@ -6,10 +6,12 @@ import type {
} from './types'
import type { SshConnectionState } from './ssh-types'
import type { TerminalSideEffectBatch } from './terminal-side-effect-facts'
import type { RuntimeNativeChatLaunchDraftResolution } from './runtime-types'
export type RuntimeClientEvent =
| { type: 'reposChanged' }
| { type: 'worktreesChanged'; repoId: string }
| ({ type: 'nativeChatLaunchDraftResolved' } & RuntimeNativeChatLaunchDraftResolution)
| { type: 'terminalSideEffects'; batch: TerminalSideEffectBatch }
// Why: SSH connections live on the runtime host; paired clients have no IPC
// channel for ssh:state-changed, so without this event their reconnect
+9
View File
@@ -146,10 +146,17 @@ export type RuntimeSyncWindowGraph = {
mobileSessionTabs?: RuntimeMobileSessionTabsSnapshot[]
}
export type RuntimeNativeChatLaunchDraftResolution = {
tabId: string
text: string
createdAt: number
}
export type RuntimeSyncWindowGraphResult = RuntimeStatus & {
/** Main owns terminal handles/dispatches, so renderer graph sync returns the
* parent metadata needed by title-derived agent rows without name guessing. */
agentOrchestrationByPaneKey?: Record<string, AgentStatusOrchestrationContext>
nativeChatLaunchDraftResolutions?: RuntimeNativeChatLaunchDraftResolution[]
}
export type RuntimeMobileSessionTerminalTab = {
@@ -174,6 +181,8 @@ export type RuntimeMobileSessionTerminalTab = {
/** 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
/** Identity of the launch draft text, used to retire only the adopted generation. */
launchDraftCreatedAt?: number
isActive: boolean
}