fix(native-chat): mirror multi-line launch drafts into the chat composer (#11253)
* fix(native-chat): mirror multi-line launch drafts into the chat composer
seedNativeChatLaunchDraftForAgentTab rejected any text containing a newline,
so every Linear launch ("Linked Linear issue: X\n<url>") and any GitHub launch
with a typed note was invisible in chat. The rejection existed because the send
path pre-cleared the TUI with a single Ctrl+U, which cannot clear a buffer with
embedded newlines.
Orca injects the draft itself, so when the composer still holds exactly what was
injected the buffer already IS the message: the send becomes the submit key
alone — no clear, no paste, nothing that can concatenate, and multi-line submits
as one turn for free. Only the edited case needs real buffer replacement, and
that now clears every line and verifies against the agent's rendered input line
instead of firing blind.
Measured on real PTYs against Claude Code and codex (both agree exactly):
clearing N logical lines costs 2N-1 Ctrl+U. See src/shared/agent-tui-input-clear.ts
for the law, the sequences that do NOT work, and why an upper bound is safe.
* fix(native-chat): send the mobile clear burst as its own write
Live QA caught the bundled form failing: a multi-line burst prefixed onto the
body in the SAME terminal.send reached the agent as LITERAL Ctrl+U characters,
so the parked draft survived and the message arrived as
draft + 21x \x15 + body. Sending the burst as its own non-submitting write —
the shape the image paste has always used — clears as intended.
The body write's own single-Ctrl+U prefix is dropped once that dedicated clear
ran, for the same reason: a Ctrl+U immediately followed by body text in one
write lands as a literal control character and headed the received message.
Re-verified live end to end: received prompt is exactly the draft, one turn,
zero control characters.
* test(native-chat): invert the multi-line Linear launch-draft mirror expectation
The Linear work-item launch seeds `Linked Linear issue: ENG-42\n<url>\n`.
This test pinned the pre-relaxation rule (multi-line drafts withheld), which
the send path no longer needs now that it submits the TUI buffer in place or
clears every line first — so it asserted the exact behavior the fix removes.
Assert the seeded payload instead of absence, so the test fails if the mirror
regresses to single-line-only.
* fix(native-chat): preserve launch draft send contents
* fix(native-chat): preserve confirmed send queue ordering
* fix(native-chat): preserve send pacing after renderer stalls
* test(native-chat): align activation with multiline draft mirroring
* fix(native-chat): clear launch drafts from any cursor
* fix(native-chat): retire mobile-consumed launch drafts
* test(mobile): stabilize QR capacity boundary fixture
This commit is contained in:
parent
914da17e52
commit
bbb3e7e5ee
|
|
@ -3675,6 +3675,7 @@ export default function SessionScreen() {
|
|||
getActiveWorktreeConnectionId,
|
||||
beforeTerminalSend: flushPendingLiveInputBeforeAttachmentSend,
|
||||
nativeChatBaseSend: nativeChatController.handleNativeChatSendWithOutcome,
|
||||
readSeededLaunchDraft: nativeChatController.readSeededLaunchDraft,
|
||||
showToast,
|
||||
onNativeChatSendError: nativeChatSendError.show,
|
||||
onSuccess: triggerSelection,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest'
|
|||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { RpcResponse, RpcSuccess } from '../transport/types'
|
||||
import { pasteMobileNativeChatImagePaths } from './mobile-native-chat-image-send'
|
||||
import { buildAgentTuiClearInputForText } from '../../../src/shared/agent-tui-input-clear'
|
||||
|
||||
function sendResult(accepted: boolean, id = 'send'): RpcSuccess {
|
||||
return { id, ok: true, result: { send: { accepted } }, _meta: { runtimeId: 'r' } }
|
||||
|
|
@ -101,3 +102,52 @@ describe('pasteMobileNativeChatImagePaths', () => {
|
|||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('clearing a parked multi-line launch draft before the image paste', () => {
|
||||
it('leads with the caller-sized burst instead of one Ctrl+U', async () => {
|
||||
// One Ctrl+U kills only the LAST line, so the draft's earlier lines would
|
||||
// survive and ride along with the image as part of the prompt body.
|
||||
const client = clientWithResponses([sendResult(true), sendResult(true)])
|
||||
const clearInput = buildAgentTuiClearInputForText('Linked Linear issue: ABC-123\nhttps://x')
|
||||
|
||||
await pasteMobileNativeChatImagePaths({
|
||||
client,
|
||||
terminal: 'term-1',
|
||||
deviceToken: null,
|
||||
imagePaths: ['/tmp/a.png'],
|
||||
clearInput
|
||||
})
|
||||
|
||||
expect(client.calls[0]?.params.text).toBe(clearInput)
|
||||
expect(client.calls[0]?.params.text).not.toBe('\x15')
|
||||
})
|
||||
|
||||
it('clears once, before the paste — never between or after the image writes', async () => {
|
||||
const client = clientWithResponses([sendResult(true), sendResult(true), sendResult(true)])
|
||||
const clearInput = buildAgentTuiClearInputForText('a\nb\nc')
|
||||
|
||||
await pasteMobileNativeChatImagePaths({
|
||||
client,
|
||||
terminal: 'term-1',
|
||||
deviceToken: null,
|
||||
imagePaths: ['/tmp/a.png', '/tmp/b.png'],
|
||||
clearInput
|
||||
})
|
||||
|
||||
expect(client.calls.filter((call) => call.params.text === clearInput)).toHaveLength(1)
|
||||
expect(client.calls[0]?.params.text).toBe(clearInput)
|
||||
})
|
||||
|
||||
it('falls back to a single Ctrl+U when no draft is parked', async () => {
|
||||
const client = clientWithResponses([sendResult(true), sendResult(true)])
|
||||
|
||||
await pasteMobileNativeChatImagePaths({
|
||||
client,
|
||||
terminal: 'term-1',
|
||||
deviceToken: null,
|
||||
imagePaths: ['/tmp/a.png']
|
||||
})
|
||||
|
||||
expect(client.calls[0]?.params.text).toBe('\x15')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -26,6 +26,10 @@ type PasteImagesArgs = {
|
|||
/** Budget shared with the rest of the user action (the text body that follows, or
|
||||
* the send this is healing for). Omit to open a fresh one for this paste alone. */
|
||||
readonly deadline?: number
|
||||
/** Bytes for the leading clear. Defaults to a single Ctrl+U, which clears only
|
||||
* ONE logical line — callers holding a parked multi-line launch draft must
|
||||
* pass a burst, or its earlier lines survive and glue onto the message. */
|
||||
readonly clearInput?: string
|
||||
}
|
||||
|
||||
/** Clears the agent's unsubmitted input line, then pastes each uploaded image
|
||||
|
|
@ -38,7 +42,8 @@ export async function pasteMobileNativeChatImagePaths({
|
|||
terminal,
|
||||
deviceToken,
|
||||
imagePaths,
|
||||
deadline: sharedDeadline
|
||||
deadline: sharedDeadline,
|
||||
clearInput
|
||||
}: PasteImagesArgs): Promise<boolean> {
|
||||
const mobileClient: MobileTerminalClient | null = deviceToken
|
||||
? { id: deviceToken, type: 'mobile' }
|
||||
|
|
@ -49,7 +54,7 @@ export async function pasteMobileNativeChatImagePaths({
|
|||
// once and let each write draw from what's left.
|
||||
const deadline = sharedDeadline ?? openMobileNativeChatSendBudget()
|
||||
for (const text of [
|
||||
MOBILE_NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT,
|
||||
clearInput ?? MOBILE_NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT,
|
||||
...imagePaths.map(buildMobileImagePastePayload)
|
||||
]) {
|
||||
const remainingMs = deadline - Date.now()
|
||||
|
|
|
|||
|
|
@ -5,9 +5,11 @@ import { LogicalClientCutoverError } from '../transport/stable-logical-rpc-clien
|
|||
import {
|
||||
MOBILE_NATIVE_CHAT_SEND_TIMEOUT_MS,
|
||||
openMobileNativeChatSendBudget,
|
||||
clearMobileNativeChatInput,
|
||||
sendMobileNativeChatMessage,
|
||||
sendMobileNativeChatMessageWithOutcome
|
||||
} from './mobile-native-chat-send'
|
||||
import { buildAgentTuiClearInputForText } from '../../../src/shared/agent-tui-input-clear'
|
||||
|
||||
function clientWithResponse(response: unknown): RpcClient {
|
||||
return {
|
||||
|
|
@ -29,6 +31,7 @@ describe('sendMobileNativeChatMessage', () => {
|
|||
client,
|
||||
terminal: 'term',
|
||||
text: 'hello',
|
||||
resolvedLaunchDraft: { text: 'seed', createdAt: 7 },
|
||||
mobileClient: { id: 'device', type: 'mobile' }
|
||||
})
|
||||
).resolves.toBe(true)
|
||||
|
|
@ -38,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 }
|
||||
|
|
@ -285,3 +289,83 @@ describe('sendMobileNativeChatMessage', () => {
|
|||
expect(budget).toBeLessThanOrEqual(MOBILE_NATIVE_CHAT_SEND_TIMEOUT_MS)
|
||||
})
|
||||
})
|
||||
|
||||
describe('clearMobileNativeChatInput', () => {
|
||||
const accepted = {
|
||||
id: 'request',
|
||||
ok: true,
|
||||
result: { send: { accepted: true } },
|
||||
_meta: { runtimeId: 'runtime' }
|
||||
}
|
||||
const params = (client: RpcClient) =>
|
||||
vi.mocked(client.sendRequest).mock.calls[0]![1] as { text: string; enter: boolean }
|
||||
|
||||
it('writes the burst as its OWN non-submitting write', async () => {
|
||||
// Bundling the burst into the body write reached the agent as LITERAL Ctrl+U
|
||||
// text and the parked draft concatenated (observed live).
|
||||
const client = clientWithResponse(accepted)
|
||||
const clearInput = buildAgentTuiClearInputForText('Linked Linear issue: ABC-123\nhttps://x')
|
||||
await expect(
|
||||
clearMobileNativeChatInput({ client, terminal: 'term', clearInput })
|
||||
).resolves.toBe(true)
|
||||
expect(params(client)).toMatchObject({ text: clearInput, enter: false })
|
||||
})
|
||||
|
||||
it('reports failure when the host rejects the clear', async () => {
|
||||
const client = clientWithResponse({
|
||||
id: 'request',
|
||||
ok: true,
|
||||
result: { send: { accepted: false } },
|
||||
_meta: { runtimeId: 'runtime' }
|
||||
})
|
||||
await expect(
|
||||
clearMobileNativeChatInput({ client, terminal: 'term', clearInput: '\x15' })
|
||||
).resolves.toBe(false)
|
||||
})
|
||||
|
||||
it('refuses to start an underfunded clear rather than half-clearing', async () => {
|
||||
const client = clientWithResponse(accepted)
|
||||
await expect(
|
||||
clearMobileNativeChatInput({
|
||||
client,
|
||||
terminal: 'term',
|
||||
clearInput: '\x15',
|
||||
deadline: Date.now() + 10
|
||||
})
|
||||
).resolves.toBe(false)
|
||||
expect(client.sendRequest).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('the body write never carries a multi-line burst', () => {
|
||||
const accepted = {
|
||||
id: 'request',
|
||||
ok: true,
|
||||
result: { send: { accepted: true } },
|
||||
_meta: { runtimeId: 'runtime' }
|
||||
}
|
||||
const sentText = (client: RpcClient): string =>
|
||||
(vi.mocked(client.sendRequest).mock.calls[0]![1] as { text: string }).text
|
||||
|
||||
it('still prefixes only a single Ctrl+U when asked to clear first', async () => {
|
||||
const client = clientWithResponse(accepted)
|
||||
await sendMobileNativeChatMessage({
|
||||
client,
|
||||
terminal: 'term',
|
||||
text: 'hello',
|
||||
clearInputFirst: true
|
||||
})
|
||||
expect(sentText(client)).toBe('\x15hello')
|
||||
})
|
||||
|
||||
it('never prefixes a clear when the caller already pasted (image sends)', async () => {
|
||||
const client = clientWithResponse(accepted)
|
||||
await sendMobileNativeChatMessage({
|
||||
client,
|
||||
terminal: 'term',
|
||||
text: 'caption',
|
||||
clearInputFirst: false
|
||||
})
|
||||
expect(sentText(client)).toBe('caption')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -11,6 +11,11 @@ type MobileTerminalClient = {
|
|||
// Why: Ctrl+U kills the TUI's current input line (desktop native chat sends the
|
||||
// same byte before its body), so a launch-context prefill parked there cannot
|
||||
// concatenate with a mobile chat message. The host writes text bytes verbatim.
|
||||
//
|
||||
// One Ctrl+U clears ONE logical line, which is all this prefix can do. A parked
|
||||
// launch draft is routinely multi-line (every Linear block is); callers that know
|
||||
// one is parked must call clearMobileNativeChatInput FIRST — see
|
||||
// src/shared/agent-tui-input-clear.ts for the measured 2N-1 law.
|
||||
const CLEAR_UNSUBMITTED_INPUT = '\x15'
|
||||
|
||||
type MobileNativeChatSendArgs = {
|
||||
|
|
@ -19,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. */
|
||||
|
|
@ -59,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
|
||||
|
|
@ -84,3 +92,42 @@ export async function sendMobileNativeChatMessage(
|
|||
): Promise<boolean> {
|
||||
return (await sendMobileNativeChatMessageWithOutcome(args)) === 'accepted'
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the agent's input line as its OWN write, before any body.
|
||||
*
|
||||
* Why not prefix it onto the body write: a multi-line clear burst bundled into
|
||||
* the same `terminal.send` as the text reached the agent as LITERAL Ctrl+U
|
||||
* characters — the draft survived and the burst landed in the middle of the
|
||||
* message (observed live: draft + 21 literal \x15 + body). A standalone write is
|
||||
* the shape the image paste has always used, and it clears as intended.
|
||||
*/
|
||||
export async function clearMobileNativeChatInput(args: {
|
||||
client: RpcClient
|
||||
terminal: string
|
||||
clearInput: string
|
||||
mobileClient?: MobileTerminalClient
|
||||
deadline?: number
|
||||
}): Promise<boolean> {
|
||||
const timeoutMs =
|
||||
args.deadline === undefined ? MOBILE_NATIVE_CHAT_SEND_TIMEOUT_MS : args.deadline - Date.now()
|
||||
if (timeoutMs < MOBILE_NATIVE_CHAT_MIN_WRITE_TIMEOUT_MS) {
|
||||
return false
|
||||
}
|
||||
try {
|
||||
const response = await args.client.sendRequest(
|
||||
'terminal.send',
|
||||
{
|
||||
terminal: args.terminal,
|
||||
text: args.clearInput,
|
||||
enter: false,
|
||||
...(args.mobileClient ? { client: args.mobileClient } : {})
|
||||
},
|
||||
{ timeoutMs, budgetSpansConnect: true }
|
||||
)
|
||||
return isTerminalSendRpcAccepted(response)
|
||||
} catch {
|
||||
// A failed clear must not send the body on top of an uncleared line.
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -35,6 +35,8 @@ vi.mock('./use-mobile-native-chat-drafts', () => ({
|
|||
setComposerText: vi.fn(),
|
||||
pending: [],
|
||||
captureSendOrigin,
|
||||
readSeededLaunchDraft: () => null,
|
||||
readSeededLaunchDraftSeed: () => null,
|
||||
clearDraftForSend,
|
||||
restoreRejectedDraft,
|
||||
acceptSend,
|
||||
|
|
@ -327,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
|
||||
}
|
||||
|
||||
|
|
@ -385,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
|
||||
})
|
||||
|
|
|
|||
|
|
@ -73,6 +73,9 @@ export type MobileNativeChatController = {
|
|||
images?: string[],
|
||||
deadline?: number
|
||||
) => Promise<MobileNativeChatSendOutcome>
|
||||
/** Launch-context text still parked on the agent's TUI input line, or null.
|
||||
* Image sends read it to size their leading clear (one Ctrl+U per line). */
|
||||
readSeededLaunchDraft: () => string | null
|
||||
}
|
||||
|
||||
/** Owns mobile native-chat state and teardown outside the already dense session
|
||||
|
|
@ -134,6 +137,8 @@ export function useMobileNativeChatController(args: {
|
|||
setComposerText: setChatComposerText,
|
||||
pending: chatPending,
|
||||
captureSendOrigin,
|
||||
readSeededLaunchDraft,
|
||||
readSeededLaunchDraftSeed,
|
||||
clearDraftForSend,
|
||||
restoreRejectedDraft,
|
||||
acceptSend,
|
||||
|
|
@ -145,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.
|
||||
|
|
@ -243,6 +249,7 @@ export function useMobileNativeChatController(args: {
|
|||
handleRef: activeHandleRef,
|
||||
deviceTokenRef,
|
||||
captureSendOrigin,
|
||||
readSeededLaunchDraftSeed,
|
||||
clearDraftForSend,
|
||||
restoreRejectedDraft,
|
||||
acceptSend,
|
||||
|
|
@ -278,6 +285,7 @@ export function useMobileNativeChatController(args: {
|
|||
loadNativeChatFiles,
|
||||
handleNativeChatQuestionAnswer,
|
||||
handleNativeChatSend,
|
||||
handleNativeChatSendWithOutcome
|
||||
handleNativeChatSendWithOutcome,
|
||||
readSeededLaunchDraft
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'))
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ import {
|
|||
type UnconfirmedSend
|
||||
} 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
|
||||
|
|
@ -43,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
|
||||
|
|
@ -55,6 +58,11 @@ export function useMobileNativeChatDrafts(args: {
|
|||
setComposerText: Dispatch<SetStateAction<string>>
|
||||
pending: MobileNativeChatPendingMessage[]
|
||||
captureSendOrigin: (text: string) => MobileNativeChatSendOrigin | null
|
||||
/** Launch-context text still believed to be parked on the agent's TUI input
|
||||
* 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. */
|
||||
|
|
@ -73,6 +81,7 @@ export function useMobileNativeChatDrafts(args: {
|
|||
sessionId,
|
||||
messages,
|
||||
launchDraft,
|
||||
launchDraftCreatedAt,
|
||||
chatActive = true,
|
||||
transcriptLoading
|
||||
} = args
|
||||
|
|
@ -91,62 +100,15 @@ export function useMobileNativeChatDrafts(args: {
|
|||
activePendingKeyRef.current = pendingKey
|
||||
const mountedRef = useRef(false)
|
||||
|
||||
// Seeded launch-context text per tab; '' marks a permanent decline so a
|
||||
// cleared composer never resurrects the prefill.
|
||||
const seededLaunchDraftByKeyRef = useRef(new Map<string, string>())
|
||||
|
||||
// Why: launch context delivered as a TUI-input prefill is invisible in chat;
|
||||
// adopt it once as the composer draft so mobile shows the same context.
|
||||
useEffect(() => {
|
||||
if (
|
||||
!draftKey ||
|
||||
!chatActive ||
|
||||
!launchDraft?.trim() ||
|
||||
seededLaunchDraftByKeyRef.current.has(draftKey)
|
||||
) {
|
||||
return
|
||||
}
|
||||
// Why: `session.tabs` carries launchDraft before the transcript read settles,
|
||||
// and an empty (or previous tab's) list would let the decline below misjudge
|
||||
// an already-submitted prefill — long enough for a send to duplicate it.
|
||||
if (transcriptLoading) {
|
||||
return
|
||||
}
|
||||
// A user turn already in the transcript means the one-line TUI prefill was
|
||||
// submitted or deliberately cleared; decline instead of resurrecting it.
|
||||
if (messages.some((message) => normalizedUserText(message) !== null)) {
|
||||
seededLaunchDraftByKeyRef.current.set(draftKey, '')
|
||||
return
|
||||
}
|
||||
seededLaunchDraftByKeyRef.current.set(draftKey, launchDraft)
|
||||
setDrafts((previous) =>
|
||||
(previous[draftKey] ?? '') === '' ? { ...previous, [draftKey]: launchDraft } : previous
|
||||
)
|
||||
}, [chatActive, draftKey, launchDraft, messages, transcriptLoading])
|
||||
|
||||
// Drop an untouched adopted copy once the prefill is resolved elsewhere — a
|
||||
// user turn landed (sent or cleared TUI-side) or the host stopped publishing
|
||||
// it (desktop sent or reconciled it). User edits are always kept.
|
||||
useEffect(() => {
|
||||
// Same gates as the seed: off-chat there is no retraction to read (the tab
|
||||
// publishes no draft to us), and an untrusted transcript would wipe an
|
||||
// untouched copy on the strength of another tab's user turns.
|
||||
if (!draftKey || !chatActive || transcriptLoading) {
|
||||
return
|
||||
}
|
||||
const seeded = seededLaunchDraftByKeyRef.current.get(draftKey)
|
||||
if (!seeded) {
|
||||
return
|
||||
}
|
||||
const hasUserTurn = messages.some((message) => normalizedUserText(message) !== null)
|
||||
if (!hasUserTurn && launchDraft?.trim()) {
|
||||
return
|
||||
}
|
||||
seededLaunchDraftByKeyRef.current.set(draftKey, '')
|
||||
setDrafts((previous) =>
|
||||
(previous[draftKey] ?? '') === seeded ? { ...previous, [draftKey]: '' } : previous
|
||||
)
|
||||
}, [chatActive, draftKey, launchDraft, messages, transcriptLoading])
|
||||
const { readSeededLaunchDraft, readSeededLaunchDraftSeed } = useMobileNativeChatLaunchDraftSeed({
|
||||
draftKey,
|
||||
messages,
|
||||
launchDraft,
|
||||
launchDraftCreatedAt,
|
||||
chatActive,
|
||||
transcriptLoading,
|
||||
setDrafts
|
||||
})
|
||||
|
||||
const setComposerText: Dispatch<SetStateAction<string>> = useCallback(
|
||||
(value) => {
|
||||
|
|
@ -358,6 +320,8 @@ export function useMobileNativeChatDrafts(args: {
|
|||
setComposerText,
|
||||
pending,
|
||||
captureSendOrigin,
|
||||
readSeededLaunchDraft,
|
||||
readSeededLaunchDraftSeed,
|
||||
clearDraftForSend,
|
||||
restoreRejectedDraft,
|
||||
acceptSend,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { buildAgentTuiClearInputForText } from '../../../src/shared/agent-tui-input-clear'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { RpcResponse, RpcSuccess } from '../transport/types'
|
||||
|
|
@ -69,6 +70,7 @@ function baseArgs(overrides: Partial<HookArgs> & Pick<HookArgs, 'client'>): Hook
|
|||
showToast: vi.fn(),
|
||||
onSendError: vi.fn(),
|
||||
baseSend: vi.fn().mockResolvedValue('accepted'),
|
||||
readSeededLaunchDraft: () => null,
|
||||
sleep: async () => {},
|
||||
...overrides
|
||||
}
|
||||
|
|
@ -201,6 +203,40 @@ describe('useMobileNativeChatImageAttachments', () => {
|
|||
expect(hook!.attachments).toEqual([])
|
||||
})
|
||||
|
||||
it('leads the image paste with a clear sized to a parked multi-line launch draft', async () => {
|
||||
// A single Ctrl+U kills only the last line, so the draft's earlier lines
|
||||
// would survive the clear and ride along with the image as prompt body.
|
||||
pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' })
|
||||
const client = makeClient([
|
||||
methodNotFound('start'),
|
||||
ok('save', '/tmp/a.png'),
|
||||
sendResult(true),
|
||||
sendResult(true)
|
||||
])
|
||||
const draft = 'Linked Linear issue: ABC-123\nhttps://linear.app/x/issue/ABC-123'
|
||||
mount(
|
||||
baseArgs({
|
||||
client: client as unknown as RpcClient,
|
||||
deviceTokenRef: { current: 'device-1' },
|
||||
readSeededLaunchDraft: () => draft
|
||||
})
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await hook!.attachImage('library')
|
||||
})
|
||||
await act(async () => {
|
||||
await hook!.sendNativeChat('look at this')
|
||||
})
|
||||
|
||||
const firstSend = client.calls.find((c) => c.method === 'terminal.send')
|
||||
expect(firstSend?.params).toMatchObject({
|
||||
text: buildAgentTuiClearInputForText(draft),
|
||||
enter: false
|
||||
})
|
||||
expect(firstSend?.params.text).not.toBe('\x15')
|
||||
})
|
||||
|
||||
it('spends one budget across the image paste and the text body that follows', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useCallback, useRef, useState } from 'react'
|
||||
import { CLIPBOARD_IMAGE_TOO_LARGE_ERROR } from '../../../src/shared/clipboard-image'
|
||||
import { buildAgentTuiClearInputForText } from '../../../src/shared/agent-tui-input-clear'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { ConnectionState } from '../transport/types'
|
||||
import {
|
||||
|
|
@ -56,6 +57,10 @@ type Args = {
|
|||
imagePreviewUris?: string[],
|
||||
deadline?: number
|
||||
) => Promise<MobileNativeChatSendOutcome>
|
||||
/** Launch-context text parked on the agent's TUI input line, or null. The
|
||||
* paste's leading clear must cover every line of it, or the draft's earlier
|
||||
* lines survive and ride along with the image. */
|
||||
readonly readSeededLaunchDraft: () => string | null
|
||||
readonly onAttachSuccess?: () => void
|
||||
readonly onError?: () => void
|
||||
// Injected so the settle between image paste and submit is instant in tests.
|
||||
|
|
@ -106,6 +111,7 @@ export function useMobileNativeChatImageAttachments({
|
|||
showToast,
|
||||
onSendError,
|
||||
baseSend,
|
||||
readSeededLaunchDraft,
|
||||
onAttachSuccess,
|
||||
onError,
|
||||
sleep = defaultSleep
|
||||
|
|
@ -269,12 +275,16 @@ export function useMobileNativeChatImageAttachments({
|
|||
return false
|
||||
}
|
||||
try {
|
||||
const seededLaunchDraft = readSeededLaunchDraft()
|
||||
const pasted = await pasteMobileNativeChatImagePaths({
|
||||
client,
|
||||
terminal: handle,
|
||||
deviceToken: deviceTokenRef.current,
|
||||
imagePaths: pendingImages.map((attachment) => attachment.path),
|
||||
deadline
|
||||
deadline,
|
||||
...(seededLaunchDraft
|
||||
? { clearInput: buildAgentTuiClearInputForText(seededLaunchDraft) }
|
||||
: {})
|
||||
})
|
||||
if (!pasted) {
|
||||
// Keep the chips so the user can retry; the failed paste never submitted.
|
||||
|
|
@ -351,6 +361,7 @@ export function useMobileNativeChatImageAttachments({
|
|||
enabled,
|
||||
onError,
|
||||
onSendError,
|
||||
readSeededLaunchDraft,
|
||||
scopeKey,
|
||||
sleep
|
||||
]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,122 @@
|
|||
import { useCallback, useEffect, useRef, type Dispatch, type SetStateAction } from 'react'
|
||||
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
|
||||
* so the general draft/pending accounting stays separate from this one concern.
|
||||
*/
|
||||
export function useMobileNativeChatLaunchDraftSeed(args: {
|
||||
draftKey: string | null
|
||||
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>>>
|
||||
}): {
|
||||
/** Text still believed to be parked on the agent's TUI input line, or null
|
||||
* 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,
|
||||
launchDraftCreatedAt,
|
||||
chatActive,
|
||||
transcriptLoading,
|
||||
setDrafts
|
||||
} = args
|
||||
|
||||
// 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, 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.
|
||||
useEffect(() => {
|
||||
if (
|
||||
!draftKey ||
|
||||
!chatActive ||
|
||||
!launchDraft?.trim() ||
|
||||
seededLaunchDraftByKeyRef.current.has(draftKey)
|
||||
) {
|
||||
return
|
||||
}
|
||||
// Why: `session.tabs` carries launchDraft before the transcript read settles,
|
||||
// and an empty (or previous tab's) list would let the decline below misjudge
|
||||
// an already-submitted prefill — long enough for a send to duplicate it.
|
||||
if (transcriptLoading) {
|
||||
return
|
||||
}
|
||||
// A user turn already in the transcript means the TUI prefill was submitted
|
||||
// or deliberately cleared; decline instead of resurrecting it.
|
||||
if (messages.some((message) => normalizedUserText(message) !== null)) {
|
||||
seededLaunchDraftByKeyRef.current.set(draftKey, null)
|
||||
return
|
||||
}
|
||||
seededLaunchDraftByKeyRef.current.set(draftKey, {
|
||||
text: launchDraft,
|
||||
createdAt: launchDraftCreatedAt ?? null
|
||||
})
|
||||
setDrafts((previous) =>
|
||||
(previous[draftKey] ?? '') === '' ? { ...previous, [draftKey]: launchDraft } : previous
|
||||
)
|
||||
}, [
|
||||
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
|
||||
// it (desktop sent or reconciled it). User edits are always kept.
|
||||
useEffect(() => {
|
||||
// Same gates as the seed: off-chat there is no retraction to read (the tab
|
||||
// publishes no draft to us), and an untrusted transcript would wipe an
|
||||
// untouched copy on the strength of another tab's user turns.
|
||||
if (!draftKey || !chatActive || transcriptLoading) {
|
||||
return
|
||||
}
|
||||
const seeded = seededLaunchDraftByKeyRef.current.get(draftKey)
|
||||
if (!seeded) {
|
||||
return
|
||||
}
|
||||
const hasUserTurn = messages.some((message) => normalizedUserText(message) !== null)
|
||||
if (!hasUserTurn && launchDraft?.trim()) {
|
||||
return
|
||||
}
|
||||
seededLaunchDraftByKeyRef.current.set(draftKey, null)
|
||||
setDrafts((previous) =>
|
||||
(previous[draftKey] ?? '') === seeded.text ? { ...previous, [draftKey]: '' } : previous
|
||||
)
|
||||
}, [chatActive, draftKey, launchDraft, messages, setDrafts, transcriptLoading])
|
||||
|
||||
// A missing or declined entry means there is nothing of ours on the TUI line.
|
||||
const readSeededLaunchDraft = useCallback(
|
||||
() => (draftKey ? (seededLaunchDraftByKeyRef.current.get(draftKey)?.text ?? null) : null),
|
||||
[draftKey]
|
||||
)
|
||||
const readSeededLaunchDraftSeed = useCallback(
|
||||
() => (draftKey ? (seededLaunchDraftByKeyRef.current.get(draftKey) ?? null) : null),
|
||||
[draftKey]
|
||||
)
|
||||
|
||||
return { readSeededLaunchDraft, readSeededLaunchDraftSeed }
|
||||
}
|
||||
|
|
@ -0,0 +1,176 @@
|
|||
// Covers the wiring the image-attachments suite structurally cannot: that hook
|
||||
// injects its own baseSend stub, so it never observes the real send params.
|
||||
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const sendWithOutcome = vi.fn()
|
||||
const clearInputWrite = vi.fn()
|
||||
vi.mock('./mobile-native-chat-send', () => ({
|
||||
sendMobileNativeChatMessageWithOutcome: (...args: unknown[]) => sendWithOutcome(...args),
|
||||
clearMobileNativeChatInput: (...args: unknown[]) => clearInputWrite(...args),
|
||||
openMobileNativeChatSendBudget: () => Date.now() + 15_000,
|
||||
MOBILE_NATIVE_CHAT_SEND_TIMEOUT_MS: 15_000,
|
||||
MOBILE_NATIVE_CHAT_MIN_WRITE_TIMEOUT_MS: 2_000
|
||||
}))
|
||||
vi.mock('./mobile-native-chat-stale-input', () => ({
|
||||
healMobileNativeChatStaleInput: () => Promise.resolve(true)
|
||||
}))
|
||||
|
||||
import { useMobileNativeChatMessageSend } from './use-mobile-native-chat-message-send'
|
||||
import { buildAgentTuiClearInputForText } from '../../../src/shared/agent-tui-input-clear'
|
||||
|
||||
type Send = ReturnType<typeof useMobileNativeChatMessageSend>
|
||||
|
||||
const DRAFT = 'Linked Linear issue: ABC-123\nhttps://linear.app/x/issue/ABC-123'
|
||||
|
||||
describe('useMobileNativeChatMessageSend', () => {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
let api: Send | null = null
|
||||
|
||||
const mount = (
|
||||
readSeededLaunchDraftSeed: () => { text: string; createdAt: number | null } | null
|
||||
): void => {
|
||||
function Probe(): null {
|
||||
api = useMobileNativeChatMessageSend({
|
||||
client: { sendRequest: vi.fn() } as never,
|
||||
enabled: true,
|
||||
handleRef: { current: 'term' },
|
||||
deviceTokenRef: { current: 'device' },
|
||||
captureSendOrigin: () => ({ draftKey: 'k', pendingKey: 'p' }) as never,
|
||||
readSeededLaunchDraftSeed,
|
||||
clearDraftForSend: () => {},
|
||||
restoreRejectedDraft: () => {},
|
||||
acceptSend: () => {},
|
||||
holdUnconfirmedSend: () => {},
|
||||
onSendError: () => {}
|
||||
})
|
||||
return null
|
||||
}
|
||||
act(() => {
|
||||
renderer = create(createElement(Probe))
|
||||
})
|
||||
}
|
||||
|
||||
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 }
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
sendWithOutcome.mockReset()
|
||||
sendWithOutcome.mockResolvedValue('accepted')
|
||||
clearInputWrite.mockReset()
|
||||
clearInputWrite.mockResolvedValue(true)
|
||||
})
|
||||
afterEach(() => {
|
||||
act(() => {
|
||||
renderer?.unmount()
|
||||
})
|
||||
renderer = null
|
||||
api = null
|
||||
})
|
||||
|
||||
it('sizes the pre-clear to every line of a parked launch draft', async () => {
|
||||
mount(() => ({ text: DRAFT, createdAt: 1 }))
|
||||
await act(async () => {
|
||||
await api!.send('hello')
|
||||
})
|
||||
expect(clearArgs().clearInput).toBe(buildAgentTuiClearInputForText(DRAFT))
|
||||
})
|
||||
|
||||
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(() => ({ text: DRAFT, createdAt: 1 }))
|
||||
await act(async () => {
|
||||
await api!.send('hello')
|
||||
})
|
||||
expect(clearInputWrite).toHaveBeenCalledTimes(1)
|
||||
expect(sendWithOutcome).toHaveBeenCalledTimes(1)
|
||||
expect(clearInputWrite.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
sendWithOutcome.mock.invocationCallOrder[0]!
|
||||
)
|
||||
})
|
||||
|
||||
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(() => ({ text: DRAFT, createdAt: 1 }))
|
||||
let result: boolean | undefined
|
||||
await act(async () => {
|
||||
result = await api!.send('hello')
|
||||
})
|
||||
expect(result).toBe(false)
|
||||
expect(sendWithOutcome).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
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(() => ({ 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 () => {
|
||||
mount(() => null)
|
||||
await act(async () => {
|
||||
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 () => {
|
||||
mount(() => null)
|
||||
await act(async () => {
|
||||
await api!.send('hello')
|
||||
})
|
||||
expect(clearInputWrite).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reads the draft at send time, so a retired seed stops widening the clear', async () => {
|
||||
let parked: { text: string; createdAt: number } | null = { text: DRAFT, createdAt: 1 }
|
||||
mount(() => parked)
|
||||
await act(async () => {
|
||||
await api!.send('first')
|
||||
})
|
||||
parked = null
|
||||
await act(async () => {
|
||||
await api!.send('second')
|
||||
})
|
||||
expect(sendWithOutcome.mock.calls[1]![0]).toMatchObject({ clearInputFirst: true })
|
||||
expect(clearInputWrite).toHaveBeenCalledTimes(1)
|
||||
expect(sendWithOutcome.mock.calls[0]![0]).toMatchObject({ clearInputFirst: false })
|
||||
})
|
||||
|
||||
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(() => ({ 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()
|
||||
})
|
||||
})
|
||||
|
|
@ -1,12 +1,15 @@
|
|||
import { useCallback, type MutableRefObject } from 'react'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import {
|
||||
clearMobileNativeChatInput,
|
||||
openMobileNativeChatSendBudget,
|
||||
sendMobileNativeChatMessageWithOutcome,
|
||||
type MobileNativeChatSendOutcome
|
||||
} 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 = {
|
||||
/** Composer send that syncs the draft (clear on send, restore on rejection). */
|
||||
|
|
@ -32,6 +35,9 @@ export function useMobileNativeChatMessageSend(args: {
|
|||
handleRef: MutableRefObject<string | null>
|
||||
deviceTokenRef: MutableRefObject<string | null>
|
||||
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. */
|
||||
readSeededLaunchDraftSeed: () => MobileNativeChatLaunchDraftSeed | null
|
||||
clearDraftForSend: (origin: MobileNativeChatSendOrigin, text: string) => void
|
||||
restoreRejectedDraft: (origin: MobileNativeChatSendOrigin, text: string) => void
|
||||
acceptSend: (origin: MobileNativeChatSendOrigin, text: string, images?: string[]) => void
|
||||
|
|
@ -48,6 +54,7 @@ export function useMobileNativeChatMessageSend(args: {
|
|||
handleRef,
|
||||
deviceTokenRef,
|
||||
captureSendOrigin,
|
||||
readSeededLaunchDraftSeed,
|
||||
clearDraftForSend,
|
||||
restoreRejectedDraft,
|
||||
acceptSend,
|
||||
|
|
@ -95,6 +102,36 @@ export function useMobileNativeChatMessageSend(args: {
|
|||
if (syncComposer) {
|
||||
clearDraftForSend(origin, text)
|
||||
}
|
||||
// Why: a parked launch draft is routinely multi-line, and one Ctrl+U clears
|
||||
// only one logical line. Size the clear to the text Orca injected, with
|
||||
// slack — the user can also have typed into the TUI line directly, so that
|
||||
// line count is a lower bound. Mobile cannot read the agent's screen, so
|
||||
// there is no empty-line observable to confirm against here; the upper
|
||||
// bound plus the host's write acceptance is what makes it safe.
|
||||
//
|
||||
// The burst goes out as its OWN write: bundled into the body write it
|
||||
// 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 = readSeededLaunchDraftSeed()
|
||||
if (seededLaunchDraft && !images?.length) {
|
||||
const cleared = await clearMobileNativeChatInput({
|
||||
client,
|
||||
terminal: handle,
|
||||
clearInput: buildAgentTuiClearInputForText(seededLaunchDraft.text),
|
||||
deadline,
|
||||
...(deviceTokenRef.current
|
||||
? { mobileClient: { id: deviceTokenRef.current, type: 'mobile' } }
|
||||
: {})
|
||||
})
|
||||
if (!cleared) {
|
||||
if (syncComposer) {
|
||||
restoreRejectedDraft(origin, text)
|
||||
}
|
||||
onSendError('Message not sent')
|
||||
return 'rejected'
|
||||
}
|
||||
}
|
||||
const outcome = await sendMobileNativeChatMessageWithOutcome({
|
||||
client,
|
||||
terminal: handle,
|
||||
|
|
@ -105,7 +142,20 @@ export function useMobileNativeChatMessageSend(args: {
|
|||
// this message. An image send already led its own paste with Ctrl+U, and a
|
||||
// second one here would wipe the image it just pasted (desktop's image path
|
||||
// likewise clears once, before the paste, and never again).
|
||||
clearInputFirst: !images?.length,
|
||||
//
|
||||
// Also skipped once the dedicated clear above ran: the line is already
|
||||
// empty, and a Ctrl+U written immediately before body text in the SAME
|
||||
// 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' } }
|
||||
|
|
@ -141,6 +191,7 @@ export function useMobileNativeChatMessageSend(args: {
|
|||
handleRef,
|
||||
holdUnconfirmedSend,
|
||||
onSendError,
|
||||
readSeededLaunchDraftSeed,
|
||||
restoreRejectedDraft
|
||||
]
|
||||
)
|
||||
|
|
|
|||
|
|
@ -31,6 +31,9 @@ type Args = {
|
|||
images?: string[],
|
||||
deadline?: number
|
||||
) => Promise<MobileNativeChatSendOutcome>
|
||||
/** Launch-context text parked on the agent's TUI input line, or null — sizes
|
||||
* the image paste's leading clear so a multi-line draft cannot ride along. */
|
||||
readonly readSeededLaunchDraft: () => string | null
|
||||
readonly showToast: (message: string, durationMs?: number) => void
|
||||
/** Native-chat send failures — rendered in the composer's inline banner. */
|
||||
readonly onNativeChatSendError: (message: string) => void
|
||||
|
|
@ -54,6 +57,7 @@ export function useMobileSessionImageAttachments({
|
|||
getActiveWorktreeConnectionId,
|
||||
beforeTerminalSend,
|
||||
nativeChatBaseSend,
|
||||
readSeededLaunchDraft,
|
||||
showToast,
|
||||
onNativeChatSendError,
|
||||
onSuccess,
|
||||
|
|
@ -86,6 +90,7 @@ export function useMobileSessionImageAttachments({
|
|||
showToast,
|
||||
onSendError: onNativeChatSendError,
|
||||
baseSend: nativeChatBaseSend,
|
||||
readSeededLaunchDraft,
|
||||
onAttachSuccess: onSuccess,
|
||||
onError
|
||||
})
|
||||
|
|
|
|||
|
|
@ -3,6 +3,9 @@ import { encodePairingOffer } from '../../shared/pairing'
|
|||
import type { PairingOffer } from '../../shared/mobile-relay-pairing-offer'
|
||||
import { encodeMobilePairingQr } from './mobile-pairing-qr'
|
||||
|
||||
// Keep every capacity probe in the same encoded payload family.
|
||||
const FIXED_INVITE_EXPIRES_AT = Date.now() + 5 * 60_000
|
||||
|
||||
function pairingUrl(endpointLength: number, relay: boolean): string {
|
||||
const prefix = 'wss://pair.example/'
|
||||
const offer: PairingOffer = {
|
||||
|
|
@ -20,7 +23,7 @@ function pairingUrl(endpointLength: number, relay: boolean): string {
|
|||
assignmentEpoch: 1,
|
||||
relayHostId: 'a'.repeat(16),
|
||||
inviteToken: 'b'.repeat(43),
|
||||
inviteExpiresAt: Date.now() + 60_000,
|
||||
inviteExpiresAt: FIXED_INVITE_EXPIRES_AT,
|
||||
e2eeFraming: 2
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
@ -1789,6 +1790,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
|
||||
}
|
||||
|
||||
|
|
@ -2594,6 +2599,12 @@ type LayoutQueueEntry = {
|
|||
}[]
|
||||
}
|
||||
|
||||
type NativeChatLaunchDraftResolutionTombstone = RuntimeNativeChatLaunchDraftResolution & {
|
||||
worktreeId: string
|
||||
}
|
||||
|
||||
const MAX_NATIVE_CHAT_LAUNCH_DRAFT_RESOLUTION_TOMBSTONES = 200
|
||||
|
||||
async function hasLocalWorktreeBaseRef(
|
||||
repoPath: string,
|
||||
baseRef: string,
|
||||
|
|
@ -2714,6 +2725,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
|
||||
|
|
@ -4717,12 +4732,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 })
|
||||
|
|
@ -5186,9 +5335,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 } : {})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -27890,7 +28044,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
|
||||
|
|
@ -28505,6 +28661,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()
|
||||
})
|
||||
})
|
||||
|
|
@ -473,6 +473,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 })
|
||||
})
|
||||
|
|
|
|||
|
|
@ -3307,6 +3307,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
|
||||
|
|
|
|||
|
|
@ -4254,6 +4254,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) => {
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
submitNativeChatPrompt
|
||||
} from './native-chat-runtime-send'
|
||||
import type { NativeChatSendHandle } from './native-chat-runtime-send'
|
||||
import { resolveNativeChatLaunchDraftSend } from './native-chat-launch-draft-send'
|
||||
import { getVerifiedNativeChatCommands } from '../../../../shared/native-chat-agent-profiles'
|
||||
import { emitNativeChatMessageSent } from '@/lib/native-chat-telemetry'
|
||||
import {
|
||||
|
|
@ -250,21 +251,29 @@ export const NativeChatComposer = forwardRef<NativeChatComposerHandle, NativeCha
|
|||
return
|
||||
}
|
||||
const classification = classifySend(text)
|
||||
// A parked launch draft must be cleared line-by-line before the body.
|
||||
const { sendOptions } = resolveNativeChatLaunchDraftSend({
|
||||
launchDraft,
|
||||
launchDraftResolved,
|
||||
agent,
|
||||
readScreen: () => readTerminalScreen?.()
|
||||
})
|
||||
let pendingHandle: NativeChatSendHandle | null = null
|
||||
// Why: image attachments take the attachment send path even for a
|
||||
// command/unknown send, otherwise `clearImageAttachments()` below drops
|
||||
// them silently when the text starts with the agent's slash/skill prefix.
|
||||
if (classification !== 'chat' && imagePaths.length === 0) {
|
||||
pendingHandle = sendNativeChatMessage(target.settings, target.ptyId, text)
|
||||
pendingHandle = sendNativeChatMessage(target.settings, target.ptyId, text, sendOptions)
|
||||
} else if (imagePaths.length > 0) {
|
||||
pendingHandle = sendNativeChatMessageWithImageAttachments(
|
||||
target.settings,
|
||||
target.ptyId,
|
||||
text,
|
||||
imagePaths
|
||||
imagePaths,
|
||||
sendOptions
|
||||
)
|
||||
} else if (text.trim().length > 0) {
|
||||
pendingHandle = sendNativeChatMessage(target.settings, target.ptyId, text)
|
||||
pendingHandle = sendNativeChatMessage(target.settings, target.ptyId, text, sendOptions)
|
||||
} else {
|
||||
submitNativeChatPrompt(target.settings, target.ptyId)
|
||||
}
|
||||
|
|
@ -296,8 +305,7 @@ export const NativeChatComposer = forwardRef<NativeChatComposerHandle, NativeCha
|
|||
clearSkillOrigin()
|
||||
clearImageAttachments()
|
||||
setNotice(null)
|
||||
// Why: the send path pre-clears the TUI input line, so any launch-draft
|
||||
// prefill still parked there is gone — retire the composer seed with it.
|
||||
// The send cleared the TUI input line before its body, so retire the seed.
|
||||
useAppStore.getState().clearNativeChatLaunchDraft(terminalTabId)
|
||||
}, [
|
||||
agent,
|
||||
|
|
@ -308,6 +316,9 @@ export const NativeChatComposer = forwardRef<NativeChatComposerHandle, NativeCha
|
|||
imageAttachments,
|
||||
disabled,
|
||||
isDispatchingSessionOption,
|
||||
launchDraft,
|
||||
launchDraftResolved,
|
||||
readTerminalScreen,
|
||||
resolveTarget,
|
||||
onOptimisticSend,
|
||||
onSlashCommand,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,111 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
agentInputLineCleared,
|
||||
planNativeChatLaunchDraftSend,
|
||||
resolveNativeChatLaunchDraftSend
|
||||
} from './native-chat-launch-draft-send'
|
||||
import {
|
||||
AGENT_TUI_CLEAR_INPUT_LINE,
|
||||
buildAgentTuiClearInputForText
|
||||
} from '../../../../shared/agent-tui-input-clear'
|
||||
|
||||
const SEEDED = 'Linked Linear issue: ABC-123\nhttps://linear.app/x/issue/ABC-123'
|
||||
|
||||
/** Claude's frame: the input line and its continuation rows sit last. */
|
||||
const screenHoldingDraft = [
|
||||
' ▘▘ ▝▝ ~/repo',
|
||||
'────────────────────────────────────────',
|
||||
'❯ Linked Linear issue: ABC-123',
|
||||
' https://linear.app/x/issue/ABC-123',
|
||||
'────────────────────────────────────────'
|
||||
].join('\n')
|
||||
|
||||
const screenPlaceholder = [
|
||||
' ▘▘ ▝▝ ~/repo',
|
||||
'────────────────────────────────────────',
|
||||
'❯ Try "create a util logging.py that..."',
|
||||
'────────────────────────────────────────'
|
||||
].join('\n')
|
||||
|
||||
const plan = (over: Partial<Parameters<typeof planNativeChatLaunchDraftSend>[0]> = {}) =>
|
||||
planNativeChatLaunchDraftSend({
|
||||
seededText: SEEDED,
|
||||
...over
|
||||
})
|
||||
|
||||
describe('planNativeChatLaunchDraftSend', () => {
|
||||
it('replaces the parked draft even when the composer copy is unchanged', () => {
|
||||
expect(plan()).toEqual({
|
||||
kind: 'replace-draft',
|
||||
clearInput: buildAgentTuiClearInputForText(SEEDED),
|
||||
seededText: SEEDED
|
||||
})
|
||||
})
|
||||
|
||||
it('does not submit a terminal-side edit that preserves the old short prefix', () => {
|
||||
const samePrefixEdit = [
|
||||
'────────────────────────────────────────',
|
||||
'❯ Linked Linear but terminal-side text changed',
|
||||
'────────────────────────────────────────'
|
||||
].join('\n')
|
||||
|
||||
const result = resolveNativeChatLaunchDraftSend({
|
||||
launchDraft: { agent: 'codex', text: SEEDED },
|
||||
launchDraftResolved: false,
|
||||
agent: 'codex',
|
||||
readScreen: () => samePrefixEdit
|
||||
})
|
||||
expect(result.plan.kind).toBe('replace-draft')
|
||||
})
|
||||
|
||||
it('keeps the ordinary send path when nothing is parked on the line', () => {
|
||||
expect(plan({ seededText: null })).toEqual({ kind: 'default' })
|
||||
expect(plan({ seededText: ' ' })).toEqual({ kind: 'default' })
|
||||
})
|
||||
|
||||
it('sizes a multi-line clear well past a single Ctrl+U', () => {
|
||||
const result = plan()
|
||||
expect(result.kind === 'replace-draft' && result.clearInput.length).toBeGreaterThan(
|
||||
AGENT_TUI_CLEAR_INPUT_LINE.length
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('agentInputLineCleared', () => {
|
||||
it('confirms only an observably empty prompt', () => {
|
||||
expect(agentInputLineCleared('› \n gpt-5.6 · ~/repo')).toBe(true)
|
||||
})
|
||||
|
||||
it('does not call a different nonempty prompt cleared', () => {
|
||||
const edited = [
|
||||
'────────────────────────────────────────',
|
||||
'❯ issue: ABC-123 residue after a cursor-middle clear',
|
||||
'────────────────────────────────────────'
|
||||
].join('\n')
|
||||
expect(agentInputLineCleared(edited)).toBe(false)
|
||||
})
|
||||
|
||||
it('does not ignore nonempty continuation rows after an empty prompt row', () => {
|
||||
const residue = [
|
||||
'────────────────────────────────────────',
|
||||
'❯ ',
|
||||
' suffix after a cursor-middle clear',
|
||||
'────────────────────────────────────────'
|
||||
].join('\n')
|
||||
expect(agentInputLineCleared(residue)).toBe(false)
|
||||
})
|
||||
|
||||
it('treats placeholders and parked drafts as unconfirmed', () => {
|
||||
expect(agentInputLineCleared(screenPlaceholder)).toBe(false)
|
||||
expect(agentInputLineCleared(screenHoldingDraft)).toBe(false)
|
||||
})
|
||||
|
||||
it('treats an unreadable screen as unconfirmed', () => {
|
||||
expect(agentInputLineCleared(null)).toBe(false)
|
||||
expect(agentInputLineCleared('unparseable')).toBe(false)
|
||||
})
|
||||
|
||||
it('reads an empty prompt through serializer ANSI', () => {
|
||||
expect(agentInputLineCleared(`[2m❯[0m `)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
// Choosing how a chat send lands when the agent's TUI input line still holds a
|
||||
// launch-context draft that Orca itself injected.
|
||||
|
||||
import { buildAgentTuiClearInputForText } from '../../../../shared/agent-tui-input-clear'
|
||||
import { stripScrollbackAnsi } from './native-chat-scrape-fallback'
|
||||
|
||||
export type NativeChatLaunchDraftSendPlan =
|
||||
/** Input line holds a stale injected draft — replace it, clearing every line. */
|
||||
| { kind: 'replace-draft'; clearInput: string; seededText: string }
|
||||
/** No injected draft is parked on the line; keep the ordinary send path. */
|
||||
| { kind: 'default' }
|
||||
|
||||
/** Prompt glyphs both supported agent TUIs draw at the start of the input line. */
|
||||
const COMPOSER_PROMPT_LINE = /^\s*([❯›])\s?(.*)$/
|
||||
const CLAUDE_FRAME_LINE = /^\s*─{3,}\s*$/
|
||||
const CODEX_FOOTER_LINE = /^\s*\S.*\s[·•]\s.*$/
|
||||
|
||||
function composerContinuationIsEmpty(lines: string[], promptIndex: number, glyph: string): boolean {
|
||||
for (let index = promptIndex + 1; index < lines.length; index += 1) {
|
||||
const line = lines[index]!
|
||||
if (
|
||||
(glyph === '❯' && CLAUDE_FRAME_LINE.test(line)) ||
|
||||
(glyph === '›' && CODEX_FOOTER_LINE.test(line))
|
||||
) {
|
||||
return true
|
||||
}
|
||||
if (line.trim() !== '') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the rendered composer prompt is observably empty. Placeholder text,
|
||||
* unrelated edits, and unreadable screens are all unconfirmed.
|
||||
*/
|
||||
export function agentInputLineCleared(screen: string | null | undefined): boolean {
|
||||
if (!screen) {
|
||||
return false
|
||||
}
|
||||
const lines = stripScrollbackAnsi(screen).split('\n')
|
||||
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
||||
const match = COMPOSER_PROMPT_LINE.exec(lines[index]!)
|
||||
if (match) {
|
||||
return match[2]!.trim() === '' && composerContinuationIsEmpty(lines, index, match[1]!)
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* A serialized screen cannot prove the whole parked draft still matches: visual
|
||||
* wrapping loses logical-line boundaries. Always replace from the composer copy.
|
||||
*/
|
||||
export function planNativeChatLaunchDraftSend(args: {
|
||||
/** Text Orca injected into the TUI line, or null when nothing is parked there. */
|
||||
seededText: string | null | undefined
|
||||
}): NativeChatLaunchDraftSendPlan {
|
||||
const seededText = args.seededText
|
||||
if (!seededText || seededText.trim() === '') {
|
||||
return { kind: 'default' }
|
||||
}
|
||||
return {
|
||||
kind: 'replace-draft',
|
||||
clearInput: buildAgentTuiClearInputForText(seededText),
|
||||
seededText
|
||||
}
|
||||
}
|
||||
|
||||
/** What a composer send needs: which path to take, and the clear/confirm bytes
|
||||
* the ordinary send paths should use when it is a draft replacement. */
|
||||
export function resolveNativeChatLaunchDraftSend(args: {
|
||||
launchDraft: { agent: string; text: string } | null | undefined
|
||||
launchDraftResolved: boolean
|
||||
agent: string
|
||||
readScreen: () => string | null | undefined
|
||||
}): {
|
||||
plan: NativeChatLaunchDraftSendPlan
|
||||
sendOptions: { clearInput: string; confirmCleared: () => boolean } | undefined
|
||||
} {
|
||||
const { launchDraft, launchDraftResolved, agent, readScreen } = args
|
||||
// A resolved draft was already submitted or cleared TUI-side, so nothing of
|
||||
// ours is on the line any more — treating it as parked would clear or submit
|
||||
// a buffer that no longer holds it.
|
||||
const seededText =
|
||||
launchDraft && launchDraft.agent === agent && !launchDraftResolved ? launchDraft.text : null
|
||||
const plan = planNativeChatLaunchDraftSend({ seededText })
|
||||
if (plan.kind !== 'replace-draft') {
|
||||
return { plan, sendOptions: undefined }
|
||||
}
|
||||
return {
|
||||
plan,
|
||||
sendOptions: {
|
||||
clearInput: plan.clearInput,
|
||||
confirmCleared: () => agentInputLineCleared(readScreen())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@
|
|||
export type NativeChatPtySendQueueHandle = {
|
||||
cancel: () => void
|
||||
settleAfterMs: number
|
||||
settled: Promise<void>
|
||||
bodyStarted: () => boolean
|
||||
finished: () => boolean
|
||||
}
|
||||
|
|
@ -97,6 +98,16 @@ export function enqueueNativeChatPtySend(
|
|||
const timers: ReturnType<typeof setTimeout>[] = []
|
||||
let release: (() => void) | null = null
|
||||
|
||||
const finishEntry = (): void => {
|
||||
if (finished) {
|
||||
return
|
||||
}
|
||||
finished = true
|
||||
const resolve = release
|
||||
release = null
|
||||
resolve?.()
|
||||
}
|
||||
|
||||
const delay = (ms: number, fn: () => void): void => {
|
||||
const timer = setTimeout(() => {
|
||||
if (!cancelled) {
|
||||
|
|
@ -106,19 +117,17 @@ export function enqueueNativeChatPtySend(
|
|||
timers.push(timer)
|
||||
}
|
||||
|
||||
const markFinished = (): void => {
|
||||
finished = true
|
||||
}
|
||||
|
||||
const markSubmitted = (): void => {
|
||||
submitted = true
|
||||
finishEntry()
|
||||
}
|
||||
|
||||
const execute = (): Promise<void> =>
|
||||
new Promise<void>((resolve) => {
|
||||
release = resolve
|
||||
if (cancelled) {
|
||||
markFinished()
|
||||
release = null
|
||||
finished = true
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
|
|
@ -126,17 +135,7 @@ export function enqueueNativeChatPtySend(
|
|||
start({ isCancelled: () => cancelled, delay, markSubmitted })
|
||||
if (durationMs <= 0) {
|
||||
markSubmitted()
|
||||
markFinished()
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
// Why: always release after the declared duration so a cancel mid-flight
|
||||
// cannot stall the per-pty queue forever.
|
||||
const done = setTimeout(() => {
|
||||
markFinished()
|
||||
resolve()
|
||||
}, durationMs)
|
||||
timers.push(done)
|
||||
})
|
||||
|
||||
const runPromise =
|
||||
|
|
@ -148,7 +147,7 @@ export function enqueueNativeChatPtySend(
|
|||
|
||||
const settleQueueEntry = (): void => {
|
||||
state.depth = Math.max(0, state.depth - 1)
|
||||
markFinished()
|
||||
finished = true
|
||||
dropHandle()
|
||||
// Why: drop the per-pty record once nothing is in flight so the map does not
|
||||
// accumulate one permanent entry per pty over a long, multi-pane session.
|
||||
|
|
@ -157,7 +156,8 @@ export function enqueueNativeChatPtySend(
|
|||
}
|
||||
}
|
||||
|
||||
state.tail = runPromise.then(settleQueueEntry, settleQueueEntry)
|
||||
const settled = runPromise.then(settleQueueEntry, settleQueueEntry)
|
||||
state.tail = settled
|
||||
|
||||
const handle: NativeChatPtySendQueueHandle = {
|
||||
cancel: () => {
|
||||
|
|
@ -169,20 +169,19 @@ export function enqueueNativeChatPtySend(
|
|||
clearTimeout(timer)
|
||||
}
|
||||
const shouldClear = bodyStarted && !submitted
|
||||
markFinished()
|
||||
// Why: refund only THIS sequence's charged window rather than collapsing
|
||||
// freeAt to now — later queued sends still hold the line, so a blanket
|
||||
// reset would understate the next enqueue's settle time and let a send
|
||||
// card drop while a queued Enter is still pending.
|
||||
state.freeAt = Math.max(Date.now(), state.freeAt - Math.max(0, durationMs))
|
||||
release?.()
|
||||
release = null
|
||||
finishEntry()
|
||||
dropHandle()
|
||||
if (shouldClear) {
|
||||
options?.onCancelUnsubmitted?.()
|
||||
}
|
||||
},
|
||||
settleAfterMs,
|
||||
settled,
|
||||
bodyStarted: () => bodyStarted,
|
||||
finished: () => finished
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,195 @@
|
|||
// Send-path behaviour when a launch-context draft is still parked on the agent's
|
||||
// TUI input line: the multi-line clear and its confirmation step.
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const sendRuntimePtyInput = vi.fn()
|
||||
const sendRuntimePtyInputVerified = vi.fn()
|
||||
vi.mock('@/runtime/runtime-terminal-inspection', () => ({
|
||||
sendRuntimePtyInput: (...args: unknown[]) => sendRuntimePtyInput(...args),
|
||||
sendRuntimePtyInputVerified: (...args: unknown[]) => sendRuntimePtyInputVerified(...args)
|
||||
}))
|
||||
|
||||
import {
|
||||
NATIVE_CHAT_CLEAR_CONFIRM_MS,
|
||||
NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT,
|
||||
NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS,
|
||||
NATIVE_CHAT_SUBMIT_DELAY_MS,
|
||||
resetNativeChatPtySendQueuesForTests,
|
||||
sendNativeChatMessage,
|
||||
sendNativeChatMessageWithImageAttachments
|
||||
} from './native-chat-runtime-send'
|
||||
import { buildNativeChatPasteBytes, NATIVE_CHAT_SUBMIT } from './native-chat-send'
|
||||
import {
|
||||
AGENT_TUI_CLEAR_INPUT_MAX,
|
||||
buildAgentTuiClearInputForText
|
||||
} from '../../../../shared/agent-tui-input-clear'
|
||||
|
||||
const SETTINGS = {} as Parameters<typeof sendNativeChatMessage>[0]
|
||||
const PTY = 'pty-launch-draft'
|
||||
const DRAFT = 'Linked Linear issue: ABC-123\nhttps://linear.app/x/issue/ABC-123'
|
||||
|
||||
const writes = (): string[] => sendRuntimePtyInput.mock.calls.map((call) => call[2] as string)
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
sendRuntimePtyInput.mockClear()
|
||||
sendRuntimePtyInput.mockReturnValue(true)
|
||||
resetNativeChatPtySendQueuesForTests()
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
resetNativeChatPtySendQueuesForTests()
|
||||
})
|
||||
|
||||
describe('sendNativeChatMessage with a parked multi-line draft', () => {
|
||||
it('leads with a clear sized to every line of the draft, not one Ctrl+U', () => {
|
||||
const clearInput = buildAgentTuiClearInputForText(DRAFT)
|
||||
sendNativeChatMessage(SETTINGS, PTY, 'edited text', { clearInput })
|
||||
expect(writes()).toEqual([clearInput, buildNativeChatPasteBytes('edited text')])
|
||||
expect(clearInput).not.toBe(NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT)
|
||||
})
|
||||
|
||||
it('still defaults to a single Ctrl+U when no draft is parked', () => {
|
||||
sendNativeChatMessage(SETTINGS, PTY, 'plain')
|
||||
expect(writes()[0]).toBe(NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT)
|
||||
})
|
||||
|
||||
it('holds the body until the clear is confirmed, then submits after the gap', () => {
|
||||
const clearInput = buildAgentTuiClearInputForText(DRAFT)
|
||||
sendNativeChatMessage(SETTINGS, PTY, 'edited', {
|
||||
clearInput,
|
||||
confirmCleared: () => true
|
||||
})
|
||||
// Body must NOT ride out with the clear — the confirm happens in between.
|
||||
expect(writes()).toEqual([clearInput])
|
||||
vi.advanceTimersByTime(NATIVE_CHAT_CLEAR_CONFIRM_MS)
|
||||
expect(writes()).toEqual([clearInput, buildNativeChatPasteBytes('edited')])
|
||||
vi.advanceTimersByTime(NATIVE_CHAT_SUBMIT_DELAY_MS)
|
||||
expect(writes()).toEqual([clearInput, buildNativeChatPasteBytes('edited'), NATIVE_CHAT_SUBMIT])
|
||||
})
|
||||
|
||||
it('preserves the body-to-Enter gap when the renderer stalls past both nominal deadlines', async () => {
|
||||
vi.useRealTimers()
|
||||
const writeTimes = new Map<string, number>()
|
||||
sendRuntimePtyInput.mockImplementation((_settings, _pty, bytes: string) => {
|
||||
writeTimes.set(bytes, performance.now())
|
||||
return true
|
||||
})
|
||||
sendNativeChatMessage(SETTINGS, PTY, 'edited', {
|
||||
clearInput: buildAgentTuiClearInputForText(DRAFT),
|
||||
confirmCleared: () => true
|
||||
})
|
||||
sendNativeChatMessage(SETTINGS, PTY, 'queued')
|
||||
|
||||
const blockedUntil =
|
||||
performance.now() + NATIVE_CHAT_CLEAR_CONFIRM_MS + NATIVE_CHAT_SUBMIT_DELAY_MS + 50
|
||||
while (performance.now() < blockedUntil) {
|
||||
// Simulate a renderer long task delaying both nominal deadlines.
|
||||
}
|
||||
|
||||
await vi.waitFor(() => expect(writeTimes.has(NATIVE_CHAT_SUBMIT)).toBe(true), {
|
||||
timeout: NATIVE_CHAT_SUBMIT_DELAY_MS + 1_000
|
||||
})
|
||||
await vi.waitFor(() => expect(writeTimes.has(buildNativeChatPasteBytes('queued'))).toBe(true))
|
||||
expect(
|
||||
writeTimes.get(NATIVE_CHAT_SUBMIT)! - writeTimes.get(buildNativeChatPasteBytes('edited'))!
|
||||
).toBeGreaterThanOrEqual(NATIVE_CHAT_SUBMIT_DELAY_MS - 20)
|
||||
expect(writes().indexOf(NATIVE_CHAT_SUBMIT)).toBeLessThan(
|
||||
writes().indexOf(buildNativeChatPasteBytes('queued'))
|
||||
)
|
||||
})
|
||||
|
||||
it('widens to a maximal burst when the draft is still observed on the line', () => {
|
||||
const clearInput = buildAgentTuiClearInputForText(DRAFT)
|
||||
sendNativeChatMessage(SETTINGS, PTY, 'edited', {
|
||||
clearInput,
|
||||
confirmCleared: () => false
|
||||
})
|
||||
vi.advanceTimersByTime(NATIVE_CHAT_CLEAR_CONFIRM_MS)
|
||||
expect(writes()).toEqual([
|
||||
clearInput,
|
||||
AGENT_TUI_CLEAR_INPUT_MAX,
|
||||
buildNativeChatPasteBytes('edited')
|
||||
])
|
||||
})
|
||||
|
||||
it('re-clears before the body, never after it', () => {
|
||||
sendNativeChatMessage(SETTINGS, PTY, 'edited', {
|
||||
clearInput: buildAgentTuiClearInputForText(DRAFT),
|
||||
confirmCleared: () => false
|
||||
})
|
||||
vi.advanceTimersByTime(NATIVE_CHAT_CLEAR_CONFIRM_MS + NATIVE_CHAT_SUBMIT_DELAY_MS)
|
||||
const order = writes()
|
||||
expect(order.indexOf(AGENT_TUI_CLEAR_INPUT_MAX)).toBeLessThan(
|
||||
order.indexOf(buildNativeChatPasteBytes('edited'))
|
||||
)
|
||||
})
|
||||
|
||||
it('charges the confirm gap to the handle so the send card outlives the Enter', () => {
|
||||
const withConfirm = sendNativeChatMessage(SETTINGS, PTY, 'a', {
|
||||
clearInput: '\x15',
|
||||
confirmCleared: () => true
|
||||
})
|
||||
expect(withConfirm.settleAfterMs).toBe(
|
||||
NATIVE_CHAT_SUBMIT_DELAY_MS + NATIVE_CHAT_CLEAR_CONFIRM_MS
|
||||
)
|
||||
})
|
||||
|
||||
it('submits before a queued send starts after clear confirmation', async () => {
|
||||
const clearInput = buildAgentTuiClearInputForText(DRAFT)
|
||||
sendNativeChatMessage(SETTINGS, PTY, 'first', {
|
||||
clearInput,
|
||||
confirmCleared: () => true
|
||||
})
|
||||
sendNativeChatMessage(SETTINGS, PTY, 'second')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(NATIVE_CHAT_CLEAR_CONFIRM_MS + NATIVE_CHAT_SUBMIT_DELAY_MS)
|
||||
|
||||
expect(writes()).toEqual([
|
||||
clearInput,
|
||||
buildNativeChatPasteBytes('first'),
|
||||
NATIVE_CHAT_SUBMIT,
|
||||
NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT,
|
||||
buildNativeChatPasteBytes('second')
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('image sends with a parked multi-line draft', () => {
|
||||
it('clears every draft line before pasting, so no line rides along with the image', () => {
|
||||
const clearInput = buildAgentTuiClearInputForText(DRAFT)
|
||||
sendNativeChatMessageWithImageAttachments(SETTINGS, PTY, 'caption', ['/tmp/a.png'], {
|
||||
clearInput
|
||||
})
|
||||
expect(writes()[0]).toBe(clearInput)
|
||||
})
|
||||
|
||||
it('clears exactly once — a second Ctrl+U would wipe the just-pasted image', () => {
|
||||
const clearInput = buildAgentTuiClearInputForText(DRAFT)
|
||||
sendNativeChatMessageWithImageAttachments(SETTINGS, PTY, 'caption', ['/tmp/a.png'], {
|
||||
clearInput
|
||||
})
|
||||
vi.advanceTimersByTime(10_000)
|
||||
expect(writes().filter((write) => write === clearInput)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('submits the image send before a queued message starts', async () => {
|
||||
const clearInput = buildAgentTuiClearInputForText(DRAFT)
|
||||
sendNativeChatMessageWithImageAttachments(SETTINGS, PTY, 'caption', ['/tmp/a.png'], {
|
||||
clearInput,
|
||||
confirmCleared: () => true
|
||||
})
|
||||
sendNativeChatMessage(SETTINGS, PTY, 'second')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(
|
||||
NATIVE_CHAT_CLEAR_CONFIRM_MS +
|
||||
NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS +
|
||||
NATIVE_CHAT_SUBMIT_DELAY_MS
|
||||
)
|
||||
|
||||
expect(writes().indexOf(NATIVE_CHAT_SUBMIT)).toBeLessThan(
|
||||
writes().indexOf(NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -142,6 +142,23 @@ describe('sendNativeChatMessage', () => {
|
|||
expect(sendRuntimePtyInput).toHaveBeenCalledTimes(6)
|
||||
})
|
||||
|
||||
it('does not let a canceled queued send stall the sends behind it', async () => {
|
||||
sendNativeChatMessage(SETTINGS, PTY, 'first')
|
||||
const canceled = sendNativeChatMessage(SETTINGS, PTY, 'canceled')
|
||||
sendNativeChatMessage(SETTINGS, PTY, 'third')
|
||||
canceled.cancel()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(NATIVE_CHAT_SUBMIT_DELAY_MS)
|
||||
|
||||
expectWriteOrder(sendRuntimePtyInput.mock.calls, [
|
||||
NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT,
|
||||
buildNativeChatPasteBytes('first'),
|
||||
NATIVE_CHAT_SUBMIT,
|
||||
NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT,
|
||||
buildNativeChatPasteBytes('third')
|
||||
])
|
||||
})
|
||||
|
||||
it('does not serialize sends across different PTYs', () => {
|
||||
sendNativeChatMessage(SETTINGS, 'pty-a', 'one')
|
||||
sendNativeChatMessage(SETTINGS, 'pty-b', 'two')
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
} from '@/runtime/runtime-terminal-inspection'
|
||||
import type { getSettingsForAgentTabRuntimeOwner } from '@/lib/agent-paste-draft'
|
||||
import type { AskAnswerKeyGroup } from './native-chat-interactive-prompt'
|
||||
import { AGENT_TUI_CLEAR_INPUT_MAX } from '../../../../shared/agent-tui-input-clear'
|
||||
import {
|
||||
NATIVE_CHAT_ADVANCE_BUFFER_MS,
|
||||
NATIVE_CHAT_QUESTION_STEP_MS,
|
||||
|
|
@ -34,20 +35,86 @@ export const NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS = 300
|
|||
// start from an empty line so a prior cancelled paste cannot glue onto the next
|
||||
// prompt. Not used on verified option commands — model-switch confirmation
|
||||
// observes the PTY and Ctrl+U can miss confirmation markers.
|
||||
//
|
||||
// One Ctrl+U only ever clears ONE logical line. When the line may hold an
|
||||
// injected multi-line launch draft, callers pass `clearInput` built by
|
||||
// buildAgentTuiClearInputForText — see agent-tui-input-clear.ts for the measured
|
||||
// 2N-1 law and the sequences that do NOT work.
|
||||
export const NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT = '\x15'
|
||||
|
||||
/** Gap before re-reading the agent's input line to confirm a clear landed. */
|
||||
export const NATIVE_CHAT_CLEAR_CONFIRM_MS = 140
|
||||
|
||||
export type NativeChatSendOptions = {
|
||||
/** Bytes that empty the agent's input line. Defaults to a single Ctrl+U. */
|
||||
clearInput?: string
|
||||
/**
|
||||
* Observed check that the input line is now empty.
|
||||
* Supplied only for launch-draft replacement; when it reports "not cleared"
|
||||
* the send widens to a maximal burst before writing the body rather than
|
||||
* pasting on top of residue.
|
||||
*/
|
||||
confirmCleared?: () => boolean
|
||||
}
|
||||
|
||||
/** Cancels an in-flight send's pending pty writes (the delayed Enter, and any
|
||||
* later question bodies/Enters). Safe to call after the send completes. */
|
||||
export type NativeChatSendHandle = {
|
||||
cancel: () => void
|
||||
/** Time after which every scheduled write has fired and the handle can drop. */
|
||||
settleAfterMs: number
|
||||
/** Actual completion, which can outlive the nominal schedule if the renderer stalls. */
|
||||
settled?: Promise<void>
|
||||
}
|
||||
|
||||
type RuntimeSettings = ReturnType<typeof getSettingsForAgentTabRuntimeOwner>
|
||||
|
||||
function clearUnsubmittedAgentInput(settings: RuntimeSettings, ptyId: string): void {
|
||||
sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT)
|
||||
function clearUnsubmittedAgentInput(
|
||||
settings: RuntimeSettings,
|
||||
ptyId: string,
|
||||
options?: NativeChatSendOptions
|
||||
): void {
|
||||
sendRuntimePtyInput(settings, ptyId, options?.clearInput ?? NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT)
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `writeBody` once the input line is clear. With no `confirmCleared` the
|
||||
* clear is a plain in-order write on the same byte stream, so the TUI consumes
|
||||
* it before the body and the body follows immediately. With one, we pause to
|
||||
* actually look at the agent's input line, and widen to a maximal burst when the
|
||||
* draft is still visible — the injected line count is only a lower bound on what
|
||||
* the buffer holds, since the user can type into the TUI directly.
|
||||
*/
|
||||
function clearThenWrite(
|
||||
settings: RuntimeSettings,
|
||||
ptyId: string,
|
||||
options: NativeChatSendOptions | undefined,
|
||||
delay: (ms: number, fn: () => void) => void,
|
||||
writeBody: () => void
|
||||
): void {
|
||||
clearUnsubmittedAgentInput(settings, ptyId, options)
|
||||
const confirmCleared = options?.confirmCleared
|
||||
if (!confirmCleared) {
|
||||
writeBody()
|
||||
return
|
||||
}
|
||||
delay(NATIVE_CHAT_CLEAR_CONFIRM_MS, () => {
|
||||
let cleared = false
|
||||
try {
|
||||
cleared = confirmCleared()
|
||||
} catch {
|
||||
// An unreadable terminal is unconfirmed; the maximal clear remains safe.
|
||||
}
|
||||
if (!cleared) {
|
||||
sendRuntimePtyInput(settings, ptyId, AGENT_TUI_CLEAR_INPUT_MAX)
|
||||
}
|
||||
writeBody()
|
||||
})
|
||||
}
|
||||
|
||||
/** Extra time a send needs when it stops to confirm the clear before the body. */
|
||||
function clearConfirmDurationMs(options?: NativeChatSendOptions): number {
|
||||
return options?.confirmCleared ? NATIVE_CHAT_CLEAR_CONFIRM_MS : 0
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -61,27 +128,31 @@ function clearUnsubmittedAgentInput(settings: RuntimeSettings, ptyId: string): v
|
|||
export function sendNativeChatMessage(
|
||||
settings: RuntimeSettings,
|
||||
ptyId: string,
|
||||
text: string
|
||||
text: string,
|
||||
options?: NativeChatSendOptions
|
||||
): NativeChatSendHandle {
|
||||
return enqueueNativeChatPtySend(
|
||||
ptyId,
|
||||
NATIVE_CHAT_SUBMIT_DELAY_MS,
|
||||
NATIVE_CHAT_SUBMIT_DELAY_MS + clearConfirmDurationMs(options),
|
||||
({ isCancelled, delay, markSubmitted }) => {
|
||||
if (isCancelled()) {
|
||||
return
|
||||
}
|
||||
clearUnsubmittedAgentInput(settings, ptyId)
|
||||
if (isCancelled()) {
|
||||
return
|
||||
}
|
||||
sendRuntimePtyInput(settings, ptyId, buildNativeChatPasteBytes(text))
|
||||
delay(NATIVE_CHAT_SUBMIT_DELAY_MS, () => {
|
||||
sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT)
|
||||
markSubmitted()
|
||||
clearThenWrite(settings, ptyId, options, delay, () => {
|
||||
if (isCancelled()) {
|
||||
return
|
||||
}
|
||||
sendRuntimePtyInput(settings, ptyId, buildNativeChatPasteBytes(text))
|
||||
// Schedule from the actual body write: an overdue clear-confirm callback
|
||||
// must not collapse the required body-to-Enter gap after a renderer stall.
|
||||
delay(NATIVE_CHAT_SUBMIT_DELAY_MS, () => {
|
||||
sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT)
|
||||
markSubmitted()
|
||||
})
|
||||
})
|
||||
},
|
||||
{
|
||||
onCancelUnsubmitted: () => clearUnsubmittedAgentInput(settings, ptyId)
|
||||
onCancelUnsubmitted: () => clearUnsubmittedAgentInput(settings, ptyId, options)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
@ -146,16 +217,17 @@ export function sendNativeChatMessageWithImageAttachments(
|
|||
settings: RuntimeSettings,
|
||||
ptyId: string,
|
||||
text: string,
|
||||
imagePaths: readonly string[]
|
||||
imagePaths: readonly string[],
|
||||
options?: NativeChatSendOptions
|
||||
): NativeChatSendHandle {
|
||||
if (imagePaths.length === 0) {
|
||||
return sendNativeChatMessage(settings, ptyId, text)
|
||||
return sendNativeChatMessage(settings, ptyId, text, options)
|
||||
}
|
||||
const trimmedText = text.trim()
|
||||
const durationMs =
|
||||
trimmedText.length > 0
|
||||
(trimmedText.length > 0
|
||||
? NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS + NATIVE_CHAT_SUBMIT_DELAY_MS
|
||||
: NATIVE_CHAT_SUBMIT_DELAY_MS
|
||||
: NATIVE_CHAT_SUBMIT_DELAY_MS) + clearConfirmDurationMs(options)
|
||||
return enqueueNativeChatPtySend(
|
||||
ptyId,
|
||||
durationMs,
|
||||
|
|
@ -163,30 +235,31 @@ export function sendNativeChatMessageWithImageAttachments(
|
|||
if (isCancelled()) {
|
||||
return
|
||||
}
|
||||
clearUnsubmittedAgentInput(settings, ptyId)
|
||||
if (isCancelled()) {
|
||||
return
|
||||
}
|
||||
for (const imagePath of imagePaths) {
|
||||
sendRuntimePtyInput(settings, ptyId, buildNativeChatImagePasteBytes(imagePath))
|
||||
}
|
||||
if (trimmedText.length > 0) {
|
||||
delay(NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS, () => {
|
||||
sendRuntimePtyInput(settings, ptyId, buildNativeChatPasteBytes(text))
|
||||
})
|
||||
delay(NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS + NATIVE_CHAT_SUBMIT_DELAY_MS, () => {
|
||||
clearThenWrite(settings, ptyId, options, delay, () => {
|
||||
if (isCancelled()) {
|
||||
return
|
||||
}
|
||||
for (const imagePath of imagePaths) {
|
||||
sendRuntimePtyInput(settings, ptyId, buildNativeChatImagePasteBytes(imagePath))
|
||||
}
|
||||
if (trimmedText.length > 0) {
|
||||
delay(NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS, () => {
|
||||
sendRuntimePtyInput(settings, ptyId, buildNativeChatPasteBytes(text))
|
||||
delay(NATIVE_CHAT_SUBMIT_DELAY_MS, () => {
|
||||
sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT)
|
||||
markSubmitted()
|
||||
})
|
||||
})
|
||||
return
|
||||
}
|
||||
delay(NATIVE_CHAT_SUBMIT_DELAY_MS, () => {
|
||||
sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT)
|
||||
markSubmitted()
|
||||
})
|
||||
return
|
||||
}
|
||||
delay(NATIVE_CHAT_SUBMIT_DELAY_MS, () => {
|
||||
sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT)
|
||||
markSubmitted()
|
||||
})
|
||||
},
|
||||
{
|
||||
onCancelUnsubmitted: () => clearUnsubmittedAgentInput(settings, ptyId)
|
||||
onCancelUnsubmitted: () => clearUnsubmittedAgentInput(settings, ptyId, options)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -66,4 +66,30 @@ describe('useNativeChatSendLifecycle', () => {
|
|||
expect(settled.cancel).not.toHaveBeenCalled()
|
||||
expect(onPendingSendCanceled).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps a renderer-stalled send cancelable past its nominal schedule', async () => {
|
||||
vi.useFakeTimers()
|
||||
let resolveSettled!: () => void
|
||||
const stalled = {
|
||||
...handle(640),
|
||||
settled: new Promise<void>((resolve) => {
|
||||
resolveSettled = resolve
|
||||
})
|
||||
}
|
||||
const onPendingSendCanceled = vi.fn()
|
||||
const { result, rerender } = renderHook(
|
||||
({ targetPtyId }) => useNativeChatSendLifecycle('tab-1', targetPtyId, onPendingSendCanceled),
|
||||
{ initialProps: { targetPtyId: 'pty-1' as string | null } }
|
||||
)
|
||||
|
||||
act(() => result.current.trackPendingSend(stalled, 'pending-1'))
|
||||
act(() => vi.advanceTimersByTime(stalled.settleAfterMs + 1_000))
|
||||
rerender({ targetPtyId: 'pty-2' })
|
||||
|
||||
expect(stalled.cancel).toHaveBeenCalledOnce()
|
||||
expect(onPendingSendCanceled).toHaveBeenCalledWith('pending-1')
|
||||
await act(async () => {
|
||||
resolveSettled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -14,13 +14,15 @@ export function useNativeChatSendLifecycle(
|
|||
const pendingSendHandlesRef = useRef(
|
||||
new Map<
|
||||
NativeChatSendHandle,
|
||||
{ cleanupTimer: ReturnType<typeof setTimeout>; pendingId?: string }
|
||||
{ cleanupTimer: ReturnType<typeof setTimeout> | null; pendingId?: string }
|
||||
>()
|
||||
)
|
||||
const cancelPendingSends = useCallback(() => {
|
||||
for (const [handle, entry] of pendingSendHandlesRef.current) {
|
||||
const { cleanupTimer, pendingId } = entry
|
||||
clearTimeout(cleanupTimer)
|
||||
if (cleanupTimer !== null) {
|
||||
clearTimeout(cleanupTimer)
|
||||
}
|
||||
handle.cancel()
|
||||
if (pendingId) {
|
||||
onPendingSendCanceled?.(pendingId)
|
||||
|
|
@ -29,13 +31,22 @@ export function useNativeChatSendLifecycle(
|
|||
pendingSendHandlesRef.current.clear()
|
||||
}, [onPendingSendCanceled])
|
||||
const trackPendingSend = useCallback((handle: NativeChatSendHandle, pendingId?: string) => {
|
||||
const cleanupTimer = setTimeout(() => {
|
||||
const entry = {
|
||||
cleanupTimer: null as ReturnType<typeof setTimeout> | null,
|
||||
...(pendingId ? { pendingId } : {})
|
||||
}
|
||||
pendingSendHandlesRef.current.set(handle, entry)
|
||||
if (handle.settled) {
|
||||
void handle.settled.then(() => {
|
||||
if (pendingSendHandlesRef.current.get(handle) === entry) {
|
||||
pendingSendHandlesRef.current.delete(handle)
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
entry.cleanupTimer = setTimeout(() => {
|
||||
pendingSendHandlesRef.current.delete(handle)
|
||||
}, handle.settleAfterMs)
|
||||
pendingSendHandlesRef.current.set(handle, {
|
||||
cleanupTimer,
|
||||
...(pendingId ? { pendingId } : {})
|
||||
})
|
||||
}, [])
|
||||
|
||||
// Why: delayed Enter/image writes belong to the exact PTY target. A pane
|
||||
|
|
|
|||
|
|
@ -756,7 +756,7 @@ describe('submitFolderWorkspaceCreate native-chat launch draft', () => {
|
|||
expect(seededDraftFor('tab-1')?.text).toBe(ISSUE_URL)
|
||||
})
|
||||
|
||||
it('leaves a multi-line draft in the terminal only', async () => {
|
||||
it('mirrors a multi-line draft into chat', async () => {
|
||||
await submitFolderWorkspaceCreate({
|
||||
projectGroup: makeProjectGroup(),
|
||||
name: '',
|
||||
|
|
@ -770,8 +770,6 @@ describe('submitFolderWorkspaceCreate native-chat launch draft', () => {
|
|||
onOpenChange: vi.fn()
|
||||
})
|
||||
|
||||
// Terminal still gets it; the chat mirror is withheld until multi-line send
|
||||
// is safe, and decideInitialAgentTabViewMode keeps this launch in terminal.
|
||||
expect(mocks.ensureAgentStartupInTerminal).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
startup: expect.objectContaining({
|
||||
|
|
@ -779,7 +777,7 @@ describe('submitFolderWorkspaceCreate native-chat launch draft', () => {
|
|||
})
|
||||
})
|
||||
)
|
||||
expect(seededDraftFor('tab-1')).toBeUndefined()
|
||||
expect(seededDraftFor('tab-1')?.text).toBe(`Reproduce on Windows first\n\n${ISSUE_URL}`)
|
||||
})
|
||||
|
||||
it('does not mirror an unlinked note, which is submitted rather than drafted', async () => {
|
||||
|
|
@ -832,9 +830,9 @@ describe('folder-workspace draft: seeded set == chat-opening set', () => {
|
|||
// view-mode gate, and both must agree with what the composer actually holds.
|
||||
it.each([
|
||||
['argv-prefill', 'claude' as const, '', true],
|
||||
['argv-prefill multi-line', 'claude' as const, 'Reproduce on Windows first', false],
|
||||
['argv-prefill multi-line', 'claude' as const, 'Reproduce on Windows first', true],
|
||||
['startup-paste', 'codex' as const, '', true],
|
||||
['startup-paste multi-line', 'codex' as const, 'Reproduce on Windows first', false]
|
||||
['startup-paste multi-line', 'codex' as const, 'Reproduce on Windows first', true]
|
||||
])('%s', async (_label, quickAgent, note, expectMirrored) => {
|
||||
await submitFolderWorkspaceCreate({
|
||||
projectGroup: makeProjectGroup(),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -3475,6 +3480,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()) {
|
||||
|
|
|
|||
|
|
@ -31,17 +31,19 @@ describe('seedNativeChatLaunchDraftForAgentTab', () => {
|
|||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('rejects multi-line text at the helper, not just at the delivery caller', () => {
|
||||
// Worktree-create and work-item launches seed through this helper directly
|
||||
// (a Linear draft is always `Linked Linear issue: …\n<url>\n`), so the
|
||||
// Ctrl+U kill-to-start-of-LINE constraint has to live here.
|
||||
seedNativeChatLaunchDraftForAgentTab({
|
||||
it('mirrors multi-line text — the majority of real drafts', () => {
|
||||
// A Linear draft is always `Linked Linear issue: …\n<url>\n`, so rejecting
|
||||
// newlines made every Linear launch invisible in chat. Send now clears every
|
||||
// parked line first, so there is nothing left to glue.
|
||||
const text = 'Linked Linear issue: STA-1234\nhttps://linear.app/o/issue/STA-1234\n'
|
||||
seedNativeChatLaunchDraftForAgentTab({ tabId: 'linear-tab', agent: 'codex', text })
|
||||
|
||||
expect(mocks.seedNativeChatLaunchDraft).toHaveBeenCalledWith({
|
||||
tabId: 'linear-tab',
|
||||
agent: 'codex',
|
||||
text: 'Linked Linear issue: STA-1234\nhttps://linear.app/o/issue/STA-1234\n'
|
||||
text,
|
||||
createdAt: expect.any(Number)
|
||||
})
|
||||
|
||||
expect(mocks.seedNativeChatLaunchDraft).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('seeds single-line text', () => {
|
||||
|
|
@ -138,19 +140,23 @@ describe('deliverLaunchPromptToAgentTab', () => {
|
|||
expect(mocks.seedNativeChatLaunchPrompt).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not seed a launch draft for multi-line content', async () => {
|
||||
// The chat send pre-clears the TUI with Ctrl+U (kill-to-start-of-LINE), so a
|
||||
// multi-line prefill (e.g. scraped session-fork context) would leave earlier
|
||||
// lines behind to glue onto the next message.
|
||||
it('seeds a launch draft for multi-line content', async () => {
|
||||
// Note+URL launches join with a blank line, so this shape is common too.
|
||||
const content = 'Forked from session\n\nhttps://example.test/context'
|
||||
await deliverLaunchPromptToAgentTab({
|
||||
tabId: 'fork-tab',
|
||||
agent: 'codex',
|
||||
content: 'Forked from session\n\nhttps://example.test/context',
|
||||
content,
|
||||
submit: false,
|
||||
forcePaste: false
|
||||
})
|
||||
|
||||
expect(mocks.seedNativeChatLaunchDraft).not.toHaveBeenCalled()
|
||||
expect(mocks.seedNativeChatLaunchDraft).toHaveBeenCalledWith({
|
||||
tabId: 'fork-tab',
|
||||
agent: 'codex',
|
||||
text: content,
|
||||
createdAt: expect.any(Number)
|
||||
})
|
||||
})
|
||||
|
||||
it('does not seed a launch draft for submitted, unsupported, or empty content', async () => {
|
||||
|
|
|
|||
|
|
@ -308,7 +308,7 @@ describe('launchAgentInNewTab', () => {
|
|||
)
|
||||
})
|
||||
|
||||
it('keeps a multi-line draft out of chat entirely', async () => {
|
||||
it('mirrors a multi-line draft into chat and opens the tab there', async () => {
|
||||
store.settings = {
|
||||
agentCmdOverrides: {},
|
||||
agentDefaultArgs: {},
|
||||
|
|
@ -319,21 +319,22 @@ describe('launchAgentInNewTab', () => {
|
|||
}
|
||||
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
|
||||
|
||||
const prompt = 'Reproduce first\n\nhttps://github.com/o/r/issues/12'
|
||||
launchAgentInNewTab({
|
||||
agent: 'claude',
|
||||
worktreeId: 'wt-1',
|
||||
prompt: 'Reproduce first\n\nhttps://github.com/o/r/issues/12',
|
||||
prompt,
|
||||
promptDelivery: 'draft'
|
||||
})
|
||||
|
||||
// Unseedable and un-opened must move together: a chat view here would be
|
||||
// an empty composer beside a filled TUI input.
|
||||
expect(mockSeedNativeChatLaunchDraft).not.toHaveBeenCalled()
|
||||
expect(mockSeedNativeChatLaunchDraft).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ tabId: 'tab-1', agent: 'claude', text: prompt })
|
||||
)
|
||||
expect(mockCreateTab).toHaveBeenCalledWith(
|
||||
'wt-1',
|
||||
undefined,
|
||||
undefined,
|
||||
expect.not.objectContaining({ viewMode: 'chat' })
|
||||
expect.objectContaining({ viewMode: 'chat' })
|
||||
)
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -467,10 +467,9 @@ describe('launchWorkItemDirect', () => {
|
|||
expect(startup?.launchDraftText).toBe('https://github.com/acme/repo/issues/12')
|
||||
})
|
||||
|
||||
it('withholds the chat-composer launch draft for a multi-line Linear draft launch', async () => {
|
||||
// A Linear draft is always `Linked Linear issue: ENG-42\n<url>\n`. The chat
|
||||
// send pre-clears the TUI with Ctrl+U (kill-to-start-of-LINE), so seeding it
|
||||
// would leave the first line parked to glue onto the next message.
|
||||
it('seeds the chat-composer launch draft for a multi-line Linear draft launch', async () => {
|
||||
// A Linear draft is always `Linked Linear issue: ENG-42\n<url>\n`, so withholding
|
||||
// multi-line drafts made every Linear launch invisible in the chat view.
|
||||
mocks.ensureDetectedAgents.mockResolvedValue(['claude'])
|
||||
const { launchWorkItemDirect } = await import('./launch-work-item-direct')
|
||||
|
||||
|
|
@ -490,7 +489,12 @@ describe('launchWorkItemDirect', () => {
|
|||
})
|
||||
).resolves.toBe(true)
|
||||
|
||||
expect(mocks.seedNativeChatLaunchDraft).not.toHaveBeenCalled()
|
||||
expect(mocks.seedNativeChatLaunchDraft).toHaveBeenCalledWith({
|
||||
tabId: 'tab-1',
|
||||
agent: 'claude',
|
||||
text: 'Linked Linear issue: ENG-42\nhttps://linear.app/acme/issue/ENG-42/ship-linear-parity\n',
|
||||
createdAt: expect.any(Number)
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves explicit Linear paste content submit-after-ready behavior', async () => {
|
||||
|
|
|
|||
|
|
@ -104,7 +104,21 @@ describe('decideInitialAgentTabViewMode', () => {
|
|||
|
||||
it.each([
|
||||
['multi-line', 'Reproduce first\n\nhttps://github.com/o/r/issues/12'],
|
||||
['trailing-newline', 'https://github.com/o/r/issues/12\n'],
|
||||
['trailing-newline', 'https://github.com/o/r/issues/12\n']
|
||||
])('opens a %s draft in chat with its mirrored composer text', (_label, launchDraftText) => {
|
||||
expect(
|
||||
decideInitialAgentTabViewMode({
|
||||
experimentalNativeChat: true,
|
||||
openAgentTabsInChatByDefault: true,
|
||||
agent: 'claude',
|
||||
promptDelivery: 'draft',
|
||||
launchDraftText
|
||||
})
|
||||
).toBe('chat')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['Unicode-line-separator', 'one\u2028two'],
|
||||
['blank', ' '],
|
||||
['absent', undefined]
|
||||
])('keeps a %s draft in the terminal, where its text actually is', (_label, launchDraftText) => {
|
||||
|
|
|
|||
|
|
@ -13,6 +13,10 @@ vi.mock('@/store', () => ({
|
|||
import { seedNativeChatLaunchDraftForAgentTab } from './agent-launch-prompt-delivery'
|
||||
import { canMirrorLaunchDraftToNativeChat } from './native-chat-launch-draft-mirrorability'
|
||||
import { decideInitialAgentTabViewMode } from './native-chat-initial-view-mode'
|
||||
import { AGENT_TUI_CLEAR_MAX_LINES } from '../../../shared/agent-tui-input-clear'
|
||||
|
||||
const maxLineDraft = Array.from({ length: AGENT_TUI_CLEAR_MAX_LINES }, () => 'line').join('\n')
|
||||
const overMaxLineDraft = `${maxLineDraft}\nline`
|
||||
|
||||
/**
|
||||
* Every shape a launch draft takes today. `formatDraftContextBlock` appends a
|
||||
|
|
@ -28,6 +32,8 @@ const DRAFT_TEXTS = [
|
|||
'ORC-123: Restore linked quick-create\nhttps://linear.app/o/issue/ORC-123',
|
||||
'ORC-123 https://linear.app/o/issue/ORC-123\n',
|
||||
' spaced but single line ',
|
||||
maxLineDraft,
|
||||
overMaxLineDraft,
|
||||
'',
|
||||
' ',
|
||||
'\n'
|
||||
|
|
@ -69,18 +75,21 @@ describe('launch draft mirrorability', () => {
|
|||
expect(opensInChat(text)).toBe(expected)
|
||||
})
|
||||
|
||||
// The only test that pins the rule itself; the agreement tests above adapt on
|
||||
// their own. Multi-line send work updates the predicate body and this test.
|
||||
it('accepts single-line text and rejects any line separator', () => {
|
||||
it('accepts CR/LF drafts and rejects unsupported Unicode line separators', () => {
|
||||
expect(canMirrorLaunchDraftToNativeChat('https://github.com/o/r/issues/12')).toBe(true)
|
||||
expect(canMirrorLaunchDraftToNativeChat('one\ntwo')).toBe(false)
|
||||
expect(canMirrorLaunchDraftToNativeChat('one\rtwo')).toBe(false)
|
||||
expect(canMirrorLaunchDraftToNativeChat('one\ntwo')).toBe(true)
|
||||
expect(canMirrorLaunchDraftToNativeChat('one\rtwo')).toBe(true)
|
||||
expect(canMirrorLaunchDraftToNativeChat('one\u2028two')).toBe(false)
|
||||
expect(canMirrorLaunchDraftToNativeChat('one\u2029two')).toBe(false)
|
||||
expect(canMirrorLaunchDraftToNativeChat('trailing\n')).toBe(false)
|
||||
expect(canMirrorLaunchDraftToNativeChat('trailing\n')).toBe(true)
|
||||
expect(canMirrorLaunchDraftToNativeChat(' ')).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects drafts beyond the bounded TUI-clear budget', () => {
|
||||
expect(canMirrorLaunchDraftToNativeChat(maxLineDraft)).toBe(true)
|
||||
expect(canMirrorLaunchDraftToNativeChat(overMaxLineDraft)).toBe(false)
|
||||
})
|
||||
|
||||
it('withholds the mirror from agents without a native-chat renderer', () => {
|
||||
// The view mode already returns undefined for these, so the sets still
|
||||
// agree — but only the seeding side enforces it.
|
||||
|
|
|
|||
|
|
@ -1,3 +1,8 @@
|
|||
import {
|
||||
AGENT_TUI_CLEAR_MAX_LINES,
|
||||
countAgentTuiInputLines
|
||||
} from '../../../shared/agent-tui-input-clear'
|
||||
|
||||
/**
|
||||
* Single source of truth for whether unsent launch context can be mirrored from
|
||||
* the agent's TUI input into the native-chat composer.
|
||||
|
|
@ -7,12 +12,13 @@
|
|||
* predicate, so a draft launch can never open in chat with a composer that
|
||||
* chat then refuses to fill.
|
||||
*
|
||||
* Multi-line is rejected because the chat send pre-clears the TUI input with
|
||||
* Ctrl+U (\x15) — kill-to-start-of-LINE, not of the whole buffer — so earlier
|
||||
* lines of a multi-line mirror would survive and concatenate onto the message
|
||||
* being sent. Relaxing that rule belongs here and nowhere else: teaching this
|
||||
* predicate to accept multi-line flips seeding and view mode together.
|
||||
* CR/LF drafts are safe within the bounded TUI-clear budget. Unicode line
|
||||
* separators and drafts beyond that budget remain terminal-only.
|
||||
*/
|
||||
export function canMirrorLaunchDraftToNativeChat(text: string): boolean {
|
||||
return text.trim().length > 0 && !/[\r\n\u2028\u2029]/.test(text)
|
||||
return (
|
||||
text.trim().length > 0 &&
|
||||
!/[\u2028\u2029]/.test(text) &&
|
||||
countAgentTuiInputLines(text) <= AGENT_TUI_CLEAR_MAX_LINES
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -501,11 +501,10 @@ describe('ensureWorktreeHasInitialTerminal', () => {
|
|||
})
|
||||
})
|
||||
|
||||
// A draft opens in chat only when the composer can actually show it; the
|
||||
// multi-line case would otherwise be an empty composer beside a filled TUI.
|
||||
it.each([
|
||||
['mirrorable', 'https://github.com/o/r/issues/12', { viewMode: 'chat' }],
|
||||
['multi-line', 'Review this\n\nhttps://github.com/o/r/issues/12', {}]
|
||||
['multi-line', 'Review this\n\nhttps://github.com/o/r/issues/12', { viewMode: 'chat' }],
|
||||
['unsupported-separator', 'Review this\u2028https://github.com/o/r/issues/12', {}]
|
||||
])('opens a %s draft startup payload accordingly', (_label, draftPrompt, expectedViewMode) => {
|
||||
const store = createMockStore({
|
||||
settings: {
|
||||
|
|
@ -538,7 +537,8 @@ describe('ensureWorktreeHasInitialTerminal', () => {
|
|||
// nothing mirrored — an empty composer beside a filled TUI input.
|
||||
it.each([
|
||||
['mirrorable', 'https://github.com/o/r/issues/12', { viewMode: 'chat' }],
|
||||
['multi-line', 'Review this\n\nhttps://github.com/o/r/issues/12', {}]
|
||||
['multi-line', 'Review this\n\nhttps://github.com/o/r/issues/12', { viewMode: 'chat' }],
|
||||
['unsupported-separator', 'Review this\u2028https://github.com/o/r/issues/12', {}]
|
||||
])(
|
||||
'gates a %s argv-prefill draft on launchDraftText alone',
|
||||
(_label, launchDraftText, expectedViewMode) => {
|
||||
|
|
@ -599,7 +599,8 @@ describe('ensureWorktreeHasInitialTerminal', () => {
|
|||
|
||||
it.each([
|
||||
['mirrorable', 'https://github.com/o/r/issues/12', { viewMode: 'chat' }],
|
||||
['multi-line', 'Review this\n\nhttps://github.com/o/r/issues/12', {}]
|
||||
['multi-line', 'Review this\n\nhttps://github.com/o/r/issues/12', { viewMode: 'chat' }],
|
||||
['unsupported-separator', 'Review this\u2028https://github.com/o/r/issues/12', {}]
|
||||
])(
|
||||
'opens a %s draft startup default tab accordingly',
|
||||
(_label, draftPrompt, expectedViewMode) => {
|
||||
|
|
|
|||
|
|
@ -133,7 +133,7 @@ describe('seedAgentTabStateAfterWorktreeCreate', () => {
|
|||
setTabs([{ id: 'agent-tab', launchAgent: 'claude', viewMode: 'chat' }])
|
||||
|
||||
seedAgentTabStateAfterWorktreeCreate({
|
||||
request: { ...request, launchDraftPrompt: 'note\rhttps://github.com/o/r/issues/12' },
|
||||
request: { ...request, launchDraftPrompt: 'note\u2028https://github.com/o/r/issues/12' },
|
||||
worktreeId: 'wt-1',
|
||||
primaryTabId: 'agent-tab',
|
||||
startupTerminalTabId: 'agent-tab',
|
||||
|
|
|
|||
|
|
@ -735,7 +735,7 @@ describe('staged background worktree creation', () => {
|
|||
|
||||
it.each([
|
||||
['mirrorable local Grok', 'grok', 'https://github.com/o/r/issues/12', 'chat'],
|
||||
['multi-line Claude', 'claude', 'note\nhttps://github.com/o/r/issues/12', 'terminal']
|
||||
['multi-line Claude', 'claude', 'note\nhttps://github.com/o/r/issues/12', 'chat']
|
||||
] as const)('passes %s draft mode to backend startup', async (_label, agent, draft, viewMode) => {
|
||||
store.settings.experimentalNativeChat = true
|
||||
store.settings.openAgentTabsInChatByDefault = true
|
||||
|
|
|
|||
|
|
@ -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]) {
|
||||
|
|
|
|||
|
|
@ -1343,6 +1343,7 @@ function createRuntimeApi(): NonNullable<Partial<PreloadApi>['runtime']> {
|
|||
reclaimBrowserForDesktop: () => Promise.resolve({ reclaimed: false }),
|
||||
onTerminalFitOverrideChanged: () => noopUnsubscribe,
|
||||
onTerminalDriverChanged: () => noopUnsubscribe,
|
||||
onNativeChatLaunchDraftResolved: () => noopUnsubscribe,
|
||||
onBrowserDriverChanged: () => noopUnsubscribe
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,87 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
AGENT_TUI_CLEAR_INPUT_FORWARD,
|
||||
AGENT_TUI_CLEAR_INPUT_LINE,
|
||||
AGENT_TUI_CLEAR_INPUT_MAX,
|
||||
AGENT_TUI_CLEAR_LINE_SLACK,
|
||||
AGENT_TUI_CLEAR_MAX_LINES,
|
||||
buildAgentTuiClearInput,
|
||||
buildAgentTuiClearInputForText,
|
||||
countAgentTuiInputLines
|
||||
} from './agent-tui-input-clear'
|
||||
|
||||
const countCtrlU = (bytes: string): number =>
|
||||
bytes.split('').filter((char) => char === AGENT_TUI_CLEAR_INPUT_LINE).length
|
||||
const countCtrlK = (bytes: string): number =>
|
||||
bytes.split('').filter((char) => char === AGENT_TUI_CLEAR_INPUT_FORWARD).length
|
||||
|
||||
describe('buildAgentTuiClearInput', () => {
|
||||
// The measured law: N kills + (N-1) joins. A constant here silently under-clears
|
||||
// (5 clears 3 lines but leaves residue at 4), which is what glues onto the next
|
||||
// message — so pin every small N, not just one.
|
||||
it.each([
|
||||
[1, 1],
|
||||
[2, 3],
|
||||
[3, 5],
|
||||
[4, 7],
|
||||
[10, 19]
|
||||
])('clears %i logical lines with %i Ctrl+U', (lines, expected) => {
|
||||
const clearInput = buildAgentTuiClearInput(lines)
|
||||
expect(countCtrlU(clearInput)).toBe(expected)
|
||||
expect(countCtrlK(clearInput)).toBe(expected)
|
||||
})
|
||||
|
||||
it('clears before the cursor before clearing the suffix after it', () => {
|
||||
expect(buildAgentTuiClearInput(4)).toBe(
|
||||
AGENT_TUI_CLEAR_INPUT_LINE.repeat(7) + AGENT_TUI_CLEAR_INPUT_FORWARD.repeat(7)
|
||||
)
|
||||
})
|
||||
|
||||
it('still clears one line for a zero or negative count', () => {
|
||||
expect(countCtrlU(buildAgentTuiClearInput(0))).toBe(1)
|
||||
expect(countCtrlU(buildAgentTuiClearInput(-5))).toBe(1)
|
||||
})
|
||||
|
||||
it('caps the burst so a pathological draft cannot emit an unbounded write', () => {
|
||||
expect(countCtrlU(buildAgentTuiClearInput(10_000))).toBe(2 * AGENT_TUI_CLEAR_MAX_LINES - 1)
|
||||
expect(AGENT_TUI_CLEAR_INPUT_MAX).toBe(buildAgentTuiClearInput(AGENT_TUI_CLEAR_MAX_LINES))
|
||||
})
|
||||
})
|
||||
|
||||
describe('countAgentTuiInputLines', () => {
|
||||
it.each([
|
||||
['one line', 1],
|
||||
['a\nb', 2],
|
||||
['a\r\nb\r\nc', 3],
|
||||
['a\rb', 2],
|
||||
['trailing\n', 2]
|
||||
])('counts %j as %i logical lines', (text, expected) => {
|
||||
expect(countAgentTuiInputLines(text)).toBe(expected)
|
||||
})
|
||||
|
||||
it('ignores visual wrapping — only logical newlines cost a Ctrl+U', () => {
|
||||
expect(countAgentTuiInputLines('x'.repeat(5_000))).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildAgentTuiClearInputForText', () => {
|
||||
it('sizes the burst from the text plus slack for TUI-side edits', () => {
|
||||
// The injected text is a LOWER bound: the user can type into the TUI line too.
|
||||
expect(countCtrlU(buildAgentTuiClearInputForText('a\nb'))).toBe(
|
||||
2 * (2 + AGENT_TUI_CLEAR_LINE_SLACK) - 1
|
||||
)
|
||||
})
|
||||
|
||||
it('clears strictly more than the draft needs, never less', () => {
|
||||
const draft = 'Linked Linear issue: ABC-123\nhttps://linear.app/x/issue/ABC-123\n'
|
||||
expect(countCtrlU(buildAgentTuiClearInputForText(draft))).toBeGreaterThan(
|
||||
2 * countAgentTuiInputLines(draft) - 1
|
||||
)
|
||||
})
|
||||
|
||||
it('a long wrapped single line does not inflate the burst', () => {
|
||||
expect(buildAgentTuiClearInputForText('y'.repeat(5_000))).toBe(
|
||||
buildAgentTuiClearInputForText('y')
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
// Clearing an agent TUI's input buffer when it may hold MORE THAN ONE line.
|
||||
//
|
||||
// Shared by desktop native chat and mobile: the law below is a property of the
|
||||
// agent TUIs (Claude Code, codex), not of either client.
|
||||
|
||||
/** Ctrl+U — clears toward the start of the input buffer. */
|
||||
export const AGENT_TUI_CLEAR_INPUT_LINE = '\x15'
|
||||
|
||||
/** Ctrl+K — clears toward the end of the input buffer. */
|
||||
export const AGENT_TUI_CLEAR_INPUT_FORWARD = '\x0b'
|
||||
|
||||
/** Clear up to `lineCount` logical lines from any cursor position. */
|
||||
export function buildAgentTuiClearInput(lineCount: number): string {
|
||||
const lines = Math.max(1, Math.min(AGENT_TUI_CLEAR_MAX_LINES, Math.floor(lineCount)))
|
||||
const repetitions = 2 * lines - 1
|
||||
return (
|
||||
AGENT_TUI_CLEAR_INPUT_LINE.repeat(repetitions) +
|
||||
AGENT_TUI_CLEAR_INPUT_FORWARD.repeat(repetitions)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Headroom over the line count Orca knows about. The text Orca injected is a
|
||||
* LOWER BOUND on what the buffer holds — the user can also type straight into
|
||||
* the TUI line — so the count is deliberately biased upward. Overshoot is free:
|
||||
* 41 Ctrl+U against a 1-line buffer measured perfectly clean on both agents,
|
||||
* and an undershoot is what leaves residue to glue onto the next message.
|
||||
*/
|
||||
export const AGENT_TUI_CLEAR_LINE_SLACK = 8
|
||||
|
||||
/** Bounds the burst so a pathological draft cannot emit an unbounded write. */
|
||||
export const AGENT_TUI_CLEAR_MAX_LINES = 40
|
||||
|
||||
/** Widest burst we ever send — the remedy when a clear is not observed to land. */
|
||||
export const AGENT_TUI_CLEAR_INPUT_MAX = buildAgentTuiClearInput(AGENT_TUI_CLEAR_MAX_LINES)
|
||||
|
||||
/** Logical lines in `text`. Visual wrapping is irrelevant to the clear cost. */
|
||||
export function countAgentTuiInputLines(text: string): number {
|
||||
return text.split(/\r\n|\r|\n/).length
|
||||
}
|
||||
|
||||
/** Clear bytes for a buffer believed to hold `text`, with slack for TUI-side edits. */
|
||||
export function buildAgentTuiClearInputForText(text: string): string {
|
||||
return buildAgentTuiClearInput(countAgentTuiInputLines(text) + AGENT_TUI_CLEAR_LINE_SLACK)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue