mirror of
https://github.com/stablyai/orca.git
synced 2026-10-22 08:03:37 +00:00
* 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
85 lines
3.6 KiB
TypeScript
85 lines
3.6 KiB
TypeScript
import type { RpcClient } from '../transport/rpc-client'
|
|
import { buildMobileImagePastePayload } from './mobile-clipboard-image'
|
|
import {
|
|
MOBILE_NATIVE_CHAT_MIN_WRITE_TIMEOUT_MS,
|
|
openMobileNativeChatSendBudget
|
|
} from './mobile-native-chat-send'
|
|
import { isTerminalSendRpcAccepted } from '../terminal/terminal-send-rpc-response'
|
|
|
|
// Give the agent TUI a beat to register each bracketed image paste before the
|
|
// message text + Enter arrive, so the image attaches instead of being treated as
|
|
// part of the prompt body (mirrors desktop's NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS).
|
|
export const MOBILE_NATIVE_CHAT_IMAGE_SETTLE_MS = 300
|
|
|
|
// Ctrl+U kills the agent's unsubmitted input line. Sent before pasting so a retry
|
|
// after a rejected body/Enter can't leave a stale image paste that then rides along
|
|
// with (and duplicates) the next attempt — matches desktop clearUnsubmittedAgentInput.
|
|
const MOBILE_NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT = '\x15'
|
|
|
|
type MobileTerminalClient = { id: string; type: 'mobile' }
|
|
|
|
type PasteImagesArgs = {
|
|
readonly client: Pick<RpcClient, 'sendRequest'>
|
|
readonly terminal: string
|
|
readonly deviceToken: string | null
|
|
readonly imagePaths: readonly string[]
|
|
/** 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
|
|
* path into the terminal as a bracketed paste (no Enter) — the same payload
|
|
* desktop native chat rides along on submit. The leading clear keeps a retry
|
|
* idempotent after a failed body/Enter. Returns false as soon as the host rejects
|
|
* one, so the caller can abort before Enter. */
|
|
export async function pasteMobileNativeChatImagePaths({
|
|
client,
|
|
terminal,
|
|
deviceToken,
|
|
imagePaths,
|
|
deadline: sharedDeadline,
|
|
clearInput
|
|
}: PasteImagesArgs): Promise<boolean> {
|
|
const mobileClient: MobileTerminalClient | null = deviceToken
|
|
? { id: deviceToken, type: 'mobile' }
|
|
: null
|
|
const clientField = mobileClient ? { client: mobileClient } : {}
|
|
// Why: this is a sequential loop, so a per-write budget multiplies by the number
|
|
// of images — the composer stays `sending` the whole time. Budget the sequence
|
|
// once and let each write draw from what's left.
|
|
const deadline = sharedDeadline ?? openMobileNativeChatSendBudget()
|
|
for (const text of [
|
|
clearInput ?? MOBILE_NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT,
|
|
...imagePaths.map(buildMobileImagePastePayload)
|
|
]) {
|
|
const remainingMs = deadline - Date.now()
|
|
// Why: the budget is the whole sequence's — starting a write it can't fund would
|
|
// let a multi-image paste overrun before the text body even begins its own send.
|
|
// Abort instead; the caller reports the failure and can retry.
|
|
if (remainingMs < MOBILE_NATIVE_CHAT_MIN_WRITE_TIMEOUT_MS) {
|
|
return false
|
|
}
|
|
const response = await client.sendRequest(
|
|
'terminal.send',
|
|
{
|
|
terminal,
|
|
text,
|
|
enter: false,
|
|
...clientField
|
|
},
|
|
// The remaining budget covers the reconnect wait too; a fresh post-connect
|
|
// clock here would let one write outlast the whole sequence's ceiling.
|
|
{ timeoutMs: remainingMs, budgetSpansConnect: true }
|
|
)
|
|
if (!isTerminalSendRpcAccepted(response)) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|