mirror of
https://github.com/stablyai/orca.git
synced 2026-10-19 16:03:26 +00:00
fix(native-chat): preserve launch draft send contents
This commit is contained in:
@@ -361,6 +361,7 @@ export function useMobileNativeChatImageAttachments({
|
||||
enabled,
|
||||
onError,
|
||||
onSendError,
|
||||
readSeededLaunchDraft,
|
||||
scopeKey,
|
||||
sleep
|
||||
]
|
||||
|
||||
@@ -145,12 +145,13 @@ describe('useMobileNativeChatMessageSend', () => {
|
||||
expect(sendWithOutcome.mock.calls[0]![0]).toMatchObject({ clearInputFirst: false })
|
||||
})
|
||||
|
||||
it('still skips the pre-clear for an image send, which pasted its own first', async () => {
|
||||
it('does not clear an image send after the image was pasted', async () => {
|
||||
// A second clear here would wipe the image that was just pasted.
|
||||
mount(() => DRAFT)
|
||||
await act(async () => {
|
||||
await api!.send('caption', ['file:///a.png'])
|
||||
})
|
||||
expect(clearInputWrite).not.toHaveBeenCalled()
|
||||
expect(sentArgs().clearInputFirst).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -113,7 +113,7 @@ export function useMobileNativeChatMessageSend(args: {
|
||||
// clearMobileNativeChatInput). A rejected clear aborts the send rather
|
||||
// than pasting on top of an uncleared line.
|
||||
const seededLaunchDraft = readSeededLaunchDraft()
|
||||
if (seededLaunchDraft) {
|
||||
if (seededLaunchDraft && !images?.length) {
|
||||
const cleared = await clearMobileNativeChatInput({
|
||||
client,
|
||||
terminal: handle,
|
||||
|
||||
@@ -5,7 +5,6 @@ import { getSettingsForAgentTabRuntimeOwner } from '@/lib/agent-paste-draft'
|
||||
import {
|
||||
sendNativeChatMessage,
|
||||
sendNativeChatMessageWithImageAttachments,
|
||||
submitNativeChatDraftInPlace,
|
||||
submitNativeChatPrompt
|
||||
} from './native-chat-runtime-send'
|
||||
import type { NativeChatSendHandle } from './native-chat-runtime-send'
|
||||
@@ -252,26 +251,18 @@ export const NativeChatComposer = forwardRef<NativeChatComposerHandle, NativeCha
|
||||
return
|
||||
}
|
||||
const classification = classifySend(text)
|
||||
// Why: the agent's input line may still hold the launch-context draft Orca
|
||||
// injected at launch. When it does, the buffer already holds this exact
|
||||
// message, so the send is Enter alone — that cannot concatenate and makes
|
||||
// a multi-line draft submit as one turn. Otherwise the line must be
|
||||
// cleared line-by-line before the body (see agent-tui-input-clear.ts).
|
||||
const { plan: launchDraftPlan, sendOptions } = resolveNativeChatLaunchDraftSend({
|
||||
// A parked launch draft must be cleared line-by-line before the body.
|
||||
const { sendOptions } = resolveNativeChatLaunchDraftSend({
|
||||
launchDraft,
|
||||
launchDraftResolved,
|
||||
agent,
|
||||
text,
|
||||
hasImages: imagePaths.length > 0,
|
||||
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 (launchDraftPlan.kind === 'submit-in-place') {
|
||||
pendingHandle = submitNativeChatDraftInPlace(target.settings, target.ptyId)
|
||||
} else if (classification !== 'chat' && imagePaths.length === 0) {
|
||||
if (classification !== 'chat' && imagePaths.length === 0) {
|
||||
pendingHandle = sendNativeChatMessage(target.settings, target.ptyId, text, sendOptions)
|
||||
} else if (imagePaths.length > 0) {
|
||||
pendingHandle = sendNativeChatMessageWithImageAttachments(
|
||||
@@ -314,9 +305,7 @@ export const NativeChatComposer = forwardRef<NativeChatComposerHandle, NativeCha
|
||||
clearSkillOrigin()
|
||||
clearImageAttachments()
|
||||
setNotice(null)
|
||||
// Why: the send either submitted the parked prefill in place or cleared the
|
||||
// TUI input line before its body — either way it is gone, so 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,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
agentInputLineClearedSeededDraft,
|
||||
agentInputLineShowsSeededDraft,
|
||||
planNativeChatLaunchDraftSend
|
||||
agentInputLineCleared,
|
||||
planNativeChatLaunchDraftSend,
|
||||
resolveNativeChatLaunchDraftSend
|
||||
} from './native-chat-launch-draft-send'
|
||||
import {
|
||||
AGENT_TUI_CLEAR_INPUT_LINE,
|
||||
@@ -20,7 +20,7 @@ const screenHoldingDraft = [
|
||||
'────────────────────────────────────────'
|
||||
].join('\n')
|
||||
|
||||
const screenClear = [
|
||||
const screenPlaceholder = [
|
||||
' ▘▘ ▝▝ ~/repo',
|
||||
'────────────────────────────────────────',
|
||||
'❯ Try "create a util logging.py that..."',
|
||||
@@ -30,15 +30,32 @@ const screenClear = [
|
||||
const plan = (over: Partial<Parameters<typeof planNativeChatLaunchDraftSend>[0]> = {}) =>
|
||||
planNativeChatLaunchDraftSend({
|
||||
seededText: SEEDED,
|
||||
text: SEEDED,
|
||||
hasImages: false,
|
||||
readScreen: () => screenHoldingDraft,
|
||||
...over
|
||||
})
|
||||
|
||||
describe('planNativeChatLaunchDraftSend', () => {
|
||||
it('submits in place when the composer still holds exactly the injected draft', () => {
|
||||
expect(plan()).toEqual({ kind: 'submit-in-place' })
|
||||
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', () => {
|
||||
@@ -46,111 +63,39 @@ describe('planNativeChatLaunchDraftSend', () => {
|
||||
expect(plan({ seededText: ' ' })).toEqual({ kind: 'default' })
|
||||
})
|
||||
|
||||
it('replaces the draft when the user edited it, sizing the clear from the INJECTED text', () => {
|
||||
// The buffer holds what was injected, not what the composer now shows — a
|
||||
// burst sized from the (possibly one-line) edit would leave earlier lines.
|
||||
const result = plan({ text: 'just one line now' })
|
||||
expect(result).toEqual({
|
||||
kind: 'replace-draft',
|
||||
clearInput: buildAgentTuiClearInputForText(SEEDED),
|
||||
seededText: SEEDED
|
||||
})
|
||||
})
|
||||
|
||||
it('never submits in place for an image send, but still clears every draft line', () => {
|
||||
const result = plan({ hasImages: true })
|
||||
expect(result).toEqual({
|
||||
kind: 'replace-draft',
|
||||
clearInput: buildAgentTuiClearInputForText(SEEDED),
|
||||
seededText: SEEDED
|
||||
})
|
||||
})
|
||||
|
||||
// The hazard: the user can clear or retype the TUI line directly in terminal
|
||||
// view while the composer copy lives on. Pressing Enter then submits the wrong
|
||||
// buffer — or an empty one — and the message is silently lost.
|
||||
it('falls back to replace-draft when the input line no longer shows the draft', () => {
|
||||
expect(plan({ readScreen: () => screenClear }).kind).toBe('replace-draft')
|
||||
})
|
||||
|
||||
it('falls back to replace-draft when the screen cannot be read at all', () => {
|
||||
expect(plan({ readScreen: () => null }).kind).toBe('replace-draft')
|
||||
expect(plan({ readScreen: () => undefined }).kind).toBe('replace-draft')
|
||||
})
|
||||
|
||||
it('sizes a multi-line clear well past a single Ctrl+U', () => {
|
||||
const result = plan({ text: 'edited' })
|
||||
const result = plan()
|
||||
expect(result.kind === 'replace-draft' && result.clearInput.length).toBeGreaterThan(
|
||||
AGENT_TUI_CLEAR_INPUT_LINE.length
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('agentInputLineShowsSeededDraft', () => {
|
||||
it('sees the draft still parked on the input line', () => {
|
||||
expect(agentInputLineShowsSeededDraft(screenHoldingDraft, SEEDED)).toBe(true)
|
||||
describe('agentInputLineCleared', () => {
|
||||
it('confirms only an observably empty prompt', () => {
|
||||
expect(agentInputLineCleared('› \n gpt-5.6 · ~/repo')).toBe(true)
|
||||
})
|
||||
|
||||
it('reports gone once the TUI redraws its placeholder', () => {
|
||||
expect(agentInputLineShowsSeededDraft(screenClear, SEEDED)).toBe(false)
|
||||
})
|
||||
|
||||
it('reads codex frames too', () => {
|
||||
const codex = ['› Linked Linear issue: ABC-123', ' gpt-5.6 · ~/repo'].join('\n')
|
||||
expect(agentInputLineShowsSeededDraft(codex, SEEDED)).toBe(true)
|
||||
})
|
||||
|
||||
it('ignores the same text sitting ABOVE as an already-sent turn', () => {
|
||||
// Otherwise a sent draft reads as residue forever and every send re-clears.
|
||||
const afterSend = [
|
||||
'> Linked Linear issue: ABC-123',
|
||||
' https://linear.app/x/issue/ABC-123',
|
||||
it('does not call a different nonempty prompt cleared', () => {
|
||||
const edited = [
|
||||
'────────────────────────────────────────',
|
||||
'❯ Try "create a util logging.py that..."',
|
||||
'❯ issue: ABC-123 residue after a cursor-middle clear',
|
||||
'────────────────────────────────────────'
|
||||
].join('\n')
|
||||
expect(agentInputLineShowsSeededDraft(afterSend, SEEDED)).toBe(false)
|
||||
expect(agentInputLineCleared(edited)).toBe(false)
|
||||
})
|
||||
|
||||
it('still sees a draft whose first line WRAPPED across rows', () => {
|
||||
// The serializer breaks a wrapped line with a newline. A fingerprint long
|
||||
// enough to span that break would miss and report the draft gone — the
|
||||
// unsafe direction, since the caller would then paste on top of it.
|
||||
const wrapped = [
|
||||
'────────────────────',
|
||||
'❯ Linked Linear issu',
|
||||
' e: ABC-123',
|
||||
' https://linear.app',
|
||||
'────────────────────'
|
||||
].join('\n')
|
||||
expect(agentInputLineShowsSeededDraft(wrapped, SEEDED)).toBe(true)
|
||||
it('treats placeholders and parked drafts as unconfirmed', () => {
|
||||
expect(agentInputLineCleared(screenPlaceholder)).toBe(false)
|
||||
expect(agentInputLineCleared(screenHoldingDraft)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns null — not a verdict — when nothing can be observed', () => {
|
||||
expect(agentInputLineShowsSeededDraft(null, SEEDED)).toBeNull()
|
||||
expect(agentInputLineShowsSeededDraft('no prompt glyph anywhere', SEEDED)).toBeNull()
|
||||
expect(agentInputLineShowsSeededDraft(screenHoldingDraft, ' ')).toBeNull()
|
||||
it('treats an unreadable screen as unconfirmed', () => {
|
||||
expect(agentInputLineCleared(null)).toBe(false)
|
||||
expect(agentInputLineCleared('unparseable')).toBe(false)
|
||||
})
|
||||
|
||||
it('matches through the ANSI the serializer leaves in the frame', () => {
|
||||
const ansi = `[2m❯[0m [38;5;1mLinked Linear issue: ABC-123[0m`
|
||||
expect(agentInputLineShowsSeededDraft(ansi, SEEDED)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('agentInputLineClearedSeededDraft', () => {
|
||||
it('confirms cleared only on a positive observation that the draft is gone', () => {
|
||||
expect(agentInputLineClearedSeededDraft(screenClear, SEEDED)).toBe(true)
|
||||
})
|
||||
|
||||
it('treats an unreadable screen as NOT cleared so the caller re-clears', () => {
|
||||
// Wrong in this direction costs one harmless extra burst; the other direction
|
||||
// pastes on top of residue and concatenates.
|
||||
expect(agentInputLineClearedSeededDraft(null, SEEDED)).toBe(false)
|
||||
expect(agentInputLineClearedSeededDraft('unparseable', SEEDED)).toBe(false)
|
||||
})
|
||||
|
||||
it('reports not-cleared while the draft is still on the line', () => {
|
||||
expect(agentInputLineClearedSeededDraft(screenHoldingDraft, SEEDED)).toBe(false)
|
||||
it('reads an empty prompt through serializer ANSI', () => {
|
||||
expect(agentInputLineCleared(`[2m❯[0m `)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,102 +1,48 @@
|
||||
// Choosing how a chat send lands when the agent's TUI input line still holds a
|
||||
// launch-context draft that Orca itself injected.
|
||||
//
|
||||
// The point of the plan: Orca INJECTED that text, so when the input line still
|
||||
// holds exactly what was injected, the buffer already IS the message. The send
|
||||
// is then just the submit key — no clear, no paste, nothing that can
|
||||
// concatenate, and multi-line submits as one turn with no buffer surgery.
|
||||
|
||||
import { buildAgentTuiClearInputForText } from '../../../../shared/agent-tui-input-clear'
|
||||
import { stripScrollbackAnsi } from './native-chat-scrape-fallback'
|
||||
|
||||
export type NativeChatLaunchDraftSendPlan =
|
||||
/** Input line already holds exactly this text — press Enter and nothing else. */
|
||||
| { kind: 'submit-in-place' }
|
||||
/** 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 COMPOSER_PROMPT_LINE = /^\s*[❯›]\s?(.*)$/
|
||||
|
||||
/**
|
||||
* A short prefix of the draft's first line. Deliberately SHORT: the input line
|
||||
* wraps long text, and a fingerprint spanning a wrap would go unmatched — so a
|
||||
* long one would report "gone" for a draft that is still sitting there.
|
||||
* Whether the rendered composer prompt is observably empty. Placeholder text,
|
||||
* unrelated edits, and unreadable screens are all unconfirmed.
|
||||
*/
|
||||
const DRAFT_FINGERPRINT_LENGTH = 12
|
||||
|
||||
function seededDraftFingerprint(seededText: string): string {
|
||||
const firstLine = seededText.split(/\r\n|\r|\n/).find((line) => line.trim() !== '') ?? ''
|
||||
return firstLine.trim().slice(0, DRAFT_FINGERPRINT_LENGTH)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the agent's rendered input line still shows the injected draft.
|
||||
* `null` means it could not be observed at all (no screen, no locatable prompt
|
||||
* line, no usable fingerprint) — callers must treat that as "unknown" and pick
|
||||
* whichever branch is safe when wrong, never as evidence either way.
|
||||
*
|
||||
* Both TUIs redraw their placeholder once the buffer is genuinely empty, so a
|
||||
* `false` here is a real observation rather than an assumption that a write landed.
|
||||
*/
|
||||
export function agentInputLineShowsSeededDraft(
|
||||
screen: string | null | undefined,
|
||||
seededText: string
|
||||
): boolean | null {
|
||||
export function agentInputLineCleared(screen: string | null | undefined): boolean {
|
||||
if (!screen) {
|
||||
return null
|
||||
}
|
||||
const fingerprint = seededDraftFingerprint(seededText)
|
||||
if (fingerprint === '') {
|
||||
return null
|
||||
return false
|
||||
}
|
||||
const lines = stripScrollbackAnsi(screen).split('\n')
|
||||
let promptIndex = -1
|
||||
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
||||
if (COMPOSER_PROMPT_LINE.test(lines[index]!)) {
|
||||
promptIndex = index
|
||||
break
|
||||
const match = COMPOSER_PROMPT_LINE.exec(lines[index]!)
|
||||
if (match) {
|
||||
return match[1]!.trim() === ''
|
||||
}
|
||||
}
|
||||
if (promptIndex < 0) {
|
||||
return null
|
||||
}
|
||||
// Only the input line downward: the same text sitting in the transcript above
|
||||
// is a sent turn, not residue, and must not be read as a still-parked draft.
|
||||
return lines.slice(promptIndex).join('\n').includes(fingerprint)
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Submitting in place is only safe on POSITIVE evidence that the draft is still
|
||||
* on the input line. The user may have cleared or edited that line directly in
|
||||
* terminal view while the composer copy lived on, and pressing Enter on a buffer
|
||||
* that no longer holds the draft would submit the wrong thing (or nothing at
|
||||
* all) and silently lose the message. Anything short of "yes, it is still there"
|
||||
* falls through to replace-draft, which is correct in every state.
|
||||
* 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
|
||||
text: string
|
||||
hasImages: boolean
|
||||
readScreen: () => string | null | undefined
|
||||
}): NativeChatLaunchDraftSendPlan {
|
||||
const seededText = args.seededText
|
||||
if (!seededText || seededText.trim() === '') {
|
||||
return { kind: 'default' }
|
||||
}
|
||||
// Images must be pasted, so they can never submit in place — but they still
|
||||
// need the multi-line clear, since the image path's own single Ctrl+U would
|
||||
// leave every earlier line of the draft to glue onto the message.
|
||||
if (
|
||||
!args.hasImages &&
|
||||
args.text === seededText &&
|
||||
agentInputLineShowsSeededDraft(args.readScreen(), seededText) === true
|
||||
) {
|
||||
return { kind: 'submit-in-place' }
|
||||
}
|
||||
return {
|
||||
kind: 'replace-draft',
|
||||
clearInput: buildAgentTuiClearInputForText(seededText),
|
||||
@@ -104,26 +50,12 @@ export function planNativeChatLaunchDraftSend(args: {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Did the clear empty the input line? Only a positive observation that the draft
|
||||
* is GONE counts; unknown stays uncleared so the caller widens to a maximal
|
||||
* burst. A wasted burst is harmless (measured), a missed one concatenates.
|
||||
*/
|
||||
export function agentInputLineClearedSeededDraft(
|
||||
screen: string | null | undefined,
|
||||
seededText: string
|
||||
): boolean {
|
||||
return agentInputLineShowsSeededDraft(screen, seededText) === false
|
||||
}
|
||||
|
||||
/** 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
|
||||
text: string
|
||||
hasImages: boolean
|
||||
readScreen: () => string | null | undefined
|
||||
}): {
|
||||
plan: NativeChatLaunchDraftSendPlan
|
||||
@@ -135,12 +67,7 @@ export function resolveNativeChatLaunchDraftSend(args: {
|
||||
// a buffer that no longer holds it.
|
||||
const seededText =
|
||||
launchDraft && launchDraft.agent === agent && !launchDraftResolved ? launchDraft.text : null
|
||||
const plan = planNativeChatLaunchDraftSend({
|
||||
seededText,
|
||||
text: args.text,
|
||||
hasImages: args.hasImages,
|
||||
readScreen
|
||||
})
|
||||
const plan = planNativeChatLaunchDraftSend({ seededText })
|
||||
if (plan.kind !== 'replace-draft') {
|
||||
return { plan, sendOptions: undefined }
|
||||
}
|
||||
@@ -148,7 +75,7 @@ export function resolveNativeChatLaunchDraftSend(args: {
|
||||
plan,
|
||||
sendOptions: {
|
||||
clearInput: plan.clearInput,
|
||||
confirmCleared: () => agentInputLineClearedSeededDraft(readScreen(), plan.seededText)
|
||||
confirmCleared: () => agentInputLineCleared(readScreen())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-17
@@ -1,5 +1,5 @@
|
||||
// Send-path behaviour when a launch-context draft is still parked on the agent's
|
||||
// TUI input line: submit-in-place, the multi-line clear, and the confirm step.
|
||||
// TUI input line: the multi-line clear and its confirmation step.
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
@@ -16,8 +16,7 @@ import {
|
||||
NATIVE_CHAT_SUBMIT_DELAY_MS,
|
||||
resetNativeChatPtySendQueuesForTests,
|
||||
sendNativeChatMessage,
|
||||
sendNativeChatMessageWithImageAttachments,
|
||||
submitNativeChatDraftInPlace
|
||||
sendNativeChatMessageWithImageAttachments
|
||||
} from './native-chat-runtime-send'
|
||||
import { buildNativeChatPasteBytes, NATIVE_CHAT_SUBMIT } from './native-chat-send'
|
||||
import {
|
||||
@@ -42,20 +41,6 @@ afterEach(() => {
|
||||
resetNativeChatPtySendQueuesForTests()
|
||||
})
|
||||
|
||||
describe('submitNativeChatDraftInPlace', () => {
|
||||
it('writes ONLY the submit key — no clear, no body', () => {
|
||||
// The buffer already holds the message, so anything else would either wipe
|
||||
// it or paste a second copy alongside it.
|
||||
submitNativeChatDraftInPlace(SETTINGS, PTY)
|
||||
expect(writes()).toEqual([NATIVE_CHAT_SUBMIT])
|
||||
})
|
||||
|
||||
it('does not write the clear byte at all', () => {
|
||||
submitNativeChatDraftInPlace(SETTINGS, PTY)
|
||||
expect(writes()).not.toContain(NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT)
|
||||
})
|
||||
})
|
||||
|
||||
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)
|
||||
|
||||
@@ -49,7 +49,7 @@ 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 free of the injected draft.
|
||||
* 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.
|
||||
@@ -109,27 +109,6 @@ function clearConfirmDurationMs(options?: NativeChatSendOptions): number {
|
||||
return options?.confirmCleared ? NATIVE_CHAT_CLEAR_CONFIRM_MS : 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit what the agent's input line ALREADY holds — Enter and nothing else.
|
||||
*
|
||||
* Used when the composer still holds exactly the launch draft Orca injected, so
|
||||
* the TUI buffer is already the message. No clear and no paste means nothing can
|
||||
* concatenate and a multi-line draft submits as one turn for free (measured: a
|
||||
* bare CR submits a multi-line buffer whole on both agents).
|
||||
*/
|
||||
export function submitNativeChatDraftInPlace(
|
||||
settings: RuntimeSettings,
|
||||
ptyId: string
|
||||
): NativeChatSendHandle {
|
||||
return enqueueNativeChatPtySend(ptyId, 0, ({ isCancelled, markSubmitted }) => {
|
||||
if (isCancelled()) {
|
||||
return
|
||||
}
|
||||
sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT)
|
||||
markSubmitted()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Chat message path:
|
||||
* 1. clear any unsubmitted TUI line
|
||||
|
||||
@@ -33,9 +33,8 @@ describe('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. The send path no
|
||||
// longer pastes on top of the prefill (it submits the buffer in place, or
|
||||
// clears every line first), so there is nothing left to glue.
|
||||
// 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 })
|
||||
|
||||
|
||||
@@ -12,10 +12,7 @@ export function seedNativeChatLaunchDraftForAgentTab(args: {
|
||||
agent: TuiAgent
|
||||
text: string
|
||||
}): void {
|
||||
// Multi-line drafts mirror too: the chat send no longer pastes on top of the
|
||||
// parked prefill. It submits the TUI buffer in place when the composer still
|
||||
// holds exactly what was injected, and otherwise clears every line before its
|
||||
// body (agent-tui-input-clear.ts), so earlier lines cannot glue onto the message.
|
||||
// Multi-line drafts are safe because chat clears every parked line before send.
|
||||
if (args.text.trim().length === 0 || !isNativeChatSupportedAgent(args.agent)) {
|
||||
return
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user