mirror of
https://github.com/stablyai/orca.git
synced 2026-10-25 00:03:59 +00:00
fix(native-chat): retire mobile-consumed launch drafts
This commit is contained in:
@@ -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
|
||||
]
|
||||
)
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 }] : []
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
+11
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]) {
|
||||
|
||||
@@ -1339,6 +1339,7 @@ function createRuntimeApi(): NonNullable<Partial<PreloadApi>['runtime']> {
|
||||
reclaimBrowserForDesktop: () => Promise.resolve({ reclaimed: false }),
|
||||
onTerminalFitOverrideChanged: () => noopUnsubscribe,
|
||||
onTerminalDriverChanged: () => noopUnsubscribe,
|
||||
onNativeChatLaunchDraftResolved: () => noopUnsubscribe,
|
||||
onBrowserDriverChanged: () => noopUnsubscribe
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user