Files
orca/mobile/src/session/use-mobile-native-chat-drafts-launch-draft.test.ts
T
Brennan Benson bbb3e7e5ee 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
2026-07-30 11:08:56 -07:00

329 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
import { useMobileNativeChatDrafts } from './use-mobile-native-chat-drafts'
type DraftState = ReturnType<typeof useMobileNativeChatDrafts>
function userTextMessage(id: string, text: string): NativeChatMessage {
return {
id,
role: 'user',
blocks: [{ type: 'text', text }],
timestamp: null,
source: 'transcript'
}
}
// Adoption and retirement of the host-published launch draft (the TUI-input
// prefill mirrored into the chat composer). Split from the send/pending suite
// so both stay under the per-file line cap.
describe('useMobileNativeChatDrafts launch draft', () => {
let renderer: ReactTestRenderer | null = null
let state: DraftState | null = null
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
})
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
state = null
})
function Harness({
tabId,
sessionId = `session-${tabId}`,
messages = [],
launchDraft = null,
launchDraftCreatedAt = null,
chatActive = true,
transcriptLoading = false
}: {
tabId: string
sessionId?: string | null
messages?: NativeChatMessage[]
launchDraft?: string | null
launchDraftCreatedAt?: number | null
chatActive?: boolean
transcriptLoading?: boolean
}): null {
state = useMobileNativeChatDrafts({
hostId: 'host',
worktreeId: 'worktree',
tabId,
sessionId,
messages,
launchDraft,
launchDraftCreatedAt,
chatActive,
transcriptLoading
})
return null
}
async function mount(tabId: string): Promise<void> {
const original = console.error
const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) {
return
}
original(...args)
})
try {
await act(async () => {
renderer = create(createElement(Harness, { tabId }))
})
} finally {
consoleSpy.mockRestore()
}
}
it('prefills the composer from a host launch draft exactly once', async () => {
await mount('a')
await act(async () =>
renderer?.update(
createElement(Harness, { tabId: 'a', launchDraft: 'https://github.com/o/r/issues/12' })
)
)
expect(state?.composerText).toBe('https://github.com/o/r/issues/12')
// A user clear must not see the prefill resurrected on the next render.
act(() => state?.setComposerText(''))
await act(async () =>
renderer?.update(
createElement(Harness, { tabId: 'a', launchDraft: 'https://github.com/o/r/issues/12' })
)
)
expect(state?.composerText).toBe('')
})
it('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'))
await act(async () =>
renderer?.update(createElement(Harness, { tabId: 'a', launchDraft: 'issue link' }))
)
expect(state?.composerText).toBe('typed first')
})
it('declines a launch draft when the transcript already has a user turn', async () => {
await mount('a')
await act(async () =>
renderer?.update(
createElement(Harness, {
tabId: 'a',
messages: [userTextMessage('m1', 'already sent')],
launchDraft: 'issue link'
})
)
)
expect(state?.composerText).toBe('')
})
it('clears an untouched prefill once a user turn lands, keeping user edits', async () => {
await mount('a')
await act(async () =>
renderer?.update(createElement(Harness, { tabId: 'a', launchDraft: 'issue link' }))
)
expect(state?.composerText).toBe('issue link')
await act(async () =>
renderer?.update(
createElement(Harness, {
tabId: 'a',
messages: [userTextMessage('m1', 'sent from the TUI')],
launchDraft: 'issue link'
})
)
)
expect(state?.composerText).toBe('')
// Edited prefill survives resolution on another tab's copy.
await act(async () =>
renderer?.update(createElement(Harness, { tabId: 'b', launchDraft: 'issue link' }))
)
act(() => state?.setComposerText('issue link plus my notes'))
await act(async () =>
renderer?.update(
createElement(Harness, {
tabId: 'b',
messages: [userTextMessage('m2', 'sent from the TUI')],
launchDraft: 'issue link'
})
)
)
expect(state?.composerText).toBe('issue link plus my notes')
})
it('holds the launch draft until the transcript read settles', async () => {
// `session.tabs` carries launchDraft before the transcript loads. Seeding on
// the empty in-flight list would prefill an already-submitted issue link, and
// a send tapped before it retracts duplicates it to the agent.
await mount('a')
await act(async () =>
renderer?.update(
createElement(Harness, { tabId: 'a', launchDraft: 'issue link', transcriptLoading: true })
)
)
expect(state?.composerText).toBe('')
// The settled transcript already holds the submitted turn — decline for good.
await act(async () =>
renderer?.update(
createElement(Harness, {
tabId: 'a',
launchDraft: 'issue link',
messages: [userTextMessage('m1', 'already sent from the TUI')]
})
)
)
expect(state?.composerText).toBe('')
})
it('seeds once the transcript settles empty', async () => {
await mount('a')
await act(async () =>
renderer?.update(
createElement(Harness, { tabId: 'a', launchDraft: 'issue link', transcriptLoading: true })
)
)
expect(state?.composerText).toBe('')
await act(async () =>
renderer?.update(createElement(Harness, { tabId: 'a', launchDraft: 'issue link' }))
)
expect(state?.composerText).toBe('issue link')
})
it('holds the seed while the tab is not resolved to chat view', async () => {
// Off chat the session hook is not subscribed, so `messages` is empty for a
// reason that says nothing about the transcript — judging the seed from it
// would prefill an issue link the agent already submitted in the TUI.
await mount('a')
await act(async () =>
renderer?.update(
createElement(Harness, { tabId: 'a', launchDraft: 'issue link', chatActive: false })
)
)
expect(state?.composerText).toBe('')
await act(async () =>
renderer?.update(
createElement(Harness, {
tabId: 'a',
launchDraft: 'issue link',
messages: [userTextMessage('m1', 'already sent from the TUI')]
})
)
)
expect(state?.composerText).toBe('')
})
it('keeps an adopted prefill when the tab momentarily drops out of the snapshot', async () => {
// A session-tabs frame can transiently omit the active tab: the draft then
// reads as retracted and chat as inactive, but nothing was resolved.
await mount('a')
await act(async () =>
renderer?.update(createElement(Harness, { tabId: 'a', launchDraft: 'issue link' }))
)
expect(state?.composerText).toBe('issue link')
await act(async () =>
renderer?.update(createElement(Harness, { tabId: 'a', launchDraft: null, chatActive: false }))
)
expect(state?.composerText).toBe('issue link')
await act(async () =>
renderer?.update(createElement(Harness, { tabId: 'a', launchDraft: 'issue link' }))
)
expect(state?.composerText).toBe('issue link')
})
it('does not decline another tab’s prefill from the transcript it is still showing', async () => {
// The session hook resets its list in an effect, so the commit that first
// sees tab b still carries tab a's turns. Only transcriptLoading says so.
const carriedOver = [userTextMessage('m1', 'sent on a')]
await mount('a')
await act(async () =>
renderer?.update(createElement(Harness, { tabId: 'a', messages: carriedOver }))
)
await act(async () =>
renderer?.update(
createElement(Harness, {
tabId: 'b',
messages: carriedOver,
launchDraft: 'issue link',
transcriptLoading: true
})
)
)
expect(state?.composerText).toBe('')
await act(async () =>
renderer?.update(createElement(Harness, { tabId: 'b', launchDraft: 'issue link' }))
)
expect(state?.composerText).toBe('issue link')
})
it('does not retire an adopted prefill from the transcript it is still showing', async () => {
const carriedOver = [userTextMessage('m1', 'sent on a')]
await mount('b')
await act(async () =>
renderer?.update(createElement(Harness, { tabId: 'b', launchDraft: 'issue link' }))
)
expect(state?.composerText).toBe('issue link')
await act(async () =>
renderer?.update(createElement(Harness, { tabId: 'a', messages: carriedOver }))
)
await act(async () =>
renderer?.update(
createElement(Harness, {
tabId: 'b',
messages: carriedOver,
launchDraft: 'issue link',
transcriptLoading: true
})
)
)
expect(state?.composerText).toBe('issue link')
await act(async () =>
renderer?.update(createElement(Harness, { tabId: 'b', launchDraft: 'issue link' }))
)
expect(state?.composerText).toBe('issue link')
})
it('clears an untouched prefill when the host stops publishing the launch draft', async () => {
await mount('a')
await act(async () =>
renderer?.update(createElement(Harness, { tabId: 'a', launchDraft: 'issue link' }))
)
expect(state?.composerText).toBe('issue link')
await act(async () =>
renderer?.update(createElement(Harness, { tabId: 'a', launchDraft: null }))
)
expect(state?.composerText).toBe('')
})
})