fix(native-chat): only seed single-line unsubmitted launch drafts

The unsubmitted-delivery branch seeded on every draft delivery, which also
caught the agent-session-fork path whose prompt is multi-line scraped context.
The chat send pre-clears the TUI with Ctrl+U (kill-to-start-of-LINE), so a
multi-line prefill cannot be fully cleared and its earlier lines would glue
onto the next message. The GitHub work-item draft this feature targets is a
bare issue URL, so narrowing costs it nothing.

Also assert the composer retires the seed after a send — deleting that call
previously failed no test.
This commit is contained in:
Brennan Benson
2026-07-27 16:48:31 -07:00
parent 6b9ef9ca27
commit 35f98262eb
3 changed files with 40 additions and 4 deletions
@@ -32,7 +32,9 @@ const mocks = vi.hoisted(() => ({
sendNativeChatMessageVerified: vi.fn(),
trackPendingSend: vi.fn(),
setDraft: vi.fn(),
draftScopeKeys: [] as string[]
draftScopeKeys: [] as string[],
clearNativeChatLaunchDraft: vi.fn(),
markNativeChatLaunchDraftAdopted: vi.fn()
}))
vi.mock('../../store', () => {
@@ -40,8 +42,8 @@ vi.mock('../../store', () => {
dictationState: 'idle',
settings: { voice: { enabled: false }, nativeChatSessionOptions: {} },
updateSettings: vi.fn(),
clearNativeChatLaunchDraft: vi.fn(),
markNativeChatLaunchDraftAdopted: vi.fn()
clearNativeChatLaunchDraft: mocks.clearNativeChatLaunchDraft,
markNativeChatLaunchDraftAdopted: mocks.markNativeChatLaunchDraftAdopted
}
const useAppStore = (selector: (value: typeof state) => unknown) => selector(state)
useAppStore.getState = () => state
@@ -203,6 +205,22 @@ describe('NativeChatComposer', () => {
expect(mocks.trackPendingSend).toHaveBeenCalledWith(mocks.sendHandle, 'pending-1')
})
it('retires the launch-draft seed once a send clears the TUI input line', () => {
render(
<NativeChatComposer
terminalTabId="tab-1"
paneKey="tab-1:leaf-1"
targetPtyId="pty-1"
agent="codex"
/>
)
expect(mocks.clearNativeChatLaunchDraft).not.toHaveBeenCalled()
act(() => mocks.fieldProps?.onSend?.())
expect(mocks.clearNativeChatLaunchDraft).toHaveBeenCalledWith('tab-1')
})
it('keeps the draft scope anchored to the pane while the PTY reconnects', () => {
const view = render(
<NativeChatComposer
@@ -101,6 +101,21 @@ 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.
await deliverLaunchPromptToAgentTab({
tabId: 'fork-tab',
agent: 'codex',
content: 'Forked from session\n\nhttps://example.test/context',
submit: false,
forcePaste: false
})
expect(mocks.seedNativeChatLaunchDraft).not.toHaveBeenCalled()
})
it('does not seed a launch draft for submitted, unsupported, or empty content', async () => {
await deliverLaunchPromptToAgentTab({
tabId: 'submit-tab',
@@ -43,9 +43,12 @@ export function deliverLaunchPromptToAgentTab(args: {
text: content,
createdAt: Date.now()
})
} else if (submit !== true) {
} else if (submit !== true && !content.includes('\n')) {
// Why: an unsubmitted draft lives only in the TUI input buffer; seed the
// chat-composer copy so the context isn't invisible in the GUI view.
// Single-line only: the chat send pre-clears the TUI with Ctrl+U
// (kill-to-start-of-LINE), so a multi-line prefill (e.g. scraped fork
// context) would leave earlier lines to glue onto the next message.
seedNativeChatLaunchDraftForAgentTab({ tabId, agent, text: content })
}