fix(native-chat): a prompt card owns the chat input until its answer lands (terminal-backed chat, desktop and phone) (#25761)

* fix(native-chat): an answerable prompt card owns the chat input until its answer lands

* fix(mobile): a terminal chat's composer waits while its prompt card is up

* test(native-chat): type the prompt card fixtures without casts

* fix(native-chat): scope replies to acknowledged prompt occurrences

* fix(native-chat): preserve answer ordering and verified delivery

* test(native-chat): keep mock RPC client inside test boundary

* test(native-chat): place mock fixtures in the test-only scope

* Keep runtime comments within the module size limit

* test: preserve prompt delivery coverage in desktop CI

* Treat an older host's accepted write as delivered

A newer desktop or phone talking to a host that predates the write
settlement field read every accepted reply as "unconfirmed". Prompt cards
never dismissed, the phone showed "Response unconfirmed" on every tap and
ordinary chat messages were held as "Delivery unconfirmed".

The reader now uses writeSettlement when present and otherwise keeps the
host's whole-write accepted/refused verdict, exactly as before this branch.
Only prompt answers ask for provider settlement; ordinary callers
(follow-up delivery, paste drafts, option commands, composer sends) are
back on the original contract, so the legacy-handoff error class, its
flag, the sequence-only send helper and the mobile handoff hook are gone.

* Keep terminal-pane Escape on the plain accepted write

Every pane's Escape/Ctrl+C goes through pty:writeAccepted. This branch had
switched that IPC to wait for provider settlement, which dropped the
"remount this pane" signal for a daemon session awaiting recovery and could
stall later keystrokes behind a slow daemon acknowledgment.

pty:writeAccepted is back to its original local-only, synchronous write.
Prompt answers opt into settlement with requireWriteSettlement on the same
channel, and a settled refusal while the daemon recovers now sends the same
remount signal. Ordinary verified sends regain their original fallback write.

* Report a partly accepted local paste as unconfirmed

A settled local write split into chunks returned plain false when a later
chunk was refused after earlier ones were accepted. Callers read false as
"nothing was written", so chat showed "Message not sent" with a prefix
already in the agent's input. It now reports the write as unconfirmed,
the same verdict the paired host gives for a partial write.

* Hide the chat composer under a prompt card instead of unmounting it

When an approval or question card took the input region, the composer
unmounted. A message still waiting for its Enter was cancelled and its
bubble deleted after the draft had already been cleared, so the message
vanished without a notice; composer history was also wiped each time.

The composer now stays mounted but hidden while a card owns input, so its
state survives. A send that has not submitted yet is still stopped (its
Enter would answer the card), but its bubble stays with "Message not sent"
so the text is not lost. The composer ref is detached while hidden, so
root typing, paste and reveal focus never reach it.

* Keep an answered prompt hidden after the chat view remounts

The "answered" dismissal lived in component state. Toggling chat to
terminal and back, a PTY reconnect, or leaving the phone session and coming
back while the approved tool was still running brought the answered
approval back, and it then took over the input again.

Desktop now keeps the answered occurrence per pane outside the view;
phone keeps it per chat tab outside the controller. Both still retire it
when the pane observes the prompt clear or change, desktop also when the
tab retires, and both maps are size-bounded.

* Update the prompt-reply reliability gate for the review fixes

Older hosts' accepted answers now dismiss like acknowledged ones, the
composer stays mounted under a card, and answered prompts survive a view
remount. The gate's invariant, oracle, assertion list, new test files and
the two latest evidence runs now describe that contract.

* Let users hide a prompt card, keep Escape from denying, and gate only Send on the phone

The chat input could stay locked behind a card the host never closes (for
example after a Deny typed in the terminal), and Escape on a focused
approval card denied the tool even when the user meant to close a picker.

- A Hide control (chevron) on terminal approval and question cards, desktop
  and phone, hides that prompt occurrence and gives the input back. It writes
  nothing to the agent and uses the same per-occurrence dismissal as an
  acknowledged answer, so a new occurrence shows the card again.
- On desktop, Escape on a card now does the same Hide instead of Deny, and a
  card that appears while the user is typing no longer takes focus.
- On the phone, a card blocks only Send: typing, dictation and image attach
  keep working on the draft. The placeholder is back to the normal one.

* Fix two comments that still called older-host replies unconfirmed

Since an older host's accepted write now counts as delivered, the
requireWriteSettlement comment and the reliability gate's oracle said the
opposite of the code. Both now describe the current rule.

* Collapse prompt cards to a strip instead of hiding them, and close the round-2 gaps

Hide removed a card completely, so nothing on screen said a prompt was still
waiting, and several edges let the chat type into a live prompt.

- Collapse (the header chevron, or Escape on desktop) folds the card to a
  one-line strip above the composer; the strip's chevron expands it back.
  Collapsing writes nothing, frees the composer, and is disabled while an
  answer is still being written. Each pane or tab keeps the occurrence as
  answered or collapsed, so a remount restores the same view.
- Questions now carry the host wait's start like approvals, so an identical
  question in a new wait shows again (desktop and phone). A transcript-only
  prompt, which has no wait start, is dropped when the view stops observing
  it, and a transcript still loading no longer clears a dismissal.
- Desktop: while a card owns the input, the hidden composer cannot send or
  interrupt even if it still has keyboard focus, and the card takes focus in
  the same commit. A send the card retires no longer types Ctrl+U under it.
- Phone: an Ask hides the heuristic card read from the same waiting status,
  and the dismissal store is scoped by host, worktree and tab.

* Keep a collapsed card's partial answer, and scope its focus to its own pane

Collapsing a question card unmounted it, so expanding it again lost the
chosen step, selections and typed "Other" text; Escape typed in that text
field collapsed the card. A card arriving while the user typed in another
surface (sidebar, notes, a browser URL bar) also took the keyboard.

- The collapsed card now stays mounted but hidden (and inert on desktop)
  under its strip, on desktop and phone, so a partial answer survives
  collapse and expand. Escape inside the card's text field no longer
  collapses it. The question card shows the same focus ring as the approval
  card.
- A card takes focus only from inside its own pane (its hidden composer) or
  from the page body, never from a text field elsewhere.
- Desktop and phone share one dismissal store in src/shared, bounded by the
  existing scope-cache helper, which moves to src/shared with it.
- The card send imports the verified helper from its own module, and the
  phone files are split so each name matches its contents (header action,
  strip, lane selector).

* Return focus to the composer after a prompt card collapses

Since a collapsed card stays mounted, Escape or the chevron left keyboard
focus inside the now hidden, inert card. The composer's reveal-focus took
that as focus already in the pane and stood down, then the browser dropped
focus to the page body, so typed keys went nowhere.

Reveal-focus now treats focus inside a hidden or inert subtree as not in the
pane and focuses the composer. On the phone, collapsing a card also
dismisses the keyboard so a hidden reply field does not keep it.

* Keep the question card's collapse chevron beside its Cancel button

The question card header spread its three items with justify-between, which
put the new chevron in the middle of the header. The title now takes the
free space, as in the approval card, so the chevron sits next to Cancel at
the right edge.

* Run the prompt tests on the merged main

Main now runs Vitest under Bun, which resolves a long data: URL import as a
package name, so the SSH delivery test loads its bundled mobile module from a
temp file instead. The phone prompt harnesses mock the live line that main's
view now renders, and add Platform, which main's text-selection helper reads,
the same way main's own view tests do.
This commit is contained in:
Brennan Benson
2026-10-06 10:57:58 -07:00
committed by GitHub
parent ccab2a3110
commit 468e4e1167
99 changed files with 4868 additions and 720 deletions
+226 -1
View File
@@ -10,6 +10,231 @@
}
},
"gates": [
{
"id": "terminal-input.prompt-reply-occurrence-acknowledgment",
"title": "Terminal chat replies hide only the acknowledged prompt occurrence",
"maturity": "experimental",
"protection": "partial",
"owner": "terminal-runtime",
"layer": "renderer-mobile-provider-contract",
"surfaces": [
"desktop terminal chat prompt cards",
"mobile terminal chat composer",
"verified PTY input and paced answers"
],
"platforms": ["macos", "linux", "windows"],
"providers": ["local", "daemon", "ssh", "wsl", "remote-runtime", "mobile-relay"],
"coveredPlatforms": ["macos"],
"coveredProviders": ["local", "daemon", "ssh", "remote-runtime", "mobile-relay"],
"coverageNotes": "Production mobile controller, parser, presentation, overlay, view, question, permission and composer run with native surfaces mocked on macOS. Desktop selector groups traverse the real observed-write queue, verified helper, IPC chunker, SSH provider and transport callbacks; paired sends traverse the real dispatcher and runtime writer into that provider. Local/daemon contracts and released-version terminal codecs are covered. Provider labels describe contracts, not physical deployments; physical WSL/Linux/Windows remain gaps.",
"motivatingLinks": ["https://github.com/stablyai/orca/pull/25761"],
"invariant": "A prompt occurrence leaves the input region only when an accepted reply answers it (a current host's provider acknowledgment, or an older host's whole-write acceptance as before) or the user collapses it to a strip above the composer, which writes nothing and keeps the card mounted with its partial answer; status-backed occurrences are told apart by the host wait's start, so view toggles and remounts preserve them while real clear/replacement supersedes them, and a transcript-only one is dropped when the view stops observing it; obsolete results cannot change replacements; selector groups wait for complete writes before pacing; refusal or unknown handoff never causes replay; a card retires an unsubmitted composer send without writing under it or losing its text, and the hidden composer cannot send or interrupt; Collapse or Escape folds a card without writing, never during an in-flight answer, and a new occurrence shows expanded again; the phone blocks only Send while a card is expanded and shows one card per occurrence; cancellation stops unsent groups and active dictation can stop.",
"oracle": "After acknowledged phone replies with stale host status, the exact card disappears and Send is enabled; unknown, refused or unverifiable-settlement replies retain enabled choices, while an older host's accepted reply dismisses like an acknowledged one; a same-occurrence view toggle retains pending acknowledgment and real replacements discard it. A slow 32 KiB SSH answer completes all chunks before Enter; cancellation preserves issued bytes but omits remaining groups. Paired settlement invokes the actual provider and lost callbacks remain unknown; older hosts deliver ordinary messages, launch and selector groups once and their accepted answers dismiss as before. An answered card stays hidden across a view remount; a collapsed card is a strip that frees the composer and expands back; an identical question in a new wait shows again; a composer send caught by a new card keeps its bubble without submitting or clearing the line under it.",
"commands": [
"ORCA_BACKGROUND_LAUNCH=1 pnpm test src/renderer/src/components/native-chat/NativeChatInteractiveCard.test.tsx src/renderer/src/components/native-chat/use-native-chat-interactive-send.test.tsx src/renderer/src/components/native-chat/native-chat-observed-send.test.ts src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts src/renderer/src/components/native-chat/native-chat-runtime-send-launch-draft.test.ts src/renderer/src/components/native-chat/NativeChatResolvedView.prompt-card-input.test.tsx src/renderer/src/components/native-chat/NativeChatResolvedView.prompt-card-composer.test.tsx src/renderer/src/components/native-chat/use-native-chat-prompt-card-presentation.test.ts src/renderer/src/runtime/runtime-terminal-verified-input.test.ts src/renderer/src/components/native-chat/NativeChatComposer.test.tsx src/renderer/src/components/native-chat/NativeChatResolvedView.turn-status.test.tsx src/renderer/src/components/native-chat/NativeChatApprovalCard.test.tsx src/renderer/src/components/native-chat/native-chat-interactive-prompt.test.ts src/renderer/src/runtime/runtime-terminal-inspection.test.ts src/renderer/src/lib/agent-paste-draft.test.ts src/renderer/src/lib/agent-paste-draft-submit-retry.test.ts src/renderer/src/lib/agent-followup-delivery.test.ts src/main/ipc/pty/ipc/write-input-acceptance.test.ts src/main/ipc/pty/ipc/write-input-user-input.test.ts src/main/ipc/pty/ipc/write-input-chunk-yield.test.ts src/main/ipc/pty-write-ipc-validation.test.ts src/main/providers/ssh-pty-write.test.ts src/main/daemon/daemon-pty-write-settlement-recovery.test.ts src/main/runtime/runtime-terminal-writer.test.ts src/main/runtime/rpc/terminal-send.test.ts src/main/runtime/rpc/terminal-send-launch-draft-resolution.test.ts src/main/runtime/terminal-send-stale-leaf-liveness.test.ts src/renderer/src/lib/agent-paste-draft-readiness-budget.test.ts tests/e2e/native-chat-ssh-delivery.unit.test.ts --maxWorkers=2",
"ORCA_BACKGROUND_LAUNCH=1 mobile/node_modules/.bin/vitest run --config mobile/vitest.config.ts mobile/src/session/MobileNativeChatView.prompt-presentation.test.ts mobile/src/session/use-mobile-native-chat-controller.test.ts mobile/src/session/use-mobile-native-chat-ask-dismiss.test.ts mobile/src/session/MobileNativeChatView.test.ts mobile/src/session/mobile-prompt-controller.test.ts mobile/src/session/mobile-native-chat-permission-send.test.ts mobile/src/session/use-mobile-native-chat-cancel-ask.test.ts mobile/src/session/use-mobile-native-chat-answer-send.test.ts mobile/src/session/use-mobile-native-chat-message-send.test.ts mobile/src/session/mobile-native-chat-send.test.ts mobile/src/session/MobileNativeChatCardHeaderAction.test.tsx mobile/src/session/MobileNativeChatPromptCard.test.tsx --maxWorkers=2",
"ORCA_BACKGROUND_LAUNCH=1 pnpm test tests/e2e/cross-version-wire/cross-version-terminal-wire.unit.test.ts --maxWorkers=1",
"ORCA_BACKGROUND_LAUNCH=1 pnpm test src/renderer/src/components/native-chat/NativeChatInteractiveCard.test.tsx src/renderer/src/components/native-chat/use-native-chat-interactive-send.test.tsx src/renderer/src/components/native-chat/native-chat-observed-send.test.ts src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts src/renderer/src/components/native-chat/native-chat-runtime-send-launch-draft.test.ts src/renderer/src/components/native-chat/NativeChatResolvedView.prompt-card-input.test.tsx src/renderer/src/components/native-chat/NativeChatResolvedView.turn-status.test.tsx src/renderer/src/components/native-chat/NativeChatApprovalCard.test.tsx src/renderer/src/components/native-chat/native-chat-interactive-prompt.test.ts src/renderer/src/runtime/runtime-terminal-inspection.test.ts src/renderer/src/lib/agent-paste-draft.test.ts src/renderer/src/lib/agent-paste-draft-submit-retry.test.ts src/renderer/src/lib/agent-followup-delivery.test.ts src/main/ipc/pty/ipc/write-input-acceptance.test.ts src/main/ipc/pty/ipc/write-input-user-input.test.ts src/main/ipc/pty/ipc/write-input-chunk-yield.test.ts src/main/ipc/pty-write-ipc-validation.test.ts src/main/providers/ssh-pty-write.test.ts src/main/daemon/daemon-pty-write-settlement-recovery.test.ts --maxWorkers=2",
"ORCA_BACKGROUND_LAUNCH=1 mobile/node_modules/.bin/vitest run --config mobile/vitest.config.ts mobile/src/session/MobileNativeChatView.prompt-presentation.test.ts mobile/src/session/use-mobile-native-chat-controller.test.ts mobile/src/session/use-mobile-native-chat-ask-dismiss.test.ts mobile/src/session/MobileNativeChatView.test.ts --maxWorkers=2"
],
"testFiles": [
"src/renderer/src/components/native-chat/NativeChatInteractiveCard.test.tsx",
"src/renderer/src/components/native-chat/use-native-chat-interactive-send.test.tsx",
"src/renderer/src/components/native-chat/native-chat-observed-send.test.ts",
"src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts",
"src/renderer/src/components/native-chat/native-chat-runtime-send-launch-draft.test.ts",
"src/renderer/src/components/native-chat/NativeChatResolvedView.prompt-card-input.test.tsx",
"src/renderer/src/components/native-chat/NativeChatResolvedView.prompt-card-composer.test.tsx",
"src/renderer/src/components/native-chat/use-native-chat-prompt-card-presentation.test.ts",
"src/renderer/src/runtime/runtime-terminal-verified-input.test.ts",
"src/renderer/src/components/native-chat/NativeChatComposer.test.tsx",
"src/renderer/src/components/native-chat/NativeChatResolvedView.turn-status.test.tsx",
"src/renderer/src/components/native-chat/NativeChatApprovalCard.test.tsx",
"src/renderer/src/components/native-chat/native-chat-interactive-prompt.test.ts",
"src/renderer/src/runtime/runtime-terminal-inspection.test.ts",
"src/renderer/src/lib/agent-paste-draft.test.ts",
"src/renderer/src/lib/agent-paste-draft-submit-retry.test.ts",
"src/renderer/src/lib/agent-followup-delivery.test.ts",
"src/main/ipc/pty/ipc/write-input-acceptance.test.ts",
"src/main/ipc/pty/ipc/write-input-user-input.test.ts",
"src/main/ipc/pty/ipc/write-input-chunk-yield.test.ts",
"src/main/ipc/pty-write-ipc-validation.test.ts",
"src/main/providers/ssh-pty-write.test.ts",
"src/main/daemon/daemon-pty-write-settlement-recovery.test.ts",
"src/main/runtime/runtime-terminal-writer.test.ts",
"src/main/runtime/rpc/terminal-send.test.ts",
"src/main/runtime/rpc/terminal-send-launch-draft-resolution.test.ts",
"src/main/runtime/terminal-send-stale-leaf-liveness.test.ts",
"src/renderer/src/lib/agent-paste-draft-readiness-budget.test.ts",
"tests/e2e/native-chat-ssh-delivery.unit.test.ts",
"mobile/src/session/MobileNativeChatView.prompt-presentation.test.ts",
"mobile/src/session/use-mobile-native-chat-controller.test.ts",
"mobile/src/session/use-mobile-native-chat-ask-dismiss.test.ts",
"mobile/src/session/MobileNativeChatView.test.ts",
"mobile/src/session/mobile-prompt-controller.test.ts",
"mobile/src/session/mobile-native-chat-permission-send.test.ts",
"mobile/src/session/use-mobile-native-chat-cancel-ask.test.ts",
"mobile/src/session/use-mobile-native-chat-answer-send.test.ts",
"mobile/src/session/use-mobile-native-chat-message-send.test.ts",
"mobile/src/session/mobile-native-chat-send.test.ts",
"mobile/src/session/MobileNativeChatCardHeaderAction.test.tsx",
"mobile/src/session/MobileNativeChatPromptCard.test.tsx",
"tests/e2e/cross-version-wire/cross-version-terminal-wire.unit.test.ts"
],
"assertionRefs": [
{
"file": "src/renderer/src/components/native-chat/NativeChatInteractiveCard.test.tsx",
"assertions": [
"ignores approval A settling after answered replacement B",
"ignores a raw acknowledgment from a rebound PTY",
"sends only one cancellation, can cancel an answer, and permits retry after unknown"
]
},
{
"file": "mobile/src/session/MobileNativeChatView.prompt-presentation.test.ts",
"assertions": [
"accepted Deny hides exactly that occurrence and restores Send across reconnect",
"a late answer cannot reshow an answered replacement occurrence",
"a late answer cannot dismiss a replacement session"
]
},
{
"file": "src/main/ipc/pty/ipc/write-input-acceptance.test.ts",
"assertions": [
"reports proven refusal and unknown acknowledgment separately",
"waits for each chunk and stops a paste after refusal",
"asks a pane awaiting daemon recovery to remount on either route"
]
},
{
"file": "mobile/src/session/mobile-prompt-controller.test.ts",
"assertions": [
"keeps a question actionable after lost or unverifiable acknowledgment",
"acknowledges the same pending permission or Ask after a view-only toggle",
"keeps an acknowledged Deny hidden after the chat screen remounts on a lingering status",
"drops an accepted result after real prompt/session/PTY/tab/clear replacement",
"finishes an older-host selector once and dismisses it like an acknowledged answer",
"dismisses older-host approval choices like an acknowledged answer and restores Send",
"collapses a permission to a strip without writing, and expands it back",
"shows an identical heuristic question again after a new wait in terminal view",
"collapses an ask without writing and hides the heuristic card read from the same wait"
]
},
{
"file": "tests/e2e/native-chat-ssh-delivery.unit.test.ts",
"assertions": [
"serializes every chunk of a healthy large SSH answer before Enter and dismissal",
"cancels remaining paced groups while an already-issued body still settles",
"paired terminal.send uses provider settlement for accepted and lost acknowledgment",
"older paired hosts keep their whole-write verdict for answers and ordinary sequences",
"older paired hosts confirm ordinary chat sends and pasted answers as before",
"finishes every older-host selector group once and settles the answer as delivered"
]
},
{
"file": "src/renderer/src/components/native-chat/NativeChatResolvedView.prompt-card-composer.test.tsx",
"assertions": [
"keeps an in-flight message visible and never submits it into the approval",
"routes no typing to the hidden composer until the card is answered"
]
},
{
"file": "src/renderer/src/components/native-chat/NativeChatResolvedView.prompt-card-input.test.tsx",
"assertions": [
"keeps an answered approval hidden across a chat view remount and a PTY rebind",
"collapses to a strip that frees the composer, and expanding gives the card the input back",
"shows an identical question again in a new wait that arrived while the view was unmounted",
"keeps a collapsed transcript question collapsed while the transcript re-reads",
"keeps a partly answered question across collapse and expand, and Escape in its text field"
]
},
{
"file": "src/main/runtime/runtime-terminal-writer.test.ts",
"assertions": [
"waits for complete body settlement before Enter",
"does not send the suffix after refusal or unknown settlement",
"returns unknown after an acknowledged prefix followed by refusal or bookkeeping failure",
"preserves legacy raw body and Enter writes"
]
}
],
"evidenceRuns": [
{
"date": "2026-10-05",
"runner": "local",
"platform": "macos",
"command": "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/renderer/src/components/native-chat/NativeChatInteractiveCard.test.tsx src/renderer/src/components/native-chat/use-native-chat-interactive-send.test.tsx src/renderer/src/components/native-chat/native-chat-observed-send.test.ts src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts src/renderer/src/components/native-chat/native-chat-runtime-send-launch-draft.test.ts src/renderer/src/components/native-chat/NativeChatResolvedView.prompt-card-input.test.tsx src/renderer/src/components/native-chat/NativeChatResolvedView.turn-status.test.tsx src/renderer/src/components/native-chat/NativeChatApprovalCard.test.tsx src/renderer/src/components/native-chat/native-chat-interactive-prompt.test.ts src/renderer/src/runtime/runtime-terminal-inspection.test.ts src/renderer/src/lib/agent-paste-draft.test.ts src/renderer/src/lib/agent-paste-draft-submit-retry.test.ts src/renderer/src/lib/agent-followup-delivery.test.ts src/main/ipc/pty/ipc/write-input-acceptance.test.ts src/main/ipc/pty/ipc/write-input-user-input.test.ts src/main/ipc/pty/ipc/write-input-chunk-yield.test.ts src/main/ipc/pty-write-ipc-validation.test.ts src/main/providers/ssh-pty-write.test.ts src/main/daemon/daemon-pty-write-settlement-recovery.test.ts --maxWorkers=2",
"result": "passed",
"durationSeconds": 232.36,
"summary": "257 tests passed across 19 explicit files, covering production cards, observed sends, existing launch/paste callers, IPC validation, SSH acknowledgment and daemon settlement recovery."
},
{
"date": "2026-10-05",
"runner": "local",
"platform": "macos",
"command": "ORCA_BACKGROUND_LAUNCH=1 mobile/node_modules/.bin/vitest run --config mobile/vitest.config.ts mobile/src/session/MobileNativeChatView.prompt-presentation.test.ts mobile/src/session/use-mobile-native-chat-controller.test.ts mobile/src/session/use-mobile-native-chat-ask-dismiss.test.ts mobile/src/session/MobileNativeChatView.test.ts --maxWorkers=2",
"result": "passed",
"durationSeconds": 10.32,
"summary": "73 tests passed across four explicit files after the controller extraction, covering the production view/card/composer, occurrence dismissal, controller session scoping, unknown/refused replies and stopping active dictation."
},
{
"date": "2026-10-06",
"runner": "local",
"platform": "macos",
"command": "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/renderer/src/components/native-chat/NativeChatInteractiveCard.test.tsx src/renderer/src/components/native-chat/use-native-chat-interactive-send.test.tsx src/renderer/src/components/native-chat/native-chat-observed-send.test.ts src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts src/renderer/src/components/native-chat/native-chat-runtime-send-launch-draft.test.ts src/renderer/src/components/native-chat/NativeChatResolvedView.prompt-card-input.test.tsx src/renderer/src/components/native-chat/NativeChatResolvedView.prompt-card-composer.test.tsx src/renderer/src/components/native-chat/use-native-chat-prompt-card-presentation.test.ts src/renderer/src/runtime/runtime-terminal-verified-input.test.ts src/renderer/src/components/native-chat/NativeChatComposer.test.tsx src/renderer/src/components/native-chat/NativeChatResolvedView.turn-status.test.tsx src/renderer/src/components/native-chat/NativeChatApprovalCard.test.tsx src/renderer/src/components/native-chat/native-chat-interactive-prompt.test.ts src/renderer/src/runtime/runtime-terminal-inspection.test.ts src/renderer/src/lib/agent-paste-draft.test.ts src/renderer/src/lib/agent-paste-draft-submit-retry.test.ts src/renderer/src/lib/agent-followup-delivery.test.ts src/main/ipc/pty/ipc/write-input-acceptance.test.ts src/main/ipc/pty/ipc/write-input-user-input.test.ts src/main/ipc/pty/ipc/write-input-chunk-yield.test.ts src/main/ipc/pty-write-ipc-validation.test.ts src/main/providers/ssh-pty-write.test.ts src/main/daemon/daemon-pty-write-settlement-recovery.test.ts src/main/runtime/runtime-terminal-writer.test.ts src/main/runtime/rpc/terminal-send.test.ts src/main/runtime/rpc/terminal-send-launch-draft-resolution.test.ts src/main/runtime/terminal-send-stale-leaf-liveness.test.ts src/renderer/src/lib/agent-paste-draft-readiness-budget.test.ts tests/e2e/native-chat-ssh-delivery.unit.test.ts --maxWorkers=2",
"result": "passed",
"durationSeconds": 14.53,
"summary": "391 tests passed across 29 explicit files after the round-4 review fix, including older-host acceptance, the inert hidden composer, collapse that keeps a partial answer and returns focus to the composer, pane-scoped card focus, wait-start occurrence identity and the pane remount signal."
},
{
"date": "2026-10-06",
"runner": "local",
"platform": "macos",
"command": "ORCA_BACKGROUND_LAUNCH=1 mobile/node_modules/.bin/vitest run --config mobile/vitest.config.ts mobile/src/session/MobileNativeChatView.prompt-presentation.test.ts mobile/src/session/use-mobile-native-chat-controller.test.ts mobile/src/session/use-mobile-native-chat-ask-dismiss.test.ts mobile/src/session/MobileNativeChatView.test.ts mobile/src/session/mobile-prompt-controller.test.ts mobile/src/session/mobile-native-chat-permission-send.test.ts mobile/src/session/use-mobile-native-chat-cancel-ask.test.ts mobile/src/session/use-mobile-native-chat-answer-send.test.ts mobile/src/session/use-mobile-native-chat-message-send.test.ts mobile/src/session/mobile-native-chat-send.test.ts mobile/src/session/MobileNativeChatCardHeaderAction.test.tsx mobile/src/session/MobileNativeChatPromptCard.test.tsx --maxWorkers=2",
"result": "passed",
"durationSeconds": 1.19,
"summary": "186 tests passed across 12 explicit files after the round-3 review fixes, including older-host answers dismissing like acknowledged ones, collapse that keeps the card mounted, one card per occurrence, wait-start question identity and only Send blocked."
},
{
"date": "2026-10-06",
"runner": "local",
"platform": "macos",
"command": "ORCA_BACKGROUND_LAUNCH=1 pnpm test tests/e2e/cross-version-wire/cross-version-terminal-wire.unit.test.ts --maxWorkers=1",
"result": "passed",
"durationSeconds": 25.68,
"summary": "10 terminal wire codec compatibility cases passed against current and two supported released source versions; this is a codec/RPC-reader contract, not a physical mixed-version paired deployment."
}
],
"runtimeBudget": {
"p95Seconds": 300,
"scope": "Focused desktop and mobile unit/contract commands on the shared machine; CI p95 is not established."
},
"flakeHistory": {
"status": "not-started",
"evidence": "Local regression runs passed; cross-platform CI soak has not started."
},
"redGreenEvidence": {
"status": "partial",
"evidence": "Immutable 8b2538 baseline reproduced Enter overtaking the SSH body and paired raw acceptance without provider settlement. Desired-behavior ablations replacing only baseline mobile sender/occurrence modules fail five of fourteen cases; replacing selector scheduling fails one of six; replacing paired runtime writer fails two of six. The entire gate has not been reverted, and rendered/platform soak remains absent."
},
"performanceBudget": {
"required": true,
"evidence": "No polling, subprocess, startup await, new status producer or dismissal timer. Reuses the existing bounded per-tab committed observation and per-PTY queue; waits for each group before its existing pacing timer. Duplicate-cancel and yielding chunk tests bound writes. Optional paired proof reuses provider settlement only for requesting clients; legacy requests keep their raw path."
},
"knownGaps": [
"Provider acknowledgment proves handoff, not agent consumption or prompt closure; a host prompt gate remains deferred pending captured closing lifecycles.",
"Rendered Electron and physical mobile/microphone behavior require remote QA; native surfaces are mocked in these unit tests.",
"Physical WSL, Linux/Windows, paired mixed-version deployment and network fault campaigns have not been exercised.",
"Older hosts provide whole-write handoff only; their accepted answers dismiss the card as before, without provider acknowledgment.",
"A transcript-only question has no wait start: after the view stops observing it, a collapse or answer is dropped and the card shows again.",
"Cancelling a paced answer stops future groups; bytes already handed to the provider, including pending chunks, can still settle."
],
"promotionCriteria": [
"Record change-reverted red/green evidence and repeatable remote rendered approval/question/dictation journeys.",
"Collect physical provider/platform and CI soak evidence before promotion."
],
"demotionRule": "Keep experimental until the missing evidence is collected; investigate failures without weakening occurrence, acknowledgment, cancellation or refusal assertions."
},
{
"id": "agent-status.claude-task-wakeup-cycle",
"title": "Claude task wake-ups cannot finish an automation before its lead turn",
@@ -8720,7 +8945,7 @@
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/shared/kill-all-terminal-surfaces.test.ts src/renderer/src/components/shared/useDaemonActions.test.tsx src/renderer/src/components/terminal/terminal-tab-actions-kill-all.test.ts src/main/ipc/pty-management.test.ts",
"result": "passed",
"durationSeconds": 1.31,
"durationSeconds": 1.18,
"summary": "4 test files and 27 tests passed locally, including the existing daemon handler contract and a real 100-terminal Zustand cleanup fixture."
}
],
+1 -1
View File
@@ -5757,7 +5757,7 @@
"src/renderer/src/components/native-chat/native-chat-composer-containment.test.ts": 38,
"src/renderer/src/components/native-chat/native-chat-composer-drop-scope.test.tsx": 1320,
"src/renderer/src/components/native-chat/native-chat-composer-reveal-focus.test.tsx": 622,
"src/renderer/src/components/native-chat/native-chat-composer-scope-cache.test.ts": 47,
"src/shared/native-chat-scope-cache.test.ts": 47,
"src/renderer/src/components/native-chat/native-chat-composer-state.test.ts": 98,
"src/renderer/src/components/native-chat/native-chat-composer-workspace-file-drop.test.tsx": 1742,
"src/renderer/src/components/native-chat/native-chat-context-usage-summary.test.ts": 54,
+15 -2
View File
@@ -4,6 +4,7 @@ import { Check } from 'lucide-react-native'
import type { AskAnswerSelection, AskPrompt } from '../../../src/shared/native-chat-ask'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
import { TEXT_INPUT_FONT_SIZE } from '../platform/text-input-font-size'
import { MobileNativeChatCardHeaderAction } from './MobileNativeChatCardHeaderAction'
type Props = {
prompt: AskPrompt
@@ -12,6 +13,8 @@ type Props = {
* option's stable number instead of pasted label text (STA-1860). */
onAnswer: (selections: AskAnswerSelection[]) => Promise<boolean>
onCancel?: () => Promise<boolean>
/** Fold the card to a strip and free Send, writing nothing. */
onCollapse?: () => void
}
// Sentinel index for the free-text "Other…" row (never a real option index).
@@ -21,7 +24,12 @@ const OTHER = -1
* question per step with tabs across the top, a Next button that advances (Send
* on the last step), and a Cancel that dismisses the prompt. Neutral styling
* with a subtle green accent on the active choice to match the rest of the app. */
export function MobileNativeChatAsk({ prompt, onAnswer, onCancel }: Props): React.JSX.Element {
export function MobileNativeChatAsk({
prompt,
onAnswer,
onCancel,
onCollapse
}: Props): React.JSX.Element {
const [index, setIndex] = useState(0)
const [selections, setSelections] = useState<number[][]>(() => prompt.questions.map(() => []))
const [otherText, setOtherText] = useState<string[]>(() => prompt.questions.map(() => ''))
@@ -127,7 +135,10 @@ export function MobileNativeChatAsk({ prompt, onAnswer, onCancel }: Props): Reac
) : null}
<ScrollView style={styles.scroll} keyboardShouldPersistTaps="always">
<Text style={styles.questionText}>{q.question}</Text>
<View style={styles.questionRow}>
<Text style={styles.questionText}>{q.question}</Text>
<MobileNativeChatCardHeaderAction onCollapse={onCollapse} disabled={submitting} />
</View>
{q.options.map((opt, optIndex) => (
<OptionRow
key={`${optIndex}:${opt.label}`}
@@ -275,7 +286,9 @@ const styles = StyleSheet.create({
scroll: {
paddingHorizontal: spacing.md
},
questionRow: { flexDirection: 'row', alignItems: 'flex-start', gap: spacing.sm },
questionText: {
flex: 1,
color: colors.textPrimary,
fontSize: typography.bodySize + 1,
fontWeight: '600',
@@ -0,0 +1,92 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { describe, expect, it, vi } from 'vitest'
import { Keyboard } from 'react-native'
import { MobileNativeChatAsk } from './MobileNativeChatAsk'
import { MobileNativeChatPermission } from './MobileNativeChatPermission'
vi.mock('react-native', () => ({
Keyboard: { dismiss: vi.fn() },
Pressable: 'Pressable',
ScrollView: 'ScrollView',
StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 },
Text: 'Text',
TextInput: 'TextInput',
View: 'View',
Platform: { OS: 'ios', select: (o: Record<string, unknown>) => o.ios }
}))
vi.mock('lucide-react-native', () => ({
Check: 'Check',
ChevronDown: 'ChevronDown',
ChevronUp: 'ChevronUp',
ShieldQuestion: 'ShieldQuestion',
X: 'X'
}))
vi.mock('../components/MobileMarkdown', () => ({ MobileMarkdown: 'Markdown' }))
describe('rendered prompt card collapse controls', () => {
it('collapses an Ask from its header chevron without cancelling or answering it', async () => {
const onCollapse = vi.fn()
const onCancel = vi.fn(async () => true)
const onAnswer = vi.fn(async () => true)
let tree: ReactTestRenderer | null = null
await act(async () => {
tree = create(
createElement(MobileNativeChatAsk, {
prompt: {
questions: [{ question: 'Pick?', options: [{ label: 'East' }], multiSelect: false }]
},
onAnswer,
onCancel,
onCollapse
})
)
})
act(() => tree!.root.findByProps({ accessibilityLabel: 'Collapse' }).props.onPress())
expect(onCollapse).toHaveBeenCalledOnce()
// The reply field the collapse hides must not keep the keyboard.
expect(Keyboard.dismiss).toHaveBeenCalledOnce()
expect(onCancel).not.toHaveBeenCalled()
expect(onAnswer).not.toHaveBeenCalled()
act(() => tree!.unmount())
})
it('shows Collapse in a terminal permission header and keeps Cancel where the lane can cancel', async () => {
const permission = {
title: 'Allow Bash?',
options: [
{ label: 'Allow', send: '1' },
{ label: 'Deny', send: '\x1b' }
]
}
const onCollapse = vi.fn()
const onCancel = vi.fn(async () => true)
let tree: ReactTestRenderer | null = null
await act(async () => {
tree = create(
createElement(MobileNativeChatPermission, {
permission,
onRespond: async () => true,
onCollapse
})
)
})
expect(tree!.root.findAllByProps({ accessibilityLabel: 'Cancel' })).toHaveLength(0)
act(() => tree!.root.findByProps({ accessibilityLabel: 'Collapse' }).props.onPress())
expect(onCollapse).toHaveBeenCalledOnce()
await act(async () => {
tree!.update(
createElement(MobileNativeChatPermission, {
permission,
onRespond: async () => true,
onCancel,
onCollapse
})
)
})
expect(tree!.root.findAllByProps({ accessibilityLabel: 'Collapse' })).toHaveLength(0)
act(() => tree!.root.findByProps({ accessibilityLabel: 'Cancel' }).props.onPress())
expect(onCancel).toHaveBeenCalledOnce()
act(() => tree!.unmount())
})
})
@@ -0,0 +1,45 @@
import { Keyboard, Pressable, StyleSheet } from 'react-native'
import { ChevronDown, X } from 'lucide-react-native'
import { colors } from '../theme/mobile-theme'
/** A prompt card's header action: Cancel where the lane can cancel, else a Collapse that writes nothing. */
export function MobileNativeChatCardHeaderAction<Prompt>({
prompt,
onCancel,
onCollapse,
disabled
}: {
prompt?: Prompt
onCancel?: (prompt?: Prompt) => Promise<boolean>
onCollapse?: () => void
disabled?: boolean
}): React.JSX.Element | null {
if (!onCancel && !onCollapse) {
return null
}
const Icon = onCancel ? X : ChevronDown
return (
<Pressable
accessibilityLabel={onCancel ? 'Cancel' : 'Collapse'}
accessibilityState={onCancel ? undefined : { expanded: true }}
hitSlop={8}
style={styles.action}
onPress={() => {
if (onCancel) {
void onCancel(prompt)
return
}
// Why: a reply field hidden by the collapse must not keep the keyboard up.
Keyboard.dismiss()
onCollapse?.()
}}
disabled={disabled}
>
<Icon size={16} color={colors.textMuted} />
</Pressable>
)
}
const styles = StyleSheet.create({
action: { width: 28, height: 28, alignItems: 'center', justifyContent: 'center' }
})
@@ -68,6 +68,8 @@ type Props = {
onMicPressIn?: () => void
onMicPressOut?: () => void
disabled?: boolean
/** Only Send is unavailable; typing, dictation and attachments still edit the draft. */
sendDisabled?: boolean
placeholder?: string
filePaths?: string[]
onNeedFiles?: (query: string) => void
@@ -94,6 +96,7 @@ export function MobileNativeChatComposer({
onMicPressIn,
onMicPressOut,
disabled = false,
sendDisabled = false,
placeholder = 'Message, @files, /commands',
filePaths = NO_FILE_PATHS,
onNeedFiles
@@ -123,6 +126,7 @@ export function MobileNativeChatComposer({
const canSend =
(trimmed.length > 0 || attachments.length > 0) &&
!disabled &&
!sendDisabled &&
!sending &&
!isAttaching &&
!sessionOptionDispatching
@@ -303,7 +307,7 @@ export function MobileNativeChatComposer({
onPressIn={dictationMode === 'hold' ? onMicPressIn : undefined}
onPressOut={dictationMode === 'hold' ? onMicPressOut : undefined}
onLongPress={dictationMode === 'hold' ? keepHeldPressThroughLongPress : undefined}
disabled={disabled}
disabled={disabled && !micActive}
>
{/* The icon swaps on press; as the page's touch target, its removal would send
touchend to a detached node and lose the release. */}
@@ -93,10 +93,14 @@ export function MobileNativeChatOverlay({
onStop={controller.handleNativeChatStop}
ask={controller.nativeChatAsk}
askKey={controller.nativeChatAskKey}
promptKey={controller.nativeChatPromptKey}
onDismissAsk={controller.dismissNativeChatAsk}
onAnswerAsk={controller.handleNativeChatAnswerAsk}
onCancelAsk={controller.handleNativeChatCancelAsk}
onCancelPrompt={controller.handleNativeChatCancelPrompt}
onCollapseAsk={controller.collapseNativeChatAsk}
onCollapsePrompt={controller.collapseNativeChatPrompt}
collapsedPrompt={controller.nativeChatCollapsedPrompt}
question={controller.nativeChatQuestion}
onAnswerQuestion={controller.handleNativeChatQuestionAnswer}
permission={controller.nativeChatPermission}
@@ -11,7 +11,12 @@ vi.mock('react-native', () => ({
View: 'View'
}))
vi.mock('lucide-react-native', () => ({ ShieldQuestion: 'ShieldQuestion', X: 'X' }))
vi.mock('lucide-react-native', () => ({
ChevronDown: 'ChevronDown',
ChevronUp: 'ChevronUp',
ShieldQuestion: 'ShieldQuestion',
X: 'X'
}))
vi.mock('../components/MobileMarkdown', () => ({ MobileMarkdown: 'MobileMarkdown' }))
describe('MobileNativeChatPermission', () => {
@@ -1,6 +1,7 @@
import { memo, useRef, useState } from 'react'
import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'
import { ShieldQuestion, X } from 'lucide-react-native'
import { ShieldQuestion } from 'lucide-react-native'
import { MobileNativeChatCardHeaderAction } from './MobileNativeChatCardHeaderAction'
import { MobileMarkdown } from '../components/MobileMarkdown'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
import {
@@ -15,11 +16,14 @@ import type { MobileChatPermission } from './mobile-native-chat-permission'
function MobileNativeChatPermissionImpl({
permission,
onRespond,
onCancel
onCancel,
onCollapse
}: {
permission: MobileChatPermission
onRespond: (send: string) => Promise<boolean>
onCancel?: (prompt?: NonNullable<MobileChatPermission['prompt']>) => Promise<boolean>
/** Fold the card to a strip and free Send, writing nothing. */
onCollapse?: () => void
}): React.JSX.Element {
const [submitting, setSubmitting] = useState(false)
const submittingRef = useRef(false)
@@ -57,17 +61,12 @@ function MobileNativeChatPermissionImpl({
>
{permission.title}
</Text>
{onCancel ? (
<Pressable
accessibilityLabel="Cancel"
hitSlop={8}
style={styles.cancel}
onPress={() => void onCancel(permission.prompt)}
disabled={submitting}
>
<X size={16} color={colors.textMuted} />
</Pressable>
) : null}
<MobileNativeChatCardHeaderAction
prompt={permission.prompt}
onCancel={onCancel}
onCollapse={onCollapse}
disabled={submitting}
/>
</View>
{hasContext ? (
<ScrollView
@@ -170,12 +169,6 @@ const styles = StyleSheet.create({
fontSize: typography.bodySize,
fontWeight: '600'
},
cancel: {
width: 28,
height: 28,
alignItems: 'center',
justifyContent: 'center'
},
detail: {
color: colors.textSecondary,
fontSize: typography.metaSize,
@@ -0,0 +1,54 @@
import { createElement } from 'react'
import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer'
import { describe, expect, it, vi } from 'vitest'
import { MobileNativeChatPromptCard } from './MobileNativeChatPromptCard'
vi.mock('react-native', () => ({
Pressable: 'Pressable',
ScrollView: 'ScrollView',
StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 },
Text: 'Text',
TextInput: 'TextInput',
View: 'View',
Platform: { OS: 'ios', select: (o: Record<string, unknown>) => o.ios }
}))
vi.mock('lucide-react-native', () => ({
Check: 'Check',
ChevronDown: 'ChevronDown',
ChevronUp: 'ChevronUp',
CircleHelp: 'CircleHelp',
ShieldQuestion: 'ShieldQuestion',
X: 'X'
}))
vi.mock('../components/MobileMarkdown', () => ({ MobileMarkdown: 'Markdown' }))
const textInput = (root: ReactTestInstance) =>
root.find((node) => node.props.placeholder === 'Type your answer')
const ask = { questions: [{ question: 'Pick?', options: [{ label: 'East' }], multiSelect: false }] }
describe('MobileNativeChatPromptCard collapse', () => {
it('keeps a partly answered Ask mounted but hidden while collapsed', async () => {
let tree: ReactTestRenderer | null = null
const render = (collapsed: boolean) =>
createElement(MobileNativeChatPromptCard, {
ask,
collapsedPrompt: collapsed ? { title: 'Pick?', expand: () => {} } : null
})
await act(async () => {
tree = create(render(false))
})
const other = tree!.root.findAll((node) => node.props.label === 'Other…')[0]!
act(() => other.props.onPress())
act(() => textInput(tree!.root).props.onChangeText('turbo'))
await act(async () => tree!.update(render(true)))
expect(tree!.root.findByProps({ testID: 'native-chat-prompt-strip' })).toBeTruthy()
const hidden = tree!.root.findAll((node) => node.props.style?.display === 'none')
expect(hidden).toHaveLength(1)
expect(textInput(hidden[0]!).props.value).toBe('turbo')
await act(async () => tree!.update(render(false)))
expect(textInput(tree!.root).props.value).toBe('turbo')
act(() => tree!.unmount())
})
})
@@ -1,14 +1,16 @@
import { StyleSheet, View } from 'react-native'
import type { AskAnswerSelection, AskPrompt } from '../../../src/shared/native-chat-ask'
import { MobileNativeChatAsk } from './MobileNativeChatAsk'
import { MobileNativeChatPermission } from './MobileNativeChatPermission'
import type { MobileChatPermission } from './mobile-native-chat-permission'
import { MobileNativeChatQuestion } from './MobileNativeChatQuestion'
import { mobileChatQuestionKey, type MobileChatQuestion } from './mobile-native-chat-question'
import { MobileNativeChatPromptStrip } from './MobileNativeChatPromptStrip'
/** The one pending agent prompt shown above the composer: a structured
* AskUserQuestion wins, then a heuristic permission, then a heuristic question.
* The controller owns dismissal (it must survive this subtree unmounting on a
* view toggle); `ask` arrives already nulled while dismissed. */
* view toggle); `ask` arrives already nulled once answered. */
export function MobileNativeChatPromptCard({
ask,
askKey,
@@ -16,6 +18,9 @@ export function MobileNativeChatPromptCard({
onAnswerAsk,
onCancelAsk,
onCancelPrompt,
onCollapseAsk,
onCollapsePrompt,
collapsedPrompt,
permission,
onRespondPermission,
question,
@@ -27,52 +32,66 @@ export function MobileNativeChatPromptCard({
onAnswerAsk?: (prompt: AskPrompt, selections: AskAnswerSelection[]) => Promise<boolean>
onCancelAsk?: () => Promise<boolean>
onCancelPrompt?: (prompt?: NonNullable<MobileChatPermission['prompt']>) => Promise<boolean>
onCollapseAsk?: () => void
/** Fold the permission/question occurrence to a strip and free Send, writing nothing. */
onCollapsePrompt?: () => void
/** Set while the shown occurrence is collapsed: its card stays mounted but hidden under a strip. */
collapsedPrompt?: { title: string; expand: () => void } | null
permission?: MobileChatPermission | null
onRespondPermission?: (send: string) => Promise<boolean>
question?: MobileChatQuestion | null
onAnswerQuestion?: (text: string) => Promise<boolean>
}): React.JSX.Element | null {
if (ask) {
return (
<MobileNativeChatAsk
key={askKey ?? 'ask'}
prompt={ask}
onAnswer={async (selections) => {
const accepted = (await onAnswerAsk?.(ask, selections)) ?? false
if (accepted) {
onDismissAsk?.()
}
return accepted
}}
onCancel={async () => {
const accepted = (await onCancelAsk?.()) ?? false
if (accepted) {
onDismissAsk?.()
}
return accepted
}}
/>
)
}
if (permission) {
return (
<MobileNativeChatPermission
key={JSON.stringify(permission)}
permission={permission}
onRespond={async (send) => (await onRespondPermission?.(send)) ?? false}
onCancel={onCancelPrompt}
/>
)
}
if (question) {
return (
<MobileNativeChatQuestion
key={mobileChatQuestionKey(question)}
question={question}
onAnswer={async (text) => (await onAnswerQuestion?.(text)) ?? false}
onCancel={onCancelPrompt}
/>
)
}
return null
const card = ask ? (
<MobileNativeChatAsk
key={askKey ?? 'ask'}
prompt={ask}
onAnswer={async (selections) => {
const accepted = (await onAnswerAsk?.(ask, selections)) ?? false
if (accepted) {
onDismissAsk?.()
}
return accepted
}}
onCancel={async () => {
const accepted = (await onCancelAsk?.()) ?? false
if (accepted) {
onDismissAsk?.()
}
return accepted
}}
onCollapse={onCollapseAsk}
/>
) : permission ? (
<MobileNativeChatPermission
key={JSON.stringify(permission)}
permission={permission}
onRespond={async (send) => (await onRespondPermission?.(send)) ?? false}
onCancel={onCancelPrompt}
onCollapse={onCollapsePrompt}
/>
) : question ? (
<MobileNativeChatQuestion
key={mobileChatQuestionKey(question)}
question={question}
onAnswer={async (text) => (await onAnswerQuestion?.(text)) ?? false}
onCancel={onCancelPrompt}
onCollapse={onCollapsePrompt}
/>
) : null
// Why one stable wrapper: collapsing hides the card without remounting it, so a partly answered
// card keeps its selections.
return (
<>
<View style={collapsedPrompt ? styles.collapsed : undefined}>{card}</View>
{collapsedPrompt ? (
<MobileNativeChatPromptStrip
title={collapsedPrompt.title}
onExpand={collapsedPrompt.expand}
/>
) : null}
</>
)
}
const styles = StyleSheet.create({ collapsed: { display: 'none' } })
@@ -0,0 +1,45 @@
import { Pressable, StyleSheet, Text, View } from 'react-native'
import { ChevronUp } from 'lucide-react-native'
import { colors, spacing, typography } from '../theme/mobile-theme'
/** A collapsed prompt above the usable composer; expanding gives it Send again. */
export function MobileNativeChatPromptStrip({
title,
onExpand
}: {
title: string
onExpand: () => void
}): React.JSX.Element {
return (
<View testID="native-chat-prompt-strip" style={styles.strip}>
<Text style={styles.title} numberOfLines={1}>
{title}
</Text>
<Pressable
accessibilityLabel="Expand"
accessibilityState={{ expanded: false }}
hitSlop={8}
style={styles.action}
onPress={onExpand}
>
<ChevronUp size={16} color={colors.textMuted} />
</Pressable>
</View>
)
}
const styles = StyleSheet.create({
action: { width: 28, height: 28, alignItems: 'center', justifyContent: 'center' },
strip: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingLeft: spacing.md,
paddingRight: spacing.sm,
paddingVertical: spacing.xs,
backgroundColor: colors.bgPanel,
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: colors.borderSubtle
},
title: { flex: 1, color: colors.textPrimary, fontSize: typography.bodySize, fontWeight: '600' }
})
@@ -14,6 +14,8 @@ vi.mock('react-native', () => ({
vi.mock('lucide-react-native', () => ({
ArrowUp: 'ArrowUp',
Check: 'Check',
ChevronDown: 'ChevronDown',
ChevronUp: 'ChevronUp',
CircleHelp: 'CircleHelp',
X: 'X'
}))
+12 -19
View File
@@ -1,6 +1,7 @@
import { useMemo, useRef, useState } from 'react'
import { Pressable, StyleSheet, Text, TextInput, View } from 'react-native'
import { ArrowUp, Check, CircleHelp, X } from 'lucide-react-native'
import { ArrowUp, Check, CircleHelp } from 'lucide-react-native'
import { MobileNativeChatCardHeaderAction } from './MobileNativeChatCardHeaderAction'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
import { mobileNativeChatInputStyles } from './mobile-native-chat-input-styles'
import {
@@ -14,6 +15,8 @@ type Props = {
question: MobileChatQuestion
onAnswer: (text: string) => Promise<boolean>
onCancel?: (prompt?: NonNullable<MobileChatQuestion['prompt']>) => Promise<boolean>
/** Fold the card to a strip and free Send, writing nothing. */
onCollapse?: () => void
}
/** Renders an agent's choice prompt as a tappable card. Single-select answers
@@ -23,7 +26,8 @@ type Props = {
export function MobileNativeChatQuestion({
question,
onAnswer,
onCancel
onCancel,
onCollapse
}: Props): React.JSX.Element {
const [selectedOptionIndexes, setSelectedOptionIndexes] = useState<number[]>([])
const [freeText, setFreeText] = useState('')
@@ -108,17 +112,12 @@ export function MobileNativeChatQuestion({
<View style={styles.header}>
<CircleHelp size={15} color={colors.accentBlue} strokeWidth={2.2} />
<Text style={styles.question}>{question.question}</Text>
{onCancel ? (
<Pressable
accessibilityLabel="Cancel"
hitSlop={8}
style={styles.cancel}
onPress={() => void onCancel(question.prompt)}
disabled={sending}
>
<X size={16} color={colors.textMuted} />
</Pressable>
) : null}
<MobileNativeChatCardHeaderAction
prompt={question.prompt}
onCancel={onCancel}
onCollapse={onCollapse}
disabled={sending}
/>
</View>
{hasOptions ? (
@@ -231,12 +230,6 @@ const styles = StyleSheet.create({
fontWeight: '600',
lineHeight: typography.bodySize + 7
},
cancel: {
width: 28,
height: 28,
alignItems: 'center',
justifyContent: 'center'
},
options: {
gap: spacing.xs
},
@@ -0,0 +1,229 @@
import { createElement, type ComponentProps } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { Pressable } from 'react-native'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { MobileNativeChatView } from './MobileNativeChatView'
import { useMobileNativeChatPromptPresentation } from './use-mobile-native-chat-prompt-presentation'
import type { MobileChatPermission } from './mobile-native-chat-permission'
vi.mock('react-native', async () => {
const React = await import('react')
return {
ActivityIndicator: 'ActivityIndicator',
Image: 'Image',
Platform: { OS: 'ios' },
Keyboard: { dismiss: vi.fn() },
FlatList: React.forwardRef((props, ref) => {
React.useImperativeHandle(ref, () => ({ scrollToEnd: vi.fn(), scrollToOffset: vi.fn() }), [])
return React.createElement('FlatList', props)
}),
Pressable: 'Pressable',
ScrollView: 'ScrollView',
TextInput: 'TextInput',
StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 },
Text: 'Text',
View: 'View'
}
})
vi.mock('react-native-safe-area-context', () => ({
useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 })
}))
vi.mock('react-native-gesture-handler', () => {
type GestureChain = {
runOnJS: () => GestureChain
onStart: () => GestureChain
onUpdate: () => GestureChain
}
const chain: GestureChain = { runOnJS: () => chain, onStart: () => chain, onUpdate: () => chain }
return {
Gesture: { Simultaneous: () => ({}), Native: () => ({}), Pinch: () => chain },
GestureDetector: 'GestureDetector',
GestureHandlerRootView: 'GestureHandlerRootView'
}
})
vi.mock('lucide-react-native', () =>
Object.fromEntries(
[
'ArrowDown',
'ArrowUp',
'ChevronDown',
'ChevronUp',
'ChevronsDownUp',
'ChevronsUpDown',
'ShieldQuestion',
'ImagePlus',
'Mic',
'Square',
'X'
].map((x) => [x, x])
)
)
vi.mock('./MobileNativeChatMessage', () => ({ MobileNativeChatMessage: 'ChatMessage' }))
vi.mock('./MobileNativeChatLiveLine', () => ({ MobileNativeChatLiveLine: 'LiveStatus' }))
vi.mock('./MobileNativeChatAsk', () => ({ MobileNativeChatAsk: 'ChatAsk' }))
vi.mock('./MobileNativeChatQuestion', () => ({ MobileNativeChatQuestion: 'ChatQuestion' }))
vi.mock('./MobileAgentWorkingIndicator', () => ({
MobileAgentWorkingIndicator: 'WorkingIndicator'
}))
vi.mock('./MobileNativeChatSessionOptionPickers', () => ({
MobileNativeChatSessionOptionPickers: 'SessionOptions'
}))
vi.mock('./MobileNativeChatComposerSuggestions', () => ({
MobileNativeChatComposerSuggestions: 'Suggestions',
composerSuggestionInsertText: () => ''
}))
vi.mock('../components/ActionSheetModal', () => ({ ActionSheetModal: 'ActionSheetModal' }))
vi.mock('../components/MobileMarkdown', () => ({ MobileMarkdown: 'Markdown' }))
let tree: ReactTestRenderer | null = null
const props = {
messages: [],
folded: [],
status: 'ready' as const,
streaming: null,
pending: [],
onSend: vi.fn(async () => true),
sendSurfaceId: 'terminal-1',
getSendCompletionGeneration: () => 0,
getComposerEditGeneration: () => 0,
composerText: 'no, do X instead',
onComposerTextChange: vi.fn()
}
const permission: MobileChatPermission = {
title: 'Claude wants to use Bash',
options: [{ label: 'Deny', send: String.fromCharCode(27) }]
}
type Overrides = Partial<ComponentProps<typeof MobileNativeChatView>> & {
waitStartedAt?: number
sessionKey?: string
}
function Harness(overrides: Overrides) {
const shown = useMobileNativeChatPromptPresentation({
permission: overrides.permission ?? null,
question: overrides.question ?? null,
waitStartedAt: overrides.waitStartedAt ?? 10,
scopeKey: 'tab-1',
sessionKey: overrides.sessionKey ?? 'session-1',
observing: true,
respondPermission: overrides.onRespondPermission ?? (async () => false),
answerQuestion: overrides.onAnswerQuestion ?? (async () => false)
})
return createElement(MobileNativeChatView, {
...props,
...overrides,
permission: shown.permission,
question: shown.question,
promptKey: shown.occurrenceKey,
onRespondPermission: shown.respondPermission,
onAnswerQuestion: shown.answerQuestion
})
}
const render = async (overrides: Overrides) => {
await act(async () => {
const element = createElement(Harness, overrides)
if (tree) {
tree.update(element)
} else {
tree = create(element)
}
})
}
const sendButton = () => tree!.root.findByProps({ accessibilityLabel: 'Send message' })
const approvalAction = () =>
tree!.root.findByProps({ testID: 'native-chat-approval-actions' }).findByType(Pressable)
afterEach(() => {
act(() => tree?.unmount())
tree = null
vi.unstubAllGlobals()
})
describe('terminal prompt presentation with the production view, card and composer', () => {
it('accepted Deny hides exactly that occurrence and restores Send across reconnect', async () => {
vi.stubGlobal('requestAnimationFrame', (callback: () => void) => setTimeout(callback, 0))
const onRespondPermission = vi.fn(async () => true)
await render({ permission, onRespondPermission })
expect(sendButton().props.disabled).toBe(true)
await act(async () => {
await approvalAction().props.onPress()
})
expect(onRespondPermission).toHaveBeenCalledExactlyOnceWith(String.fromCharCode(27))
expect(tree!.root.findAllByProps({ testID: 'native-chat-approval-actions' })).toHaveLength(0)
expect(sendButton().props.disabled).toBe(false)
await render({ permission, onRespondPermission, inputLockReason: 'disconnected' })
await render({ permission, onRespondPermission, inputLockReason: null })
expect(sendButton().props.disabled).toBe(false)
await render({ permission, onRespondPermission, waitStartedAt: 20 })
expect(sendButton().props.disabled).toBe(true)
expect(approvalAction().props.disabled).toBe(false)
})
it('refused or unknown answers keep the choices enabled', async () => {
const onRespondPermission = vi.fn(async () => false)
await render({ permission, onRespondPermission })
await act(async () => {
await approvalAction().props.onPress()
})
expect(approvalAction().props.disabled).toBe(false)
expect(sendButton().props.disabled).toBe(true)
})
it('a late answer cannot reshow an answered replacement occurrence', async () => {
let finishOld: (accepted: boolean) => void = () => {}
const onRespondPermission = vi
.fn<(send: string) => Promise<boolean>>()
.mockReturnValueOnce(
new Promise((resolve) => {
finishOld = resolve
})
)
.mockResolvedValueOnce(true)
await render({ permission, onRespondPermission })
act(() => {
void approvalAction().props.onPress()
})
await render({ permission, onRespondPermission, waitStartedAt: 20 })
await act(async () => {
await approvalAction().props.onPress()
})
expect(sendButton().props.disabled).toBe(false)
await act(async () => finishOld(true))
expect(sendButton().props.disabled).toBe(false)
})
it('a late answer cannot dismiss a replacement session', async () => {
let finishOld: (accepted: boolean) => void = () => {}
const onRespondPermission = vi.fn(
() =>
new Promise<boolean>((resolve) => {
finishOld = resolve
})
)
await render({ permission, onRespondPermission })
act(() => {
void approvalAction().props.onPress()
})
await render({ permission, onRespondPermission, sessionKey: 'session-2' })
await act(async () => finishOld(true))
expect(sendButton().props.disabled).toBe(true)
expect(approvalAction().props.disabled).toBe(false)
})
it.each(['toggle', 'hold'])(
'keeps %s dictation and attachments available while a prompt blocks Send',
async (dictationMode) => {
const onMicPress = vi.fn()
const onMicPressOut = vi.fn()
await render({ micActive: true, onMicPress, onMicPressOut, dictationMode })
await render({ permission, micActive: true, onMicPress, onMicPressOut, dictationMode })
const stop = tree!.root.findByProps({ accessibilityLabel: 'Stop dictation' })
expect(stop.props.disabled).toBe(false)
act(() => {
;(dictationMode === 'hold' ? stop.props.onPressOut : stop.props.onPress)()
})
expect(dictationMode === 'hold' ? onMicPressOut : onMicPress).toHaveBeenCalledOnce()
await render({ permission, onMicPress, dictationMode, onAttachImage: vi.fn() })
expect(tree!.root.findByProps({ accessibilityLabel: 'Dictate' }).props.disabled).toBe(false)
expect(tree!.root.findByProps({ accessibilityLabel: 'Attach image' }).props.disabled).toBe(
false
)
expect(sendButton().props.disabled).toBe(true)
}
)
})
@@ -714,4 +714,36 @@ describe('MobileNativeChatView', () => {
vi.useRealTimers()
}
})
// Why: a terminal-backed send types into the agent's prompt and could answer it; drafting cannot.
describe('a prompt card owns Send in terminal-backed chat', () => {
const permission = { title: 'Approve?', options: [{ label: 'Allow', send: '1' }] }
it('blocks only Send while the card shows, and frees it when the card clears', async () => {
await render({ permission })
expect(composer().props.sendDisabled).toBe(true)
expect(composer().props.disabled).toBe(false)
expect(composer().props.placeholder).toBe('Message, @files, /commands')
await update({ permission: null })
expect(composer().props.sendDisabled).toBe(false)
})
it('blocks Send for an ask and a heuristic question too', async () => {
await render({
ask: {
questions: [{ question: 'Tabs?', multiSelect: false, options: [{ label: 'Tabs' }] }]
}
})
expect(composer().props.sendDisabled).toBe(true)
await update({
question: { question: 'Name?', options: [], multiSelect: false, optionTokens: [] }
})
expect(composer().props.sendDisabled).toBe(true)
})
it('leaves a structured chat composer open: its host queues the send behind the prompt', async () => {
await render({ permission, structuredActivityUi: true })
expect(composer().props.sendDisabled).toBe(false)
})
})
})
+22 -18
View File
@@ -29,7 +29,10 @@ import {
import { useMobileNativeChatPinchGesture } from './use-mobile-native-chat-pinch-gesture'
import { useMobileNativeChatTailFollow } from './use-mobile-native-chat-tail-follow'
import { useMobileNativeChatTurnDisclosure } from './use-mobile-native-chat-turn-disclosure'
import { useSettledMobileNativeChatInputLock } from './use-mobile-native-chat-input-lease'
import {
mobileNativeChatComposerPlaceholder,
useSettledMobileNativeChatInputLock
} from './use-mobile-native-chat-input-lease'
import { MobileNativeChatLiveLine } from './MobileNativeChatLiveLine'
import { MobileAgentWorkingIndicator } from './MobileAgentWorkingIndicator'
import type { PendingNativeChatImage } from './mobile-native-chat-image-attachment'
@@ -131,9 +134,13 @@ type Props = MobileQueuedSlotProps & {
onCancelAsk?: () => Promise<boolean>
/** Cancel a structured approval/question with exact item identity when supported. */
onCancelPrompt?: (prompt?: { itemId: string; expectedRevision: number }) => Promise<boolean>
onCollapseAsk?: () => void
onCollapsePrompt?: () => void
collapsedPrompt?: { title: string; expand: () => void } | null
question?: MobileChatQuestion | null
onAnswerQuestion?: (text: string) => Promise<boolean>
permission?: MobileChatPermission | null
promptKey?: string | null
onRespondPermission?: (send: string) => Promise<boolean>
/** Open a worktree file tapped in agent markdown. */
onOpenFile?: (relativePath: string) => void
@@ -190,9 +197,13 @@ export function MobileNativeChatView({
onAnswerAsk,
onCancelAsk,
onCancelPrompt,
onCollapseAsk,
onCollapsePrompt,
collapsedPrompt,
question,
onAnswerQuestion,
permission,
promptKey,
onRespondPermission,
queuedSlot: { cards: queuedCards, composerInputRef: inputRef } = NO_QUEUED_SLOT,
onOpenFile,
@@ -310,6 +321,10 @@ export function MobileNativeChatView({
const showLoading = status === 'loading' && messages.length === 0
const lockReason = useSettledMobileNativeChatInputLock(inputLockReason)
// Why only Send, terminal-backed only: that send types into the agent's prompt and can answer it,
// while drafting never does; the host queues a structured send behind it.
const expandedPromptOwnsSend =
!structuredActivityUi && !collapsedPrompt && (ask ?? permission ?? question) != null
const emptyStateView = emptyState ? (
<View style={styles.center}>
<Text style={styles.emptyTitle}>{emptyState.title}</Text>
@@ -386,16 +401,10 @@ export function MobileNativeChatView({
)}
{queuedCards}
<MobileNativeChatPromptCard
ask={ask}
askKey={askKey}
onDismissAsk={onDismissAsk}
onAnswerAsk={onAnswerAsk}
onCancelAsk={onCancelAsk}
onCancelPrompt={onCancelPrompt}
permission={permission}
onRespondPermission={onRespondPermission}
question={question}
onAnswerQuestion={onAnswerQuestion}
key={promptKey ?? undefined}
{...{ ask, askKey, onDismissAsk, onAnswerAsk, onCancelAsk, onCancelPrompt, onCollapseAsk }}
{...{ permission, onRespondPermission, question, onAnswerQuestion, onCollapsePrompt }}
collapsedPrompt={collapsedPrompt}
/>
<View style={styles.chromeRow}>
<View style={styles.chromeLeft}>
@@ -456,13 +465,8 @@ export function MobileNativeChatView({
onMicPressIn={onMicPressIn}
onMicPressOut={onMicPressOut}
disabled={lockReason !== null}
placeholder={
lockReason === 'disconnected'
? 'Reconnecting…'
: lockReason === 'waiting'
? 'Waiting for terminal…'
: 'Message, @files, /commands'
}
sendDisabled={expandedPromptOwnsSend}
placeholder={mobileNativeChatComposerPlaceholder(lockReason)}
filePaths={filePaths}
onNeedFiles={onNeedFiles}
/>
@@ -0,0 +1,299 @@
import { createElement } from 'react'
import { Pressable, Text } from 'react-native'
import type { RpcClient } from '../../transport/rpc-client'
import type { RpcResponse, ConnectionState } from '../../transport/types'
import type { MobileNativeChatTab } from '../mobile-native-chat-eligibility'
import type { MobileNativeChatController } from '../mobile-native-chat-controller-contract'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { vi } from 'vitest'
import { useMobileNativeChatController } from '../use-mobile-native-chat-controller'
import { MobileNativeChatOverlay } from '../MobileNativeChatOverlay'
import { MobileNativeChatAsk } from '../MobileNativeChatAsk'
import type { AskAnswerSelection } from '../../../../src/shared/native-chat-ask'
import { mobileNativeChatPromptDismissals } from '../mobile-native-chat-prompt-dismissals'
vi.mock('react-native', async () => {
const React = await import('react')
return {
ActivityIndicator: 'ActivityIndicator',
Image: 'Image',
Platform: { OS: 'ios' },
Keyboard: { dismiss: vi.fn() },
FlatList: React.forwardRef((props, ref) => {
React.useImperativeHandle(ref, () => ({ scrollToEnd: vi.fn(), scrollToOffset: vi.fn() }), [])
return React.createElement('FlatList', props)
}),
Pressable: 'Pressable',
ScrollView: 'ScrollView',
TextInput: 'TextInput',
StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 },
Text: 'Text',
View: 'View'
}
})
vi.mock('react-native-safe-area-context', () => ({
useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 })
}))
vi.mock('react-native-gesture-handler', () => {
type GestureChain = {
runOnJS: () => GestureChain
onStart: () => GestureChain
onUpdate: () => GestureChain
}
const chain: GestureChain = { runOnJS: () => chain, onStart: () => chain, onUpdate: () => chain }
return {
Gesture: { Simultaneous: () => ({}), Native: () => ({}), Pinch: () => chain },
GestureDetector: 'GestureDetector',
GestureHandlerRootView: 'GestureHandlerRootView'
}
})
vi.mock('lucide-react-native', () =>
Object.fromEntries(
[
'ArrowDown',
'ArrowUp',
'ChevronDown',
'ChevronUp',
'ChevronsDownUp',
'ChevronsUpDown',
'ShieldQuestion',
'ImagePlus',
'Mic',
'Square',
'X',
'Check',
'CircleHelp'
].map((x) => [x, x])
)
)
vi.mock('../MobileNativeChatMessage', () => ({ MobileNativeChatMessage: 'ChatMessage' }))
vi.mock('../MobileNativeChatLiveLine', () => ({ MobileNativeChatLiveLine: 'LiveStatus' }))
vi.mock('../MobileNativeChatAsk', () => ({ MobileNativeChatAsk: 'ChatAsk' }))
vi.mock('../MobileAgentWorkingIndicator', () => ({
MobileAgentWorkingIndicator: 'WorkingIndicator'
}))
vi.mock('../MobileNativeChatSessionOptionPickers', () => ({
MobileNativeChatSessionOptionPickers: 'SessionOptions'
}))
vi.mock('../MobileNativeChatComposerSuggestions', () => ({
MobileNativeChatComposerSuggestions: 'Suggestions',
composerSuggestionInsertText: () => ''
}))
vi.mock('../../components/ActionSheetModal', () => ({ ActionSheetModal: 'ActionSheetModal' }))
vi.mock('../../components/MobileMarkdown', () => ({ MobileMarkdown: 'Markdown' }))
export const visible = { value: true }
const session = { messages: [], folded: [], status: 'ready', transcriptLoading: false }
const structured = {
session,
isWorking: false,
turnId: null,
turnIndicator: null,
workingStartedAt: null,
settledTurns: null,
turnJournal: null,
permission: null,
question: null,
queued: { cards: [] },
respondPermission: vi.fn(async () => true),
respondQuestion: vi.fn(async () => true),
cancelPrompt: vi.fn(async () => true),
sendWithOutcome: vi.fn(async () => 'accepted'),
cancel: vi.fn(),
setStructuredOption: vi.fn(),
invokeStructuredOption: vi.fn()
}
vi.mock('../use-mobile-session-view-mode', () => ({
useMobileSessionViewMode: () => ({
isTabChatView: () => visible.value,
toggleTabChatView: () => {
visible.value = !visible.value
}
})
}))
vi.mock('../use-mobile-native-chat-session-lane', () => ({
useMobileNativeChatSessionLane: () => ({ session, structuredSession: structured })
}))
vi.mock('../use-mobile-native-chat-drafts', () => ({
useMobileNativeChatDrafts: () => ({
composerText: 'ordinary message',
setComposerText: vi.fn(),
getComposerEditGeneration: () => 0,
appendComposerText: () => true,
pending: [],
imagePreviewsByMessageId: {},
captureSendOrigin: (text: string) => ({ draftKey: 'draft', normalizedText: text }),
readSeededLaunchDraft: () => null,
readSeededLaunchDraftSeed: () => null,
clearDraftForSend: vi.fn(),
restoreRejectedDraft: vi.fn(),
acceptSend: vi.fn(),
holdUnconfirmedSend: vi.fn()
})
}))
vi.mock('../use-mobile-native-chat-session-option-controller', () => ({
useMobileNativeChatSessionOptionController: () => ({
nativeChatSessionOptions: undefined,
recordCommand: vi.fn()
})
}))
vi.mock('../use-mobile-native-chat-file-search', () => ({
useMobileNativeChatFileSearch: () => ({ nativeChatFilePaths: [], loadNativeChatFiles: vi.fn() })
}))
vi.mock('../use-mobile-native-chat-queued-slot', () => ({
NO_QUEUED_SLOT: {},
useMobileNativeChatQueuedSlot: () => ({})
}))
vi.mock('../use-mobile-native-chat-streaming-bubble', () => ({
useMobileNativeChatStreamingBubble: () => null
}))
let tree: ReactTestRenderer | null = null
let controller: MobileNativeChatController
let lastProps: HarnessProps | null = null
export const handleRef = { current: 'term-1' }
export const sendError = vi.fn()
const resolved = vi.fn()
let client: RpcClient
export function response(
accepted = true,
outcome: 'accepted' | 'refused' | 'unverifiable' | 'legacy' = accepted ? 'accepted' : 'refused'
): RpcResponse {
return {
id: 'send',
ok: true,
result: {
send: { accepted, ...(outcome === 'legacy' ? {} : { writeSettlement: { outcome } }) }
},
_meta: { runtimeId: 'r' }
}
}
export const baseTab = {
type: 'terminal',
launchAgent: 'claude',
agentStatus: {
agentType: 'claude',
state: 'waiting',
prompt: '',
updatedAt: 10,
paneKey: 'tab-1:leaf-1',
stateHistory: [],
stateStartedAt: 10,
providerSession: { id: 'session-1', key: 'session_id' },
lastAssistantMessage: 'Pick destination?\n1. East\n2. West'
}
} satisfies MobileNativeChatTab
export const permissionTab = {
...baseTab,
agentStatus: {
...baseTab.agentStatus,
lastAssistantMessage: '',
interactivePrompt: JSON.stringify({ approval: { tool: 'Bash', summary: 'echo yes' } })
}
} satisfies MobileNativeChatTab
type HarnessProps = {
tab?: MobileNativeChatTab
tabId?: string
connState?: ConnectionState
}
function Harness(props: HarnessProps) {
const { tab = baseTab, tabId = 'tab-1', connState = 'connected' } = props
const current = useMobileNativeChatController({
client,
hostId: 'host',
worktreeId: 'folder:work',
activeSessionTab: tab,
activeSessionTabId: tabId,
activeHandleRef: handleRef,
deviceTokenRef: { current: null },
nativeChatTranscriptIsLocalReadable: true,
nativeChatInputLeaseReady: true,
connState,
onSendError: sendError,
onSendResolved: resolved
})
captureRenderedController(current)
return createElement(MobileNativeChatOverlay, {
controller: current,
onOpenFile: vi.fn(),
images: {
sendNativeChat: controller.handleNativeChatSend,
attachments: [],
isAttaching: false,
attachImage: async () => {},
removeAttachment: () => {}
},
onMicPress: vi.fn(),
onMicPressOut: vi.fn(),
onMicPressIn: vi.fn(),
micActive: false,
dictationMode: 'toggle',
inputLockReason: connState === 'connected' ? null : 'disconnected',
sendErrorMessage: null,
onClearSendError: vi.fn(),
sendSurfaceId: 'surface',
getSendCompletionGeneration: () => 0,
keyboardInset: 0
})
}
export async function render(props: HarnessProps = lastProps ?? {}) {
lastProps = props
await act(async () => {
const element = createElement(Harness, props)
if (tree) {
tree.update(element)
} else {
tree = create(element)
}
})
}
export function sendButton() {
return getTree().root.findByProps({ accessibilityLabel: 'Send message' })
}
/** Index 1 is Deny. */
export function permissionAction(index = 1) {
return getTree()
.root.findByProps({ testID: 'native-chat-approval-actions' })
.findAllByType(Pressable)[index]
}
export function questionOption() {
return getTree()
.root.findAllByType(Pressable)
.find((node) => node.findAllByType(Text).some((text) => text.children.includes('East')))
}
export async function unmount() {
await act(async () => tree?.unmount())
tree = null
}
export function reset(rpcClient: RpcClient) {
client = rpcClient
visible.value = true
handleRef.current = 'term-1'
lastProps = null
sendError.mockReset()
resolved.mockReset()
mobileNativeChatPromptDismissals.clearForTests()
vi.stubGlobal('requestAnimationFrame', (callback: (time: number) => void) =>
setTimeout(() => callback(0), 0)
)
}
export function getTree(): ReactTestRenderer {
if (!tree) {
throw new Error('not rendered')
}
return tree
}
function captureRenderedController(value: MobileNativeChatController): void {
controller = value
}
export function getController(): MobileNativeChatController {
return controller
}
export function askCancel(): Promise<boolean> {
return getTree().root.findByType(MobileNativeChatAsk).props.onCancel()
}
export function askCollapse(): void {
getTree().root.findByType(MobileNativeChatAsk).props.onCollapse()
}
export function askAnswer(selections: AskAnswerSelection[]): Promise<boolean> {
return getTree().root.findByType(MobileNativeChatAsk).props.onAnswer(selections)
}
@@ -48,6 +48,8 @@ export type MobileNativeChatController = {
/** Host/workspace/tab/session scope for stateful streaming suppression. */
nativeChatStreamScopeKey: string
nativeChatPermission: ReturnType<typeof detectAgentPermission>
/** Terminal prompt occurrence; a replacement resets only the card's local submission state. */
nativeChatPromptKey: string | null
nativeChatQuestion: ReturnType<typeof parseAgentQuestion>
/** The pending ask, already null while dismissed (dismissal lives here so it
* survives the chat-view subtree unmounting on a view toggle). */
@@ -61,6 +63,11 @@ export type MobileNativeChatController = {
selections: AskAnswerSelection[]
) => Promise<boolean>
handleNativeChatCancelAsk: () => Promise<boolean>
/** Terminal lane: fold the occurrence to a strip and free Send, writing nothing. */
collapseNativeChatAsk: () => void
collapseNativeChatPrompt?: () => void
/** The collapsed terminal occurrence, shown as a strip above the composer. */
nativeChatCollapsedPrompt: { title: string; expand: () => void } | null
handleNativeChatCancelPrompt?: (prompt?: {
itemId: string
expectedRevision: number
@@ -45,6 +45,7 @@ describe('sendMobileNativeChatPermissionResponse', () => {
terminal: 'terminal',
text: '1',
enter: false,
requireWriteSettlement: true,
client: { id: 'phone', type: 'mobile' }
},
{ timeoutMs: MOBILE_NATIVE_CHAT_SEND_TIMEOUT_MS, budgetSpansConnect: true }
@@ -22,6 +22,7 @@ export function sendMobileNativeChatPermissionResponse(args: {
terminal: args.terminal,
text: args.text,
enter: false,
requireWriteSettlement: true,
...(args.deviceToken ? { mobileClient: { id: args.deviceToken, type: 'mobile' as const } } : {})
})
}
@@ -0,0 +1,12 @@
import { createPromptDismissalStore } from '../../../src/shared/prompt-dismissal-store'
export type MobileNativeChatPromptDismissal = {
sessionKey: string | null
promptKey: string
/** Answered: the card is gone. Collapsed: the user folded it to a strip above the composer. */
state: 'answered' | 'collapsed'
}
/** Each chat tab's dismissed prompt occurrence, keyed by card kind and tab scope. */
export const mobileNativeChatPromptDismissals =
createPromptDismissalStore<MobileNativeChatPromptDismissal>()
@@ -4,6 +4,7 @@ import { isRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity'
import { isLogicalClientCutoverError } from '../transport/stable-logical-rpc-client'
import { nativeChatTerminalWrite } from './mobile-session-write-operations'
import { typeAgentTuiCommand } from '../../../src/shared/agent-tui-command-typing'
import { readTerminalSendAcknowledgment } from '../../../src/shared/terminal-send-acknowledgment'
/** What a native-chat write takes, named from an operation so no module names the raw port. */
export type MobileNativeChatRpcSender = Parameters<typeof nativeChatTerminalWrite.request>[0]
@@ -24,6 +25,7 @@ type MobileNativeChatSendArgs = {
/** 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. */
deadline?: number
requireWriteSettlement?: true
}
/** 'unknown' = the RPC failed without proof the request never reached the
@@ -65,6 +67,7 @@ export async function sendMobileNativeChatMessageWithOutcome(
terminal: args.terminal,
text: args.text,
enter: args.enter ?? true,
...(args.requireWriteSettlement ? { requireWriteSettlement: true as const } : {}),
...(args.resolvedLaunchDraft ? { resolvedLaunchDraft: args.resolvedLaunchDraft } : {}),
...(args.mobileClient ? { client: args.mobileClient } : {})
},
@@ -73,6 +76,15 @@ export async function sendMobileNativeChatMessageWithOutcome(
// pins the composer for twice as long.
{ timeoutMs, budgetSpansConnect: true }
)
if (args.requireWriteSettlement && response.ok) {
const acknowledgment = readTerminalSendAcknowledgment(response.result)
if (acknowledgment === 'unverifiable') {
return 'unknown'
}
if (acknowledgment === 'refused') {
return 'rejected'
}
}
if (nativeChatTerminalWrite.interpret(response) !== true) {
return 'rejected'
}
@@ -103,6 +115,7 @@ export async function typeMobileNativeChatCommandWithOutcome(args: {
resolvedLaunchDraft?: { text: string; createdAt: number }
mobileClient?: MobileTerminalClient
deadline?: number
requireWriteSettlement?: true
}): Promise<MobileNativeChatWriteOutcome> {
let writeIndex = 0
return typeAgentTuiCommand({
@@ -115,6 +128,7 @@ export async function typeMobileNativeChatCommandWithOutcome(args: {
terminal: args.terminal,
text: key,
enter: false,
...(args.requireWriteSettlement ? { requireWriteSettlement: true as const } : {}),
...(isSubmit && args.resolvedLaunchDraft
? { resolvedLaunchDraft: args.resolvedLaunchDraft }
: {}),
@@ -0,0 +1,359 @@
import { act } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { markRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity'
import type { RpcResponse } from '../transport/types'
import type { RpcClient } from '../transport/rpc-client'
import { buildAskAnswerKeys, type AskPrompt } from '../../../src/shared/native-chat-ask'
import {
visible,
handleRef,
baseTab,
permissionTab,
response,
render,
reset,
unmount,
sendButton,
permissionAction,
questionOption,
getController,
askCancel,
askAnswer,
askCollapse,
sendError,
getTree
} from './__mocks__/mobile-prompt-controller'
const client: RpcClient = {
sendRequest: vi.fn<RpcClient['sendRequest']>(),
subscribe: () => () => {},
updateTerminalSubscriptionViewport: () => {},
getState: () => 'connected',
getReconnectAttempt: () => 0,
getLastConnectedAt: () => null,
onStateChange: () => () => {},
notifyForeground: () => {},
close: () => {}
}
beforeEach(() => {
vi.mocked(client.sendRequest).mockReset().mockResolvedValue(response())
reset(client)
})
afterEach(async () => {
await unmount()
vi.useRealTimers()
vi.unstubAllGlobals()
})
function option() {
const found = questionOption()
if (!found) {
throw new Error('question choice missing')
}
return found
}
describe('prompt cards through the production controller, send contract and view', () => {
it.each(['lost', 'unverifiable'])(
'keeps a question actionable after %s acknowledgment',
async (mode) => {
vi.mocked(client.sendRequest).mockImplementation(async (method, params) => {
if (
method === 'terminal.send' &&
typeof params === 'object' &&
params !== null &&
'enter' in params &&
params.enter === true
) {
if (mode === 'lost') {
throw markRpcDeliveryUnknown(new Error('lost acknowledgment'))
}
return response(false, 'unverifiable')
}
return response()
})
await render()
await act(async () => {
await option().props.onPress()
})
expect(getController().nativeChatQuestion).not.toBe(null)
expect(sendButton().props.disabled).toBe(true)
expect(option().props.disabled).toBeFalsy()
}
)
it('hides the acknowledged question and enables ordinary Send with stale host status', async () => {
await render()
await act(async () => {
await option().props.onPress()
})
expect(getController().nativeChatQuestion).toBe(null)
expect(sendButton().props.disabled).toBe(false)
expect(client.sendRequest).toHaveBeenCalledWith(
'terminal.send',
expect.objectContaining({ requireWriteSettlement: true, enter: true }),
expect.any(Object)
)
})
it('finishes an older-host selector once and dismisses it like an acknowledged answer', async () => {
vi.useFakeTimers()
const prompt: AskPrompt = {
questions: [{ question: 'Answer?', options: [{ label: 'A' }], multiSelect: false }]
}
const selections = [{ indices: [], other: 'custom answer' }]
const groups = buildAskAnswerKeys(prompt, selections)
vi.mocked(client.sendRequest).mockResolvedValue(response(true, 'legacy'))
await render({
tab: {
...baseTab,
agentStatus: {
...baseTab.agentStatus,
lastAssistantMessage: '',
toolName: 'AskUserQuestion',
interactivePrompt: JSON.stringify(prompt)
}
}
})
let pending: Promise<boolean> | undefined
act(() => {
pending = askAnswer(selections)
})
await act(async () => {
await vi.advanceTimersByTimeAsync(4000)
await expect(pending).resolves.toBe(true)
})
const writes = vi
.mocked(client.sendRequest)
.mock.calls.filter(([method]) => method === 'terminal.send')
.map(([, params]) => params)
expect(writes).toEqual(
groups.map((group) =>
expect.objectContaining({
text: 'raw' in group ? group.raw : group.text,
enter: false,
requireWriteSettlement: true
})
)
)
expect(getController().nativeChatAsk).toBe(null)
expect(sendButton().props.disabled).toBe(false)
expect(sendError).not.toHaveBeenCalled()
})
it.each([0, 1])(
'dismisses older-host approval choice %i like an acknowledged answer and restores Send',
async (choice) => {
vi.mocked(client.sendRequest).mockResolvedValue(response(true, 'legacy'))
await render({ tab: permissionTab })
await act(async () => {
await permissionAction(choice).props.onPress()
})
expect(getController().nativeChatPermission).toBe(null)
expect(sendButton().props.disabled).toBe(false)
expect(sendError).not.toHaveBeenCalled()
expect(client.sendRequest).toHaveBeenCalledOnce()
}
)
it.each(['refused', 'unverifiable'] as const)(
'retains permission choices on %s',
async (mode) => {
vi.mocked(client.sendRequest).mockResolvedValue(response(false, mode))
await render({ tab: permissionTab })
await act(async () => {
await permissionAction().props.onPress()
})
expect(getController().nativeChatPermission).not.toBe(null)
expect(permissionAction().props.disabled).toBe(false)
expect(sendButton().props.disabled).toBe(true)
expect(client.sendRequest).toHaveBeenCalledWith(
'terminal.send',
expect.objectContaining({ requireWriteSettlement: true, enter: false, text: '\x1b' }),
expect.any(Object)
)
}
)
it.each(['permission', 'ask'])(
'acknowledges the same pending %s after a view-only toggle',
async (kind) => {
const tab =
kind === 'permission'
? permissionTab
: {
...baseTab,
agentStatus: {
...baseTab.agentStatus,
lastAssistantMessage: '',
toolName: 'AskUserQuestion',
interactivePrompt: JSON.stringify({
questions: [
{
question: 'Pick destination?',
options: [{ label: 'East' }, { label: 'West' }]
}
]
})
}
}
let finish: (reply: RpcResponse) => void = () => {
throw new Error('write not started')
}
vi.mocked(client.sendRequest).mockImplementationOnce(
() =>
new Promise((resolve) => {
finish = resolve
})
)
await render({ tab })
let action: Promise<boolean> | undefined
act(() => {
action = kind === 'permission' ? permissionAction().props.onPress() : askCancel()
})
visible.value = false
await render({ tab })
visible.value = true
await render({ tab })
await act(async () => {
finish(response())
await action
})
expect(getController().nativeChatPermission).toBe(null)
expect(getController().nativeChatAsk).toBe(null)
expect(sendButton().props.disabled).toBe(false)
}
)
it('collapses a permission to a strip without writing, and expands it back', async () => {
await render({ tab: permissionTab })
act(() => {
getTree().root.findByProps({ accessibilityLabel: 'Collapse' }).props.onPress()
})
// Still mounted under the strip, so a partly made choice survives.
expect(getController().nativeChatPermission).not.toBe(null)
expect(getController().nativeChatCollapsedPrompt?.title).toBeTruthy()
expect(getTree().root.findAllByProps({ testID: 'native-chat-prompt-strip' })).toHaveLength(1)
expect(sendButton().props.disabled).toBe(false)
act(() => {
getTree().root.findByProps({ accessibilityLabel: 'Expand' }).props.onPress()
})
expect(getController().nativeChatPermission).not.toBe(null)
expect(sendButton().props.disabled).toBe(true)
expect(client.sendRequest).not.toHaveBeenCalled()
})
it('shows an identical heuristic question again after a new wait in terminal view', async () => {
await render({ tab: baseTab })
act(() => {
getTree().root.findByProps({ accessibilityLabel: 'Collapse' }).props.onPress()
})
visible.value = false
await render({ tab: { ...baseTab, agentStatus: { ...baseTab.agentStatus, state: 'working' } } })
await render({
tab: { ...baseTab, agentStatus: { ...baseTab.agentStatus, stateStartedAt: 40 } }
})
visible.value = true
await render()
expect(getController().nativeChatQuestion).not.toBe(null)
expect(sendButton().props.disabled).toBe(true)
})
it('shows a collapsed prompt again as a new wait', async () => {
await render({ tab: permissionTab })
act(() => {
getTree().root.findByProps({ accessibilityLabel: 'Collapse' }).props.onPress()
})
await render({
tab: { ...permissionTab, agentStatus: { ...permissionTab.agentStatus, stateStartedAt: 20 } }
})
expect(getController().nativeChatPermission).not.toBe(null)
expect(getController().nativeChatCollapsedPrompt).toBe(null)
})
it('collapses an ask without writing and hides the heuristic card read from the same wait', async () => {
await render({
tab: {
...baseTab,
agentStatus: {
...baseTab.agentStatus,
lastAssistantMessage: 'Before I proceed I want to confirm a choice.',
toolName: 'AskUserQuestion',
interactivePrompt: JSON.stringify({
questions: [{ question: 'Pick?', options: [{ label: 'East' }] }]
})
}
}
})
act(() => {
askCollapse()
})
expect(getController().nativeChatAsk).not.toBe(null)
expect(getController().nativeChatPermission).toBe(null)
expect(getController().nativeChatQuestion).toBe(null)
expect(getController().nativeChatCollapsedPrompt?.title).toBe('Pick?')
expect(sendButton().props.disabled).toBe(false)
expect(client.sendRequest).not.toHaveBeenCalled()
})
it('keeps an acknowledged Deny hidden after the chat screen remounts on a lingering status', async () => {
await render({ tab: permissionTab })
await act(async () => {
await permissionAction().props.onPress()
})
expect(sendButton().props.disabled).toBe(false)
await unmount()
await render({
tab: { ...permissionTab, agentStatus: { ...permissionTab.agentStatus, updatedAt: 99 } }
})
expect(getController().nativeChatPermission).toBe(null)
expect(sendButton().props.disabled).toBe(false)
})
it.each(['prompt', 'session', 'PTY', 'tab', 'clear'])(
'drops an accepted result after real %s replacement',
async (replacement) => {
let finish: (reply: RpcResponse) => void = () => {
throw new Error('write not started')
}
vi.mocked(client.sendRequest).mockImplementationOnce(
() =>
new Promise((resolve) => {
finish = resolve
})
)
await render({ tab: permissionTab })
act(() => {
void permissionAction().props.onPress()
})
const agentStatus: NonNullable<typeof permissionTab.agentStatus> = {
...permissionTab.agentStatus
}
if (replacement === 'prompt') {
agentStatus.stateStartedAt = 20
}
if (replacement === 'session') {
agentStatus.providerSession = { id: 'session-2', key: 'session_id' }
}
if (replacement === 'PTY') {
handleRef.current = 'term-2'
}
if (replacement === 'clear') {
await render({
tab: { ...baseTab, agentStatus: { ...baseTab.agentStatus, state: 'working' } }
})
}
await render({
tab: { ...permissionTab, agentStatus },
tabId: replacement === 'tab' ? 'tab-2' : 'tab-1'
})
await act(async () => {
finish(response())
})
expect(getController().nativeChatPermission).not.toBe(null)
expect(permissionAction().props.disabled).toBe(false)
expect(sendButton().props.disabled).toBe(true)
}
)
})
@@ -30,7 +30,7 @@ import {
export type MobileNativeChatAnswerSend = {
/** Answer the current question(s) from the card's per-question selections. */
answerAsk: (prompt: AskPrompt, selections: AskAnswerSelection[]) => Promise<boolean>
/** Drop any in-flight per-keystroke writes (call on Stop). */
/** Cancel unsent groups; an already-issued write may still settle. */
cancelPending: () => void
}
@@ -49,8 +49,8 @@ function sanitizeAskFreeText(text: string): string {
* `terminal.send` passthrough (raw text, no enter) — same contract the
* permission card already uses, so old runtimes replay them verbatim (no new
* RPC; keystrokes are built client-side). The scheduled wait chain is cancelled
* on a new answer, on `cancelPending` (Stop), and on unmount / session swap — so
* a detached chain can never write PTY bytes to a stale pane.
* on a new answer, on `cancelPending` (Stop), and on unmount / session swap.
* Cancellation prevents later groups; already-issued writes retain their verdict.
*/
export function useMobileNativeChatAnswerSend(args: {
client: RpcClient | null
@@ -175,6 +175,7 @@ export function useMobileNativeChatAnswerSend(args: {
terminal: handle,
text: body,
enter,
requireWriteSettlement: true,
deadline,
...(deviceTokenRef.current
? { mobileClient: { id: deviceTokenRef.current, type: 'mobile' } }
@@ -3,6 +3,7 @@ import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import type { AskPrompt } from '../../../src/shared/native-chat-ask'
import { useMobileNativeChatAskDismiss } from './use-mobile-native-chat-ask-dismiss'
import { mobileNativeChatPromptDismissals } from './mobile-native-chat-prompt-dismissals'
describe('useMobileNativeChatAskDismiss', () => {
let renderer: ReactTestRenderer | null = null
@@ -11,6 +12,7 @@ describe('useMobileNativeChatAskDismiss', () => {
beforeEach(() => {
renders = 0
mobileNativeChatPromptDismissals.clearForTests()
})
afterEach(() => {
@@ -1,79 +1,42 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { useMemo } from 'react'
import { nativeChatAskDismissKey, type AskPrompt } from '../../../src/shared/native-chat-ask'
import { useMobileNativeChatPromptDismiss } from './use-mobile-native-chat-prompt-dismiss'
type AskDismissal = { sessionKey: string | null; askKey: string }
type DetectedAsk = { sessionKey: string | null; askKey: string | null }
/** Track the answered-ask key so the lingering live status doesn't re-show the
* same card. The agent emits a post-tool event with the same prompt right after
* an answer, so the card is hidden until a genuinely different question arrives.
*
* Owned by the controller, not the chat subtree: the overlay unmounts on a
* chat↔terminal view toggle, and a dismissal must survive that round-trip. */
/** Keep an answered or collapsed ask so across view toggles and remounts until a real observation
* supersedes it. */
export function useMobileNativeChatAskDismiss(args: {
ask: AskPrompt | null
/** Ungated prompt payload. A working/done status hides the card but does not
* prove the sticky prompt itself cleared. Required, and never defaulted to
* `ask`: reading the gated prompt as the detected one is the resurfacing bug
* this hook exists to close. */
detectedAsk: AskPrompt | null
/** Tab scope retains dismissals across tab switches. */
scopeKey: string | null
/** Provider-session identity distinguishes restarts without growing the tab map. */
sessionKey: string | null
/** True while the chat surface can actually observe the prompt. A null ask it
* cannot see — off-chat, or before a re-subscribed transcript lands — proves
* nothing and must not reset the dismissal; that reset resurfaced the card. */
observing: boolean
}): {
askKey: string | null
showAsk: boolean
/** The collapsed ask, shown as a strip above the composer. */
collapsedAsk: { title: string; expand: () => void } | null
dismissAsk: () => void
collapseAsk: () => void
} {
const { ask, detectedAsk, scopeKey, sessionKey, observing } = args
const askKey = useMemo(() => nativeChatAskDismissKey(ask), [ask])
const detectedAskKey = useMemo(() => nativeChatAskDismissKey(detectedAsk), [detectedAsk])
const detectedByScopeRef = useRef(new Map<string | null, DetectedAsk>())
const [dismissedByScope, setDismissedByScope] = useState<Map<string | null, AskDismissal>>(
() => new Map()
const askKey = useMemo(() => nativeChatAskDismissKey(args.ask), [args.ask])
const detectedPromptKey = useMemo(
() => nativeChatAskDismissKey(args.detectedAsk),
[args.detectedAsk]
)
useEffect(() => {
if (observing) {
detectedByScopeRef.current.set(scopeKey, { sessionKey, askKey: detectedAskKey })
}
}, [observing, detectedAskKey, scopeKey, sessionKey])
// A cleared or genuinely different detected prompt retires the old dismissal.
useEffect(() => {
if (observing) {
setDismissedByScope((previous) => {
const dismissed = previous.get(scopeKey)
if (
dismissed === undefined ||
(dismissed.sessionKey === sessionKey && dismissed.askKey === detectedAskKey)
) {
return previous
}
const next = new Map(previous)
next.delete(scopeKey)
return next
})
}
}, [observing, detectedAskKey, scopeKey, sessionKey])
const dismissed = dismissedByScope.get(scopeKey)
const showAsk =
askKey !== null && !(dismissed?.sessionKey === sessionKey && dismissed.askKey === askKey)
const dismissAsk = (): void => {
const detected = detectedByScopeRef.current.get(scopeKey)
if (askKey !== null && detected?.sessionKey === sessionKey && detected.askKey === askKey) {
setDismissedByScope((previous) => {
const current = previous.get(scopeKey)
if (current?.sessionKey === sessionKey && current.askKey === askKey) {
return previous
}
return new Map(previous).set(scopeKey, { sessionKey, askKey })
})
}
const dismiss = useMobileNativeChatPromptDismiss({
...args,
kind: 'ask',
promptKey: askKey,
detectedPromptKey
})
return {
askKey,
showAsk: dismiss.showPrompt,
collapsedAsk:
dismiss.collapsed && args.ask
? { title: args.ask.questions[0]?.question ?? '', expand: dismiss.expandPrompt }
: null,
dismissAsk: dismiss.dismissPrompt,
collapseAsk: dismiss.collapsePrompt
}
return { askKey, showAsk, dismissAsk }
}
@@ -10,7 +10,7 @@ export function useMobileNativeChatCancelAsk(args: {
enabled: boolean
handleRef: MutableRefObject<string | null>
deviceTokenRef: MutableRefObject<string | null>
/** Drops any in-flight paced answer writes before the Escape lands. */
/** Cancels unsent answer groups before issuing Escape. */
cancelPending: () => void
onSendError: (message: string) => void
}): () => Promise<boolean> {
@@ -29,6 +29,7 @@ export function useMobileNativeChatCancelAsk(args: {
terminal: handle,
text: String.fromCharCode(27),
enter: false,
requireWriteSettlement: true,
...(deviceTokenRef.current
? { mobileClient: { id: deviceTokenRef.current, type: 'mobile' } }
: {})
@@ -160,6 +160,7 @@ import {
type MobileNativeChatController
} from './use-mobile-native-chat-controller'
import type { MobileNativeChatStatus } from './use-mobile-native-chat-session'
import { mobileNativeChatPromptDismissals } from './mobile-native-chat-prompt-dismissals'
const sendWithOutcome = vi.mocked(sendMobileNativeChatMessageWithOutcome)
@@ -668,10 +669,10 @@ describe('useMobileNativeChatController ask dismissal across a transcript reload
}
beforeEach(() => {
mobileNativeChatPromptDismissals.clearForTests()
viewMode.isTabChatView = () => true
setTranscript('ready')
promptsState.ask = PROMPT
promptsState.detectedAsk = PROMPT
Object.assign(promptsState, { ask: PROMPT, detectedAsk: PROMPT })
act(() => {
renderer = create(createElement(Harness))
})
@@ -681,8 +682,7 @@ describe('useMobileNativeChatController ask dismissal across a transcript reload
act(() => renderer?.unmount())
renderer = null
controller = null
promptsState.ask = null
promptsState.detectedAsk = null
Object.assign(promptsState, { ask: null, detectedAsk: null })
setTranscript('ready', 0)
viewMode.isTabChatView = () => true
activeTab.id = 'tab-1'
@@ -17,6 +17,8 @@ import { useThrottledLatestValue } from './use-throttled-latest-value'
import type { MobileNativeChatController } from './mobile-native-chat-controller-contract'
import { useMobileBridgeChatPromptWrites } from './use-mobile-bridge-chat-prompt-writes'
import { useMobileNativeChatActiveResolution } from './use-mobile-native-chat-active-resolution'
import { useMobileNativeChatPromptCards } from './use-mobile-native-chat-prompt-cards'
import { mobileNativeChatScopeKey } from './mobile-native-chat-scope-key'
export type { MobileNativeChatController } from './mobile-native-chat-controller-contract'
@@ -164,14 +166,11 @@ export function useMobileNativeChatController(args: {
const nativeChatTranscriptSettled =
nativeChatSession.status === 'ready' ||
(nativeChatSession.status === 'error' && nativeChatSession.messages.length > 0)
const {
askKey: nativeChatAskKey,
showAsk: showNativeChatAsk,
dismissAsk: dismissNativeChatAsk
} = useMobileNativeChatAskDismiss({
const promptScopeKey = mobileNativeChatScopeKey(hostId, worktreeId, activeSessionTabId)
const askDismissal = useMobileNativeChatAskDismiss({
ask: nativeChatAskPrompt,
detectedAsk: nativeChatDetectedAsk,
scopeKey: activeSessionTabId,
scopeKey: promptScopeKey,
sessionKey: activeChatSessionId,
observing: showNativeChat && (nativeChatDetectedAsk != null || nativeChatTranscriptSettled)
})
@@ -270,14 +269,20 @@ export function useMobileNativeChatController(args: {
// Card actions retire the route's held failure banner too, not just sends.
const answerAsk = useNativeChatAcceptedAction(handleNativeChatAnswerAsk, onSendResolved)
const cancelAsk = useNativeChatAcceptedAction(handleNativeChatCancelAsk, onSendResolved)
const handleNativeChatRespondPermission = activeChatStructured
? structuredNativeChat.respondPermission
: legacyHandleNativeChatRespondPermission
const respond = useNativeChatAcceptedAction(handleNativeChatRespondPermission, onSendResolved)
const structuredCancelPrompt = useNativeChatAcceptedAction(
activeChatStructured ? structuredNativeChat.cancelPrompt : async () => false,
const promptCards = useMobileNativeChatPromptCards({
terminal: {
permission: legacyNativeChatPermission,
question: legacyQuestion,
waitStartedAt: nativeChatStatus?.stateStartedAt ?? null,
scopeKey: promptScopeKey,
sessionKey: streamIdentity,
observing: showNativeChat && !activeChatStructured && nativeChatStatus != null,
respondPermission: legacyHandleNativeChatRespondPermission,
answerQuestion: legacyHandleNativeChatQuestionAnswer
},
structured: activeChatStructured ? structuredNativeChat : null,
onSendResolved
)
})
return {
isTabChatView,
@@ -304,27 +309,22 @@ export function useMobileNativeChatController(args: {
nativeChatStreamingText,
nativeChatStreamLive,
nativeChatStreamScopeKey: streamScopeKey,
nativeChatPermission: activeChatStructured
? structuredNativeChat.permission
: legacyNativeChatPermission,
nativeChatQuestion: activeChatStructured ? structuredNativeChat.question : legacyQuestion,
nativeChatAsk: !activeChatStructured && showNativeChatAsk ? nativeChatAskPrompt : null,
nativeChatAskKey,
dismissNativeChatAsk,
...promptCards,
nativeChatAsk:
!activeChatStructured && (askDismissal.showAsk || askDismissal.collapsedAsk)
? nativeChatAskPrompt
: null,
nativeChatAskKey: askDismissal.askKey,
dismissNativeChatAsk: askDismissal.dismissAsk,
collapseNativeChatAsk: askDismissal.collapseAsk,
nativeChatCollapsedPrompt: askDismissal.collapsedAsk ?? promptCards.nativeChatCollapsedPrompt,
handleNativeChatAnswerAsk: answerAsk,
handleNativeChatCancelAsk: cancelAsk,
// Heuristic/legacy cards have no durable prompt identity, so keep their
// cancel affordance absent instead of exposing a dead action.
handleNativeChatCancelPrompt: activeChatStructured ? structuredCancelPrompt : undefined,
handleNativeChatRespondPermission: respond,
handleNativeChatStop: activeChatStructured ? structuredNativeChat.cancel : handleNativeChatStop,
// The inactive lane's session is starved of identity, so its cards stay empty.
nativeChatQueued: structuredNativeChat.queued,
nativeChatFilePaths,
loadNativeChatFiles,
handleNativeChatQuestionAnswer: activeChatStructured
? structuredNativeChat.respondQuestion
: legacyHandleNativeChatQuestionAnswer,
handleNativeChatSend: activeChatStructured
? structuredNativeChatSend.send
: handleNativeChatSend,
@@ -92,3 +92,14 @@ export function useSettledMobileNativeChatInputLock(
}, [lockHeld, rawLockHeld])
return lockHeld ? (rawLockReason ?? 'waiting') : null
}
/** What the composer says while the input lease blocks it, or its normal prompt. */
export function mobileNativeChatComposerPlaceholder(
lockReason: MobileNativeChatInputLockReason | null
): string {
return lockReason === 'disconnected'
? 'Reconnecting…'
: lockReason === 'waiting'
? 'Waiting for terminal…'
: 'Message, @files, /commands'
}
@@ -92,7 +92,8 @@ export function useMobileNativeChatMessageSend(args: {
images: string[] | undefined,
syncComposer: boolean,
recordControlSend: boolean,
sharedDeadline?: number
sharedDeadline?: number,
requireWriteSettlement?: true
): Promise<MobileNativeChatSendOutcome> => {
// The host writes trailing whitespace verbatim onto the agent's input line,
// where it can glue the next rapid send onto this one (#14262). Only the
@@ -175,6 +176,7 @@ export function useMobileNativeChatMessageSend(args: {
client,
terminal: handle,
command: text,
...(requireWriteSettlement ? { requireWriteSettlement } : {}),
...(resolvedLaunchDraft ? { resolvedLaunchDraft } : {}),
...(mobileClient ? { mobileClient } : {}),
deadline
@@ -183,6 +185,7 @@ export function useMobileNativeChatMessageSend(args: {
client,
terminal: handle,
text,
...(requireWriteSettlement ? { requireWriteSettlement } : {}),
...(resolvedLaunchDraft ? { resolvedLaunchDraft } : {}),
deadline,
...(mobileClient ? { mobileClient } : {})
@@ -262,7 +265,7 @@ export function useMobileNativeChatMessageSend(args: {
return false
}
try {
return (await sendMessage(text, undefined, false, true)) !== 'rejected'
return (await sendMessage(text, undefined, false, true, undefined, true)) === 'accepted'
} finally {
if (terminal) {
releaseMobileNativeChatTerminalWrite(terminal)
@@ -0,0 +1,53 @@
import type { MobileNativeChatController } from './mobile-native-chat-controller-contract'
import type { useMobileStructuredAgentSession } from './use-mobile-structured-agent-session'
import { useNativeChatAcceptedAction } from './use-native-chat-action-outcomes'
import { useMobileNativeChatPromptPresentation } from './use-mobile-native-chat-prompt-presentation'
/** Select the lane's existing card actions and apply terminal-only presentation dismissal. */
export function useMobileNativeChatPromptCards({
terminal,
structured,
onSendResolved
}: {
terminal: Parameters<typeof useMobileNativeChatPromptPresentation>[0]
structured: Pick<
ReturnType<typeof useMobileStructuredAgentSession>,
'permission' | 'question' | 'respondPermission' | 'respondQuestion' | 'cancelPrompt'
> | null
onSendResolved: () => void
}): Pick<
MobileNativeChatController,
| 'nativeChatPermission'
| 'nativeChatQuestion'
| 'nativeChatPromptKey'
| 'handleNativeChatRespondPermission'
| 'handleNativeChatQuestionAnswer'
| 'handleNativeChatCancelPrompt'
| 'collapseNativeChatPrompt'
| 'nativeChatCollapsedPrompt'
> {
const respond = useNativeChatAcceptedAction(
structured?.respondPermission ?? terminal.respondPermission,
onSendResolved
)
const cancel = useNativeChatAcceptedAction(
structured?.cancelPrompt ?? (async () => false),
onSendResolved
)
const presentation = useMobileNativeChatPromptPresentation({
...terminal,
respondPermission: respond
})
return {
nativeChatPermission: structured ? structured.permission : presentation.permission,
nativeChatQuestion: structured ? structured.question : presentation.question,
nativeChatPromptKey: structured ? null : presentation.occurrenceKey,
handleNativeChatRespondPermission: structured ? respond : presentation.respondPermission,
handleNativeChatQuestionAnswer: structured
? structured.respondQuestion
: presentation.answerQuestion,
handleNativeChatCancelPrompt: structured ? cancel : undefined,
collapseNativeChatPrompt: structured ? undefined : presentation.collapsePrompt,
nativeChatCollapsedPrompt: structured ? null : presentation.collapsed
}
}
@@ -0,0 +1,78 @@
import { useEffect, useLayoutEffect, useMemo, useRef, useSyncExternalStore } from 'react'
import {
mobileNativeChatPromptDismissals as dismissals,
type MobileNativeChatPromptDismissal
} from './mobile-native-chat-prompt-dismissals'
type DetectedPrompt = { sessionKey: string | null; promptKey: string | null }
/** Presentation only: retain one answered or collapsed occurrence per tab until observations
* supersede it. */
export function useMobileNativeChatPromptDismiss({
kind,
promptKey,
detectedPromptKey,
scopeKey,
sessionKey,
observing
}: {
/** Ask and permission/question cards keep separate answers for the same tab. */
kind: 'ask' | 'prompt'
promptKey: string | null
detectedPromptKey: string | null
scopeKey: string | null
sessionKey: string | null
observing: boolean
}): {
showPrompt: boolean
collapsed: boolean
dismissPrompt: () => void
collapsePrompt: () => void
expandPrompt: () => void
} {
const storeKey = JSON.stringify([kind, scopeKey])
const detectedByScopeRef = useRef(new Map<string | null, DetectedPrompt>())
const observation = useMemo(() => {
const previous = detectedByScopeRef.current.get(scopeKey)
return previous?.sessionKey === sessionKey && previous.promptKey === detectedPromptKey
? previous
: { sessionKey, promptKey: detectedPromptKey }
}, [sessionKey, detectedPromptKey, scopeKey])
const dismissed = useSyncExternalStore(dismissals.subscribe, () => dismissals.read(storeKey))
useLayoutEffect(() => {
if (observing) {
detectedByScopeRef.current.set(scopeKey, observation)
}
}, [observing, detectedPromptKey, scopeKey, sessionKey, observation])
// A cleared or genuinely different detected prompt retires the old dismissal.
useEffect(() => {
if (
observing &&
dismissed !== undefined &&
!(dismissed.sessionKey === sessionKey && dismissed.promptKey === detectedPromptKey)
) {
dismissals.forget(storeKey)
}
}, [observing, dismissed, detectedPromptKey, storeKey, sessionKey])
const matches =
promptKey !== null && dismissed?.sessionKey === sessionKey && dismissed.promptKey === promptKey
const record = (state: MobileNativeChatPromptDismissal['state']): void => {
const detected = detectedByScopeRef.current.get(scopeKey)
if (
promptKey !== null &&
detected === observation &&
detected.sessionKey === sessionKey &&
detected.promptKey === promptKey
) {
dismissals.write(storeKey, { sessionKey, promptKey, state })
}
}
return {
showPrompt: promptKey !== null && !matches,
collapsed: matches && dismissed?.state === 'collapsed',
dismissPrompt: () => record('answered'),
collapsePrompt: () => record('collapsed'),
expandPrompt: () => dismissals.forget(storeKey)
}
}
@@ -0,0 +1,78 @@
import { useCallback, useMemo } from 'react'
import type { MobileChatPermission } from './mobile-native-chat-permission'
import type { MobileChatQuestion } from './mobile-native-chat-question'
import { useMobileNativeChatPromptDismiss } from './use-mobile-native-chat-prompt-dismiss'
/** Acknowledged answers hide, and the user's Collapse folds, a terminal card without changing
* host status. */
export function useMobileNativeChatPromptPresentation({
permission,
question,
waitStartedAt,
scopeKey,
sessionKey,
observing,
respondPermission,
answerQuestion
}: {
permission: MobileChatPermission | null
question: MobileChatQuestion | null
/** The host wait's start: identical prompts in separate waits are separate occurrences. */
waitStartedAt: number | null
scopeKey: string | null
sessionKey: string | null
observing: boolean
respondPermission: (send: string) => Promise<boolean>
answerQuestion: (text: string) => Promise<boolean>
}) {
const promptKey = useMemo(
() =>
permission
? JSON.stringify(['approval', permission, waitStartedAt])
: question
? JSON.stringify(['question', question, waitStartedAt])
: null,
[permission, question, waitStartedAt]
)
const { showPrompt, collapsed, dismissPrompt, collapsePrompt, expandPrompt } =
useMobileNativeChatPromptDismiss({
kind: 'prompt',
promptKey,
detectedPromptKey: promptKey,
scopeKey,
sessionKey,
observing
})
const respond = useCallback(
async (send: string): Promise<boolean> => {
const accepted = await respondPermission(send)
if (accepted) {
dismissPrompt()
}
return accepted
},
[respondPermission, dismissPrompt]
)
const answer = useCallback(
async (text: string): Promise<boolean> => {
const accepted = await answerQuestion(text)
if (accepted) {
dismissPrompt()
}
return accepted
},
[answerQuestion, dismissPrompt]
)
return {
occurrenceKey: promptKey === null ? null : JSON.stringify([scopeKey, sessionKey, promptKey]),
permission: showPrompt || collapsed ? permission : null,
question: showPrompt || collapsed ? question : null,
collapsePrompt,
collapsed:
collapsed && (permission ?? question)
? { title: permission?.title ?? question?.question ?? '', expand: expandPrompt }
: null,
respondPermission: respond,
answerQuestion: answer
}
}
@@ -58,8 +58,9 @@ export function useMobileNativeChatPrompts(args: {
const detectedAsk = askFromStatus ?? askFromMessages
return {
permission,
question,
// Why: a heuristic card read from the same waiting status would answer the Ask's dialog.
permission: askFromStatus ? null : permission,
question: askFromStatus ? null : question,
detectedAsk: enabled ? detectedAsk : null,
// Only the status payload needs the paused gate the approval envelope uses:
// it outlives its answer, so a working/done agent must not surface one. The
@@ -0,0 +1,128 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
WRITE_ACCEPTED,
writeRefused,
writeUnverifiable,
type WriteSettlement
} from '../../../../shared/pty-write-settlement'
import { TERMINAL_INPUT_CHUNK_MAX_BYTES } from '../../../../shared/terminal-input'
import { PtyWriteUnavailableError } from '../../../providers/pty-write-unavailable-error'
import { ptyOwnership } from '../provider/ownership-state'
import type { PtyRendererDelivery } from '../session'
import { createPtyWriteInput } from './write-input'
const { provider } = vi.hoisted(() => ({
provider: {
hasPty: vi.fn(() => true),
write: vi.fn(),
writeWithSettlement:
vi.fn<(id: string, data: string) => WriteSettlement | Promise<WriteSettlement>>()
}
}))
vi.mock('../provider/registry', () => ({ tryGetProviderForPty: () => provider }))
const id = 'pty-acceptance'
const send = vi.fn()
const mainWindow: PtyRendererDelivery = {
isDestroyed: () => false,
isFocused: () => true,
isVisible: () => true,
isMinimized: () => false,
webContents: { id: 1, isDestroyed: () => false, send, on: vi.fn(), removeListener: vi.fn() }
}
const input = () => createPtyWriteInput({ mainWindow })
const write = (data: string) =>
input().writePtyInputAccepted({ id, data, inputKind: 'driving', requireWriteSettlement: true })
const paneWrite = (data: string) =>
input().writePtyInputAccepted({ id, data, inputKind: 'driving' })
beforeEach(() => {
vi.clearAllMocks()
provider.hasPty.mockReturnValue(true)
provider.writeWithSettlement.mockReturnValue(WRITE_ACCEPTED)
})
afterEach(() => {
ptyOwnership.delete(id)
})
describe('verified renderer writes reuse provider settlement', () => {
it.each(['local', 'daemon', 'WSL', 'SSH'])(
'waits for %s acceptance without a raw duplicate',
async (host) => {
ptyOwnership.set(id, host === 'SSH' ? 'connection-1' : null)
let finish: (settlement: WriteSettlement) => void = () => {}
provider.writeWithSettlement.mockReturnValueOnce(
new Promise((resolve) => {
finish = resolve
})
)
let completed = false
const pending = Promise.resolve(write('\x1b')).then((accepted) => {
completed = true
return accepted
})
await Promise.resolve()
expect(completed).toBe(false)
expect(provider.writeWithSettlement).toHaveBeenCalledExactlyOnceWith(id, '\x1b')
finish(WRITE_ACCEPTED)
await expect(pending).resolves.toBe(true)
expect(provider.write).not.toHaveBeenCalled()
}
)
it('reports proven refusal and unknown acknowledgment separately', async () => {
ptyOwnership.set(id, 'connection-1')
provider.writeWithSettlement.mockReturnValueOnce(writeRefused('endpoint_disconnected'))
expect(write('1')).toBe(false)
provider.writeWithSettlement.mockResolvedValueOnce(
writeUnverifiable('transport_settlement_lost', true)
)
await expect(write('1')).rejects.toThrow('acknowledgment unavailable')
expect(provider.write).not.toHaveBeenCalled()
})
it('never sends an unowned or absent PTY', () => {
expect(write('1')).toBe(false)
ptyOwnership.set(id, null)
provider.hasPty.mockReturnValue(false)
expect(write('1')).toBe(false)
expect(provider.writeWithSettlement).not.toHaveBeenCalled()
})
it('waits for each chunk and stops a paste after refusal', async () => {
ptyOwnership.set(id, 'connection-1')
const paste = 'x'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES * 2 + 1)
provider.writeWithSettlement.mockReturnValueOnce(writeRefused('transport_queue_full'))
await expect(write(paste)).resolves.toBe(false)
provider.writeWithSettlement
.mockReturnValueOnce(WRITE_ACCEPTED)
.mockReturnValueOnce(writeRefused('transport_queue_full'))
// An accepted prefix makes the refusal partial, matching the paired host's verdict.
await expect(write(paste)).rejects.toThrow('acknowledgment unavailable: partial_write')
expect(provider.writeWithSettlement).toHaveBeenCalledTimes(3)
expect(provider.write).not.toHaveBeenCalled()
})
})
describe('pane Escape/Ctrl+C keep the plain accepted write', () => {
it('writes local input without waiting for settlement and refuses SSH', () => {
ptyOwnership.set(id, null)
expect(paneWrite('\x1b')).toBe(true)
expect(provider.write).toHaveBeenCalledExactlyOnceWith(id, '\x1b')
ptyOwnership.set(id, 'connection-1')
expect(paneWrite('\x03')).toBe(false)
expect(provider.writeWithSettlement).not.toHaveBeenCalled()
})
it('asks a pane awaiting daemon recovery to remount on either route', () => {
ptyOwnership.set(id, null)
provider.write.mockImplementationOnce(() => {
throw new PtyWriteUnavailableError('awaiting recovery')
})
expect(paneWrite('\x1b')).toBe(false)
provider.writeWithSettlement.mockReturnValueOnce(writeRefused('endpoint_awaiting_recovery'))
expect(write('1')).toBe(false)
expect(send).toHaveBeenCalledTimes(2)
expect(send).toHaveBeenCalledWith('pty:writeUnavailable', { id })
})
})
+89 -19
View File
@@ -11,6 +11,7 @@ import { ptyOwnership } from '../provider/ownership-state'
import { tryGetProviderForPty } from '../provider/registry'
import type { TerminalInputKind } from '../../../../shared/terminal-input-kind'
import { interactiveOutputCharsByPty, lastInputAtByPty } from '../delivery/visibility-state'
import { isSettledWrite, type WriteSettlement } from '../../../../shared/pty-write-settlement'
export function isMainWindowPtyIpcEvent(
event: IpcMainEvent | IpcMainInvokeEvent,
@@ -26,7 +27,13 @@ export function isMainWindowPtyIpcEvent(
)
}
export type PtyWritePayload = { id: string; data: string; inputKind: TerminalInputKind }
export type PtyWritePayload = {
id: string
data: string
inputKind: TerminalInputKind
/** Accepted-write callers only: wait for the provider's settlement, on any provider. */
requireWriteSettlement?: true
}
export type PtyViewportClaimPayload = { id: string; cols: number; rows: number }
export function createPtyWriteInput(deps: {
@@ -41,9 +48,8 @@ export function createPtyWriteInput(deps: {
} {
const { mainWindow, runtime } = deps
const reportUnavailablePtyWrite = (id: string, error: unknown): void => {
const sendPtyWriteUnavailable = (id: string): void => {
if (
!isPtyWriteUnavailableError(error) ||
!mainWindow ||
mainWindow.isDestroyed() ||
(typeof mainWindow.webContents.isDestroyed === 'function' &&
@@ -54,49 +60,89 @@ export function createPtyWriteInput(deps: {
mainWindow.webContents.send('pty:writeUnavailable', { id })
}
const reportUnavailablePtyWrite = (id: string, error: unknown): void => {
if (isPtyWriteUnavailableError(error)) {
sendPtyWriteUnavailable(id)
}
}
const writePtyProviderInputWithinLimit = (
provider: IPtyProvider,
id: string,
data: string
data: string,
verify = false
): boolean | Promise<boolean> => {
const chunks = iterateTerminalInputChunks(data)
const first = chunks.next()
if (first.done) {
provider.write(id, data)
return true
return writeChunk(provider, id, data, verify)
}
const second = chunks.next()
if (second.done) {
provider.write(id, first.value)
return writeChunk(provider, id, first.value, verify)
}
return writePtyProviderInputChunks(provider, id, chunks, first.value, second.value, verify)
}
const acceptedSettlement = (id: string, settlement: WriteSettlement): boolean => {
if (settlement.outcome === 'unverifiable') {
// A lost acknowledgment must not trigger a fallback write of the same bytes.
throw new Error(`PTY write acknowledgment unavailable: ${settlement.reason}`)
}
if (settlement.outcome === 'refused' && settlement.reason === 'endpoint_awaiting_recovery') {
// Settlement reports what a plain write would have thrown; the pane still needs to remount.
sendPtyWriteUnavailable(id)
}
return settlement.outcome === 'accepted'
}
const writeChunk = (
provider: IPtyProvider,
id: string,
data: string,
verify: boolean
): boolean | Promise<boolean> => {
if (!verify) {
provider.write(id, data)
return true
}
return writePtyProviderInputChunks(provider, id, chunks, first.value, second.value)
const settlement = provider.writeWithSettlement(id, data)
return isSettledWrite(settlement)
? acceptedSettlement(id, settlement)
: settlement.then((settled) => acceptedSettlement(id, settled))
}
const failedWrite = (id: string, error: unknown, verify: boolean): false => {
reportUnavailablePtyWrite(id, error)
if (verify && !isPtyWriteUnavailableError(error)) {
throw error
}
return false
}
const writePtyProviderInput = (
provider: IPtyProvider,
id: string,
data: string
data: string,
verify = false
): boolean | Promise<boolean> => {
try {
const tooLarge = isTerminalInputTooLargeWithDeferredMeasurement(data)
if (typeof tooLarge === 'boolean') {
return tooLarge ? false : writePtyProviderInputWithinLimit(provider, id, data)
return tooLarge ? false : writePtyProviderInputWithinLimit(provider, id, data, verify)
}
return tooLarge
.then((result) => {
if (result) {
return false
}
return writePtyProviderInputWithinLimit(provider, id, data)
return writePtyProviderInputWithinLimit(provider, id, data, verify)
})
.catch((error) => {
reportUnavailablePtyWrite(id, error)
return false
return failedWrite(id, error, verify)
})
} catch (error) {
reportUnavailablePtyWrite(id, error)
return false
return failedWrite(id, error, verify)
}
}
@@ -105,13 +151,23 @@ export function createPtyWriteInput(deps: {
id: string,
chunks: Iterator<string>,
firstChunk: string,
secondChunk: string
secondChunk: string,
verify: boolean
): Promise<boolean> => {
try {
let chunk: IteratorResult<string> = { done: false, value: firstChunk }
let nextChunk: IteratorResult<string> = { done: false, value: secondChunk }
let wroteChunk = false
while (!chunk.done) {
provider.write(id, chunk.value)
const accepted = writeChunk(provider, id, chunk.value, verify)
if (!(typeof accepted === 'boolean' ? accepted : await accepted)) {
if (wroteChunk) {
// An accepted prefix is already in the PTY, so this is not a clean refusal.
throw new Error('PTY write acknowledgment unavailable: partial_write')
}
return false
}
wroteChunk = true
if (!nextChunk.done) {
// setImmediate, not setTimeout(0): the yield exists to let abort/data callbacks run
// between chunks, and a clamped timer tick per 16 KiB is pure latency.
@@ -122,8 +178,7 @@ export function createPtyWriteInput(deps: {
}
return true
} catch (error) {
reportUnavailablePtyWrite(id, error)
return false
return failedWrite(id, error, verify)
}
}
@@ -172,10 +227,25 @@ export function createPtyWriteInput(deps: {
}
}
const writePtyInputSettled = (args: PtyWritePayload): boolean | Promise<boolean> => {
if (!ptyOwnership.has(args.id)) {
return false
}
const provider = tryGetProviderForPty(args.id)
if (!provider?.hasPty?.(args.id)) {
return false
}
noteRendererPtyInput(args)
return writePtyProviderInput(provider, args.id, args.data, true)
}
const writePtyInputAccepted = (args: PtyWritePayload): boolean | Promise<boolean> => {
if (runtime?.getDriver(args.id).kind === 'mobile') {
return false
}
if (args.requireWriteSettlement === true) {
return writePtyInputSettled(args)
}
// Why: the ack infers Ctrl+C/Escape reached the local PTY; SSH providers are fire-and-forget relay notifications and can't truthfully acknowledge yet.
if (ptyOwnership.get(args.id) !== null) {
return false
@@ -106,6 +106,7 @@ export class OrcaRuntimeWithControllerKnowsPtyIsLive extends OrcaRuntimeWithReso
afterWrite?: (ptyId: string) => void | Promise<void>
suffixFailureError?: string
inputKind: TerminalInputKind
requireWriteSettlement?: true
}
): Promise<RuntimeTerminalSend> {
const pty = this.getLivePtyForHandle(handle)
@@ -118,11 +119,20 @@ export class OrcaRuntimeWithControllerKnowsPtyIsLive extends OrcaRuntimeWithReso
throw new Error('invalid_terminal_send')
}
await assertTerminalInputWithinLimitWithYield(action.text)
await this.writeTerminalAction(pty.pty.ptyId, action, payload, options)
const writeSettlement = await this.writeTerminalAction(
pty.pty.ptyId,
action,
payload,
options
)
return {
handle,
accepted: true,
bytesWritten: Buffer.byteLength(payload, 'utf8')
accepted: !writeSettlement || writeSettlement.outcome === 'accepted',
...(writeSettlement ? { writeSettlement } : {}),
bytesWritten:
!writeSettlement || writeSettlement.outcome === 'accepted'
? Buffer.byteLength(payload, 'utf8')
: 0
}
}
@@ -143,12 +153,16 @@ export class OrcaRuntimeWithControllerKnowsPtyIsLive extends OrcaRuntimeWithReso
throw new Error('terminal_not_writable')
}
await this.writeTerminalAction(leaf.ptyId, action, payload, options)
const writeSettlement = await this.writeTerminalAction(leaf.ptyId, action, payload, options)
return {
handle,
accepted: true,
bytesWritten: Buffer.byteLength(payload, 'utf8')
accepted: !writeSettlement || writeSettlement.outcome === 'accepted',
...(writeSettlement ? { writeSettlement } : {}),
bytesWritten:
!writeSettlement || writeSettlement.outcome === 'accepted'
? Buffer.byteLength(payload, 'utf8')
: 0
}
}
@@ -10,6 +10,7 @@ import type { RuntimePtyWorktreeRecord } from './runtime-terminal-state-records'
import { renewRuntimeMobileAgentStatusFromPtyTitle } from './runtime-mobile-agent-status-projection'
import { getDisplayPromptLifecycle } from './runtime-worktree-status-projection'
import type { RuntimeTerminalWriteOptions } from './runtime-terminal-writer'
import type { WriteSettlement } from '../../shared/pty-write-settlement'
import { getRegisteredSshState } from '../ssh/ssh-target-registry'
import { splitWorktreeIdForFilesystem } from '../../shared/worktree/id'
import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path'
@@ -164,7 +165,7 @@ export class OrcaRuntimeWithResolveAuthoritativeTerminalWaitPermission extends O
action: { text?: string; enter?: boolean; interrupt?: boolean },
payload: string,
options: RuntimeTerminalWriteOptions
): Promise<void> {
): Promise<WriteSettlement | undefined> {
return this.terminalWriter.writeAction(ptyId, action, payload, options)
}
@@ -172,7 +173,7 @@ export class OrcaRuntimeWithResolveAuthoritativeTerminalWaitPermission extends O
ptyId: string,
text: string,
options: RuntimeTerminalWriteOptions
): Promise<void> {
): Promise<WriteSettlement | undefined> {
return this.terminalWriter.writeChunks(ptyId, text, options)
}
+7 -8
View File
@@ -45,6 +45,7 @@ import type { PtyIncarnationHandleRecord } from './orca-runtime-core'
import { MailPointerRepointScheduler } from './orchestration/mail-pointer-repoint-scheduler'
import { RuntimeTerminalWaiterRegistry } from './runtime-terminal-waiter-registry'
import { RuntimeTerminalWriter } from './runtime-terminal-writer'
import { writeRefused } from '../../shared/pty-write-settlement'
import { RuntimeTerminalIdlePolls } from './runtime-terminal-idle-polls'
import { TerminalIntentionalStops } from './terminal-intentional-stops'
import { TerminalRunFactsRegister, type TerminalSpawnCommit } from './terminal-run-facts'
@@ -250,9 +251,7 @@ export class OrcaRuntimeWithRuntimeId {
this.intentionalPtyStops.noteSpawnCommit(commit.id)
}
// Why: coalesces title/status-driven session.tabs emits so spinner churn
// doesn't fan out (and per-client JSON.stringify) a snapshot several times a
// second. Emit reads the latest snapshot, so only the freshest version ships.
// Coalesce title/status notifications and emit the latest session snapshot.
protected readonly mobileSessionTabsNotifyCoalescer: MobileSessionTabsNotifyCoalescer =
createMobileSessionTabsNotifyCoalescer((worktreeId) =>
this.flushScheduledMobileSessionTabsChanged(worktreeId)
@@ -264,10 +263,7 @@ export class OrcaRuntimeWithRuntimeId {
(worktreeId) => this.touchMobileSessionTabsForWorktree(worktreeId)
)
// Why: concurrent host terminal.focus storms (CLI switch fan-out / bulk open)
// each await a full host reveal; only one terminal can be focused, so latest-wins
// single-flight bounds host work. Does not replace cheaper activation or
// reconnect-scan bounding for sequential soft freezes.
// Concurrent focus requests share one host reveal; the latest pane wins.
protected readonly terminalFocusNavigationCoalescer =
new TerminalFocusNavigationCoalescer<RuntimeTerminalFocus>()
@@ -338,7 +334,10 @@ export class OrcaRuntimeWithRuntimeId {
protected readonly terminalWriter = new RuntimeTerminalWriter(
(ptyId, data, inputKind) => this.ptyController?.write(ptyId, data, inputKind) ?? false,
(ptyId) => this.getPtyWriteHostPlatform(ptyId),
(ptyId) => this.getPtyAgent(ptyId)
(ptyId) => this.getPtyAgent(ptyId),
(ptyId, data, inputKind) =>
this.ptyController?.writeWithSettlement?.(ptyId, data, inputKind) ??
writeRefused('provider_cannot_settle')
)
// Why one source: every tui-idle site must read the same evidence, or they rank one pane differently.
@@ -237,6 +237,7 @@ export const TERMINAL_SEND_METHODS = [
signal,
// Why: a wire write carries no provenance beyond a client's own query reply.
inputKind: params.inputKind === 'query-reply' ? 'query-reply' : 'driving',
...(params.requireWriteSettlement ? { requireWriteSettlement: true as const } : {}),
...(reserveWrite ? { reserveWrite } : {}),
...(params.inputKind !== 'query-reply' && mobileFloorClientId
? { afterWrite: () => commitMobileInputFloorClaim(mobileFloorClaim) }
@@ -0,0 +1,162 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { RuntimeTerminalWriter } from './runtime-terminal-writer'
import {
WRITE_ACCEPTED,
writeRefused,
writeUnverifiable,
type WriteSettlement
} from '../../shared/pty-write-settlement'
import { TERMINAL_INPUT_CHUNK_MAX_BYTES } from '../../shared/terminal-input'
afterEach(() => vi.useRealTimers())
describe('requested terminal write settlement', () => {
it('waits for complete body settlement before suffix and host acknowledgment', async () => {
vi.useFakeTimers()
const raw = vi.fn((_id: string, _data: string) => true)
const bytes: string[] = []
let finish: (result: WriteSettlement) => void = () => {
throw new Error('write not started')
}
const settled = vi.fn((_id: string, data: string) => {
bytes.push(data)
return bytes.length === 1
? new Promise<WriteSettlement>((resolve) => {
finish = resolve
})
: WRITE_ACCEPTED
})
const writer = new RuntimeTerminalWriter(
raw,
() => 'linux',
() => null,
settled
)
const text = 'x'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES + 1)
const pending = writer.writeAction('pty', { text, enter: true }, `${text}\r`, {
inputKind: 'driving',
requireWriteSettlement: true
})
await vi.advanceTimersByTimeAsync(2000)
expect(bytes).toHaveLength(1)
finish(WRITE_ACCEPTED)
await vi.advanceTimersByTimeAsync(2000)
await expect(pending).resolves.toEqual(WRITE_ACCEPTED)
expect(bytes.join('')).toBe(`${text}\r`)
expect(bytes.at(-1)).toBe('\r')
expect(raw).not.toHaveBeenCalled()
})
it.each([
writeRefused('endpoint_disconnected'),
writeUnverifiable('transport_settlement_lost', true)
])('preserves first-write $outcome and never sends a suffix', async (verdict) => {
const raw = vi.fn((_id: string, _data: string) => true)
const settled = vi.fn(() => verdict)
const writer = new RuntimeTerminalWriter(
raw,
() => 'linux',
() => null,
settled
)
await expect(
writer.writeAction('pty', { text: 'body', enter: true }, 'body\r', {
inputKind: 'driving',
requireWriteSettlement: true
})
).resolves.toEqual(verdict)
expect(settled).toHaveBeenCalledOnce()
expect(raw).not.toHaveBeenCalled()
})
it('keeps a later refusal unknown after an acknowledged prefix', async () => {
const settled = vi
.fn()
.mockReturnValueOnce(WRITE_ACCEPTED)
.mockReturnValueOnce(writeRefused('transport_queue_full'))
const writer = new RuntimeTerminalWriter(
() => true,
() => 'linux',
() => null,
settled
)
const text = 'x'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES + 1)
await expect(
writer.writeAction('pty', { text, enter: true }, `${text}\r`, {
inputKind: 'driving',
requireWriteSettlement: true
})
).resolves.toMatchObject({
outcome: 'unverifiable',
reason: 'partial_write',
bytesHandedToTransport: true
})
expect(settled).toHaveBeenCalledTimes(2)
})
it.each(['afterWrite', 'beforeSuffix'] as const)(
'keeps a %s failure unknown after handing off bytes',
async (failure) => {
vi.useFakeTimers()
const settled = vi.fn(() => WRITE_ACCEPTED)
let calls = 0
const writer = new RuntimeTerminalWriter(
() => true,
() => 'linux',
() => null,
settled
)
const pending = writer.writeAction('pty', { text: 'body', enter: true }, 'body\r', {
inputKind: 'driving',
requireWriteSettlement: true,
beforeWrite: () => {
if (++calls > 1 && failure === 'beforeSuffix') {
throw new Error('terminal_not_writable')
}
},
afterWrite: () => {
if (failure === 'afterWrite') {
throw new Error('bookkeeping failed')
}
}
})
await vi.advanceTimersByTimeAsync(1000)
await expect(pending).resolves.toMatchObject({
outcome: 'unverifiable',
bytesHandedToTransport: true
})
expect(settled).toHaveBeenCalledOnce()
}
)
it('refuses a requested settlement before raw writes when the provider cannot settle', async () => {
const raw = vi.fn((_id: string, _data: string) => true)
const writer = new RuntimeTerminalWriter(raw)
await expect(
writer.writeAction('pty', { text: 'choice' }, 'choice', {
inputKind: 'driving',
requireWriteSettlement: true
})
).resolves.toEqual(writeRefused('provider_cannot_settle'))
expect(raw).not.toHaveBeenCalled()
})
it('preserves legacy raw body and Enter requests without adding provider waits', async () => {
vi.useFakeTimers()
const raw = vi.fn((_id: string, _data: string) => true)
const settled = vi.fn(() => WRITE_ACCEPTED)
const writer = new RuntimeTerminalWriter(
raw,
() => 'linux',
() => null,
settled
)
const pending = writer.writeAction('pty', { text: 'body', enter: true }, 'body\r', {
inputKind: 'driving'
})
await vi.advanceTimersByTimeAsync(1000)
await expect(pending).resolves.toBeUndefined()
expect(raw.mock.calls.map((call) => call[1])).toEqual(['body', '\r'])
expect(settled).not.toHaveBeenCalled()
})
})
+82 -12
View File
@@ -2,6 +2,12 @@ import { resolveAgentPromptSubmitDelayForAgent } from '../../shared/agent-prompt
import type { TerminalAgent } from '../../shared/terminal-agent'
import { iterateTerminalInputChunks } from '../../shared/terminal-input'
import type { TerminalInputKind } from '../../shared/terminal-input-kind'
import {
WRITE_ACCEPTED,
writeRefused,
writeUnverifiable,
type WriteSettlement
} from '../../shared/pty-write-settlement'
export type RuntimeTerminalWriteOptions = {
inputKind: TerminalInputKind
@@ -10,6 +16,7 @@ export type RuntimeTerminalWriteOptions = {
reserveWrite?: (ptyId: string) => void
afterWrite?: (ptyId: string) => void | Promise<void>
suffixFailureError?: string
requireWriteSettlement?: true
}
export class RuntimeTerminalWriter {
@@ -17,7 +24,12 @@ export class RuntimeTerminalWriter {
private readonly write: (ptyId: string, data: string, inputKind: TerminalInputKind) => boolean,
private readonly getWriteHostPlatform: (ptyId: string) => NodeJS.Platform = () =>
process.platform,
private readonly getAgent: (ptyId: string) => TerminalAgent | null = () => null
private readonly getAgent: (ptyId: string) => TerminalAgent | null = () => null,
private readonly writeWithSettlement?: (
ptyId: string,
data: string,
inputKind: TerminalInputKind
) => WriteSettlement | Promise<WriteSettlement>
) {}
async writeAction(
@@ -25,13 +37,45 @@ export class RuntimeTerminalWriter {
action: { text?: string; enter?: boolean; interrupt?: boolean },
payload: string,
options: RuntimeTerminalWriteOptions
): Promise<void> {
): Promise<WriteSettlement | undefined> {
let acknowledgedPrefix = false
const guardedOptions = options.requireWriteSettlement
? {
...options,
afterWrite: async (id: string): Promise<void> => {
acknowledgedPrefix = true
await options.afterWrite?.(id)
}
}
: options
try {
const settlement = await this.writeActionWithinLimit(ptyId, action, payload, guardedOptions)
return acknowledgedPrefix && settlement?.outcome === 'refused'
? writeUnverifiable('partial_write', true)
: settlement
} catch (error) {
if (acknowledgedPrefix) {
return writeUnverifiable('partial_write', true)
}
throw error
}
}
private async writeActionWithinLimit(
ptyId: string,
action: { text?: string; enter?: boolean; interrupt?: boolean },
payload: string,
options: RuntimeTerminalWriteOptions
): Promise<WriteSettlement | undefined> {
// Why: direct terminal.send can carry paste-sized text from RPC/mobile
// clients; chunk text before PTY/ConPTY while preserving suffix separation.
const text = typeof action.text === 'string' ? action.text : ''
const hasSuffix = action.enter || action.interrupt
if (text) {
await this.writeChunks(ptyId, text, options)
const settlement = await this.writeChunks(ptyId, text, options)
if (settlement && settlement.outcome !== 'accepted') {
return settlement
}
}
if (hasSuffix) {
const suffix = (action.enter ? '\r' : '') + (action.interrupt ? '\x03' : '')
@@ -56,35 +100,39 @@ export class RuntimeTerminalWriter {
throw error
}
options.reserveWrite?.(ptyId)
if (!this.write(ptyId, suffix, options.inputKind)) {
throw new Error(options.suffixFailureError ?? 'terminal_not_writable')
const settlement = await this.writeInput(ptyId, suffix, options)
if (settlement && settlement.outcome !== 'accepted') {
return settlement
}
await options.afterWrite?.(ptyId)
return
return settlement
}
if (text) {
return
return options.requireWriteSettlement ? WRITE_ACCEPTED : undefined
}
await options.beforeWrite?.(ptyId)
options.reserveWrite?.(ptyId)
if (!this.write(ptyId, payload, options.inputKind)) {
throw new Error('terminal_not_writable')
const settlement = await this.writeInput(ptyId, payload, options)
if (settlement && settlement.outcome !== 'accepted') {
return settlement
}
await options.afterWrite?.(ptyId)
return settlement
}
async writeChunks(
ptyId: string,
text: string,
options: RuntimeTerminalWriteOptions
): Promise<void> {
): Promise<WriteSettlement | undefined> {
const chunks = iterateTerminalInputChunks(text)
let chunk = chunks.next()
while (!chunk.done) {
await options.beforeWrite?.(ptyId)
options.reserveWrite?.(ptyId)
if (!this.write(ptyId, chunk.value, options.inputKind)) {
throw new Error('terminal_not_writable')
const settlement = await this.writeInput(ptyId, chunk.value, options)
if (settlement && settlement.outcome !== 'accepted') {
return settlement
}
await options.afterWrite?.(ptyId)
chunk = chunks.next()
@@ -92,6 +140,28 @@ export class RuntimeTerminalWriter {
await yieldBetweenTerminalInputChunks()
}
}
return options.requireWriteSettlement ? WRITE_ACCEPTED : undefined
}
private async writeInput(
ptyId: string,
data: string,
options: RuntimeTerminalWriteOptions
): Promise<WriteSettlement | undefined> {
if (!options.requireWriteSettlement) {
if (!this.write(ptyId, data, options.inputKind)) {
throw new Error(options.suffixFailureError ?? 'terminal_not_writable')
}
return undefined
}
if (!this.writeWithSettlement) {
return writeRefused('provider_cannot_settle')
}
try {
return await this.writeWithSettlement(ptyId, data, options.inputKind)
} catch {
return writeUnverifiable('provider_threw_after_handoff', true)
}
}
}
+7 -1
View File
@@ -78,7 +78,13 @@ export type PtyApi = {
shellReadyArmed?: boolean
}>
write: (id: string, data: string, inputKind: TerminalInputKind) => void
writeAccepted: (id: string, data: string, inputKind: TerminalInputKind) => Promise<boolean>
/** `requireWriteSettlement` waits for the provider's acknowledgment on any provider. */
writeAccepted: (
id: string,
data: string,
inputKind: TerminalInputKind,
options?: { requireWriteSettlement?: true }
) => Promise<boolean>
onWriteUnavailable?: (callback: (payload: { id: string }) => void) => () => void
resize: (id: string, cols: number, rows: number) => void
claimViewport: (id: string, cols: number, rows: number) => void
@@ -75,8 +75,13 @@ export const ptySessionControlApi = {
write: (id: string, data: string, inputKind: TerminalInputKind): void => {
ipcRenderer.send('pty:write', { id, data, inputKind })
},
writeAccepted: (id: string, data: string, inputKind: TerminalInputKind): Promise<boolean> =>
ipcRenderer.invoke('pty:writeAccepted', { id, data, inputKind }),
writeAccepted: (
id: string,
data: string,
inputKind: TerminalInputKind,
options?: { requireWriteSettlement?: true }
): Promise<boolean> =>
ipcRenderer.invoke('pty:writeAccepted', { id, data, inputKind, ...options }),
onWriteUnavailable: (callback: (payload: { id: string }) => void): (() => void) => {
const handler = (_event: Electron.IpcRendererEvent, payload: { id: string }): void =>
callback(payload)
@@ -131,6 +131,30 @@ describe('NativeChatApprovalCard', () => {
outside.remove()
})
it.each([
['this pane', true],
['another surface', false]
] as const)('with a text field focused in %s, takes focus: %s', (_where, takes) => {
const pane = document.createElement('div')
pane.setAttribute('data-native-chat-root', 'true')
const draft = document.createElement('textarea')
;(takes ? pane : document.body).appendChild(draft)
document.body.appendChild(pane)
draft.focus()
render(
<NativeChatApprovalCard
approval={{ title: 'Allow command?', options: [{ label: 'Allow', send: 'allow' }] }}
onChoose={() => {}}
shouldFocus
/>,
{ container: pane }
)
const card = screen.getByRole('group', { name: 'Allow command?' })
expect(document.activeElement).toBe(takes ? card : draft)
pane.remove()
draft.remove()
})
it('keeps all oversized provider context in one bounded scroller above the actions', () => {
const description = `Read access outside the workspace ${'description '.repeat(400)}`
const decisionReason = `The path is outside the allowed root. ${'reason '.repeat(400)}`
@@ -1,4 +1,4 @@
import { useEffect, useRef } from 'react'
import { useRef } from 'react'
import { ShieldQuestion, X } from 'lucide-react'
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
@@ -10,6 +10,8 @@ import {
} from '../../../../shared/agent-session-approval-subject'
import { NativeChatCodeBlock } from './NativeChatCodeBlock'
import type { ChatApproval } from './native-chat-interactive-prompt'
import { NativeChatPromptCollapseToggle } from './NativeChatPromptCollapse'
import { useNativeChatPromptCardFocus } from './use-native-chat-prompt-card-focus'
export type NativeChatApprovalCardProps = {
approval: ChatApproval
@@ -17,6 +19,10 @@ export type NativeChatApprovalCardProps = {
onChoose: (option: string) => void
/** Cancel the active provider turn while this card owns the composer region. */
onCancel?: () => void
/** Without `onCancel`: fold the card to a strip and give the input back, writing nothing. */
onCollapse?: () => void
/** A choice is being delivered: the options wait for its answer. */
isSubmitting?: boolean
shouldFocus?: boolean
/** A plan body renders as markdown; these make its file paths clickable. */
onLinkClick?: CommentMarkdownLinkClickHandler
@@ -32,6 +38,8 @@ export function NativeChatApprovalCard({
approval,
onChoose,
onCancel,
onCollapse,
isSubmitting = false,
shouldFocus = false,
onLinkClick,
allowFileUriLinks = false
@@ -47,11 +55,8 @@ export function NativeChatApprovalCard({
approval.subject ||
approval.detail
)
useEffect(() => {
if (shouldFocus) {
cardRef.current?.focus()
}
}, [shouldFocus])
useNativeChatPromptCardFocus(cardRef, shouldFocus)
const escape = onCancel ?? (isSubmitting ? undefined : onCollapse)
return (
<div className="min-h-0 shrink overflow-hidden bg-chat-canvas">
@@ -63,10 +68,10 @@ export function NativeChatApprovalCard({
aria-label={approval.title}
tabIndex={-1}
onKeyDown={(event) => {
if (event.key === 'Escape' && !event.nativeEvent.isComposing && onCancel) {
if (event.key === 'Escape' && !event.nativeEvent.isComposing && escape) {
event.preventDefault()
event.stopPropagation()
onCancel()
escape()
}
}}
className="flex min-h-0 w-full flex-1 flex-col gap-2 overflow-hidden rounded-lg border border-input bg-card px-4 py-3 shadow-xs focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
@@ -87,6 +92,12 @@ export function NativeChatApprovalCard({
>
<X className="size-4" />
</button>
) : onCollapse ? (
<NativeChatPromptCollapseToggle
expanded
disabled={isSubmitting}
onToggle={onCollapse}
/>
) : null}
</div>
{hasContext ? (
@@ -170,7 +181,7 @@ export function NativeChatApprovalCard({
<button
key={`${opt.label}-${i}`}
type="button"
disabled={newerSubject}
disabled={newerSubject || isSubmitting}
onClick={() => onChoose(opt.send)}
className={cn(
'rounded-md px-4 py-1.5 text-sm font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50',
@@ -156,6 +156,7 @@ vi.mock('./use-native-chat-send-lifecycle', () => ({
}))
import { NativeChatComposer } from './NativeChatComposer'
import { sendRuntimePtyInput } from '@/runtime/runtime-terminal-inspection'
describe('NativeChatComposer', () => {
beforeEach(() => {
@@ -245,6 +246,22 @@ describe('NativeChatComposer', () => {
)
})
it('writes nothing from a composer hidden under a prompt card that still holds focus', () => {
render(
<NativeChatComposer
terminalTabId="tab-1"
paneKey="tab-1:leaf-1"
targetPtyId="pty-1"
agent="claude"
inputOwnedByCard
/>
)
act(() => mocks.fieldProps?.onSend?.())
act(() => mocks.fieldProps?.onStop?.())
expect(mocks.sendNativeChatMessage).not.toHaveBeenCalled()
expect(sendRuntimePtyInput).not.toHaveBeenCalled()
})
it('associates a delayed submit with its optimistic cache entry', () => {
const onOptimisticSend = vi.fn(() => 'pending-1')
render(
@@ -69,7 +69,8 @@ const NativeChatComposerPane = forwardRef<NativeChatComposerHandle, NativeChatCo
readTerminalScreen,
launchSeed,
structuredTransport,
steerQueued
steerQueued,
inputOwnedByCard = false
},
ref
): React.JSX.Element {
@@ -100,7 +101,8 @@ const NativeChatComposerPane = forwardRef<NativeChatComposerHandle, NativeChatCo
const { cancelPendingSends, trackPendingSend } = useNativeChatSendLifecycle(
terminalTabId,
targetPtyId,
onOptimisticSendCanceled
onOptimisticSendCanceled,
{ inputOwnedByCard, onPendingSendRetired: optimisticSendOutcome?.reject }
)
const { agentCommands, sessionSkillNames } = useNativeChatComposerCatalog(
@@ -138,9 +140,10 @@ const NativeChatComposerPane = forwardRef<NativeChatComposerHandle, NativeChatCo
return { ptyId: targetPtyId, settings: getSettingsForAgentTabRuntimeOwner(terminalTabId) }
}, [targetPtyId, terminalTabId])
// Why inputOwnedByCard: the hidden field can still hold keyboard focus for a frame.
const [hasPty, disabled] = structuredTransport
? [true, !canSend]
: [targetPtyId !== null, targetPtyId === null || !canSend]
: [targetPtyId !== null, targetPtyId === null || !canSend || inputOwnedByCard]
const syncCaret = useCallback((el: NativeChatComposerInput) => {
setCaret(el.selectionStart ?? el.value.length)
@@ -290,6 +293,7 @@ const NativeChatComposerPane = forwardRef<NativeChatComposerHandle, NativeChatCo
})
const interrupt = useNativeChatComposerInterrupt({
inert: inputOwnedByCard,
cancelPendingSends,
isWorking,
onStop,
@@ -18,26 +18,36 @@ const INITIAL_PROMPT = JSON.stringify({
]
})
const storeState = {
agentStatusByPaneKey: {
'tab-1:leaf-1': {
interactivePrompt: INITIAL_PROMPT as string | undefined,
toolName: 'AskUserQuestion' as string | undefined,
state: undefined as string | undefined
}
}
type PaneStatus = {
interactivePrompt?: string
toolName?: string
state?: string
stateStartedAt?: number
}
const paneStatus: PaneStatus = {
interactivePrompt: INITIAL_PROMPT,
toolName: 'AskUserQuestion',
stateStartedAt: 1
}
const storeState = { agentStatusByPaneKey: { 'tab-1:leaf-1': paneStatus } }
vi.mock('../../store', () => ({
useAppStore: (selector: (state: typeof storeState) => unknown) => selector(storeState)
}))
import { NativeChatInteractiveCard } from './NativeChatInteractiveCard'
import { TooltipProvider } from '@/components/ui/tooltip'
import { useNativeChatInteractivePromptCard } from './use-native-chat-interactive-prompt-card'
import { useNativeChatPromptCardPresentation } from './use-native-chat-prompt-card-presentation'
import { nativeChatPromptDismissals } from './native-chat-prompt-dismissals'
// Answered occurrences outlive a view by design; each test starts with none.
beforeEach(nativeChatPromptDismissals.clearForTests)
const mocks = {
sendAnswer: vi.fn<NativeChatInteractiveSend['sendAnswer']>(),
sendRaw: vi.fn<NativeChatInteractiveSend['sendRaw']>(),
sendRawVerified: vi.fn<NativeChatInteractiveSend['sendRawVerified']>(),
cancelPending: vi.fn<NativeChatInteractiveSend['cancelPending']>(),
cancel: vi.fn<NativeChatInteractiveSend['cancel']>(),
cancelAsk: vi.fn<NativeChatInteractiveSend['cancelAsk']>()
@@ -52,52 +62,64 @@ const NO_MESSAGES: readonly NativeChatMessage[] = []
function cardElement(
canSend = true,
messages?: readonly NativeChatMessage[],
onShowingQuestionChange?: (showing: boolean) => void,
transcriptSettled = true
): React.JSX.Element {
return (
<CardHarness
canSend={canSend}
messages={messages}
onShowingQuestionChange={onShowingQuestionChange}
transcriptSettled={transcriptSettled}
/>
)
return <CardHarness canSend={canSend} messages={messages} transcriptSettled={transcriptSettled} />
}
// The view derives the card and hands it over; this stands in for that view.
const SEND: NativeChatInteractiveSend = {
sendAnswer: mocks.sendAnswer,
sendRaw: mocks.sendRaw,
sendRawVerified: mocks.sendRawVerified,
cancelPending: mocks.cancelPending,
cancel: mocks.cancel,
cancelAsk: mocks.cancelAsk
}
// Stands in for the view: it derives the shown card and renders it XOR the composer.
function CardHarness({
canSend,
messages,
onShowingQuestionChange,
transcriptSettled
transcriptSettled,
targetPtyId = 'pty-1'
}: {
canSend: boolean
messages?: readonly NativeChatMessage[]
onShowingQuestionChange?: (showing: boolean) => void
transcriptSettled: boolean
}): React.JSX.Element | null {
targetPtyId?: string
}): React.JSX.Element {
const card = useNativeChatInteractivePromptCard({
paneKey: 'tab-1:leaf-1',
messages: messages ?? NO_MESSAGES,
transcriptSettled: transcriptSettled && messages !== undefined
})
const presentation = useNativeChatPromptCardPresentation({
paneKey: 'tab-1:leaf-1',
targetPtyId,
card,
canSend
})
return (
<NativeChatInteractiveCard
card={card}
canSend={canSend}
onShowingQuestionChange={onShowingQuestionChange}
send={{
sendAnswer: mocks.sendAnswer,
sendRaw: mocks.sendRaw,
cancelPending: mocks.cancelPending,
cancel: mocks.cancel,
cancelAsk: mocks.cancelAsk
}}
/>
<TooltipProvider>
{presentation.card ? (
<NativeChatInteractiveCard
key={presentation.occurrenceKey ?? 'prompt'}
card={presentation.card}
onDismiss={presentation.dismiss}
onCollapse={presentation.collapse}
send={SEND}
/>
) : (
<div data-testid="composer" />
)}
</TooltipProvider>
)
}
function composerShown(): boolean {
return screen.queryByTestId('composer') !== null
}
function askCallMessage(question: string): NativeChatMessage {
return {
id: `call-${question}`,
@@ -136,16 +158,186 @@ function chooseSpacesAndSubmit(): void {
fireEvent.click(screen.getByRole('button', { name: 'Submit' }))
}
const APPROVAL = JSON.stringify({ approval: { tool: 'Bash', summary: 'rm -rf build' } })
describe('NativeChatInteractiveCard approvals', () => {
const status = storeState.agentStatusByPaneKey['tab-1:leaf-1']
beforeEach(() => {
vi.clearAllMocks()
status.interactivePrompt = APPROVAL
status.toolName = undefined
status.state = 'waiting'
status.stateStartedAt = 10
})
afterEach(() => {
cleanup()
status.toolName = 'AskUserQuestion'
})
function deferredDelivery(): { settle: (delivered: boolean) => Promise<void> } {
let resolve: (delivered: boolean) => void = () => {}
mocks.sendRawVerified.mockReturnValue(
new Promise<boolean>((done) => {
resolve = done
})
)
return {
settle: async (delivered) => {
await act(async () => resolve(delivered))
}
}
}
it('ignores approval A settling after answered replacement B', async () => {
let finishA: (accepted: boolean) => void = () => {}
mocks.sendRawVerified
.mockReturnValueOnce(
new Promise<boolean>((resolve) => {
finishA = resolve
})
)
.mockResolvedValueOnce(true)
const view = render(cardElement())
fireEvent.click(screen.getByRole('button', { name: 'Allow' }))
status.stateStartedAt = 20
view.rerender(cardElement())
await act(async () => fireEvent.click(screen.getByRole('button', { name: 'Allow' })))
expect(composerShown()).toBe(true)
await act(async () => finishA(true))
expect(composerShown()).toBe(true)
})
it('ignores a raw acknowledgment from a rebound PTY', async () => {
let finishOld: (accepted: boolean) => void = () => {}
mocks.sendRawVerified.mockReturnValueOnce(
new Promise<boolean>((resolve) => {
finishOld = resolve
})
)
const view = render(<CardHarness canSend transcriptSettled targetPtyId="pty-old" />)
fireEvent.click(screen.getByRole('button', { name: 'Allow' }))
view.rerender(<CardHarness canSend transcriptSettled targetPtyId="pty-new" />)
await act(async () => finishOld(true))
expect(screen.getByRole('button', { name: 'Allow' })).toBeEnabled()
expect(composerShown()).toBe(false)
})
// Why: a composer message typed under an approval lands in the agent's selector.
it('replaces the composer and hides only once the choice was delivered', async () => {
const delivery = deferredDelivery()
render(cardElement())
expect(screen.getByText('Allow Bash?')).toBeInTheDocument()
expect(composerShown()).toBe(false)
fireEvent.click(screen.getByRole('button', { name: 'Allow' }))
expect(mocks.sendRawVerified).toHaveBeenCalledOnce()
expect(screen.getByRole('button', { name: 'Deny' })).toBeDisabled()
fireEvent.click(screen.getByRole('button', { name: 'Deny' }))
expect(mocks.sendRawVerified).toHaveBeenCalledOnce()
await delivery.settle(true)
expect(screen.queryByText('Allow Bash?')).not.toBeInTheDocument()
expect(composerShown()).toBe(true)
})
it('keeps the card answerable when the choice was refused or its delivery is unknown', async () => {
const delivery = deferredDelivery()
render(cardElement())
fireEvent.click(screen.getByRole('button', { name: 'Allow' }))
await delivery.settle(false)
expect(screen.getByText('Allow Bash?')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Allow' })).toBeEnabled()
expect(composerShown()).toBe(false)
})
it.each(['Escape', 'Collapse'])(
'collapses the approval on %s without writing, until a new wait shows it again',
(gesture) => {
const rendered = render(cardElement())
if (gesture === 'Escape') {
fireEvent.keyDown(screen.getByRole('group'), { key: 'Escape' })
} else {
fireEvent.click(screen.getByRole('button', { name: 'Collapse' }))
}
expect(composerShown()).toBe(true)
expect(mocks.sendRawVerified).not.toHaveBeenCalled()
expect(mocks.sendRaw).not.toHaveBeenCalled()
status.stateStartedAt = 20
rendered.rerender(cardElement())
expect(screen.getByText('Allow Bash?')).toBeInTheDocument()
}
)
it('shows a second approval with the same text once it is a new wait', async () => {
mocks.sendRawVerified.mockResolvedValue(true)
const rendered = render(cardElement())
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Allow' }))
})
// The answered wait lingers in status: still the same occurrence.
rendered.rerender(cardElement())
expect(screen.queryByText('Allow Bash?')).not.toBeInTheDocument()
status.stateStartedAt = 20
rendered.rerender(cardElement())
expect(screen.getByText('Allow Bash?')).toBeInTheDocument()
})
it('leaves the composer in place while this window may not send', () => {
render(cardElement(false))
expect(screen.queryByText('Allow Bash?')).not.toBeInTheDocument()
expect(composerShown()).toBe(true)
})
})
describe('NativeChatInteractiveCard answer lifecycle', () => {
it('routes question Cancel to rejection and releases the composer slot without Stop', () => {
const onShowingQuestionChange = vi.fn()
render(cardElement(true, undefined, onShowingQuestionChange))
it('routes question Cancel to rejection and releases the composer slot without Stop', async () => {
mocks.cancelAsk.mockResolvedValue(true)
render(cardElement())
expect(screen.getByTestId('native-chat-question-card-title')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
expect(composerShown()).toBe(false)
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
})
expect(mocks.cancelAsk).toHaveBeenCalledOnce()
expect(mocks.cancel).not.toHaveBeenCalled()
expect(screen.queryByTestId('native-chat-question-card-title')).not.toBeInTheDocument()
expect(onShowingQuestionChange).toHaveBeenLastCalledWith(false)
expect(composerShown()).toBe(true)
})
it.each(['Escape', 'Collapse'])('collapses the question on %s without writing', (gesture) => {
render(cardElement())
if (gesture === 'Escape') {
fireEvent.keyDown(screen.getByTestId('native-chat-question-card-title'), { key: 'Escape' })
} else {
fireEvent.click(screen.getByRole('button', { name: 'Collapse' }))
}
expect(composerShown()).toBe(true)
expect(mocks.cancelAsk).not.toHaveBeenCalled()
expect(mocks.sendAnswer).not.toHaveBeenCalled()
})
it('cannot collapse a question while its answer is still being written', () => {
mocks.sendAnswer.mockReturnValue({ settleAfterMs: 5_000 })
render(cardElement())
chooseSpacesAndSubmit()
expect(screen.getByRole('button', { name: 'Collapse' })).toBeDisabled()
fireEvent.keyDown(screen.getByTestId('native-chat-question-card-title'), { key: 'Escape' })
expect(composerShown()).toBe(false)
expect(mocks.cancelPending).not.toHaveBeenCalled()
})
it('keeps the question when its Cancel was not delivered', async () => {
mocks.cancelAsk.mockResolvedValue(false)
render(cardElement())
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
})
expect(screen.getByTestId('native-chat-question-card-title')).toBeInTheDocument()
expect(composerShown()).toBe(false)
})
beforeEach(() => {
vi.clearAllMocks()
@@ -158,7 +350,7 @@ describe('NativeChatInteractiveCard answer lifecycle', () => {
})
it('keeps the card retryable when no PTY answer was sent', () => {
mocks.sendAnswer.mockReturnValue({ settleAfterMs: 0, waitsForVerifiedDelivery: false })
mocks.sendAnswer.mockReturnValue({ settleAfterMs: 0 })
renderCard()
chooseSpacesAndSubmit()
@@ -169,7 +361,7 @@ describe('NativeChatInteractiveCard answer lifecycle', () => {
})
it('cancels delayed PTY writes when the owning card unmounts', () => {
mocks.sendAnswer.mockReturnValue({ settleAfterMs: 5_000, waitsForVerifiedDelivery: false })
mocks.sendAnswer.mockReturnValue({ settleAfterMs: 5_000 })
const rendered = renderCard()
chooseSpacesAndSubmit()
@@ -180,7 +372,7 @@ describe('NativeChatInteractiveCard answer lifecycle', () => {
})
it('cancels delayed PTY writes when desktop send authority is lost', () => {
mocks.sendAnswer.mockReturnValue({ settleAfterMs: 5_000, waitsForVerifiedDelivery: false })
mocks.sendAnswer.mockReturnValue({ settleAfterMs: 5_000 })
const rendered = renderCard()
chooseSpacesAndSubmit()
@@ -190,7 +382,7 @@ describe('NativeChatInteractiveCard answer lifecycle', () => {
})
it('shows the paced send as busy and freezes the snapshotted answer', () => {
mocks.sendAnswer.mockReturnValue({ settleAfterMs: 5_000, waitsForVerifiedDelivery: false })
mocks.sendAnswer.mockReturnValue({ settleAfterMs: 5_000 })
renderCard()
chooseSpacesAndSubmit()
@@ -202,7 +394,7 @@ describe('NativeChatInteractiveCard answer lifecycle', () => {
})
it('cancels the old answer sequence when a replacement prompt arrives', () => {
mocks.sendAnswer.mockReturnValue({ settleAfterMs: 5_000, waitsForVerifiedDelivery: false })
mocks.sendAnswer.mockReturnValue({ settleAfterMs: 5_000 })
const rendered = renderCard()
chooseSpacesAndSubmit()
@@ -221,11 +413,43 @@ describe('NativeChatInteractiveCard answer lifecycle', () => {
expect(screen.getByText('Choose a shell?')).toBeInTheDocument()
})
it('sends only one cancellation, can cancel an answer, and permits retry after unknown', async () => {
let finishCancel: (accepted: boolean) => void = () => {}
let finishAnswer: ((accepted: boolean) => void) | undefined
mocks.sendAnswer.mockImplementation((_prompt, _selections, settled) => {
finishAnswer = settled
return { settleAfterMs: 500 }
})
mocks.cancelAsk
.mockReturnValueOnce(
new Promise<boolean>((resolve) => {
finishCancel = resolve
})
)
.mockResolvedValueOnce(true)
renderCard()
chooseSpacesAndSubmit()
const cancel = screen.getByRole('button', { name: 'Cancel' })
expect(cancel).toBeEnabled()
fireEvent.click(cancel)
expect(cancel).toBeDisabled()
fireEvent.click(cancel)
expect(mocks.cancelAsk).toHaveBeenCalledOnce()
act(() => finishAnswer?.(true))
expect(composerShown()).toBe(false)
await act(async () => finishCancel(false))
expect(cancel).toBeEnabled()
expect(screen.getByRole('button', { name: 'Submit' })).toBeEnabled()
await act(async () => fireEvent.click(cancel))
expect(mocks.cancelAsk).toHaveBeenCalledTimes(2)
expect(composerShown()).toBe(true)
})
it('keeps a verified send visible until delivery succeeds', () => {
let settleDelivery: ((delivered: boolean) => void) | undefined
mocks.sendAnswer.mockImplementation((_prompt, _selections, onDeliverySettled) => {
settleDelivery = onDeliverySettled
return { settleAfterMs: 500, waitsForVerifiedDelivery: true }
return { settleAfterMs: 500 }
})
renderCard()
@@ -240,7 +464,7 @@ describe('NativeChatInteractiveCard answer lifecycle', () => {
let settleDelivery: ((delivered: boolean) => void) | undefined
mocks.sendAnswer.mockImplementation((_prompt, _selections, onDeliverySettled) => {
settleDelivery = onDeliverySettled
return { settleAfterMs: 500, waitsForVerifiedDelivery: true }
return { settleAfterMs: 500 }
})
renderCard()
@@ -267,16 +491,15 @@ describe('NativeChatInteractiveCard transcript fallback', () => {
cleanup()
})
it('renders a pending transcript ask and reports the composer replacement', () => {
const onShowingQuestionChange = vi.fn()
render(cardElement(true, [askCallMessage('Tabs or spaces?')], onShowingQuestionChange))
it('renders a pending transcript ask in place of the composer', () => {
render(cardElement(true, [askCallMessage('Tabs or spaces?')]))
expect(screen.getByText('Tabs or spaces?')).toBeInTheDocument()
expect(onShowingQuestionChange).toHaveBeenCalledWith(true)
expect(composerShown()).toBe(false)
})
it('withholds a retained transcript ask while its replacement read is unsettled', () => {
render(cardElement(true, [askCallMessage('Stale transcript question?')], undefined, false))
render(cardElement(true, [askCallMessage('Stale transcript question?')], false))
expect(screen.queryByText('Stale transcript question?')).not.toBeInTheDocument()
})
@@ -299,14 +522,14 @@ describe('NativeChatInteractiveCard transcript fallback', () => {
})
it('stays dismissed after answering while the transcript call is still pending', () => {
mocks.sendAnswer.mockReturnValue({ settleAfterMs: 500, waitsForVerifiedDelivery: true })
mocks.sendAnswer.mockReturnValue({ settleAfterMs: 500 })
const messages = [askCallMessage('Tabs or spaces?')]
const rendered = render(cardElement(true, messages))
let settleDelivery: ((delivered: boolean) => void) | undefined
mocks.sendAnswer.mockImplementation((_prompt, _selections, onDeliverySettled) => {
settleDelivery = onDeliverySettled
return { settleAfterMs: 500, waitsForVerifiedDelivery: true }
return { settleAfterMs: 500 }
})
chooseSpacesAndSubmit()
act(() => settleDelivery?.(true))
@@ -1,152 +1,148 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { useCallback, useLayoutEffect, useRef, useState } from 'react'
import type { InteractivePromptCard } from './native-chat-interactive-prompt'
import { nativeChatCardDismissKey } from './native-chat-dismiss-key'
import { NativeChatQuestionCard } from './NativeChatQuestionCard'
import { NativeChatApprovalCard } from './NativeChatApprovalCard'
import type { NativeChatInteractiveSend } from './use-native-chat-interactive-send'
/**
* Render the pane's interactive card (see useNativeChatInteractivePromptCard): a
* question wizard or a tool approval. Cleared by the host once the agent moves
* on, so it disappears automatically. Sends through the composer's verified
* runtime path (R8/R6): answers via agent-specific paste or selector
* keystrokes; cancel/deny as ESC.
* Guarded by `canSend` so a mobile presence-lock blocks desktop sends too.
* Render one prompt occurrence the view chose (see useNativeChatPromptCardPresentation): a
* question wizard or a tool approval, in the composer's place. Sends through the composer's
* verified runtime path (R8/R6): answers via agent-specific paste or selector keystrokes;
* cancel/deny as ESC. Unmount cancels scheduled writes and ignores late results; writes already
* issued to the transport cannot be recalled.
*
* Dismiss-on-answer (mobile parity): the live status lingers after answering —
* the agent emits a post-tool event carrying the same prompt — so we track the
* answered prompt by content key and hide the card until a genuinely different
* prompt arrives. The dismissal resets once the prompt clears, so a later
* (even identical) prompt shows again instead of staying hidden.
* The card hides (`onDismiss`) only once its answer was delivered: a refused or unconfirmed
* answer keeps the choices up, so the user can answer again here or in the terminal.
*/
export function NativeChatInteractiveCard({
card,
send,
canSend,
onShowingQuestionChange,
onDismiss,
onCollapse,
shouldFocus = false,
answerInputRef
}: {
card: InteractivePromptCard
card: NonNullable<InteractivePromptCard>
send: NativeChatInteractiveSend
canSend: boolean
/** Reports whether a question card is on screen so the view can replace the
* composer with it (the card's free-text row is the answer input). */
onShowingQuestionChange?: (showing: boolean) => void
/** Hide this occurrence after its answer write was acknowledged. */
onDismiss: () => void
/** Fold this occurrence to a strip above the composer, writing nothing. */
onCollapse?: () => void
/** Take focus when the card takes the input region from the composer. */
shouldFocus?: boolean
/** Forwarded to the question card's free-text row so pane-level Paste keeps
* a target while the composer is unmounted. */
answerInputRef?: React.RefObject<HTMLInputElement | null>
}): React.JSX.Element | null {
const { sendAnswer, sendRaw, cancelPending, cancelAsk } = send
const cardKey = useMemo(() => nativeChatCardDismissKey(card), [card])
const [dismissedKey, setDismissedKey] = useState<string | null>(null)
}): React.JSX.Element {
const { sendAnswer, sendRawVerified, cancelPending, cancelAsk } = send
// A question answer is a paced multi-step write (body→Enter per question); keep
// the card up until it settles instead of dismissing on the click, so it doesn't
// vanish mid-send. `submitting` also gates a second submit racing the first.
const dismissTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const submittingRef = useRef(false)
const [submitting, setSubmitting] = useState(false)
const clearDismissTimer = useCallback((): void => {
if (dismissTimerRef.current) {
clearTimeout(dismissTimerRef.current)
dismissTimerRef.current = null
}
const activeRef = useRef(true)
const attemptRef = useRef(0)
const cancellingRef = useRef(false)
const [cancelling, setCancelling] = useState(false)
const settle = useCallback((): void => {
submittingRef.current = false
setSubmitting(false)
cancellingRef.current = false
setCancelling(false)
}, [])
// A replacement prompt, ownership loss, or unmount must stop both timers and
// PTY writes during commit, before an old answer can type into the new prompt.
useLayoutEffect(
() => () => {
clearDismissTimer()
// Retire callbacks during commit; an already-issued write may still settle later.
useLayoutEffect(() => {
activeRef.current = true
return () => {
activeRef.current = false
attemptRef.current += 1
cancelPending()
},
[canSend, cardKey, cancelPending, clearDismissTimer]
)
// Forget the dismissal once the prompt clears so a fresh prompt can show.
const present = card != null
useEffect(() => {
if (!present) {
setDismissedKey(null)
clearDismissTimer()
}
}, [present, clearDismissTimer])
}, [cancelPending])
// Tell the view when a question card is up so it can hide the composer (this
// card supplies its own input). Reset on unmount so the composer comes back.
const showingQuestion = card?.kind === 'question' && canSend && cardKey !== dismissedKey
useEffect(() => {
onShowingQuestionChange?.(showingQuestion)
return () => onShowingQuestionChange?.(false)
}, [showingQuestion, onShowingQuestionChange])
if (!card || !canSend || cardKey === dismissedKey) {
return null
}
if (card.kind === 'question') {
return (
<NativeChatQuestionCard
key={cardKey ?? 'question'}
prompt={card.prompt}
isSubmitting={submitting}
isCancelling={cancelling}
answerInputRef={answerInputRef}
onAnswer={(selections) => {
if (submittingRef.current) {
return
}
submittingRef.current = true
const dismissAnsweredCard = (): void => {
setDismissedKey(cardKey)
submittingRef.current = false
setSubmitting(false)
dismissTimerRef.current = null
}
const keepRejectedAnswerVisible = (): void => {
submittingRef.current = false
setSubmitting(false)
}
const attempt = ++attemptRef.current
const result = sendAnswer(card.prompt, selections, (delivered) => {
if (!activeRef.current || attempt !== attemptRef.current) {
return
}
settle()
if (delivered) {
dismissAnsweredCard()
} else {
keepRejectedAnswerVisible()
onDismiss()
}
})
if (result.settleAfterMs <= 0) {
// Keep the actionable card visible when its PTY disappeared between
// render and submit; the next live target update can make it retryable.
keepRejectedAnswerVisible()
settle()
return
}
setSubmitting(true)
if (result.waitsForVerifiedDelivery) {
// Why: remote acceptance can outlive the keystroke pacing window.
// Keep the card until delivery is proven instead of cancelling the
// inference callback at the old fixed dismissal deadline.
}}
onCollapse={onCollapse}
shouldFocus={shouldFocus}
onCancel={() => {
if (cancellingRef.current) {
return
}
// Hold the card until the paced write finishes, then mark it answered
// (which hides it and restores the composer).
dismissTimerRef.current = setTimeout(() => {
cancelPending()
dismissAnsweredCard()
}, result.settleAfterMs)
}}
onCancel={() => {
clearDismissTimer()
setDismissedKey(cardKey)
cancelAsk()
settle()
const attempt = ++attemptRef.current
cancellingRef.current = true
setCancelling(true)
submittingRef.current = true
setSubmitting(true)
void cancelAsk()
.catch(() => false)
.then((delivered) => {
if (!activeRef.current || attempt !== attemptRef.current) {
return
}
settle()
if (delivered) {
onDismiss()
}
})
}}
/>
)
}
const choose = (raw: string): void => {
if (submittingRef.current) {
return
}
submittingRef.current = true
const attempt = ++attemptRef.current
setSubmitting(true)
void sendRawVerified(raw)
.catch(() => false)
.then((delivered) => {
if (!activeRef.current || attempt !== attemptRef.current) {
return
}
settle()
if (delivered) {
onDismiss()
}
})
}
return (
<NativeChatApprovalCard
approval={card.approval}
onChoose={(raw) => {
setDismissedKey(cardKey)
sendRaw(raw)
}}
shouldFocus={shouldFocus}
isSubmitting={submitting}
onChoose={choose}
onCollapse={onCollapse}
/>
)
}
@@ -0,0 +1,67 @@
import { ChevronDown, ChevronUp } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { ShortcutKeyCombo } from '@/components/ShortcutKeyCombo'
import { translate } from '@/i18n/i18n'
import type { InteractivePromptCard } from './native-chat-interactive-prompt'
/** A prompt card's one collapse affordance; folding or unfolding writes nothing to the agent. */
export function NativeChatPromptCollapseToggle({
expanded,
disabled = false,
onToggle
}: {
expanded: boolean
disabled?: boolean
onToggle: () => void
}): React.JSX.Element {
const label = expanded
? translate('components.native-chat.prompt.collapse', 'Collapse')
: translate('components.native-chat.prompt.expand', 'Expand')
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label={label}
aria-expanded={expanded}
disabled={disabled}
onClick={onToggle}
>
{expanded ? <ChevronDown /> : <ChevronUp />}
</Button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
{label}
{expanded ? <ShortcutKeyCombo keys={['Esc']} /> : null}
</TooltipContent>
</Tooltip>
)
}
/** A collapsed prompt above the usable composer; expanding gives it the input again. */
export function NativeChatPromptStrip({
card,
onExpand
}: {
card: NonNullable<InteractivePromptCard>
onExpand: () => void
}): React.JSX.Element {
const title =
card.kind === 'approval' ? card.approval.title : (card.prompt.questions[0]?.question ?? '')
return (
<div className="shrink-0 bg-chat-canvas">
<div className="mx-auto w-full max-w-(--chat-content-max-width) px-3 pt-2 sm:px-4">
<div
data-native-chat-prompt-strip="true"
className="flex items-center gap-2 rounded-lg border border-input bg-card py-1 pr-1.5 pl-3.5 shadow-xs"
>
<p className="min-w-0 flex-1 truncate text-sm font-medium text-foreground">{title}</p>
<NativeChatPromptCollapseToggle expanded={false} onToggle={onExpand} />
</div>
</div>
</div>
)
}
@@ -1,19 +1,27 @@
import { ImeInput } from '@/lib/ime-text-field'
import { useState, type RefObject } from 'react'
import { useRef, useState, type RefObject } from 'react'
import { Check, Pencil, X } from 'lucide-react'
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
import type { AskAnswerSelection, AskPrompt } from './native-chat-interactive-prompt'
import { NativeChatPromptCollapseToggle } from './NativeChatPromptCollapse'
import { useNativeChatPromptCardFocus } from './use-native-chat-prompt-card-focus'
import { isEditableTarget } from '@/lib/editable-target'
export type NativeChatQuestionCardProps = {
prompt: AskPrompt
/** Whether the snapshotted answer is still being delivered to the agent. */
isSubmitting?: boolean
isCancelling?: boolean
/** Deliver the chosen answer (per-question option indices + free text). */
onAnswer: (selections: AskAnswerSelection[]) => void
allowOther?: boolean | readonly boolean[]
/** Dismiss the prompt (sends Escape to the agent). */
onCancel: () => void
/** Fold the card to a strip and give the input back, writing nothing; Escape does too. */
onCollapse?: () => void
/** Take focus when the card takes the input region. */
shouldFocus?: boolean
/** Exposes the free-text row so pane-level Paste can target it while the
* card replaces the composer. */
answerInputRef?: RefObject<HTMLInputElement | null>
@@ -34,11 +42,16 @@ const TYPED_ANSWER = -1
export function NativeChatQuestionCard({
prompt,
isSubmitting = false,
isCancelling = false,
onAnswer,
allowOther = true,
onCancel,
onCollapse,
shouldFocus = false,
answerInputRef
}: NativeChatQuestionCardProps): React.JSX.Element {
const cardRef = useRef<HTMLDivElement>(null)
useNativeChatPromptCardFocus(cardRef, shouldFocus)
const [index, setIndex] = useState(0)
// Keep option identity by index: labels are display text and are not guaranteed
// unique, while Claude's selector commits the numbered row (STA-1860).
@@ -158,7 +171,28 @@ export function NativeChatQuestionCard({
// Part of the composer: docked in the bottom input region, matching the
// composer's width and padding, rendered as the "ask" dialog card directly
// above the text input. Its free-text row is the answer input.
<div className="shrink-0 bg-chat-canvas" aria-busy={isSubmitting}>
<div
ref={cardRef}
role="group"
aria-label={q.question}
tabIndex={-1}
className="shrink-0 bg-chat-canvas focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
aria-busy={isSubmitting}
onKeyDown={(event) => {
// Why not from a text field: Escape there is editing, and collapsing would hide the draft.
if (
event.key === 'Escape' &&
!event.nativeEvent.isComposing &&
onCollapse &&
!isSubmitting &&
!isEditableTarget(event.target)
) {
event.preventDefault()
event.stopPropagation()
onCollapse()
}
}}
>
<div className="mx-auto w-full max-w-(--chat-content-max-width) px-3 pt-2 pb-4 sm:px-4">
{total > 1 ? (
<div className="mb-2 flex gap-1 overflow-x-auto pb-1 scrollbar-sleek">
@@ -190,16 +224,24 @@ export function NativeChatQuestionCard({
) : null}
<div className="overflow-hidden rounded-lg border border-input bg-card shadow-xs">
<div className="flex items-start justify-between gap-2 px-3.5 py-2.5">
<div className="flex items-start gap-2 px-3.5 py-2.5">
<p
data-testid="native-chat-question-card-title"
className="min-w-0 break-words text-sm font-semibold text-foreground"
className="min-w-0 flex-1 break-words text-sm font-semibold text-foreground"
>
{q.question}
</p>
{onCollapse ? (
<NativeChatPromptCollapseToggle
expanded
disabled={isSubmitting}
onToggle={onCollapse}
/>
) : null}
<button
type="button"
onClick={onCancel}
disabled={isCancelling}
aria-label={translate('components.native-chat.question.cancel', 'Cancel')}
className="flex size-6 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
@@ -0,0 +1,243 @@
// @vitest-environment happy-dom
import '@testing-library/jest-dom/vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import type { AgentStatusPayload } from '../../../../shared/agent-status-types'
import type { NativeChatMessage } from '../../../../shared/native-chat-types'
import type { NativeChatLiveSession } from './use-native-chat-live-session'
import type {
NativeChatComposerHandle,
NativeChatComposerProps
} from './native-chat-composer-types'
import type * as RuntimeTerminalInspectionModule from '@/runtime/runtime-terminal-inspection'
type RuntimeTerminalInspection = typeof RuntimeTerminalInspectionModule
const MESSAGE = 'please also run lint'
const retained = vi.hoisted((): { session: NativeChatLiveSession | null } => ({ session: null }))
const composer = vi.hoisted(() => ({ mounts: 0, typed: vi.fn<(text: string) => boolean>() }))
const pty = vi.hoisted(() => ({
verified: vi.fn<(settings: unknown, id: string, data: string) => Promise<boolean>>(),
raw: vi.fn<(settings: unknown, id: string, data: string) => boolean>()
}))
vi.mock('./use-native-chat-retained-session', () => ({
useNativeChatRetainedSession: () => retained.session
}))
vi.mock('@/runtime/runtime-terminal-inspection', async (importOriginal) => ({
...(await importOriginal<RuntimeTerminalInspection>()),
sendRuntimePtyInput: pty.raw
}))
vi.mock('@/runtime/runtime-terminal-verified-input', () => ({
sendRuntimePtyInputVerified: pty.verified
}))
// Stand-in field over the production send lifecycle, queue and observed writes, wired as the
// composer wires them.
vi.mock('./NativeChatComposer', async () => {
const { forwardRef, useEffect, useImperativeHandle, useRef } = await import('react')
const { useNativeChatSendLifecycle } = await import('./use-native-chat-send-lifecycle')
const { sendNativeChatMessage } = await import('./native-chat-runtime-send')
return {
NativeChatComposer: forwardRef<NativeChatComposerHandle, NativeChatComposerProps>(
function StandInComposer(props, ref) {
useEffect(() => {
composer.mounts += 1
}, [])
const fieldRef = useRef<HTMLTextAreaElement>(null)
useImperativeHandle(ref, () => ({
focus: () => {
fieldRef.current?.focus()
return true
},
insertTypedText: composer.typed,
handlePasteEvent: () => {},
pasteFromClipboard: () => {},
contains: () => false
}))
const lifecycle = useNativeChatSendLifecycle(
props.terminalTabId,
props.targetPtyId,
props.onOptimisticSendCanceled,
{
inputOwnedByCard: props.inputOwnedByCard === true,
onPendingSendRetired: props.optimisticSendOutcome?.reject
}
)
const send = (): void => {
let pendingId: string | undefined
const handle = sendNativeChatMessage(null, props.targetPtyId ?? '', MESSAGE, {
onWriteRejected: () => {
if (pendingId) {
props.optimisticSendOutcome?.reject(pendingId)
}
}
})
pendingId = props.onOptimisticSend?.(MESSAGE)
lifecycle.trackPendingSend(handle, pendingId)
}
return (
<>
<textarea ref={fieldRef} data-testid="composer-field" />
<button type="button" data-testid="composer-send" onClick={send}>
send
</button>
</>
)
}
)
}
})
const { NativeChatResolvedView } = await import('./NativeChatResolvedView')
const { TooltipProvider } = await import('@/components/ui/tooltip')
const { useAppStore } = await import('../../store')
const { installNativeChatMessageListTestViewport } =
await import('./native-chat-message-list-test-viewport')
const { resetNativeChatPtySendQueuesForTests } = await import('./native-chat-runtime-send')
const { nativeChatPromptDismissals } = await import('./native-chat-prompt-dismissals')
const paneKey = 'tab-hidden:leaf-hidden'
const approval = JSON.stringify({ approval: { tool: 'Bash', summary: 'npm test' } })
let restoreViewport = (): void => {}
const userTurn: NativeChatMessage = {
id: 'user-1',
role: 'user',
blocks: [{ type: 'text', text: 'Clean the build' }],
timestamp: 1,
source: 'transcript'
}
function setStatus(payload: Omit<AgentStatusPayload, 'prompt' | 'agentType'>): void {
useAppStore
.getState()
.setAgentStatus(paneKey, { prompt: 'Clean the build', agentType: 'claude', ...payload })
}
function renderPane(): void {
render(
<TooltipProvider>
<NativeChatResolvedView
paneKey={paneKey}
agent="claude"
sessionId="session-hidden"
transcriptPath={null}
isVisible
isFocusedGroup
targetPtyId="pty-hidden"
terminalTabId="tab-hidden"
ownsTabWideLaunchDraft={false}
/>
</TooltipProvider>
)
}
function rootElement(): Element {
const root = document.querySelector('[data-native-chat-root="true"]')
if (!root) {
throw new Error('chat root missing')
}
return root
}
beforeEach(() => {
restoreViewport = installNativeChatMessageListTestViewport()
resetNativeChatPtySendQueuesForTests()
nativeChatPromptDismissals.clearForTests()
composer.mounts = 0
composer.typed.mockReset().mockReturnValue(true)
pty.verified.mockReset().mockResolvedValue(true)
pty.raw.mockReset().mockReturnValue(true)
useAppStore.setState({ agentStatusByPaneKey: {}, nativeChatLaunchPromptByTabId: {} })
retained.session = {
messages: [userTurn],
status: 'working',
sessionId: 'session-hidden',
agent: 'claude',
hookAwaitingInput: false,
hasMore: false,
loadingEarlier: false,
olderHistoryGeneration: 0,
loadEarlier: vi.fn(),
readPhase: 'ready'
}
})
afterEach(() => {
cleanup()
resetNativeChatPtySendQueuesForTests()
restoreViewport()
useAppStore.setState({ agentStatusByPaneKey: {}, nativeChatLaunchPromptByTabId: {} })
})
describe('a prompt card hides the composer without unmounting it', () => {
it('keeps an in-flight message visible and never submits it into the approval', async () => {
setStatus({ state: 'working' })
renderPane()
await act(async () => {
fireEvent.click(screen.getByTestId('composer-send'))
})
expect(pty.verified.mock.calls.map((call) => call[2]).join('')).toContain(MESSAGE)
// The approval arrives inside the body-to-Enter gap.
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 100))
setStatus({ state: 'waiting', interactivePrompt: approval })
})
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 900))
})
expect(document.querySelector('[data-native-chat-approval-card="true"]')).not.toBeNull()
expect(pty.verified.mock.calls.map((call) => call[2])).not.toContain('\r')
// Only the pre-body clear: retiring the send types nothing under the card.
expect(pty.raw.mock.calls.map((call) => call[2])).toEqual(['\x15'])
expect(screen.getByTestId('composer-send').closest('[hidden]')).not.toBeNull()
expect(screen.getAllByText(MESSAGE).length).toBeGreaterThan(0)
expect(screen.getByText('Message not sent')).toBeInTheDocument()
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Allow' }))
})
expect(screen.getByTestId('composer-send').closest('[hidden]')).toBeNull()
expect(composer.mounts).toBe(1)
})
it('routes no typing to the hidden composer until the card is answered', async () => {
setStatus({ state: 'waiting', interactivePrompt: approval })
renderPane()
fireEvent.keyDown(rootElement(), { key: 'x' })
expect(composer.typed).not.toHaveBeenCalled()
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Allow' }))
})
fireEvent.keyDown(rootElement(), { key: 'x' })
expect(composer.typed).toHaveBeenCalledExactlyOnceWith('x')
expect(composer.mounts).toBe(1)
})
it.each(['Escape', 'chevron'])(
'gives the composer focus after a %s collapse',
async (gesture) => {
setStatus({ state: 'waiting', interactivePrompt: approval })
renderPane()
const card = document.querySelector<HTMLElement>('[data-native-chat-approval-card="true"]')!
expect(document.activeElement).toBe(card)
act(() => {
if (gesture === 'Escape') {
fireEvent.keyDown(card, { key: 'Escape' })
} else {
fireEvent.click(screen.getByRole('button', { name: 'Collapse' }))
}
})
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 100))
})
expect(document.activeElement).toBe(screen.getByTestId('composer-field'))
expect(pty.verified).not.toHaveBeenCalled()
}
)
})
@@ -0,0 +1,309 @@
// @vitest-environment happy-dom
import '@testing-library/jest-dom/vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import type { AgentStatusPayload } from '../../../../shared/agent-status-types'
import type { NativeChatMessage } from '../../../../shared/native-chat-types'
import type { NativeChatLiveSession } from './use-native-chat-live-session'
// The composer is a marker that counts its mounts, so the test sees whether the view remounted it.
const retained = vi.hoisted((): { session: NativeChatLiveSession | null } => ({ session: null }))
const composer = vi.hoisted(() => ({ mounts: 0 }))
const ptyInput = vi.hoisted(() => ({ verified: vi.fn<() => Promise<boolean>>() }))
vi.mock('./use-native-chat-retained-session', () => ({
useNativeChatRetainedSession: () => retained.session
}))
vi.mock('./NativeChatComposer', async () => {
const { useEffect } = await import('react')
return {
NativeChatComposer: () => {
useEffect(() => {
composer.mounts += 1
}, [])
return <div data-testid="native-chat-composer" />
}
}
})
vi.mock('@/runtime/runtime-terminal-verified-input', () => ({
sendRuntimePtyInputVerified: ptyInput.verified
}))
const { NativeChatResolvedView } = await import('./NativeChatResolvedView')
const { TooltipProvider } = await import('@/components/ui/tooltip')
const { nativeChatPromptDismissals } = await import('./native-chat-prompt-dismissals')
const { useAppStore } = await import('../../store')
const { installNativeChatMessageListTestViewport } =
await import('./native-chat-message-list-test-viewport')
const paneKey = 'tab-card:leaf-card'
let restoreViewport = (): void => {}
const userTurn: NativeChatMessage = {
id: 'user-1',
role: 'user',
blocks: [{ type: 'text', text: 'Clean the build' }],
timestamp: 1,
source: 'transcript'
}
const APPROVAL = JSON.stringify({ approval: { tool: 'Bash', summary: 'npm test' } })
const ASK_CALL: NativeChatMessage = {
id: 'call-ask',
role: 'assistant',
timestamp: 2,
source: 'transcript',
blocks: [
{
type: 'tool-call',
name: 'AskUserQuestion',
input: {
questions: [
{ question: 'Tabs or spaces?', multiSelect: false, options: [{ label: 'Tabs' }] }
]
}
}
]
}
function transcript(): NativeChatLiveSession {
return {
messages: [userTurn],
status: 'ready',
sessionId: 'session-card',
agent: 'claude',
hookAwaitingInput: true,
hasMore: false,
loadingEarlier: false,
olderHistoryGeneration: 0,
loadEarlier: vi.fn(),
readPhase: 'ready'
}
}
function setStatus(
payload: Omit<AgentStatusPayload, 'prompt' | 'agentType'>,
stateStartedAt?: number
): void {
useAppStore
.getState()
.setAgentStatus(
paneKey,
{ prompt: 'Clean the build', agentType: 'claude', ...payload },
undefined,
stateStartedAt === undefined ? undefined : { stateStartedAt }
)
}
function renderPane(targetPtyId = 'pty-card'): ReturnType<typeof render> {
return render(paneElement(targetPtyId))
}
function paneElement(targetPtyId = 'pty-card'): React.JSX.Element {
return (
<TooltipProvider>
<NativeChatResolvedView
paneKey={paneKey}
agent="claude"
sessionId="session-card"
transcriptPath={null}
isVisible
isFocusedGroup
targetPtyId={targetPtyId}
terminalTabId="tab-card"
ownsTabWideLaunchDraft={false}
/>
</TooltipProvider>
)
}
beforeEach(() => {
restoreViewport = installNativeChatMessageListTestViewport()
composer.mounts = 0
nativeChatPromptDismissals.clearForTests()
ptyInput.verified.mockReset()
useAppStore.setState({ agentStatusByPaneKey: {}, nativeChatLaunchPromptByTabId: {} })
retained.session = transcript()
})
afterEach(() => {
cleanup()
restoreViewport()
useAppStore.setState({ agentStatusByPaneKey: {}, nativeChatLaunchPromptByTabId: {} })
})
// A terminal-backed send types into the PTY, so whatever card is up would take the message as its
// answer. Every answerable card owns the input region, as structured chat's cards do.
describe('NativeChatResolvedView prompt cards own the input region', () => {
it('shows the composer when no prompt is pending', () => {
setStatus({ state: 'working' })
renderPane()
expect(screen.getByTestId('native-chat-composer')).toBeInTheDocument()
})
it('hides the composer under an approval, and shows it again once Allow lands', async () => {
ptyInput.verified.mockResolvedValue(true)
setStatus({
state: 'waiting',
interactivePrompt: JSON.stringify({ approval: { tool: 'Bash', summary: 'rm -rf build' } })
})
renderPane()
const card = document.querySelector('[data-native-chat-approval-card="true"]')
expect(card).not.toBeNull()
expect(screen.getByTestId('native-chat-composer').closest('[hidden]')).not.toBeNull()
// No text field is visible, so keyboard focus lands on the card's choices.
expect(document.activeElement).toBe(card)
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Allow' }))
})
expect(ptyInput.verified).toHaveBeenCalledOnce()
expect(screen.getByTestId('native-chat-composer').closest('[hidden]')).toBeNull()
expect(composer.mounts).toBe(1)
})
it('keeps the approval up when its choice was not delivered', async () => {
ptyInput.verified.mockRejectedValue(new Error('delivery unknown'))
setStatus({
state: 'waiting',
interactivePrompt: JSON.stringify({ approval: { tool: 'Bash', summary: 'rm -rf build' } })
})
renderPane()
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Allow' }))
})
expect(screen.getByRole('button', { name: 'Allow' })).toBeEnabled()
expect(screen.getByTestId('native-chat-composer').closest('[hidden]')).not.toBeNull()
})
it('hides the composer behind a question card', () => {
setStatus({
state: 'waiting',
toolName: 'AskUserQuestion',
interactivePrompt: JSON.stringify({
questions: [
{
question: 'Which folder?',
multiSelect: false,
options: [{ label: 'build' }, { label: 'dist' }]
}
]
})
})
renderPane()
expect(screen.getAllByText('Which folder?').length).toBeGreaterThan(0)
expect(screen.getByTestId('native-chat-composer').closest('[hidden]')).not.toBeNull()
})
it('keeps an answered approval hidden across a chat view remount and a PTY rebind', async () => {
ptyInput.verified.mockResolvedValue(true)
setStatus({
state: 'waiting',
interactivePrompt: JSON.stringify({ approval: { tool: 'Bash', summary: 'npm test' } })
})
const first = renderPane()
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Allow' }))
})
// The approved tool is still running, so the host status lingers: a chat/terminal toggle.
first.unmount()
const second = renderPane()
expect(document.querySelector('[data-native-chat-approval-card="true"]')).toBeNull()
expect(screen.getByTestId('native-chat-composer').closest('[hidden]')).toBeNull()
second.unmount()
renderPane('pty-card-reconnected')
expect(document.querySelector('[data-native-chat-approval-card="true"]')).toBeNull()
expect(ptyInput.verified).toHaveBeenCalledOnce()
})
it('collapses to a strip that frees the composer, and expanding gives the card the input back', () => {
setStatus({ state: 'waiting', interactivePrompt: APPROVAL })
renderPane()
fireEvent.click(screen.getByRole('button', { name: 'Collapse' }))
const collapsedCard = document.querySelector('[data-native-chat-approval-card="true"]')
expect(collapsedCard?.closest('[hidden]')).toHaveAttribute('inert')
expect(document.querySelector('[data-native-chat-prompt-strip="true"]')).toHaveTextContent(
'Allow Bash?'
)
expect(screen.queryAllByText(/Awaiting user input/)).toHaveLength(0)
expect(screen.getByTestId('native-chat-composer').closest('[hidden]')).toBeNull()
fireEvent.click(screen.getByRole('button', { name: 'Expand' }))
expect(document.activeElement).toBe(
document.querySelector('[data-native-chat-approval-card="true"]')
)
expect(screen.getByTestId('native-chat-composer').closest('[hidden]')).not.toBeNull()
expect(ptyInput.verified).not.toHaveBeenCalled()
})
it('shows an identical question again in a new wait that arrived while the view was unmounted', () => {
const question = JSON.stringify({
questions: [{ question: 'Continue?', multiSelect: false, options: [{ label: 'Yes' }] }]
})
const ask = {
state: 'waiting',
toolName: 'AskUserQuestion',
interactivePrompt: question
} as const
setStatus(ask, 100)
const first = renderPane()
fireEvent.click(screen.getByRole('button', { name: 'Collapse' }))
first.unmount()
setStatus({ state: 'working' }, 200)
setStatus(ask, 300)
renderPane()
expect(screen.getByTestId('native-chat-question-card-title')).toHaveTextContent('Continue?')
})
it('keeps a collapsed transcript question collapsed while the transcript re-reads', () => {
const withAsk = { ...transcript(), messages: [userTurn, ASK_CALL] }
retained.session = withAsk
setStatus({ state: 'waiting' })
const view = renderPane()
fireEvent.click(screen.getByRole('button', { name: 'Collapse' }))
retained.session = { ...withAsk, messages: [], readPhase: 'loading' }
view.rerender(paneElement())
retained.session = withAsk
view.rerender(paneElement())
expect(document.querySelector('[data-native-chat-prompt-strip="true"]')).not.toBeNull()
})
it('keeps a partly answered question across collapse and expand, and Escape in its text field', () => {
setStatus(
{
state: 'waiting',
toolName: 'AskUserQuestion',
interactivePrompt: JSON.stringify({
questions: [
{ question: 'Which folder?', multiSelect: false, options: [{ label: 'dist' }] },
{ question: 'Which mode?', multiSelect: false, options: [{ label: 'slow' }] }
]
})
},
100
)
renderPane()
fireEvent.click(screen.getByRole('button', { name: /dist/ }))
fireEvent.click(screen.getByRole('button', { name: /Next/ }))
const other = screen.getByPlaceholderText('Type your answer')
fireEvent.change(other, { target: { value: 'turbo' } })
fireEvent.keyDown(other, { key: 'Escape' })
expect(document.querySelector('[data-native-chat-prompt-strip="true"]')).toBeNull()
fireEvent.click(screen.getByRole('button', { name: 'Collapse' }))
fireEvent.click(screen.getByRole('button', { name: 'Expand' }))
expect(screen.getByTestId('native-chat-question-card-title')).toHaveTextContent('Which mode?')
expect(screen.getByPlaceholderText('Type your answer')).toHaveValue('turbo')
})
})
@@ -17,6 +17,8 @@ import { useNativeChatFontSize } from './use-native-chat-font-size'
import { useNativeChatCanSend } from './use-native-chat-can-send'
import { NativeChatInteractiveCard } from './NativeChatInteractiveCard'
import { useNativeChatInteractivePromptCard } from './use-native-chat-interactive-prompt-card'
import { useNativeChatPromptCardPresentation } from './use-native-chat-prompt-card-presentation'
import { NativeChatPromptStrip } from './NativeChatPromptCollapse'
import { NativeChatEmptyState } from './NativeChatEmptyState'
import { useNativeChatInteractiveSend } from './use-native-chat-interactive-send'
import { shouldClearNativeChatWorkingSuppression } from './native-chat-working-suppression'
@@ -119,8 +121,6 @@ export function NativeChatResolvedView({
const interactiveSend = useNativeChatInteractiveSend(terminalTabId, paneKey, targetPtyId, agent)
const [workingInterrupted, setWorkingInterrupted] = useState(false)
const previousWorkingEpochRef = useRef<number | null>(null)
// True while a question card owns the input region, so the composer is hidden.
const [questionActive, setQuestionActive] = useState(false)
const rootRef = useRef<HTMLDivElement>(null)
const composerRef = useRef<NativeChatComposerHandle>(null)
// The question card's free-text row; keeps Paste working while the card
@@ -132,13 +132,6 @@ export function NativeChatResolvedView({
composerRef,
questionAnswerInputRef
})
useNativeChatComposerRevealFocus({
rootRef,
composerRef,
isVisible,
isFocusedGroup,
composerReady: !questionActive && targetPtyId !== null && canSend
})
const contextMenu = useNativeChatContextMenu({
rootRef,
onSwitchToTerminal,
@@ -228,6 +221,25 @@ export function NativeChatResolvedView({
messages: sessionAfterCommandBoundaries.messages,
transcriptSettled: session.readPhase === 'ready'
})
// Why one derived value: an answerable card takes the input region from the composer, which
// would type into the agent's selector; both are never visible in one commit.
const promptCardPresentation = useNativeChatPromptCardPresentation({
paneKey,
targetPtyId,
card: promptCard,
canSend,
transcriptSettled: session.readPhase === 'ready'
})
const shownPromptCard = promptCardPresentation.card
const collapsedCard = promptCardPresentation.collapsedCard
const mountedPromptCard = shownPromptCard ?? collapsedCard
useNativeChatComposerRevealFocus({
rootRef,
composerRef,
isVisible,
isFocusedGroup,
composerReady: shownPromptCard === null && targetPtyId !== null && canSend
})
// The streaming preview bubble (if any) sits after the transcript but before
// the optimistic user echoes — same order mobile uses.
@@ -381,22 +393,31 @@ export function NativeChatResolvedView({
/>
)}
</div>
{/* Live interactive prompt (question / approval) is the bottom input region
(mobile parity). A question card supplies its own answer input, so it
fully replaces the composer while active — no stray "Send a message". */}
<NativeChatInteractiveCard
card={promptCard}
send={interactiveSend}
canSend={canSend}
onShowingQuestionChange={setQuestionActive}
answerInputRef={questionAnswerInputRef}
/>
{/* A collapsed card stays mounted but hidden, so a partly answered question survives. */}
{mountedPromptCard ? (
<div hidden={collapsedCard !== null} inert={collapsedCard !== null} className="contents">
<NativeChatInteractiveCard
key={promptCardPresentation.occurrenceKey ?? 'prompt'}
card={mountedPromptCard}
send={interactiveSend}
onDismiss={promptCardPresentation.dismiss}
onCollapse={promptCardPresentation.collapse}
shouldFocus={shownPromptCard !== null && isVisible && isFocusedGroup}
answerInputRef={questionAnswerInputRef}
/>
</div>
) : null}
{collapsedCard ? (
<NativeChatPromptStrip card={collapsedCard} onExpand={promptCardPresentation.expand} />
) : null}
{/* canSend reflects the mobile presence-lock: when a mobile client holds
the pty, the composer shows its guarded state instead of racing the
mobile driver (R8). */}
{questionActive ? null : (
mobile driver (R8). Under a card it stays mounted but hidden, so its state survives;
detaching the ref keeps root typing, paste and reveal focus off it. */}
<div hidden={shownPromptCard !== null} className="contents">
<NativeChatComposer
ref={composerRef}
ref={shownPromptCard ? undefined : composerRef}
inputOwnedByCard={shownPromptCard !== null}
terminalTabId={terminalTabId}
paneKey={paneKey}
targetPtyId={targetPtyId}
@@ -413,7 +434,7 @@ export function NativeChatResolvedView({
readTerminalScreen={readTerminalScreen}
launchSeed={{ ...launchDraftSignal, ownsTabWideLaunchDraft }}
/>
)}
</div>
{contextMenu.menu}
<LinkActionPopover request={linkActionRequest} onClose={closeLinkActions} />
</div>
@@ -17,6 +17,7 @@ vi.mock('./use-native-chat-retained-session', () => ({
vi.mock('./NativeChatComposer', () => ({ NativeChatComposer: () => null }))
const { NativeChatResolvedView } = await import('./NativeChatResolvedView')
const { TooltipProvider } = await import('@/components/ui/tooltip')
const { useAppStore } = await import('../../store')
const { installNativeChatMessageListTestViewport } =
await import('./native-chat-message-list-test-viewport')
@@ -110,17 +111,19 @@ function patchStatus(fields: Partial<AgentStatusEntry>): void {
function renderPane(): void {
render(
<NativeChatResolvedView
paneKey={paneKey}
agent="claude"
sessionId="session-state"
transcriptPath={null}
isVisible
isFocusedGroup={false}
targetPtyId="pty-state"
terminalTabId="tab-state"
ownsTabWideLaunchDraft={false}
/>
<TooltipProvider>
<NativeChatResolvedView
paneKey={paneKey}
agent="claude"
sessionId="session-state"
transcriptPath={null}
isVisible
isFocusedGroup={false}
targetPtyId="pty-state"
terminalTabId="tab-state"
ownsTabWideLaunchDraft={false}
/>
</TooltipProvider>
)
}
@@ -4,7 +4,7 @@
// optimistic-send pruning in native-chat-pending.ts, which is a separate rule.
import type { NativeChatMessage } from '../../../../shared/native-chat-types'
import { setBoundedScopeCacheEntry } from './native-chat-composer-scope-cache'
import { setBoundedScopeCacheEntry } from '../../../../shared/native-chat-scope-cache'
/** A locally-recorded slash command (e.g. `/clear`). Slash commands dispatch to
* the agent's TUI and are not chat turns, so we surface a small system line as
@@ -69,6 +69,8 @@ export type NativeChatComposerProps = {
optimisticSendOutcome?: NativeChatOptimisticSendOutcome
/** Remove an optimistic echo when its delayed submit is canceled. */
onOptimisticSendCanceled?: (pendingId: string) => void
/** A prompt card owns the input region; the composer stays mounted but hidden. */
inputOwnedByCard?: boolean
/** Record a dispatched slash command that does not create a chat turn; `output`
* carries the host's answer when the agent never saw the command. */
onSlashCommand?: (command: string, output?: string) => void
@@ -4,7 +4,7 @@ import {
readNativeChatDraftCache,
writeNativeChatDraftCache
} from './native-chat-draft-cache'
import { NATIVE_CHAT_COMPOSER_SCOPE_CACHE_MAX } from './native-chat-composer-scope-cache'
import { NATIVE_CHAT_COMPOSER_SCOPE_CACHE_MAX } from '../../../../shared/native-chat-scope-cache'
afterEach(() => {
clearNativeChatDraftCacheForTests()
@@ -22,6 +22,9 @@ export type NativeChatSendOptions = {
onWriteRejected?: () => void
/** A write's acknowledgment was lost; it may or may not have landed. */
onWriteUnconfirmed?: () => void
/** All body/submit writes settled; false includes unknown acceptance. Setting it asks the
* host for each write's provider acknowledgment. */
onDeliverySettled?: (acknowledged: boolean) => void
/** Bytes that empty the agent's input line. Defaults to a single Ctrl+U. */
clearInput?: string
/**
@@ -40,6 +43,9 @@ export function clearUnsubmittedAgentInput(
ptyId: string,
options?: NativeChatSendOptions
): void {
if (options?.clearInput === '') {
return
}
sendRuntimePtyInput(
settings,
ptyId,
@@ -67,3 +67,40 @@ it('serializes rapid sends through their acknowledged Enter and preserves the pa
NATIVE_CHAT_SUBMIT
])
})
it.each(['accepted', 'refused', 'unknown'] as const)(
'reports a pasted answer as acknowledged only after %s settlement',
async (outcome) => {
const settled = vi.fn()
if (outcome === 'refused') {
io.verified.mockResolvedValueOnce(false)
} else if (outcome === 'unknown') {
io.verified.mockRejectedValueOnce(new Error('lost acknowledgment'))
}
sendNativeChatMessage(null, 'pane', 'answer', { onDeliverySettled: settled })
expect(settled).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(1000)
expect(settled).toHaveBeenCalledExactlyOnceWith(outcome === 'accepted')
expect(io.verified.mock.calls.map((call) => call[2])).toEqual(
outcome === 'refused'
? [buildNativeChatPasteBytes('answer')]
: [buildNativeChatPasteBytes('answer'), NATIVE_CHAT_SUBMIT]
)
}
)
it('ignores a pasted answer acknowledgment after cancellation', async () => {
let acknowledge: (accepted: boolean) => void = () => {}
io.verified.mockReturnValueOnce(
new Promise<boolean>((resolve) => {
acknowledge = resolve
})
)
const settled = vi.fn()
const handle = sendNativeChatMessage(null, 'pane', 'answer', { onDeliverySettled: settled })
handle.cancel()
acknowledge(true)
await vi.advanceTimersByTimeAsync(1000)
expect(settled).not.toHaveBeenCalled()
expect(io.verified).toHaveBeenCalledOnce()
})
@@ -13,34 +13,49 @@ export function sendNativeChatObservedWrites(
settings: ReturnType<typeof getSettingsForAgentTabRuntimeOwner>,
ptyId: string,
writes: readonly { data: string; delayBeforeMs: number }[],
options: NativeChatSendOptions
options: NativeChatSendOptions & { stopOnUnconfirmed?: boolean; settleDelayMs?: number }
) {
// Why: only an answer that dismisses its card needs the provider's acknowledgment.
const writeOptions = options.onDeliverySettled
? ({ requireWriteSettlement: true } as const)
: undefined
return enqueueNativeChatPtySend(
ptyId,
writes.reduce((total, write) => total + write.delayBeforeMs, 0) +
clearConfirmDurationMs(options),
clearConfirmDurationMs(options) +
(options.settleDelayMs ?? 0),
({ isCancelled, delay, markSubmitted }) => {
let reportedUnconfirmed = false
let acknowledged = true
const writeAt = (index: number): void => {
if (isCancelled()) {
return
}
const write = writes[index]
if (!write) {
markSubmitted()
const finish = (): void => {
options.onDeliverySettled?.(acknowledged)
markSubmitted()
}
if (options.settleDelayMs) {
delay(options.settleDelayMs, finish)
} else {
finish()
}
return
}
const send = (): void => {
if (isCancelled()) {
return
}
void sendRuntimePtyInputVerified(settings, ptyId, write.data, 'driving')
void sendRuntimePtyInputVerified(settings, ptyId, write.data, 'driving', writeOptions)
.then((accepted) => {
if (isCancelled()) {
return
}
if (!accepted) {
options.onWriteRejected?.()
options.onDeliverySettled?.(false)
markSubmitted()
return
}
@@ -49,10 +64,19 @@ export function sendNativeChatObservedWrites(
// A lost acknowledgment is not a refusal: never re-send these bytes, but still submit
// a body that may have landed, as the unobserved path does.
.catch(() => {
if (isCancelled()) {
return
}
acknowledged = false
if (!reportedUnconfirmed) {
reportedUnconfirmed = true
options.onWriteUnconfirmed?.()
}
if (options.stopOnUnconfirmed) {
options.onDeliverySettled?.(false)
markSubmitted()
return
}
writeAt(index + 1)
})
}
@@ -4,7 +4,7 @@
// rule (match on normalized user-message content) is unit-testable without React.
import type { NativeChatMessage } from '../../../../shared/native-chat-types'
import { setBoundedScopeCacheEntry } from './native-chat-composer-scope-cache'
import { setBoundedScopeCacheEntry } from '../../../../shared/native-chat-scope-cache'
import type { NativeChatLaunchPrompt } from '@/lib/native-chat-launch-prompt'
import {
advancedNativeChatUserContentCounts,
@@ -0,0 +1,17 @@
import { createPromptDismissalStore } from '../../../../shared/prompt-dismissal-store'
export type NativeChatPromptDismissal = Readonly<{
/** The card's content key. */
content: string
/** The host wait's start for a status-backed prompt; null for a transcript-only one. */
startedAt: number | null
/** Answered: the card is gone. Collapsed: the user folded it to a strip above the composer. */
state: 'answered' | 'collapsed'
}>
/** Each pane's dismissed prompt occurrence, keyed by pane key. */
export const nativeChatPromptDismissals = createPromptDismissalStore<NativeChatPromptDismissal>()
export function forgetNativeChatPromptDismissalsForTab(tabId: string): void {
nativeChatPromptDismissals.forgetWhere((paneKey) => paneKey.startsWith(`${tabId}:`))
}
@@ -7,7 +7,8 @@
// Enter cannot land on Claude's model confirmation dialog.
export type NativeChatPtySendQueueHandle = {
cancel: () => void
/** `keepInput` skips the unsubmitted-input clear, e.g. while a dialog owns the agent's input. */
cancel: (keepInput?: boolean) => void
settleAfterMs: number
settled: Promise<void>
bodyStarted: () => boolean
@@ -160,7 +161,7 @@ export function enqueueNativeChatPtySend(
state.tail = settled
const handle: NativeChatPtySendQueueHandle = {
cancel: () => {
cancel: (keepInput = false) => {
if (cancelled) {
return
}
@@ -176,7 +177,7 @@ export function enqueueNativeChatPtySend(
state.freeAt = Math.max(Date.now(), state.freeAt - Math.max(0, durationMs))
finishEntry()
dropHandle()
if (shouldClear) {
if (shouldClear && !keepInput) {
options?.onCancelUnsubmitted?.()
}
},
@@ -499,11 +499,13 @@ describe('empty prompt submit', () => {
describe('sendNativeChatAskAnswer', () => {
beforeEach(() => {
vi.useFakeTimers()
resetNativeChatPtySendQueuesForTests()
sendRuntimePtyInput.mockClear()
sendRuntimePtyInput.mockReturnValue(true)
sendRuntimePtyInputVerified.mockReset().mockResolvedValue(true)
})
afterEach(() => {
resetNativeChatPtySendQueuesForTests()
vi.useRealTimers()
})
@@ -514,7 +516,7 @@ describe('sendNativeChatAskAnswer', () => {
expect(sendRuntimePtyInput).not.toHaveBeenCalled()
})
it('paces key groups so selector steps render before the next write', () => {
it('paces key groups so selector steps render before the next write', async () => {
const handle = sendNativeChatAskAnswer(SETTINGS, PTY, [
{ raw: '1' },
{ raw: '2' },
@@ -524,28 +526,41 @@ describe('sendNativeChatAskAnswer', () => {
2 * NATIVE_CHAT_QUESTION_STEP_MS + NATIVE_CHAT_SUBMIT_DELAY_MS
)
vi.advanceTimersByTime(0)
expect(sendRuntimePtyInput).toHaveBeenCalledWith(SETTINGS, PTY, '1', 'driving')
await vi.advanceTimersByTimeAsync(0)
expect(sendRuntimePtyInputVerified).toHaveBeenCalledWith(
SETTINGS,
PTY,
'1',
'driving',
undefined
)
vi.advanceTimersByTime(NATIVE_CHAT_QUESTION_STEP_MS)
expect(sendRuntimePtyInput).toHaveBeenCalledWith(SETTINGS, PTY, '2', 'driving')
await vi.advanceTimersByTimeAsync(NATIVE_CHAT_QUESTION_STEP_MS)
expect(sendRuntimePtyInputVerified).toHaveBeenCalledWith(
SETTINGS,
PTY,
'2',
'driving',
undefined
)
vi.advanceTimersByTime(NATIVE_CHAT_QUESTION_STEP_MS)
expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(
await vi.advanceTimersByTimeAsync(NATIVE_CHAT_QUESTION_STEP_MS)
expect(sendRuntimePtyInputVerified).toHaveBeenLastCalledWith(
SETTINGS,
PTY,
buildNativeChatPasteBytes('custom answer'),
'driving'
'driving',
undefined
)
})
it('cancels remaining key group timers', () => {
it('cancels remaining key group timers', async () => {
const handle = sendNativeChatAskAnswer(SETTINGS, PTY, [{ raw: '1' }, { raw: '2' }])
vi.advanceTimersByTime(0)
expect(sendRuntimePtyInput).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(0)
expect(sendRuntimePtyInputVerified).toHaveBeenCalledTimes(1)
handle.cancel()
vi.advanceTimersByTime(NATIVE_CHAT_QUESTION_STEP_MS * 2)
expect(sendRuntimePtyInput).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(NATIVE_CHAT_QUESTION_STEP_MS * 2)
expect(sendRuntimePtyInputVerified).toHaveBeenCalledTimes(1)
})
it('reports verified delivery only after settling and suppresses it after cancellation', async () => {
@@ -576,10 +591,13 @@ describe('sendNativeChatAskAnswer', () => {
const handle = sendNativeChatAskAnswer(SETTINGS, PTY, [{ raw: '2' }], onSettled)
await vi.advanceTimersByTimeAsync(handle.settleAfterMs)
expect(sendRuntimePtyInputVerified).toHaveBeenCalledWith(SETTINGS, PTY, '2', 'driving')
expect(sendRuntimePtyInputVerified).toHaveBeenCalledWith(SETTINGS, PTY, '2', 'driving', {
requireWriteSettlement: true
})
expect(onSettled).not.toHaveBeenCalled()
resolveAccepted(true)
await vi.advanceTimersByTimeAsync(NATIVE_CHAT_SUBMIT_DELAY_MS)
await vi.waitFor(() => expect(onSettled).toHaveBeenCalledExactlyOnceWith(true))
})
})
@@ -38,7 +38,7 @@ export { resetNativeChatPtySendQueuesForTests }
/** 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
cancel: (keepInput?: boolean) => 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. */
@@ -61,7 +61,7 @@ export function sendNativeChatMessage(
text: string,
options?: NativeChatSendOptions
): NativeChatSendHandle {
if (options?.onWriteRejected) {
if (options?.onWriteRejected || options?.onDeliverySettled) {
return sendNativeChatObservedWrites(
settings,
ptyId,
@@ -232,48 +232,18 @@ export function sendNativeChatAskAnswer(
if (groups.length === 0) {
return { cancel: () => {}, settleAfterMs: 0 }
}
const timers: ReturnType<typeof setTimeout>[] = []
const verifiedWrites: Promise<boolean>[] = []
let cancelled = false
groups.forEach((group, index) => {
timers.push(
setTimeout(() => {
const bytes = 'raw' in group ? group.raw : buildNativeChatPasteBytes(group.text)
if (onSettled) {
// Why: inference must use the remote host's acceptance result, not
// the fire-and-forget renderer dispatch result.
verifiedWrites.push(
sendRuntimePtyInputVerified(settings, ptyId, bytes, 'driving').catch(() => false)
)
} else {
sendRuntimePtyInput(settings, ptyId, bytes, 'driving')
}
}, index * NATIVE_CHAT_QUESTION_STEP_MS)
)
})
const settleAfterMs =
(groups.length - 1) * NATIVE_CHAT_QUESTION_STEP_MS + NATIVE_CHAT_SUBMIT_DELAY_MS
if (onSettled) {
// Why: status inference must wait for every paced write and must not run
// after cancellation or a rejected runtime write.
timers.push(
setTimeout(() => {
void Promise.all(verifiedWrites).then((results) => {
if (!cancelled) {
onSettled(results.every(Boolean))
}
})
}, settleAfterMs)
)
}
return {
cancel: () => {
cancelled = true
for (const timer of timers) {
clearTimeout(timer)
}
},
// Hold the card until the last keystroke has fired and its submit gap passed.
settleAfterMs
}
return sendNativeChatObservedWrites(
settings,
ptyId,
groups.map((group, index) => ({
data: 'raw' in group ? group.raw : buildNativeChatPasteBytes(group.text),
delayBeforeMs: index === 0 ? 0 : NATIVE_CHAT_QUESTION_STEP_MS
})),
{
clearInput: '',
stopOnUnconfirmed: !!onSettled,
settleDelayMs: NATIVE_CHAT_SUBMIT_DELAY_MS,
onDeliverySettled: onSettled
}
)
}
@@ -6,7 +6,7 @@ import {
type NativeChatSessionOptionRecord,
type TrackedNativeChatSessionOption
} from '../../../../shared/native-chat-session-option-state'
import { setBoundedScopeCacheEntry } from './native-chat-composer-scope-cache'
import { setBoundedScopeCacheEntry } from '../../../../shared/native-chat-scope-cache'
const sessionOptionCache = new Map<string, NativeChatSessionOptionRecord>()
@@ -11,13 +11,18 @@ const ESC = '\x1b'
/** Stop the hosted agent: the structured lane's own stop when a turn is running,
* else the ESC keystroke the TUI reads as its interrupt. */
export function useNativeChatComposerInterrupt(args: {
/** A prompt card owns the input: Escape must not reach the agent as Deny. */
inert?: boolean
cancelPendingSends: () => void
isWorking: boolean
onStop?: () => void
resolveTarget: () => NativeChatResolvedTarget | null
}): () => void {
const { cancelPendingSends, isWorking, onStop, resolveTarget } = args
const { inert, cancelPendingSends, isWorking, onStop, resolveTarget } = args
return useCallback(() => {
if (inert) {
return
}
cancelPendingSends()
if (isWorking && onStop) {
onStop()
@@ -27,5 +32,5 @@ export function useNativeChatComposerInterrupt(args: {
if (target) {
sendRuntimePtyInput(target.settings, target.ptyId, ESC, 'driving')
}
}, [cancelPendingSends, isWorking, onStop, resolveTarget])
}, [inert, cancelPendingSends, isWorking, onStop, resolveTarget])
}
@@ -75,7 +75,8 @@ export function useNativeChatComposerRevealFocus({
}
const active = rootRef.current?.ownerDocument.activeElement ?? null
// Focus already inside this pane is our own take landing or the user's click; either ends it.
if (rootRef.current?.contains(active) === true) {
// A just-collapsed card is hidden and inert, so focus left in it is about to drop to body.
if (rootRef.current?.contains(active) === true && !active?.closest('[hidden], [inert]')) {
finish()
return
}
@@ -7,6 +7,7 @@ const mocks = vi.hoisted(() => ({
cancel: vi.fn(),
inferQuestionAnswered: vi.fn(() => Promise.resolve(true)),
sendRuntimePtyInput: vi.fn(),
sendRuntimePtyInputVerified: vi.fn((..._args: unknown[]) => Promise.resolve(true)),
sendNativeChatAskAnswer: vi.fn(),
sendNativeChatMessage: vi.fn(),
// Mutable so a test can swap the live status between sendAnswer and settle.
@@ -32,7 +33,11 @@ vi.mock('../../store', () => ({
}))
vi.mock('@/runtime/runtime-terminal-inspection', () => ({
sendRuntimePtyInput: (...args: unknown[]) => mocks.sendRuntimePtyInput(...args)
sendRuntimePtyInput: (...args: unknown[]) => mocks.sendRuntimePtyInput(...args),
sendRuntimePtyInputVerified: (...args: unknown[]) => mocks.sendRuntimePtyInputVerified(...args)
}))
vi.mock('@/runtime/runtime-terminal-verified-input', () => ({
sendRuntimePtyInputVerified: (...args: unknown[]) => mocks.sendRuntimePtyInputVerified(...args)
}))
vi.mock('@/lib/agent-paste-draft', () => ({
@@ -75,7 +80,8 @@ describe('useNativeChatInteractiveSend', () => {
expect(mocks.sendNativeChatMessage).toHaveBeenCalledWith(
{ terminalTabId: 'tab-1' },
'pty-1',
'B'
'B',
{ onDeliverySettled: expect.any(Function) }
)
expect(mocks.sendNativeChatAskAnswer).not.toHaveBeenCalled()
})
@@ -138,7 +144,7 @@ describe('useNativeChatInteractiveSend', () => {
resultValue = result.current.sendAnswer(PROMPT, [{ indices: [] }])
})
expect(resultValue).toEqual({ settleAfterMs: 0, waitsForVerifiedDelivery: false })
expect(resultValue).toEqual({ settleAfterMs: 0 })
expect(mocks.sendNativeChatAskAnswer).not.toHaveBeenCalled()
expect(mocks.sendNativeChatMessage).not.toHaveBeenCalled()
})
@@ -203,22 +209,32 @@ describe('useNativeChatInteractiveSend', () => {
)
it.each(['opencode', 'opencode2'] as const)(
'rejects a %s question with one Escape and no delayed Stop',
(agent) => {
'rejects a %s question with one acknowledged Escape and no delayed Stop',
async (agent) => {
const { result } = renderHook(() =>
useNativeChatInteractiveSend('tab-1', PANE_KEY, 'pty-1', agent)
)
act(() => result.current.cancelAsk())
expect(mocks.sendRuntimePtyInput).toHaveBeenCalledExactlyOnceWith(
await expect(result.current.cancelAsk()).resolves.toBe(true)
expect(mocks.sendRuntimePtyInputVerified).toHaveBeenCalledExactlyOnceWith(
{ terminalTabId: 'tab-1' },
'pty-1',
'\x1b',
'driving'
'driving',
{ requireWriteSettlement: true }
)
expect(mocks.sendRuntimePtyInput).not.toHaveBeenCalled()
expect(mocks.sendNativeChatAskAnswer).not.toHaveBeenCalled()
}
)
it('reports an Escape whose delivery is unknown as not delivered', async () => {
mocks.sendRuntimePtyInputVerified.mockRejectedValueOnce(new Error('timeout'))
const { result } = renderHook(() =>
useNativeChatInteractiveSend('tab-1', PANE_KEY, 'pty-1', 'claude')
)
await expect(result.current.cancelAsk()).resolves.toBe(false)
})
it.each(['opencode', 'opencode2'] as const)(
'delivers a non-default %s answer through selector keys',
(agent) => {
@@ -315,7 +331,7 @@ describe('useNativeChatInteractiveSend', () => {
act(() => {
sendResult = result.current.sendAnswer(PROMPT, [{ indices: [1] }], onDeliverySettled)
})
expect(sendResult).toEqual({ settleAfterMs: 500, waitsForVerifiedDelivery: true })
expect(sendResult).toEqual({ settleAfterMs: 500 })
const onSettled = mocks.sendNativeChatAskAnswer.mock.calls[0]?.[3]
onSettled?.(false)
@@ -1,6 +1,7 @@
import { useCallback, useLayoutEffect, useRef } from 'react'
import { useAppStore } from '../../store'
import { sendRuntimePtyInput } from '@/runtime/runtime-terminal-inspection'
import { sendRuntimePtyInputVerified } from '@/runtime/runtime-terminal-verified-input'
import { getSettingsForAgentTabRuntimeOwner } from '@/lib/agent-paste-draft'
import type { AgentType } from '../../../../shared/native-chat-types'
import {
@@ -33,13 +34,16 @@ export type NativeChatInteractiveSend = {
prompt: AskPrompt,
selections: AskAnswerSelection[],
onDeliverySettled?: (delivered: boolean) => void
) => { settleAfterMs: number; waitsForVerifiedDelivery: boolean }
) => { settleAfterMs: number }
/** Send a raw control string (e.g. an approval option number or ESC) as-is. */
sendRaw: (raw: string) => void
/** `sendRaw` that resolves to whether the write was acknowledged; unknown delivery is false. */
sendRawVerified: (raw: string) => Promise<boolean>
/** Stop delayed writes without interrupting the agent. */
cancelPending: () => void
/** Reject the active question without requesting session interruption. */
cancelAsk: () => void
/** Reject the active question without requesting session interruption; resolves to whether
* the Escape was acknowledged. */
cancelAsk: () => Promise<boolean>
/** Interrupt the active turn. */
cancel: () => void
}
@@ -88,14 +92,28 @@ export function useNativeChatInteractiveSend(
[terminalTabId, targetPtyId]
)
const sendRawVerified = useCallback(
(raw: string): Promise<boolean> =>
targetPtyId
? sendRuntimePtyInputVerified(
getSettingsForAgentTabRuntimeOwner(terminalTabId),
targetPtyId,
raw,
'driving',
{ requireWriteSettlement: true }
).catch(() => false)
: Promise.resolve(false),
[terminalTabId, targetPtyId]
)
const sendAnswer = useCallback(
(
prompt: AskPrompt,
selections: AskAnswerSelection[],
onDeliverySettled?: (delivered: boolean) => void
): { settleAfterMs: number; waitsForVerifiedDelivery: boolean } => {
): { settleAfterMs: number } => {
if (!targetPtyId || !hasAskAnswer(prompt, selections)) {
return { settleAfterMs: 0, waitsForVerifiedDelivery: false }
return { settleAfterMs: 0 }
}
// Cancel any prior in-flight answer before starting a new one.
cancelInFlight()
@@ -113,27 +131,25 @@ export function useNativeChatInteractiveSend(
? useAppStore.getState().agentStatusByPaneKey[paneKey]
: undefined
let settledHandle: NativeChatSendHandle | null = null
const onSettled = stepsAnswer
? (delivered: boolean): void => {
if (settledHandle && inFlightRef.current === settledHandle) {
// Why: a completed verified send otherwise retains its timers,
// promises, and prompt callback until the next send or unmount.
inFlightRef.current = null
}
if (delivered) {
inferQuestionAnsweredFromCurrentStatus({
paneKey,
getStatusEntry: () => questionStatusBaseline,
inferQuestionAnswered: (request) =>
window.api.agentStatus.inferQuestionAnswered(request).catch((err) => {
console.warn('[agent-question] native-chat inference failed:', err)
return false
})
const onSettled = (delivered: boolean): void => {
if (settledHandle && inFlightRef.current === settledHandle) {
// Why: a completed verified send otherwise retains its timers,
// promises, and prompt callback until the next send or unmount.
inFlightRef.current = null
}
if (delivered && stepsAnswer) {
inferQuestionAnsweredFromCurrentStatus({
paneKey,
getStatusEntry: () => questionStatusBaseline,
inferQuestionAnswered: (request) =>
window.api.agentStatus.inferQuestionAnswered(request).catch((err) => {
console.warn('[agent-question] native-chat inference failed:', err)
return false
})
}
onDeliverySettled?.(delivered)
}
: undefined
})
}
onDeliverySettled?.(delivered)
}
const handle: NativeChatSendHandle = stepsAnswer
? sendNativeChatAskAnswer(
settings,
@@ -143,15 +159,16 @@ export function useNativeChatInteractiveSend(
: buildAskAnswerKeys(prompt, selections),
onSettled
)
: sendNativeChatMessage(settings, targetPtyId, formatAskAnswer(prompt, selections))
: sendNativeChatMessage(settings, targetPtyId, formatAskAnswer(prompt, selections), {
onDeliverySettled: onSettled
})
// Why: native-chat answer writes bypass xterm.onData. Infer only after
// every paced selector write has fired, so an early digit in a multi-step
// answer cannot dismiss the wait or cancel the remaining writes.
settledHandle = handle
inFlightRef.current = handle
return {
settleAfterMs: handle.settleAfterMs,
waitsForVerifiedDelivery: onSettled !== undefined
settleAfterMs: handle.settleAfterMs
}
},
[terminalTabId, paneKey, targetPtyId, agent, cancelInFlight]
@@ -159,8 +176,8 @@ export function useNativeChatInteractiveSend(
const cancelAsk = useCallback(() => {
cancelInFlight()
sendRaw(ESC)
}, [cancelInFlight, sendRaw])
return sendRawVerified(ESC)
}, [cancelInFlight, sendRawVerified])
const cancel = useCallback(() => {
cancelInFlight()
@@ -176,5 +193,5 @@ export function useNativeChatInteractiveSend(
sendRaw(ESC)
}, [agent, cancelInFlight, sendRaw, targetPtyId, terminalTabId])
return { sendAnswer, sendRaw, cancelPending: cancelInFlight, cancelAsk, cancel }
return { sendAnswer, sendRaw, sendRawVerified, cancelPending: cancelInFlight, cancelAsk, cancel }
}
@@ -0,0 +1,22 @@
import { useLayoutEffect, type RefObject } from 'react'
/**
* Focus a prompt card when it takes the input region, in the same commit, so keys leave this
* pane's hidden composer at once. Never takes focus from another surface the user is in.
*/
export function useNativeChatPromptCardFocus(
cardRef: RefObject<HTMLElement | null>,
shouldFocus: boolean
): void {
useLayoutEffect(() => {
const card = cardRef.current
const active = document.activeElement
const fromThisPane =
!active ||
active === document.body ||
card?.closest('[data-native-chat-root]')?.contains(active)
if (shouldFocus && fromThisPane) {
card?.focus()
}
}, [cardRef, shouldFocus])
}
@@ -0,0 +1,87 @@
// @vitest-environment happy-dom
import { act, renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it } from 'vitest'
import { useAppStore } from '../../store'
import { useNativeChatPromptCardPresentation } from './use-native-chat-prompt-card-presentation'
import {
nativeChatPromptDismissals,
forgetNativeChatPromptDismissalsForTab
} from './native-chat-prompt-dismissals'
import type { InteractivePromptCard } from './native-chat-interactive-prompt'
const PANE = 'tab-1:leaf-1'
const question: InteractivePromptCard = {
kind: 'question',
prompt: {
questions: [{ question: 'Which folder?', multiSelect: false, options: [{ label: 'build' }] }]
}
}
function renderPresentation(card: InteractivePromptCard = question) {
return renderHook(
(props: { card: InteractivePromptCard }) =>
useNativeChatPromptCardPresentation({
paneKey: PANE,
targetPtyId: 'pty-1',
card: props.card,
canSend: true
}),
{ initialProps: { card } }
)
}
function waitFromStatus(stateStartedAt: number): void {
useAppStore
.getState()
.setAgentStatus(
PANE,
{ state: 'waiting', prompt: '', agentType: 'claude', interactivePrompt: '{}' },
undefined,
{ stateStartedAt }
)
}
beforeEach(() => {
nativeChatPromptDismissals.clearForTests()
useAppStore.setState({ agentStatusByPaneKey: {} })
})
describe('dismissed prompt occurrences outlive the view but not the prompt', () => {
it('keeps a status-backed answer hidden for a remounted view', () => {
waitFromStatus(10)
const first = renderPresentation()
act(() => first.result.current.dismiss())
expect(first.result.current.card).toBeNull()
first.unmount()
expect(renderPresentation().result.current.card).toBeNull()
})
it('re-shows a transcript-only question after the view stops observing it', () => {
const first = renderPresentation()
act(() => first.result.current.collapse())
expect(first.result.current.collapsedCard).toBe(question)
first.unmount()
expect(renderPresentation().result.current.card).toBe(question)
})
it('shows an identical question again once the prompt cleared', () => {
const view = renderPresentation()
act(() => view.result.current.dismiss())
view.rerender({ card: null })
view.rerender({ card: question })
expect(view.result.current.card).toBe(question)
})
it('forgets the dismissal when its tab retires', () => {
waitFromStatus(10)
const first = renderPresentation()
act(() => first.result.current.collapse())
first.unmount()
forgetNativeChatPromptDismissalsForTab('tab-1')
expect(renderPresentation().result.current.card).toBe(question)
})
})
@@ -0,0 +1,102 @@
import { useCallback, useLayoutEffect, useMemo, useRef, useSyncExternalStore } from 'react'
import { useAppStore } from '../../store'
import { nativeChatCardDismissKey } from './native-chat-dismiss-key'
import {
nativeChatPromptDismissals as dismissals,
type NativeChatPromptDismissal
} from './native-chat-prompt-dismissals'
import type { InteractivePromptCard } from './native-chat-interactive-prompt'
export type NativeChatPromptCardPresentation = {
/** The card that owns the input region now; null leaves it to the composer. */
card: InteractivePromptCard
/** The occurrence the user collapsed to a strip above the composer. */
collapsedCard: InteractivePromptCard
/** Identifies this prompt occurrence; a new one remounts the card with fresh state. */
occurrenceKey: string | null
/** Hide this occurrence once its answer was delivered. */
dismiss: () => void
collapse: () => void
expand: () => void
}
/**
* Which prompt card the pane shows, derived in render so the card and the composer never share a
* commit. Dismissal is presentation only: it hides or collapses one occurrence and never says the
* agent moved on. A lingering live status must not reshow it, including after the view remounts.
*/
export function useNativeChatPromptCardPresentation({
paneKey,
targetPtyId = null,
card,
canSend,
transcriptSettled = true
}: {
paneKey: string
targetPtyId?: string | null
card: InteractivePromptCard
/** False while a phone holds this PTY: no card answers from here, and the composer shows why. */
canSend: boolean
/** While the transcript loads, an absent transcript card is unknown, not cleared. */
transcriptSettled?: boolean
}): NativeChatPromptCardPresentation {
// Why the wait's start: two prompts with the same text are separate occurrences.
const startedAt = useAppStore((s) => {
const entry = s.agentStatusByPaneKey[paneKey]
return card && entry?.interactivePrompt ? (entry.stateStartedAt ?? null) : null
})
const content = nativeChatCardDismissKey(card)
const promptKey =
content === null || card?.kind !== 'approval' ? content : `${content}@${startedAt}`
const scopeKey = JSON.stringify([paneKey, targetPtyId])
const occurrenceKey = promptKey === null ? null : `${scopeKey}:${promptKey}`
const occurrence = useMemo(() => ({ occurrenceKey, canSend }), [occurrenceKey, canSend])
const activeOccurrenceRef = useRef<object | null>(null)
useLayoutEffect(() => {
activeOccurrenceRef.current = occurrence
return () => {
activeOccurrenceRef.current = null
}
}, [occurrence])
const dismissal = useSyncExternalStore(dismissals.subscribe, () => dismissals.read(paneKey))
// A transcript-only card matches its status-backed record across the post-answer handoff.
const matches =
dismissal !== undefined &&
dismissal.content === content &&
(startedAt === null || dismissal.startedAt === startedAt)
// Why retire on a cleared or changed prompt: a later, identical question must show again.
useLayoutEffect(() => {
if (dismissal !== undefined && !matches && (content !== null || transcriptSettled)) {
dismissals.forget(paneKey)
}
}, [dismissal, matches, content, transcriptSettled, paneKey])
// Why: with no wait start to tell occurrences apart, an unobserved stretch may hide a new one.
useLayoutEffect(
() => () => {
if (dismissals.read(paneKey)?.startedAt === null) {
dismissals.forget(paneKey)
}
},
[paneKey]
)
const record = useCallback(
(state: NativeChatPromptDismissal['state']) => {
if (activeOccurrenceRef.current === occurrence && canSend && content !== null) {
dismissals.write(paneKey, { content, startedAt, state })
}
},
[occurrence, canSend, content, startedAt, paneKey]
)
const dismiss = useCallback(() => record('answered'), [record])
const collapse = useCallback(() => record('collapsed'), [record])
const expand = useCallback(() => dismissals.forget(paneKey), [paneKey])
const shown = card !== null && canSend
return {
card: shown && !matches ? card : null,
collapsedCard: shown && matches && dismissal?.state === 'collapsed' ? card : null,
occurrenceKey,
dismiss,
collapse,
expand
}
}
@@ -9,7 +9,13 @@ export type NativeChatSendLifecycle = {
export function useNativeChatSendLifecycle(
terminalTabId: string,
targetPtyId: string | null,
onPendingSendCanceled?: (pendingId: string) => void
onPendingSendCanceled?: (pendingId: string) => void,
cardOwnership?: {
/** A prompt card owns the agent's input while the composer stays mounted. */
inputOwnedByCard: boolean
/** Settles the echo of a send the card retired; it keeps the user's text visible. */
onPendingSendRetired?: (pendingId: string) => void
}
): NativeChatSendLifecycle {
const pendingSendHandlesRef = useRef(
new Map<
@@ -17,19 +23,26 @@ export function useNativeChatSendLifecycle(
{ cleanupTimer: ReturnType<typeof setTimeout> | null; pendingId?: string }
>()
)
const cancelPendingSends = useCallback(() => {
for (const [handle, entry] of pendingSendHandlesRef.current) {
const { cleanupTimer, pendingId } = entry
if (cleanupTimer !== null) {
clearTimeout(cleanupTimer)
const settlePendingSends = useCallback(
(onSettled?: (pendingId: string) => void, keepInput = false) => {
for (const [handle, entry] of pendingSendHandlesRef.current) {
const { cleanupTimer, pendingId } = entry
if (cleanupTimer !== null) {
clearTimeout(cleanupTimer)
}
handle.cancel(keepInput)
if (pendingId) {
onSettled?.(pendingId)
}
}
handle.cancel()
if (pendingId) {
onPendingSendCanceled?.(pendingId)
}
}
pendingSendHandlesRef.current.clear()
}, [onPendingSendCanceled])
pendingSendHandlesRef.current.clear()
},
[]
)
const cancelPendingSends = useCallback(
() => settlePendingSends(onPendingSendCanceled),
[settlePendingSends, onPendingSendCanceled]
)
const trackPendingSend = useCallback((handle: NativeChatSendHandle, pendingId?: string) => {
const entry = {
cleanupTimer: null as ReturnType<typeof setTimeout> | null,
@@ -53,5 +66,15 @@ export function useNativeChatSendLifecycle(
// swap or unmount must cancel them before that PTY can close or be reused.
useLayoutEffect(() => cancelPendingSends, [cancelPendingSends, targetPtyId, terminalTabId])
const inputOwnedByCard = cardOwnership?.inputOwnedByCard === true
const onPendingSendRetired = cardOwnership?.onPendingSendRetired ?? onPendingSendCanceled
// Why: once a card owns the agent's input, an unsubmitted Enter would answer it. No line clear
// either: it would type under the dialog, and the next send clears the line first.
useLayoutEffect(() => {
if (inputOwnedByCard) {
settlePendingSends(onPendingSendRetired, true)
}
}, [inputOwnedByCard, onPendingSendRetired, settlePendingSends])
return { cancelPendingSends, trackPendingSend }
}
+4
View File
@@ -18175,6 +18175,10 @@
"railLabel": "Your messages",
"railImageMessage": "Image attachment",
"railEmptyMessage": "Message",
"prompt": {
"collapse": "Collapse",
"expand": "Expand"
},
"question": {
"step": "Step {{value0}}",
"other": "Other…",
+4
View File
@@ -17869,6 +17869,10 @@
"loadingEarlier": "Cargando…",
"loadEarlier": "Cargar mensajes anteriores",
"loadingEarlierMessages": "Cargando mensajes anteriores…",
"prompt": {
"collapse": "Contraer",
"expand": "Expandir"
},
"question": {
"step": "Paso {{value0}}",
"other": "Otro…",
+4
View File
@@ -17968,6 +17968,10 @@
"loadingEarlier": "Chargement…",
"loadEarlier": "Charger les messages précédents",
"loadingEarlierMessages": "Chargement des messages précédents…",
"prompt": {
"collapse": "Réduire",
"expand": "Développer"
},
"question": {
"step": "Étape {{value0}}",
"other": "Autre…",
+4
View File
@@ -17904,6 +17904,10 @@
"loadingEarlier": "読み込み中…",
"loadEarlier": "以前のメッセージをロードする",
"loadingEarlierMessages": "以前のメッセージを読み込み中…",
"prompt": {
"collapse": "折りたたむ",
"expand": "展開"
},
"question": {
"step": "ステップ {{value0}}",
"other": "他の…",
+4
View File
@@ -17904,6 +17904,10 @@
"loadingEarlier": "로드 중…",
"loadEarlier": "이전 메시지 로드",
"loadingEarlierMessages": "이전 메시지 로드 중…",
"prompt": {
"collapse": "접기",
"expand": "펼치기"
},
"question": {
"step": "{{value0}} 단계",
"other": "다른…",
+4
View File
@@ -17869,6 +17869,10 @@
"loadingEarlier": "正在加载…",
"loadEarlier": "加载更早的消息",
"loadingEarlierMessages": "正在加载更早的消息…",
"prompt": {
"collapse": "收起",
"expand": "展开"
},
"question": {
"step": "步骤 {{value0}}",
"other": "其他…",
@@ -0,0 +1,69 @@
import { makePaneKey, type PaneKey } from '../../../shared/stable-pane-id'
import { useAppStore } from '../store'
type TerminalLayoutsByTabId = ReturnType<typeof useAppStore.getState>['terminalLayoutsByTabId']
type TerminalPaneOwner = {
tabId: string
leafId: string
paneKey: PaneKey
}
const paneOwnersByPtyIdByLayoutIdentity = new WeakMap<
TerminalLayoutsByTabId,
Map<string, TerminalPaneOwner>
>()
function resolvePaneKeyForPtyId(layouts: TerminalLayoutsByTabId, ptyId: string): PaneKey | null {
let paneOwnersByPtyId = paneOwnersByPtyIdByLayoutIdentity.get(layouts)
if (!paneOwnersByPtyId) {
paneOwnersByPtyId = new Map<string, TerminalPaneOwner>()
paneOwnersByPtyIdByLayoutIdentity.set(layouts, paneOwnersByPtyId)
}
const cachedOwner = paneOwnersByPtyId.get(ptyId)
if (cachedOwner) {
const layout = Object.prototype.propertyIsEnumerable.call(layouts, cachedOwner.tabId)
? layouts[cachedOwner.tabId]
: undefined
const ptyIdsByLeafId = layout?.ptyIdsByLeafId
if (
ptyIdsByLeafId &&
Object.prototype.propertyIsEnumerable.call(ptyIdsByLeafId, cachedOwner.leafId) &&
ptyIdsByLeafId[cachedOwner.leafId] === ptyId
) {
return cachedOwner.paneKey
}
paneOwnersByPtyId.delete(ptyId)
}
for (const [tabId, layout] of Object.entries(layouts)) {
for (const [leafId, leafPtyId] of Object.entries(layout?.ptyIdsByLeafId ?? {})) {
if (leafPtyId !== ptyId) {
continue
}
try {
const paneKey = makePaneKey(tabId, leafId)
paneOwnersByPtyId.set(ptyId, { tabId, leafId, paneKey })
return paneKey
} catch {
// Preserve first-match behavior for malformed legacy layout rows.
return null
}
}
}
return null
}
export function recordRuntimeTerminalInputForPtyId(ptyId: string, timestamp = Date.now()): void {
const state = useAppStore.getState()
const paneKey = resolvePaneKeyForPtyId(state.terminalLayoutsByTabId, ptyId)
if (!paneKey) {
return
}
try {
// Why: paired/runtime sends can bypass xterm.onData, so hibernation
// needs the same user-input marker from the PTY-id route.
state.recordTerminalInput(paneKey, timestamp)
} catch {
// Ignore malformed legacy layout data; the planner will stay
// conservative when a live PTY cannot be matched to an eligible pane.
}
}
@@ -1,8 +1,8 @@
import type { GlobalSettings } from '../../../shared/global-settings-types'
import type { RuntimeTerminalSend } from '../../../shared/runtime-types'
import { makePaneKey, type PaneKey } from '../../../shared/stable-pane-id'
import { isTerminalInputTooLargeWithDeferredMeasurement } from '../../../shared/terminal-input'
import { useAppStore } from '../store'
import { recordRuntimeTerminalInputForPtyId } from './runtime-terminal-input-recording'
export { recordRuntimeTerminalInputForPtyId } from './runtime-terminal-input-recording'
import { callRuntimeRpc, getActiveRuntimeTarget } from './runtime-rpc-client'
import {
getRemoteRuntimePtyEnvironmentId,
@@ -26,57 +26,6 @@ export type RuntimeTerminalProcessInspection = TerminalProcessInspection
const REMOTE_PTY_ID_PREFIX = 'remote:'
const DESKTOP_RUNTIME_CLIENT = { id: 'orca-desktop', type: 'desktop' } as const
type TerminalLayoutsByTabId = ReturnType<typeof useAppStore.getState>['terminalLayoutsByTabId']
type TerminalPaneOwner = {
tabId: string
leafId: string
paneKey: PaneKey
}
const paneOwnersByPtyIdByLayoutIdentity = new WeakMap<
TerminalLayoutsByTabId,
Map<string, TerminalPaneOwner>
>()
function resolvePaneKeyForPtyId(layouts: TerminalLayoutsByTabId, ptyId: string): PaneKey | null {
let paneOwnersByPtyId = paneOwnersByPtyIdByLayoutIdentity.get(layouts)
if (!paneOwnersByPtyId) {
paneOwnersByPtyId = new Map<string, TerminalPaneOwner>()
paneOwnersByPtyIdByLayoutIdentity.set(layouts, paneOwnersByPtyId)
}
const cachedOwner = paneOwnersByPtyId.get(ptyId)
if (cachedOwner) {
const layout = Object.prototype.propertyIsEnumerable.call(layouts, cachedOwner.tabId)
? layouts[cachedOwner.tabId]
: undefined
const ptyIdsByLeafId = layout?.ptyIdsByLeafId
if (
ptyIdsByLeafId &&
Object.prototype.propertyIsEnumerable.call(ptyIdsByLeafId, cachedOwner.leafId) &&
ptyIdsByLeafId[cachedOwner.leafId] === ptyId
) {
return cachedOwner.paneKey
}
paneOwnersByPtyId.delete(ptyId)
}
for (const [tabId, layout] of Object.entries(layouts)) {
for (const [leafId, leafPtyId] of Object.entries(layout?.ptyIdsByLeafId ?? {})) {
if (leafPtyId !== ptyId) {
continue
}
try {
const paneKey = makePaneKey(tabId, leafId)
paneOwnersByPtyId.set(ptyId, { tabId, leafId, paneKey })
return paneKey
} catch {
// Preserve first-match behavior for malformed legacy layout rows.
return null
}
}
}
return null
}
function isRuntimePtyInputTooLarge(data: string): boolean | Promise<boolean> {
return isTerminalInputTooLargeWithDeferredMeasurement(data)
}
@@ -120,22 +69,6 @@ function normalizeInspectionResult(
return result
}
export function recordRuntimeTerminalInputForPtyId(ptyId: string, timestamp = Date.now()): void {
const state = useAppStore.getState()
const paneKey = resolvePaneKeyForPtyId(state.terminalLayoutsByTabId, ptyId)
if (!paneKey) {
return
}
try {
// Why: paired/runtime sends can bypass xterm.onData, so hibernation
// needs the same user-input marker from the PTY-id route.
state.recordTerminalInput(paneKey, timestamp)
} catch {
// Ignore malformed legacy layout data; the planner will stay
// conservative when a live PTY cannot be matched to an eligible pane.
}
}
export async function inspectRuntimeTerminalProcess(
settings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined,
ptyId: string,
@@ -276,50 +209,4 @@ function sendRuntimePtyInputWithinLimit(
return true
}
export async function sendRuntimePtyInputVerified(
settings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined,
ptyId: string,
data: string,
inputKind: TerminalInputKind
): Promise<boolean> {
const tooLarge = isRuntimePtyInputTooLarge(data)
if (typeof tooLarge === 'boolean' ? tooLarge : await tooLarge) {
return false
}
const ownerEnvironmentId = getRemoteRuntimePtyEnvironmentId(ptyId)
const target = ownerEnvironmentId
? ({ kind: 'environment', environmentId: ownerEnvironmentId } as const)
: getActiveRuntimeTarget(settings)
const terminal = getRemoteRuntimeTerminalHandle(ptyId)
if (target.kind !== 'environment' || !terminal) {
const accepted = await window.api.pty.writeAccepted(ptyId, data, inputKind)
if (!accepted) {
window.api.pty.write(ptyId, data, inputKind)
// Why: SSH/local fallback writes are fire-and-forget. Callers use this
// boolean to continue UX flow, while hook telemetry confirms real turns.
recordRuntimeTerminalInputForPtyId(ptyId)
return true
}
recordRuntimeTerminalInputForPtyId(ptyId)
return accepted
}
try {
const result = await callRuntimeRpc<{ send: RuntimeTerminalSend }>(
target,
'terminal.send',
{ terminal, text: data, client: DESKTOP_RUNTIME_CLIENT },
{ timeoutMs: 15_000 }
)
if (result.send.accepted === true) {
recordRuntimeTerminalInputForPtyId(ptyId)
return true
}
return false
} catch (error) {
if (classifyTerminalProcessInspectionFailure(error) === 'terminal_gone') {
return false
}
throw error
}
}
export { sendRuntimePtyInputVerified } from './runtime-terminal-verified-input'
@@ -0,0 +1,106 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { sendRuntimePtyInputVerified } from './runtime-terminal-verified-input'
import {
createCompatibleRuntimeStatusResponseIfNeeded,
type RuntimeEnvironmentCallRequest
} from './runtime-compatibility-test-fixture'
import { clearRuntimeCompatibilityCacheForTests } from './runtime-rpc-client'
const REMOTE_PTY = 'remote:env-1@@terminal-1'
const runtimeSend = vi.fn()
const localWrite = vi.fn()
const localWriteAccepted = vi.fn()
beforeEach(() => {
clearRuntimeCompatibilityCacheForTests()
vi.clearAllMocks()
vi.stubGlobal('window', {
api: {
runtimeEnvironments: {
call: (args: RuntimeEnvironmentCallRequest) =>
createCompatibleRuntimeStatusResponseIfNeeded(args) ?? runtimeSend(args)
},
pty: { write: localWrite, writeAccepted: localWriteAccepted }
}
})
})
function hostReply(send: Record<string, unknown>) {
runtimeSend.mockResolvedValue({
ok: true,
result: { send: { handle: 'terminal-1', bytesWritten: 1, ...send } },
_meta: { runtimeId: 'runtime-1' }
})
}
describe('verified input that requires provider settlement', () => {
it.each([
['older host whole-write', { accepted: true }, true],
['acknowledged', { accepted: true, writeSettlement: { outcome: 'accepted' } }, true],
[
'refused',
{ accepted: false, writeSettlement: { outcome: 'refused', reason: 'endpoint_disconnected' } },
false
]
] as const)('reads the %s remote verdict', async (_label, send, accepted) => {
hostReply(send)
await expect(
sendRuntimePtyInputVerified(null, REMOTE_PTY, 'x', 'driving', {
requireWriteSettlement: true
})
).resolves.toBe(accepted)
expect(runtimeSend).toHaveBeenCalledWith(
expect.objectContaining({
method: 'terminal.send',
params: expect.objectContaining({ requireWriteSettlement: true })
})
)
})
it('rejects a remote send whose settlement is unverifiable, without a fallback write', async () => {
hostReply({
accepted: false,
writeSettlement: {
outcome: 'unverifiable',
reason: 'transport_settlement_lost',
bytesHandedToTransport: true
}
})
await expect(
sendRuntimePtyInputVerified(null, REMOTE_PTY, 'x', 'driving', {
requireWriteSettlement: true
})
).rejects.toThrow('acknowledgment unavailable')
expect(localWrite).not.toHaveBeenCalled()
expect(runtimeSend).toHaveBeenCalledOnce()
})
})
describe('local verified input', () => {
it('keeps the fire-and-forget fallback for ordinary input the provider cannot accept', async () => {
localWriteAccepted.mockResolvedValue(false)
await expect(
sendRuntimePtyInputVerified(null, 'ssh:conn-1@@pty-1', 'x', 'driving')
).resolves.toBe(true)
expect(localWriteAccepted).toHaveBeenCalledWith('ssh:conn-1@@pty-1', 'x', 'driving')
expect(localWrite).toHaveBeenCalledWith('ssh:conn-1@@pty-1', 'x', 'driving')
})
it('never falls back to an unacknowledged write when settlement is required', async () => {
const settled = { requireWriteSettlement: true } as const
localWriteAccepted.mockResolvedValueOnce(true)
await expect(
sendRuntimePtyInputVerified(null, 'ssh:conn-1@@pty-1', 'answer', 'driving', settled)
).resolves.toBe(true)
localWriteAccepted.mockResolvedValueOnce(false)
await expect(
sendRuntimePtyInputVerified(null, 'ssh:conn-1@@pty-1', '1', 'driving', settled)
).resolves.toBe(false)
localWriteAccepted.mockRejectedValueOnce(new Error('acknowledgment unavailable'))
await expect(
sendRuntimePtyInputVerified(null, 'ssh:conn-1@@pty-1', '\r', 'driving', settled)
).rejects.toThrow('acknowledgment unavailable')
expect(localWriteAccepted).toHaveBeenCalledWith('ssh:conn-1@@pty-1', '\r', 'driving', settled)
expect(localWrite).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,83 @@
import type { GlobalSettings } from '../../../shared/global-settings-types'
import type { RuntimeTerminalSend } from '../../../shared/runtime-types'
import type { TerminalInputKind } from '../../../shared/terminal-input-kind'
import { isTerminalInputTooLargeWithDeferredMeasurement } from '../../../shared/terminal-input'
import { readTerminalSendAcknowledgment } from '../../../shared/terminal-send-acknowledgment'
import { classifyTerminalProcessInspectionFailure } from '../../../shared/terminal-process-inspection'
import { callRuntimeRpc, getActiveRuntimeTarget } from './runtime-rpc-client'
import {
getRemoteRuntimePtyEnvironmentId,
getRemoteRuntimeTerminalHandle
} from './runtime-terminal-stream'
import { recordRuntimeTerminalInputForPtyId } from './runtime-terminal-input-recording'
const DESKTOP_RUNTIME_CLIENT = { id: 'orca-desktop', type: 'desktop' } as const
/**
* True means accepted, false means refusal. `requireWriteSettlement` asks the local provider or a
* current host for its acknowledgment; a lost one rejects.
*/
export async function sendRuntimePtyInputVerified(
settings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined,
ptyId: string,
data: string,
inputKind: TerminalInputKind,
options?: { requireWriteSettlement?: true }
): Promise<boolean> {
const tooLarge = isTerminalInputTooLargeWithDeferredMeasurement(data)
if (typeof tooLarge === 'boolean' ? tooLarge : await tooLarge) {
return false
}
const ownerEnvironmentId = getRemoteRuntimePtyEnvironmentId(ptyId)
const target = ownerEnvironmentId
? ({ kind: 'environment', environmentId: ownerEnvironmentId } as const)
: getActiveRuntimeTarget(settings)
const terminal = getRemoteRuntimeTerminalHandle(ptyId)
if (target.kind !== 'environment' || !terminal) {
if (options?.requireWriteSettlement) {
// Why: an answer that dismisses its card must not fall back to an unacknowledged write.
const accepted = await window.api.pty.writeAccepted(ptyId, data, inputKind, options)
if (accepted) {
recordRuntimeTerminalInputForPtyId(ptyId)
}
return accepted
}
const accepted = await window.api.pty.writeAccepted(ptyId, data, inputKind)
if (!accepted) {
window.api.pty.write(ptyId, data, inputKind)
// Why: SSH/local fallback writes are fire-and-forget. Callers use this
// boolean to continue UX flow, while hook telemetry confirms real turns.
recordRuntimeTerminalInputForPtyId(ptyId)
return true
}
recordRuntimeTerminalInputForPtyId(ptyId)
return accepted
}
try {
const result = await callRuntimeRpc<{ send: RuntimeTerminalSend }>(
target,
'terminal.send',
{
terminal,
text: data,
client: DESKTOP_RUNTIME_CLIENT,
...(options?.requireWriteSettlement ? { requireWriteSettlement: true } : {})
},
{ timeoutMs: 15_000 }
)
if (result.send.accepted === true) {
recordRuntimeTerminalInputForPtyId(ptyId)
}
const acknowledgment = readTerminalSendAcknowledgment(result)
if (acknowledgment === 'unverifiable') {
throw new Error('PTY write acknowledgment unavailable')
}
return acknowledgment === 'accepted'
} catch (error) {
if (classifyTerminalProcessInspectionFailure(error) === 'terminal_gone') {
return false
}
throw error
}
}
@@ -5,6 +5,7 @@ import { forgetForegroundTerminalTabs } from '@/lib/foreground-terminal-tabs'
import { forgetAgentStartupDeliveriesForTabs } from '@/lib/agent-startup-delivery-guards'
// Why: the store-free registry (not terminal-parked-tab-watchers, which imports @/store) so a slice can import this module during its own evaluation.
import { retireParkedTerminalTab } from '@/components/terminal-pane/terminal-parked-watcher-registry'
import { forgetNativeChatPromptDismissalsForTab } from '@/components/native-chat/native-chat-prompt-dismissals'
import { retireAgentPaneAuthorityAliasesByOwnerTab } from './agent-pane-authority'
import {
buildAgentStatusTabPrefixDropPatch,
@@ -46,6 +47,7 @@ export function sweepRetiredTerminalTabState(
actions.clearPaneForegroundAgentByTabPrefix(tabId)
// Why: retirement permanently retires the tab's panes (a reopen mints a fresh leafId), so drop hibernation output epochs to keep the module map from growing forever.
forgetAgentHibernationTabOutput(tabId)
forgetNativeChatPromptDismissalsForTab(tabId)
// Why: same rationale — retired tab ids never recur, so drop the foreground last-seen and consumed agent-startup delivery guards.
forgetForegroundTerminalTabs([tabId])
forgetAgentStartupDeliveriesForTabs([tabId])
@@ -96,6 +98,7 @@ export function buildRetiredTerminalTabStateSweepPatch(
swept = { ...swept, ...patch, ...foreground }
if (!opts?.paneKeys) {
forgetAgentHibernationTabOutput(tabId)
forgetNativeChatPromptDismissalsForTab(tabId)
}
}
if (!opts?.paneKeys) {
@@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest'
import {
NATIVE_CHAT_COMPOSER_SCOPE_CACHE_MAX,
setBoundedScopeCacheEntry
} from './native-chat-composer-scope-cache'
} from './native-chat-scope-cache'
describe('setBoundedScopeCacheEntry', () => {
it('bounds the cache with LRU eviction, keeping re-set keys', () => {
+51
View File
@@ -0,0 +1,51 @@
// Dismissed prompt occurrences per chat scope, kept outside any view so a remount cannot reshow
// one. Both apps own one store each; entries die when a view observes their prompt change, or by
// the bound.
import { setBoundedScopeCacheEntry } from './native-chat-scope-cache'
export type PromptDismissalStore<T> = {
subscribe: (listener: () => void) => () => void
read: (scopeKey: string) => T | undefined
write: (scopeKey: string, dismissal: T) => void
forget: (scopeKey: string) => void
forgetWhere: (match: (scopeKey: string) => boolean) => void
clearForTests: () => void
}
export function createPromptDismissalStore<T>(): PromptDismissalStore<T> {
const dismissals = new Map<string, T>()
const listeners = new Set<() => void>()
const notify = (): void => {
for (const listener of listeners) {
listener()
}
}
return {
subscribe: (listener) => {
listeners.add(listener)
return () => {
listeners.delete(listener)
}
},
read: (scopeKey) => dismissals.get(scopeKey),
write: (scopeKey, dismissal) => {
setBoundedScopeCacheEntry(dismissals, scopeKey, dismissal)
notify()
},
forget: (scopeKey) => {
if (dismissals.delete(scopeKey)) {
notify()
}
},
forgetWhere: (match) => {
const stale = [...dismissals.keys()].filter(match)
for (const scopeKey of stale) {
dismissals.delete(scopeKey)
}
if (stale.length > 0) {
notify()
}
},
clearForTests: () => dismissals.clear()
}
}
+1
View File
@@ -25,6 +25,7 @@ export type WriteAmbiguityReason =
| 'settlement_timeout'
| 'endpoint_write_threw'
| 'provider_threw_after_handoff'
| 'partial_write'
export type WriteSettlement =
| Readonly<{ outcome: 'accepted' }>
@@ -105,6 +105,8 @@ export const TerminalSend = TerminalHandle.extend({
interrupt: z.unknown().optional(),
// Why: older hosts strip this optional intent and retain their direct-send behavior.
agentPrompt: z.literal(true).optional(),
// Older hosts strip this and omit the proof; callers read their whole-write `accepted` verdict.
requireWriteSettlement: z.literal(true).optional(),
// Why: waiting observes the same prompt receipt; it never authorizes a second write.
waitSubmitMs: z.number().int().min(0).max(3_600_000).optional(),
resolvedLaunchDraft: z
+5 -24
View File
@@ -11,6 +11,11 @@ import type { TabGroupLayoutNode } from './tab-types'
import type { TerminalExitCause } from './terminal-exit-cause'
import type { TerminalPaneLayoutNode } from './terminal-tab-types'
import type { TerminalAgent, TuiAgent } from './terminal-agent'
export type {
RuntimeTerminalSend,
RuntimeTerminalPromptStage,
RuntimeTerminalPromptDelivery
} from './runtime-terminal-send-contract'
export type RuntimeTerminalSummary = {
handle: string
@@ -210,30 +215,6 @@ export type RuntimeTerminalRename = {
title: string | null
}
export type RuntimeTerminalSend = {
handle: string
accepted: boolean
bytesWritten: number
refusedReason?: 'no-agent' | 'permission'
prompt?: RuntimeTerminalPromptDelivery
}
export type RuntimeTerminalPromptStage = 'input_accepted' | 'turn_started'
export type RuntimeTerminalPromptDelivery = {
requestId: string
stages: RuntimeTerminalPromptStage[]
provider: 'claude' | 'codex' | 'unsupported' | 'old-host'
observation: 'supported' | 'unsupported' | 'incarnation_replaced' | 'permission'
processIncarnation: string
generation: number
baselineWorkingSequence: number
/** Hook turn-start timestamp before this prompt was accepted. */
baselineExplicitWorkingStartedAt?: number | null
/** Permission observations seen before this prompt was accepted. */
baselinePermissionSequence?: number
}
export type RuntimeTerminalAgentStatusState = 'working' | 'permission' | 'idle' | null
export type RuntimeTerminalAgentStatus = {
@@ -0,0 +1,26 @@
import type { WriteSettlement } from './pty-write-settlement'
export type RuntimeTerminalSend = {
handle: string
accepted: boolean
bytesWritten: number
refusedReason?: 'no-agent' | 'permission'
prompt?: RuntimeTerminalPromptDelivery
writeSettlement?: WriteSettlement
}
export type RuntimeTerminalPromptStage = 'input_accepted' | 'turn_started'
export type RuntimeTerminalPromptDelivery = {
requestId: string
stages: RuntimeTerminalPromptStage[]
provider: 'claude' | 'codex' | 'unsupported' | 'old-host'
observation: 'supported' | 'unsupported' | 'incarnation_replaced' | 'permission'
processIncarnation: string
generation: number
baselineWorkingSequence: number
/** Hook turn-start timestamp before this prompt was accepted. */
baselineExplicitWorkingStartedAt?: number | null
/** Permission observations seen before this prompt was accepted. */
baselinePermissionSequence?: number
}
@@ -0,0 +1,33 @@
/** An older host omits `writeSettlement`; its whole-write `accepted` verdict stands, as before. */
export function readTerminalSendAcknowledgment(
result: unknown
): 'accepted' | 'refused' | 'unverifiable' {
if (typeof result !== 'object' || result === null || !('send' in result)) {
return 'unverifiable'
}
const send = result.send
if (typeof send !== 'object' || send === null) {
return 'unverifiable'
}
if ('writeSettlement' in send) {
const settlement = send.writeSettlement
if (typeof settlement === 'object' && settlement !== null && 'outcome' in settlement) {
if (settlement.outcome === 'refused') {
return 'refused'
}
if (settlement.outcome === 'accepted' && 'accepted' in send && send.accepted === true) {
return 'accepted'
}
}
return 'unverifiable'
}
if ('accepted' in send) {
if (send.accepted === true) {
return 'accepted'
}
if (send.accepted === false) {
return 'refused'
}
}
return 'unverifiable'
}
@@ -0,0 +1,213 @@
import { vi } from 'vitest'
import { build } from 'esbuild'
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import type { sendMobileNativeChatPermissionResponse } from '../../../mobile/src/session/mobile-native-chat-permission-send'
import { SshChannelMultiplexer } from '../../../src/main/ssh/ssh-channel-multiplexer'
import { SshPtyProvider } from '../../../src/main/providers/ssh-pty-provider'
import {
HEADER_LENGTH,
MessageType,
parseJsonRpcMessage
} from '../../../src/main/ssh/relay-protocol'
import { createPtyWriteInput } from '../../../src/main/ipc/pty/ipc/write-input'
import type { IPtyProvider } from '../../../src/main/providers/types'
import type * as PtyProviderRegistry from '../../../src/main/ipc/pty/provider/registry'
import { ptyOwnership } from '../../../src/main/ipc/pty/provider/ownership-state'
import { OrcaRuntimeService } from '../../../src/main/runtime/orca-runtime'
import { makeStore } from '../../../src/main/runtime/runtime-rpc-worktree-store-fixtures'
const io = vi.hoisted(() => ({
getProvider: (): IPtyProvider => {
throw new Error('provider not installed')
},
rpc: vi.fn()
}))
export { io }
vi.mock('../../../src/main/ipc/pty/provider/registry', async (importOriginal) => {
const original = await importOriginal<typeof PtyProviderRegistry>()
return {
...original,
tryGetProviderForPty: () => io.getProvider(),
getProviderForPty: () => io.getProvider()
}
})
vi.mock('@/store', () => ({ useAppStore: { getState: () => ({ terminalLayoutsByTabId: {} }) } }))
vi.mock('@/runtime/runtime-rpc-client', () => ({
getActiveRuntimeTarget: () => ({ kind: 'local' }),
callRuntimeRpc: (...args: unknown[]) => io.rpc(...args)
}))
vi.mock('@/runtime/runtime-terminal-stream', () => ({
getRemoteRuntimePtyEnvironmentId: (id: string) => (id.startsWith('remote:') ? 'owner' : null),
getRemoteRuntimeTerminalHandle: (id: string) => (id.startsWith('remote:') ? 'terminal' : null)
}))
export function createSshDelivery(mode: 'accepted' | 'lost' | 'slow' = 'accepted') {
const bytes: string[] = []
const mux = new SshChannelMultiplexer({
supportsWriteSettlement: true,
onData: () => {},
onClose: () => {},
write: (frame, callback) => {
if (frame[0] !== MessageType.Regular) {
callback?.({ ok: true })
return true
}
const message = parseJsonRpcMessage(frame.subarray(HEADER_LENGTH))
if (
!('method' in message) ||
message.method !== 'pty.data' ||
typeof message.params?.data !== 'string'
) {
throw new Error('unexpected transport frame')
}
bytes.push(message.params.data)
const settle = (): void =>
callback?.(
mode === 'lost'
? { ok: false, error: new Error('lost write acknowledgment') }
: { ok: true }
)
if (mode === 'lost') {
setTimeout(settle, 10)
} else if (mode === 'slow' && message.params.data.startsWith('X')) {
setTimeout(settle, 1300)
} else {
settle()
}
return true
}
})
const provider = new SshPtyProvider('connection', mux)
vi.spyOn(provider, 'hasPty').mockReturnValue(true)
const settlement = vi.spyOn(provider, 'writeWithSettlement')
io.getProvider = () => provider
const input = createPtyWriteInput({})
const id = 'ssh:connection@@pty-1'
ptyOwnership.set(id, 'connection')
vi.stubGlobal('window', {
api: {
pty: {
write: (ptyId: string, data: string, inputKind: 'driving') =>
input.writePtyInput({ id: ptyId, data, inputKind }),
writeAccepted: (
ptyId: string,
data: string,
inputKind: 'driving',
options?: { requireWriteSettlement?: true }
) => input.writePtyInputAccepted({ id: ptyId, data, inputKind, ...options })
}
}
})
return {
id,
bytes,
provider,
settlement,
close: () => {
ptyOwnership.delete(id)
provider.dispose()
mux.dispose()
}
}
}
export async function createPairedRuntime(ssh: ReturnType<typeof createSshDelivery>) {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This leaf-only fixture uses the existing store stub and never invokes repository mutations.
const runtime = new OrcaRuntimeService(makeStore() as never)
runtime.setPtyController({
write: (_, data) => ssh.provider.write(ssh.id, data),
writeWithSettlement: (_, data) => ssh.provider.writeWithSettlement(ssh.id, data),
hasPty: () => true,
kill: () => true,
getForegroundProcess: async () => null
})
runtime.attachWindow(1)
runtime.syncWindowGraph(1, {
tabs: [
{
tabId: 'tab-1',
worktreeId: 'repo-1::/tmp/worktree-a',
title: 'Codex',
activeLeafId: '11111111-1111-4111-8111-111111111111',
layout: null
}
],
leaves: [
{
tabId: 'tab-1',
worktreeId: 'repo-1::/tmp/worktree-a',
leafId: '11111111-1111-4111-8111-111111111111',
paneRuntimeId: 1,
ptyId: ssh.id,
paneTitle: null,
title: ''
}
]
})
const { terminals } = await runtime.listTerminals('id:repo-1::/tmp/worktree-a')
if (!terminals[0]) {
throw new Error('terminal fixture missing')
}
return { runtime, handle: terminals[0].handle }
}
let mobilePermissionModule: Promise<unknown> | undefined
async function bundleMobilePermissionModule(): Promise<unknown> {
const result = await build({
entryPoints: [
fileURLToPath(
new URL(
'../../../mobile/src/session/mobile-native-chat-permission-send.ts',
import.meta.url
)
)
],
bundle: true,
platform: 'node',
format: 'esm',
write: false,
// Root-only test workers have no Expo tsconfig; execute the unchanged source bundle.
tsconfigRaw: {}
})
const source = result.outputFiles[0]?.text
if (!source) {
throw new Error('Mobile permission module did not build')
}
// Why a file, not a data: URL: the Bun test runtime resolves a long data: URL as a package name.
const dir = await mkdtemp(join(tmpdir(), 'orca-mobile-permission-send-'))
try {
const file = join(dir, 'mobile-native-chat-permission-send.mjs')
await writeFile(file, source)
return await import(/* @vite-ignore */ pathToFileURL(file).href)
} finally {
await rm(dir, { recursive: true, force: true })
}
}
export async function sendMobilePermissionResponse(
args: Parameters<typeof sendMobileNativeChatPermissionResponse>[0]
): ReturnType<typeof sendMobileNativeChatPermissionResponse> {
const mobileModule = await (mobilePermissionModule ??= bundleMobilePermissionModule())
if (
typeof mobileModule !== 'object' ||
mobileModule === null ||
!('sendMobileNativeChatPermissionResponse' in mobileModule) ||
typeof mobileModule.sendMobileNativeChatPermissionResponse !== 'function'
) {
throw new Error('Mobile permission module export is unavailable')
}
const outcome: unknown = await mobileModule.sendMobileNativeChatPermissionResponse(args)
if (
outcome === 'accepted' ||
outcome === 'rejected' ||
outcome === 'unknown' ||
outcome === 'queued'
) {
return outcome
}
throw new Error('Mobile permission module returned an invalid outcome')
}
@@ -0,0 +1,262 @@
import '../../src/main/runtime/rpc/unused-default-rpc-methods.test-fixture'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
createSshDelivery,
createPairedRuntime,
sendMobilePermissionResponse,
io
} from './__mocks__/native-chat-ssh-delivery'
import {
sendNativeChatAskAnswer,
sendNativeChatMessage,
sendNativeChatMessageVerified,
resetNativeChatPtySendQueuesForTests
} from '../../src/renderer/src/components/native-chat/native-chat-runtime-send'
import { buildAskAnswerKeys } from '../../src/shared/native-chat-ask'
import { sendRuntimePtyInputVerified } from '@/runtime/runtime-terminal-inspection'
import { RpcDispatcher } from '../../src/main/runtime/rpc/dispatcher'
import { TERMINAL_METHODS } from '../../src/main/runtime/rpc/methods/terminal'
import { sendAgentDraftPasteContentNow } from '../../src/renderer/src/lib/agent-draft-paste-content'
import type { RpcClient } from '../../mobile/src/transport/rpc-client'
let close: (() => void) | undefined
afterEach(() => {
resetNativeChatPtySendQueuesForTests()
close?.()
close = undefined
vi.useRealTimers()
vi.unstubAllGlobals()
io.rpc.mockReset()
})
describe('prompt delivery through production IPC, provider and paired host', () => {
it('serializes every chunk of a healthy large SSH answer before Enter and dismissal', async () => {
vi.useFakeTimers()
const ssh = createSshDelivery('slow')
close = ssh.close
const text = `${'X'.repeat(32768)}TAIL`
const groups = buildAskAnswerKeys(
{ questions: [{ question: 'Answer?', options: [{ label: 'A' }], multiSelect: false }] },
[{ indices: [], other: text }]
)
const onSettled = vi.fn()
sendNativeChatAskAnswer(null, ssh.id, groups, onSettled)
await vi.advanceTimersByTimeAsync(2000)
expect(ssh.bytes).not.toContain('\r')
expect(onSettled).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(4000)
expect(ssh.bytes.join('')).toBe(
groups.map((group) => ('raw' in group ? group.raw : group.text)).join('')
)
expect(ssh.bytes.findIndex((data) => data.includes('TAIL'))).toBeLessThan(
ssh.bytes.indexOf('\r')
)
expect(onSettled).toHaveBeenCalledExactlyOnceWith(true)
})
it('cancels remaining paced groups while an already-issued body still settles', async () => {
vi.useFakeTimers()
const ssh = createSshDelivery('slow')
close = ssh.close
const onSettled = vi.fn()
const handle = sendNativeChatAskAnswer(
null,
ssh.id,
[{ text: `${'X'.repeat(32768)}TAIL` }, { raw: '\r' }],
onSettled
)
await vi.advanceTimersByTimeAsync(500)
handle.cancel()
await vi.advanceTimersByTimeAsync(4000)
expect(ssh.bytes.join('')).toBe(`${'X'.repeat(32768)}TAIL`)
expect(ssh.bytes).not.toContain('\r')
expect(onSettled).not.toHaveBeenCalled()
})
it('keeps ordinary healthy SSH body and Enter complete', async () => {
vi.useFakeTimers()
const ssh = createSshDelivery()
close = ssh.close
const onDeliverySettled = vi.fn()
sendNativeChatMessage(null, ssh.id, 'body', { onDeliverySettled })
await vi.advanceTimersByTimeAsync(600)
expect(ssh.bytes.join('')).toBe('\x15body\r')
expect(onDeliverySettled).toHaveBeenCalledExactlyOnceWith(true)
})
it.each(['accepted', 'lost'] as const)(
'paired terminal.send uses provider settlement for %s acknowledgment',
async (mode) => {
const ssh = createSshDelivery(mode)
close = ssh.close
const { runtime, handle } = await createPairedRuntime(ssh)
const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS })
io.rpc.mockImplementation(async (_target, method, params) => {
const reply = await dispatcher.dispatch({
id: 'send',
authToken: 'token',
method,
params: { ...params, terminal: handle }
})
if (!reply.ok) {
throw new Error(reply.error.message)
}
return reply.result
})
const pending = sendRuntimePtyInputVerified(
null,
'remote:owner@@terminal',
'\x1b',
'driving',
{
requireWriteSettlement: true
}
)
await (mode === 'accepted'
? expect(pending).resolves.toBe(true)
: expect(pending).rejects.toThrow('acknowledgment unavailable'))
expect(ssh.settlement).toHaveBeenCalledOnce()
expect(ssh.bytes).toEqual(['\x1b'])
expect(io.rpc).toHaveBeenCalledWith(
expect.anything(),
'terminal.send',
expect.objectContaining({ requireWriteSettlement: true }),
expect.anything()
)
}
)
it('older paired hosts keep their whole-write verdict for answers and ordinary sequences', async () => {
const ssh = createSshDelivery()
close = ssh.close
const { runtime, handle } = await createPairedRuntime(ssh)
const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS })
io.rpc.mockImplementation(async (_target, _method, params) => {
// Older request schemas strip the optional requirement and perform the legacy write.
const reply = await dispatcher.dispatch({
id: 'legacy',
authToken: 'token',
method: 'terminal.send',
params: { ...params, terminal: handle, requireWriteSettlement: undefined }
})
if (!reply.ok) {
throw new Error(reply.error.message)
}
return reply.result
})
await expect(
sendRuntimePtyInputVerified(null, 'remote:owner@@terminal', 'answer', 'driving', {
requireWriteSettlement: true
})
).resolves.toBe(true)
await expect(
sendNativeChatMessageVerified(null, 'remote:owner@@terminal', 'body')
).resolves.toBe(true)
await expect(
sendAgentDraftPasteContentNow(null, 'remote:owner@@terminal', 'launch', 'launch')
).resolves.toBe(true)
expect(ssh.bytes).toEqual(['answer', 'body', '\r', '\x1b[200~launch\x1b[201~'])
expect(ssh.settlement).not.toHaveBeenCalled()
})
it('older paired hosts confirm ordinary chat sends and pasted answers as before', async () => {
vi.useFakeTimers()
io.rpc.mockResolvedValue({ send: { handle: 'terminal', accepted: true, bytesWritten: 1 } })
const onWriteUnconfirmed = vi.fn()
const onWriteRejected = vi.fn()
sendNativeChatMessage(null, 'remote:owner@@terminal', 'hello', {
onWriteRejected,
onWriteUnconfirmed
})
const onDeliverySettled = vi.fn()
sendNativeChatMessage(null, 'remote:owner@@terminal', 'answer', { onDeliverySettled })
await vi.advanceTimersByTimeAsync(2000)
expect(onWriteUnconfirmed).not.toHaveBeenCalled()
expect(onWriteRejected).not.toHaveBeenCalled()
expect(onDeliverySettled).toHaveBeenCalledExactlyOnceWith(true)
})
it('sends ordinary paired input without asking the host for provider settlement', async () => {
io.rpc.mockResolvedValue({ send: { handle: 'terminal', accepted: true, bytesWritten: 4 } })
await expect(
sendRuntimePtyInputVerified(null, 'remote:owner@@terminal', 'body', 'driving')
).resolves.toBe(true)
expect(io.rpc).toHaveBeenCalledWith(
expect.anything(),
'terminal.send',
expect.not.objectContaining({ requireWriteSettlement: expect.anything() }),
expect.anything()
)
})
it.each(['accepted', 'lost', 'older-host'] as const)(
'mobile permission send reads the actual paired %s verdict',
async (mode) => {
const ssh = createSshDelivery(mode === 'lost' ? 'lost' : 'accepted')
close = ssh.close
const { runtime, handle } = await createPairedRuntime(ssh)
const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS })
const client: RpcClient = {
sendRequest: (method, params) =>
dispatcher.dispatch({
id: 'mobile',
authToken: 'token',
method,
// An older host's schema strips the optional requirement.
params:
mode === 'older-host' && typeof params === 'object' && params !== null
? { ...params, requireWriteSettlement: undefined }
: params
}),
subscribe: () => () => {},
updateTerminalSubscriptionViewport: () => {},
getState: () => 'connected',
getReconnectAttempt: () => 0,
getLastConnectedAt: () => null,
onStateChange: () => () => {},
notifyForeground: () => {},
close: () => {}
}
await expect(
sendMobilePermissionResponse({
client,
terminal: handle,
deviceToken: null,
text: '\x1b'
})
).resolves.toBe(mode === 'lost' ? 'unknown' : 'accepted')
expect(ssh.settlement).toHaveBeenCalledTimes(mode === 'older-host' ? 0 : 1)
expect(ssh.bytes).toEqual(['\x1b'])
}
)
it('finishes every older-host selector group once and settles the answer as delivered', async () => {
vi.useFakeTimers()
const ssh = createSshDelivery()
close = ssh.close
const { runtime, handle } = await createPairedRuntime(ssh)
const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS })
io.rpc.mockImplementation(async (_target, _method, params) => {
const reply = await dispatcher.dispatch({
id: 'legacy-selector',
authToken: 'token',
method: 'terminal.send',
params: { ...params, terminal: handle, requireWriteSettlement: undefined }
})
if (!reply.ok) {
throw new Error(reply.error.message)
}
return reply.result
})
const groups = buildAskAnswerKeys(
{ questions: [{ question: 'Answer?', options: [{ label: 'A' }], multiSelect: false }] },
[{ indices: [], other: 'custom answer' }]
)
const onSettled = vi.fn()
sendNativeChatAskAnswer(null, 'remote:owner@@terminal', groups, onSettled)
await vi.advanceTimersByTimeAsync(4000)
expect(ssh.bytes).toEqual(groups.map((group) => ('raw' in group ? group.raw : group.text)))
expect(ssh.settlement).not.toHaveBeenCalled()
expect(onSettled).toHaveBeenCalledExactlyOnceWith(true)
})
})