From 468e4e1167b4bd64d510022f29827610a37aed30 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 10:57:58 -0700 Subject: [PATCH] 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. --- config/reliability-gates.jsonc | 227 ++++++++++- config/scripts/ci-shard-timings.json | 2 +- mobile/src/session/MobileNativeChatAsk.tsx | 17 +- .../MobileNativeChatCardHeaderAction.test.tsx | 92 +++++ .../MobileNativeChatCardHeaderAction.tsx | 45 +++ .../src/session/MobileNativeChatComposer.tsx | 6 +- .../src/session/MobileNativeChatOverlay.tsx | 4 + .../MobileNativeChatPermission.test.ts | 7 +- .../session/MobileNativeChatPermission.tsx | 31 +- .../MobileNativeChatPromptCard.test.tsx | 54 +++ .../session/MobileNativeChatPromptCard.tsx | 107 +++--- .../session/MobileNativeChatPromptStrip.tsx | 45 +++ .../session/MobileNativeChatQuestion.test.tsx | 2 + .../src/session/MobileNativeChatQuestion.tsx | 31 +- ...NativeChatView.prompt-presentation.test.ts | 229 +++++++++++ .../src/session/MobileNativeChatView.test.ts | 32 ++ mobile/src/session/MobileNativeChatView.tsx | 40 +- .../__mocks__/mobile-prompt-controller.ts | 299 +++++++++++++++ .../mobile-native-chat-controller-contract.ts | 7 + ...mobile-native-chat-permission-send.test.ts | 1 + .../mobile-native-chat-permission-send.ts | 1 + .../mobile-native-chat-prompt-dismissals.ts | 12 + mobile/src/session/mobile-native-chat-send.ts | 14 + .../session/mobile-prompt-controller.test.ts | 359 ++++++++++++++++++ .../use-mobile-native-chat-answer-send.ts | 7 +- ...use-mobile-native-chat-ask-dismiss.test.ts | 2 + .../use-mobile-native-chat-ask-dismiss.ts | 89 ++--- .../use-mobile-native-chat-cancel-ask.ts | 3 +- .../use-mobile-native-chat-controller.test.ts | 8 +- .../use-mobile-native-chat-controller.ts | 54 +-- .../use-mobile-native-chat-input-lease.ts | 11 + .../use-mobile-native-chat-message-send.ts | 7 +- .../use-mobile-native-chat-prompt-cards.ts | 53 +++ .../use-mobile-native-chat-prompt-dismiss.ts | 78 ++++ ...-mobile-native-chat-prompt-presentation.ts | 78 ++++ .../session/use-mobile-native-chat-prompts.ts | 5 +- .../pty/ipc/write-input-acceptance.test.ts | 128 +++++++ src/main/ipc/pty/ipc/write-input.ts | 108 +++++- ...ca-runtime-controller-knows-pty-is-live.ts | 26 +- ...-authoritative-terminal-wait-permission.ts | 5 +- src/main/runtime/orca-runtime-runtime-id.ts | 15 +- .../methods/terminal/terminal-send-method.ts | 1 + .../runtime/runtime-terminal-writer.test.ts | 162 ++++++++ src/main/runtime/runtime-terminal-writer.ts | 94 ++++- src/preload/api/pty-api.ts | 8 +- src/preload/api/pty-bridge-session-control.ts | 9 +- .../NativeChatApprovalCard.test.tsx | 24 ++ .../native-chat/NativeChatApprovalCard.tsx | 29 +- .../native-chat/NativeChatComposer.test.tsx | 17 + .../native-chat/NativeChatComposer.tsx | 10 +- .../NativeChatInteractiveCard.test.tsx | 329 +++++++++++++--- .../native-chat/NativeChatInteractiveCard.tsx | 178 +++++---- .../native-chat/NativeChatPromptCollapse.tsx | 67 ++++ .../native-chat/NativeChatQuestionCard.tsx | 50 ++- ...ResolvedView.prompt-card-composer.test.tsx | 243 ++++++++++++ ...hatResolvedView.prompt-card-input.test.tsx | 309 +++++++++++++++ .../native-chat/NativeChatResolvedView.tsx | 67 ++-- ...ativeChatResolvedView.turn-status.test.tsx | 25 +- .../native-chat/native-chat-command-marker.ts | 2 +- .../native-chat/native-chat-composer-types.ts | 2 + .../native-chat-draft-cache.test.ts | 2 +- .../native-chat/native-chat-input-clear.ts | 6 + .../native-chat-observed-send.test.ts | 37 ++ .../native-chat/native-chat-observed-send.ts | 32 +- .../native-chat/native-chat-pending.ts | 2 +- .../native-chat-prompt-dismissals.ts | 17 + .../native-chat/native-chat-pty-send-queue.ts | 7 +- .../native-chat-runtime-send.test.ts | 46 ++- .../native-chat/native-chat-runtime-send.ts | 62 +-- .../native-chat-session-option-cache.ts | 2 +- .../use-native-chat-composer-interrupt.ts | 9 +- .../use-native-chat-composer-reveal-focus.ts | 3 +- .../use-native-chat-interactive-send.test.tsx | 34 +- .../use-native-chat-interactive-send.ts | 79 ++-- .../use-native-chat-prompt-card-focus.ts | 22 ++ ...tive-chat-prompt-card-presentation.test.ts | 87 +++++ ...se-native-chat-prompt-card-presentation.ts | 102 +++++ .../use-native-chat-send-lifecycle.ts | 49 ++- src/renderer/src/i18n/locales/en.json | 4 + src/renderer/src/i18n/locales/es.json | 4 + src/renderer/src/i18n/locales/fr.json | 4 + src/renderer/src/i18n/locales/ja.json | 4 + src/renderer/src/i18n/locales/ko.json | 4 + src/renderer/src/i18n/locales/zh.json | 4 + .../runtime-terminal-input-recording.ts | 69 ++++ .../runtime/runtime-terminal-inspection.ts | 119 +----- .../runtime-terminal-verified-input.test.ts | 106 ++++++ .../runtime-terminal-verified-input.ts | 83 ++++ .../retired-terminal-tab-state-sweep.ts | 3 + .../native-chat-scope-cache.test.ts} | 2 +- .../native-chat-scope-cache.ts} | 0 src/shared/prompt-dismissal-store.ts | 51 +++ src/shared/pty-write-settlement.ts | 1 + .../rpc-contract/terminal-unary-params.ts | 2 + src/shared/runtime-terminal-contracts.ts | 29 +- src/shared/runtime-terminal-send-contract.ts | 26 ++ src/shared/terminal-send-acknowledgment.ts | 33 ++ .../e2e/__mocks__/native-chat-ssh-delivery.ts | 213 +++++++++++ .../e2e/native-chat-ssh-delivery.unit.test.ts | 262 +++++++++++++ 99 files changed, 4868 insertions(+), 720 deletions(-) create mode 100644 mobile/src/session/MobileNativeChatCardHeaderAction.test.tsx create mode 100644 mobile/src/session/MobileNativeChatCardHeaderAction.tsx create mode 100644 mobile/src/session/MobileNativeChatPromptCard.test.tsx create mode 100644 mobile/src/session/MobileNativeChatPromptStrip.tsx create mode 100644 mobile/src/session/MobileNativeChatView.prompt-presentation.test.ts create mode 100644 mobile/src/session/__mocks__/mobile-prompt-controller.ts create mode 100644 mobile/src/session/mobile-native-chat-prompt-dismissals.ts create mode 100644 mobile/src/session/mobile-prompt-controller.test.ts create mode 100644 mobile/src/session/use-mobile-native-chat-prompt-cards.ts create mode 100644 mobile/src/session/use-mobile-native-chat-prompt-dismiss.ts create mode 100644 mobile/src/session/use-mobile-native-chat-prompt-presentation.ts create mode 100644 src/main/ipc/pty/ipc/write-input-acceptance.test.ts create mode 100644 src/main/runtime/runtime-terminal-writer.test.ts create mode 100644 src/renderer/src/components/native-chat/NativeChatPromptCollapse.tsx create mode 100644 src/renderer/src/components/native-chat/NativeChatResolvedView.prompt-card-composer.test.tsx create mode 100644 src/renderer/src/components/native-chat/NativeChatResolvedView.prompt-card-input.test.tsx create mode 100644 src/renderer/src/components/native-chat/native-chat-prompt-dismissals.ts create mode 100644 src/renderer/src/components/native-chat/use-native-chat-prompt-card-focus.ts create mode 100644 src/renderer/src/components/native-chat/use-native-chat-prompt-card-presentation.test.ts create mode 100644 src/renderer/src/components/native-chat/use-native-chat-prompt-card-presentation.ts create mode 100644 src/renderer/src/runtime/runtime-terminal-input-recording.ts create mode 100644 src/renderer/src/runtime/runtime-terminal-verified-input.test.ts create mode 100644 src/renderer/src/runtime/runtime-terminal-verified-input.ts rename src/{renderer/src/components/native-chat/native-chat-composer-scope-cache.test.ts => shared/native-chat-scope-cache.test.ts} (96%) rename src/{renderer/src/components/native-chat/native-chat-composer-scope-cache.ts => shared/native-chat-scope-cache.ts} (100%) create mode 100644 src/shared/prompt-dismissal-store.ts create mode 100644 src/shared/runtime-terminal-send-contract.ts create mode 100644 src/shared/terminal-send-acknowledgment.ts create mode 100644 tests/e2e/__mocks__/native-chat-ssh-delivery.ts create mode 100644 tests/e2e/native-chat-ssh-delivery.unit.test.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 6df235c0a0f..e11f4cfa19e 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -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." } ], diff --git a/config/scripts/ci-shard-timings.json b/config/scripts/ci-shard-timings.json index 9ba114966a8..deee669865c 100644 --- a/config/scripts/ci-shard-timings.json +++ b/config/scripts/ci-shard-timings.json @@ -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, diff --git a/mobile/src/session/MobileNativeChatAsk.tsx b/mobile/src/session/MobileNativeChatAsk.tsx index 33c9311715e..228b266de9e 100644 --- a/mobile/src/session/MobileNativeChatAsk.tsx +++ b/mobile/src/session/MobileNativeChatAsk.tsx @@ -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 onCancel?: () => Promise + /** 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(() => prompt.questions.map(() => [])) const [otherText, setOtherText] = useState(() => prompt.questions.map(() => '')) @@ -127,7 +135,10 @@ export function MobileNativeChatAsk({ prompt, onAnswer, onCancel }: Props): Reac ) : null} - {q.question} + + {q.question} + + {q.options.map((opt, optIndex) => ( ({ + 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) => 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()) + }) +}) diff --git a/mobile/src/session/MobileNativeChatCardHeaderAction.tsx b/mobile/src/session/MobileNativeChatCardHeaderAction.tsx new file mode 100644 index 00000000000..4c7fa3ef4ad --- /dev/null +++ b/mobile/src/session/MobileNativeChatCardHeaderAction.tsx @@ -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, + onCancel, + onCollapse, + disabled +}: { + prompt?: Prompt + onCancel?: (prompt?: Prompt) => Promise + onCollapse?: () => void + disabled?: boolean +}): React.JSX.Element | null { + if (!onCancel && !onCollapse) { + return null + } + const Icon = onCancel ? X : ChevronDown + return ( + { + 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} + > + + + ) +} + +const styles = StyleSheet.create({ + action: { width: 28, height: 28, alignItems: 'center', justifyContent: 'center' } +}) diff --git a/mobile/src/session/MobileNativeChatComposer.tsx b/mobile/src/session/MobileNativeChatComposer.tsx index ae05d9622c1..34adc23b83e 100644 --- a/mobile/src/session/MobileNativeChatComposer.tsx +++ b/mobile/src/session/MobileNativeChatComposer.tsx @@ -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. */} diff --git a/mobile/src/session/MobileNativeChatOverlay.tsx b/mobile/src/session/MobileNativeChatOverlay.tsx index d3d6632b68e..3d08c8d66bd 100644 --- a/mobile/src/session/MobileNativeChatOverlay.tsx +++ b/mobile/src/session/MobileNativeChatOverlay.tsx @@ -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} diff --git a/mobile/src/session/MobileNativeChatPermission.test.ts b/mobile/src/session/MobileNativeChatPermission.test.ts index 9d82f254ffc..ccf579f739f 100644 --- a/mobile/src/session/MobileNativeChatPermission.test.ts +++ b/mobile/src/session/MobileNativeChatPermission.test.ts @@ -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', () => { diff --git a/mobile/src/session/MobileNativeChatPermission.tsx b/mobile/src/session/MobileNativeChatPermission.tsx index ab7dda7374e..84a6048540b 100644 --- a/mobile/src/session/MobileNativeChatPermission.tsx +++ b/mobile/src/session/MobileNativeChatPermission.tsx @@ -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 onCancel?: (prompt?: NonNullable) => Promise + /** 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} - {onCancel ? ( - void onCancel(permission.prompt)} - disabled={submitting} - > - - - ) : null} + {hasContext ? ( ({ + Pressable: 'Pressable', + ScrollView: 'ScrollView', + StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 }, + Text: 'Text', + TextInput: 'TextInput', + View: 'View', + Platform: { OS: 'ios', select: (o: Record) => 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()) + }) +}) diff --git a/mobile/src/session/MobileNativeChatPromptCard.tsx b/mobile/src/session/MobileNativeChatPromptCard.tsx index 31a007801dc..210c9183962 100644 --- a/mobile/src/session/MobileNativeChatPromptCard.tsx +++ b/mobile/src/session/MobileNativeChatPromptCard.tsx @@ -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 onCancelAsk?: () => Promise onCancelPrompt?: (prompt?: NonNullable) => Promise + 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 question?: MobileChatQuestion | null onAnswerQuestion?: (text: string) => Promise }): React.JSX.Element | null { - if (ask) { - return ( - { - 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 ( - (await onRespondPermission?.(send)) ?? false} - onCancel={onCancelPrompt} - /> - ) - } - if (question) { - return ( - (await onAnswerQuestion?.(text)) ?? false} - onCancel={onCancelPrompt} - /> - ) - } - return null + const card = ask ? ( + { + 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 ? ( + (await onRespondPermission?.(send)) ?? false} + onCancel={onCancelPrompt} + onCollapse={onCollapsePrompt} + /> + ) : question ? ( + (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 ( + <> + {card} + {collapsedPrompt ? ( + + ) : null} + + ) } + +const styles = StyleSheet.create({ collapsed: { display: 'none' } }) diff --git a/mobile/src/session/MobileNativeChatPromptStrip.tsx b/mobile/src/session/MobileNativeChatPromptStrip.tsx new file mode 100644 index 00000000000..89b0fab2bc3 --- /dev/null +++ b/mobile/src/session/MobileNativeChatPromptStrip.tsx @@ -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 ( + + + {title} + + + + + + ) +} + +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' } +}) diff --git a/mobile/src/session/MobileNativeChatQuestion.test.tsx b/mobile/src/session/MobileNativeChatQuestion.test.tsx index 96cae6556a4..5c877dabb3f 100644 --- a/mobile/src/session/MobileNativeChatQuestion.test.tsx +++ b/mobile/src/session/MobileNativeChatQuestion.test.tsx @@ -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' })) diff --git a/mobile/src/session/MobileNativeChatQuestion.tsx b/mobile/src/session/MobileNativeChatQuestion.tsx index f5f9e69cb8e..314e3efd4a1 100644 --- a/mobile/src/session/MobileNativeChatQuestion.tsx +++ b/mobile/src/session/MobileNativeChatQuestion.tsx @@ -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 onCancel?: (prompt?: NonNullable) => Promise + /** 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([]) const [freeText, setFreeText] = useState('') @@ -108,17 +112,12 @@ export function MobileNativeChatQuestion({ {question.question} - {onCancel ? ( - void onCancel(question.prompt)} - disabled={sending} - > - - - ) : null} + {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 }, diff --git a/mobile/src/session/MobileNativeChatView.prompt-presentation.test.ts b/mobile/src/session/MobileNativeChatView.prompt-presentation.test.ts new file mode 100644 index 00000000000..cfd1157ab27 --- /dev/null +++ b/mobile/src/session/MobileNativeChatView.prompt-presentation.test.ts @@ -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> & { + 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>() + .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((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) + } + ) +}) diff --git a/mobile/src/session/MobileNativeChatView.test.ts b/mobile/src/session/MobileNativeChatView.test.ts index d96c9179b08..cf9be553e79 100644 --- a/mobile/src/session/MobileNativeChatView.test.ts +++ b/mobile/src/session/MobileNativeChatView.test.ts @@ -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) + }) + }) }) diff --git a/mobile/src/session/MobileNativeChatView.tsx b/mobile/src/session/MobileNativeChatView.tsx index a559bd02ef2..a9b4a2f2191 100644 --- a/mobile/src/session/MobileNativeChatView.tsx +++ b/mobile/src/session/MobileNativeChatView.tsx @@ -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 /** Cancel a structured approval/question with exact item identity when supported. */ onCancelPrompt?: (prompt?: { itemId: string; expectedRevision: number }) => Promise + onCollapseAsk?: () => void + onCollapsePrompt?: () => void + collapsedPrompt?: { title: string; expand: () => void } | null question?: MobileChatQuestion | null onAnswerQuestion?: (text: string) => Promise permission?: MobileChatPermission | null + promptKey?: string | null onRespondPermission?: (send: string) => Promise /** 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 ? ( {emptyState.title} @@ -386,16 +401,10 @@ export function MobileNativeChatView({ )} {queuedCards} @@ -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} /> diff --git a/mobile/src/session/__mocks__/mobile-prompt-controller.ts b/mobile/src/session/__mocks__/mobile-prompt-controller.ts new file mode 100644 index 00000000000..397c4483ef2 --- /dev/null +++ b/mobile/src/session/__mocks__/mobile-prompt-controller.ts @@ -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 { + return getTree().root.findByType(MobileNativeChatAsk).props.onCancel() +} +export function askCollapse(): void { + getTree().root.findByType(MobileNativeChatAsk).props.onCollapse() +} +export function askAnswer(selections: AskAnswerSelection[]): Promise { + return getTree().root.findByType(MobileNativeChatAsk).props.onAnswer(selections) +} diff --git a/mobile/src/session/mobile-native-chat-controller-contract.ts b/mobile/src/session/mobile-native-chat-controller-contract.ts index 90ebecb42eb..fab3838d368 100644 --- a/mobile/src/session/mobile-native-chat-controller-contract.ts +++ b/mobile/src/session/mobile-native-chat-controller-contract.ts @@ -48,6 +48,8 @@ export type MobileNativeChatController = { /** Host/workspace/tab/session scope for stateful streaming suppression. */ nativeChatStreamScopeKey: string nativeChatPermission: ReturnType + /** Terminal prompt occurrence; a replacement resets only the card's local submission state. */ + nativeChatPromptKey: string | null nativeChatQuestion: ReturnType /** 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 handleNativeChatCancelAsk: () => Promise + /** 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 diff --git a/mobile/src/session/mobile-native-chat-permission-send.test.ts b/mobile/src/session/mobile-native-chat-permission-send.test.ts index 1525f090029..a3c5d3910f8 100644 --- a/mobile/src/session/mobile-native-chat-permission-send.test.ts +++ b/mobile/src/session/mobile-native-chat-permission-send.test.ts @@ -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 } diff --git a/mobile/src/session/mobile-native-chat-permission-send.ts b/mobile/src/session/mobile-native-chat-permission-send.ts index 7f58b0ab44e..864968b396a 100644 --- a/mobile/src/session/mobile-native-chat-permission-send.ts +++ b/mobile/src/session/mobile-native-chat-permission-send.ts @@ -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 } } : {}) }) } diff --git a/mobile/src/session/mobile-native-chat-prompt-dismissals.ts b/mobile/src/session/mobile-native-chat-prompt-dismissals.ts new file mode 100644 index 00000000000..42d3c501399 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-prompt-dismissals.ts @@ -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() diff --git a/mobile/src/session/mobile-native-chat-send.ts b/mobile/src/session/mobile-native-chat-send.ts index 2a3bc5a60b2..19a37120be3 100644 --- a/mobile/src/session/mobile-native-chat-send.ts +++ b/mobile/src/session/mobile-native-chat-send.ts @@ -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[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 { 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 } : {}), diff --git a/mobile/src/session/mobile-prompt-controller.test.ts b/mobile/src/session/mobile-prompt-controller.test.ts new file mode 100644 index 00000000000..be14c5e2d33 --- /dev/null +++ b/mobile/src/session/mobile-prompt-controller.test.ts @@ -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(), + 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 | 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 | 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 = { + ...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) + } + ) +}) diff --git a/mobile/src/session/use-mobile-native-chat-answer-send.ts b/mobile/src/session/use-mobile-native-chat-answer-send.ts index 40925931d3c..b9b2ecdf0ec 100644 --- a/mobile/src/session/use-mobile-native-chat-answer-send.ts +++ b/mobile/src/session/use-mobile-native-chat-answer-send.ts @@ -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 - /** 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' } } diff --git a/mobile/src/session/use-mobile-native-chat-ask-dismiss.test.ts b/mobile/src/session/use-mobile-native-chat-ask-dismiss.test.ts index 49d69ab4c3b..92101cc6f47 100644 --- a/mobile/src/session/use-mobile-native-chat-ask-dismiss.test.ts +++ b/mobile/src/session/use-mobile-native-chat-ask-dismiss.test.ts @@ -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(() => { diff --git a/mobile/src/session/use-mobile-native-chat-ask-dismiss.ts b/mobile/src/session/use-mobile-native-chat-ask-dismiss.ts index beb46b45d95..ed447e77332 100644 --- a/mobile/src/session/use-mobile-native-chat-ask-dismiss.ts +++ b/mobile/src/session/use-mobile-native-chat-ask-dismiss.ts @@ -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()) - const [dismissedByScope, setDismissedByScope] = useState>( - () => 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 } } diff --git a/mobile/src/session/use-mobile-native-chat-cancel-ask.ts b/mobile/src/session/use-mobile-native-chat-cancel-ask.ts index fb09f2d4aad..fa949db146b 100644 --- a/mobile/src/session/use-mobile-native-chat-cancel-ask.ts +++ b/mobile/src/session/use-mobile-native-chat-cancel-ask.ts @@ -10,7 +10,7 @@ export function useMobileNativeChatCancelAsk(args: { enabled: boolean handleRef: MutableRefObject deviceTokenRef: MutableRefObject - /** 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 { @@ -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' } } : {}) diff --git a/mobile/src/session/use-mobile-native-chat-controller.test.ts b/mobile/src/session/use-mobile-native-chat-controller.test.ts index cff91f7ab8b..4adc88dca23 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.test.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.test.ts @@ -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' diff --git a/mobile/src/session/use-mobile-native-chat-controller.ts b/mobile/src/session/use-mobile-native-chat-controller.ts index 3b6c1e23818..c44001a4573 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.ts @@ -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, diff --git a/mobile/src/session/use-mobile-native-chat-input-lease.ts b/mobile/src/session/use-mobile-native-chat-input-lease.ts index f1489faa9cd..242910e8b43 100644 --- a/mobile/src/session/use-mobile-native-chat-input-lease.ts +++ b/mobile/src/session/use-mobile-native-chat-input-lease.ts @@ -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' +} diff --git a/mobile/src/session/use-mobile-native-chat-message-send.ts b/mobile/src/session/use-mobile-native-chat-message-send.ts index 53be0f16bda..2d8615f5dd3 100644 --- a/mobile/src/session/use-mobile-native-chat-message-send.ts +++ b/mobile/src/session/use-mobile-native-chat-message-send.ts @@ -92,7 +92,8 @@ export function useMobileNativeChatMessageSend(args: { images: string[] | undefined, syncComposer: boolean, recordControlSend: boolean, - sharedDeadline?: number + sharedDeadline?: number, + requireWriteSettlement?: true ): Promise => { // 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) diff --git a/mobile/src/session/use-mobile-native-chat-prompt-cards.ts b/mobile/src/session/use-mobile-native-chat-prompt-cards.ts new file mode 100644 index 00000000000..5ddc4a0c4b5 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-prompt-cards.ts @@ -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[0] + structured: Pick< + ReturnType, + '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 + } +} diff --git a/mobile/src/session/use-mobile-native-chat-prompt-dismiss.ts b/mobile/src/session/use-mobile-native-chat-prompt-dismiss.ts new file mode 100644 index 00000000000..c48d1c3ca89 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-prompt-dismiss.ts @@ -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()) + 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) + } +} diff --git a/mobile/src/session/use-mobile-native-chat-prompt-presentation.ts b/mobile/src/session/use-mobile-native-chat-prompt-presentation.ts new file mode 100644 index 00000000000..c8eeaacb856 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-prompt-presentation.ts @@ -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 + answerQuestion: (text: string) => Promise +}) { + 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 => { + const accepted = await respondPermission(send) + if (accepted) { + dismissPrompt() + } + return accepted + }, + [respondPermission, dismissPrompt] + ) + const answer = useCallback( + async (text: string): Promise => { + 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 + } +} diff --git a/mobile/src/session/use-mobile-native-chat-prompts.ts b/mobile/src/session/use-mobile-native-chat-prompts.ts index 58a17e77f99..e5b643846e3 100644 --- a/mobile/src/session/use-mobile-native-chat-prompts.ts +++ b/mobile/src/session/use-mobile-native-chat-prompts.ts @@ -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 diff --git a/src/main/ipc/pty/ipc/write-input-acceptance.test.ts b/src/main/ipc/pty/ipc/write-input-acceptance.test.ts new file mode 100644 index 00000000000..a5577c3e03c --- /dev/null +++ b/src/main/ipc/pty/ipc/write-input-acceptance.test.ts @@ -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>() + } +})) +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 }) + }) +}) diff --git a/src/main/ipc/pty/ipc/write-input.ts b/src/main/ipc/pty/ipc/write-input.ts index 2f8ea72cd9b..14ecb14c0c1 100644 --- a/src/main/ipc/pty/ipc/write-input.ts +++ b/src/main/ipc/pty/ipc/write-input.ts @@ -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 => { 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 => { + 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 => { 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, firstChunk: string, - secondChunk: string + secondChunk: string, + verify: boolean ): Promise => { try { let chunk: IteratorResult = { done: false, value: firstChunk } let nextChunk: IteratorResult = { 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 => { + 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 => { 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 diff --git a/src/main/runtime/orca-runtime-controller-knows-pty-is-live.ts b/src/main/runtime/orca-runtime-controller-knows-pty-is-live.ts index 6eb3e7879f8..82368a663a5 100644 --- a/src/main/runtime/orca-runtime-controller-knows-pty-is-live.ts +++ b/src/main/runtime/orca-runtime-controller-knows-pty-is-live.ts @@ -106,6 +106,7 @@ export class OrcaRuntimeWithControllerKnowsPtyIsLive extends OrcaRuntimeWithReso afterWrite?: (ptyId: string) => void | Promise suffixFailureError?: string inputKind: TerminalInputKind + requireWriteSettlement?: true } ): Promise { 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 } } diff --git a/src/main/runtime/orca-runtime-resolve-authoritative-terminal-wait-permission.ts b/src/main/runtime/orca-runtime-resolve-authoritative-terminal-wait-permission.ts index 1f66feb4681..ac21f8de73e 100644 --- a/src/main/runtime/orca-runtime-resolve-authoritative-terminal-wait-permission.ts +++ b/src/main/runtime/orca-runtime-resolve-authoritative-terminal-wait-permission.ts @@ -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 { + ): Promise { return this.terminalWriter.writeAction(ptyId, action, payload, options) } @@ -172,7 +173,7 @@ export class OrcaRuntimeWithResolveAuthoritativeTerminalWaitPermission extends O ptyId: string, text: string, options: RuntimeTerminalWriteOptions - ): Promise { + ): Promise { return this.terminalWriter.writeChunks(ptyId, text, options) } diff --git a/src/main/runtime/orca-runtime-runtime-id.ts b/src/main/runtime/orca-runtime-runtime-id.ts index 18cdbf79c5e..c872401f04b 100644 --- a/src/main/runtime/orca-runtime-runtime-id.ts +++ b/src/main/runtime/orca-runtime-runtime-id.ts @@ -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() @@ -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. diff --git a/src/main/runtime/rpc/methods/terminal/terminal-send-method.ts b/src/main/runtime/rpc/methods/terminal/terminal-send-method.ts index ca7ac0aeb75..8309d8c15c1 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-send-method.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-send-method.ts @@ -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) } diff --git a/src/main/runtime/runtime-terminal-writer.test.ts b/src/main/runtime/runtime-terminal-writer.test.ts new file mode 100644 index 00000000000..4fd79f8d9f3 --- /dev/null +++ b/src/main/runtime/runtime-terminal-writer.test.ts @@ -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((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() + }) +}) diff --git a/src/main/runtime/runtime-terminal-writer.ts b/src/main/runtime/runtime-terminal-writer.ts index 8c308d22b28..ddc52342404 100644 --- a/src/main/runtime/runtime-terminal-writer.ts +++ b/src/main/runtime/runtime-terminal-writer.ts @@ -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 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 ) {} async writeAction( @@ -25,13 +37,45 @@ export class RuntimeTerminalWriter { action: { text?: string; enter?: boolean; interrupt?: boolean }, payload: string, options: RuntimeTerminalWriteOptions - ): Promise { + ): Promise { + let acknowledgedPrefix = false + const guardedOptions = options.requireWriteSettlement + ? { + ...options, + afterWrite: async (id: string): Promise => { + 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 { // 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 { + ): Promise { 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 { + 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) + } } } diff --git a/src/preload/api/pty-api.ts b/src/preload/api/pty-api.ts index 4cfb5572776..8a7604b4ac2 100644 --- a/src/preload/api/pty-api.ts +++ b/src/preload/api/pty-api.ts @@ -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 + /** `requireWriteSettlement` waits for the provider's acknowledgment on any provider. */ + writeAccepted: ( + id: string, + data: string, + inputKind: TerminalInputKind, + options?: { requireWriteSettlement?: true } + ) => Promise onWriteUnavailable?: (callback: (payload: { id: string }) => void) => () => void resize: (id: string, cols: number, rows: number) => void claimViewport: (id: string, cols: number, rows: number) => void diff --git a/src/preload/api/pty-bridge-session-control.ts b/src/preload/api/pty-bridge-session-control.ts index 1f8639c58be..e407163e5f6 100644 --- a/src/preload/api/pty-bridge-session-control.ts +++ b/src/preload/api/pty-bridge-session-control.ts @@ -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 => - ipcRenderer.invoke('pty:writeAccepted', { id, data, inputKind }), + writeAccepted: ( + id: string, + data: string, + inputKind: TerminalInputKind, + options?: { requireWriteSettlement?: true } + ): Promise => + 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) diff --git a/src/renderer/src/components/native-chat/NativeChatApprovalCard.test.tsx b/src/renderer/src/components/native-chat/NativeChatApprovalCard.test.tsx index 8e50bce5e9f..54b2285d618 100644 --- a/src/renderer/src/components/native-chat/NativeChatApprovalCard.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatApprovalCard.test.tsx @@ -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( + {}} + 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)}` diff --git a/src/renderer/src/components/native-chat/NativeChatApprovalCard.tsx b/src/renderer/src/components/native-chat/NativeChatApprovalCard.tsx index 22002520610..00f883c9943 100644 --- a/src/renderer/src/components/native-chat/NativeChatApprovalCard.tsx +++ b/src/renderer/src/components/native-chat/NativeChatApprovalCard.tsx @@ -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 (
@@ -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({ > + ) : onCollapse ? ( + ) : null}
{hasContext ? ( @@ -170,7 +181,7 @@ export function NativeChatApprovalCard({ + + + {label} + {expanded ? : null} + + + ) +} + +/** A collapsed prompt above the usable composer; expanding gives it the input again. */ +export function NativeChatPromptStrip({ + card, + onExpand +}: { + card: NonNullable + onExpand: () => void +}): React.JSX.Element { + const title = + card.kind === 'approval' ? card.approval.title : (card.prompt.questions[0]?.question ?? '') + return ( +
+
+
+

{title}

+ +
+
+
+ ) +} diff --git a/src/renderer/src/components/native-chat/NativeChatQuestionCard.tsx b/src/renderer/src/components/native-chat/NativeChatQuestionCard.tsx index c7d50ebc816..dbb90df8b9f 100644 --- a/src/renderer/src/components/native-chat/NativeChatQuestionCard.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQuestionCard.tsx @@ -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 @@ -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(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. -
+
{ + // 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() + } + }} + >
{total > 1 ? (
@@ -190,16 +224,24 @@ export function NativeChatQuestionCard({ ) : null}
-
+

{q.question}

+ {onCollapse ? ( + + ) : null}