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}