From e310a63624c86c0e91e9d77df71f038b91d79ea0 Mon Sep 17 00:00:00 2001 From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> Date: Tue, 27 Oct 2026 15:34:56 +0000 Subject: [PATCH] fix(mobile): use desktop's rules for queued messages, turn status, Stop and the chat composer (#34000) * refactor(mobile): share the queued-card projection with desktop Image-only queued drafts showed a blank card on the phone. * fix(mobile): honor the host's Queue follow-ups setting With it off, a mid-turn send from the phone still queued while desktop sent it into the running turn. * fix(mobile): keep a queued-card edit when the card leaves the queue The phone closed the editor and dropped the typing; it now goes to the chat box as on desktop. * fix(mobile): hold /clear and /compact for background work and sends The phone fired the command where desktop holds it, showed start failures twice, and left the agent unnamed in failures. * fix(mobile): hand a queued edit back to the chat it came from * fix(mobile): scope a command's send hold to its own chat * fix(mobile): read a published null queue as empty * refactor(mobile): share the returned queued-card caption rule * test(mobile): drop queued-card tests the shared rules already own * refactor(native-chat): share the needs-attention rule and drop leftover wrappers * fix(mobile): type the structured sends mock, drop the moved queued-cards path and ship queued edit copy at boot * fix(native-chat): drop the queued card's unused image-label import after the rebase * fix(mobile): order the phone's queue reorder preview by position, as the shared cards sort * test(mobile): keep the queued-messages test within its line budget * fix(mobile): settle terminal chat working state and turn timing with desktop's rules Move desktop's live-status merge, Stop suppression, hook-state resolver and terminal turn timing to src/shared/native-chat. Mobile reads the transcript lifecycle the host already sends, so a provider turn end settles Working the same way desktop does. Terminal chats now get the turn bar, timer and folding, and the streaming preview uses the shared deriveNativeChatStreamingText. * fix(mobile): send desktop's terminal Stop keys, a second Escape only for OpenCode Desktop sends one Escape on Stop. It adds a paced second Escape only for OpenCode, which confirms an interrupt that way. Claude Code and Codex read Esc-Esc at an idle prompt as rewind. The rule moves to native-chat-agent-support, and the phone now paces the second Escape at desktop's answer step instead of 80ms. * fix(mobile): word Orca-stop and usage-limit notices like desktop Move desktop's Orca-stop sentences to src/shared with an English table that desktop translates. The phone names the Orca host it pairs with, and when that host is unnamed it says "the remote server". Usage-limit rows rebuild desktop's sentence from the failure fact. The usageLimitLineText fallback, which only the phone used, is deleted. * test(mobile): pass the overlay's host name in the prompt controller harness * test(mobile): re-record Stop goldens at desktop's step, split status-notice tests, pin the turn clock and ship notice copy at boot * fix(mobile): offer structured Stop while a send is out and read queue-sends-next as working Move desktop's structured Stop rule, its Stop-joining helpers and the queue-sends-next rule to src/shared. Both sides now use them. The phone counts its in-flight sends, so a conversation-stop host offers Stop before the turn opens. Between queued turns the phone reads as working, and commands that can't wait in line hold like they do on desktop. Untargeted Stops are no longer joined on legacy hosts. * fix(mobile): offer terminal Stop again when its Escape went nowhere Stop hides Working on the phone like it does on desktop. The phone's Escape can fail outright, though, while desktop writes to a local PTY. When no Escape could have landed, the phone now drops that suppression. The streaming preview also treats a pending echo as the transcript's tail, as desktop does, so an earlier reply can't hide a new preview. * fix(mobile): hold structured Stop while a send it covers is in flight On desktop, Stop's held and Stopping state depends on whether the chat can stop, and that includes a send still in flight. The phone now does the same. Once Stop is pressed before the turn opens, the button holds and repeated presses send no more untargeted cancels. * fix(mobile): end the terminal Stop hold when a new message is sent Desktop clears Stop's working suppression on an optimistic send, so a new turn that keeps the same hook epoch (Claude with working subagents) still offers Stop. The phone now does the same when a new pending echo appears. * refactor(mobile): trim chat turn wiring after the desktop rule move Drop the controller's unread nativeChatAgentWorking field. Re-render only when a chat starts or stops having a send out. Keep the structured turn indicator stable across stream frames. Type the Stop test table instead of casting. * refactor(mobile): track structured sends in flight once for Stop and command holds * test(mobile): re-record Stop goldens now that Stop settles with its Escape delivery * fix(mobile): escape terminal image paths like desktop drops * fix(mobile): keep launch-draft prefill over older user turns * fix(mobile): rank @ file matches with desktop's fuzzy Quick Open search * fix(mobile): use desktop's composer triggers for mid-prompt / and $ picks Picked @ files are now quoted when the path has spaces. * fix(mobile): send picked skill paths on structured chats * fix(mobile): list the host's disk-scanned skills in the / and $ menus The phone names the workspace and the host resolves its folder, refusing SSH workspaces as desktop does; hosts without skills.discover-workspace.v1 list none. * fix(mobile): forget a picked skill once its token is deleted * fix(mobile): drop a picked skill as soon as its token is deleted, and retry failed skill scans * fix(mobile): treat a picked leading skill as chat and stop failed skill scans from refiring * test(mobile): drop the menu ranking case the shared picker tests own * refactor(mobile): keep skill picks on the draft record and reuse shared send rules A rejected send now keeps its picks and leading-skill origin on both send paths. * fix(mobile): replace a re-picked skill's file and restore picks with a rejected send * fix(mobile): read the chat agent for the skill catalog from the active resolution * style(native-chat): format the composer autocomplete files * fix(native-chat): import the picker's apply and send-classify rules from the shared composer module * refactor(mobile): import the composer's draft-store hooks once * style(mobile): format the notice row's style import --- .../native-chat-live-session-benchmark.ts | 2 +- config/tsconfig.node.json | 1 - ...chestration.workerterminaluserinput-1.json | 182 ++++-- ...sion.native-chat-stop-terminal.send-1.json | 230 ++++---- ...sion.native-chat-stop-terminal.send-2.json | 530 +++++++++--------- .../goldens/native-chat-stop-accepted.json | 32 +- .../native-chat-stop-both-rejected.json | 100 ++-- .../native-chat-stop-delivery-unknown.json | 94 ++-- mobile/rpc-foundation/pilot-scenarios.json | 6 +- .../session/MobileNativeChatOverlay.test.ts | 132 +---- .../src/session/MobileNativeChatOverlay.tsx | 174 +++--- ...NativeChatView.prompt-presentation.test.ts | 3 +- ...obileNativeChatView.streaming-rows.test.ts | 5 +- mobile/src/session/MobileNativeChatView.tsx | 35 +- .../MobileNativeChatView.turn-status.test.ts | 8 +- .../session/MobileQueuedMessageCardRow.tsx | 7 +- .../session/MobileSessionActiveContent.tsx | 6 + .../__mocks__/mobile-prompt-controller.ts | 6 +- .../__mocks__/mobile-prompt-render-mocks.ts | 3 +- .../mobile-native-chat-controller-contract.ts | 11 +- ...chat-merged-snapshot-parity-hooks.test.tsx | 9 +- .../mobile-native-chat-notice-context.ts | 8 + .../session/mobile-session-read-operations.ts | 12 + ...ile-session-terminal-frame-layout.test.tsx | 1 + .../mobile-structured-agent-session-cancel.ts | 27 +- ...obile-structured-agent-session-contract.ts | 6 +- ...e-structured-agent-session-host-support.ts | 12 +- .../mobile-structured-agent-session-send.ts | 9 +- ...mobile-structured-composer-command.test.ts | 51 +- .../mobile-structured-composer-command.ts | 79 ++- ...le-structured-queued-message-cards.test.ts | 245 +------- .../mobile-structured-queued-message-cards.ts | 173 ++---- ...ile-structured-queued-message-feed.test.ts | 77 --- .../mobile-structured-queued-message-feed.ts | 47 -- .../composer/MobileNativeChatComposer.test.ts | 29 + .../composer/MobileNativeChatComposer.tsx | 15 +- .../composer/MobileNativeChatComposerMenu.tsx | 12 +- .../mobile-native-chat-autocomplete.test.ts | 115 ---- .../mobile-native-chat-autocomplete.ts | 78 --- .../mobile-native-chat-composer-menu.test.ts | 66 ++- .../mobile-native-chat-composer-menu.ts | 90 +-- .../mobile-native-chat-draft-store.test.ts | 44 ++ .../mobile-native-chat-draft-store.ts | 58 +- .../mobile-native-chat-image-send.test.ts | 16 + .../composer/mobile-native-chat-image-send.ts | 5 +- .../use-mobile-native-chat-composer-menu.ts | 105 ++-- ...-mobile-native-chat-composer-tray.test.tsx | 4 +- ...le-native-chat-drafts-launch-draft.test.ts | 30 +- ...use-mobile-native-chat-file-search.test.ts | 2 +- .../use-mobile-native-chat-file-search.ts | 15 +- ...se-mobile-native-chat-launch-draft-seed.ts | 27 +- .../use-mobile-native-chat-skills.test.ts | 90 +++ .../composer/use-mobile-native-chat-skills.ts | 79 +++ ...se-mobile-native-chat-workspace-catalog.ts | 24 + .../MobileNativeChatQueuedMessages.test.tsx | 14 +- .../input/MobileNativeChatQueuedMessages.tsx | 6 +- .../mobile-native-chat-send-classification.ts | 6 +- .../mobile-native-chat-stream-frame.ts | 7 +- .../mobile-native-chat-streaming-gate.test.ts | 283 ---------- .../mobile-native-chat-streaming-gate.ts | 76 --- ...leNativeChatMessage.status-notices.test.ts | 264 +++++++++ .../MobileNativeChatMessage.test.ts | 148 +---- .../transcript/MobileNativeChatNoticeRow.tsx | 40 +- .../use-mobile-native-chat-message-send.ts | 7 +- ...use-mobile-native-chat-streaming-bubble.ts | 25 - .../src/session/session-read-reply-schema.ts | 34 ++ .../use-mobile-bridge-chat-prompt-writes.ts | 3 +- ...se-mobile-native-chat-active-resolution.ts | 13 +- .../use-mobile-native-chat-controller.test.ts | 121 ++-- .../use-mobile-native-chat-controller.ts | 58 +- ...use-mobile-native-chat-queue-follow-ups.ts | 33 ++ .../session/use-mobile-native-chat-session.ts | 18 +- .../use-mobile-native-chat-stop.test.ts | 62 +- .../session/use-mobile-native-chat-stop.ts | 46 +- .../use-mobile-native-chat-terminal-turn.ts | 142 +++++ ...-mobile-queued-message-composer-actions.ts | 18 +- ...e-structured-agent-session-queued.test.tsx | 32 +- ...ile-structured-agent-session-send.test.tsx | 28 +- .../use-mobile-structured-agent-session.ts | 72 ++- .../use-mobile-structured-agent-state.ts | 65 +-- ...structured-native-chat-send-bridge.test.ts | 20 +- ...bile-structured-native-chat-send-bridge.ts | 19 +- ...use-mobile-structured-queued-edit.test.tsx | 43 +- .../use-mobile-structured-queued-edit.ts | 151 +++-- ...bile-structured-queued-message-controls.ts | 27 +- ...use-mobile-structured-send-with-outcome.ts | 86 ++- .../use-mobile-structured-sends.test.tsx | 2 +- .../session/use-mobile-structured-sends.ts | 4 +- .../use-mobile-structured-stop-offer.test.ts | 69 +++ .../use-mobile-structured-stop-offer.ts | 66 +++ .../use-mobile-structured-turn-indicator.ts | 44 ++ .../src/tasks/mobile-tasks-host-subtitle.tsx | 21 +- .../session-screen-read-mount-adapters.ts | 2 + .../src/transport/settings-read-operations.ts | 36 +- mobile/src/transport/use-host-profile.ts | 24 + ...ent-session-codex-version-recovery.test.ts | 2 +- .../search/file-search-mobile-paths.test.ts | 49 ++ src/main/runtime/files/search/file-search.ts | 10 +- .../files/search/mobile-path-search.test.ts | 77 --- .../files/search/mobile-path-search.ts | 31 - src/main/runtime/rpc/methods/skills.test.ts | 34 +- src/main/runtime/rpc/methods/skills.ts | 21 +- ...ttings-queue-follow-ups-projection.test.ts | 28 + .../runtime/runtime-client-settings-shape.ts | 1 + src/main/runtime/runtime-client-settings.ts | 2 + .../runtime-rpc-mobile-method-routing.ts | 2 + src/main/runtime/runtime-store-contract.ts | 1 + .../NativeChatAutocompleteMenus.test.tsx | 2 +- .../NativeChatAutocompleteMenus.tsx | 6 +- .../native-chat/NativeChatComposerField.tsx | 6 +- .../NativeChatImageAttachmentPreview.tsx | 6 +- .../native-chat/NativeChatMessageList.tsx | 4 +- .../native-chat/NativeChatNoticeRow.tsx | 15 +- .../NativeChatQueuedMessageCard.tsx | 62 +- .../NativeChatQueuedMessageEditor.test.tsx | 2 +- .../NativeChatQueuedMessageImageCount.tsx | 13 +- ...tiveChatQueuedMessageList.arrival.test.tsx | 2 +- ...ChatQueuedMessageList.inline-edit.test.tsx | 2 +- .../NativeChatQueuedMessageList.test.tsx | 2 +- .../NativeChatQueuedMessageList.tsx | 9 +- .../NativeChatQueuedMessageRows.test.tsx | 2 +- .../NativeChatQueuedMessageRows.tsx | 2 +- .../native-chat/NativeChatResolvedView.tsx | 4 +- ...tiveChatStructuredSession.test-harness.tsx | 2 +- .../NativeChatTranscriptChrome.tsx | 2 +- .../agent-session-write-notice-text.ts | 1 + .../native-chat-composer-draft-persistence.ts | 3 +- .../native-chat/native-chat-image-label.ts | 24 +- .../native-chat/native-chat-image-paste.ts | 7 +- .../native-chat-orca-stop-words-text.ts | 24 + .../native-chat-orca-stop-words.ts | 58 -- ...e-chat-preassembled-session-parity.test.ts | 2 +- .../native-chat-runtime-image-send.ts | 15 +- .../native-chat-transcript-tail-row.ts | 2 +- .../native-chat-view-state.test.ts | 24 + .../queued-message-edit-notices.ts | 67 +-- ...ctured-agent-session-queued-edit-images.ts | 6 +- ...ured-agent-session-queued-edit-standing.ts | 40 -- ...agent-session-sending-queued-cards.test.ts | 79 +++ ...ured-agent-session-sending-queued-cards.ts | 56 ++ ...ructured-conversation-command-send.test.ts | 34 -- .../structured-conversation-command-send.ts | 135 +---- .../use-native-chat-composer-catalog.ts | 20 +- .../use-native-chat-composer-keydown.test.tsx | 7 +- .../use-native-chat-composer-keydown.ts | 6 +- .../use-native-chat-hook-status.ts | 43 +- .../use-native-chat-interactive-send.ts | 5 +- .../use-native-chat-launch-draft-adoption.ts | 2 +- .../use-native-chat-live-session.ts | 7 +- ...use-native-chat-picker-command-dispatch.ts | 2 +- .../use-native-chat-picker-state.ts | 32 +- .../use-native-chat-resume-command.ts | 2 +- .../use-native-chat-skills.react.test.tsx | 2 +- .../use-native-chat-skills.test.ts | 110 ---- .../native-chat/use-native-chat-skills.ts | 29 +- .../use-native-chat-terminal-turn-timing.ts | 27 +- .../use-structured-agent-session-mutate.ts | 34 +- ...uctured-agent-session-queued-edit.test.tsx | 2 +- ...se-structured-agent-session-queued-edit.ts | 17 +- ...tructured-agent-session-queued-messages.ts | 8 +- ...ured-agent-session-queued-reorder.test.tsx | 2 +- ...structured-agent-session-queued-reorder.ts | 2 +- .../use-structured-agent-session-sends.ts | 2 +- ...tructured-agent-session-transport-state.ts | 10 +- .../use-structured-agent-session.ts | 15 +- .../terminal-pane/terminal-drop-shell.ts | 9 +- .../terminal-drop-worktree-path.ts | 2 +- .../terminal-native-file-drop.ts | 2 +- .../src/i18n/en-runtime-required.json | 19 +- src/renderer/src/i18n/locales/en.json | 6 +- src/renderer/src/i18n/locales/es.json | 6 +- src/renderer/src/i18n/locales/fr.json | 6 +- src/renderer/src/i18n/locales/ja.json | 6 +- src/renderer/src/i18n/locales/ko.json | 6 +- src/renderer/src/i18n/locales/pt.json | 6 +- src/renderer/src/i18n/locales/zh.json | 6 +- ...gent-session-launch-create-message.test.ts | 2 +- ...d-agent-session-launch-prompt-stop.test.ts | 2 +- src/shared/agent-image-paste-path.ts | 12 + src/shared/agent-session-usage-limit-words.ts | 33 +- src/shared/agent-session-write-notice-copy.ts | 1 + src/shared/image-path-paste.ts | 9 + src/shared/native-chat-agent-support.ts | 5 + src/shared/native-chat-clock-skew.ts | 2 + ...native-chat-composer-autocomplete.test.ts} | 22 +- .../native-chat-composer-autocomplete.ts} | 58 +- src/shared/native-chat-image-label.ts | 33 ++ ...ative-chat-launch-draft-resolution.test.ts | 2 +- .../native-chat-launch-draft-resolution.ts | 11 +- .../native-chat-skill-visibility.test.ts | 111 ++++ src/shared/native-chat-skill-visibility.ts | 55 ++ .../native-chat-live-status.test.ts | 25 +- .../native-chat/native-chat-live-status.ts | 54 +- .../native-chat-orca-stop-words.ts | 32 ++ .../native-chat-terminal-turn.test.ts | 5 +- .../native-chat/native-chat-terminal-turn.ts | 38 +- .../native-chat-working-suppression.ts | 0 src/shared/queued-message-edit.ts | 75 ++- src/shared/skill-install-capability.ts | 6 +- .../structured-agent-session-live-turn.ts | 8 + ...uctured-agent-session-queued-cards.test.ts | 63 +-- .../structured-agent-session-queued-cards.ts | 136 +++-- .../structured-agent-session-stop-control.ts | 17 +- .../structured-agent-session-stop-join.ts | 33 ++ ...ructured-conversation-command-hold.test.ts | 37 ++ .../structured-conversation-command-hold.ts | 117 ++++ ...ve-chat-transcript-file-order.unit.test.ts | 2 +- 207 files changed, 4300 insertions(+), 3606 deletions(-) create mode 100644 mobile/src/session/mobile-native-chat-notice-context.ts delete mode 100644 mobile/src/session/mobile-structured-queued-message-feed.test.ts delete mode 100644 mobile/src/session/mobile-structured-queued-message-feed.ts delete mode 100644 mobile/src/session/native-chat/composer/mobile-native-chat-autocomplete.test.ts delete mode 100644 mobile/src/session/native-chat/composer/mobile-native-chat-autocomplete.ts create mode 100644 mobile/src/session/native-chat/composer/use-mobile-native-chat-skills.test.ts create mode 100644 mobile/src/session/native-chat/composer/use-mobile-native-chat-skills.ts create mode 100644 mobile/src/session/native-chat/composer/use-mobile-native-chat-workspace-catalog.ts delete mode 100644 mobile/src/session/native-chat/mobile-native-chat-streaming-gate.test.ts delete mode 100644 mobile/src/session/native-chat/mobile-native-chat-streaming-gate.ts create mode 100644 mobile/src/session/native-chat/transcript/MobileNativeChatMessage.status-notices.test.ts delete mode 100644 mobile/src/session/native-chat/use-mobile-native-chat-streaming-bubble.ts create mode 100644 mobile/src/session/use-mobile-native-chat-queue-follow-ups.ts create mode 100644 mobile/src/session/use-mobile-native-chat-terminal-turn.ts create mode 100644 mobile/src/session/use-mobile-structured-stop-offer.test.ts create mode 100644 mobile/src/session/use-mobile-structured-stop-offer.ts create mode 100644 mobile/src/session/use-mobile-structured-turn-indicator.ts create mode 100644 mobile/src/transport/use-host-profile.ts create mode 100644 src/main/runtime/files/search/file-search-mobile-paths.test.ts create mode 100644 src/main/runtime/runtime-client-settings-queue-follow-ups-projection.test.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-orca-stop-words-text.ts delete mode 100644 src/renderer/src/components/native-chat/native-chat-orca-stop-words.ts delete mode 100644 src/renderer/src/components/native-chat/structured-agent-session-queued-edit-standing.ts create mode 100644 src/renderer/src/components/native-chat/structured-agent-session-sending-queued-cards.test.ts create mode 100644 src/renderer/src/components/native-chat/structured-agent-session-sending-queued-cards.ts create mode 100644 src/shared/agent-image-paste-path.ts create mode 100644 src/shared/native-chat-clock-skew.ts rename src/{renderer/src/components/native-chat/native-chat-composer-state.test.ts => shared/native-chat-composer-autocomplete.test.ts} (97%) rename src/{renderer/src/components/native-chat/native-chat-composer-state.ts => shared/native-chat-composer-autocomplete.ts} (79%) create mode 100644 src/shared/native-chat-image-label.ts rename src/{renderer/src/components/native-chat => shared}/native-chat-launch-draft-resolution.test.ts (97%) rename src/{renderer/src/components/native-chat => shared}/native-chat-launch-draft-resolution.ts (79%) create mode 100644 src/shared/native-chat-skill-visibility.test.ts create mode 100644 src/shared/native-chat-skill-visibility.ts rename src/{renderer/src/components => shared}/native-chat/native-chat-live-status.test.ts (91%) rename src/{renderer/src/components => shared}/native-chat/native-chat-live-status.ts (74%) create mode 100644 src/shared/native-chat/native-chat-orca-stop-words.ts rename src/{renderer/src/components => shared}/native-chat/native-chat-terminal-turn.test.ts (98%) rename src/{renderer/src/components => shared}/native-chat/native-chat-terminal-turn.ts (76%) rename src/{renderer/src/components => shared}/native-chat/native-chat-working-suppression.ts (100%) rename src/{renderer/src/components/native-chat => shared}/structured-agent-session-queued-cards.test.ts (75%) rename src/{renderer/src/components/native-chat => shared}/structured-agent-session-queued-cards.ts (57%) rename src/{renderer/src/components/native-chat => shared}/structured-agent-session-stop-control.ts (67%) create mode 100644 src/shared/structured-agent-session-stop-join.ts create mode 100644 src/shared/structured-conversation-command-hold.test.ts create mode 100644 src/shared/structured-conversation-command-hold.ts diff --git a/config/scripts/native-chat-live-session-benchmark.ts b/config/scripts/native-chat-live-session-benchmark.ts index 74180423fb3..5323c803e23 100644 --- a/config/scripts/native-chat-live-session-benchmark.ts +++ b/config/scripts/native-chat-live-session-benchmark.ts @@ -7,7 +7,7 @@ import type { NativeChatMessage, NativeChatSession } from '../../src/shared/nati import { getVerifiedNativeChatCommands } from '../../src/shared/native-chat-agent-profiles' import { surfaceSkillInvocationUserTurns } from '../../src/shared/native-chat/native-chat-command-envelope' import { prepareNativeChatLiveMessages } from '../../src/renderer/src/components/native-chat/native-chat-live-message-preparation' -import { mergeNativeChatLiveSession } from '../../src/renderer/src/components/native-chat/native-chat-live-status' +import { mergeNativeChatLiveSession } from '../../src/shared/native-chat/native-chat-live-status' import { assembleNativeChatSession } from '../../src/renderer/src/components/native-chat/native-chat-session-assembler' type Operation = (index: number) => number diff --git a/config/tsconfig.node.json b/config/tsconfig.node.json index ae2325bc2cf..bc9bf87d0d9 100644 --- a/config/tsconfig.node.json +++ b/config/tsconfig.node.json @@ -18,7 +18,6 @@ "../src/renderer/src/components/native-chat/native-chat-resolution-receipt.ts", "../src/renderer/src/components/native-chat/structured-agent-question-projection.ts", "../src/renderer/src/components/native-chat/native-chat-subagent-delegation.ts", - "../src/renderer/src/components/native-chat/structured-agent-session-queued-cards.ts", "../src/renderer/src/components/native-chat/structured-agent-session-pending-sends.ts", "../src/renderer/src/components/native-chat/native-chat-composer-primary-action.ts", "../src/renderer/src/components/terminal-pane/legacy-relay-bridge-panes.ts", diff --git a/mobile/rpc-foundation/goldens/matrix-session.native-chat-stop-orchestration.workerterminaluserinput-1.json b/mobile/rpc-foundation/goldens/matrix-session.native-chat-stop-orchestration.workerterminaluserinput-1.json index ecab8becfb2..d9a97945bc4 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.native-chat-stop-orchestration.workerterminaluserinput-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.native-chat-stop-orchestration.workerterminaluserinput-1.json @@ -4,6 +4,11 @@ "family": "session.native-chat-stop", "namedDeltas": [], "values": { + "0ab481baef7d": { + "name": "terminal.send#2", + "ordinal": 9, + "json": "{\"id\":\"frame-4\",\"deviceToken\":\"recording-device\",\"method\":\"terminal.send\",\"params\":{\"terminal\":\"terminal-1\",\"text\":\"\\u001b\",\"client\":{\"id\":\"device-token-1\",\"type\":\"mobile\"}}}" + }, "23018091278a": { "name": "orchestration.workerTerminalUserInput#1", "ordinal": 4, @@ -37,12 +42,18 @@ } } }, + "3e2d28f3728b": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 1000, + "value": true + }, "5cd1d4a9aed5": { "name": "terminal.send#1", "ordinal": 3, "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"terminal.send\",\"params\":{\"terminal\":\"terminal-1\",\"text\":\"\\u001b\",\"client\":{\"id\":\"device-token-1\",\"type\":\"mobile\"}}}" }, - "650fdd02bab4": { + "5cd6e007c8ca": { "name": "terminal.send#2", "ordinal": 6, "args": [ @@ -65,14 +76,14 @@ "name": "options", "value": { "budgetSpansConnect": true, - "timeoutMs": 14920 + "timeoutMs": 14000 } } ], "settlement": { "status": "fulfilled", - "startedAt": 80, - "settledAt": 120, + "startedAt": 1000, + "settledAt": 1000, "value": { "id": "frame-3", "ok": true, @@ -123,6 +134,48 @@ } } }, + "672e5a827dbd": { + "name": "terminal.send#2", + "ordinal": 8, + "args": [ + { + "name": "method", + "value": "terminal.send" + }, + { + "name": "params", + "value": { + "client": { + "id": "device-token-1", + "type": "mobile" + }, + "terminal": "terminal-1", + "text": "\u001b" + } + }, + { + "name": "options", + "value": { + "budgetSpansConnect": true, + "timeoutMs": 14000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 1000, + "settledAt": 1000, + "value": { + "id": "frame-4", + "ok": true, + "result": { + "send": { + "accepted": true + } + } + } + } + }, "6d6078dd8a8f": { "name": "orchestration.workerTerminalUserInput#1", "ordinal": 4, @@ -164,6 +217,34 @@ "ordinal": 1, "value": {} }, + "8699d16b8581": { + "name": "orchestration.workerTerminalUserInput#2", + "ordinal": 6, + "args": [ + { + "name": "method", + "value": "orchestration.workerTerminalUserInput" + }, + { + "name": "params", + "value": { + "terminal": "terminal-1" + } + }, + { + "name": "options", + "value": { + "budgetSpansConnect": true, + "failWhenDisconnected": true, + "timeoutMs": 5000 + } + } + ], + "settlement": { + "status": "pending", + "startedAt": 250 + } + }, "8c21e926b759": { "name": "orchestration.workerTerminalUserInput#1", "ordinal": 4, @@ -200,6 +281,10 @@ } } }, + "9270aeb7d9c6": { + "status": "pending", + "startedAt": 0 + }, "ab0f3d65723e": { "name": "terminal.send#1", "ordinal": 2, @@ -310,6 +395,11 @@ } } }, + "b81e2536b6b7": { + "name": "orchestration.workerTerminalUserInput#2", + "ordinal": 7, + "json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"orchestration.workerTerminalUserInput\",\"params\":{\"terminal\":\"terminal-1\"}}" + }, "c3dd831302ac": { "name": "orchestration.workerTerminalUserInput#1", "ordinal": 4, @@ -461,14 +551,6 @@ "debf84af8d66": { "errors": [] }, - "eb79a9b3682a": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "$rpc": "undefined" - } - }, "ef422fa31f61": { "name": "terminal.send#2", "ordinal": 7, @@ -525,7 +607,7 @@ "sender": ["ab0f3d65723e", "fcdecc63b46e"], "payloads": ["5cd1d4a9aed5", "ef7005279d33"], "settlements": { - "stop": "eb79a9b3682a" + "stop": "9270aeb7d9c6" }, "state": "debf84af8d66", "effects": ["75263c07c807"] @@ -534,10 +616,10 @@ { "id": "native-chat-stop-accepted.normal:settled", "observation": { - "sender": ["ab0f3d65723e", "fcdecc63b46e", "650fdd02bab4"], + "sender": ["ab0f3d65723e", "fcdecc63b46e", "5cd6e007c8ca"], "payloads": ["5cd1d4a9aed5", "ef7005279d33", "ef422fa31f61"], "settlements": { - "stop": "eb79a9b3682a" + "stop": "3e2d28f3728b" }, "state": "debf84af8d66", "effects": ["75263c07c807"] @@ -549,7 +631,7 @@ "sender": ["ab0f3d65723e", "23018091278a"], "payloads": ["5cd1d4a9aed5", "ef7005279d33"], "settlements": { - "stop": "eb79a9b3682a" + "stop": "9270aeb7d9c6" }, "state": "debf84af8d66", "effects": ["75263c07c807"] @@ -558,10 +640,10 @@ { "id": "native-chat-stop-accepted.result-absent:settled", "observation": { - "sender": ["ab0f3d65723e", "23018091278a", "650fdd02bab4"], + "sender": ["ab0f3d65723e", "23018091278a", "5cd6e007c8ca"], "payloads": ["5cd1d4a9aed5", "ef7005279d33", "ef422fa31f61"], "settlements": { - "stop": "eb79a9b3682a" + "stop": "3e2d28f3728b" }, "state": "debf84af8d66", "effects": ["75263c07c807"] @@ -573,7 +655,7 @@ "sender": ["ab0f3d65723e", "6d6078dd8a8f"], "payloads": ["5cd1d4a9aed5", "ef7005279d33"], "settlements": { - "stop": "eb79a9b3682a" + "stop": "9270aeb7d9c6" }, "state": "debf84af8d66", "effects": ["75263c07c807"] @@ -582,10 +664,10 @@ { "id": "native-chat-stop-accepted.result-null:settled", "observation": { - "sender": ["ab0f3d65723e", "6d6078dd8a8f", "650fdd02bab4"], + "sender": ["ab0f3d65723e", "6d6078dd8a8f", "5cd6e007c8ca"], "payloads": ["5cd1d4a9aed5", "ef7005279d33", "ef422fa31f61"], "settlements": { - "stop": "eb79a9b3682a" + "stop": "3e2d28f3728b" }, "state": "debf84af8d66", "effects": ["75263c07c807"] @@ -597,7 +679,7 @@ "sender": ["ab0f3d65723e", "8c21e926b759"], "payloads": ["5cd1d4a9aed5", "ef7005279d33"], "settlements": { - "stop": "eb79a9b3682a" + "stop": "9270aeb7d9c6" }, "state": "debf84af8d66", "effects": ["75263c07c807"] @@ -606,10 +688,10 @@ { "id": "native-chat-stop-accepted.inner-ok-missing:settled", "observation": { - "sender": ["ab0f3d65723e", "8c21e926b759", "650fdd02bab4"], + "sender": ["ab0f3d65723e", "8c21e926b759", "5cd6e007c8ca"], "payloads": ["5cd1d4a9aed5", "ef7005279d33", "ef422fa31f61"], "settlements": { - "stop": "eb79a9b3682a" + "stop": "3e2d28f3728b" }, "state": "debf84af8d66", "effects": ["75263c07c807"] @@ -621,7 +703,7 @@ "sender": ["ab0f3d65723e", "d8fa0931ea72"], "payloads": ["5cd1d4a9aed5", "ef7005279d33"], "settlements": { - "stop": "eb79a9b3682a" + "stop": "9270aeb7d9c6" }, "state": "debf84af8d66", "effects": ["75263c07c807"] @@ -630,10 +712,10 @@ { "id": "native-chat-stop-accepted.inner-false-string-error:settled", "observation": { - "sender": ["ab0f3d65723e", "d8fa0931ea72", "650fdd02bab4"], + "sender": ["ab0f3d65723e", "d8fa0931ea72", "5cd6e007c8ca"], "payloads": ["5cd1d4a9aed5", "ef7005279d33", "ef422fa31f61"], "settlements": { - "stop": "eb79a9b3682a" + "stop": "3e2d28f3728b" }, "state": "debf84af8d66", "effects": ["75263c07c807"] @@ -645,7 +727,7 @@ "sender": ["ab0f3d65723e", "662c347cb071"], "payloads": ["5cd1d4a9aed5", "ef7005279d33"], "settlements": { - "stop": "eb79a9b3682a" + "stop": "9270aeb7d9c6" }, "state": "debf84af8d66", "effects": ["75263c07c807"] @@ -654,10 +736,10 @@ { "id": "native-chat-stop-accepted.inner-false-object-error:settled", "observation": { - "sender": ["ab0f3d65723e", "662c347cb071", "650fdd02bab4"], + "sender": ["ab0f3d65723e", "662c347cb071", "5cd6e007c8ca"], "payloads": ["5cd1d4a9aed5", "ef7005279d33", "ef422fa31f61"], "settlements": { - "stop": "eb79a9b3682a" + "stop": "3e2d28f3728b" }, "state": "debf84af8d66", "effects": ["75263c07c807"] @@ -669,7 +751,7 @@ "sender": ["ab0f3d65723e", "da27d1e6c936"], "payloads": ["5cd1d4a9aed5", "ef7005279d33"], "settlements": { - "stop": "eb79a9b3682a" + "stop": "9270aeb7d9c6" }, "state": "debf84af8d66", "effects": ["75263c07c807"] @@ -678,10 +760,10 @@ { "id": "native-chat-stop-accepted.outer-refused:settled", "observation": { - "sender": ["ab0f3d65723e", "da27d1e6c936", "650fdd02bab4"], - "payloads": ["5cd1d4a9aed5", "ef7005279d33", "ef422fa31f61"], + "sender": ["ab0f3d65723e", "da27d1e6c936", "8699d16b8581", "672e5a827dbd"], + "payloads": ["5cd1d4a9aed5", "ef7005279d33", "b81e2536b6b7", "0ab481baef7d"], "settlements": { - "stop": "eb79a9b3682a" + "stop": "3e2d28f3728b" }, "state": "debf84af8d66", "effects": ["75263c07c807"] @@ -693,7 +775,7 @@ "sender": ["ab0f3d65723e", "c3dd831302ac"], "payloads": ["5cd1d4a9aed5", "ef7005279d33"], "settlements": { - "stop": "eb79a9b3682a" + "stop": "9270aeb7d9c6" }, "state": "debf84af8d66", "effects": ["75263c07c807"] @@ -702,10 +784,10 @@ { "id": "native-chat-stop-accepted.outer-refused-no-message:settled", "observation": { - "sender": ["ab0f3d65723e", "c3dd831302ac", "650fdd02bab4"], - "payloads": ["5cd1d4a9aed5", "ef7005279d33", "ef422fa31f61"], + "sender": ["ab0f3d65723e", "c3dd831302ac", "8699d16b8581", "672e5a827dbd"], + "payloads": ["5cd1d4a9aed5", "ef7005279d33", "b81e2536b6b7", "0ab481baef7d"], "settlements": { - "stop": "eb79a9b3682a" + "stop": "3e2d28f3728b" }, "state": "debf84af8d66", "effects": ["75263c07c807"] @@ -717,7 +799,7 @@ "sender": ["ab0f3d65723e", "dd4ba833e8b5"], "payloads": ["5cd1d4a9aed5", "ef7005279d33"], "settlements": { - "stop": "eb79a9b3682a" + "stop": "9270aeb7d9c6" }, "state": "debf84af8d66", "effects": ["75263c07c807"] @@ -726,10 +808,10 @@ { "id": "native-chat-stop-accepted.method-not-found:settled", "observation": { - "sender": ["ab0f3d65723e", "dd4ba833e8b5", "650fdd02bab4"], - "payloads": ["5cd1d4a9aed5", "ef7005279d33", "ef422fa31f61"], + "sender": ["ab0f3d65723e", "dd4ba833e8b5", "8699d16b8581", "672e5a827dbd"], + "payloads": ["5cd1d4a9aed5", "ef7005279d33", "b81e2536b6b7", "0ab481baef7d"], "settlements": { - "stop": "eb79a9b3682a" + "stop": "3e2d28f3728b" }, "state": "debf84af8d66", "effects": ["75263c07c807"] @@ -741,7 +823,7 @@ "sender": ["ab0f3d65723e", "b59f001bf55b"], "payloads": ["5cd1d4a9aed5", "ef7005279d33"], "settlements": { - "stop": "eb79a9b3682a" + "stop": "9270aeb7d9c6" }, "state": "debf84af8d66", "effects": ["75263c07c807"] @@ -750,10 +832,10 @@ { "id": "native-chat-stop-accepted.transport-rejection:settled", "observation": { - "sender": ["ab0f3d65723e", "b59f001bf55b", "650fdd02bab4"], - "payloads": ["5cd1d4a9aed5", "ef7005279d33", "ef422fa31f61"], + "sender": ["ab0f3d65723e", "b59f001bf55b", "8699d16b8581", "672e5a827dbd"], + "payloads": ["5cd1d4a9aed5", "ef7005279d33", "b81e2536b6b7", "0ab481baef7d"], "settlements": { - "stop": "eb79a9b3682a" + "stop": "3e2d28f3728b" }, "state": "debf84af8d66", "effects": ["75263c07c807"] @@ -765,7 +847,7 @@ "sender": ["ab0f3d65723e", "ad3d324fcb52"], "payloads": ["5cd1d4a9aed5", "ef7005279d33"], "settlements": { - "stop": "eb79a9b3682a" + "stop": "9270aeb7d9c6" }, "state": "debf84af8d66", "effects": ["75263c07c807"] @@ -774,10 +856,10 @@ { "id": "native-chat-stop-accepted.transport-rejection-no-message:settled", "observation": { - "sender": ["ab0f3d65723e", "ad3d324fcb52", "650fdd02bab4"], - "payloads": ["5cd1d4a9aed5", "ef7005279d33", "ef422fa31f61"], + "sender": ["ab0f3d65723e", "ad3d324fcb52", "8699d16b8581", "672e5a827dbd"], + "payloads": ["5cd1d4a9aed5", "ef7005279d33", "b81e2536b6b7", "0ab481baef7d"], "settlements": { - "stop": "eb79a9b3682a" + "stop": "3e2d28f3728b" }, "state": "debf84af8d66", "effects": ["75263c07c807"] diff --git a/mobile/rpc-foundation/goldens/matrix-session.native-chat-stop-terminal.send-1.json b/mobile/rpc-foundation/goldens/matrix-session.native-chat-stop-terminal.send-1.json index 9008a996265..53a1fcb3a0c 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.native-chat-stop-terminal.send-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.native-chat-stop-terminal.send-1.json @@ -82,47 +82,11 @@ } } }, - "44045fc21677": { - "name": "terminal.send#2", - "ordinal": 4, - "args": [ - { - "name": "method", - "value": "terminal.send" - }, - { - "name": "params", - "value": { - "client": { - "id": "device-token-1", - "type": "mobile" - }, - "terminal": "terminal-1", - "text": "\u001b" - } - }, - { - "name": "options", - "value": { - "budgetSpansConnect": true, - "timeoutMs": 14920 - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 80, - "settledAt": 120, - "value": { - "id": "frame-2", - "ok": true, - "result": { - "send": { - "accepted": true - } - } - } - } + "3e2d28f3728b": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 1000, + "value": true }, "4b3ad1f19363": { "name": "terminal.send#1", @@ -250,7 +214,7 @@ "ordinal": 3, "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"terminal.send\",\"params\":{\"terminal\":\"terminal-1\",\"text\":\"\\u001b\",\"client\":{\"id\":\"device-token-1\",\"type\":\"mobile\"}}}" }, - "650fdd02bab4": { + "5cd6e007c8ca": { "name": "terminal.send#2", "ordinal": 6, "args": [ @@ -273,14 +237,14 @@ "name": "options", "value": { "budgetSpansConnect": true, - "timeoutMs": 14920 + "timeoutMs": 14000 } } ], "settlement": { "status": "fulfilled", - "startedAt": 80, - "settledAt": 120, + "startedAt": 1000, + "settledAt": 1000, "value": { "id": "frame-3", "ok": true, @@ -338,6 +302,38 @@ "ordinal": 1, "value": {} }, + "77b1709caa79": { + "name": "orchestration.workerTerminalUserInput#1", + "ordinal": 6, + "args": [ + { + "name": "method", + "value": "orchestration.workerTerminalUserInput" + }, + { + "name": "params", + "value": { + "terminal": "terminal-1" + } + }, + { + "name": "options", + "value": { + "budgetSpansConnect": true, + "failWhenDisconnected": true, + "timeoutMs": 5000 + } + } + ], + "settlement": { + "status": "pending", + "startedAt": 1000 + } + }, + "9270aeb7d9c6": { + "status": "pending", + "startedAt": 0 + }, "ab0f3d65723e": { "name": "terminal.send#1", "ordinal": 2, @@ -464,34 +460,6 @@ } } }, - "c7702de4a9d5": { - "name": "orchestration.workerTerminalUserInput#1", - "ordinal": 6, - "args": [ - { - "name": "method", - "value": "orchestration.workerTerminalUserInput" - }, - { - "name": "params", - "value": { - "terminal": "terminal-1" - } - }, - { - "name": "options", - "value": { - "budgetSpansConnect": true, - "failWhenDisconnected": true, - "timeoutMs": 5000 - } - } - ], - "settlement": { - "status": "pending", - "startedAt": 120 - } - }, "cfe4beb4c8c9": { "name": "terminal.send#1", "ordinal": 2, @@ -540,6 +508,48 @@ "ordinal": 5, "json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"terminal.send\",\"params\":{\"terminal\":\"terminal-1\",\"text\":\"\\u001b\",\"client\":{\"id\":\"device-token-1\",\"type\":\"mobile\"}}}" }, + "da95ce195845": { + "name": "terminal.send#2", + "ordinal": 4, + "args": [ + { + "name": "method", + "value": "terminal.send" + }, + { + "name": "params", + "value": { + "client": { + "id": "device-token-1", + "type": "mobile" + }, + "terminal": "terminal-1", + "text": "\u001b" + } + }, + { + "name": "options", + "value": { + "budgetSpansConnect": true, + "timeoutMs": 14000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 1000, + "settledAt": 1000, + "value": { + "id": "frame-2", + "ok": true, + "result": { + "send": { + "accepted": true + } + } + } + } + }, "debf84af8d66": { "errors": [] }, @@ -581,14 +591,6 @@ } } }, - "eb79a9b3682a": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "$rpc": "undefined" - } - }, "ef422fa31f61": { "name": "terminal.send#2", "ordinal": 7, @@ -645,7 +647,7 @@ "sender": ["ab0f3d65723e", "fcdecc63b46e"], "payloads": ["5cd1d4a9aed5", "ef7005279d33"], "settlements": { - "stop": "eb79a9b3682a" + "stop": "9270aeb7d9c6" }, "state": "debf84af8d66", "effects": ["75263c07c807"] @@ -654,10 +656,10 @@ { "id": "native-chat-stop-accepted.normal:settled", "observation": { - "sender": ["ab0f3d65723e", "fcdecc63b46e", "650fdd02bab4"], + "sender": ["ab0f3d65723e", "fcdecc63b46e", "5cd6e007c8ca"], "payloads": ["5cd1d4a9aed5", "ef7005279d33", "ef422fa31f61"], "settlements": { - "stop": "eb79a9b3682a" + "stop": "3e2d28f3728b" }, "state": "debf84af8d66", "effects": ["75263c07c807"] @@ -669,7 +671,7 @@ "sender": ["1a7f859d2c49"], "payloads": ["5cd1d4a9aed5"], "settlements": { - "stop": "eb79a9b3682a" + "stop": "9270aeb7d9c6" }, "state": "debf84af8d66", "effects": ["75263c07c807"] @@ -678,10 +680,10 @@ { "id": "native-chat-stop-accepted.result-absent:settled", "observation": { - "sender": ["1a7f859d2c49", "44045fc21677", "c7702de4a9d5"], + "sender": ["1a7f859d2c49", "da95ce195845", "77b1709caa79"], "payloads": ["5cd1d4a9aed5", "d5972c43f562", "b524bdde3f98"], "settlements": { - "stop": "eb79a9b3682a" + "stop": "3e2d28f3728b" }, "state": "debf84af8d66", "effects": ["75263c07c807"] @@ -693,7 +695,7 @@ "sender": ["4c712df25964"], "payloads": ["5cd1d4a9aed5"], "settlements": { - "stop": "eb79a9b3682a" + "stop": "9270aeb7d9c6" }, "state": "debf84af8d66", "effects": ["75263c07c807"] @@ -702,10 +704,10 @@ { "id": "native-chat-stop-accepted.result-null:settled", "observation": { - "sender": ["4c712df25964", "44045fc21677", "c7702de4a9d5"], + "sender": ["4c712df25964", "da95ce195845", "77b1709caa79"], "payloads": ["5cd1d4a9aed5", "d5972c43f562", "b524bdde3f98"], "settlements": { - "stop": "eb79a9b3682a" + "stop": "3e2d28f3728b" }, "state": "debf84af8d66", "effects": ["75263c07c807"] @@ -717,7 +719,7 @@ "sender": ["4b3ad1f19363"], "payloads": ["5cd1d4a9aed5"], "settlements": { - "stop": "eb79a9b3682a" + "stop": "9270aeb7d9c6" }, "state": "debf84af8d66", "effects": ["75263c07c807"] @@ -726,10 +728,10 @@ { "id": "native-chat-stop-accepted.inner-ok-missing:settled", "observation": { - "sender": ["4b3ad1f19363", "44045fc21677", "c7702de4a9d5"], + "sender": ["4b3ad1f19363", "da95ce195845", "77b1709caa79"], "payloads": ["5cd1d4a9aed5", "d5972c43f562", "b524bdde3f98"], "settlements": { - "stop": "eb79a9b3682a" + "stop": "3e2d28f3728b" }, "state": "debf84af8d66", "effects": ["75263c07c807"] @@ -741,7 +743,7 @@ "sender": ["4db98b98a612"], "payloads": ["5cd1d4a9aed5"], "settlements": { - "stop": "eb79a9b3682a" + "stop": "9270aeb7d9c6" }, "state": "debf84af8d66", "effects": ["75263c07c807"] @@ -750,10 +752,10 @@ { "id": "native-chat-stop-accepted.inner-false-string-error:settled", "observation": { - "sender": ["4db98b98a612", "44045fc21677", "c7702de4a9d5"], + "sender": ["4db98b98a612", "da95ce195845", "77b1709caa79"], "payloads": ["5cd1d4a9aed5", "d5972c43f562", "b524bdde3f98"], "settlements": { - "stop": "eb79a9b3682a" + "stop": "3e2d28f3728b" }, "state": "debf84af8d66", "effects": ["75263c07c807"] @@ -765,7 +767,7 @@ "sender": ["cfe4beb4c8c9"], "payloads": ["5cd1d4a9aed5"], "settlements": { - "stop": "eb79a9b3682a" + "stop": "9270aeb7d9c6" }, "state": "debf84af8d66", "effects": ["75263c07c807"] @@ -774,10 +776,10 @@ { "id": "native-chat-stop-accepted.inner-false-object-error:settled", "observation": { - "sender": ["cfe4beb4c8c9", "44045fc21677", "c7702de4a9d5"], + "sender": ["cfe4beb4c8c9", "da95ce195845", "77b1709caa79"], "payloads": ["5cd1d4a9aed5", "d5972c43f562", "b524bdde3f98"], "settlements": { - "stop": "eb79a9b3682a" + "stop": "3e2d28f3728b" }, "state": "debf84af8d66", "effects": ["75263c07c807"] @@ -789,7 +791,7 @@ "sender": ["6669eafe604b"], "payloads": ["5cd1d4a9aed5"], "settlements": { - "stop": "eb79a9b3682a" + "stop": "9270aeb7d9c6" }, "state": "debf84af8d66", "effects": ["75263c07c807"] @@ -798,10 +800,10 @@ { "id": "native-chat-stop-accepted.outer-refused:settled", "observation": { - "sender": ["6669eafe604b", "44045fc21677", "c7702de4a9d5"], + "sender": ["6669eafe604b", "da95ce195845", "77b1709caa79"], "payloads": ["5cd1d4a9aed5", "d5972c43f562", "b524bdde3f98"], "settlements": { - "stop": "eb79a9b3682a" + "stop": "3e2d28f3728b" }, "state": "debf84af8d66", "effects": ["75263c07c807"] @@ -813,7 +815,7 @@ "sender": ["08f063891b6e"], "payloads": ["5cd1d4a9aed5"], "settlements": { - "stop": "eb79a9b3682a" + "stop": "9270aeb7d9c6" }, "state": "debf84af8d66", "effects": ["75263c07c807"] @@ -822,10 +824,10 @@ { "id": "native-chat-stop-accepted.outer-refused-no-message:settled", "observation": { - "sender": ["08f063891b6e", "44045fc21677", "c7702de4a9d5"], + "sender": ["08f063891b6e", "da95ce195845", "77b1709caa79"], "payloads": ["5cd1d4a9aed5", "d5972c43f562", "b524bdde3f98"], "settlements": { - "stop": "eb79a9b3682a" + "stop": "3e2d28f3728b" }, "state": "debf84af8d66", "effects": ["75263c07c807"] @@ -837,7 +839,7 @@ "sender": ["bbb8c9d51c2a"], "payloads": ["5cd1d4a9aed5"], "settlements": { - "stop": "eb79a9b3682a" + "stop": "9270aeb7d9c6" }, "state": "debf84af8d66", "effects": ["75263c07c807"] @@ -846,10 +848,10 @@ { "id": "native-chat-stop-accepted.method-not-found:settled", "observation": { - "sender": ["bbb8c9d51c2a", "44045fc21677", "c7702de4a9d5"], + "sender": ["bbb8c9d51c2a", "da95ce195845", "77b1709caa79"], "payloads": ["5cd1d4a9aed5", "d5972c43f562", "b524bdde3f98"], "settlements": { - "stop": "eb79a9b3682a" + "stop": "3e2d28f3728b" }, "state": "debf84af8d66", "effects": ["75263c07c807"] @@ -861,7 +863,7 @@ "sender": ["e218d5d3a813"], "payloads": ["5cd1d4a9aed5"], "settlements": { - "stop": "eb79a9b3682a" + "stop": "9270aeb7d9c6" }, "state": "debf84af8d66", "effects": ["75263c07c807"] @@ -870,10 +872,10 @@ { "id": "native-chat-stop-accepted.transport-rejection:settled", "observation": { - "sender": ["e218d5d3a813", "44045fc21677", "c7702de4a9d5"], + "sender": ["e218d5d3a813", "da95ce195845", "77b1709caa79"], "payloads": ["5cd1d4a9aed5", "d5972c43f562", "b524bdde3f98"], "settlements": { - "stop": "eb79a9b3682a" + "stop": "3e2d28f3728b" }, "state": "debf84af8d66", "effects": ["75263c07c807"] @@ -885,7 +887,7 @@ "sender": ["b406684d86f2"], "payloads": ["5cd1d4a9aed5"], "settlements": { - "stop": "eb79a9b3682a" + "stop": "9270aeb7d9c6" }, "state": "debf84af8d66", "effects": ["75263c07c807"] @@ -894,10 +896,10 @@ { "id": "native-chat-stop-accepted.transport-rejection-no-message:settled", "observation": { - "sender": ["b406684d86f2", "44045fc21677", "c7702de4a9d5"], + "sender": ["b406684d86f2", "da95ce195845", "77b1709caa79"], "payloads": ["5cd1d4a9aed5", "d5972c43f562", "b524bdde3f98"], "settlements": { - "stop": "eb79a9b3682a" + "stop": "3e2d28f3728b" }, "state": "debf84af8d66", "effects": ["75263c07c807"] diff --git a/mobile/rpc-foundation/goldens/matrix-session.native-chat-stop-terminal.send-2.json b/mobile/rpc-foundation/goldens/matrix-session.native-chat-stop-terminal.send-2.json index 64ae9b3e45e..11273b00bd8 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.native-chat-stop-terminal.send-2.json +++ b/mobile/rpc-foundation/goldens/matrix-session.native-chat-stop-terminal.send-2.json @@ -4,7 +4,7 @@ "family": "session.native-chat-stop", "namedDeltas": [], "values": { - "042114cacf6b": { + "2b73ac4adae4": { "name": "terminal.send#2", "ordinal": 6, "args": [ @@ -27,92 +27,14 @@ "name": "options", "value": { "budgetSpansConnect": true, - "timeoutMs": 14920 + "timeoutMs": 14000 } } ], "settlement": { "status": "fulfilled", - "startedAt": 80, - "settledAt": 120, - "value": { - "id": "frame-3", - "ok": true - } - } - }, - "23fd4ec9cdb3": { - "name": "terminal.send#2", - "ordinal": 6, - "args": [ - { - "name": "method", - "value": "terminal.send" - }, - { - "name": "params", - "value": { - "client": { - "id": "device-token-1", - "type": "mobile" - }, - "terminal": "terminal-1", - "text": "\u001b" - } - }, - { - "name": "options", - "value": { - "budgetSpansConnect": true, - "timeoutMs": 14920 - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 80, - "settledAt": 120, - "value": { - "error": { - "code": "refused", - "message": "" - }, - "id": "frame-3", - "ok": false - } - } - }, - "3bfec3b63b7a": { - "name": "terminal.send#2", - "ordinal": 6, - "args": [ - { - "name": "method", - "value": "terminal.send" - }, - { - "name": "params", - "value": { - "client": { - "id": "device-token-1", - "type": "mobile" - }, - "terminal": "terminal-1", - "text": "\u001b" - } - }, - { - "name": "options", - "value": { - "budgetSpansConnect": true, - "timeoutMs": 14920 - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 80, - "settledAt": 120, + "startedAt": 1000, + "settledAt": 1000, "value": { "id": "frame-3", "ok": true, @@ -122,7 +44,7 @@ } } }, - "4946d9c85c20": { + "2d7a0d50473b": { "name": "terminal.send#2", "ordinal": 6, "args": [ @@ -145,24 +67,25 @@ "name": "options", "value": { "budgetSpansConnect": true, - "timeoutMs": 14920 + "timeoutMs": 14000 } } ], "settlement": { "status": "fulfilled", - "startedAt": 80, - "settledAt": 120, + "startedAt": 1000, + "settledAt": 1000, "value": { + "error": { + "code": "method_not_found", + "message": "Unknown method" + }, "id": "frame-3", - "ok": true, - "result": { - "$rpc": "null" - } + "ok": false } } }, - "5087fb12a234": { + "3c186b43b6de": { "name": "terminal.send#2", "ordinal": 6, "args": [ @@ -185,21 +108,65 @@ "name": "options", "value": { "budgetSpansConnect": true, - "timeoutMs": 14920 + "timeoutMs": 14000 + } + } + ], + "settlement": { + "status": "rejected", + "startedAt": 1000, + "settledAt": 1000, + "error": { + "category": "Error", + "message": "", + "isRpcDeliveryUnknown": true + } + } + }, + "3e2d28f3728b": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 1000, + "value": true + }, + "4e8a6d1de561": { + "name": "terminal.send#2", + "ordinal": 6, + "args": [ + { + "name": "method", + "value": "terminal.send" + }, + { + "name": "params", + "value": { + "client": { + "id": "device-token-1", + "type": "mobile" + }, + "terminal": "terminal-1", + "text": "\u001b" + } + }, + { + "name": "options", + "value": { + "budgetSpansConnect": true, + "timeoutMs": 14000 } } ], "settlement": { "status": "fulfilled", - "startedAt": 80, - "settledAt": 120, + "startedAt": 1000, + "settledAt": 1000, "value": { + "error": { + "code": "refused", + "message": "outer refused" + }, "id": "frame-3", - "ok": true, - "result": { - "error": "inner refused", - "ok": false - } + "ok": false } } }, @@ -208,7 +175,7 @@ "ordinal": 3, "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"terminal.send\",\"params\":{\"terminal\":\"terminal-1\",\"text\":\"\\u001b\",\"client\":{\"id\":\"device-token-1\",\"type\":\"mobile\"}}}" }, - "650fdd02bab4": { + "5cd6e007c8ca": { "name": "terminal.send#2", "ordinal": 6, "args": [ @@ -231,14 +198,14 @@ "name": "options", "value": { "budgetSpansConnect": true, - "timeoutMs": 14920 + "timeoutMs": 14000 } } ], "settlement": { "status": "fulfilled", - "startedAt": 80, - "settledAt": 120, + "startedAt": 1000, + "settledAt": 1000, "value": { "id": "frame-3", "ok": true, @@ -250,89 +217,14 @@ } } }, - "70c7b87bd776": { - "name": "terminal.send#2", - "ordinal": 6, - "args": [ - { - "name": "method", - "value": "terminal.send" - }, - { - "name": "params", - "value": { - "client": { - "id": "device-token-1", - "type": "mobile" - }, - "terminal": "terminal-1", - "text": "\u001b" - } - }, - { - "name": "options", - "value": { - "budgetSpansConnect": true, - "timeoutMs": 14920 - } - } - ], - "settlement": { - "status": "rejected", - "startedAt": 80, - "settledAt": 120, - "error": { - "category": "Error", - "message": "transport failure", - "isRpcDeliveryUnknown": true - } - } - }, "75263c07c807": { "name": "cancel-pending", "ordinal": 1, "value": {} }, - "a5a6027c8212": { - "name": "terminal.send#2", - "ordinal": 6, - "args": [ - { - "name": "method", - "value": "terminal.send" - }, - { - "name": "params", - "value": { - "client": { - "id": "device-token-1", - "type": "mobile" - }, - "terminal": "terminal-1", - "text": "\u001b" - } - }, - { - "name": "options", - "value": { - "budgetSpansConnect": true, - "timeoutMs": 14920 - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 80, - "settledAt": 120, - "value": { - "error": { - "code": "method_not_found", - "message": "Unknown method" - }, - "id": "frame-3", - "ok": false - } - } + "9270aeb7d9c6": { + "status": "pending", + "startedAt": 0 }, "ab0f3d65723e": { "name": "terminal.send#1", @@ -376,7 +268,7 @@ } } }, - "bb85eef75ee8": { + "c3f35523d970": { "name": "terminal.send#2", "ordinal": 6, "args": [ @@ -399,14 +291,136 @@ "name": "options", "value": { "budgetSpansConnect": true, - "timeoutMs": 14920 + "timeoutMs": 14000 } } ], "settlement": { "status": "fulfilled", - "startedAt": 80, - "settledAt": 120, + "startedAt": 1000, + "settledAt": 1000, + "value": { + "error": { + "code": "refused", + "message": "" + }, + "id": "frame-3", + "ok": false + } + } + }, + "d8f0e66070a6": { + "name": "terminal.send#2", + "ordinal": 6, + "args": [ + { + "name": "method", + "value": "terminal.send" + }, + { + "name": "params", + "value": { + "client": { + "id": "device-token-1", + "type": "mobile" + }, + "terminal": "terminal-1", + "text": "\u001b" + } + }, + { + "name": "options", + "value": { + "budgetSpansConnect": true, + "timeoutMs": 14000 + } + } + ], + "settlement": { + "status": "rejected", + "startedAt": 1000, + "settledAt": 1000, + "error": { + "category": "Error", + "message": "transport failure", + "isRpcDeliveryUnknown": true + } + } + }, + "debf84af8d66": { + "errors": [] + }, + "e179d88224b1": { + "name": "terminal.send#2", + "ordinal": 6, + "args": [ + { + "name": "method", + "value": "terminal.send" + }, + { + "name": "params", + "value": { + "client": { + "id": "device-token-1", + "type": "mobile" + }, + "terminal": "terminal-1", + "text": "\u001b" + } + }, + { + "name": "options", + "value": { + "budgetSpansConnect": true, + "timeoutMs": 14000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 1000, + "settledAt": 1000, + "value": { + "id": "frame-3", + "ok": true, + "result": { + "$rpc": "null" + } + } + } + }, + "e46d1bc83f99": { + "name": "terminal.send#2", + "ordinal": 6, + "args": [ + { + "name": "method", + "value": "terminal.send" + }, + { + "name": "params", + "value": { + "client": { + "id": "device-token-1", + "type": "mobile" + }, + "terminal": "terminal-1", + "text": "\u001b" + } + }, + { + "name": "options", + "value": { + "budgetSpansConnect": true, + "timeoutMs": 14000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 1000, + "settledAt": 1000, "value": { "id": "frame-3", "ok": true, @@ -419,7 +433,7 @@ } } }, - "de2c3a23d630": { + "e72c54dda684": { "name": "terminal.send#2", "ordinal": 6, "args": [ @@ -442,33 +456,59 @@ "name": "options", "value": { "budgetSpansConnect": true, - "timeoutMs": 14920 + "timeoutMs": 14000 } } ], "settlement": { "status": "fulfilled", - "startedAt": 80, - "settledAt": 120, + "startedAt": 1000, + "settledAt": 1000, "value": { - "error": { - "code": "refused", - "message": "outer refused" - }, "id": "frame-3", - "ok": false + "ok": true, + "result": { + "error": "inner refused", + "ok": false + } } } }, - "debf84af8d66": { - "errors": [] - }, - "eb79a9b3682a": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "$rpc": "undefined" + "e8dc704e9d67": { + "name": "terminal.send#2", + "ordinal": 6, + "args": [ + { + "name": "method", + "value": "terminal.send" + }, + { + "name": "params", + "value": { + "client": { + "id": "device-token-1", + "type": "mobile" + }, + "terminal": "terminal-1", + "text": "\u001b" + } + }, + { + "name": "options", + "value": { + "budgetSpansConnect": true, + "timeoutMs": 14000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 1000, + "settledAt": 1000, + "value": { + "id": "frame-3", + "ok": true + } } }, "ef422fa31f61": { @@ -516,44 +556,6 @@ } } } - }, - "fe6add283d6d": { - "name": "terminal.send#2", - "ordinal": 6, - "args": [ - { - "name": "method", - "value": "terminal.send" - }, - { - "name": "params", - "value": { - "client": { - "id": "device-token-1", - "type": "mobile" - }, - "terminal": "terminal-1", - "text": "\u001b" - } - }, - { - "name": "options", - "value": { - "budgetSpansConnect": true, - "timeoutMs": 14920 - } - } - ], - "settlement": { - "status": "rejected", - "startedAt": 80, - "settledAt": 120, - "error": { - "category": "Error", - "message": "", - "isRpcDeliveryUnknown": true - } - } } }, "recording": { @@ -565,7 +567,7 @@ "sender": ["ab0f3d65723e", "fcdecc63b46e"], "payloads": ["5cd1d4a9aed5", "ef7005279d33"], "settlements": { - "stop": "eb79a9b3682a" + "stop": "9270aeb7d9c6" }, "state": "debf84af8d66", "effects": ["75263c07c807"] @@ -574,10 +576,10 @@ { "id": "native-chat-stop-accepted.normal:settled", "observation": { - "sender": ["ab0f3d65723e", "fcdecc63b46e", "650fdd02bab4"], + "sender": ["ab0f3d65723e", "fcdecc63b46e", "5cd6e007c8ca"], "payloads": ["5cd1d4a9aed5", "ef7005279d33", "ef422fa31f61"], "settlements": { - "stop": "eb79a9b3682a" + "stop": "3e2d28f3728b" }, "state": "debf84af8d66", "effects": ["75263c07c807"] @@ -586,10 +588,10 @@ { "id": "native-chat-stop-accepted.result-absent:settled", "observation": { - "sender": ["ab0f3d65723e", "fcdecc63b46e", "042114cacf6b"], + "sender": ["ab0f3d65723e", "fcdecc63b46e", "e8dc704e9d67"], "payloads": ["5cd1d4a9aed5", "ef7005279d33", "ef422fa31f61"], "settlements": { - "stop": "eb79a9b3682a" + "stop": "3e2d28f3728b" }, "state": "debf84af8d66", "effects": ["75263c07c807"] @@ -598,10 +600,10 @@ { "id": "native-chat-stop-accepted.result-null:settled", "observation": { - "sender": ["ab0f3d65723e", "fcdecc63b46e", "4946d9c85c20"], + "sender": ["ab0f3d65723e", "fcdecc63b46e", "e179d88224b1"], "payloads": ["5cd1d4a9aed5", "ef7005279d33", "ef422fa31f61"], "settlements": { - "stop": "eb79a9b3682a" + "stop": "3e2d28f3728b" }, "state": "debf84af8d66", "effects": ["75263c07c807"] @@ -610,10 +612,10 @@ { "id": "native-chat-stop-accepted.inner-ok-missing:settled", "observation": { - "sender": ["ab0f3d65723e", "fcdecc63b46e", "3bfec3b63b7a"], + "sender": ["ab0f3d65723e", "fcdecc63b46e", "2b73ac4adae4"], "payloads": ["5cd1d4a9aed5", "ef7005279d33", "ef422fa31f61"], "settlements": { - "stop": "eb79a9b3682a" + "stop": "3e2d28f3728b" }, "state": "debf84af8d66", "effects": ["75263c07c807"] @@ -622,10 +624,10 @@ { "id": "native-chat-stop-accepted.inner-false-string-error:settled", "observation": { - "sender": ["ab0f3d65723e", "fcdecc63b46e", "5087fb12a234"], + "sender": ["ab0f3d65723e", "fcdecc63b46e", "e72c54dda684"], "payloads": ["5cd1d4a9aed5", "ef7005279d33", "ef422fa31f61"], "settlements": { - "stop": "eb79a9b3682a" + "stop": "3e2d28f3728b" }, "state": "debf84af8d66", "effects": ["75263c07c807"] @@ -634,10 +636,10 @@ { "id": "native-chat-stop-accepted.inner-false-object-error:settled", "observation": { - "sender": ["ab0f3d65723e", "fcdecc63b46e", "bb85eef75ee8"], + "sender": ["ab0f3d65723e", "fcdecc63b46e", "e46d1bc83f99"], "payloads": ["5cd1d4a9aed5", "ef7005279d33", "ef422fa31f61"], "settlements": { - "stop": "eb79a9b3682a" + "stop": "3e2d28f3728b" }, "state": "debf84af8d66", "effects": ["75263c07c807"] @@ -646,10 +648,10 @@ { "id": "native-chat-stop-accepted.outer-refused:settled", "observation": { - "sender": ["ab0f3d65723e", "fcdecc63b46e", "de2c3a23d630"], + "sender": ["ab0f3d65723e", "fcdecc63b46e", "4e8a6d1de561"], "payloads": ["5cd1d4a9aed5", "ef7005279d33", "ef422fa31f61"], "settlements": { - "stop": "eb79a9b3682a" + "stop": "3e2d28f3728b" }, "state": "debf84af8d66", "effects": ["75263c07c807"] @@ -658,10 +660,10 @@ { "id": "native-chat-stop-accepted.outer-refused-no-message:settled", "observation": { - "sender": ["ab0f3d65723e", "fcdecc63b46e", "23fd4ec9cdb3"], + "sender": ["ab0f3d65723e", "fcdecc63b46e", "c3f35523d970"], "payloads": ["5cd1d4a9aed5", "ef7005279d33", "ef422fa31f61"], "settlements": { - "stop": "eb79a9b3682a" + "stop": "3e2d28f3728b" }, "state": "debf84af8d66", "effects": ["75263c07c807"] @@ -670,10 +672,10 @@ { "id": "native-chat-stop-accepted.method-not-found:settled", "observation": { - "sender": ["ab0f3d65723e", "fcdecc63b46e", "a5a6027c8212"], + "sender": ["ab0f3d65723e", "fcdecc63b46e", "2d7a0d50473b"], "payloads": ["5cd1d4a9aed5", "ef7005279d33", "ef422fa31f61"], "settlements": { - "stop": "eb79a9b3682a" + "stop": "3e2d28f3728b" }, "state": "debf84af8d66", "effects": ["75263c07c807"] @@ -682,10 +684,10 @@ { "id": "native-chat-stop-accepted.transport-rejection:settled", "observation": { - "sender": ["ab0f3d65723e", "fcdecc63b46e", "70c7b87bd776"], + "sender": ["ab0f3d65723e", "fcdecc63b46e", "d8f0e66070a6"], "payloads": ["5cd1d4a9aed5", "ef7005279d33", "ef422fa31f61"], "settlements": { - "stop": "eb79a9b3682a" + "stop": "3e2d28f3728b" }, "state": "debf84af8d66", "effects": ["75263c07c807"] @@ -694,10 +696,10 @@ { "id": "native-chat-stop-accepted.transport-rejection-no-message:settled", "observation": { - "sender": ["ab0f3d65723e", "fcdecc63b46e", "fe6add283d6d"], + "sender": ["ab0f3d65723e", "fcdecc63b46e", "3c186b43b6de"], "payloads": ["5cd1d4a9aed5", "ef7005279d33", "ef422fa31f61"], "settlements": { - "stop": "eb79a9b3682a" + "stop": "3e2d28f3728b" }, "state": "debf84af8d66", "effects": ["75263c07c807"] diff --git a/mobile/rpc-foundation/goldens/native-chat-stop-accepted.json b/mobile/rpc-foundation/goldens/native-chat-stop-accepted.json index ef83d7f679b..4f22500eade 100644 --- a/mobile/rpc-foundation/goldens/native-chat-stop-accepted.json +++ b/mobile/rpc-foundation/goldens/native-chat-stop-accepted.json @@ -4,12 +4,18 @@ "family": "session.native-chat-stop", "namedDeltas": [], "values": { + "3e2d28f3728b": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 1000, + "value": true + }, "5cd1d4a9aed5": { "name": "terminal.send#1", "ordinal": 3, "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"terminal.send\",\"params\":{\"terminal\":\"terminal-1\",\"text\":\"\\u001b\",\"client\":{\"id\":\"device-token-1\",\"type\":\"mobile\"}}}" }, - "650fdd02bab4": { + "5cd6e007c8ca": { "name": "terminal.send#2", "ordinal": 6, "args": [ @@ -32,14 +38,14 @@ "name": "options", "value": { "budgetSpansConnect": true, - "timeoutMs": 14920 + "timeoutMs": 14000 } } ], "settlement": { "status": "fulfilled", - "startedAt": 80, - "settledAt": 120, + "startedAt": 1000, + "settledAt": 1000, "value": { "id": "frame-3", "ok": true, @@ -56,6 +62,10 @@ "ordinal": 1, "value": {} }, + "9270aeb7d9c6": { + "status": "pending", + "startedAt": 0 + }, "ab0f3d65723e": { "name": "terminal.send#1", "ordinal": 2, @@ -101,14 +111,6 @@ "debf84af8d66": { "errors": [] }, - "eb79a9b3682a": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "$rpc": "undefined" - } - }, "ef422fa31f61": { "name": "terminal.send#2", "ordinal": 7, @@ -165,7 +167,7 @@ "sender": ["ab0f3d65723e", "fcdecc63b46e"], "payloads": ["5cd1d4a9aed5", "ef7005279d33"], "settlements": { - "stop": "eb79a9b3682a" + "stop": "9270aeb7d9c6" }, "state": "debf84af8d66", "effects": ["75263c07c807"] @@ -174,10 +176,10 @@ { "id": "settled", "observation": { - "sender": ["ab0f3d65723e", "fcdecc63b46e", "650fdd02bab4"], + "sender": ["ab0f3d65723e", "fcdecc63b46e", "5cd6e007c8ca"], "payloads": ["5cd1d4a9aed5", "ef7005279d33", "ef422fa31f61"], "settlements": { - "stop": "eb79a9b3682a" + "stop": "3e2d28f3728b" }, "state": "debf84af8d66", "effects": ["75263c07c807"] diff --git a/mobile/rpc-foundation/goldens/native-chat-stop-both-rejected.json b/mobile/rpc-foundation/goldens/native-chat-stop-both-rejected.json index 9d3cd0ca1ef..a0195d1297b 100644 --- a/mobile/rpc-foundation/goldens/native-chat-stop-both-rejected.json +++ b/mobile/rpc-foundation/goldens/native-chat-stop-both-rejected.json @@ -9,47 +9,11 @@ "ordinal": 3, "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"terminal.send\",\"params\":{\"terminal\":\"terminal-1\",\"text\":\"\\u001b\",\"client\":{\"id\":\"device-token-1\",\"type\":\"mobile\"}}}" }, - "6fc7ca31cbf5": { - "name": "terminal.send#2", - "ordinal": 4, - "args": [ - { - "name": "method", - "value": "terminal.send" - }, - { - "name": "params", - "value": { - "client": { - "id": "device-token-1", - "type": "mobile" - }, - "terminal": "terminal-1", - "text": "\u001b" - } - }, - { - "name": "options", - "value": { - "budgetSpansConnect": true, - "timeoutMs": 14920 - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 80, - "settledAt": 120, - "value": { - "id": "frame-2", - "ok": true, - "result": { - "send": { - "accepted": false - } - } - } - } + "6b21adc16f94": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 1000, + "value": false }, "75263c07c807": { "name": "cancel-pending", @@ -103,16 +67,50 @@ "ordinal": 5, "json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"terminal.send\",\"params\":{\"terminal\":\"terminal-1\",\"text\":\"\\u001b\",\"client\":{\"id\":\"device-token-1\",\"type\":\"mobile\"}}}" }, + "db139841535a": { + "name": "terminal.send#2", + "ordinal": 4, + "args": [ + { + "name": "method", + "value": "terminal.send" + }, + { + "name": "params", + "value": { + "client": { + "id": "device-token-1", + "type": "mobile" + }, + "terminal": "terminal-1", + "text": "\u001b" + } + }, + { + "name": "options", + "value": { + "budgetSpansConnect": true, + "timeoutMs": 14000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 1000, + "settledAt": 1000, + "value": { + "id": "frame-2", + "ok": true, + "result": { + "send": { + "accepted": false + } + } + } + } + }, "e6e087b39540": { "errors": ["Stop not sent"] - }, - "eb79a9b3682a": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "$rpc": "undefined" - } } }, "recording": { @@ -121,10 +119,10 @@ { "id": "reported", "observation": { - "sender": ["81225cffe7a1", "6fc7ca31cbf5"], + "sender": ["81225cffe7a1", "db139841535a"], "payloads": ["5cd1d4a9aed5", "d5972c43f562"], "settlements": { - "stop": "eb79a9b3682a" + "stop": "6b21adc16f94" }, "state": "e6e087b39540", "effects": ["75263c07c807"] diff --git a/mobile/rpc-foundation/goldens/native-chat-stop-delivery-unknown.json b/mobile/rpc-foundation/goldens/native-chat-stop-delivery-unknown.json index 3495ee46fe0..108a3db81a7 100644 --- a/mobile/rpc-foundation/goldens/native-chat-stop-delivery-unknown.json +++ b/mobile/rpc-foundation/goldens/native-chat-stop-delivery-unknown.json @@ -4,11 +4,55 @@ "family": "session.native-chat-stop", "namedDeltas": [], "values": { + "3e2d28f3728b": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 1000, + "value": true + }, "5cd1d4a9aed5": { "name": "terminal.send#1", "ordinal": 3, "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"terminal.send\",\"params\":{\"terminal\":\"terminal-1\",\"text\":\"\\u001b\",\"client\":{\"id\":\"device-token-1\",\"type\":\"mobile\"}}}" }, + "619592d7bf39": { + "name": "terminal.send#2", + "ordinal": 4, + "args": [ + { + "name": "method", + "value": "terminal.send" + }, + { + "name": "params", + "value": { + "client": { + "id": "device-token-1", + "type": "mobile" + }, + "terminal": "terminal-1", + "text": "\u001b" + } + }, + { + "name": "options", + "value": { + "budgetSpansConnect": true, + "timeoutMs": 14000 + } + } + ], + "settlement": { + "status": "rejected", + "startedAt": 1000, + "settledAt": 1000, + "error": { + "category": "Error", + "message": "Connection lost", + "isRpcDeliveryUnknown": true + } + } + }, "75263c07c807": { "name": "cancel-pending", "ordinal": 1, @@ -59,52 +103,6 @@ "name": "terminal.send#2", "ordinal": 5, "json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"terminal.send\",\"params\":{\"terminal\":\"terminal-1\",\"text\":\"\\u001b\",\"client\":{\"id\":\"device-token-1\",\"type\":\"mobile\"}}}" - }, - "dc4d354f7e02": { - "name": "terminal.send#2", - "ordinal": 4, - "args": [ - { - "name": "method", - "value": "terminal.send" - }, - { - "name": "params", - "value": { - "client": { - "id": "device-token-1", - "type": "mobile" - }, - "terminal": "terminal-1", - "text": "\u001b" - } - }, - { - "name": "options", - "value": { - "budgetSpansConnect": true, - "timeoutMs": 14920 - } - } - ], - "settlement": { - "status": "rejected", - "startedAt": 80, - "settledAt": 120, - "error": { - "category": "Error", - "message": "Connection lost", - "isRpcDeliveryUnknown": true - } - } - }, - "eb79a9b3682a": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "$rpc": "undefined" - } } }, "recording": { @@ -113,10 +111,10 @@ { "id": "unconfirmed", "observation": { - "sender": ["a9ec80c50d30", "dc4d354f7e02"], + "sender": ["a9ec80c50d30", "619592d7bf39"], "payloads": ["5cd1d4a9aed5", "d5972c43f562"], "settlements": { - "stop": "eb79a9b3682a" + "stop": "3e2d28f3728b" }, "state": "9fe5713ca2d1", "effects": ["75263c07c807"] diff --git a/mobile/rpc-foundation/pilot-scenarios.json b/mobile/rpc-foundation/pilot-scenarios.json index 54f8fc33221..ae927039580 100644 --- a/mobile/rpc-foundation/pilot-scenarios.json +++ b/mobile/rpc-foundation/pilot-scenarios.json @@ -17965,7 +17965,7 @@ "checkpoint": "first-accepted" }, { - "advance": 120 + "advance": 1000 }, { "complete": "terminal.send#2", @@ -18023,7 +18023,7 @@ } }, { - "advance": 120 + "advance": 1000 }, { "complete": "terminal.send#2", @@ -18077,7 +18077,7 @@ } }, { - "advance": 120 + "advance": 1000 }, { "complete": "terminal.send#2", diff --git a/mobile/src/session/MobileNativeChatOverlay.test.ts b/mobile/src/session/MobileNativeChatOverlay.test.ts index fb6593edc0e..0483c9a8cfd 100644 --- a/mobile/src/session/MobileNativeChatOverlay.test.ts +++ b/mobile/src/session/MobileNativeChatOverlay.test.ts @@ -5,7 +5,7 @@ import type { NativeChatMessage } from '../../../src/shared/native-chat-types' import { MobileNativeChatOverlay } from './MobileNativeChatOverlay' import type { MobileNativeChatController } from './mobile-native-chat-controller-contract' import type { MobileImageSource } from '../platform/media-picker-contract' -import { findAllByHostType, findByHostType } from '../test-support/react-test-renderer-host-nodes' +import { findByHostType } from '../test-support/react-test-renderer-host-nodes' vi.mock('react-native', () => ({ StyleSheet: { create: (styles: unknown) => styles, absoluteFillObject: {} }, @@ -27,16 +27,9 @@ vi.mock('./native-chat/transcript/MobileNativeChatRowOverlaysHost', () => ({ MobileNativeChatRowOverlaysHost: 'View' })) -function assistantTurn(id: string, text: string): NativeChatMessage { - return { id, role: 'assistant', blocks: [{ type: 'text', text }], timestamp: 0, source: 'hook' } -} - type Tick = { show?: boolean messages?: NativeChatMessage[] - streamingText?: string - streamLive?: boolean - identity?: string stopping?: boolean acceptsImages?: boolean agent?: string @@ -55,15 +48,11 @@ function overlayElement(tick: Tick): ReturnType { nativeChatStructured: tick.structured ?? false, setTabView: tick.setTabView ?? vi.fn(), nativeChatAcceptsImages: tick.acceptsImages ?? true, - nativeChatAgentWorking: tick.streamLive ?? false, nativeChatTurnIndicator: { thinking: false, activityText: null, stopping: tick.stopping ?? false }, - nativeChatStreamingText: tick.streamingText, - nativeChatStreamLive: tick.streamLive ?? false, - nativeChatStreamScopeKey: tick.identity ?? 'tab-a', chatPending: [], chatImagePreviewsByMessageId: {}, chatDraftKey: null, @@ -97,123 +86,14 @@ function overlayElement(tick: Tick): ReturnType { inputLockReason: null, sendErrorMessage: null, onClearSendError: vi.fn(), - sendSurfaceId: tick.identity ?? 'tab-a', + sendSurfaceId: 'tab-a', sessionId: null, getSendCompletionGeneration: () => 0, - keyboardInset: 0 + keyboardInset: 0, + hostName: null }) } -describe('MobileNativeChatOverlay streaming gate', () => { - let renderer: ReactTestRenderer | null = null - - afterEach(() => { - act(() => renderer?.unmount()) - renderer = null - }) - - async function render(tick: Tick): Promise { - await act(async () => { - renderer = create(overlayElement(tick)) - }) - } - - async function update(tick: Tick): Promise { - await act(async () => { - renderer?.update(overlayElement(tick)) - }) - } - - function streaming(): string | null | 'hidden' { - const views = findAllByHostType(renderer!.root, 'ChatView') - return views.length === 0 ? 'hidden' : (views[0].props.streaming as string | null) - } - - it('keeps streaming a reply that repeats the previous turn as a prefix', async () => { - const prior = [assistantTurn('a1', 'The tests pass.')] - await render({ messages: prior }) - expect(streaming()).toBeNull() - - await update({ messages: prior, streamingText: 'The tests', streamLive: true }) - - expect(streaming()).toBe('The tests') - }) - - it('drops the streaming bubble once the reply lands as its own turn', async () => { - const prior = [assistantTurn('a1', 'Done.')] - await render({ messages: prior }) - await update({ messages: prior, streamingText: 'Done.', streamLive: true }) - expect(streaming()).toBe('Done.') - - await update({ - messages: [...prior, assistantTurn('a2', 'Done.')], - streamingText: 'Done.', - streamLive: true - }) - - expect(streaming()).toBeNull() - }) - - it('keeps the bubble across a peek at the terminal view', async () => { - // Keep the baseline above chat-list unmounts so repeated-prefix replies still stream after terminal toggles. - const prior = [assistantTurn('a1', 'Done.')] - await render({ messages: prior }) - await update({ messages: prior, streamingText: 'Done.', streamLive: true }) - expect(streaming()).toBe('Done.') - - await update({ show: false, messages: [], streamLive: true }) - expect(streaming()).toBe('hidden') - // Status streaming returns before the transcript refresh; that round-trip gap must remain visible. - await update({ messages: [], streamLive: true }) - await update({ messages: [], streamingText: 'Done.', streamLive: true }) - await update({ messages: prior, streamingText: 'Done.', streamLive: true }) - - expect(streaming()).toBe('Done.') - }) - - it('keeps the bubble across a peek at the terminal taken between turns', async () => { - // An idle toggle must not replace the baseline with an empty withheld transcript. - const prior = [assistantTurn('a1', 'Done.')] - await render({ messages: prior }) - - await update({ show: false, messages: [] }) - await update({ show: false, messages: [], streamLive: true }) - await update({ messages: [], streamLive: true }) - await update({ messages: [], streamingText: 'Done.', streamLive: true }) - await update({ messages: prior, streamingText: 'Done.', streamLive: true }) - - expect(streaming()).toBe('Done.') - }) - - it('hides a repeated part whose own turn landed during a mid-turn gap', async () => { - // Textless tool ticks must not adopt the just-finished part as history and draw it twice. - const prior = [assistantTurn('a1', 'Done.')] - await render({ messages: prior }) - await update({ messages: prior, streamingText: 'Done.', streamLive: true }) - expect(streaming()).toBe('Done.') - - const landed = [...prior, assistantTurn('a2', 'Done.')] - await update({ messages: landed, streamLive: true }) - await update({ messages: landed, streamingText: 'Done.', streamLive: true }) - - expect(streaming()).toBeNull() - }) - - it("does not carry one chat's baseline into another stream identity", async () => { - const prior = [assistantTurn('a1', 'Shared answer text')] - await render({ messages: prior, identity: 'tab-a' }) - - await update({ - messages: prior, - streamingText: 'Shared answer', - streamLive: true, - identity: 'tab-b' - }) - - expect(streaming()).toBeNull() - }) -}) - describe("MobileNativeChatOverlay while a person's Stop ends the turn", () => { let renderer: ReactTestRenderer | null = null @@ -231,11 +111,11 @@ describe("MobileNativeChatOverlay while a person's Stop ends the turn", () => { } it("holds the queued cards' Steer while the agent works and the host says Stopping", async () => { - expect(await steerHeld({ streamLive: true, stopping: true })).toBe(true) + expect(await steerHeld({ stopping: true })).toBe(true) }) it('leaves Steer to the cards otherwise', async () => { - expect(await steerHeld({ streamLive: true, stopping: false })).toBe(false) + expect(await steerHeld({ stopping: false })).toBe(false) }) }) diff --git a/mobile/src/session/MobileNativeChatOverlay.tsx b/mobile/src/session/MobileNativeChatOverlay.tsx index b0c0122bcf3..411b3131d6e 100644 --- a/mobile/src/session/MobileNativeChatOverlay.tsx +++ b/mobile/src/session/MobileNativeChatOverlay.tsx @@ -5,7 +5,6 @@ import { foldMobileNativeChatMessages } from './mobile-native-chat-render-data' import type { MobileNativeChatImageAttachments } from './native-chat/composer/use-mobile-native-chat-image-attachments' import type { MobileNativeChatController } from './use-mobile-native-chat-controller' import type { MobileNativeChatStructuredComposer } from './native-chat/composer/MobileNativeChatComposer' -import { useMobileNativeChatStreamingBubble } from './native-chat/use-mobile-native-chat-streaming-bubble' import { useMobileNativeChatComposerTray } from './native-chat/composer/use-mobile-native-chat-composer-tray' import { useMobileNativeChatVisualRenderer } from './native-chat/visual/MobileNativeChatVisual' import { MobileNativeChatVisualContext } from './native-chat/visual/mobile-native-chat-visual-context' @@ -17,6 +16,8 @@ import { } from './native-chat/transcript/mobile-native-chat-reply-reveals' import { MobileNativeChatRowOverlaysHost } from './native-chat/transcript/MobileNativeChatRowOverlaysHost' import { nativeChatAskRequiresTerminal } from '../../../src/shared/native-chat-agent-support' +import { tuiAgentDisplayName } from '../../../src/shared/tui-agent-display-names' +import { MobileNativeChatNoticeContext } from './mobile-native-chat-notice-context' type Props = { controller: MobileNativeChatController @@ -37,6 +38,8 @@ type Props = { sessionId: string | null getSendCompletionGeneration: () => number keyboardInset: number + /** The Orca host that runs this chat, named for notices; null when unnamed. */ + hostName: string | null } // Keep the terminal subscription and streaming gate above view toggles; the chat list unmounts below this boundary. @@ -57,7 +60,8 @@ export function MobileNativeChatOverlay({ sendSurfaceId, sessionId, getSendCompletionGeneration, - keyboardInset + keyboardInset, + hostName }: Props): React.JSX.Element | null { const session = controller.nativeChatSession const agent = controller.nativeChatAgent ?? '' @@ -69,12 +73,7 @@ export function MobileNativeChatOverlay({ () => foldMobileNativeChatMessages(fullContent.messages, agent), [fullContent.messages, agent] ) - const streaming = useMobileNativeChatStreamingBubble( - folded, - controller.nativeChatStreamingText, - controller.nativeChatStreamScopeKey, - controller.nativeChatStreamLive - ) + const streaming = controller.nativeChatStreamingText ?? null const queued = controller.nativeChatQueued const composerTray = useMobileNativeChatComposerTray({ queued: { @@ -122,6 +121,10 @@ export function MobileNativeChatOverlay({ : null, [itemSource, fullContent.detail, load] ) + const noticeNames = useMemo( + () => ({ hostName, ...(agent ? { agentName: tuiAgentDisplayName(agent) ?? agent } : {}) }), + [hostName, agent] + ) if (!controller.showNativeChat) { return null } @@ -134,81 +137,86 @@ export function MobileNativeChatOverlay({ - controller.setTabView(activeSessionTabId, 'terminal') - : undefined - } - onCancelAsk={controller.handleNativeChatCancelAsk} - onCancelPrompt={controller.handleNativeChatCancelPrompt} - onCollapseAsk={controller.collapseNativeChatAsk} - onCollapsePrompt={controller.collapseNativeChatPrompt} - collapsedPrompt={controller.nativeChatCollapsedPrompt} - question={controller.nativeChatQuestion} - onAnswerQuestion={controller.handleNativeChatQuestionAnswer} - permission={controller.nativeChatPermission} - onRespondPermission={controller.handleNativeChatRespondPermission} - composerTray={composerTray} - onOpenFile={onOpenFile} - hasMore={session.hasMore} - olderPages={session.olderPages} - onLoadEarlier={session.loadEarlier} - onSend={images.sendNativeChat} - sendSurfaceId={sendSurfaceId} - getSendCompletionGeneration={getSendCompletionGeneration} - getComposerEditGeneration={controller.getChatComposerEditGeneration} - pending={controller.chatPending} - imagePreviewsByMessageId={controller.chatImagePreviewsByMessageId} - draftKey={controller.chatDraftKey} - onComposerTextChange={controller.setChatComposerText} - onAttachImage={ - controller.nativeChatAcceptsImages - ? () => void images.attachImage('library') - : undefined - } - attachments={images.attachments} - onRemoveAttachment={images.removeAttachment} - isAttaching={images.isAttaching} - onMicPress={onMicPress} - micActive={micActive} - dictationMode={dictationMode} - onMicPressIn={onMicPressIn} - onMicPressOut={onMicPressOut} - inputLockReason={inputLockReason} - sendErrorMessage={sendErrorMessage} - onClearSendError={onClearSendError} - filePaths={controller.nativeChatFilePaths} - onNeedFiles={controller.loadNativeChatFiles} - sessionOptions={controller.nativeChatSessionOptions} - structuredComposer={structuredComposer} - keyboardInset={keyboardInset} - /> + + controller.setTabView(activeSessionTabId, 'terminal') + : undefined + } + onCancelAsk={controller.handleNativeChatCancelAsk} + onCancelPrompt={controller.handleNativeChatCancelPrompt} + onCollapseAsk={controller.collapseNativeChatAsk} + onCollapsePrompt={controller.collapseNativeChatPrompt} + collapsedPrompt={controller.nativeChatCollapsedPrompt} + question={controller.nativeChatQuestion} + onAnswerQuestion={controller.handleNativeChatQuestionAnswer} + permission={controller.nativeChatPermission} + onRespondPermission={controller.handleNativeChatRespondPermission} + composerTray={composerTray} + onOpenFile={onOpenFile} + hasMore={session.hasMore} + olderPages={session.olderPages} + onLoadEarlier={session.loadEarlier} + onSend={images.sendNativeChat} + sendSurfaceId={sendSurfaceId} + getSendCompletionGeneration={getSendCompletionGeneration} + getComposerEditGeneration={controller.getChatComposerEditGeneration} + pending={controller.chatPending} + imagePreviewsByMessageId={controller.chatImagePreviewsByMessageId} + draftKey={controller.chatDraftKey} + onComposerTextChange={controller.setChatComposerText} + onAttachImage={ + controller.nativeChatAcceptsImages + ? () => void images.attachImage('library') + : undefined + } + attachments={images.attachments} + onRemoveAttachment={images.removeAttachment} + isAttaching={images.isAttaching} + onMicPress={onMicPress} + micActive={micActive} + dictationMode={dictationMode} + onMicPressIn={onMicPressIn} + onMicPressOut={onMicPressOut} + inputLockReason={inputLockReason} + sendErrorMessage={sendErrorMessage} + onClearSendError={onClearSendError} + filePaths={controller.nativeChatFilePaths} + onNeedFiles={controller.loadNativeChatFiles} + skillDiscovery={controller.nativeChatSkills} + onNeedSkills={controller.loadNativeChatSkills} + sessionOptions={controller.nativeChatSessionOptions} + structuredComposer={structuredComposer} + keyboardInset={keyboardInset} + /> + diff --git a/mobile/src/session/MobileNativeChatView.prompt-presentation.test.ts b/mobile/src/session/MobileNativeChatView.prompt-presentation.test.ts index 42b45be8832..f435e366c81 100644 --- a/mobile/src/session/MobileNativeChatView.prompt-presentation.test.ts +++ b/mobile/src/session/MobileNativeChatView.prompt-presentation.test.ts @@ -76,8 +76,7 @@ vi.mock('./native-chat/input/MobileNativeChatSessionOptionPickers', () => ({ MobileNativeChatSessionOptionPickers: 'SessionOptions' })) vi.mock('./native-chat/composer/MobileNativeChatComposerMenu', () => ({ - MobileNativeChatComposerMenu: 'Suggestions', - composerMenuInsertText: () => '' + MobileNativeChatComposerMenu: 'Suggestions' })) vi.mock('../components/ActionSheetModal', () => ({ ActionSheetModal: 'ActionSheetModal' })) vi.mock('../components/MobileMarkdown', () => ({ MobileMarkdown: 'Markdown' })) diff --git a/mobile/src/session/MobileNativeChatView.streaming-rows.test.ts b/mobile/src/session/MobileNativeChatView.streaming-rows.test.ts index 1b18c4f7f64..e1312189bc6 100644 --- a/mobile/src/session/MobileNativeChatView.streaming-rows.test.ts +++ b/mobile/src/session/MobileNativeChatView.streaming-rows.test.ts @@ -230,7 +230,7 @@ function Harness({ nativeChatSession: { messages, status: 'ready' }, nativeChatAgent: 'claude', nativeChatStructured: true, - nativeChatAgentWorking: true, + nativeChatTurnActive: true, nativeChatItemSource: source, nativeChatVisualSource: source, nativeChatSettledTurns: timing.settledTurns, @@ -264,7 +264,8 @@ function Harness({ sendSurfaceId: source?.sessionId ?? stable.sendSurfaceId, sessionId: source?.sessionId ?? 'session', getSendCompletionGeneration: stable.getSendCompletionGeneration, - keyboardInset: 0 + keyboardInset: 0, + hostName: null }) } diff --git a/mobile/src/session/MobileNativeChatView.tsx b/mobile/src/session/MobileNativeChatView.tsx index 36dd1d73442..d63c0de4e0e 100644 --- a/mobile/src/session/MobileNativeChatView.tsx +++ b/mobile/src/session/MobileNativeChatView.tsx @@ -1,3 +1,4 @@ +import type { NativeChatSkillDiscoverySnapshot } from '../../../src/shared/native-chat/input/native-chat-picker-items' import { useCallback, useMemo } from 'react' import { nativeChatTaskListPredecessors } from '../../../src/shared/native-chat/input/native-chat-task-list-history' import { @@ -40,6 +41,8 @@ import { useSettledMobileNativeChatInputLock } from './native-chat/use-mobile-native-chat-input-lease' import { MobileNativeChatLiveLine } from './native-chat/transcript/MobileNativeChatLiveLine' +import { MobileNativeChatAwaitingInputRow } from './native-chat/transcript/MobileNativeChatAwaitingInputRow' +import type { NativeChatAwaitingInput } from '../../../src/shared/native-chat/native-chat-terminal-turn' import { MobileNativeChatFloatingControls } from './MobileNativeChatFloatingControls' import type { PendingNativeChatImage } from './native-chat/composer/mobile-native-chat-image-attachment' import { @@ -71,8 +74,10 @@ type Props = ComposerTrayProps & { // Permanent read failure replaces the pane because further chat actions cannot succeed. readFailedFinally?: boolean agent?: string | null + /** The turn is live: working, or waiting on the reader. */ agentWorking?: boolean canStop?: boolean + awaitingInput?: NativeChatAwaitingInput | null structuredActivityUi?: boolean turnIndicator?: NativeChatLiveTurnIndicator | null workingStartedAt?: number | null @@ -111,6 +116,8 @@ type Props = ComposerTrayProps & { onClearSendError?: () => void filePaths?: string[] onNeedFiles?: (query: string) => void + skillDiscovery?: NativeChatSkillDiscoverySnapshot + onNeedSkills?: () => void sessionOptions?: MobileNativeChatSessionOptionPickersProps | null structuredComposer?: MobileNativeChatStructuredComposer | null ask?: AskPrompt | null @@ -143,6 +150,7 @@ export function MobileNativeChatView({ agent, agentWorking, canStop = agentWorking, + awaitingInput = null, structuredActivityUi = false, turnIndicator = null, workingStartedAt, @@ -177,6 +185,8 @@ export function MobileNativeChatView({ onClearSendError, filePaths, onNeedFiles, + skillDiscovery, + onNeedSkills, sessionOptions, structuredComposer = null, ask, @@ -249,7 +259,7 @@ export function MobileNativeChatView({ const turns = useMobileNativeChatTurnDisclosure({ messages: data, - enabled: structuredActivityUi, + enabled: true, isWorking: agentWorking === true, workingStartedAt, settledTurns, @@ -280,14 +290,17 @@ export function MobileNativeChatView({ actionablePromptIds }) - const liveStatus = turns.liveLine ? ( - - ) : null + const liveStatus = + awaitingInput === 'unshown' ? ( + + ) : turns.liveLine ? ( + + ) : null const header = mobileNativeChatListHeader(hasMore, olderPages, loadEarlier) // Changing text scale relays out every row, so preserve the page’s reading anchor. @@ -395,11 +408,13 @@ export function MobileNativeChatView({ placeholder={composerPlaceholder} filePaths={filePaths} onNeedFiles={onNeedFiles} + skillDiscovery={skillDiscovery} + onNeedSkills={onNeedSkills} stop={ canStop ? { onStop, - held: agentWorking === true && turnIndicator?.stopRequestInFlight === true + held: turnIndicator?.stopRequestInFlight === true } : null } diff --git a/mobile/src/session/MobileNativeChatView.turn-status.test.ts b/mobile/src/session/MobileNativeChatView.turn-status.test.ts index 5fdcf0a7bf2..e79590664ec 100644 --- a/mobile/src/session/MobileNativeChatView.turn-status.test.ts +++ b/mobile/src/session/MobileNativeChatView.turn-status.test.ts @@ -4,7 +4,6 @@ import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'rea import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { NativeChatMessage } from '../../../src/shared/native-chat-types' import { MobileNativeChatView } from './MobileNativeChatView' -import { styles } from './mobile-native-chat-view-styles' const scrollToEnd = vi.hoisted(() => vi.fn()) const scrollToOffset = vi.hoisted(() => vi.fn()) @@ -315,16 +314,13 @@ describe('MobileNativeChatView', () => { expect(footerProps()).toMatchObject({ activityText: 'Running pnpm test' }) }) - it('gives the bridge lane the same live Working line, with no turn status', async () => { + it('gives the bridge lane the same turn status and live Working line', async () => { const folded = [userTurn('u1', 'go')] await render({ messages: folded, folded, agentWorking: true }) const props = rowProps('u1') expect(props.structuredActivityUi).toBe(false) - expect(props.turnStatus).toBeNull() - expect(props.activeTurnIsWorking).toBe(false) + expect(props.turnStatus).toMatchObject({ workedSeconds: null }) expect(footerProps()).toEqual({ thinking: false, activityText: null, stopping: false }) - const list = renderer!.root.find((node) => String(node.type) === 'FlatList') - expect(list.props.contentContainerStyle).toBe(styles.listContent) }) it('settles the finished turn to a tappable duration', async () => { diff --git a/mobile/src/session/MobileQueuedMessageCardRow.tsx b/mobile/src/session/MobileQueuedMessageCardRow.tsx index 8948e370d8b..c1035f95765 100644 --- a/mobile/src/session/MobileQueuedMessageCardRow.tsx +++ b/mobile/src/session/MobileQueuedMessageCardRow.tsx @@ -10,6 +10,7 @@ import { Trash2 } from 'lucide-react-native' import { colors } from '../theme/mobile-theme' +import { queuedMessageCardSteers } from '../../../src/shared/structured-agent-session-queued-cards' import type { MobileQueuedMessageCard } from './mobile-structured-queued-message-cards' import type { MobileQueuedMessageInlineEditor } from './use-mobile-structured-queued-edit' import { MobileNativeChatQueuedMessageEditor } from './native-chat/input/MobileNativeChatQueuedMessageEditor' @@ -61,7 +62,7 @@ function QueuedMessageCardContents({ }: Omit): React.JSX.Element { const [expanded, setExpanded] = useState(false) const returned = card.state === 'returned' - const steers = !returned && !card.paused && !card.command + const steers = queuedMessageCardSteers(card) return ( @@ -78,7 +79,7 @@ function QueuedMessageCardContents({ ) : null} setExpanded(!expanded)} /> @@ -161,7 +162,7 @@ function QueuedMessageCardContents({ accessibilityHint="Shows less of the message" onPress={() => setExpanded(false)} > - {card.text} + {card.line} ) : null} {returned && card.caption ? ( diff --git a/mobile/src/session/MobileSessionActiveContent.tsx b/mobile/src/session/MobileSessionActiveContent.tsx index 18cbd8628c3..1250b19f93c 100644 --- a/mobile/src/session/MobileSessionActiveContent.tsx +++ b/mobile/src/session/MobileSessionActiveContent.tsx @@ -8,6 +8,8 @@ import { colors } from '../theme/mobile-theme' import { styles } from './mobile-session-styles' import type { MobileSessionController } from './use-mobile-session-controller' import { useWorkspaceExecutionHost } from '../navigation/workspace-execution-host' +import { useHostProfile } from '../transport/use-host-profile' +import { useHostDisplay } from '../transport/use-host-display' import { FileReader } from './MobileSessionFileReader' import { MarkdownReader } from './MobileSessionMarkdownReader' @@ -90,6 +92,8 @@ export function MobileSessionActiveContent({ toastAnimatedStyle, createTabBusy } = controller + const hostProfile = useHostProfile(hostId) + const hostTitle = useHostDisplay(hostProfile).title const content = showLoadingState ? ( @@ -255,6 +259,8 @@ export function MobileSessionActiveContent({ sessionId={resolveMobileNativeChatSessionId(controller.activeSessionTab)} getSendCompletionGeneration={controller.getSendCompletionGeneration} keyboardInset={keyboardLift} + // A workspace routed to another server stops with that server, whose name the phone lacks. + hostName={executionHost === undefined && hostProfile ? hostTitle : null} /> {toastMessage && ( diff --git a/mobile/src/session/__mocks__/mobile-prompt-controller.ts b/mobile/src/session/__mocks__/mobile-prompt-controller.ts index a26bbf00f0c..5978514a199 100644 --- a/mobile/src/session/__mocks__/mobile-prompt-controller.ts +++ b/mobile/src/session/__mocks__/mobile-prompt-controller.ts @@ -77,9 +77,6 @@ vi.mock('../native-chat/composer/use-mobile-native-chat-composer-tray', () => ({ NO_COMPOSER_TRAY: {}, useMobileNativeChatComposerTray: () => ({}) })) -vi.mock('../native-chat/use-mobile-native-chat-streaming-bubble', () => ({ - useMobileNativeChatStreamingBubble: () => null -})) let tree: ReactTestRenderer | null = null let controller: MobileNativeChatController @@ -166,7 +163,8 @@ function Harness(props: HarnessProps) { sendSurfaceId: 'surface', sessionId: null, getSendCompletionGeneration: () => 0, - keyboardInset: 0 + keyboardInset: 0, + hostName: null }) } export async function render(props: HarnessProps = lastProps ?? {}) { diff --git a/mobile/src/session/__mocks__/mobile-prompt-render-mocks.ts b/mobile/src/session/__mocks__/mobile-prompt-render-mocks.ts index 03e0f793774..97da83855b9 100644 --- a/mobile/src/session/__mocks__/mobile-prompt-render-mocks.ts +++ b/mobile/src/session/__mocks__/mobile-prompt-render-mocks.ts @@ -73,8 +73,7 @@ vi.mock('../native-chat/input/MobileNativeChatSessionOptionPickers', () => ({ MobileNativeChatSessionOptionPickers: 'SessionOptions' })) vi.mock('../native-chat/composer/MobileNativeChatComposerMenu', () => ({ - MobileNativeChatComposerMenu: 'Suggestions', - composerMenuInsertText: () => '' + MobileNativeChatComposerMenu: 'Suggestions' })) vi.mock('../../components/ActionSheetModal', () => ({ ActionSheetModal: 'ActionSheetModal' })) vi.mock('../../components/MobileMarkdown', () => ({ MobileMarkdown: 'Markdown' })) diff --git a/mobile/src/session/mobile-native-chat-controller-contract.ts b/mobile/src/session/mobile-native-chat-controller-contract.ts index f5e9b1dceac..c133489ba35 100644 --- a/mobile/src/session/mobile-native-chat-controller-contract.ts +++ b/mobile/src/session/mobile-native-chat-controller-contract.ts @@ -1,3 +1,4 @@ +import type { NativeChatSkillDiscoverySnapshot } from '../../../src/shared/native-chat/input/native-chat-picker-items' import type { StructuredMobileSendAttachment } from './use-mobile-structured-send-with-outcome' import type { MobileSessionView } from '../storage/session-view-preferences' import type { Dispatch, MutableRefObject, SetStateAction } from 'react' @@ -10,6 +11,7 @@ import type { NativeChatSettledTurns } from '../../../src/shared/native-chat-turn-status' import type { NativeChatTurnJournal } from '../../../src/shared/native-chat-turn-membership' +import type { NativeChatAwaitingInput } from '../../../src/shared/native-chat/native-chat-terminal-turn' import type { NativeChatResolvedPrompt } from '../../../src/shared/native-chat-resolution-receipt' import type { MobileNativeChatSendOutcome } from './native-chat/mobile-native-chat-send' import type { MobileNativeChatCommandRefusalCauses } from './native-chat/use-mobile-native-chat-send-error' @@ -38,7 +40,9 @@ export type MobileNativeChatController = { nativeChatStructured: boolean nativeChatVisualSource: MobileNativeChatVisualSource | null nativeChatItemSource: MobileNativeChatVisualSource | null - nativeChatAgentWorking: boolean + /** Working or waiting on the reader: keeps the turn clock running. */ + nativeChatTurnActive: boolean + nativeChatAwaitingInput: NativeChatAwaitingInput | null nativeChatTurnIndicator: NativeChatLiveTurnIndicator | null nativeChatWorkingStartedAt: number | null nativeChatSettledTurns: NativeChatSettledTurns | null @@ -48,9 +52,6 @@ export type MobileNativeChatController = { nativeChatCommandRefusalCauses: MobileNativeChatCommandRefusalCauses nativeChatCanStop: boolean nativeChatStreamingText?: string - // Keep stream liveness independent of whether chat is visible. - nativeChatStreamLive: boolean - nativeChatStreamScopeKey: string nativeChatPermission: MobileChatPermission | null // A new prompt occurrence resets local card submission state only. nativeChatPromptKey: string | null @@ -79,6 +80,8 @@ export type MobileNativeChatController = { nativeChatBackgroundTasks: MobileStructuredBackgroundTasks | null nativeChatFilePaths: string[] loadNativeChatFiles: (query: string) => void + nativeChatSkills: NativeChatSkillDiscoverySnapshot + loadNativeChatSkills: () => void /** A grouped structured answer names the prompt it was rendered for. */ handleNativeChatQuestionAnswer?: ( text: string, diff --git a/mobile/src/session/mobile-native-chat-merged-snapshot-parity-hooks.test.tsx b/mobile/src/session/mobile-native-chat-merged-snapshot-parity-hooks.test.tsx index 405b024e1ae..afe9e2e45e6 100644 --- a/mobile/src/session/mobile-native-chat-merged-snapshot-parity-hooks.test.tsx +++ b/mobile/src/session/mobile-native-chat-merged-snapshot-parity-hooks.test.tsx @@ -212,20 +212,18 @@ async function mountPhone(): Promise { transcriptSettled: session.session.status === 'ready' }) latest = drafts - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: every member the list's rows, bars and footer read comes from the two hooks above, as the controller passes them; showNativeChat, nativeChatAgent, nativeChatCanStop, nativeChatStructured and the scope key are constants; the overlay's other members stay undefined: the composer's and Stop's handlers, files and options never reach the list, the streaming text is undefined on a structured tab too, and the prompts, which would hide the live status, are absent in this flow. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: every member the list's rows, bars and footer read comes from the two hooks above, as the controller passes them; showNativeChat, nativeChatAgent, nativeChatCanStop, nativeChatStructured are constants; the overlay's other members stay undefined: the composer's and Stop's handlers, files and options never reach the list, the streaming text is undefined on a structured tab too, and the prompts, which would hide the live status, are absent in this flow. const controller = { showNativeChat: true, nativeChatSession: session.session, nativeChatAgent: 'codex', - nativeChatAgentWorking: session.isWorking, nativeChatCanStop: false, nativeChatStructured: true, nativeChatTurnIndicator: session.turnIndicator, nativeChatWorkingStartedAt: session.workingStartedAt, nativeChatSettledTurns: session.settledTurns, nativeChatTurnJournal: session.turnJournal, - nativeChatStreamLive: session.isWorking, - nativeChatStreamScopeKey: 'host\0workspace\0tab', + nativeChatTurnActive: session.isWorking, chatPending: drafts.pending, chatImagePreviewsByMessageId: drafts.imagePreviewsByMessageId, chatDraftKey: drafts.draftKey, @@ -248,7 +246,8 @@ async function mountPhone(): Promise { sendSurfaceId: 'host\0workspace\0tab', sessionId: null, getSendCompletionGeneration: () => 0, - keyboardInset: 0 + keyboardInset: 0, + hostName: null }) } let renderer: ReactTestRenderer | null = null diff --git a/mobile/src/session/mobile-native-chat-notice-context.ts b/mobile/src/session/mobile-native-chat-notice-context.ts new file mode 100644 index 00000000000..6d0b0fbc88d --- /dev/null +++ b/mobile/src/session/mobile-native-chat-notice-context.ts @@ -0,0 +1,8 @@ +import { createContext } from 'react' + +/** Names a notice row needs: the Orca host behind the chat (null when unnamed) and the agent. */ +export type MobileNativeChatNoticeNames = { hostName: string | null; agentName?: string } + +export const MobileNativeChatNoticeContext = createContext({ + hostName: null +}) diff --git a/mobile/src/session/mobile-session-read-operations.ts b/mobile/src/session/mobile-session-read-operations.ts index 68cbad0570b..28b17ad7d40 100644 --- a/mobile/src/session/mobile-session-read-operations.ts +++ b/mobile/src/session/mobile-session-read-operations.ts @@ -8,6 +8,7 @@ import { sessionForwardedReplySchema, sessionTerminalInventorySchema, sessionWorktreeRecordSchema, + skillDiscoveryReplySchema, terminalQuickCommandsSchema, workspaceFilePathsSchema } from './session-read-reply-schema' @@ -163,3 +164,14 @@ export const markdownTabRead = bindDeferredRpcOperation( read: rpcResultVariant('markdown-tab-doc', markdownTabDocumentSchema) }) ) + +// Mobile names the workspace and lets the host resolve and scan it; see SKILL_DISCOVER_WORKSPACE_CAPABILITY. +export const nativeChatSkillDiscoveryRead = bindDeferredRpcOperation( + defineRpcOperation({ + name: 'skills.discover-or-skip', + method: 'skills.discover', + acceptance: 'success-result-or-skip', + barrier: 'after-caller-barrier', + read: rpcResultVariant('skill-discovery', skillDiscoveryReplySchema) + }) +) diff --git a/mobile/src/session/mobile-session-terminal-frame-layout.test.tsx b/mobile/src/session/mobile-session-terminal-frame-layout.test.tsx index 1f4dd8a536f..010275dcb0a 100644 --- a/mobile/src/session/mobile-session-terminal-frame-layout.test.tsx +++ b/mobile/src/session/mobile-session-terminal-frame-layout.test.tsx @@ -33,6 +33,7 @@ vi.mock('../storage/preferences', () => ({ saveTerminalTextScale: () => Promise. vi.mock('../browser/MobileBrowserPane', () => ({ MobileBrowserPane: () => null })) vi.mock('./TerminalPaneView', () => ({ TerminalPaneView: () => null })) vi.mock('./MobileNativeChatOverlay', () => ({ MobileNativeChatOverlay: () => null })) +vi.mock('../transport/use-host-profile', () => ({ useHostProfile: () => null })) vi.mock('./MobileSessionFileReader', () => ({ FileReader: () => null })) vi.mock('./MobileSessionMarkdownReader', () => ({ MarkdownReader: () => null })) vi.mock('./mobile-session-styles', () => ({ styles: {} })) diff --git a/mobile/src/session/mobile-structured-agent-session-cancel.ts b/mobile/src/session/mobile-structured-agent-session-cancel.ts index 8c41da9ec66..78e4b77f4c8 100644 --- a/mobile/src/session/mobile-structured-agent-session-cancel.ts +++ b/mobile/src/session/mobile-structured-agent-session-cancel.ts @@ -1,6 +1,10 @@ import type { AgentSessionCancelResult } from '../../../src/shared/agent-session-wire' import type { StructuredAgentSessionState } from '../../../src/shared/structured-agent-session-reducer' import { runningStructuredAgentSessionTurnId } from '../../../src/shared/structured-agent-session-live-turn' +import { + joinStructuredAgentSessionStop, + structuredAgentSessionStopIsJoinable +} from '../../../src/shared/structured-agent-session-stop-join' import type { RpcClient } from '../transport/rpc-client' import { requestStructuredAgentSessionMutation, @@ -47,23 +51,18 @@ export async function requestMobileStructuredAgentSessionCancel(args: { // Mint an id for each Stop so the ledger cannot replay an earlier press instead of stopping work. const fence = current.fence const stop = () => sendStop({ client, sessionId, fence, fields, onSendError }) - if (args.hostAnswersRepeatedStops === true) { + if ( + args.hostAnswersRepeatedStops === true || + !structuredAgentSessionStopIsJoinable('agentSession.cancel', fields) + ) { return stop() } // Legacy hosts need client-side joining until they support quiet repeated Stop. - const key = `${sessionId}:agentSession.cancel:${JSON.stringify(fields)}` - const joined = inFlight.get(key) - if (joined) { - return joined - } - const stopping = stop() - inFlight.set(key, stopping) - void stopping.finally(() => { - if (inFlight.get(key) === stopping) { - inFlight.delete(key) - } - }) - return stopping + return joinStructuredAgentSessionStop( + inFlight, + `${sessionId}:agentSession.cancel:${JSON.stringify(fields)}`, + stop + ) } async function sendStop(input: { diff --git a/mobile/src/session/mobile-structured-agent-session-contract.ts b/mobile/src/session/mobile-structured-agent-session-contract.ts index f7a71f7047d..2be70f89bfa 100644 --- a/mobile/src/session/mobile-structured-agent-session-contract.ts +++ b/mobile/src/session/mobile-structured-agent-session-contract.ts @@ -11,6 +11,7 @@ import type { NativeChatResolvedPrompt } from '../../../src/shared/native-chat-r import type { useMobileStructuredAgentOptions } from './use-mobile-structured-agent-options' import type { useMobileStructuredAgentTurnTiming } from './use-mobile-structured-agent-turn-timing' import type { StructuredMobileSendAttachment } from './use-mobile-structured-send-with-outcome' +import type { AgentJournalSkillRef } from '../../../src/shared/agent-session-journal-types' import type { MobileNativeChatCommandRefusalCauses, MobileNativeChatSendErrorReporter @@ -22,13 +23,16 @@ export type StructuredMobileSession = ReturnType & { session: MobileNativeChatSession isWorking: boolean + /** Desktop's Stop rule: a running turn, or conversation Stop while working or sending. */ + canStop: boolean turnId: string | null turnIndicator: NativeChatLiveTurnIndicator sendWithOutcome: ( text: string, images?: string[], deadline?: number, - attachments?: readonly StructuredMobileSendAttachment[] + attachments?: readonly StructuredMobileSendAttachment[], + skills?: readonly AgentJournalSkillRef[] ) => Promise cancel: () => void receipts: ReadonlyMap diff --git a/mobile/src/session/mobile-structured-agent-session-host-support.ts b/mobile/src/session/mobile-structured-agent-session-host-support.ts index d689e2e6ebb..97e90b291e6 100644 --- a/mobile/src/session/mobile-structured-agent-session-host-support.ts +++ b/mobile/src/session/mobile-structured-agent-session-host-support.ts @@ -12,12 +12,16 @@ import { AGENT_SESSION_STATUS_FEED_RUNTIME_CAPABILITY, AGENT_SESSION_REPEATED_STOP_RUNTIME_CAPABILITY } from '../../../src/shared/protocol-version' -import { AGENT_SESSION_SEND_OPTIONS_RUNTIME_CAPABILITY } from '../../../src/shared/agent-session-create-capabilities' +import { + AGENT_SESSION_SEND_OPTIONS_RUNTIME_CAPABILITY, + AGENT_SESSION_SEND_SKILLS_RUNTIME_CAPABILITY +} from '../../../src/shared/agent-session-create-capabilities' import { AGENT_SESSION_PERMISSION_MODES_RUNTIME_CAPABILITY, AGENT_SESSION_PERMISSION_OWN_RUNTIME_CAPABILITY } from '../../../src/shared/agent-session-permission-modes-capability' import { AGENT_SESSION_PHONE_ATTACHMENTS_RUNTIME_CAPABILITY } from '../../../src/shared/agent-session-runtime-capabilities' +import { SKILL_DISCOVER_WORKSPACE_CAPABILITY } from '../../../src/shared/skill-install-capability' export type StructuredAgentSessionHostSupport = { coherentModelCatalog?: boolean @@ -36,6 +40,10 @@ export type StructuredAgentSessionHostSupport = { statusFeed: boolean quietRepeatedStop: boolean sendOptions: boolean + /** Older hosts refuse a send body carrying picked skill paths. */ + sendSkills?: boolean + /** Older hosts need the workspace folder to scan skills, which the phone does not know. */ + discoverWorkspaceSkills?: boolean /** A Stop naming no turn stops what the chat has in flight; an older host needs the turn. */ conversationStop: boolean /** The host applies each chat's permission mode; 1.4.225–1.4.226 do without advertising it. */ @@ -64,6 +72,8 @@ export function structuredAgentSessionHostSupport( statusFeed: capabilities.includes(AGENT_SESSION_STATUS_FEED_RUNTIME_CAPABILITY), quietRepeatedStop: capabilities.includes(AGENT_SESSION_REPEATED_STOP_RUNTIME_CAPABILITY), sendOptions: capabilities.includes(AGENT_SESSION_SEND_OPTIONS_RUNTIME_CAPABILITY), + sendSkills: capabilities.includes(AGENT_SESSION_SEND_SKILLS_RUNTIME_CAPABILITY), + discoverWorkspaceSkills: capabilities.includes(SKILL_DISCOVER_WORKSPACE_CAPABILITY), conversationStop: capabilities.includes(AGENT_SESSION_CONVERSATION_STOP_RUNTIME_CAPABILITY), permissionOwn: capabilities.includes(AGENT_SESSION_PERMISSION_OWN_RUNTIME_CAPABILITY), permissionModes: capabilities.includes(AGENT_SESSION_PERMISSION_MODES_RUNTIME_CAPABILITY), diff --git a/mobile/src/session/mobile-structured-agent-session-send.ts b/mobile/src/session/mobile-structured-agent-session-send.ts index b4b3cd089df..c52e7310497 100644 --- a/mobile/src/session/mobile-structured-agent-session-send.ts +++ b/mobile/src/session/mobile-structured-agent-session-send.ts @@ -1,5 +1,8 @@ import type { AgentSessionSendResult } from '../../../src/shared/agent-session-wire' -import type { AgentJournalCursor } from '../../../src/shared/agent-session-journal-types' +import type { + AgentJournalCursor, + AgentJournalSkillRef +} from '../../../src/shared/agent-session-journal-types' import { structuredAgentSessionSendBody, type StructuredAgentSessionAttachment @@ -24,6 +27,8 @@ export async function sendMobileStructuredAgentSessionMessage(input: { expectedRuntimeFence: number text: string attachments: readonly StructuredAgentSessionAttachment[] + /** Sent only when the host advertises `agent-session.send-skills.v1`. */ + skills?: readonly AgentJournalSkillRef[] /** Sent only when the host advertises `agent-session.queued-messages.v1`. */ delivery?: 'queue-if-active' /** Sent only when the host advertises `agent-session.send-options.v1`. */ @@ -38,7 +43,7 @@ export async function sendMobileStructuredAgentSessionMessage(input: { input.onError('Message not sent') return 'rejected' } - const body = structuredAgentSessionSendBody(input.text, input.attachments) + const body = structuredAgentSessionSendBody(input.text, input.attachments, input.skills) // The host refuses it unexplained, so say why here and keep the draft. if (structuredAgentSessionBlocksTooLarge(body.blocks)) { input.onError(agentSessionWriteNoticeEnglish(AGENT_SESSION_MESSAGE_TOO_LARGE_PARTS)) diff --git a/mobile/src/session/mobile-structured-composer-command.test.ts b/mobile/src/session/mobile-structured-composer-command.test.ts index 76fffa44af6..42c2f940ca4 100644 --- a/mobile/src/session/mobile-structured-composer-command.test.ts +++ b/mobile/src/session/mobile-structured-composer-command.test.ts @@ -9,7 +9,19 @@ const asyncStorage = vi.hoisted(() => ({ vi.mock('@react-native-async-storage/async-storage', () => ({ default: asyncStorage })) -import { dispatchMobileStructuredCommand } from './mobile-structured-composer-command' +import { + dispatchMobileStructuredCommand, + type MobileStructuredCommandHolds +} from './mobile-structured-composer-command' + +const IDLE: MobileStructuredCommandHolds = { + working: false, + prompt: false, + background: false, + backgroundTasksRunning: false, + sending: false +} +const holding = (shown: Partial) => () => ({ ...IDLE, ...shown }) function setup() { const sendRequest = vi.fn< @@ -32,7 +44,8 @@ function setup() { setOption: vi.fn(async () => true), conversationCommands: ['clear', 'compact'] }, - busy: () => null, + holds: () => IDLE, + startFailures: () => [], waitsInLine: () => false, onError: vi.fn(), timeoutMs: 15000 @@ -101,7 +114,7 @@ describe('mobile structured conversation commands', () => { input.text = '/compact instructions' } if (reason === 'pending work') { - input.busy = () => 'working' + input.holds = holding({ working: true }) } expect(await dispatchMobileStructuredCommand(input)).toBe('rejected') expect(sendRequest).not.toHaveBeenCalled() @@ -143,7 +156,7 @@ describe('mobile structured conversation commands', () => { } } }) - input.busy = () => 'working' + input.holds = holding({ working: true }) input.waitsInLine = (command) => command === 'compact' expect(await dispatchMobileStructuredCommand(input)).toBe('accepted') const fields = requestFields(sendRequest.mock.calls[0]) @@ -154,19 +167,19 @@ describe('mobile structured conversation commands', () => { }) it('a /clear while the agent works says so in plain words', async () => { const { input, sendRequest } = setup() - input.busy = () => 'working' + input.holds = holding({ working: true }) expect(await dispatchMobileStructuredCommand({ ...input, text: '/clear' })).toBe('rejected') expect(input.onError).toHaveBeenLastCalledWith( "The agent is still working. Run /clear when it's done.", { refusedWhile: 'working' } ) - input.busy = () => 'prompt' + input.holds = holding({ prompt: true }) expect(await dispatchMobileStructuredCommand({ ...input, text: '/clear' })).toBe('rejected') expect(input.onError).toHaveBeenLastCalledWith( "Answer the agent's question or approval, then run /clear.", { refusedWhile: 'prompt' } ) - input.busy = () => 'working' + input.holds = holding({ working: true }) expect(await dispatchMobileStructuredCommand(input)).toBe('rejected') expect(input.onError).toHaveBeenLastCalledWith( "The agent is still working. Run /compact when it's done.", @@ -193,20 +206,40 @@ describe('mobile structured conversation commands', () => { } sendRequest.mockResolvedValue(refused) input.waitsInLine = () => true - input.busy = () => 'working' + input.holds = holding({ working: true }) expect(await dispatchMobileStructuredCommand(input)).toBe('rejected') expect(input.onError).toHaveBeenLastCalledWith( "The agent is still working. Run /compact when it's done.", { refusedWhile: 'working' } ) // Keep a newer host’s unreadable cause as failure rather than claiming which work blocked it. - input.busy = () => null + input.holds = () => IDLE expect(await dispatchMobileStructuredCommand(input)).toBe('rejected') expect(input.onError).toHaveBeenLastCalledWith( "The agent is still working. Run /compact when it's done.", undefined ) }) + it('holds for background tasks or a message still sending, and waits silently behind one in line', async () => { + const { input, sendRequest } = setup() + input.holds = holding({ background: true, backgroundTasksRunning: true }) + expect(await dispatchMobileStructuredCommand(input)).toBe('rejected') + expect(input.onError).toHaveBeenLastCalledWith( + 'Wait for pending work and messages to finish before using this command.', + { refusedWhile: 'background' } + ) + input.holds = holding({ sending: true }) + expect(await dispatchMobileStructuredCommand(input)).toBe('rejected') + expect(input.onError).toHaveBeenLastCalledWith( + 'Your earlier message is still being sent. Run /compact once it has gone.', + { refusedWhile: 'sending' } + ) + input.waitsInLine = () => true + vi.mocked(input.onError).mockClear() + expect(await dispatchMobileStructuredCommand(input)).toBe('rejected') + expect(input.onError).not.toHaveBeenCalled() + expect(sendRequest).not.toHaveBeenCalled() + }) it('keeps ordinary messages on the existing send path', async () => { const { input, sendRequest } = setup() expect(await dispatchMobileStructuredCommand({ ...input, text: 'hello' })).toBeNull() diff --git a/mobile/src/session/mobile-structured-composer-command.ts b/mobile/src/session/mobile-structured-composer-command.ts index c90d091dcb9..db0dd3b5d9e 100644 --- a/mobile/src/session/mobile-structured-composer-command.ts +++ b/mobile/src/session/mobile-structured-composer-command.ts @@ -2,29 +2,30 @@ import type { AgentSessionConversationCommand, AgentSessionConversationCommandResult } from '../../../src/shared/agent-session/agent-session-conversation-command' +import type { AgentSessionFailureFact } from '../../../src/shared/agent-session-failure' import { dispatchStructuredAgentSessionComposerCommand, isStructuredAgentSessionComposerCommand, + type StructuredAgentSessionCommandRefusalCause, type StructuredAgentSessionComposerOptions } from '../../../src/shared/structured-agent-session-composer' -import { structuredAgentSessionCommandHostRefusalCause } from '../../../src/shared/structured-agent-session-command-refusal-cause' +import { + conversationCommandReply, + heldConversationCommandParts, + structuredConversationCommandHold +} from '../../../src/shared/structured-conversation-command-hold' import { agentSessionWriteNoticeEnglish } from '../../../src/shared/agent-session-refusal-notice' import type { RpcClient } from '../transport/rpc-client' import type { MobileNativeChatSendOutcome } from './native-chat/mobile-native-chat-send' import { requestStructuredAgentSessionMutation } from './mobile-structured-agent-session-rpc' import type { MobileNativeChatSendErrorReporter } from './native-chat/use-mobile-native-chat-send-error' -function busyCommandText( - command: AgentSessionConversationCommand, - busy: 'working' | 'prompt' -): string { - const clear = command === 'clear' - return agentSessionWriteNoticeEnglish( - busy === 'prompt' - ? [clear ? 'clearAfterAnswer' : 'compactAfterAnswer'] - : ['agentStillWorking', clear ? 'runClearWhenDone' : 'runCompactWhenDone'] - ) -} +/** What the phone shows at keypress that holds a command; `background` also counts the strip mid-turn. */ +export type MobileStructuredCommandHolds = Readonly< + Record, boolean> & { + backgroundTasksRunning: boolean + } +> export async function dispatchMobileStructuredCommand(input: { text: string @@ -34,16 +35,16 @@ export async function dispatchMobileStructuredCommand(input: { fence: number pending: { current: boolean } controller: StructuredAgentSessionComposerOptions - busy: () => 'working' | 'prompt' | null + agentName?: string + holds: () => MobileStructuredCommandHolds + /** What the chat's loaded start-failure rows state, read when the reply lands. */ + startFailures: () => readonly AgentSessionFailureFact[] // Queued commands are held by the host; client work must not refuse them first. waitsInLine: (command: AgentSessionConversationCommand) => boolean /** `refusedWhile`: what the phone showed the refused command waiting on. */ onError: MobileNativeChatSendErrorReporter timeoutMs: number }): Promise { - if (input.pending.current) { - return 'rejected' - } if ( !isStructuredAgentSessionComposerCommand( input.text, @@ -51,7 +52,7 @@ export async function dispatchMobileStructuredCommand(input: { input.controller.sessionCommands ) ) { - return null + return input.pending.current ? 'rejected' : null } if (input.hasAttachments) { input.onError('Remove attachments before using a chat-session command.') @@ -61,11 +62,31 @@ export async function dispatchMobileStructuredCommand(input: { const outcome = await dispatchStructuredAgentSessionComposerCommand(input.text, { ...input.controller, runConversationCommand: async (command) => { - const shown = input.busy() + const held = (hold: Parameters[1]) => + agentSessionWriteNoticeEnglish(heldConversationCommandParts(command, hold)) + // A command on its way is the agent's work in flight. + if (input.pending.current) { + return { accepted: false, error: held('working') } + } + const shown = input.holds() const waitsInLine = input.waitsInLine(command) - const busy = waitsInLine ? null : shown - if (busy) { - return { accepted: false, error: busyCommandText(command, busy), refusedWhile: busy } + const hold = structuredConversationCommandHold({ + waitsInLine, + agentWorking: shown.working, + promptPending: shown.prompt, + backgroundTasksRunning: shown.backgroundTasksRunning, + sendPending: shown.sending + }) + // The message ahead is still sending; nothing is armed. + if (hold === 'ahead') { + return { accepted: false, error: null } + } + if (hold !== null) { + return { + accepted: false, + error: held(hold), + ...(shown[hold] ? { refusedWhile: hold } : {}) + } } input.pending.current = true try { @@ -92,13 +113,19 @@ export async function dispatchMobileStructuredCommand(input: { if (result.status !== 'accepted') { return { accepted: false, error: result.message } } + const reply = conversationCommandReply(result.value, { + agentName: input.agentName, + startFailures: input.startFailures() + }) // A refusal names its cause only when the phone showed it, as on desktop. - const cause = structuredAgentSessionCommandHostRefusalCause(result.value) return { - accepted: !result.value.error, - error: result.value.error ?? null, - ...(result.value.error && cause !== undefined && cause === shown - ? { refusedWhile: cause } + accepted: reply.accepted, + error: reply.error, + ...(reply.error && + reply.cause !== undefined && + reply.cause !== 'retry' && + shown[reply.cause] + ? { refusedWhile: reply.cause } : {}) } } finally { diff --git a/mobile/src/session/mobile-structured-queued-message-cards.test.ts b/mobile/src/session/mobile-structured-queued-message-cards.test.ts index 013dda3a817..260b329ca6a 100644 --- a/mobile/src/session/mobile-structured-queued-message-cards.test.ts +++ b/mobile/src/session/mobile-structured-queued-message-cards.test.ts @@ -1,13 +1,9 @@ import { describe, expect, it } from 'vitest' import { agentSessionFailureFact } from '../../../src/shared/agent-session-failure' import { agentSessionFailureWords } from '../../../src/shared/agent-session-failure-words' -import { DISPATCH_REJECTED_HOST_RESTARTED } from '../../../src/shared/structured-agent-session-dispatch-rejection' import { QUEUED_MESSAGE_PAUSED_SEND_FAILED } from '../../../src/shared/agent-session-wire' import type { AgentSessionQueuedMessage } from '../../../src/shared/agent-session-wire' -import { - mobileQueueHasResumableCard, - mobileQueuedMessageCards -} from './mobile-structured-queued-message-cards' +import { mobileQueuedMessageCards } from './mobile-structured-queued-message-cards' function returnedAs(fact: Parameters[0]) { const { reason, rejection } = agentSessionFailureWords(fact, { surface: 'rejection' }) @@ -28,77 +24,6 @@ function draft(overrides: Partial & { messageId: stri } describe('mobileQueuedMessageCards', () => { - it.each([ - ['Claude', 'claude auth login'], - ['Codex', 'codex login'], - ['Grok', 'grok login'], - ['OpenCode', 'opencode auth login'], - ['Pi', '/login'], - ['OMP', 'Sign in to OMP.'] - ])('keeps %s identity and its sign-in action on returned cards', (agentName, guidance) => { - const [card] = mobileQueuedMessageCards( - [draft({ messageId: 'auth', ...returnedAs(agentSessionFailureFact('notSignedIn')) })], - [], - { agentName } - ) - expect(card?.caption).toContain(agentName) - expect(card?.caption).toContain(guidance) - expect(card?.caption).not.toContain('send your message again') - expect(card?.needsAttention).toBe(true) - }) - - it.each(['Claude', 'Codex'])( - 'keeps %s managed guidance unless a row already explains it', - (agentName) => { - const fact = agentSessionFailureFact('notSignedIn', { account: 'managed' }) - const drafts = [draft({ messageId: 'auth', ...returnedAs(fact) })] - const [card] = mobileQueuedMessageCards(drafts, [], { agentName }) - expect(card?.caption).toBe( - `This ${agentName} account isn't signed in. Sign in again in ${agentName} Accounts settings.` - ) - const [stated] = mobileQueuedMessageCards(drafts, [], { - agentName, - statedFailures: [fact] - }) - expect(stated?.caption).toBe('Your message was not sent.') - } - ) - - it('marks a /compact card as a command, its text as typed', () => { - const [card] = mobileQueuedMessageCards( - [ - draft({ - messageId: 'c', - body: { - kind: 'message', - role: 'user', - blocks: [{ type: 'text', text: '/compact' }], - command: { name: 'compact' } - } - }) - ], - [] - ) - expect(card).toMatchObject({ text: '/compact', command: true, caption: null }) - expect(card).not.toHaveProperty('waitsForAgent') - expect(mobileQueuedMessageCards([draft({ messageId: 'a' })], [])[0]).not.toHaveProperty( - 'command' - ) - const compact = draft({ - messageId: 'c', - body: { - kind: 'message', - role: 'user', - blocks: [{ type: 'text', text: '/compact' }], - command: { name: 'compact' } - } - }) - expect(mobileQueuedMessageCards([compact], [], { agentWorking: true })[0]).toMatchObject({ - command: true, - waitsForAgent: true - }) - }) - it("a send-failed command card's caption names Send only when Send is there", () => { const failed = draft({ messageId: 'c', @@ -118,25 +43,6 @@ describe('mobileQueuedMessageCards', () => { "Couldn't send — tap Send to retry" ) }) - - it('renders nothing without a published list', () => { - expect(mobileQueuedMessageCards(null, [])).toEqual([]) - expect(mobileQueuedMessageCards([], [])).toEqual([]) - }) - - it('captions nothing on a plain waiting draft, as the desktop row', () => { - const [card] = mobileQueuedMessageCards([draft({ messageId: 'a' })], []) - expect(card).toEqual({ - messageId: 'a', - text: 'body of a', - state: 'waiting', - paused: false, - needsAttention: false, - caption: null, - attribution: null - }) - }) - it("keeps a card's own failed send beside a plainly queued one", () => { const cards = mobileQueuedMessageCards( [ @@ -146,34 +52,11 @@ describe('mobileQueuedMessageCards', () => { [] ) expect(cards.map((card) => card.caption)).toEqual(["Couldn't send — tap Send to retry", null]) - expect(cards[0]?.paused).toBe(true) }) - - it('finds something for Resume to send only in a waiting card with no hold, ahead of a returned one', () => { - const resumable = (drafts: AgentSessionQueuedMessage[]) => - mobileQueueHasResumableCard(mobileQueuedMessageCards(drafts, [])) - const returned = draft({ - messageId: 'r', - ...returnedAs(agentSessionFailureFact('hostRestarted')) - }) - const failed = draft({ - messageId: 'f', - paused: true, - pausedReason: QUEUED_MESSAGE_PAUSED_SEND_FAILED - }) - const waiting = draft({ messageId: 'w', position: 2 }) - expect(resumable([returned])).toBe(false) - expect(resumable([returned, waiting])).toBe(false) - expect(resumable([failed])).toBe(false) - expect(resumable([failed, waiting])).toBe(true) - expect(resumable([waiting, { ...returned, position: 3 }])).toBe(true) - }) - it('captions a reasonless pause as a plain pause, promising no release rule', () => { const [card] = mobileQueuedMessageCards([draft({ messageId: 'a', paused: true })], []) expect(card?.caption).toBe('Paused') }) - it("shows a returned card with the provider's own words and holds drafts behind it", () => { const refused = agentSessionFailureFact('providerRejected', { detail: { text: 'Steering is unavailable', audience: 'person' } @@ -188,40 +71,6 @@ describe('mobileQueuedMessageCards', () => { expect(cards[1]?.caption).toBe('Waiting — a message ahead needs attention') expect(cards[1]?.needsAttention).toBe(false) }) - - it('hides a waiting card once its hand-off arrived, never a returned one', () => { - const cards = mobileQueuedMessageCards( - [ - draft({ messageId: 'returned', ...returnedAs(agentSessionFailureFact('hostRestarted')) }), - draft({ messageId: 'drained', position: 2 }), - draft({ messageId: 'waiting', position: 3 }) - ], - [ - { queuedMessageId: 'returned', dispatchState: 'rejected' }, - { queuedMessageId: 'drained', dispatchState: 'pending' } - ] - ) - expect(cards.map((card) => card.messageId)).toEqual(['returned', 'waiting']) - expect(cards[1]?.caption).toBe('Waiting — a message ahead needs attention') - }) - - it('shows a draft a Stop requeued beside its rejected hand-off', () => { - // Rejected hand-offs stay hidden in history; the requeued card is the only reachable copy of their text. - const cards = mobileQueuedMessageCards( - [ - draft({ messageId: 'requeued' }), - draft({ messageId: 'sending', position: 2 }), - draft({ messageId: 'sent', position: 3 }) - ], - [ - { queuedMessageId: 'requeued', dispatchState: 'rejected' }, - { queuedMessageId: 'sending', dispatchState: 'pending' }, - { queuedMessageId: 'sent', dispatchState: 'accepted' } - ] - ) - expect(cards.map((card) => card.messageId)).toEqual(['requeued']) - }) - it('maps a Stop-withdrawn returned card to its own English copy', () => { const [card] = mobileQueuedMessageCards( [draft({ messageId: 'a', ...returnedAs(agentSessionFailureFact('cancelled')) })], @@ -229,74 +78,6 @@ describe('mobileQueuedMessageCards', () => { ) expect(card?.caption).toBe('Stopped before it was sent') }) - - it('reads a Stop withdrawal from the fact, whatever sentence rides beside it', () => { - const [card] = mobileQueuedMessageCards( - [ - draft({ - messageId: 'a', - state: 'returned', - returnedReason: 'This message was withdrawn before the agent started it.', - returnedRejection: { kind: 'cancelled' } - }) - ], - [] - ) - expect(card?.caption).toBe('Stopped before it was sent') - }) - - it('words a host-restart returned card from its fact, as a rejected send', () => { - const [card] = mobileQueuedMessageCards( - [draft({ messageId: 'a', ...returnedAs(agentSessionFailureFact('hostRestarted')) })], - [] - ) - expect(card?.caption).toBe('Orca restarted before this message was sent.') - }) - - it("keeps a provider's log-only detail off the card", () => { - const refused = agentSessionFailureFact('providerRejected', { - detail: { text: 'stack trace for the log', audience: 'log' } - }) - const [card] = mobileQueuedMessageCards([draft({ messageId: 'a', ...returnedAs(refused) })], []) - expect(card?.caption).not.toContain('stack trace') - }) - - it("words a fact kind this build cannot place with the host's sentence", () => { - const [card] = mobileQueuedMessageCards( - [ - draft({ - messageId: 'a', - state: 'returned', - returnedReason: 'Words for a kind a newer host added.', - returnedRejection: { kind: 'laterKind' } - }) - ], - [] - ) - expect(card?.caption).toBe('Words for a kind a newer host added.') - }) - - it("shows the host's sentence for a fact this build cannot read all of, as the desktop card", () => { - const reason = "Claude couldn't start. Start a new chat to continue." - // As a newer host sends it: a known code with a reason this build doesn't know. - const newer = { - kind: 'startFailed', - refusal: { code: 'agent_session_conflict', details: { reason: 'newerReason' } } - } - const [card] = mobileQueuedMessageCards( - [ - draft({ - messageId: 'a', - state: 'returned', - returnedReason: reason, - returnedRejection: newer - }) - ], - [] - ) - expect(card?.caption).toBe(reason) - }) - it('maps the send-failed pause marker to English and an unknown marker to a plain pause', () => { const cards = mobileQueuedMessageCards( [ @@ -313,18 +94,20 @@ describe('mobileQueuedMessageCards', () => { ]) expect(cards.map((card) => card.needsAttention)).toEqual([true, false]) }) - - it('never shows an internal rejection reason verbatim, even from a host that wrote no fact', () => { - const [card] = mobileQueuedMessageCards( - [ - draft({ - messageId: 'a', - state: 'returned', - returnedReason: DISPATCH_REJECTED_HOST_RESTARTED - }) - ], + it("reads a card of images alone by the image's name or their count", () => { + const images = (...paths: string[]) => + draft({ + messageId: paths.join(), + body: { + kind: 'message', + role: 'user', + blocks: paths.map((path) => ({ type: 'image-ref' as const, path })) + } + }) + const cards = mobileQueuedMessageCards( + [images('/tmp/shot.png'), images('/tmp/orca-paste-1.png'), images('/a.png', '/b.png')], [] ) - expect(card?.caption).toBe('Your message was not sent.') + expect(cards.map((card) => card.line)).toEqual(['shot.png', 'Pasted image', '2 images']) }) }) diff --git a/mobile/src/session/mobile-structured-queued-message-cards.ts b/mobile/src/session/mobile-structured-queued-message-cards.ts index 53d94fe29ec..c6513a05207 100644 --- a/mobile/src/session/mobile-structured-queued-message-cards.ts +++ b/mobile/src/session/mobile-structured-queued-message-cards.ts @@ -1,150 +1,75 @@ -// Derive queue captions from draft/live facts because the host sends no hold copy. +// The shared projection decides each card's hold; this layer words it for the phone. -import { - readWholeAgentSessionFailureFact, - type AgentSessionFailureFact -} from '../../../src/shared/agent-session-failure' -import { agentSessionFailureStatedByRow } from '../../../src/shared/agent-session-visible-failures' -import { - agentSessionWriteNotDoneParts, - agentSessionWriteNoticeEnglish -} from '../../../src/shared/agent-session-refusal-notice' +import type { AgentSessionFailureFact } from '../../../src/shared/agent-session-failure' +import { agentSessionWriteNoticeEnglish } from '../../../src/shared/agent-session-refusal-notice' import type { AgentJournalSubmission } from '../../../src/shared/agent-session-journal-types' -import { dispatchWasWithdrawn } from '../../../src/shared/structured-agent-session-dispatch-rejection' -import { structuredAgentSessionAttemptFailureParts } from '../../../src/shared/structured-agent-session-rejection-words' import { QUEUED_MESSAGE_PAUSED_SEND_FAILED, type AgentSessionQueuedMessage } from '../../../src/shared/agent-session-wire' -import { readAgentMessageSource } from '../../../src/shared/agent-session/agent-session-message-source' +import { + projectQueuedMessageCards, + queuedMessageCardLine, + queuedMessageCardNeedsAttention, + queuedMessageReturnedNotice, + type QueuedMessageCard +} from '../../../src/shared/structured-agent-session-queued-cards' import { agentMessageAttribution } from './mobile-agent-message-attribution' -export type MobileQueuedMessageCard = { - messageId: string - automatic?: true - text: string - state: 'waiting' | 'returned' - paused: boolean +export type MobileQueuedMessageCard = QueuedMessageCard & { + /** The text, or for a card of images alone, the image's name or their count. */ + line: string needsAttention: boolean // Leave plain waits uncaptioned; the question or queue-pause row already explains the hold. caption: string | null attribution: string | null - /** A conversation command such as /compact: it never steers into a running turn. */ - command?: true - /** A command card while the agent works: it offers no send until the agent is idle. */ - waitsForAgent?: true } -function queuedMessageBodyText(body: AgentSessionQueuedMessage['body']): string { - return body.blocks.flatMap((block) => (block.type === 'text' ? [block.text] : [])).join('\n') -} - -function returnedCaption( - draft: Pick, - agentName?: string, - statedFailures: readonly AgentSessionFailureFact[] = [] -): string { - const reason = draft.returnedReason ?? null - const rejection = draft.returnedRejection - if (agentSessionFailureStatedByRow(rejection, statedFailures)) { - return agentSessionWriteNoticeEnglish(agentSessionWriteNotDoneParts('send')) - } - if (dispatchWasWithdrawn({ dispatchState: 'rejected', reason, rejection })) { - return 'Stopped before it was sent' - } - // Reuse desktop failure wording but omit retry instructions: this card’s Send is the retry. - return agentSessionWriteNoticeEnglish( - structuredAgentSessionAttemptFailureParts( - { kind: 'rejected', reason }, - { agentName, retryControl: true }, - readWholeAgentSessionFailureFact(rejection) - ) - ) -} - -/** One card's own hold: a failed conversion; the queue's pause is the list's header. */ -function pausedCaption(reason: string | undefined, waitsForAgent: boolean): string { - if (reason === QUEUED_MESSAGE_PAUSED_SEND_FAILED) { - return waitsForAgent - ? "Couldn't send — tap Send to retry once the agent finishes" - : "Couldn't send — tap Send to retry" - } - // Absent or unknown (newer host) marker: a plain pause, promising no release rule. - return 'Paused' -} - -// Returned cards stop the drain, so waiting cards behind one cannot make Resume useful. -export function mobileQueueHasResumableCard(cards: readonly MobileQueuedMessageCard[]): boolean { - for (const card of cards) { - if (card.state === 'returned') { - return false - } - if (!card.paused) { - return true +function queuedMessageCardCaption( + card: QueuedMessageCard, + agentName: string | undefined, + statedFailures: readonly AgentSessionFailureFact[] +): string | null { + switch (card.hold) { + case 'returned': { + const notice = queuedMessageReturnedNotice(card, agentName, statedFailures) + return notice === 'withdrawn' + ? 'Stopped before it was sent' + : agentSessionWriteNoticeEnglish(notice) } + case 'paused': + // Absent or unknown (newer host) marker: a plain pause, promising no release rule. + if (card.pausedReason !== QUEUED_MESSAGE_PAUSED_SEND_FAILED) { + return 'Paused' + } + return card.waitsForAgent + ? "Couldn't send — tap Send to retry once the agent finishes" + : "Couldn't send — tap Send to retry" + case 'behind-returned': + return 'Waiting — a message ahead needs attention' + // The phone draws no card for a send still on its way. + case 'sending': + case 'turn': + case 'queue-paused': + return null } - return false } -// Hide waiting cards with non-rejected hand-offs until catch-up publishes the final queue; rejected hand-offs and returned cards must remain reachable. export function mobileQueuedMessageCards( - queuedMessages: readonly AgentSessionQueuedMessage[] | null, - submissions: readonly Pick[], + queuedMessages: readonly AgentSessionQueuedMessage[] | null | undefined, + submissions: readonly AgentJournalSubmission[], facts: { agentWorking?: boolean + queuePaused?: boolean agentName?: string statedFailures?: readonly AgentSessionFailureFact[] } = {} ): MobileQueuedMessageCard[] { - if (!queuedMessages || queuedMessages.length === 0) { - return [] - } - const handedOff = new Set( - submissions.flatMap((submission) => - submission.queuedMessageId !== undefined && submission.dispatchState !== 'rejected' - ? [submission.queuedMessageId] - : [] - ) - ) - let behindReturned = false - const cards: MobileQueuedMessageCard[] = [] - for (const draft of queuedMessages) { - if (draft.state !== 'returned' && handedOff.has(draft.messageId)) { - continue - } - const paused = draft.paused === true - const caption = - draft.state === 'returned' - ? returnedCaption(draft, facts.agentName, facts.statedFailures) - : paused - ? pausedCaption( - draft.pausedReason, - draft.body.command !== undefined && facts.agentWorking === true - ) - : behindReturned - ? 'Waiting — a message ahead needs attention' - : null - cards.push({ - messageId: draft.messageId, - ...(draft.body.from !== undefined ? { automatic: true as const } : {}), - text: queuedMessageBodyText(draft.body), - state: draft.state, - paused, - needsAttention: - draft.state === 'returned' || - (paused && draft.pausedReason === QUEUED_MESSAGE_PAUSED_SEND_FAILED), - caption, - attribution: agentMessageAttribution('From', readAgentMessageSource(draft.body.from)), - ...(draft.body.command !== undefined - ? { - command: true as const, - ...(facts.agentWorking ? { waitsForAgent: true as const } : {}) - } - : {}) - }) - if (draft.state === 'returned') { - behindReturned = true - } - } - return cards + return projectQueuedMessageCards(queuedMessages, submissions, facts).map((card) => ({ + ...card, + line: queuedMessageCardLine(card), + needsAttention: queuedMessageCardNeedsAttention(card), + caption: queuedMessageCardCaption(card, facts.agentName, facts.statedFailures ?? []), + attribution: agentMessageAttribution('From', card.from) + })) } diff --git a/mobile/src/session/mobile-structured-queued-message-feed.test.ts b/mobile/src/session/mobile-structured-queued-message-feed.test.ts deleted file mode 100644 index 3fc742b48b8..00000000000 --- a/mobile/src/session/mobile-structured-queued-message-feed.test.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { - AgentSessionQueuedMessage, - AgentSessionQueuePause, - AgentSessionSubscribeEvent -} from '../../../src/shared/agent-session-wire' -import { - reduceMobileQueuePause, - reduceMobileQueuedMessageFeed -} from './mobile-structured-queued-message-feed' - -function draft(messageId: string, position: number): AgentSessionQueuedMessage { - return { - messageId, - position, - body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: `text ${messageId}` }] }, - state: 'waiting' - } -} - -function batch( - queuedMessages?: AgentSessionQueuedMessage[] | null, - queuePause?: AgentSessionQueuePause | null -): AgentSessionSubscribeEvent { - return { - type: 'batch', - sessionId: 'session-1', - batch: { cursor: { epoch: 'e', sequence: 1 }, items: [], removedItemIds: [], submissions: [] }, - ...(queuedMessages !== undefined ? { queuedMessages } : {}), - ...(queuePause !== undefined ? { queuePause } : {}) - } -} - -describe('reduceMobileQueuePause', () => { - it('rides with the list: a frame without the list keeps it, one with the list states it', () => { - const paused = reduceMobileQueuePause(null, batch([draft('a', 1)], { reason: 'stopped' })) - expect(paused).toEqual({ reason: 'stopped' }) - expect(reduceMobileQueuePause(paused, batch())).toBe(paused) - expect(reduceMobileQueuePause(paused, batch([draft('a', 1)], { reason: 'stopped' }))).toBe( - paused - ) - expect(reduceMobileQueuePause(paused, batch([draft('a', 1)]))).toBeNull() - }) - - it('keeps a pause that names no reason', () => { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a malformed or newer host's pause, with no reason this build can read. - const reasonless = {} as AgentSessionQueuePause - const paused = reduceMobileQueuePause(null, batch([draft('a', 1)], reasonless)) - expect(paused).toBe(reasonless) - }) -}) - -describe('reduceMobileQueuedMessageFeed', () => { - it('holds no claim until the host publishes the field', () => { - expect(reduceMobileQueuedMessageFeed(null, batch())).toBeNull() - }) - - it('adopts a published list ordered by position', () => { - const next = reduceMobileQueuedMessageFeed(null, batch([draft('b', 2), draft('a', 1)])) - expect(next?.map((entry) => entry.messageId)).toEqual(['a', 'b']) - }) - - it('keeps the last list when a frame omits the field', () => { - const held = reduceMobileQueuedMessageFeed(null, batch([draft('a', 1)])) - expect(reduceMobileQueuedMessageFeed(held, batch())).toBe(held) - }) - - it('reads null as empty', () => { - const held = reduceMobileQueuedMessageFeed(null, batch([draft('a', 1)])) - expect(reduceMobileQueuedMessageFeed(held, batch(null))).toEqual([]) - }) - - it('never advances on the end frame', () => { - const held = reduceMobileQueuedMessageFeed(null, batch([draft('a', 1)])) - expect(reduceMobileQueuedMessageFeed(held, { type: 'end' })).toBe(held) - }) -}) diff --git a/mobile/src/session/mobile-structured-queued-message-feed.ts b/mobile/src/session/mobile-structured-queued-message-feed.ts deleted file mode 100644 index f5e97ff0b3c..00000000000 --- a/mobile/src/session/mobile-structured-queued-message-feed.ts +++ /dev/null @@ -1,47 +0,0 @@ -// Live lists are authoritative: absent means unchanged and null means empty; history pages must never replace newer queue state. - -import type { - AgentSessionQueuedMessage, - AgentSessionQueuePause, - AgentSessionSubscribeEvent -} from '../../../src/shared/agent-session-wire' - -/** Null = no claim yet (older host, or nothing published on this stream). */ -export type MobileQueuedMessageFeed = AgentSessionQueuedMessage[] | null - -/** The whole queue's pause; null when it sends on its own or nothing was published. */ -export type MobileQueuePause = AgentSessionQueuePause | null - -// List and pause publish together; omitted lists preserve both. -export function reduceMobileQueuePause( - previous: MobileQueuePause, - event: AgentSessionSubscribeEvent -): MobileQueuePause { - if (event.type === 'end' || event.queuedMessages === undefined) { - return previous - } - const next = event.queuePause ?? null - // Presence first: a pause with no reason, or one this build does not know, is still a pause. - if ((next === null) !== (previous === null)) { - return next - } - return next?.reason === previous?.reason ? previous : next -} - -export function reduceMobileQueuedMessageFeed( - previous: MobileQueuedMessageFeed, - event: AgentSessionSubscribeEvent -): MobileQueuedMessageFeed { - if (event.type === 'end') { - return previous - } - if (event.queuedMessages === undefined) { - return previous - } - const list = event.queuedMessages ?? [] - if (list.length === 0 && previous !== null && previous.length === 0) { - return previous - } - // Use host positions so publication order cannot reorder cards. - return [...list].sort((left, right) => left.position - right.position) -} diff --git a/mobile/src/session/native-chat/composer/MobileNativeChatComposer.test.ts b/mobile/src/session/native-chat/composer/MobileNativeChatComposer.test.ts index 1ff76aa5412..890aa689a01 100644 --- a/mobile/src/session/native-chat/composer/MobileNativeChatComposer.test.ts +++ b/mobile/src/session/native-chat/composer/MobileNativeChatComposer.test.ts @@ -512,6 +512,35 @@ describe('MobileNativeChatComposer', () => { expect(input().props.selection).toBeUndefined() }) + it.each([ + ['then /cl', '/clear', 'then /clear '], + ['see @do', 'docs/my notes.md', 'see @"docs/my notes.md" '] + ])('completes the mid-prompt token in %j like desktop', async (value, label, expected) => { + const onChangeText = vi.fn() + await act(async () => { + renderer = create( + createElement(MobileNativeChatComposer, { + value, + onChangeText, + onSend: vi.fn().mockResolvedValue(true), + sendSurfaceId: 'tab-a', + getSendCompletionGeneration: getCurrentSendCompletionGeneration, + agent: 'claude', + filePaths: ['docs/my notes.md'] + }) + ) + }) + const input = findByHostType(renderer!.root, 'TextInput') + await act(async () => + input.props.onSelectionChange({ nativeEvent: { selection: { end: value.length } } }) + ) + const row = renderer!.root.find( + (node) => node.props.accessibilityLabel === label && node.props.onPress !== undefined + ) + await act(async () => row.props.onPress()) + expect(onChangeText).toHaveBeenCalledWith(expected) + }) + it('serves the active agent’s shared command catalog with descriptions', async () => { await act(async () => { renderer = create( diff --git a/mobile/src/session/native-chat/composer/MobileNativeChatComposer.tsx b/mobile/src/session/native-chat/composer/MobileNativeChatComposer.tsx index 72ea58045e6..ac6225c5a4c 100644 --- a/mobile/src/session/native-chat/composer/MobileNativeChatComposer.tsx +++ b/mobile/src/session/native-chat/composer/MobileNativeChatComposer.tsx @@ -1,3 +1,4 @@ +import type { NativeChatSkillDiscoverySnapshot } from '../../../../../src/shared/native-chat/input/native-chat-picker-items' import { useEffect, useLayoutEffect, useRef, useState } from 'react' import { ActivityIndicator, Keyboard, Pressable, type TextInput, View } from 'react-native' import { ArrowUp, Mic, Plus, Square } from 'lucide-react-native' @@ -21,7 +22,10 @@ import { keepHeldPressThroughLongPress } from '../../held-press-long-press' import { mobileNativeChatComposerShowsStop } from './mobile-native-chat-composer-layout' import { MobileNativeChatStopButton } from '../input/MobileNativeChatStopButton' import { mobileNativeChatComposerStyles as styles } from './mobile-native-chat-composer-styles' -import { useMobileNativeChatDraft } from './mobile-native-chat-draft-store' +import { + recordMobileNativeChatSkillPick, + useMobileNativeChatDraft +} from './mobile-native-chat-draft-store' const NO_FILE_PATHS: string[] = [] const NO_ATTACHMENTS: PendingNativeChatImage[] = [] @@ -65,6 +69,9 @@ type Props = { placeholder?: string filePaths?: string[] onNeedFiles?: (query: string) => void + /** The host's disk-scanned skills, loaded once a skill trigger opens. */ + skillDiscovery?: NativeChatSkillDiscoverySnapshot + onNeedSkills?: () => void /** Set while the agent can be stopped; Stop takes Send's slot when Send can't deliver. */ stop?: { onStop?: () => void; held: boolean } | null } @@ -94,6 +101,8 @@ export function MobileNativeChatComposer({ placeholder = NATIVE_CHAT_COMPOSER_PLACEHOLDER, filePaths = NO_FILE_PATHS, onNeedFiles, + skillDiscovery, + onNeedSkills, stop = null }: Props): React.JSX.Element { const fallbackInputRef = useRef(null) @@ -115,7 +124,11 @@ export function MobileNativeChatComposer({ structuredCatalog: structured?.catalog, filePaths, onNeedFiles, + skillDiscovery, + onNeedSkills, onSlashMenuOpen: structured?.onSlashMenuOpen, + // The surface id is the draft key both send paths read picks back from. + onPickSkill: (skill) => recordMobileNativeChatSkillPick(sendSurfaceId, skill), focusField: () => fieldRef.current?.focus() }) const sendingRef = useRef(false) diff --git a/mobile/src/session/native-chat/composer/MobileNativeChatComposerMenu.tsx b/mobile/src/session/native-chat/composer/MobileNativeChatComposerMenu.tsx index 90265aa6c49..7ce65ff3750 100644 --- a/mobile/src/session/native-chat/composer/MobileNativeChatComposerMenu.tsx +++ b/mobile/src/session/native-chat/composer/MobileNativeChatComposerMenu.tsx @@ -5,10 +5,6 @@ import { colors, spacing, typography } from '../../../theme/mobile-theme' export type ComposerMenuRow = NativeChatPickerItem | { kind: 'file'; id: string; path: string } -export function composerMenuInsertText(row: ComposerMenuRow): string { - return row.kind === 'file' ? `@${row.path}` : row.token -} - const HEADINGS = { command: 'Commands', skill: 'Skills', file: 'Files' } as const export function MobileNativeChatComposerMenu({ @@ -47,9 +43,11 @@ function MenuRow({ [styles.row, pressed && styles.rowPressed]} onPress={() => onPick(row)} diff --git a/mobile/src/session/native-chat/composer/mobile-native-chat-autocomplete.test.ts b/mobile/src/session/native-chat/composer/mobile-native-chat-autocomplete.test.ts deleted file mode 100644 index 7cf0f29183f..00000000000 --- a/mobile/src/session/native-chat/composer/mobile-native-chat-autocomplete.test.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { - applyAutocomplete, - detectAutocompleteTrigger, - rankSuggestions -} from './mobile-native-chat-autocomplete' - -describe('detectAutocompleteTrigger', () => { - it('opens nothing while the caret sits before the trigger character', () => { - expect(detectAutocompleteTrigger('/review-pr focus', 0)).toBeNull() - expect(detectAutocompleteTrigger('see @src', 4)).toBeNull() - expect(detectAutocompleteTrigger('/re', 1)).toMatchObject({ kind: 'slash', query: '' }) - }) - - it('detects a slash command only at the start', () => { - expect(detectAutocompleteTrigger('/rev', 4)).toEqual({ - kind: 'slash', - query: 'rev', - start: 0, - end: 4 - }) - expect(detectAutocompleteTrigger('hi /rev', 7)).toBeNull() - }) - - it('detects an @ mention after whitespace or at start', () => { - expect(detectAutocompleteTrigger('@src', 4)).toMatchObject({ kind: 'file', query: 'src' }) - expect(detectAutocompleteTrigger('look at @comp', 13)).toMatchObject({ - kind: 'file', - query: 'comp' - }) - }) - - it('does not trigger @ mid-word (email-like)', () => { - expect(detectAutocompleteTrigger('me@host', 7)).toBeNull() - }) - - it('closes the token once a space is typed', () => { - expect(detectAutocompleteTrigger('@src ', 5)).toBeNull() - }) - - it('returns empty query right after the trigger char', () => { - expect(detectAutocompleteTrigger('@', 1)).toMatchObject({ kind: 'file', query: '' }) - }) -}) - -describe('applyAutocomplete', () => { - it('replaces the trigger span and leaves a trailing space + cursor', () => { - const trigger = detectAutocompleteTrigger('look at @comp', 13)! - const { text, cursor } = applyAutocomplete('look at @comp', trigger, '@src/App.tsx') - expect(text).toBe('look at @src/App.tsx ') - expect(cursor).toBe(text.length) - }) -}) - -describe('rankSuggestions', () => { - it('prefers prefix matches on the basename', () => { - const out = rankSuggestions(['src/app/Main.tsx', 'src/AppBar.tsx', 'lib/zapp.ts'], 'app') - expect(out[0]).toBe('src/AppBar.tsx') - expect(out).toContain('lib/zapp.ts') - }) - - it('returns the head of the list for an empty query', () => { - expect(rankSuggestions(['a', 'b', 'c'], '', 2)).toEqual(['a', 'b']) - }) -}) - -describe.each([{ name: 'file', rank: rankSuggestions }])('$name suggestion bounds', ({ rank }) => { - it('keeps later prefixes ahead of the earliest substring matches', () => { - const candidates = ['team-review', 'team-review', 'pre-review', 'review-a', 'REVIEW-b'] - expect(rank(candidates, 'REVIEW', 4)).toEqual([ - 'review-a', - 'REVIEW-b', - 'team-review', - 'team-review' - ]) - }) - - it('stops substring matching once enough fallback suggestions are retained', () => { - const candidates = Array.from({ length: 10_000 }, (_, index) => `team-review-${index}`) - candidates.push('review-last', 'review-final') - const includes = String.prototype.includes - let substringChecks = 0 - const spy = vi.spyOn(String.prototype, 'includes').mockImplementation(function ( - this: string, - search: string, - position?: number - ) { - substringChecks += 1 - return includes.call(this, search, position) - }) - let result: string[] - try { - result = rank(candidates, 'review', 8) - } finally { - spy.mockRestore() - } - expect(result).toEqual(['review-last', 'review-final', ...candidates.slice(0, 6)]) - expect(substringChecks).toBeLessThanOrEqual(8) - }) - - it.each([0, -0, -1, -0.5, -Infinity, Number.NaN])( - 'preserves empty results for limit %s', - (limit) => { - expect(rank(['team-review', 'review-a', 'review-b'], 'review', limit)).toEqual([]) - } - ) - - it.each([0.5, 1.5, 2.5, Infinity])('preserves slice truncation for limit %s', (limit) => { - const candidates = ['team-review', 'pre-review', 'review-a', 'review-b'] - expect(rank(candidates, 'review', limit)).toEqual( - ['review-a', 'review-b', 'team-review', 'pre-review'].slice(0, limit) - ) - expect(rank(candidates, '', limit)).toEqual(candidates.slice(0, limit)) - }) -}) diff --git a/mobile/src/session/native-chat/composer/mobile-native-chat-autocomplete.ts b/mobile/src/session/native-chat/composer/mobile-native-chat-autocomplete.ts deleted file mode 100644 index 7ab523d047a..00000000000 --- a/mobile/src/session/native-chat/composer/mobile-native-chat-autocomplete.ts +++ /dev/null @@ -1,78 +0,0 @@ -export type AutocompleteKind = 'file' | 'slash' - -export type AutocompleteTrigger = { - kind: AutocompleteKind - query: string - start: number - end: number -} - -const TOKEN_CHAR = /[^\s]/ - -export function detectAutocompleteTrigger( - text: string, - cursor: number -): AutocompleteTrigger | null { - const pos = Math.max(0, Math.min(cursor, text.length)) - let i = pos - 1 - while (i >= 0 && TOKEN_CHAR.test(text[i]!)) { - i-- - } - const triggerIndex = i + 1 - if (pos <= triggerIndex) { - return null - } - const triggerChar = text[triggerIndex] - if (triggerChar !== '@' && triggerChar !== '/') { - return null - } - const before = triggerIndex === 0 ? '' : text[triggerIndex - 1]! - if (triggerChar === '/' && triggerIndex !== 0) { - return null - } - if (triggerChar === '@' && triggerIndex !== 0 && !/\s/.test(before)) { - return null - } - const query = text.slice(triggerIndex + 1, pos) - if (/\s/.test(query)) { - return null - } - return { - kind: triggerChar === '@' ? 'file' : 'slash', - query, - start: triggerIndex, - end: pos - } -} - -export function applyAutocomplete( - text: string, - trigger: AutocompleteTrigger, - value: string -): { text: string; cursor: number } { - const inserted = `${value} ` - const next = text.slice(0, trigger.start) + inserted + text.slice(trigger.end) - return { text: next, cursor: trigger.start + inserted.length } -} - -export function rankSuggestions(candidates: readonly string[], query: string, limit = 8): string[] { - const q = query.toLowerCase() - if (q.length === 0) { - return candidates.slice(0, limit) - } - const prefix: string[] = [] - const substring: string[] = [] - for (const candidate of candidates) { - const lower = candidate.toLowerCase() - const base = lower.slice(lower.lastIndexOf('/') + 1) - if (lower.startsWith(q) || base.startsWith(q)) { - prefix.push(candidate) - } else if (substring.length < limit && lower.includes(q)) { - substring.push(candidate) - } - if (prefix.length >= limit) { - break - } - } - return [...prefix, ...substring].slice(0, limit) -} diff --git a/mobile/src/session/native-chat/composer/mobile-native-chat-composer-menu.test.ts b/mobile/src/session/native-chat/composer/mobile-native-chat-composer-menu.test.ts index d33c0f589da..ec7d3e510be 100644 --- a/mobile/src/session/native-chat/composer/mobile-native-chat-composer-menu.test.ts +++ b/mobile/src/session/native-chat/composer/mobile-native-chat-composer-menu.test.ts @@ -1,12 +1,37 @@ import { describe, expect, it } from 'vitest' +import { deriveComposerAutocomplete } from '../../../../../src/shared/native-chat-composer-autocomplete' +import type { NativeChatSkillDiscoverySnapshot } from '../../../../../src/shared/native-chat/input/native-chat-picker-items' import { + boundedMenuItems, draftAfterSkillEdit, narrowedMenuItems, leadingSkillToken, + mobileComposerCatalog, mobileComposerSkillTokens, - mobileComposerSlashItems + type MobileComposerStructuredCatalog } from './mobile-native-chat-composer-menu' +function mobileComposerSlashItems(input: { + agent: string + structured: MobileComposerStructuredCatalog | undefined + query: string + discovery?: NativeChatSkillDiscoverySnapshot +}) { + const catalog = mobileComposerCatalog(input) + const draft = `/${input.query}` + const result = deriveComposerAutocomplete( + draft, + draft.length, + catalog.commands, + catalog.discovery.skills, + catalog.profile, + catalog.discovery, + null, + catalog.sessionSkills + ) + return result.mode === 'slash' ? boundedMenuItems(result.items) : [] +} + const reported = [ { name: 'compact', kind: 'command' as const, description: 'Compact the chat' }, { name: 'review-pr', kind: 'skill' as const, description: 'Review a pull request' }, @@ -30,15 +55,6 @@ describe('mobileComposerSlashItems', () => { ]) }) - it('narrows by the typed query across both groups', () => { - const items = mobileComposerSlashItems({ - agent: 'claude', - structured: { reported, conversationCommands: [] }, - query: 'rev' - }) - expect(items.map((item) => item.token)).toEqual(['/review-pr']) - }) - it('names skills the way the agent invokes them', () => { const items = mobileComposerSlashItems({ agent: 'codex', @@ -58,6 +74,34 @@ describe('mobileComposerSlashItems', () => { expect(items.some((item) => item.kind === 'skill')).toBe(false) }) + it("lists the host's scanned skills until the session reports its own", () => { + const discovery: NativeChatSkillDiscoverySnapshot = { + status: 'ready', + skills: [ + { + id: 'triage', + name: 'triage', + description: 'Triage an issue', + providers: ['agent-skills'], + sourceKind: 'repo', + sourceLabel: 'Repo', + rootPath: '/repo/.agents/skills', + directoryPath: '/repo/.agents/skills/triage', + skillFilePath: '/repo/.agents/skills/triage/SKILL.md', + installed: true, + updatedAt: null + } + ] + } + const tokens = (structured: MobileComposerStructuredCatalog | undefined) => + mobileComposerSlashItems({ agent: 'claude', structured, query: '', discovery }) + .filter((item) => item.kind === 'skill') + .map((item) => item.token) + expect(tokens(undefined)).toEqual(['/triage']) + expect(tokens({ reported: undefined, conversationCommands: [] })).toEqual(['/triage']) + expect(tokens({ reported, conversationCommands: [] })).toEqual(['/research', '/review-pr']) + }) + it('keeps a terminal-backed chat on its curated commands', () => { const items = mobileComposerSlashItems({ agent: 'claude', structured: undefined, query: '' }) expect(items.length).toBeGreaterThan(0) @@ -74,7 +118,7 @@ describe('mobileComposerSkillTokens', () => { const structured = { reported: many, conversationCommands: [] } const drawn = mobileComposerSlashItems({ agent: 'claude', structured, query: '' }) expect(drawn.filter((item) => item.kind === 'skill')).toHaveLength(10) - const tokens = mobileComposerSkillTokens({ agent: 'claude', structured }) + const tokens = mobileComposerSkillTokens(mobileComposerCatalog({ agent: 'claude', structured })) expect(tokens.size).toBe(60) expect(tokens.has('/skill-59')).toBe(true) }) diff --git a/mobile/src/session/native-chat/composer/mobile-native-chat-composer-menu.ts b/mobile/src/session/native-chat/composer/mobile-native-chat-composer-menu.ts index d17ebb74571..4b1422fb87e 100644 --- a/mobile/src/session/native-chat/composer/mobile-native-chat-composer-menu.ts +++ b/mobile/src/session/native-chat/composer/mobile-native-chat-composer-menu.ts @@ -1,17 +1,29 @@ import type { AgentSessionConversationCommand } from '../../../../../src/shared/agent-session/agent-session-conversation-command' import type { AgentSessionSlashCommand } from '../../../../../src/shared/agent-session-wire' -import { getNativeChatAgentProfile } from '../../../../../src/shared/native-chat-agent-profiles' +import { + getNativeChatAgentProfile, + type NativeChatAgentProfile +} from '../../../../../src/shared/native-chat-agent-profiles' +import { nativeChatPickerScannedSkills } from '../../../../../src/shared/native-chat-composer-autocomplete' import { buildNativeChatPickerItems, - type NativeChatPickerItem + type NativeChatPickerItem, + type NativeChatSkillDiscoverySnapshot } from '../../../../../src/shared/native-chat/input/native-chat-picker-items' -import { sessionReportedSkills } from '../../../../../src/shared/native-chat/input/native-chat-slash-commands' +import { + nativeChatPickerCommands, + nativeChatSessionSkills +} from '../../../../../src/shared/native-chat-skill-visibility' +import type { + NativeChatSessionSkill, + SlashCommandSuggestion +} from '../../../../../src/shared/native-chat/input/native-chat-slash-commands' import { structuredSessionSlashCommands } from '../../../../../src/shared/structured-agent-session-composer' import { getMobileNativeChatCommands } from '../mobile-native-chat-send-classification' // Why: the menu is a plain ScrollView right above a streaming transcript, so it mounts a bounded // number of rows; typing narrows the rest. -const MENU_ROWS_PER_GROUP = 10 +export const MENU_ROWS_PER_GROUP = 10 export type MobileComposerStructuredCatalog = { /** What the session reported for its `/` menu; undefined until read or on an older host. */ @@ -19,48 +31,62 @@ export type MobileComposerStructuredCatalog = { conversationCommands: readonly AgentSessionConversationCommand[] } -type SlashMenuInput = { - agent: string | null - structured: MobileComposerStructuredCatalog | undefined - query: string +export type MobileComposerCatalog = { + profile: NativeChatAgentProfile | null + commands: readonly SlashCommandSuggestion[] + discovery: NativeChatSkillDiscoverySnapshot + /** Undefined keeps the host disk scan, as on desktop. */ + sessionSkills: readonly NativeChatSessionSkill[] | undefined } -// Structured chats use reported commands/skills; terminal-backed chats retain curated commands without skills. -function slashItems( - { agent, structured, query }: SlashMenuInput, - limit: number -): NativeChatPickerItem[] { +const NO_DISCOVERY: NativeChatSkillDiscoverySnapshot = { status: 'ready', skills: [] } + +// Structured chats use reported commands/skills; terminal-backed chats keep curated commands. +export function mobileComposerCatalog({ + agent, + structured, + discovery = NO_DISCOVERY +}: { + agent: string | null + structured: MobileComposerStructuredCatalog | undefined + discovery?: NativeChatSkillDiscoverySnapshot +}): MobileComposerCatalog { if (!agent) { - return [] + return { profile: null, commands: [], discovery: NO_DISCOVERY, sessionSkills: [] } } const reported = structured?.reported const commands = structured ? structuredSessionSlashCommands(agent, structured.conversationCommands, reported) : getMobileNativeChatCommands(agent) - return buildNativeChatPickerItems( - commands, - [], - query, - getNativeChatAgentProfile(agent)?.skillPrefix ?? '/', - reported ? sessionReportedSkills(reported) : [], - limit - ) + return { + profile: getNativeChatAgentProfile(agent), + commands: nativeChatPickerCommands(agent, commands, discovery.skills), + discovery, + sessionSkills: nativeChatSessionSkills(agent, reported) + } } -export function mobileComposerSlashItems(input: SlashMenuInput): NativeChatPickerItem[] { - return slashItems(input, MENU_ROWS_PER_GROUP) -} - -export function mobileComposerSkillTokens( - input: Omit -): ReadonlySet { +export function mobileComposerSkillTokens(catalog: MobileComposerCatalog): ReadonlySet { + if (!catalog.profile) { + return new Set() + } return new Set( - slashItems({ ...input, query: '' }, Infinity).flatMap((item) => - item.kind === 'skill' ? [item.token] : [] - ) + buildNativeChatPickerItems( + catalog.commands, + nativeChatPickerScannedSkills(catalog.profile, catalog.discovery.skills), + '', + catalog.profile.skillPrefix, + catalog.sessionSkills, + Infinity + ).flatMap((item) => (item.kind === 'skill' ? [item.token] : [])) ) } +export function boundedMenuItems(items: readonly NativeChatPickerItem[]): NativeChatPickerItem[] { + const counts = { command: 0, skill: 0 } + return items.filter((item) => ++counts[item.kind] <= MENU_ROWS_PER_GROUP) +} + // If narrowing leaves no rows, show all: idle chats may report skills as plain commands. export function narrowedMenuItems( items: NativeChatPickerItem[], diff --git a/mobile/src/session/native-chat/composer/mobile-native-chat-draft-store.test.ts b/mobile/src/session/native-chat/composer/mobile-native-chat-draft-store.test.ts index 49b1048085c..d709849a245 100644 --- a/mobile/src/session/native-chat/composer/mobile-native-chat-draft-store.test.ts +++ b/mobile/src/session/native-chat/composer/mobile-native-chat-draft-store.test.ts @@ -4,7 +4,13 @@ import { afterEach, describe, expect, it } from 'vitest' import type { NativeChatMessage } from '../../../../../src/shared/native-chat-types' import { useMobileNativeChatDrafts } from './use-mobile-native-chat-drafts' import { + readMobileNativeChatPickedSkills, + editMobileNativeChatDraft, + readMobileNativeChatSkillOrigin, + recordMobileNativeChatSkillPick, + restoreMobileNativeChatPickedSkills, resetMobileNativeChatDraftStoreForTests, + setMobileNativeChatDraftText, useMobileNativeChatDraft } from './mobile-native-chat-draft-store' @@ -143,3 +149,41 @@ describe('mobile native-chat draft store', () => { expect(screenRenders).toBe(rendersBeforeTyping) }) }) + +describe('picked skills', () => { + afterEach(() => resetMobileNativeChatDraftStoreForTests()) + + it('forgets a pick once an edit deletes its token, even if the name is typed again', () => { + const review = { name: 'review', path: '/a/review/SKILL.md' } + setMobileNativeChatDraftText('draft', 'fix $review then ') + recordMobileNativeChatSkillPick('draft', { token: '$review', path: review.path }) + editMobileNativeChatDraft('draft', 'fix it then ') + editMobileNativeChatDraft('draft', 'fix it then $review') + expect(readMobileNativeChatPickedSkills('draft')).toEqual([]) + }) + + it('keeps a picked skill as the send origin through a send clear, until an edit stops it leading', () => { + editMobileNativeChatDraft('draft', '/triage ') + recordMobileNativeChatSkillPick('draft', { token: '/triage' }) + editMobileNativeChatDraft('draft', '/triage the flaky test') + setMobileNativeChatDraftText('draft', '') + setMobileNativeChatDraftText('draft', '/triage the flaky test') + expect(readMobileNativeChatSkillOrigin('draft')).toBe('/triage') + editMobileNativeChatDraft('draft', 'the flaky test') + expect(readMobileNativeChatSkillOrigin('draft')).toBeNull() + }) + + it('replaces the file a re-picked token named, and gives a rejected draft its files back', () => { + const first = { name: 'review', path: '/a/review/SKILL.md' } + const second = { name: 'review', path: '/b/review/SKILL.md' } + editMobileNativeChatDraft('draft', '$review ') + recordMobileNativeChatSkillPick('draft', { token: '$review', path: first.path }) + recordMobileNativeChatSkillPick('draft', { token: '$review', path: second.path }) + expect(readMobileNativeChatPickedSkills('draft')).toEqual([second]) + + editMobileNativeChatDraft('draft', 'something else') + setMobileNativeChatDraftText('draft', '$review ') + restoreMobileNativeChatPickedSkills('draft', [second]) + expect(readMobileNativeChatPickedSkills('draft')).toEqual([second]) + }) +}) diff --git a/mobile/src/session/native-chat/composer/mobile-native-chat-draft-store.ts b/mobile/src/session/native-chat/composer/mobile-native-chat-draft-store.ts index 81313056133..7ae8a24941f 100644 --- a/mobile/src/session/native-chat/composer/mobile-native-chat-draft-store.ts +++ b/mobile/src/session/native-chat/composer/mobile-native-chat-draft-store.ts @@ -1,8 +1,16 @@ import { useSyncExternalStore, type SetStateAction } from 'react' +import type { AgentJournalSkillRef } from '../../../../../src/shared/agent-session-journal-types' +import { pickedSkillsInText } from '../../../../../src/shared/structured-agent-session-send-mutation' import type { MobileNativeChatLaunchDraftSeed } from './use-mobile-native-chat-launch-draft-seed' // Text and its edit revision outlive the screen, including callbacks from an older mount. -type Draft = { text: string; editGeneration: number } +// Picks behave like desktop chips: user edits drop skills the text stops naming and an origin that stops leading. +type Draft = { + text: string + editGeneration: number + skills?: AgentJournalSkillRef[] + skillOrigin?: string +} const drafts = new Map() const listeners = new Set<() => void>() @@ -14,6 +22,42 @@ export const mobileNativeChatLaunchDraftSeeds = new Map< MobileNativeChatLaunchDraftSeed | null >() +export function recordMobileNativeChatSkillPick( + draftKey: string, + skill: { token: string; path?: string } +): void { + const current = drafts.get(draftKey) ?? { text: '', editGeneration: 0 } + const name = skill.token.slice(1) + // A pick replaces whatever file its token named before, like re-picking a desktop chip. + const kept = (current.skills ?? []).filter((held) => held.name !== name) + const skills = skill.path ? [...kept, { name, path: skill.path }] : kept + drafts.set(draftKey, { ...current, skillOrigin: skill.token, skills }) +} + +// A rejected send's restored text gets back the files it named, even if newer edits dropped them. +export function restoreMobileNativeChatPickedSkills( + draftKey: string, + skills: readonly AgentJournalSkillRef[] +): void { + const current = drafts.get(draftKey) + if (!current || skills.length === 0) { + return + } + const paths = new Set((current.skills ?? []).map((skill) => skill.path)) + drafts.set(draftKey, { + ...current, + skills: [...(current.skills ?? []), ...skills.filter((skill) => !paths.has(skill.path))] + }) +} + +export function readMobileNativeChatSkillOrigin(draftKey: string): string | null { + return drafts.get(draftKey)?.skillOrigin ?? null +} + +export function readMobileNativeChatPickedSkills(draftKey: string): AgentJournalSkillRef[] { + return drafts.get(draftKey)?.skills ?? [] +} + function subscribe(listener: () => void): () => void { listeners.add(listener) return () => { @@ -28,7 +72,17 @@ function writeDraft(draftKey: string, update: SetStateAction, userEdit: if (next === text && !userEdit) { return } - drafts.set(draftKey, { text: next, editGeneration: (current?.editGeneration ?? 0) + 1 }) + // Send clears and restores are not edits, so a rejected draft keeps its picks. + const skills = + userEdit && current?.skills ? pickedSkillsInText(next, current.skills) : current?.skills + const skillOrigin = + userEdit && current?.skillOrigin !== next.split(/\s/, 1)[0] ? undefined : current?.skillOrigin + drafts.set(draftKey, { + text: next, + editGeneration: (current?.editGeneration ?? 0) + 1, + ...(skills ? { skills } : {}), + ...(skillOrigin ? { skillOrigin } : {}) + }) if (next === text) { return } diff --git a/mobile/src/session/native-chat/composer/mobile-native-chat-image-send.test.ts b/mobile/src/session/native-chat/composer/mobile-native-chat-image-send.test.ts index 6e462278b18..55bdc2b9d57 100644 --- a/mobile/src/session/native-chat/composer/mobile-native-chat-image-send.test.ts +++ b/mobile/src/session/native-chat/composer/mobile-native-chat-image-send.test.ts @@ -62,6 +62,22 @@ describe('pasteMobileNativeChatImagePaths', () => { }) }) + it.each([ + ['/tmp/my image.png', '/tmp/my\\ image.png'], + ['C:\\Users\\John Doe\\shot.png', '"C:\\Users\\John Doe\\shot.png"'] + ])('escapes attachment-agent path %s like a terminal drop', async (path, pasted) => { + const client = clientWithResponses([sendResult(true), sendResult(true)]) + await pasteMobileNativeChatImagePaths({ + client, + terminal: 'term-1', + agent: 'claude', + deviceToken: null, + imagePaths: [path], + followedByText: false + }) + expect(client.calls[1]?.params.text).toBe(`\x1b[200~${pasted}\x1b[201~`) + }) + it('clears the input line, then pastes each path as a bracketed, non-submitting terminal.send with the mobile client tag', async () => { const client = clientWithResponses([ sendResult(true), diff --git a/mobile/src/session/native-chat/composer/mobile-native-chat-image-send.ts b/mobile/src/session/native-chat/composer/mobile-native-chat-image-send.ts index a6831478783..0445a808489 100644 --- a/mobile/src/session/native-chat/composer/mobile-native-chat-image-send.ts +++ b/mobile/src/session/native-chat/composer/mobile-native-chat-image-send.ts @@ -1,5 +1,6 @@ import { agentImagePasteWrites } from '../../../../../src/shared/agent-image-paste' -import { buildMobileImagePastePayload } from '../../mobile-clipboard-image' +import { agentImagePastePath } from '../../../../../src/shared/agent-image-paste-path' +import { buildNativeChatImagePasteBytes } from '../../../../../src/shared/native-chat-paste-bytes' import { MOBILE_NATIVE_CHAT_MIN_WRITE_TIMEOUT_MS, openMobileNativeChatSendBudget @@ -49,7 +50,7 @@ export async function pasteMobileNativeChatImagePaths({ clearInput ?? MOBILE_NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT, ...agentImagePasteWrites( agent, - imagePaths.map((path) => buildMobileImagePastePayload(path, agent)), + imagePaths.map((path) => buildNativeChatImagePasteBytes(agentImagePastePath(agent, path))), followedByText ) ]) { diff --git a/mobile/src/session/native-chat/composer/use-mobile-native-chat-composer-menu.ts b/mobile/src/session/native-chat/composer/use-mobile-native-chat-composer-menu.ts index 8baceb35cbe..2bb0b3e1539 100644 --- a/mobile/src/session/native-chat/composer/use-mobile-native-chat-composer-menu.ts +++ b/mobile/src/session/native-chat/composer/use-mobile-native-chat-composer-menu.ts @@ -1,18 +1,22 @@ import { useEffect, useMemo, useState } from 'react' import { - applyAutocomplete, - detectAutocompleteTrigger, - rankSuggestions -} from './mobile-native-chat-autocomplete' + applyMentionSuggestion, + applyPickerSuggestion, + deriveComposerAutocomplete, + isSkillPickerTriggered +} from '../../../../../src/shared/native-chat-composer-autocomplete' +import type { NativeChatSkillDiscoverySnapshot } from '../../../../../src/shared/native-chat/input/native-chat-picker-items' import { + boundedMenuItems, draftAfterSkillEdit, narrowedMenuItems, leadingSkillToken, + MENU_ROWS_PER_GROUP, + mobileComposerCatalog, mobileComposerSkillTokens, - mobileComposerSlashItems, type MobileComposerStructuredCatalog } from './mobile-native-chat-composer-menu' -import { composerMenuInsertText, type ComposerMenuRow } from './MobileNativeChatComposerMenu' +import type { ComposerMenuRow } from './MobileNativeChatComposerMenu' type Selection = { start: number; end: number } /** Opening the menu from the + sheet narrows it to what that row named. */ @@ -27,21 +31,27 @@ export function useMobileNativeChatComposerMenu(input: { structuredCatalog: MobileComposerStructuredCatalog | undefined filePaths: readonly string[] onNeedFiles?: (query: string) => void + skillDiscovery?: NativeChatSkillDiscoverySnapshot + onNeedSkills?: () => void /** The `/` menu opened; the session's list may have changed since it was read. */ onSlashMenuOpen?: () => void + /** A picked skill; a send carries its file while its token stays, and treats a leading one as chat. */ + onPickSkill?: (skill: { token: string; path?: string }) => void focusField: () => void }) { - const { value, onChangeText, agent, structuredCatalog, filePaths } = input + const { value, onChangeText, agent, structuredCatalog, filePaths, skillDiscovery } = input const [cursor, setCursor] = useState(0) // Drives the caret only right after an insert; a permanently controlled `selection` breaks // manual caret placement in React Native. const [pendingSelection, setPendingSelection] = useState(null) const [menuKind, setMenuKind] = useState(null) - const skillTokens = useMemo( - () => mobileComposerSkillTokens({ agent, structured: structuredCatalog }), - [agent, structuredCatalog] + const catalog = useMemo( + () => + mobileComposerCatalog({ agent, structured: structuredCatalog, discovery: skillDiscovery }), + [agent, skillDiscovery, structuredCatalog] ) + const skillTokens = useMemo(() => mobileComposerSkillTokens(catalog), [catalog]) const skillToken = leadingSkillToken(value, skillTokens) const setDraft = (next: string): void => { if (!next.startsWith('/')) { @@ -57,36 +67,51 @@ export function useMobileNativeChatComposerMenu(input: { } } - const trigger = useMemo(() => detectAutocompleteTrigger(value, cursor), [value, cursor]) - // Memoize by query content so caret movement within it rebuilds nothing. - const menuMode = trigger?.kind ?? null - const menuQuery = trigger?.query ?? '' + // Triggers read only the text before the caret, so edits after it rebuild nothing. + const beforeCaret = value.slice(0, cursor) + const autocomplete = useMemo( + () => + deriveComposerAutocomplete( + beforeCaret, + beforeCaret.length, + catalog.commands, + catalog.discovery.skills, + catalog.profile, + catalog.discovery, + null, + catalog.sessionSkills + ), + [beforeCaret, catalog] + ) const rows = useMemo(() => { - if (menuMode === 'file') { - return rankSuggestions(filePaths, menuQuery).map((path) => ({ + if (autocomplete.mode === 'mention') { + return filePaths.slice(0, MENU_ROWS_PER_GROUP).map((path) => ({ kind: 'file' as const, id: `file:${path}`, path })) } - if (menuMode !== 'slash') { + if (autocomplete.mode !== 'slash') { return [] } - const items = mobileComposerSlashItems({ - agent, - structured: structuredCatalog, - query: menuQuery - }) - return narrowedMenuItems(items, menuKind) - }, [agent, filePaths, menuKind, menuMode, menuQuery, structuredCatalog]) + return narrowedMenuItems(boundedMenuItems(autocomplete.items), menuKind) + }, [autocomplete, filePaths, menuKind]) - const { onNeedFiles, onSlashMenuOpen } = input + const { onNeedFiles, onNeedSkills, onSlashMenuOpen } = input + // Scan disk skills only once a token needs them, as desktop does. + const skillsTriggered = isSkillPickerTriggered(beforeCaret, catalog.profile) useEffect(() => { - if (trigger?.kind === 'file') { - onNeedFiles?.(trigger.query) + if (skillsTriggered) { + onNeedSkills?.() } - }, [onNeedFiles, trigger?.kind, trigger?.query]) - const slashOpen = trigger?.kind === 'slash' + }, [onNeedSkills, skillsTriggered]) + const mentionQuery = autocomplete.mode === 'mention' ? autocomplete.query : null + useEffect(() => { + if (mentionQuery !== null) { + onNeedFiles?.(mentionQuery) + } + }, [onNeedFiles, mentionQuery]) + const slashOpen = autocomplete.mode === 'slash' useEffect(() => { if (slashOpen) { onSlashMenuOpen?.() @@ -99,21 +124,27 @@ export function useMobileNativeChatComposerMenu(input: { } const pick = (row: ComposerMenuRow): void => { - if (!trigger) { + if (autocomplete.mode === 'none') { return } setMenuKind(null) // Restore page field focus after a menu tap so typing continues at the inserted pick. input.focusField() - if (row.kind === 'skill') { - const rest = value.slice(trigger.end).trimStart() - onChangeText(`${row.token} ${rest}`) - placeCaret(row.token.length + 1) + if (row.kind === 'file') { + const next = applyMentionSuggestion(value, cursor, row.path) + setDraft(next.draft) + placeCaret(next.caret) return } - const next = applyAutocomplete(value, trigger, composerMenuInsertText(row)) - setDraft(next.text) - placeCaret(next.cursor) + const next = applyPickerSuggestion(value, cursor, row) + // A skill at the start becomes the leading chip; mid-prompt it stays plain text like desktop. + const leadingSkill = + row.kind === 'skill' && next.insertedToken && next.caret === row.token.length + 1 + setDraft(leadingSkill ? `${row.token} ${value.slice(cursor).trimStart()}` : next.draft) + if (row.kind === 'skill') { + input.onPickSkill?.(row) + } + placeCaret(next.caret) } const openSlashMenu = (kind: MenuKind): void => { diff --git a/mobile/src/session/native-chat/composer/use-mobile-native-chat-composer-tray.test.tsx b/mobile/src/session/native-chat/composer/use-mobile-native-chat-composer-tray.test.tsx index 86d6b2acf31..4919ccd0fd2 100644 --- a/mobile/src/session/native-chat/composer/use-mobile-native-chat-composer-tray.test.tsx +++ b/mobile/src/session/native-chat/composer/use-mobile-native-chat-composer-tray.test.tsx @@ -57,9 +57,11 @@ vi.mock('../../../hooks/use-now', () => ({ useNow: () => 100_000 })) const CARD = { messageId: 'waiting-1', + position: 1, text: 'next', + line: 'next', state: 'waiting' as const, - paused: false, + hold: 'turn' as const, needsAttention: false, caption: null, attribution: null diff --git a/mobile/src/session/native-chat/composer/use-mobile-native-chat-drafts-launch-draft.test.ts b/mobile/src/session/native-chat/composer/use-mobile-native-chat-drafts-launch-draft.test.ts index f620d0361ea..b1604f5245a 100644 --- a/mobile/src/session/native-chat/composer/use-mobile-native-chat-drafts-launch-draft.test.ts +++ b/mobile/src/session/native-chat/composer/use-mobile-native-chat-drafts-launch-draft.test.ts @@ -117,7 +117,35 @@ describe('useMobileNativeChatDrafts launch draft', () => { ) ) - expect(state?.readSeededLaunchDraftSeed()).toEqual({ text: 'issue link', createdAt: 7 }) + expect(state?.readSeededLaunchDraftSeed()).toMatchObject({ text: 'issue link', createdAt: 7 }) + }) + + it('prefills over user turns older than the launch draft, retiring once a new turn lands', async () => { + const older = { ...userTextMessage('m1', 'earlier prompt'), timestamp: 1_000 } + await mount('a') + await act(async () => + renderer?.update( + createElement(Harness, { + tabId: 'a', + messages: [older], + launchDraft: 'issue link', + launchDraftCreatedAt: 100_000 + }) + ) + ) + expect(state?.composerText).toBe('issue link') + + await act(async () => + renderer?.update( + createElement(Harness, { + tabId: 'a', + messages: [older, { ...userTextMessage('m2', 'sent from the TUI'), timestamp: 2_000 }], + launchDraft: 'issue link', + launchDraftCreatedAt: 100_000 + }) + ) + ) + expect(state?.composerText).toBe('') }) it('does not overwrite typed composer text with a launch draft', async () => { diff --git a/mobile/src/session/native-chat/composer/use-mobile-native-chat-file-search.test.ts b/mobile/src/session/native-chat/composer/use-mobile-native-chat-file-search.test.ts index 154e13779c2..afd3914e466 100644 --- a/mobile/src/session/native-chat/composer/use-mobile-native-chat-file-search.test.ts +++ b/mobile/src/session/native-chat/composer/use-mobile-native-chat-file-search.test.ts @@ -92,7 +92,7 @@ describe('useMobileNativeChatFileSearch', () => { }) expect(state?.nativeChatFilePaths).toEqual(['src/apple.ts']) - act(() => state?.loadNativeChatFiles('readme')) + act(() => state?.loadNativeChatFiles('drm')) await act(async () => { await vi.advanceTimersByTimeAsync(120) }) diff --git a/mobile/src/session/native-chat/composer/use-mobile-native-chat-file-search.ts b/mobile/src/session/native-chat/composer/use-mobile-native-chat-file-search.ts index 72188514d22..9450fe35e46 100644 --- a/mobile/src/session/native-chat/composer/use-mobile-native-chat-file-search.ts +++ b/mobile/src/session/native-chat/composer/use-mobile-native-chat-file-search.ts @@ -8,12 +8,21 @@ import { nativeChatFileInventoryRead, nativeChatFileSearchRead } from '../../mobile-session-read-operations' -import { rankSuggestions } from './mobile-native-chat-autocomplete' +import { + getPreparedQuickOpenFiles, + rankQuickOpenFiles +} from '../../../../../src/shared/quick-open-path-search' const FILE_SEARCH_DEBOUNCE_MS = 120 const FILE_SEARCH_RESULT_LIMIT = 16 const FILE_SEARCH_QUERY_CACHE_LIMIT = 20 +function rankInventory(paths: readonly string[], query: string): string[] { + return rankQuickOpenFiles(query, getPreparedQuickOpenFiles(paths), FILE_SEARCH_RESULT_LIMIT).map( + (result) => result.path + ) +} + /** The legacy inventory is the whole workspace, so its request carries no further parameters. */ type WorkspaceInventoryParameters = Readonly> const WHOLE_WORKSPACE: WorkspaceInventoryParameters = {} @@ -114,7 +123,7 @@ export function useMobileNativeChatFileSearch(args: { const inventoryScope: RequestScope = [client, worktreeId, generation] const held = inventory.read(inventoryScope, WHOLE_WORKSPACE) if (held) { - applyPaths(rankSuggestions(held, normalizedQuery, FILE_SEARCH_RESULT_LIMIT)) + applyPaths(rankInventory(held, normalizedQuery)) return } // Share overlapping legacy full-inventory reads, especially across slow SSH. @@ -128,7 +137,7 @@ export function useMobileNativeChatFileSearch(args: { if (!loaded || inventory.commit(loaded.lease, loaded.value) !== 'committed') { return } - applyPaths(rankSuggestions(loaded.value, normalizedQuery, FILE_SEARCH_RESULT_LIMIT)) + applyPaths(rankInventory(loaded.value, normalizedQuery)) } void (async () => { if (!isCurrentRequest()) { diff --git a/mobile/src/session/native-chat/composer/use-mobile-native-chat-launch-draft-seed.ts b/mobile/src/session/native-chat/composer/use-mobile-native-chat-launch-draft-seed.ts index 4151e1f7f50..cede41ed4a4 100644 --- a/mobile/src/session/native-chat/composer/use-mobile-native-chat-launch-draft-seed.ts +++ b/mobile/src/session/native-chat/composer/use-mobile-native-chat-launch-draft-seed.ts @@ -1,11 +1,29 @@ import { useCallback, useEffect, type SetStateAction } from 'react' import type { NativeChatMessage } from '../../../../../src/shared/native-chat-types' +import { + launchDraftResolvedByTranscript, + nativeChatLaunchDraftTurnBaseline, + type NativeChatLaunchDraftTurnBaseline +} from '../../../../../src/shared/native-chat-launch-draft-resolution' import { normalizedUserText } from './mobile-native-chat-draft-reconcile' import { mobileNativeChatLaunchDraftSeeds } from './mobile-native-chat-draft-store' export type MobileNativeChatLaunchDraftSeed = { text: string createdAt: number | null + baseline?: NativeChatLaunchDraftTurnBaseline +} + +function launchDraftResolved( + createdAt: number | null, + messages: readonly NativeChatMessage[], + baseline?: NativeChatLaunchDraftTurnBaseline +): boolean { + // Old hosts send no creation time, so any user turn retires the draft. + if (createdAt === null) { + return messages.some((message) => normalizedUserText(message) !== null) + } + return launchDraftResolvedByTranscript({ createdAt }, messages, baseline) } export function useMobileNativeChatLaunchDraftSeed(args: { @@ -44,13 +62,15 @@ export function useMobileNativeChatLaunchDraftSeed(args: { if (transcriptLoading) { return } - if (messages.some((message) => normalizedUserText(message) !== null)) { + const createdAt = launchDraftCreatedAt ?? null + if (launchDraftResolved(createdAt, messages)) { mobileNativeChatLaunchDraftSeeds.set(draftKey, null) return } mobileNativeChatLaunchDraftSeeds.set(draftKey, { text: launchDraft, - createdAt: launchDraftCreatedAt ?? null + createdAt, + baseline: nativeChatLaunchDraftTurnBaseline(messages) }) setDraftText(draftKey, (current) => (current === '' ? launchDraft : current)) }, [ @@ -73,8 +93,7 @@ export function useMobileNativeChatLaunchDraftSeed(args: { if (!seeded) { return } - const hasUserTurn = messages.some((message) => normalizedUserText(message) !== null) - if (!hasUserTurn && launchDraft?.trim()) { + if (!launchDraftResolved(seeded.createdAt, messages, seeded.baseline) && launchDraft?.trim()) { return } mobileNativeChatLaunchDraftSeeds.set(draftKey, null) diff --git a/mobile/src/session/native-chat/composer/use-mobile-native-chat-skills.test.ts b/mobile/src/session/native-chat/composer/use-mobile-native-chat-skills.test.ts new file mode 100644 index 00000000000..5f8f0b94e9f --- /dev/null +++ b/mobile/src/session/native-chat/composer/use-mobile-native-chat-skills.test.ts @@ -0,0 +1,90 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { RpcClient } from '../../../transport/rpc-client' +import { useMobileNativeChatSkills } from './use-mobile-native-chat-skills' + +function skill(name: string, rootPath: string) { + return { + id: name, + name, + description: null, + providers: ['agent-skills'], + sourceKind: 'home', + sourceLabel: 'Home', + rootPath, + directoryPath: `${rootPath}/${name}`, + skillFilePath: `${rootPath}/${name}/SKILL.md`, + installed: true, + updatedAt: null + } +} + +function source(path: string, owner: string | null) { + return { + id: path, + label: path, + path, + sourceKind: 'home', + providers: ['agent-skills'], + owner, + exists: true + } +} + +const reply = { + id: 'skills', + ok: true as const, + result: { + skills: [skill('shared', '/home/.agents/skills'), skill('claude-only', '/home/.claude/skills')], + sources: [source('/home/.agents/skills', null), source('/home/.claude/skills', 'claude')], + scannedAt: 1 + }, + _meta: { runtimeId: 'runtime-1' } +} + +function fakeClient(sendRequest: unknown): RpcClient { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The hook calls `sendRequest` and `getGeneration` and nothing else on the client; every other member is unreachable from it. + return { sendRequest } as RpcClient +} + +describe('useMobileNativeChatSkills', () => { + let renderer: ReactTestRenderer | null = null + let state: ReturnType | null = null + + async function mount(client: RpcClient, supported: boolean): Promise { + function Harness(): null { + state = useMobileNativeChatSkills({ client, worktreeId: 'wt-1', agent: 'codex', supported }) + return null + } + await act(async () => { + renderer = create(createElement(Harness)) + }) + } + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + }) + + it("asks the host to scan the workspace once, keeping the agent's own skills", async () => { + const sendRequest = vi.fn(async () => reply) + await mount(fakeClient(sendRequest), true) + + await act(async () => state?.loadNativeChatSkills()) + await act(async () => state?.loadNativeChatSkills()) + + expect(sendRequest).toHaveBeenCalledExactlyOnceWith('skills.discover', { worktreeId: 'wt-1' }) + expect(state?.nativeChatSkills.skills.map((entry) => entry.name)).toEqual(['shared']) + }) + + it('lists none from a host that needs the folder the phone does not know', async () => { + const sendRequest = vi.fn(async () => reply) + await mount(fakeClient(sendRequest), false) + + await act(async () => state?.loadNativeChatSkills()) + + expect(sendRequest).not.toHaveBeenCalled() + expect(state?.nativeChatSkills.skills).toEqual([]) + }) +}) diff --git a/mobile/src/session/native-chat/composer/use-mobile-native-chat-skills.ts b/mobile/src/session/native-chat/composer/use-mobile-native-chat-skills.ts new file mode 100644 index 00000000000..5ad40ae94e2 --- /dev/null +++ b/mobile/src/session/native-chat/composer/use-mobile-native-chat-skills.ts @@ -0,0 +1,79 @@ +import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react' +import type { z } from 'zod' +import type { NativeChatSkillDiscoverySnapshot } from '../../../../../src/shared/native-chat/input/native-chat-picker-items' +import { isNativeChatSkillForAgent } from '../../../../../src/shared/native-chat-skill-visibility' +import type { RpcClient } from '../../../transport/rpc-client' +import { nativeChatSkillDiscoveryRead } from '../../mobile-session-read-operations' +import type { skillDiscoveryReplySchema } from '../../session-read-reply-schema' + +type SkillDiscoveryReply = z.output + +type Scan = { status: 'loading' | 'error' } | { status: 'ready'; reply: SkillDiscoveryReply } + +const NO_SKILLS: NativeChatSkillDiscoverySnapshot = { status: 'ready', skills: [] } + +/** The host scans the workspace named by id (desktop sends the pane's folder); hosts without that scan list none. */ +export function useMobileNativeChatSkills(args: { + client: RpcClient | null + worktreeId: string + agent: string | null + supported: boolean +}): { nativeChatSkills: NativeChatSkillDiscoverySnapshot; loadNativeChatSkills: () => void } { + const { client, worktreeId, agent, supported } = args + const [scans, setScans] = useState<{ client: RpcClient | null; byKey: Map }>({ + client: null, + byKey: new Map() + }) + const key = `${client?.getGeneration?.() ?? 0}\0${worktreeId}` + const scan = scans.client === client ? scans.byKey.get(key) : undefined + // Read through refs so a settled failure cannot recreate the loader and refire the menu's request. + const scanRef = useRef(scan) + const clientRef = useRef(client) + useLayoutEffect(() => { + scanRef.current = scan + clientRef.current = client + }, [client, scan]) + + const loadNativeChatSkills = useCallback(() => { + // The phone has no Retry button, so the next menu opening retries a failed scan. + const held = scanRef.current + if (!client || !supported || (held && held.status !== 'error')) { + return + } + const settle = (next: Scan): void => { + // A reply from a replaced connection must not wipe the current one's scans. + if (clientRef.current !== client) { + return + } + setScans((current) => { + const byKey = new Map(current.client === client ? current.byKey : []) + byKey.set(key, next) + return { client, byKey } + }) + } + settle({ status: 'loading' }) + void nativeChatSkillDiscoveryRead + .request(client, { worktreeId }) + .then((response) => { + const accepted = nativeChatSkillDiscoveryRead.interpret(response) + settle(accepted.accepted ? { status: 'ready', reply: accepted.value } : { status: 'error' }) + }) + .catch(() => settle({ status: 'error' })) + }, [client, key, supported, worktreeId]) + + const nativeChatSkills = useMemo(() => { + if (!agent || !supported) { + return NO_SKILLS + } + if (scan?.status !== 'ready') { + return { status: scan?.status ?? 'idle', skills: [] } + } + const { reply } = scan + return { + status: 'ready', + skills: reply.skills.filter((skill) => isNativeChatSkillForAgent(agent, skill, reply)) + } + }, [agent, scan, supported]) + + return { nativeChatSkills, loadNativeChatSkills } +} diff --git a/mobile/src/session/native-chat/composer/use-mobile-native-chat-workspace-catalog.ts b/mobile/src/session/native-chat/composer/use-mobile-native-chat-workspace-catalog.ts new file mode 100644 index 00000000000..f58b395d1cf --- /dev/null +++ b/mobile/src/session/native-chat/composer/use-mobile-native-chat-workspace-catalog.ts @@ -0,0 +1,24 @@ +import type { RpcClient } from '../../../transport/rpc-client' +import type { StructuredAgentSessionHostSupport } from '../../mobile-structured-agent-session-host-support' +import { useMobileNativeChatFileSearch } from './use-mobile-native-chat-file-search' +import { useMobileNativeChatSkills } from './use-mobile-native-chat-skills' + +/** The workspace's files for `@` and its disk-scanned skills for `/` and `$`. */ +export function useMobileNativeChatWorkspaceCatalog( + scope: { + client: RpcClient | null + worktreeId: string + agentSessionHostSupport?: StructuredAgentSessionHostSupport | null + }, + agent: string | null +): ReturnType & ReturnType { + const { client, worktreeId } = scope + const files = useMobileNativeChatFileSearch({ client, worktreeId }) + const skills = useMobileNativeChatSkills({ + client, + worktreeId, + agent, + supported: scope.agentSessionHostSupport?.discoverWorkspaceSkills === true + }) + return { ...files, ...skills } +} diff --git a/mobile/src/session/native-chat/input/MobileNativeChatQueuedMessages.test.tsx b/mobile/src/session/native-chat/input/MobileNativeChatQueuedMessages.test.tsx index cdf3fc7a219..fc7d0ba144a 100644 --- a/mobile/src/session/native-chat/input/MobileNativeChatQueuedMessages.test.tsx +++ b/mobile/src/session/native-chat/input/MobileNativeChatQueuedMessages.test.tsx @@ -56,10 +56,13 @@ vi.mock('../../../components/ActionSheetModal', () => ({ ActionSheetModal: 'Acti function card( overrides: Partial & { messageId: string } ): MobileQueuedMessageCard { + const text = overrides.text ?? `text of ${overrides.messageId}` return { - text: `text of ${overrides.messageId}`, + position: 1, + text, + line: text, state: 'waiting', - paused: false, + hold: overrides.state === 'returned' ? 'returned' : 'turn', needsAttention: false, caption: null, attribution: null, @@ -315,7 +318,7 @@ describe('MobileNativeChatQueuedMessages', () => { cards: [ card({ messageId: 'failed', - paused: true, + hold: 'paused', needsAttention: true, caption: "Couldn't send — tap Send to retry" }), @@ -798,7 +801,7 @@ describe('MobileNativeChatQueuedMessages', () => { expect(mutate).toHaveBeenCalledTimes(1) expect(ids()).toEqual(['c', 'a', 'b', 'd']) expect(list().props.canMove).toBeUndefined() - expect(list().props.items[0].automatic).toBe(true) + expect(list().props.items[0].from).toBeDefined() expect( list().props.items.every((entry: MobileQueuedMessageCard) => list().props.isMoveDisabled(entry) @@ -825,7 +828,8 @@ describe('MobileNativeChatQueuedMessages', () => { ) ).toBe(true) act(() => expect(list().props.onMove({ movedId: 'b', afterId: 'd' })).toBe(true)) - const published = [messages[0]!, messages[2]!, messages[3]!, messages[1]!] + // The host republishes the moved order with fresh positions; cards sort by position. + const published = [0, 2, 3, 1].map((index, position) => ({ ...messages[index]!, position })) await act(async () => renderer!.update(createElement(Harness, { published }))) expect(ids()).toEqual(['a', 'c', 'd', 'b']) await act(async () => renderer!.unmount()) diff --git a/mobile/src/session/native-chat/input/MobileNativeChatQueuedMessages.tsx b/mobile/src/session/native-chat/input/MobileNativeChatQueuedMessages.tsx index 7b466090df3..938c29432ad 100644 --- a/mobile/src/session/native-chat/input/MobileNativeChatQueuedMessages.tsx +++ b/mobile/src/session/native-chat/input/MobileNativeChatQueuedMessages.tsx @@ -7,7 +7,7 @@ import { ActionSheetModal } from '../../../components/ActionSheetModal' import { colors } from '../../../theme/mobile-theme' import { useLargeSystemText } from '../../../theme/use-large-system-text' import type { MobileQueuedMessageCard } from '../../mobile-structured-queued-message-cards' -import type { MobileQueuePause } from '../../mobile-structured-queued-message-feed' +import type { AgentSessionQueuePause } from '../../../../../src/shared/agent-session-wire' import type { MobileQueuedMessageEdit } from '../../use-mobile-structured-queued-message-controls' import type { MobileQueuedMessageInlineEditor } from '../../use-mobile-structured-queued-edit' import { MobileQueuedMessageCardRow } from '../../MobileQueuedMessageCardRow' @@ -28,7 +28,7 @@ export type MobileNativeChatQueuedMessagesProps = { onEdit?: MobileQueuedMessageEdit editor?: MobileQueuedMessageInlineEditor onMove?: (move: DragReorderMove) => Promise - pause?: MobileQueuePause + pause?: AgentSessionQueuePause | null onResume?: () => Promise steerHeld?: boolean } @@ -212,7 +212,7 @@ export function MobileNativeChatQueuedMessages({ { - it('drops a preview the provider flagged as tool output', () => { - // Tool output never lands as assistant text, so a synthetic reply bubble could not retire it. - expect( - mobileNativeChatStreamPreview( - { - lastAssistantMessage: 'Exit code 1\nimport { Foo }', - lastAssistantMessageIsToolOutput: true - }, - true - ) - ).toBeUndefined() - }) - - it('passes assistant prose through while working', () => { - expect(mobileNativeChatStreamPreview({ lastAssistantMessage: 'Working on it' }, true)).toBe( - 'Working on it' - ) - }) - - it('drops any preview once the turn is not working', () => { - expect( - mobileNativeChatStreamPreview({ lastAssistantMessage: 'Working on it' }, false) - ).toBeUndefined() - }) - - it('tolerates a missing status', () => { - expect(mobileNativeChatStreamPreview(null, true)).toBeUndefined() - expect(mobileNativeChatStreamPreview(undefined, true)).toBeUndefined() - }) -}) - -describe('deriveMobileNativeChatStreaming', () => { - it('shows a genuine reply that repeats the previous turn as a prefix', () => { - const prior = [assistant('a1', 'The tests pass.')] - const { results } = run([ - { folded: prior }, - { folded: prior, text: 'The' }, - { folded: prior, text: 'The tests' }, - { folded: prior, text: 'The tests pass.' } - ]) - expect(results).toEqual([null, 'The', 'The tests', 'The tests pass.']) - }) - - it('hides the bubble once the real turn lands leading with the streamed text', () => { - const prior = [assistant('a1', 'earlier turn')] - const landed = [...prior, assistant('a2', 'fresh answer with a tail')] - const { results } = run([ - { folded: prior }, - { folded: prior, text: 'fresh answer' }, - { folded: landed, text: 'fresh answer' } - ]) - expect(results).toEqual([null, 'fresh answer', null]) - }) - - it('keeps the bubble hidden once a thought lands below the streamed reply', () => { - const asked: NativeChatMessage[] = [ - { - id: 'u1', - role: 'user', - blocks: [{ type: 'text', text: 'look' }], - timestamp: 0, - source: 'transcript' - } - ] - const landed = [...asked, assistant('a1', 'Checking the notes file now.')] - const thought: NativeChatMessage = { - id: 'a1:reasoning-2', - role: 'reasoning', - blocks: [{ type: 'text', text: 'The file is short.' }], - timestamp: 0, - source: 'transcript' - } - const { results } = run([ - { folded: asked }, - { folded: asked, text: 'Checking the notes file now.', live: true }, - { folded: landed, text: 'Checking the notes file now.', live: true }, - { folded: [...landed, thought], text: 'Checking the notes file now.', live: true } - ]) - expect(results).toEqual([null, 'Checking the notes file now.', null, null]) - }) - - it('suppresses an identical repeated reply once its own turn lands', () => { - const prior = [assistant('a1', 'Done.')] - const landed = [...prior, assistant('a2', 'Done.')] - const { results } = run([ - { folded: prior }, - { folded: prior, text: 'Done.' }, - { folded: landed, text: 'Done.' } - ]) - expect(results).toEqual([null, 'Done.', null]) - }) - - it('keeps hiding for the rest of a segment after the turn lands', () => { - const prior = [assistant('a1', 'earlier')] - const landed = [...prior, assistant('a2', 'answer body')] - const { results } = run([ - { folded: prior }, - { folded: prior, text: 'answer' }, - { folded: landed, text: 'answer' }, - { folded: landed, text: 'answer bo' } - ]) - expect(results).toEqual([null, 'answer', null, null]) - }) - - it('keeps the segment baseline through textless ticks while the turn is live', () => { - // Returning from hidden chat can restore status text before transcript history, while the turn remains live. - const prior = [assistant('a1', 'Done.')] - const { results } = run([ - { folded: prior }, - { folded: prior, text: 'Done.', live: true }, - { folded: [], live: true }, - { folded: [], text: 'Done.', live: true }, - { folded: prior, text: 'Done.', live: true } - ]) - expect(results).toEqual([null, 'Done.', null, 'Done.', 'Done.']) - }) - - it('still hides after a hidden gap once the reply landed as its own turn', () => { - const prior = [assistant('a1', 'Done.')] - const landed = [...prior, assistant('a2', 'Done.')] - const { results } = run([ - { folded: prior }, - { folded: prior, text: 'Done.', live: true }, - { folded: [], live: true }, - { folded: landed, text: 'Done.', live: true } - ]) - expect(results).toEqual([null, 'Done.', null, null]) - }) - - it('hides a reply whose own turn landed before its status text arrived', () => { - // Transcript pushes can beat throttled status while background work keeps the pane working; anchoring here would duplicate the reply. - const prior = [assistant('a1', 'Done.')] - const landed = [...prior, assistant('a2', 'Done.')] - const { results } = run([ - { folded: prior, live: true }, - { folded: landed, live: true }, - { folded: landed, text: 'Done.', live: true }, - { folded: landed, text: 'Done.', live: true } - ]) - expect(results).toEqual([null, null, null, null]) - }) - - it('keeps the pre-stream baseline across a hidden gap taken between turns', () => { - // Hidden chat tears the transcript down to empty; anchoring there would swallow the next repeated-prefix reply. - const prior = [assistant('a1', 'Done.')] - const { results } = run([ - { folded: prior }, - { folded: [] }, - { folded: [], live: true }, - { folded: prior, text: 'Done.', live: true } - ]) - expect(results).toEqual([null, null, null, 'Done.']) - }) - - it('anchors on the first tail it sees when mounted mid-turn', () => { - const prior = [assistant('a1', 'Done.')] - const { results } = run([ - { folded: prior, live: true }, - { folded: prior, text: 'Done.', live: true } - ]) - expect(results).toEqual([null, 'Done.']) - }) - - it('anchors on a textless tick once the turn ends', () => { - const prior = [assistant('a1', 'first answer')] - const landed = [...prior, assistant('a2', 'second answer')] - const { results } = run([ - { folded: prior }, - { folded: prior, text: 'second answer', live: true }, - { folded: landed }, - { folded: landed, text: 'second answer', live: true } - ]) - expect(results).toEqual([null, 'second answer', null, 'second answer']) - }) - - it('does not treat the previous turn as a segment start after re-anchoring', () => { - // Retaining previous-turn text after re-anchoring would classify the next opener as a new segment and duplicate its landed reply. - const prior = [assistant('a1', 'context')] - const firstLanded = [...prior, assistant('a2', 'Alpha done')] - const secondLanded = [...firstLanded, assistant('a3', 'Beta reply')] - const { results } = run([ - { folded: prior }, - { folded: prior, text: 'Alpha', live: true }, - { folded: firstLanded }, - { folded: secondLanded, text: 'Beta', live: true } - ]) - expect(results).toEqual([null, 'Alpha', null, null]) - }) - - it('re-anchors when a new reply part replaces the stream mid-turn', () => { - const prior = [assistant('a1', 'context')] - const partOneLanded = [...prior, assistant('a2', 'part one full text')] - const { results } = run([ - { folded: prior }, - { folded: prior, text: 'part one' }, - { folded: partOneLanded, text: 'part one' }, - { folded: partOneLanded, text: 'part' } - ]) - expect(results).toEqual([null, 'part one', null, 'part']) - }) - - it('falls back to suppress-on-prefix when text arrives on the first tick', () => { - // Without prior history, prefer briefly hiding a repeated reply over duplicating one already landed. - const landed = [assistant('a1', 'flushed part still streaming in status')] - const { results } = run([{ folded: landed, text: 'flushed part' }]) - expect(results).toEqual([null]) - }) - - it('is idempotent for a repeated tick', () => { - const prior = [assistant('a1', 'The tests pass.')] - const first = run([{ folded: prior }, { folded: prior, text: 'The tests' }]) - const again = deriveMobileNativeChatStreaming(first.gate, prior, 'The tests') - expect(again.streaming).toBe('The tests') - expect(again.gate).toBe(first.gate) - }) - - it('drops a prior chat baseline when the stream identity changes', () => { - // Another chat’s tail cannot establish which replies landed during this segment. - const repeatedId = [assistant('a1', 'new answer landed')] - let gate = createMobileNativeChatStreamingGate('tab-a') - gate = deriveMobileNativeChatStreaming(gate, repeatedId, undefined, { scopeKey: 'tab-a' }).gate - - const switched = deriveMobileNativeChatStreaming(gate, repeatedId, 'new answer', { - scopeKey: 'tab-b' - }) - - expect(switched.streaming).toBeNull() - expect(switched.gate.scopeKey).toBe('tab-b') - expect(switched.gate.baselineTailId).toBeNull() - }) - - it('returns null for empty or whitespace streaming text', () => { - const prior = [assistant('a1', 'x')] - expect(run([{ folded: prior, text: ' ' }]).results).toEqual([null]) - expect(run([{ folded: prior }]).results).toEqual([null]) - }) - - it('shows the first reply of an empty chat and hides it once the turn lands', () => { - const landed = [assistant('a1', 'Hello there')] - const { results } = run([ - { folded: [] }, - { folded: [], text: 'Hello' }, - { folded: landed, text: 'Hello' } - ]) - expect(results).toEqual([null, 'Hello', null]) - }) -}) diff --git a/mobile/src/session/native-chat/mobile-native-chat-streaming-gate.ts b/mobile/src/session/native-chat/mobile-native-chat-streaming-gate.ts deleted file mode 100644 index c0412eee242..00000000000 --- a/mobile/src/session/native-chat/mobile-native-chat-streaming-gate.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { latestNativeChatReplyText } from '../../../../src/shared/native-chat/native-chat-streaming' -import type { NativeChatMessage } from '../../../../src/shared/native-chat-types' - -// Compare prefixes only after the segment’s captured tail so a previous turn’s identical reply cannot swallow a new preview. -export type MobileNativeChatStreamingGate = { - scopeKey: string | null - prevText: string - // Null means no tail was observed before streaming; only then fall back to suppressing any matching prefix. - baselineTailId: string | null -} - -// Tool output never lands as assistant text; treat it as unobserved so it cannot persist as a reply or become the next segment’s baseline. -export function mobileNativeChatStreamPreview( - status: - | { lastAssistantMessage?: string; lastAssistantMessageIsToolOutput?: boolean } - | null - | undefined, - working: boolean -): string | undefined { - if (!working || status?.lastAssistantMessageIsToolOutput === true) { - return undefined - } - return status?.lastAssistantMessage -} - -export function createMobileNativeChatStreamingGate( - scopeKey: string | null = null -): MobileNativeChatStreamingGate { - return { scopeKey, prevText: '', baselineTailId: null } -} - -// Reuse unchanged gates so render-time state adjustments settle by reference. -function advanceGate( - gate: MobileNativeChatStreamingGate, - prevText: string, - baselineTailId: string | null -): MobileNativeChatStreamingGate { - return gate.prevText === prevText && gate.baselineTailId === baselineTailId - ? gate - : { ...gate, prevText, baselineTailId } -} - -// Repeated text/tail observations must keep the same decision across re-renders. -export function deriveMobileNativeChatStreaming( - gate: MobileNativeChatStreamingGate, - folded: readonly NativeChatMessage[], - streamingText: string | undefined, - options: { - scopeKey?: string | null - // Textless ticks during a live turn are missing observations, not stream endings. - streamLive?: boolean - } = {} -): { gate: MobileNativeChatStreamingGate; streaming: string | null } { - const scopeKey = options.scopeKey === undefined ? gate.scopeKey : options.scopeKey - const scopedGate = - gate.scopeKey === scopeKey ? gate : createMobileNativeChatStreamingGate(scopeKey) - const text = streamingText?.trim() ?? '' - const tail = folded.at(-1) - const tailId = tail?.id ?? null - if (!text) { - // Anchor only settled real history, except on first mount: live gaps may already contain the reply and would duplicate it as a preview. - const canAnchor = tailId !== null && (!options.streamLive || scopedGate.baselineTailId === null) - return { gate: canAnchor ? advanceGate(scopedGate, '', tailId) : scopedGate, streaming: null } - } - // A non-extension begins a new segment with the current tail as its baseline. - const segmentStart = scopedGate.prevText !== '' && !text.startsWith(scopedGate.prevText) - const baselineTailId = segmentStart ? tailId : scopedGate.baselineTailId - // Only post-baseline replies can retire this segment; a missing baseline falls back to scanning the whole turn. - const baselineIndex = - baselineTailId === null ? -1 : folded.findIndex((message) => message.id === baselineTailId) - const caughtUp = latestNativeChatReplyText(folded, baselineIndex).startsWith(text) - return { - gate: advanceGate(scopedGate, text, baselineTailId), - streaming: caughtUp ? null : text - } -} diff --git a/mobile/src/session/native-chat/transcript/MobileNativeChatMessage.status-notices.test.ts b/mobile/src/session/native-chat/transcript/MobileNativeChatMessage.status-notices.test.ts new file mode 100644 index 00000000000..6d49c8e45e8 --- /dev/null +++ b/mobile/src/session/native-chat/transcript/MobileNativeChatMessage.status-notices.test.ts @@ -0,0 +1,264 @@ +import { createElement, type ReactNode } from 'react' +import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { AgentJournalStatusItem } from '../../../../../src/shared/agent-session-journal-types' +import { isAdmissibleAgentJournalItemBody } from '../../../../../src/shared/agent-session-journal-schemas' +import { projectStructuredItemToNativeChat } from '../../../../../src/shared/structured-agent-session-projection' +import { + NATIVE_CHAT_INTERRUPTED_STATUS_TEXT, + type NativeChatMessage +} from '../../../../../src/shared/native-chat-types' +import { + agentSessionFailureFact, + readProviderRetry +} from '../../../../../src/shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../../../src/shared/agent-session-failure-words' +import { colors } from '../../../theme/mobile-theme' +import { + MobileNativeChatNoticeContext, + type MobileNativeChatNoticeNames +} from '../../mobile-native-chat-notice-context' + +vi.mock('react-native', async () => { + const React = await import('react') + const Text = ({ children, ...props }: { children?: ReactNode }): unknown => + React.createElement('Text', props, children) + return { + ActivityIndicator: 'ActivityIndicator', + Animated: { + Text, + Value: class { + setValue(): void {} + }, + loop: (animation: unknown) => animation, + sequence: () => ({ start: vi.fn(), stop: vi.fn() }), + timing: () => ({ start: vi.fn(), stop: vi.fn() }) + }, + Image: 'Image', + Platform: { OS: 'ios' }, + Pressable: 'Pressable', + ScrollView: ({ children, ...props }: { children?: ReactNode }) => + React.createElement('ScrollView', props, children), + Text, + View: ({ children, ...props }: { children?: ReactNode }) => + React.createElement('View', props, children), + StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 }, + useWindowDimensions: () => ({ width: 400, height: 800 }) + } +}) +vi.mock('expo-clipboard', () => ({ setStringAsync: vi.fn() })) +vi.mock('lucide-react-native', () => ({ + AlertCircle: 'AlertCircle', + AlertTriangle: 'AlertTriangle', + ArrowUp: 'ArrowUp', + Bot: 'Bot', + Brain: 'Brain', + Check: 'Check', + ChevronDown: 'ChevronDown', + ChevronRight: 'ChevronRight', + Copy: 'Copy', + Eye: 'Eye', + FileMinus2: 'FileMinus2', + FilePen: 'FilePen', + FilePlus2: 'FilePlus2', + Folder: 'Folder', + Globe: 'Globe', + Info: 'Info', + ListChecks: 'ListChecks', + MessageSquareMore: 'MessageSquareMore', + Pencil: 'Pencil', + Plug: 'Plug', + Search: 'Search', + SquareChevronRight: 'SquareChevronRight', + SquareTerminal: 'SquareTerminal', + Wrench: 'Wrench' +})) +vi.mock('../../../components/MobileMarkdown', () => ({ MobileMarkdown: 'MobileMarkdown' })) +vi.mock('./MobileNativeChatMessageActionsSheet', () => ({ + MobileNativeChatMessageActionsSheet: 'MessageActionsSheet' +})) + +import { MobileNativeChatMessage } from './MobileNativeChatMessage' + +describe('MobileNativeChatMessage status notices', () => { + let renderer: ReactTestRenderer | null = null + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + }) + + function render(message: NativeChatMessage): ReactTestRenderer { + act(() => { + renderer = create(createElement(MobileNativeChatMessage, { message })) + }) + return renderer! + } + + const markdownIn = (node: ReactTestInstance): string[] => + node + .findAll((child) => String(child.type) === 'MobileMarkdown') + .map((markdown) => markdown.props.content) + + const statusRow = (body: AgentJournalStatusItem): NativeChatMessage => + projectStructuredItemToNativeChat({ + itemId: 'status', + revision: 1, + sequence: 2, + observedAt: 2, + body + })! + const renderNotice = (body: AgentJournalStatusItem, names: MobileNativeChatNoticeNames) => { + act(() => { + renderer = create( + createElement( + MobileNativeChatNoticeContext.Provider, + { value: names }, + createElement(MobileNativeChatMessage, { message: statusRow(body) }) + ) + ) + }) + return renderer!.root.find( + (node) => String(node.type) === 'Text' && node.props.selectable === true + ) + } + + it('names the Orca host an Orca stop came from, in desktop words', () => { + const text = renderNotice( + { + kind: 'status', + text: 'Codex stopped while this response was in progress.', + tone: 'error', + presentation: 'orca-stop', + orcaStop: { cause: 'quit' } + }, + { hostName: 'MacBook Pro' } + ) + expect(text.props.children).toBe( + 'Orca on MacBook Pro stopped while this response was in progress. You can continue in this conversation.' + ) + }) + + it.each(['without reset', 'with reset', 'from an old host'] as const)( + 'draws an OpenCode usage warning %s', + (source) => { + const hasReset = source === 'with reset' + const resetsAt = new Date(2026, 9, 15, 15, 42).getTime() + const now = vi.spyOn(Date, 'now').mockReturnValue(new Date(2026, 9, 15, 12).getTime()) + try { + const host = agentSessionFailureWords( + agentSessionFailureFact('usageLimitReached', { + detail: { + text: 'Internal error: Rate limit exceeded. Please try again later.', + audience: 'person' + }, + ...(hasReset ? { usageLimit: { resetsAt } } : {}) + }), + { surface: 'row', agentName: 'OpenCode' } + ) + expect(host.text).toBe('OpenCode hit its usage limit.') + const hostText = + source === 'from an old host' + ? 'OpenCode hit its usage limit. Send a message once it resets to continue.' + : host.text + const body: unknown = { kind: 'status', tone: 'warning', ...host, text: hostText } + if (!isAdmissibleAgentJournalItemBody(body) || body.kind !== 'status') { + throw new Error('Status fixture was not admitted') + } + const text = renderNotice(body, { hostName: null, agentName: 'OpenCode' }) + const tree = renderer! + // An old host's stored advice gives way to desktop's sentence from the fact. + expect(text.props.children).toEqual( + hasReset + ? expect.stringMatching(/^OpenCode hit its usage limit\. It resets at \S.*\.$/u) + : 'OpenCode hit its usage limit.' + ) + expect(Object.assign({}, ...text.props.style)).toMatchObject({ color: colors.statusAmber }) + expect(tree.root.find((node) => String(node.type) === 'AlertTriangle').props.color).toBe( + colors.statusAmber + ) + expect(markdownIn(tree.root)).toEqual([]) + } finally { + now.mockRestore() + } + } + ) + + it.each([ + [ + 'an API retry as a warning', + statusRow({ + kind: 'status', + tone: 'warning', + ...agentSessionFailureWords( + agentSessionFailureFact('providerRetrying', { + retry: readProviderRetry({ + error: 'overloaded', + status: 529, + attempt: 2, + maxRetries: 10 + }) + }), + { surface: 'row', agentName: 'Claude' } + ) + }), + colors.statusAmber + ], + [ + 'a sign-in failure as an error', + statusRow({ + kind: 'status', + tone: 'error', + ...agentSessionFailureWords(agentSessionFailureFact('notSignedIn'), { + agentName: 'Claude', + provider: 'claude', + surface: 'row' + }) + }), + colors.statusRed + ], + ...[ + 'Please add or update your payment method to continue', + 'Your current subscription plan does not yet include access to glm-5' + ].map( + (instruction) => + [ + `an OpenCode corrective instruction: ${instruction}`, + statusRow({ kind: 'status', tone: 'error', text: `Internal error: ${instruction}` }), + colors.statusRed + ] as const + ), + [ + 'an interruption as a faint aside', + { + id: 'interrupted', + role: 'system', + blocks: [{ type: 'text', text: NATIVE_CHAT_INTERRUPTED_STATUS_TEXT }], + timestamp: 1, + source: 'transcript' + } satisfies NativeChatMessage, + colors.textMuted + ] + ])('draws %s, never as the agent speaking', (_, message, color) => { + const tree = render(message) + expect(tree.root.findAll((node) => String(node.type) === 'MobileMarkdown')).toHaveLength(0) + const text = tree.root.find( + (node) => String(node.type) === 'Text' && node.props.selectable === true + ) + expect(text.props.children).toBe(message.blocks.find((block) => block.type === 'text')?.text) + expect(Object.assign({}, ...text.props.style)).toMatchObject({ color }) + }) + + it('draws a compaction as a divider, not as a reply', () => { + const tree = render( + statusRow({ kind: 'status', text: 'Context compacted', presentation: 'compaction' }) + ) + expect(tree.root.findAll((node) => String(node.type) === 'MobileMarkdown')).toHaveLength(0) + expect( + tree.root.findAll( + (node) => + String(node.type) === 'View' && node.props.accessibilityLabel === 'Context compacted' + ) + ).toHaveLength(1) + }) +}) diff --git a/mobile/src/session/native-chat/transcript/MobileNativeChatMessage.test.ts b/mobile/src/session/native-chat/transcript/MobileNativeChatMessage.test.ts index 21a4a8d6842..90bdd92e81f 100644 --- a/mobile/src/session/native-chat/transcript/MobileNativeChatMessage.test.ts +++ b/mobile/src/session/native-chat/transcript/MobileNativeChatMessage.test.ts @@ -1,19 +1,9 @@ import { createElement, type ReactNode } from 'react' import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer' import { afterEach, describe, expect, it, vi } from 'vitest' -import type { AgentJournalStatusItem } from '../../../../../src/shared/agent-session-journal-types' -import { isAdmissibleAgentJournalItemBody } from '../../../../../src/shared/agent-session-journal-schemas' import { projectStructuredItemToNativeChat } from '../../../../../src/shared/structured-agent-session-projection' -import { - NATIVE_CHAT_INTERRUPTED_STATUS_TEXT, - type NativeChatMessage -} from '../../../../../src/shared/native-chat-types' +import type { NativeChatMessage } from '../../../../../src/shared/native-chat-types' import { AGENT_SESSION_HOST_STATUS_COPY } from '../../../../../src/shared/agent-session-host-status-rows' -import { - agentSessionFailureFact, - readProviderRetry -} from '../../../../../src/shared/agent-session-failure' -import { agentSessionFailureWords } from '../../../../../src/shared/agent-session-failure-words' import { structuredQuestionTranscript } from '../../../../../src/shared/structured-agent-question-projection' import type { NativeChatResolvedPrompt } from '../../../../../src/shared/native-chat-resolution-receipt' import { colors, typography } from '../../../theme/mobile-theme' @@ -66,6 +56,7 @@ vi.mock('lucide-react-native', () => ({ FilePlus2: 'FilePlus2', Folder: 'Folder', Globe: 'Globe', + Info: 'Info', ListChecks: 'ListChecks', MessageSquareMore: 'MessageSquareMore', Pencil: 'Pencil', @@ -332,139 +323,6 @@ describe('MobileNativeChatMessage', () => { ) }) - const statusRow = (body: AgentJournalStatusItem): NativeChatMessage => - projectStructuredItemToNativeChat({ - itemId: 'status', - revision: 1, - sequence: 2, - observedAt: 2, - body - })! - - it.each(['without reset', 'with reset', 'from an old host'] as const)( - 'draws an OpenCode usage warning %s', - (source) => { - const hasReset = source === 'with reset' - const resetsAt = new Date(2026, 9, 15, 15, 42).getTime() - const now = vi.spyOn(Date, 'now').mockReturnValue(new Date(2026, 9, 15, 12).getTime()) - try { - const host = agentSessionFailureWords( - agentSessionFailureFact('usageLimitReached', { - detail: { - text: 'Internal error: Rate limit exceeded. Please try again later.', - audience: 'person' - }, - ...(hasReset ? { usageLimit: { resetsAt } } : {}) - }), - { surface: 'row', agentName: 'OpenCode' } - ) - expect(host.text).toBe('OpenCode hit its usage limit.') - const hostText = - source === 'from an old host' - ? 'OpenCode hit its usage limit. Send a message once it resets to continue.' - : host.text - const body: unknown = { kind: 'status', tone: 'warning', ...host, text: hostText } - if (!isAdmissibleAgentJournalItemBody(body) || body.kind !== 'status') { - throw new Error('Status fixture was not admitted') - } - const tree = render(statusRow(body)) - const text = tree.root.find( - (node) => String(node.type) === 'Text' && node.props.selectable === true - ) - expect(text.props.children).toEqual( - hasReset - ? expect.stringMatching(/^OpenCode hit its usage limit\. It resets at \S.*\.$/u) - : hostText - ) - expect(Object.assign({}, ...text.props.style)).toMatchObject({ color: colors.statusAmber }) - expect(tree.root.find((node) => String(node.type) === 'AlertTriangle').props.color).toBe( - colors.statusAmber - ) - expect(markdownIn(tree.root)).toEqual([]) - } finally { - now.mockRestore() - } - } - ) - - it.each([ - [ - 'an API retry as a warning', - statusRow({ - kind: 'status', - tone: 'warning', - ...agentSessionFailureWords( - agentSessionFailureFact('providerRetrying', { - retry: readProviderRetry({ - error: 'overloaded', - status: 529, - attempt: 2, - maxRetries: 10 - }) - }), - { surface: 'row', agentName: 'Claude' } - ) - }), - colors.statusAmber - ], - [ - 'a sign-in failure as an error', - statusRow({ - kind: 'status', - tone: 'error', - ...agentSessionFailureWords(agentSessionFailureFact('notSignedIn'), { - agentName: 'Claude', - provider: 'claude', - surface: 'row' - }) - }), - colors.statusRed - ], - ...[ - 'Please add or update your payment method to continue', - 'Your current subscription plan does not yet include access to glm-5' - ].map( - (instruction) => - [ - `an OpenCode corrective instruction: ${instruction}`, - statusRow({ kind: 'status', tone: 'error', text: `Internal error: ${instruction}` }), - colors.statusRed - ] as const - ), - [ - 'an interruption as a faint aside', - { - id: 'interrupted', - role: 'system', - blocks: [{ type: 'text', text: NATIVE_CHAT_INTERRUPTED_STATUS_TEXT }], - timestamp: 1, - source: 'transcript' - } satisfies NativeChatMessage, - colors.textMuted - ] - ])('draws %s, never as the agent speaking', (_, message, color) => { - const tree = render(message) - expect(tree.root.findAll((node) => String(node.type) === 'MobileMarkdown')).toHaveLength(0) - const text = tree.root.find( - (node) => String(node.type) === 'Text' && node.props.selectable === true - ) - expect(text.props.children).toBe(message.blocks.find((block) => block.type === 'text')?.text) - expect(Object.assign({}, ...text.props.style)).toMatchObject({ color }) - }) - - it('draws a compaction as a divider, not as a reply', () => { - const tree = render( - statusRow({ kind: 'status', text: 'Context compacted', presentation: 'compaction' }) - ) - expect(tree.root.findAll((node) => String(node.type) === 'MobileMarkdown')).toHaveLength(0) - expect( - tree.root.findAll( - (node) => - String(node.type) === 'View' && node.props.accessibilityLabel === 'Context compacted' - ) - ).toHaveLength(1) - }) - it.each(['system', 'user'] as const)( 'renders a %s host notice as selectable muted text rather than a markdown answer', (role) => { @@ -764,7 +622,7 @@ describe('MobileNativeChatMessage', () => { it('renders the settled turn status row under a user message', () => { const tree = render(userMessage([{ type: 'text', text: 'go' }]), { structuredActivityUi: true, - turnStatus: { startedAt: Date.now() - 3_000, thinking: false, workedSeconds: 3 } + turnStatus: { startedAt: 1_700_000_000_000, thinking: false, workedSeconds: 3 } }) expect(textIn(tree.root)).toContain('Worked for 3s') }) diff --git a/mobile/src/session/native-chat/transcript/MobileNativeChatNoticeRow.tsx b/mobile/src/session/native-chat/transcript/MobileNativeChatNoticeRow.tsx index 2ecbd99f97f..0cf1a296d0a 100644 --- a/mobile/src/session/native-chat/transcript/MobileNativeChatNoticeRow.tsx +++ b/mobile/src/session/native-chat/transcript/MobileNativeChatNoticeRow.tsx @@ -1,3 +1,4 @@ +import { useContext } from 'react' import { StyleSheet, Text, View } from 'react-native' import { AlertCircle, AlertTriangle, Info } from 'lucide-react-native' import { AGENT_SESSION_HOST_STATUS_COPY } from '../../../../../src/shared/agent-session-host-status-rows' @@ -7,8 +8,22 @@ import { nativeChatNoticeLook, type NativeChatNoticeTone } from '../../../../../src/shared/native-chat-notice' -import { isTextBlock, type NativeChatBlock } from '../../../../../src/shared/native-chat-types' -import { usageLimitLineText } from '../../../../../src/shared/agent-session-usage-limit-words' +import { + isTextBlock, + type NativeChatBlock, + type NativeChatTextBlock +} from '../../../../../src/shared/native-chat-types' +import { readWholeAgentSessionFailureFact } from '../../../../../src/shared/agent-session-failure' +import { agentSessionFailureSentence } from '../../../../../src/shared/agent-session-failure-words' +import { formatUsageLimitResetClock } from '../../../../../src/shared/agent-session-usage-limit' +import { + nativeChatOrcaStopRowText, + sayNativeChatOrcaStopEnglish +} from '../../../../../src/shared/native-chat/native-chat-orca-stop-words' +import { + MobileNativeChatNoticeContext, + type MobileNativeChatNoticeNames +} from '../../mobile-native-chat-notice-context' import { MobileMarkdown } from '../../../components/MobileMarkdown' import { INLINE_TEXT_SELECTION } from '../../../components/inline-text-selection' import { colors, radii, spacing, typography } from '../../../theme/mobile-theme' @@ -20,6 +35,24 @@ const TONE_COLOR: Record = { notice: colors.textMuted } +// Desktop's no-Continue wording; the phone always reads its Orca host as remote. +function noticeLineText(notice: NativeChatTextBlock, names: MobileNativeChatNoticeNames): string { + if (notice.orcaStop) { + return nativeChatOrcaStopRowText( + notice.orcaStop.cause, + names.hostName ?? sayNativeChatOrcaStopEnglish('unnamedServer'), + { continueAvailable: false, remoteHost: true } + ) + } + const failure = readWholeAgentSessionFailureFact(notice.failure) + return failure?.kind === 'usageLimitReached' + ? agentSessionFailureSentence(failure, 'row', { + ...(names.agentName ? { agentName: names.agentName } : {}), + usageLimitReset: formatUsageLimitResetClock + }) + : notice.text +} + // Render system notices separately so they cannot read as the agent’s own prose. export function MobileNativeChatNoticeRow({ blocks, @@ -32,6 +65,7 @@ export function MobileNativeChatNoticeRow({ onOpenFile?: (relativePath: string) => void onLongPress?: () => void }): React.JSX.Element | null { + const names = useContext(MobileNativeChatNoticeContext) const notice = nativeChatNoticeBlock(blocks) const aside = [styles.faint, scaledType(typography.bodySize, fontScale)] if (!notice) { @@ -95,7 +129,7 @@ export function MobileNativeChatNoticeRow({ ) case 'line': { - const lineText = usageLimitLineText(notice) + const lineText = noticeLineText(notice, names) const textStyle = [ styles.line, scaledType(TEXT_SIZE, fontScale), diff --git a/mobile/src/session/native-chat/transcript/use-mobile-native-chat-message-send.ts b/mobile/src/session/native-chat/transcript/use-mobile-native-chat-message-send.ts index a9e2921da99..9d4a0fe42bc 100644 --- a/mobile/src/session/native-chat/transcript/use-mobile-native-chat-message-send.ts +++ b/mobile/src/session/native-chat/transcript/use-mobile-native-chat-message-send.ts @@ -7,10 +7,11 @@ import { typeMobileNativeChatCommandWithOutcome, type MobileNativeChatSendOutcome } from '../mobile-native-chat-send' -import type { CatalogCommandDelivery } from '../../../../../src/shared/agent-session-option-catalog-types' +import type { CatalogCommandDelivery } from '../../../../../src/shared/agent-session-option-catalog' import { isSlashCommandDraft } from '../../../../../src/shared/native-chat/input/native-chat-slash-commands' import { healMobileNativeChatStaleInput } from '../mobile-native-chat-stale-input' import { classifyMobileNativeChatSend } from '../mobile-native-chat-send-classification' +import { readMobileNativeChatSkillOrigin } from '../composer/mobile-native-chat-draft-store' import { acquireMobileNativeChatTerminalWrite, releaseMobileNativeChatTerminalWrite @@ -109,12 +110,14 @@ export function useMobileNativeChatMessageSend(args: { onSendError('Message not sent') return 'rejected' } + // A picked skill leading the draft is chat, as on desktop; read it before the clear forgets it. + const skillOrigin = readMobileNativeChatSkillOrigin(origin.draftKey) // Clear optimistically before slow/lost acknowledgements; only definite rejection restores the draft. if (syncComposer) { clearDraftForSend(origin, draftText) } const seededLaunchDraft = readSeededLaunchDraftSeed() - const classification = classifyMobileNativeChatSend(agent, text) + const classification = classifyMobileNativeChatSend(agent, text, skillOrigin) const typesCodexCommand = agent === 'codex' && classification !== 'chat' && diff --git a/mobile/src/session/native-chat/use-mobile-native-chat-streaming-bubble.ts b/mobile/src/session/native-chat/use-mobile-native-chat-streaming-bubble.ts deleted file mode 100644 index c4f6435d8cd..00000000000 --- a/mobile/src/session/native-chat/use-mobile-native-chat-streaming-bubble.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { useState } from 'react' -import type { NativeChatMessage } from '../../../../src/shared/native-chat-types' -import { - createMobileNativeChatStreamingGate, - deriveMobileNativeChatStreaming -} from './mobile-native-chat-streaming-gate' - -// Keep the segment baseline above list remounts so view toggles preserve repeated-prefix previews; streamLive distinguishes hidden gaps from turn end. -export function useMobileNativeChatStreamingBubble( - folded: readonly NativeChatMessage[], - streamingText: string | undefined, - scopeKey: string, - streamLive: boolean -): string | null { - const [gate, setGate] = useState(() => createMobileNativeChatStreamingGate(scopeKey)) - const step = deriveMobileNativeChatStreaming(gate, folded, streamingText, { - scopeKey, - streamLive - }) - if (step.gate !== gate) { - // Idempotent derived-state adjustment settles after one render for repeated text/tail. - setGate(step.gate) - } - return step.streaming -} diff --git a/mobile/src/session/session-read-reply-schema.ts b/mobile/src/session/session-read-reply-schema.ts index 82e6b3e44b6..e10afaa6947 100644 --- a/mobile/src/session/session-read-reply-schema.ts +++ b/mobile/src/session/session-read-reply-schema.ts @@ -69,3 +69,37 @@ export const markdownTabDocumentSchema = z.looseObject({ // Keep generic tab snapshots and legacy error/window history unions opaque until callers can select their shape. export const sessionForwardedReplySchema = z.unknown() + +const skillProviders = z.array(z.enum(['codex', 'claude', 'agent-skills'])) +const skillSourceKind = z.enum(['home', 'repo', 'bundled', 'plugin']) + +// Rows a newer host shapes differently drop alone; the rest of the scan still lists. +export const skillDiscoveryReplySchema = z.looseObject({ + skills: salvagingArray( + z.looseObject({ + id: z.string(), + name: z.string(), + description: z.string().nullable(), + providers: skillProviders, + sourceKind: skillSourceKind, + sourceLabel: z.string(), + rootPath: z.string(), + rootPaths: z.array(z.string()).optional(), + directoryPath: z.string(), + skillFilePath: z.string(), + installed: z.boolean(), + updatedAt: z.number().nullable() + }) + ), + sources: salvagingArray( + z.looseObject({ + id: z.string(), + label: z.string(), + path: z.string(), + sourceKind: skillSourceKind, + providers: skillProviders, + owner: z.string().nullable(), + exists: z.boolean() + }) + ) +}) diff --git a/mobile/src/session/use-mobile-bridge-chat-prompt-writes.ts b/mobile/src/session/use-mobile-bridge-chat-prompt-writes.ts index d0d19bd7994..a9cca23f021 100644 --- a/mobile/src/session/use-mobile-bridge-chat-prompt-writes.ts +++ b/mobile/src/session/use-mobile-bridge-chat-prompt-writes.ts @@ -21,7 +21,7 @@ export function useMobileBridgeChatPromptWrites(args: { answerAsk: MobileNativeChatAnswerSend['answerAsk'] cancelAsk: () => Promise respondPermission: (send: string) => Promise - stop: () => void + stop: () => Promise } { const { client, enabled, handleRef, deviceTokenRef, streamIdentity, onSendError } = args const { answerAsk, cancelPending } = useMobileNativeChatAnswerSend({ @@ -55,6 +55,7 @@ export function useMobileBridgeChatPromptWrites(args: { enabled, handleRef, deviceTokenRef, + agentRef: args.agentRef, streamIdentity, cancelPending, onSendError diff --git a/mobile/src/session/use-mobile-native-chat-active-resolution.ts b/mobile/src/session/use-mobile-native-chat-active-resolution.ts index d51b77b7a11..c46f762669c 100644 --- a/mobile/src/session/use-mobile-native-chat-active-resolution.ts +++ b/mobile/src/session/use-mobile-native-chat-active-resolution.ts @@ -30,11 +30,9 @@ export function useMobileNativeChatActiveResolution(args: { activeChatSessionId: string | null activeChatStructured: boolean activeChatResolution: ReturnType - activeTabAgentWorking: boolean nativeChatStatus: MobileNativeChatTab['agentStatus'] | null sourceIdentity: string streamIdentity: string - streamScopeKey: string } { const { activeHandleRef, @@ -71,16 +69,9 @@ export function useMobileNativeChatActiveResolution(args: { const activeChatStructured = activeChatResolution != null && activeSessionTab?.type === 'agent-session' const activeTabStatus = activeSessionTab?.agentStatus - // Why: a turn restored across a host restart is unverifiable, so it must not offer Stop. - const activeTabAgentWorking = - activeTabStatus?.state === 'working' && - activeTabStatus.workingMode !== 'monitoring' && - activeTabStatus.restoredUnconfirmed !== true const nativeChatStatus = activeChatResolution && !activeChatStructured ? activeTabStatus : null const routeKey = `${hostId}\0${worktreeId}\0${activeSessionTabId ?? ''}` const streamIdentity = `${routeKey}\0${activeChatSessionId ?? ''}\0${activeHandleRef.current ?? ''}` - const providerSessionId = activeSessionTab?.agentStatus?.providerSession?.id ?? '' - const streamScopeKey = `${routeKey}\0${activeChatSessionId ?? providerSessionId}\0${activeHandleRef.current ?? ''}` return { sessionViewReady, @@ -95,10 +86,8 @@ export function useMobileNativeChatActiveResolution(args: { activeChatSessionId, activeChatStructured, activeChatResolution, - activeTabAgentWorking, nativeChatStatus, sourceIdentity: encodeNativeChatTranscriptIdentity([hostId, worktreeId]), - streamIdentity, - streamScopeKey + streamIdentity } } 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 3bc05bd692c..ff28e619ff5 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.test.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.test.ts @@ -13,6 +13,7 @@ const restoreRejectedDraft = vi.fn() const holdUnconfirmedSend = vi.fn() const viewMode = { isTabChatView: (_tabId: string) => true } +let draftPending: { id: string }[] = [] const sessionState = { messages: [] as unknown[], status: 'ready', transcriptLoading: false } const structuredSendWithOutcome = vi.fn() const structuredCancel = vi.fn() @@ -112,7 +113,7 @@ vi.mock('./native-chat/composer/use-mobile-native-chat-drafts', () => ({ return { draftKey: null, setComposerText: vi.fn(), - pending: [], + pending: draftPending, imagePreviewsByMessageId: {}, captureSendOrigin, readSeededLaunchDraft: () => null, @@ -137,8 +138,9 @@ vi.mock('./native-chat/input/use-mobile-native-chat-answer-send', () => ({ vi.mock('./native-chat/input/mobile-native-chat-permission-send', () => ({ useMobileNativeChatPermissionSend: () => vi.fn() })) +const terminalStop = vi.fn(async () => true) vi.mock('./use-mobile-native-chat-stop', () => ({ - useMobileNativeChatStop: () => vi.fn() + useMobileNativeChatStop: () => terminalStop })) vi.mock('./native-chat/composer/use-mobile-native-chat-file-search', () => ({ useMobileNativeChatFileSearch: () => ({ nativeChatFilePaths: [], loadNativeChatFiles: vi.fn() }) @@ -320,7 +322,13 @@ describe('useMobileNativeChatController handleNativeChatSend', () => { }) expect(accepted).toBe(true) - expect(structuredSendWithOutcome).toHaveBeenCalledWith('look') + expect(structuredSendWithOutcome).toHaveBeenCalledWith( + 'look', + undefined, + undefined, + undefined, + [] + ) expect(sendWithOutcome).not.toHaveBeenCalled() expect(clientStub.sendRequest).not.toHaveBeenCalled() }) @@ -332,32 +340,35 @@ describe('useMobileNativeChatController handleNativeChatSend', () => { await act(async () => { await controller!.handleNativeChatSend('/review') }) - expect(structuredSendWithOutcome).toHaveBeenCalledWith('/review') + expect(structuredSendWithOutcome).toHaveBeenCalledWith( + '/review', + undefined, + undefined, + undefined, + [] + ) expect(acceptSend).not.toHaveBeenCalled() expect(restoreRejectedDraft).not.toHaveBeenCalled() expect(controller!.nativeChatAcceptsImages).toBe(false) }) - it('separates structured working status from provider cancellation availability', async () => { + it('offers prompt Cancel only while a provider turn runs', async () => { const props = structuredTab('codex') structuredActivity.isWorking = true try { await act(async () => { renderer?.update(createElement(Harness, props)) }) - expect(controller?.nativeChatAgentWorking).toBe(true) - expect(controller?.nativeChatCanStop).toBe(false) + expect(controller?.nativeChatTurnActive).toBe(true) expect(controller?.handleNativeChatCancelPrompt).toBeUndefined() structuredActivity.turnId = 'provider-turn' await act(async () => { renderer?.update(createElement(Harness, props)) }) - expect(controller?.nativeChatCanStop).toBe(true) expect(controller?.handleNativeChatCancelPrompt).toEqual(expect.any(Function)) structuredActivity.turnId = null harnessHostSupport = structuredAgentSessionHostSupport(['agent-session.conversation-stop.v1']) await act(async () => renderer?.update(createElement(Harness, props))) - expect(controller?.nativeChatCanStop).toBe(true) expect(controller?.handleNativeChatCancelPrompt).toBeUndefined() } finally { harnessHostSupport = null @@ -684,10 +695,11 @@ describe('useMobileNativeChatController ask dismissal across a transcript reload }) }) -describe('useMobileNativeChatController streaming scope', () => { +describe('useMobileNativeChatController terminal turn', () => { let renderer: ReactTestRenderer | null = null let controller: MobileNativeChatController | null = null const clientStub = { sendRequest: vi.fn() } + const turnStartedAt = 1_700_000_000_000 const workingTab = { type: 'terminal', @@ -699,7 +711,10 @@ describe('useMobileNativeChatController streaming scope', () => { workingMode: undefined as 'monitoring' | undefined, agentType: 'claude', lastAssistantMessage: 'Partial reply', - providerSession: { id: 'session-1' } + providerSession: { id: 'session-1' }, + updatedAt: turnStartedAt, + stateStartedAt: turnStartedAt, + turnStartedAt }, isActive: true } @@ -722,9 +737,17 @@ describe('useMobileNativeChatController streaming scope', () => { return null } + function rerender(): void { + act(() => renderer?.update(createElement(Harness))) + } + beforeEach(() => { + // Hook freshness is read against the clock, so pin it to the hook's own update. + vi.useFakeTimers({ toFake: ['Date'] }) + vi.setSystemTime(turnStartedAt) viewMode.isTabChatView = () => true workingTab.agentStatus.workingMode = undefined + workingTab.agentStatus.stateStartedAt = turnStartedAt act(() => { renderer = create(createElement(Harness)) }) @@ -735,44 +758,72 @@ describe('useMobileNativeChatController streaming scope', () => { renderer = null controller = null viewMode.isTabChatView = () => true + Reflect.deleteProperty(sessionState, 'transcriptLifecycle') + vi.useRealTimers() }) - it('holds the stream scope and liveness while the user peeks at the terminal', () => { - expect(controller?.showNativeChat).toBe(true) - expect(controller?.nativeChatAgentWorking).toBe(true) - const scopeKey = controller?.nativeChatStreamScopeKey - expect(scopeKey).toContain('session-1') - expect(controller?.nativeChatStreamLive).toBe(true) - - viewMode.isTabChatView = () => false - act(() => renderer?.update(createElement(Harness))) - - expect(controller?.showNativeChat).toBe(false) - expect(controller?.nativeChatAgentWorking).toBe(false) - expect(controller?.nativeChatStreamScopeKey).toBe(scopeKey) - expect(controller?.nativeChatStreamLive).toBe(true) + it('runs the turn clock and streams the hook preview while the agent works', () => { + expect(controller?.nativeChatTurnActive).toBe(true) + expect(controller?.nativeChatCanStop).toBe(true) + expect(controller?.nativeChatStreamingText).toBe('Partial reply') + expect(controller?.nativeChatWorkingStartedAt).toBe(turnStartedAt) }) it('treats passive monitoring as outside the foreground turn', () => { workingTab.agentStatus.workingMode = 'monitoring' - act(() => renderer?.update(createElement(Harness))) + rerender() - expect(controller?.nativeChatAgentWorking).toBe(false) - expect(controller?.nativeChatStreamLive).toBe(false) + expect(controller?.nativeChatTurnActive).toBe(false) expect(controller?.nativeChatStreamingText).toBeUndefined() expect(controller?.nativeChatSessionOptions?.isWorking).toBe(false) }) - it('keeps the delayed-send route guard view-gated, unlike the stream scope', () => { + it("ends working at the transcript's turn end when the final hook never came", () => { + Object.assign(sessionState, { + transcriptLifecycle: { state: 'completed', turnId: 't1', timestamp: turnStartedAt + 30_000 } + }) + rerender() + + expect(controller?.nativeChatTurnActive).toBe(false) + expect(controller?.nativeChatCanStop).toBe(false) + }) + + it('holds Stop until the agent starts a newer turn', () => { + act(() => controller?.handleNativeChatStop()) + expect(controller?.nativeChatCanStop).toBe(false) + + workingTab.agentStatus.stateStartedAt = turnStartedAt + 60_000 + rerender() + expect(controller?.nativeChatCanStop).toBe(true) + }) + + it('ends the Stop hold once a new message is sent, as desktop does', () => { + act(() => controller?.handleNativeChatStop()) + expect(controller?.nativeChatCanStop).toBe(false) + + draftPending = [{ id: 'pending-1' }] + try { + rerender() + expect(controller?.nativeChatCanStop).toBe(true) + } finally { + draftPending = [] + } + }) + + it('offers Stop again when the Escape definitely went nowhere', async () => { + terminalStop.mockResolvedValueOnce(false) + await act(async () => controller?.handleNativeChatStop()) + expect(controller?.nativeChatCanStop).toBe(true) + }) + + it('keeps the delayed-send route guard view-gated', () => { const before = answerSendArgs.at(-1)?.streamIdentity - expect(before).toBe(controller?.nativeChatStreamScopeKey) + expect(before).toContain('term-1') viewMode.isTabChatView = () => false - act(() => renderer?.update(createElement(Harness))) + rerender() - const after = answerSendArgs.at(-1)?.streamIdentity - expect(after).not.toBe(before) - expect(after).not.toContain('session-1') - expect(controller?.nativeChatStreamScopeKey).toBe(before) + expect(answerSendArgs.at(-1)?.streamIdentity).not.toBe(before) + expect(controller?.nativeChatTurnActive).toBe(false) }) }) diff --git a/mobile/src/session/use-mobile-native-chat-controller.ts b/mobile/src/session/use-mobile-native-chat-controller.ts index be145f84600..78c33a0529e 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.ts @@ -5,15 +5,14 @@ import type { MobileNativeChatTab } from './native-chat/mobile-native-chat-eligi import type { StructuredAgentSessionHostSupport } from './mobile-structured-agent-session-host-support' import { useMobileNativeChatAskDismiss } from './native-chat/input/use-mobile-native-chat-ask-dismiss' import { useMobileNativeChatDrafts } from './native-chat/composer/use-mobile-native-chat-drafts' -import { useMobileNativeChatFileSearch } from './native-chat/composer/use-mobile-native-chat-file-search' +import { useMobileNativeChatWorkspaceCatalog } from './native-chat/composer/use-mobile-native-chat-workspace-catalog' import { useMobileNativeChatMessageSend } from './native-chat/transcript/use-mobile-native-chat-message-send' -import { mobileNativeChatStreamPreview } from './native-chat/mobile-native-chat-streaming-gate' import { useMobileNativeChatSessionOptionController } from './native-chat/input/use-mobile-native-chat-session-option-controller' import { useMobileNativeChatSessionLane } from './use-mobile-native-chat-session-lane' import { useMobileStructuredNativeChatSendBridge } from './use-mobile-structured-native-chat-send-bridge' import { useMobileNativeChatPrompts } from './native-chat/input/use-mobile-native-chat-prompts' import { useNativeChatAcceptedAction } from './use-native-chat-action-outcomes' -import { useThrottledLatestValue } from './use-throttled-latest-value' +import { useMobileNativeChatTerminalTurn } from './use-mobile-native-chat-terminal-turn' 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' @@ -54,12 +53,12 @@ export function useMobileNativeChatController(args: { onSendResolved } = args const { + activeChatAgent, activeChatAgentConfirmed, activeChatAgentRef, activeChatResolution, activeChatSessionId, activeChatStructured, - activeTabAgentWorking, isTabChatView, sessionViewReady, setTabView, @@ -68,7 +67,6 @@ export function useMobileNativeChatController(args: { showNativeChatRef, sourceIdentity, streamIdentity, - streamScopeKey, toggleTabChatView } = useMobileNativeChatActiveResolution(args) @@ -116,19 +114,6 @@ export function useMobileNativeChatController(args: { transcriptSettled: nativeChatSession.status === 'ready' }) - // Observe turn liveness even off-chat so hidden mid-turn cannot look ended. - const nativeChatStreamLive = activeChatStructured - ? structuredNativeChat.isWorking - : activeTabAgentWorking - const nativeChatAgentWorking = - nativeChatStreamLive && (activeChatStructured || activeChatResolution != null) - // Throttle status-part previews to avoid reparsing the whole accumulated markdown per part. - const nativeChatStreamingText = useThrottledLatestValue( - activeChatStructured - ? undefined - : mobileNativeChatStreamPreview(nativeChatStatus, nativeChatAgentWorking), - 50 - ) const { permission: legacyNativeChatPermission, ask: nativeChatAskPrompt, @@ -175,7 +160,19 @@ export function useMobileNativeChatController(args: { onSendError }) - const nativeChatFiles = useMobileNativeChatFileSearch({ client, worktreeId }) + const terminalTurn = useMobileNativeChatTerminalTurn({ + status: nativeChatStatus, + resolution: activeChatResolution, + session: activeChatStructured ? null : nativeChatSession, + latestPendingId: chatPending.at(-1)?.id ?? null, + hasPromptCard: (legacyNativeChatPermission ?? nativeChatAskPrompt) != null, + scopeKey: streamIdentity, + stop: handleNativeChatStop + }) + const activeTurn = activeChatStructured ? structuredNativeChat : terminalTurn + const nativeChatAgentWorking = activeTurn.isWorking + + const workspaceCatalog = useMobileNativeChatWorkspaceCatalog(args, activeChatAgent) // Break the send/options cycle through a ref so snapshot changes cannot recreate send callbacks. const recordSessionOptionCommandRef = useRef<(command: string) => void>(() => {}) @@ -275,22 +272,19 @@ export function useMobileNativeChatController(args: { activeChatStructured && agentSessionHostSupport?.itemDetail === true ? structuredNativeChat.visualSource : null, - nativeChatAgentWorking, + nativeChatTurnActive: activeChatStructured + ? structuredNativeChat.isWorking + : terminalTurn.turnActive, + nativeChatAwaitingInput: activeChatStructured ? null : terminalTurn.awaitingInput, nativeChatTurnIndicator: activeChatStructured ? structuredNativeChat.turnIndicator : null, - nativeChatWorkingStartedAt: activeChatStructured ? structuredNativeChat.workingStartedAt : null, - nativeChatSettledTurns: activeChatStructured ? structuredNativeChat.settledTurns : null, + nativeChatWorkingStartedAt: activeTurn.workingStartedAt, + nativeChatSettledTurns: activeTurn.settledTurns, nativeChatTurnJournal: activeChatStructured ? structuredNativeChat.turnJournal : null, nativeChatReceipts: activeChatStructured ? structuredNativeChat.receipts : null, nativeChatActionablePromptIds: structuredNativeChat.actionablePromptIds, nativeChatCommandRefusalCauses: structuredNativeChat.commandRefusalCauses, - // A host that stops what the chat has in flight takes Stop before the agent opens a turn. - nativeChatCanStop: activeChatStructured - ? structuredNativeChat.turnId !== null || - (agentSessionHostSupport?.conversationStop === true && structuredNativeChat.isWorking) - : nativeChatAgentWorking, - nativeChatStreamingText, - nativeChatStreamLive, - nativeChatStreamScopeKey: streamScopeKey, + nativeChatCanStop: activeChatStructured ? structuredNativeChat.canStop : nativeChatAgentWorking, + nativeChatStreamingText: terminalTurn.streamingText, ...promptCards, nativeChatAsk: askDismissal.showAsk || askDismissal.collapsedAsk ? nativeChatAskPrompt : null, nativeChatAskKey: askDismissal.askKey, @@ -299,10 +293,10 @@ export function useMobileNativeChatController(args: { nativeChatCollapsedPrompt: askDismissal.collapsedAsk ?? promptCards.nativeChatCollapsedPrompt, handleNativeChatAnswerAsk: answerAsk, handleNativeChatCancelAsk: cancelAsk, - handleNativeChatStop: activeChatStructured ? structuredNativeChat.cancel : handleNativeChatStop, + handleNativeChatStop: activeChatStructured ? structuredNativeChat.cancel : terminalTurn.stop, nativeChatQueued: structuredNativeChat.queued, nativeChatBackgroundTasks: activeChatStructured ? structuredNativeChat.backgroundTasks : null, - ...nativeChatFiles, + ...workspaceCatalog, handleNativeChatSend: activeChatStructured ? structuredNativeChatSend.send : handleNativeChatSend, diff --git a/mobile/src/session/use-mobile-native-chat-queue-follow-ups.ts b/mobile/src/session/use-mobile-native-chat-queue-follow-ups.ts new file mode 100644 index 00000000000..1f0509c0827 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-queue-follow-ups.ts @@ -0,0 +1,33 @@ +import { useEffect, useState } from 'react' +import { nativeChatQueueFollowUpsRead } from '../transport/settings-read-operations' +import type { RpcClient } from '../transport/rpc-client' + +// Read per connection and chat: the phone has no settings stream, and on is today's default. +export function useMobileNativeChatQueueFollowUps( + client: RpcClient | null, + connected: boolean, + sessionId: string | null +): boolean { + const [queueFollowUps, setQueueFollowUps] = useState(true) + useEffect(() => { + if (!client || !connected || !sessionId) { + return + } + let stale = false + void nativeChatQueueFollowUpsRead + .request(client) + .then((response) => { + const preference = nativeChatQueueFollowUpsRead.interpret(response) + if (!stale && preference.accepted) { + setQueueFollowUps(preference.value) + } + }) + .catch(() => { + // Keep the last answer when the host is unreachable. + }) + return () => { + stale = true + } + }, [client, connected, sessionId]) + return queueFollowUps +} diff --git a/mobile/src/session/use-mobile-native-chat-session.ts b/mobile/src/session/use-mobile-native-chat-session.ts index 4765a24d110..a1862ac24ee 100644 --- a/mobile/src/session/use-mobile-native-chat-session.ts +++ b/mobile/src/session/use-mobile-native-chat-session.ts @@ -4,7 +4,10 @@ import { encodeNativeChatTranscriptIdentity } from '../../../src/shared/native-chat-transcript-retention' import { createNativeChatMerger, replaceList } from '../../../src/shared/native-chat-merge' -import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import type { + NativeChatMessage, + NativeChatTurnLifecycle +} from '../../../src/shared/native-chat-types' import { buildNativeChatSubscriptionId } from '../../../src/shared/native-chat/native-chat-stream-unsubscribe' import type { RpcClient } from '../transport/rpc-client' import { nativeChatSessionPageRead } from './mobile-session-read-operations' @@ -38,6 +41,8 @@ export type MobileNativeChatSession = { loadingEarlier: boolean olderPages?: OlderPagePhase loadEarlier: () => void + /** Latest provider turn boundary from the transcript feed; absent from older hosts. */ + transcriptLifecycle?: NativeChatTurnLifecycle } const INITIAL_LIMIT = 40 @@ -93,6 +98,10 @@ export function useMobileNativeChatSession(args: { const settled = initialStatus === 'loading' ? current : null const status = settled ? settled.status : initialStatus const [error, setError] = useState(undefined) + const [lifecycle, setLifecycle] = useState<{ + identity: string + lifecycle: NativeChatTurnLifecycle | undefined + } | null>(null) const [hasMore, setHasMore] = useState(false) const { phase: olderPages, load: loadOlderPages, supersede } = useMobileOlderPageLoader() const beforeOffsetRef = useRef(null) @@ -181,6 +190,10 @@ export function useMobileNativeChatSession(args: { beforeOffsetRef.current = applied.beforeOffset ?? null setHasMore(applied.hasMore ?? applied.messages.length >= INITIAL_LIMIT) } + // Snapshots and replacements restate the boundary; appends only advance it. + if (frame.type === 'appended' ? frame.lifecycle !== undefined : !applied.pending) { + setLifecycle({ identity, lifecycle: frame.lifecycle }) + } setMessages(applied.messages) if (!applied.windowReplaced && applied.hasMore != null) { setHasMore(applied.hasMore) @@ -286,6 +299,7 @@ export function useMobileNativeChatSession(args: { hasMore, loadingEarlier: olderPages === 'loading', olderPages, - loadEarlier + loadEarlier, + transcriptLifecycle: lifecycle?.identity === identity ? lifecycle.lifecycle : undefined } } diff --git a/mobile/src/session/use-mobile-native-chat-stop.test.ts b/mobile/src/session/use-mobile-native-chat-stop.test.ts index c6539e236e4..5bbedea610c 100644 --- a/mobile/src/session/use-mobile-native-chat-stop.test.ts +++ b/mobile/src/session/use-mobile-native-chat-stop.test.ts @@ -13,7 +13,7 @@ vi.mock('../terminal/worker-terminal-takeover-report', () => ({ describe('useMobileNativeChatStop', () => { let renderer: ReactTestRenderer | null = null - let stop: (() => void) | null = null + let stop: (() => Promise) | null = null const sendRequest = vi.fn() const onSendError = vi.fn() @@ -25,6 +25,7 @@ describe('useMobileNativeChatStop', () => { }) onSendError.mockReset() reportWorkerTerminalUserInput.mockReset() + agent = 'opencode' }) afterEach(() => { @@ -34,6 +35,8 @@ describe('useMobileNativeChatStop', () => { vi.useRealTimers() }) + let agent = 'opencode' + function Harness({ enabled, streamIdentity @@ -46,6 +49,7 @@ describe('useMobileNativeChatStop', () => { enabled, handleRef: { current: 'terminal-1' }, deviceTokenRef: { current: 'mobile-1' }, + agentRef: { current: agent }, streamIdentity, cancelPending: vi.fn(), onSendError @@ -70,7 +74,7 @@ describe('useMobileNativeChatStop', () => { ])('cancels the delayed second Escape when %s', async (_case, enabled, streamIdentity) => { await render(true, 'stream-1') - act(() => stop?.()) + act(() => void stop?.()) expect(sendRequest).toHaveBeenCalledTimes(1) await render(enabled as boolean, streamIdentity as string) @@ -81,11 +85,49 @@ describe('useMobileNativeChatStop', () => { expect(sendRequest).toHaveBeenCalledTimes(1) }) + it.each<[string, number]>([ + ['claude', 1], + ['codex', 1], + ['opencode', 2] + ])('sends %s %i Escape(s) per Stop like desktop', async (stopAgent, count) => { + agent = stopAgent + await render(true, 'stream-1') + + act(() => void stop?.()) + await act(async () => { + await vi.runAllTimersAsync() + }) + + expect(sendRequest).toHaveBeenCalledTimes(count) + }) + + it.each([ + ['a definite rejection', new Error('disconnected'), false], + ['a lost ack', markRpcDeliveryUnknown(new Error('rpc timeout')), true] + ])( + 'settles the Stop as possibly sent only when it was not refused: %s', + async (_case, error, sent) => { + agent = 'claude' + sendRequest.mockRejectedValue(error) + await render(true, 'stream-1') + + let verdict: Promise | undefined + act(() => { + verdict = stop?.() + }) + await act(async () => { + await vi.runAllTimersAsync() + }) + + await expect(verdict).resolves.toBe(sent) + } + ) + it('handles a rejected Escape without leaking an unhandled rejection', async () => { sendRequest.mockRejectedValue(new Error('disconnected')) await render(true, 'stream-1') - act(() => stop?.()) + act(() => void stop?.()) await act(async () => { await Promise.resolve() await vi.runAllTimersAsync() @@ -102,7 +144,7 @@ describe('useMobileNativeChatStop', () => { sendRequest.mockResolvedValue(response) await render(true, 'stream-1') - act(() => stop?.()) + act(() => void stop?.()) await act(async () => { await vi.runAllTimersAsync() }) @@ -121,7 +163,7 @@ describe('useMobileNativeChatStop', () => { sendRequest.mockRejectedValue(makeError()) await render(true, 'stream-1') - act(() => stop?.()) + act(() => void stop?.()) await act(async () => { await Promise.resolve() await vi.runAllTimersAsync() @@ -146,7 +188,7 @@ describe('useMobileNativeChatStop', () => { }) await render(true, 'stream-1') - act(() => stop?.()) + act(() => void stop?.()) await act(async () => { await Promise.resolve() await vi.runAllTimersAsync() @@ -167,8 +209,8 @@ describe('useMobileNativeChatStop', () => { .mockResolvedValue({ ok: true, result: { send: { accepted: true } } }) await render(true, 'stream-1') - act(() => stop?.()) - act(() => stop?.()) + act(() => void stop?.()) + act(() => void stop?.()) await act(async () => { await vi.runAllTimersAsync() }) @@ -183,7 +225,7 @@ describe('useMobileNativeChatStop', () => { it('reports the takeover once an Escape is accepted', async () => { await render(true, 'stream-1') - act(() => stop?.()) + act(() => void stop?.()) await act(async () => { await vi.runAllTimersAsync() }) @@ -198,7 +240,7 @@ describe('useMobileNativeChatStop', () => { sendRequest.mockResolvedValue({ ok: true, result: { send: { accepted: false } } }) await render(true, 'stream-1') - act(() => stop?.()) + act(() => void stop?.()) await act(async () => { await vi.runAllTimersAsync() }) diff --git a/mobile/src/session/use-mobile-native-chat-stop.ts b/mobile/src/session/use-mobile-native-chat-stop.ts index a77889ea280..e6478beba2e 100644 --- a/mobile/src/session/use-mobile-native-chat-stop.ts +++ b/mobile/src/session/use-mobile-native-chat-stop.ts @@ -5,18 +5,30 @@ import { isLogicalClientCutoverError } from '../transport/stable-logical-rpc-cli import { nativeChatTerminalWrite } from './mobile-session-write-operations' import { reportWorkerTerminalUserInput } from '../terminal/worker-terminal-takeover-report' import { openMobileNativeChatSendBudget } from './native-chat/mobile-native-chat-send' +import { nativeChatStopSendsSecondEscape } from '../../../src/shared/native-chat-agent-support' +import { NATIVE_CHAT_QUESTION_STEP_MS } from '../../../src/shared/native-chat/input/native-chat-answer-stepping' +// Resolves false only when no Escape could have landed, so the chat may offer Stop again. export function useMobileNativeChatStop(args: { client: RpcClient | null enabled: boolean handleRef: MutableRefObject deviceTokenRef: MutableRefObject + agentRef: MutableRefObject streamIdentity: string cancelPending: () => void onSendError: (message: string) => void -}): () => void { - const { client, enabled, handleRef, deviceTokenRef, streamIdentity, cancelPending, onSendError } = - args +}): () => Promise { + const { + client, + enabled, + handleRef, + deviceTokenRef, + agentRef, + streamIdentity, + cancelPending, + onSendError + } = args const timerRef = useRef | null>(null) const generationRef = useRef(0) // Settle a cancelled second Escape so first-write failure cannot wait forever for an unsent write. @@ -43,7 +55,7 @@ export function useMobileNativeChatStop(args: { const handle = handleRef.current if (!client || !handle || !enabled) { onSendError('Stop not sent (terminal not ready)') - return + return Promise.resolve(false) } cancelPending() generationRef.current += 1 @@ -56,13 +68,16 @@ export function useMobileNativeChatStop(args: { let sawAccepted = false let sawUnknown = false let sawRejected = false + let settle!: (sent: boolean) => void + const verdict = new Promise((resolve) => { + settle = resolve + }) const reportIfSettled = (): void => { - if ( - generationRef.current !== generation || - pending > 0 || - sawAccepted || - (!sawUnknown && !sawRejected) - ) { + if (pending > 0) { + return + } + settle(sawAccepted || sawUnknown || !sawRejected) + if (generationRef.current !== generation || sawAccepted || (!sawUnknown && !sawRejected)) { return } // Lost-ack/cutover Escape may have stopped work; report uncertainty instead of inviting a second key. @@ -122,19 +137,26 @@ export function useMobileNativeChatStop(args: { }) } sendEscape() + if (!nativeChatStopSendsSecondEscape(agentRef.current)) { + pending -= 1 + reportIfSettled() + return verdict + } dropSecondEscapeRef.current = () => { pending -= 1 reportIfSettled() } - // Pace Escape bytes so remote input cannot coalesce them away. + // Pace like desktop's answer steps so remote input cannot coalesce the two Escapes away. timerRef.current = setTimeout(() => { timerRef.current = null dropSecondEscapeRef.current = null sendEscape() pending -= 1 reportIfSettled() - }, 80) + }, NATIVE_CHAT_QUESTION_STEP_MS) + return verdict }, [ + agentRef, cancelPending, cancelSecondEscape, client, diff --git a/mobile/src/session/use-mobile-native-chat-terminal-turn.ts b/mobile/src/session/use-mobile-native-chat-terminal-turn.ts new file mode 100644 index 00000000000..6a921400cbb --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-terminal-turn.ts @@ -0,0 +1,142 @@ +import { useCallback, useMemo, useState } from 'react' +import type { AgentStatusEntry } from '../../../src/shared/agent-status-types' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import type { NativeChatSettledTurns } from '../../../src/shared/native-chat-turn-status' +import { + mergeNativeChatLiveSession, + nativeChatHookHasWorkingSubagents, + resolveNativeChatHookState +} from '../../../src/shared/native-chat/native-chat-live-status' +import { shouldClearNativeChatWorkingSuppression } from '../../../src/shared/native-chat/native-chat-working-suppression' +import { + nativeChatHookLatestTurnWorkedSeconds, + nativeChatHookTurnStartedAt, + nativeChatLatestTurnId, + nativeChatTerminalSettledTurns, + nativeChatTranscriptSettledTurns, + resolveNativeChatTerminalTurn, + type NativeChatTerminalTurn +} from '../../../src/shared/native-chat/native-chat-terminal-turn' +import { deriveNativeChatStreamingText } from '../../../src/shared/native-chat/native-chat-streaming' +import { useThrottledLatestValue } from './use-throttled-latest-value' +import type { MobileNativeChatSession } from './use-mobile-native-chat-session' +import type { MobileNativeChatResolution } from './native-chat/mobile-native-chat-eligibility' + +const NO_MESSAGES: NativeChatMessage[] = [] + +export type MobileNativeChatTerminalTurn = NativeChatTerminalTurn & { + streamingText: string | undefined + workingStartedAt: number | null + settledTurns: NativeChatSettledTurns + /** Stop hides working until the turn ends or a newer working epoch starts. */ + stop: () => void +} + +// Desktop's terminal-chat turn rules (NativeChatResolvedView) over the phone's hook row and transcript feed. +export function useMobileNativeChatTerminalTurn(args: { + /** The terminal chat's hook row; null for structured chats or when no chat shows. */ + status: AgentStatusEntry | null | undefined + resolution: Pick | null + /** The terminal chat's transcript; null for structured chats. */ + session: Pick | null + /** The newest pending echo; a new one is a new send, which ends Stop's suppression like desktop. */ + latestPendingId: string | null + hasPromptCard: boolean + scopeKey: string + stop: () => Promise +}): MobileNativeChatTerminalTurn { + const { status, resolution, latestPendingId, hasPromptCard, scopeKey, stop } = args + const hasPending = latestPendingId !== null + const messages = args.session?.messages ?? NO_MESSAGES + const transcriptLifecycle = args.session?.transcriptLifecycle + const hookState = resolveNativeChatHookState(status) + const stateStartedAt = status?.stateStartedAt ?? null + const live = mergeNativeChatLiveSession({ + messages, + sessionId: resolution?.sessionId ?? null, + agent: resolution?.agent ?? '', + hookState, + stateStartedAt, + transcriptLifecycle, + hookHasWorkingSubagents: nativeChatHookHasWorkingSubagents(status) + }) + const liveWorking = live.status === 'working' + // Throttle status-part previews to avoid reparsing the whole accumulated markdown per part. + const streamingText = useThrottledLatestValue( + deriveNativeChatStreamingText({ + // A pending echo is the transcript's tail on desktop, so no earlier reply can hide the preview. + messages: hasPending ? NO_MESSAGES : messages, + previewText: status?.lastAssistantMessage, + working: liveWorking, + previewIsToolOutput: status?.lastAssistantMessageIsToolOutput === true + }) ?? undefined, + 50 + ) + + // The working epoch a Stop suppressed; a newer epoch, a new send or the turn's end releases it. + const [suppression, setSuppression] = useState<{ + scopeKey: string + epoch: number | null + latestPendingId: string | null + } | null>(null) + const suppressed = suppression?.scopeKey === scopeKey ? suppression : null + const sentSince = hasPending && latestPendingId !== suppressed?.latestPendingId + if ( + suppressed && + (sentSince || + shouldClearNativeChatWorkingSuppression({ + working: liveWorking, + interrupted: true, + workingEpoch: stateStartedAt, + previousWorkingEpoch: suppressed.epoch + })) + ) { + setSuppression(null) + } else if (suppressed && suppressed.epoch === null && stateStartedAt !== null) { + setSuppression({ ...suppressed, epoch: stateStartedAt }) + } + const interrupted = suppressed !== null + const stopTurn = useCallback(() => { + const pressed = { scopeKey, epoch: stateStartedAt, latestPendingId } + setSuppression(pressed) + void stop().then((sent) => { + if (!sent) { + setSuppression((current) => (current === pressed ? null : current)) + } + }) + }, [latestPendingId, scopeKey, stateStartedAt, stop]) + + const turn = resolveNativeChatTerminalTurn({ + isConversation: messages.length > 0 || hasPending || streamingText !== undefined, + working: liveWorking, + hookAwaitingInput: live.hookAwaitingInput, + interrupted, + hasPromptCard + }) + + const turnStartedAt = nativeChatHookTurnStartedAt(status ?? undefined) + const latestWorkedSeconds = nativeChatHookLatestTurnWorkedSeconds( + status ?? undefined, + hookState === null + ) + const transcriptSettled = useMemo(() => nativeChatTranscriptSettledTurns(messages), [messages]) + const latestTurnId = useMemo(() => nativeChatLatestTurnId(messages), [messages]) + const settledTurns = useMemo( + () => + nativeChatTerminalSettledTurns({ + transcriptSettled, + latestTurnId, + latestWorkedSeconds, + turnStartedAt, + turnActive: turn.turnActive + }), + [latestTurnId, latestWorkedSeconds, transcriptSettled, turn.turnActive, turnStartedAt] + ) + return { + ...turn, + streamingText, + workingStartedAt: turn.turnActive ? turnStartedAt : null, + settledTurns, + stop: stopTurn + } +} diff --git a/mobile/src/session/use-mobile-queued-message-composer-actions.ts b/mobile/src/session/use-mobile-queued-message-composer-actions.ts index 70a851ede26..1ea421afe52 100644 --- a/mobile/src/session/use-mobile-queued-message-composer-actions.ts +++ b/mobile/src/session/use-mobile-queued-message-composer-actions.ts @@ -1,13 +1,17 @@ import { useCallback, useLayoutEffect, useRef, useState } from 'react' -import type { AgentSessionQueuedMessageMoveResult } from '../../../src/shared/agent-session-wire' +import type { + AgentSessionQueuedMessage, + AgentSessionQueuedMessageMoveResult +} from '../../../src/shared/agent-session-wire' import type { DragReorderMove } from '../components/drag-reorder-positions' -import type { MobileQueuedMessageFeed } from './mobile-structured-queued-message-feed' import type { MobileStructuredAgentMutate } from './use-mobile-structured-agent-mutation' +type QueuedMessages = readonly AgentSessionQueuedMessage[] | null | undefined + export function useMobileQueuedMessageComposerActions(args: { sessionKey: string sessionId: string | null - messages: MobileQueuedMessageFeed + messages: QueuedMessages reorderCapable: boolean enabled: boolean mutate: MobileStructuredAgentMutate @@ -23,8 +27,8 @@ export function useMobileQueuedMessageComposerActions(args: { }, [sessionKey]) const [preview, setPreview] = useState<{ scope: string - source: MobileQueuedMessageFeed - messages: MobileQueuedMessageFeed + source: QueuedMessages + messages: QueuedMessages } | null>(null) const move = useCallback( async (move: DragReorderMove): Promise => { @@ -39,10 +43,12 @@ export function useMobileQueuedMessageComposerActions(args: { return false } remaining.splice(at + ('afterId' in move ? 1 : 0), 0, source) + // Cards sort by position, so the preview hands the moved order the queue's own positions. + const positions = messages.map((message) => message.position).sort((a, b) => a - b) setPreview({ scope: sessionKey, source: messages, - messages: remaining + messages: remaining.map((message, index) => ({ ...message, position: positions[index]! })) }) const result = await mutate( 'agentSession.queuedMessageMove', diff --git a/mobile/src/session/use-mobile-structured-agent-session-queued.test.tsx b/mobile/src/session/use-mobile-structured-agent-session-queued.test.tsx index 27e1f3a56c1..5f2dc43524e 100644 --- a/mobile/src/session/use-mobile-structured-agent-session-queued.test.tsx +++ b/mobile/src/session/use-mobile-structured-agent-session-queued.test.tsx @@ -175,7 +175,7 @@ describe('mobile structured queued messages', () => { moving = hook!.queued.move!({ movedId: 'mail', afterId: 'b' }) }) expect(hook!.queued.cards.map((card) => card.messageId)).toEqual(['b', 'mail']) - expect(hook!.queued.cards[1]?.automatic).toBe(true) + expect(hook!.queued.cards[1]?.from).toBeDefined() expect(requestOf('agentSession.queuedMessageMove').params).toMatchObject({ messageId: 'mail', afterMessageId: 'b' @@ -234,6 +234,30 @@ describe('mobile structured queued messages', () => { expect(asyncStorage.setItem).not.toHaveBeenCalled() }) + it("sends straight into the turn when the host's Queue follow-ups is off, as the desktop does", async () => { + sendRequest.mockImplementation(async (method) => { + if (method === 'settings.get') { + return ok({ settings: { nativeChatQueueFollowUps: false } }) + } + if (method === 'agentSession.send') { + return mutationOk({ + clientMessageId: 'client-1', + submission: acceptedSubmission('client-1') + }) + } + return method === 'agentSession.options' ? ok({ models: [], current: {} }) : ok({}) + }) + await mountSession(CAPABLE) + await vi.waitFor(() => + expect(sendRequest.mock.calls.map(([method]) => method)).toContain('settings.get') + ) + await act(async () => {}) + await act(async () => { + await hook!.sendWithOutcome('steer me') + }) + expect('delivery' in requestOf('agentSession.send').params).toBe(false) + }) + it('keeps today’s request exactly against an incapable host', async () => { sendRequest.mockImplementation(async (method) => { if (method === 'agentSession.send') { @@ -334,9 +358,11 @@ describe('mobile structured queued messages', () => { expect(hook!.queued.cards).toEqual([ { messageId: 'draft-1', + position: 1, text: 'text of draft-1', + line: 'text of draft-1', state: 'waiting', - paused: false, + hold: 'turn', needsAttention: false, caption: null, attribution: null @@ -622,7 +648,7 @@ describe('mobile structured queued messages', () => { expect(hook!.queued.pause).toEqual({ reason: 'stopped' }) expect(hook!.queued.cards[0]).toMatchObject({ messageId: 'draft-1', - paused: false, + hold: 'queue-paused', caption: null }) act(() => listener?.(batchEvent())) diff --git a/mobile/src/session/use-mobile-structured-agent-session-send.test.tsx b/mobile/src/session/use-mobile-structured-agent-session-send.test.tsx index 23ae08f9da4..86bc376087b 100644 --- a/mobile/src/session/use-mobile-structured-agent-session-send.test.tsx +++ b/mobile/src/session/use-mobile-structured-agent-session-send.test.tsx @@ -12,7 +12,10 @@ import { import { markRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity' import { structuredSendResultFixture } from './structured-agent-send-result.test-fixture' import { useMobileStructuredAgentSession } from './use-mobile-structured-agent-session' -import { AGENT_SESSION_SEND_OPTIONS_RUNTIME_CAPABILITY } from '../../../src/shared/agent-session-create-capabilities' +import { + AGENT_SESSION_SEND_OPTIONS_RUNTIME_CAPABILITY, + AGENT_SESSION_SEND_SKILLS_RUNTIME_CAPABILITY +} from '../../../src/shared/agent-session-create-capabilities' import { structuredAgentSessionHostSupport, type StructuredAgentSessionHostSupport @@ -447,6 +450,29 @@ describe('mobile structured send actions', () => { }) }) + it.each([true, false])( + 'sends the picked skill path only to a host that takes it: %s', + async (takesSkills) => { + hostSupport = structuredAgentSessionHostSupport( + takesSkills ? [AGENT_SESSION_SEND_SKILLS_RUNTIME_CAPABILITY] : [] + ) + sendRequest.mockImplementation(async (method) => + method === 'agentSession.send' ? sendResult('accepted') : ok({ models: [], current: {} }) + ) + await mountSession() + const review = { name: 'review', path: '/repo/.agents/skills/review/SKILL.md' } + await act(async () => { + await hook!.sendWithOutcome('$review this', undefined, undefined, [], [review]) + }) + expect(fieldsOf(calls()[0]?.[1]).body).toEqual({ + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: '$review this' }], + ...(takesSkills ? { skills: [review] } : {}) + }) + } + ) + it('sends an attached file as a reference after the text, and only images as image blocks', async () => { sendRequest.mockImplementation(async (method) => method === 'agentSession.send' ? sendResult('accepted') : ok({ models: [], current: {} }) diff --git a/mobile/src/session/use-mobile-structured-agent-session.ts b/mobile/src/session/use-mobile-structured-agent-session.ts index 0191801e1a9..7a34a3200ab 100644 --- a/mobile/src/session/use-mobile-structured-agent-session.ts +++ b/mobile/src/session/use-mobile-structured-agent-session.ts @@ -11,11 +11,10 @@ import { tuiAgentDisplayName } from '../../../src/shared/tui-agent-display-names import { isStructuredAgentSessionMainAgentWorking } from '../../../src/shared/structured-agent-session-main-agent-working' import { isFinalAgentSessionReadRefusal } from '../../../src/shared/structured-agent-session-read-refusal' import { - isStructuredAgentSessionThinking, runningStructuredAgentSessionTurnId, - structuredAgentSessionHostSaysWorking + structuredAgentSessionHostSaysWorking, + structuredAgentSessionQueueSendsNext } from '../../../src/shared/structured-agent-session-live-turn' -import { selectStructuredAgentTurnActivity } from '../../../src/shared/native-chat-turn-activity' import { actionableStructuredApproval, actionableStructuredQuestion @@ -41,14 +40,13 @@ import { requestMobileStructuredAgentSessionCancel } from './mobile-structured-agent-session-cancel' import { useMobileStructuredAgentMutate } from './use-mobile-structured-agent-mutation' -import { agentStopDisplayStatus } from '../../../src/shared/agent-stop-display-status' -import { - mobileStructuredSendQueues, - useMobileStructuredSendWithOutcome -} from './use-mobile-structured-send-with-outcome' +import { useMobileStructuredSendWithOutcome } from './use-mobile-structured-send-with-outcome' import { useMobileStructuredQueuedMessageControls } from './use-mobile-structured-queued-message-controls' import { useMobileStructuredBackgroundTasks } from './use-mobile-structured-background-tasks' import { useMobileStructuredSends } from './use-mobile-structured-sends' +import { useMobileNativeChatQueueFollowUps } from './use-mobile-native-chat-queue-follow-ups' +import { useMobileStructuredStopOffer } from './use-mobile-structured-stop-offer' +import { useMobileStructuredTurnIndicator } from './use-mobile-structured-turn-indicator' export function useMobileStructuredAgentSession( args: MobileStructuredAgentSessionArgs @@ -68,6 +66,7 @@ export function useMobileStructuredAgentSession( // Gate queueing by capability while accepting published cards from every host. const queueCapable = hostSupport?.queuedMessages === true const commandsWait = queueCapable && hostSupport?.queuedCommands === true + const queueFollowUps = useMobileNativeChatQueueFollowUps(client, connected && enabled, sessionId) const promptCancelSupported = hostSupport?.promptCancel ?? null const hostAnswersRepeatedStops = hostSupport?.quietRepeatedStop ?? null const hostStopsConversation = hostSupport?.conversationStop ?? null @@ -152,12 +151,10 @@ export function useMobileStructuredAgentSession( { ...state, items: transcriptItems }, turnId ) - const activityText = - selectStructuredAgentTurnActivity(state.items, turnId, state.activity)?.text ?? null - const thinking = isStructuredAgentSessionThinking(state) const isWorking = structuredAgentSessionHostSaysWorking(state.working, () => isStructuredAgentSessionMainAgentWorking(turnId, state.submissions, state.fence) ) + const queueSendsNext = structuredAgentSessionQueueSendsNext(state.nextQueuedMessageId, isWorking) const backgroundTasks = useMobileStructuredBackgroundTasks({ sessionKey, state, @@ -171,46 +168,44 @@ export function useMobileStructuredAgentSession( enabled: enabled && connected && hostSupport?.statusFeed === true }) const stopPress = useMobileStructuredStopPress(sessionKey) - const stopping = - agentStopDisplayStatus({ - working: isWorking, - hostStopping, - stopPressed: stopPress.pressed - }) === 'stopping' - const stopRequestInFlight = isWorking && stopPress.pressed + const { canStop, sendsOut } = useMobileStructuredStopOffer({ + sessionKey, + turnId, + isWorking, + stopsConversation: hostStopsConversation === true + }) + const turnIndicator = useMobileStructuredTurnIndicator({ + state, + turnId, + isWorking, + canStop, + hostStopping, + stopPressed: stopPress.pressed, + queueCapable, + queueFollowUps + }) + const { stopping } = turnIndicator const sendWithOutcome = useMobileStructuredSendWithOutcome({ agent, client, sessionId, enabled, queueCapable, + queueFollowUps, commandsWait, stateRef, stopping, accept: sends.accept, hold: sends.hold, commandPending: commandPendingRef, + sendsOut, controller: sendController, sessionCommands: args.sessionCommands, onSendError, - sendOptions: hostSupport?.sendOptions === true ? options.sendSelection : undefined + queueSendsNext, + sendOptions: hostSupport?.sendOptions === true ? options.sendSelection : undefined, + takesSkills: hostSupport?.sendSkills === true }) - const turnIndicator = useMemo( - () => ({ - thinking, - activityText, - stopping, - stopRequestInFlight, - ...(stopping - ? { - afterStop: mobileStructuredSendQueues(queueCapable, state) - ? ('queue' as const) - : ('send' as const) - } - : {}) - }), - [thinking, activityText, stopping, stopRequestInFlight, queueCapable, state] - ) const { items, currentRequests, actionablePromptIds, status } = state // Prefer host actionability: ended-agent prompts must receive no answer. const [approvalPrompt, questionPrompt] = useMemo(() => { @@ -219,10 +214,10 @@ export function useMobileStructuredAgentSession( }, [items, currentRequests, actionablePromptIds]) const commandRefusalCauses = useMemo( () => ({ - working: turnId !== null, + working: turnId !== null || queueSendsNext, prompt: approvalPrompt !== null || questionPrompt !== null }), - [approvalPrompt, questionPrompt, turnId] + [approvalPrompt, questionPrompt, queueSendsNext, turnId] ) const queued = useMobileStructuredQueuedMessageControls({ sessionKey, @@ -290,7 +285,8 @@ export function useMobileStructuredAgentSession( olderPages, loadEarlier }, - isWorking, + isWorking: isWorking || queueSendsNext, + canStop, turnId, turnIndicator, ...turnTiming, diff --git a/mobile/src/session/use-mobile-structured-agent-state.ts b/mobile/src/session/use-mobile-structured-agent-state.ts index c0716d038a6..9b1e9221218 100644 --- a/mobile/src/session/use-mobile-structured-agent-state.ts +++ b/mobile/src/session/use-mobile-structured-agent-state.ts @@ -2,7 +2,8 @@ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react import { isRootAgentJournalItem } from '../../../src/shared/agent-session-journal-producer' import type { AgentSessionHistoryResult, - AgentSessionSubscribeEvent + AgentSessionQueuedMessage, + AgentSessionQueuePause } from '../../../src/shared/agent-session-wire' import { AGENT_SESSION_HISTORY_MAX_LIMIT } from '../../../src/shared/agent-session-wire' import { structuredAgentSessionHolderId } from '../../../src/shared/structured-agent-session-holder' @@ -23,20 +24,8 @@ import { callAgentSession } from './mobile-structured-agent-session-rpc' import { subscribeTranscriptAfterHold } from './mobile-structured-transcript-subscription' -import { - reduceMobileQueuePause, - reduceMobileQueuedMessageFeed, - type MobileQueuedMessageFeed, - type MobileQueuePause -} from './mobile-structured-queued-message-feed' import { useMobileOlderPageLoader, type OlderPagePhase } from './use-mobile-older-page-loader' -type QueuedFeed = { - messages: MobileQueuedMessageFeed - pause: MobileQueuePause -} -const NO_QUEUED_FEED: QueuedFeed = { messages: null, pause: null } - const MAX_RETAINED_SESSION_STATES = 32 export function useMobileStructuredAgentState(args: { @@ -49,8 +38,9 @@ export function useMobileStructuredAgentState(args: { }): { state: StructuredAgentSessionState stateRef: { readonly current: StructuredAgentSessionState } - queuedMessages: MobileQueuedMessageFeed - queuePause: MobileQueuePause + /** Null until the host publishes a queue (older hosts never do). */ + queuedMessages: AgentSessionQueuedMessage[] | null + queuePause: AgentSessionQueuePause | null olderPages: OlderPagePhase loadEarlier: () => void } { @@ -59,14 +49,10 @@ export function useMobileStructuredAgentState(args: { const [sessionStates, setSessionStates] = useState>( () => new Map() ) - const [queuedBySession, setQueuedBySession] = useState>(() => new Map()) const state = enabled && sessionKey ? (sessionStates.get(sessionKey) ?? EMPTY_STRUCTURED_AGENT_SESSION) : EMPTY_STRUCTURED_AGENT_SESSION - const queued = - (enabled && sessionKey ? queuedBySession.get(sessionKey) : undefined) ?? NO_QUEUED_FEED - const queuedMessages = queued.messages const { phase: olderPages, load: loadOlderPages, supersede } = useMobileOlderPageLoader() const stateRef = useRef(state) useLayoutEffect(() => { @@ -99,34 +85,6 @@ export function useMobileStructuredAgentState(args: { }, [sessionKey] ) - const applyQueued = useCallback( - (event: AgentSessionSubscribeEvent) => { - if (!sessionKey) { - return - } - setQueuedBySession((current) => { - const previous = current.get(sessionKey) ?? NO_QUEUED_FEED - const messages = reduceMobileQueuedMessageFeed(previous.messages, event) - const pause = reduceMobileQueuePause(previous.pause, event) - if (messages === previous.messages && pause === previous.pause) { - return current - } - const updated = new Map(current) - updated.delete(sessionKey) - updated.set(sessionKey, { messages, pause }) - while (updated.size > MAX_RETAINED_SESSION_STATES) { - const oldest = updated.keys().next().value - if (oldest === undefined) { - break - } - updated.delete(oldest) - } - return updated - }) - }, - [sessionKey] - ) - useEffect(() => { supersede() if (!client || !sessionId || !enabled) { @@ -146,7 +104,6 @@ export function useMobileStructuredAgentState(args: { applyRequests: apply, applyEvent: (event, options) => { apply({ type: 'event', event, ...options }) - applyQueued(event) }, applyError: (message, refusal) => { if (stateRef.current.epoch !== null && refusal === undefined) { @@ -185,7 +142,7 @@ export function useMobileStructuredAgentState(args: { ) .catch(() => undefined) } - }, [apply, applyQueued, client, connected, enabled, sessionId, sessionKey, supersede]) + }, [apply, client, connected, enabled, sessionId, sessionKey, supersede]) const loadEarlier = useCallback(() => { const start = oldestStructuredAgentSessionCursor(stateRef.current) @@ -262,5 +219,13 @@ export function useMobileStructuredAgentState(args: { loadEarlier() }, [drawsNothingFrom, loadEarlier, loadingEarlier]) - return { state, stateRef, queuedMessages, queuePause: queued.pause, olderPages, loadEarlier } + return { + state, + stateRef, + // A published null is an empty queue; only an absent list is no claim. + queuedMessages: state.queuedMessages === undefined ? null : (state.queuedMessages ?? []), + queuePause: state.queuePause ?? null, + olderPages, + loadEarlier + } } diff --git a/mobile/src/session/use-mobile-structured-native-chat-send-bridge.test.ts b/mobile/src/session/use-mobile-structured-native-chat-send-bridge.test.ts index aba3472c78b..9bc4fba5be6 100644 --- a/mobile/src/session/use-mobile-structured-native-chat-send-bridge.test.ts +++ b/mobile/src/session/use-mobile-structured-native-chat-send-bridge.test.ts @@ -5,6 +5,10 @@ import type { StructuredAgentId } from '../../../src/shared/agent-session-provid import type { MobileNativeChatSendOutcome } from './native-chat/mobile-native-chat-send' import type { MobileNativeChatSendOrigin } from './native-chat/mobile-native-chat-pending-echo' import { useMobileStructuredNativeChatSendBridge } from './use-mobile-structured-native-chat-send-bridge' +import { + recordMobileNativeChatSkillPick, + resetMobileNativeChatDraftStoreForTests +} from './native-chat/composer/mobile-native-chat-draft-store' const ORIGIN: MobileNativeChatSendOrigin = { draftKey: 'draft', @@ -49,6 +53,20 @@ describe('useMobileStructuredNativeChatSendBridge', () => { afterEach(() => { act(() => renderer?.unmount()) renderer = null + resetMobileNativeChatDraftStoreForTests() + }) + + it('sends the skills picked into the draft', async () => { + const review = { name: 'review', path: '/skills/review/SKILL.md' } + recordMobileNativeChatSkillPick(ORIGIN.draftKey, { token: '$review', path: review.path }) + sendStructured.mockResolvedValue('accepted') + mount('codex') + + await sendWithOutcome('$review this') + + expect(sendStructured).toHaveBeenCalledWith('$review this', undefined, undefined, undefined, [ + review + ]) }) // Accepted messages remain host-owned; keep the composer cleared. @@ -60,7 +78,7 @@ describe('useMobileStructuredNativeChatSendBridge', () => { await expect(sendWithOutcome('/review')).resolves.toBe(outcome) - expect(sendStructured).toHaveBeenCalledWith('/review') + expect(sendStructured).toHaveBeenCalledWith('/review', undefined, undefined, undefined, []) expect(clearDraftForSend).toHaveBeenCalledExactlyOnceWith(ORIGIN, '/review') expect(restoreRejectedDraft).not.toHaveBeenCalled() } diff --git a/mobile/src/session/use-mobile-structured-native-chat-send-bridge.ts b/mobile/src/session/use-mobile-structured-native-chat-send-bridge.ts index 834f997c2a3..6c4383e71a2 100644 --- a/mobile/src/session/use-mobile-structured-native-chat-send-bridge.ts +++ b/mobile/src/session/use-mobile-structured-native-chat-send-bridge.ts @@ -5,6 +5,11 @@ import { isStructuredAgentSessionComposerCommand } from '../../../src/shared/str import type { MobileNativeChatSendOutcome } from './native-chat/mobile-native-chat-send' import type { MobileNativeChatSendOrigin } from './native-chat/mobile-native-chat-pending-echo' import type { StructuredMobileSendAttachment } from './use-mobile-structured-send-with-outcome' +import type { AgentJournalSkillRef } from '../../../src/shared/agent-session-journal-types' +import { + readMobileNativeChatPickedSkills, + restoreMobileNativeChatPickedSkills +} from './native-chat/composer/mobile-native-chat-draft-store' export function useMobileStructuredNativeChatSendBridge(args: { agent: StructuredAgentId | undefined @@ -13,7 +18,8 @@ export function useMobileStructuredNativeChatSendBridge(args: { text: string, images?: string[], deadline?: number, - attachments?: readonly StructuredMobileSendAttachment[] + attachments?: readonly StructuredMobileSendAttachment[], + skills?: readonly AgentJournalSkillRef[] ) => Promise captureSendOrigin: (text: string) => MobileNativeChatSendOrigin | null clearDraftForSend: (origin: MobileNativeChatSendOrigin, text: string) => void @@ -50,18 +56,13 @@ export function useMobileStructuredNativeChatSendBridge(args: { return 'rejected' } const isHostCommand = isStructuredAgentSessionComposerCommand(text, agent, sessionCommands) + const skills = readMobileNativeChatPickedSkills(origin.draftKey) clearDraftForSend(origin, text) - const outcome = - attachments !== undefined - ? await sendStructured(text, images, deadline, attachments) - : deadline !== undefined - ? await sendStructured(text, images, deadline) - : images !== undefined - ? await sendStructured(text, images) - : await sendStructured(text) + const outcome = await sendStructured(text, images, deadline, attachments, skills) // Accepted messages remain host-owned; restore commands whose reply was lost. if (outcome === 'rejected' || (outcome === 'unknown' && isHostCommand)) { restoreRejectedDraft(origin, text) + restoreMobileNativeChatPickedSkills(origin.draftKey, skills) } return outcome }, diff --git a/mobile/src/session/use-mobile-structured-queued-edit.test.tsx b/mobile/src/session/use-mobile-structured-queued-edit.test.tsx index 7adc747f603..a93901ec9c3 100644 --- a/mobile/src/session/use-mobile-structured-queued-edit.test.tsx +++ b/mobile/src/session/use-mobile-structured-queued-edit.test.tsx @@ -201,7 +201,7 @@ describe('mobile in-place queued Edit', () => { expect(append).not.toHaveBeenCalled() }) - it('hands a stale Save to the composer and reopens on the newer text; keeps an unconfirmed one', async () => { + it('hands a stale Save to the composer and reopens on the newer text; closes an unconfirmed one the card holds', async () => { const message = queuedDraft({ messageId: 'draft-1' }) await mount(message) const newer = withText(message.body, 'edited elsewhere') @@ -233,7 +233,7 @@ describe('mobile in-place queued Edit', () => { await act(async () => { await hook!.queued.editor!.save() }) - expect(hook!.queued.editor?.text).toBe('saved text') + expect(hook!.queued.editor).toBeUndefined() expect(hook!.queued.cards[0]?.text).toBe('saved text') const retry = sendRequest.mock.calls.findLast( ([method]) => method === 'agentSession.queuedMessageUpdate' @@ -245,11 +245,11 @@ describe('mobile in-place queued Edit', () => { expect(append).toHaveBeenCalledTimes(1) }) - it('abandons unsaved typing and releases the hold when changing chats', async () => { + it('hands unsaved typing to the composer and releases the hold when changing chats', async () => { await mount(queuedDraft({ messageId: 'draft-1' })) act(() => hook!.queued.editor!.change('unsaved')) await act(async () => renderer?.update(createElement(Harness, { sessionId: 'other-chat' }))) - expect(append).not.toHaveBeenCalled() + expect(append).toHaveBeenCalledExactlyOnceWith('unsaved') expect(hook!.queued.editor).toBeUndefined() expect( sendRequest.mock.calls @@ -278,27 +278,23 @@ describe('mobile in-place queued Edit', () => { }) expect(hook!.queued.editor!.saving).toBe(true) await act(async () => renderer?.update(createElement(Harness, { sessionId: 'other-chat' }))) - expect(append).not.toHaveBeenCalled() + expect(append).toHaveBeenCalledExactlyOnceWith('edited in place') await act(async () => { answer?.(mutationOk({ status, messageId: message.messageId, fingerprint: 'new' })) await save }) - expect(append).not.toHaveBeenCalled() + expect(append).toHaveBeenCalledTimes(1) expect(error).not.toHaveBeenCalled() expect(resolved).not.toHaveBeenCalled() expect(hook!.queued.editor).toBeUndefined() } ) - it('retires a removed card during Save and ignores its late refusal while editing another card', async () => { + it('lets an in-flight Save decide a removed card: its refusal hands the typing to the composer', async () => { const message = queuedDraft({ messageId: 'lost-reply-card' }) const nextMessage = queuedDraft({ messageId: 'next-card', position: 2 }) await mount(message) act(() => listener?.(batchEvent([message, nextMessage]))) - expect(hook!.queued.cards.map((card) => card.messageId)).toEqual([ - message.messageId, - nextMessage.messageId - ]) let answer: ((response: RpcResponse) => void) | undefined sendRequest.mockImplementation((method, params) => method === 'agentSession.queuedMessageUpdate' @@ -312,13 +308,8 @@ describe('mobile in-place queued Edit', () => { act(() => { save = hook!.queued.editor!.save() }) - expect(hook!.queued.editor?.saving).toBe(true) act(() => listener?.(batchEvent([nextMessage]))) - expect(hook!.queued.editor).toBeUndefined() - await act(async () => { - expect(await hook!.queued.edit!(nextMessage.messageId)).toBe(true) - }) - act(() => hook!.queued.editor!.change('next edit')) + expect(hook!.queued.editor).toMatchObject({ messageId: message.messageId, saving: true }) await act(async () => { answer?.( ok({ @@ -328,17 +319,15 @@ describe('mobile in-place queued Edit', () => { ) expect(await save).toBe(false) }) - expect(hook!.queued.editor).toMatchObject({ - messageId: nextMessage.messageId, - text: 'next edit', - saving: false - }) - expect(append).not.toHaveBeenCalled() - expect(error).not.toHaveBeenCalled() + expect(hook!.queued.editor).toBeUndefined() + expect(append).toHaveBeenCalledExactlyOnceWith('submitted edit') + expect(error).toHaveBeenCalledExactlyOnceWith( + 'This message was already sent or removed. Your edit is in the chat box.' + ) expect(resolved).not.toHaveBeenCalled() }) - it('keeps a refused Save in the editor and closes only when the published card is gone', async () => { + it('keeps a refused Save in the editor and hands it to the composer once the card is gone', async () => { const message = queuedDraft({ messageId: 'refused-card' }) await mount(message) sendRequest.mockImplementation(async (method, params) => @@ -359,6 +348,10 @@ describe('mobile in-place queued Edit', () => { expect(append).not.toHaveBeenCalled() act(() => listener?.(batchEvent([]))) expect(hook!.queued.editor).toBeUndefined() + expect(append).toHaveBeenCalledExactlyOnceWith('keep this text') + expect(error).toHaveBeenLastCalledWith( + 'This message was already sent or removed. Your edit is in the chat box.' + ) }) it('keeps exact typing through disconnect and reacquires the hold on the replacement transport', async () => { diff --git a/mobile/src/session/use-mobile-structured-queued-edit.ts b/mobile/src/session/use-mobile-structured-queued-edit.ts index e9cbe0b44b0..284608180d8 100644 --- a/mobile/src/session/use-mobile-structured-queued-edit.ts +++ b/mobile/src/session/use-mobile-structured-queued-edit.ts @@ -1,12 +1,21 @@ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' +import type { AgentJournalSubmission } from '../../../src/shared/agent-session-journal-types' import type { AgentSessionQueuedMessage, AgentSessionQueuedMessageUpdateResult } from '../../../src/shared/agent-session-wire' import { agentSessionSendBodyFingerprint } from '../../../src/shared/structured-agent-session-send-mutation' import { + QUEUED_EDIT_NOTICE_COPY, + queuedCardOnScreen, + queuedEditChangedNotice, + queuedEditDirty, + queuedEditHandOffTyping, + queuedEditKeptImages, + queuedEditStanding, queuedMessageEditableText, - queuedMessageWithEdit + queuedMessageWithEdit, + type QueuedMessageEditDraft } from '../../../src/shared/queued-message-edit' import type { RpcClient } from '../transport/rpc-client' import { @@ -26,11 +35,11 @@ export type MobileQueuedMessageInlineEditor = { cancel: () => void } -type Edit = { +// The phone edits text only, so the card's images are all kept. +type Edit = QueuedMessageEditDraft & { scope: string messageId: string body: AgentSessionQueuedMessage['body'] - text: string fingerprint: string saving: boolean lease: MobileQueuedEditLease @@ -44,16 +53,20 @@ export function useMobileStructuredQueuedEdit(args: { enabled: boolean messages: readonly AgentSessionQueuedMessage[] | null cards: readonly MobileQueuedMessageCard[] + submissions: readonly AgentJournalSubmission[] mutate: MobileStructuredAgentMutate appendComposerText: ((text: string) => boolean) | undefined onSendError: (text: string) => void onActionResolved?: () => void }) { const { sessionKey, sessionId, client, enabled, mutate, onSendError, onActionResolved } = args + const { messages, cards, submissions, appendComposerText } = args const clientRef = useRef({ sessionKey, client }) + const latest = useRef({ messages, cards, submissions, appendComposerText }) useLayoutEffect(() => { clientRef.current = { sessionKey, client } - }, [client, sessionKey]) + latest.current = { messages, cards, submissions, appendComposerText } + }, [appendComposerText, cards, client, messages, sessionKey, submissions]) const active = useRef(null) // A changed reply reopens the editor once the card publishes its newer text. const reopen = useRef<{ scope: string; messageId: string; stale: string } | null>(null) @@ -67,16 +80,38 @@ export function useMobileStructuredQueuedEdit(args: { render(null) edit?.lease.end() }, [render]) + /** Unsaved typing goes to the chat box; false when there was none to keep. */ + const handOff = useCallback((edit: Edit): boolean => { + const typing = queuedEditHandOffTyping(edit.body, edit) + return typing !== '' && latest.current.appendComposerText?.(typing) === true + }, []) + const gone = useCallback( + (edit: Edit) => { + if (active.current !== edit) { + return + } + if (queuedEditDirty(edit.body, edit) && handOff(edit)) { + onSendError(QUEUED_EDIT_NOTICE_COPY.editGone) + } + close() + }, + [close, handOff, onSendError] + ) - useEffect(() => { + // On chat changes or unmount, hand unsaved typing back to the chat it came from: a layout + // cleanup runs before the composer's append is repointed at the next chat. + useLayoutEffect(() => { return () => { const edit = active.current active.current = null reopen.current = null setShown(null) + if (edit && queuedEditDirty(edit.body, edit)) { + handOff(edit) + } edit?.lease.end() } - }, [sessionKey, args.chatActive]) + }, [handOff, sessionKey, args.chatActive]) const begin = useCallback( async (messageId: string): Promise => { @@ -84,7 +119,7 @@ export function useMobileStructuredQueuedEdit(args: { if (active.current || !enabled || !client || !sessionId) { return false } - const message = args.messages?.find((entry) => entry.messageId === messageId) + const message = messages?.find((entry) => entry.messageId === messageId) const text = message ? queuedMessageEditableText(message.body) : null if (!message || text === null) { return false @@ -95,6 +130,8 @@ export function useMobileStructuredQueuedEdit(args: { messageId, body: message.body, text, + kept: queuedEditKeptImages(message.body), + added: [], fingerprint, saving: false, lease: startMobileQueuedEditLease({ @@ -111,19 +148,20 @@ export function useMobileStructuredQueuedEdit(args: { if (!isOpen(active, edit) || edit.saving) { return } - if (answer.status === 'gone') { - close() - } else if (answer.status === 'changed') { - onSendError('This message changed while you were editing.') + // Sent, removed or edited elsewhere before the edit began: the card shows what happened. + if (answer.status === 'gone' || answer.status === 'changed') { + gone(edit) } else if (answer.status === 'not-editable') { - onSendError("This message can't be edited.") + handOff(edit) + onSendError(QUEUED_EDIT_NOTICE_COPY.editFailed) + close() } }, () => undefined ) return true }, - [args.messages, client, close, enabled, onSendError, render, sessionId, sessionKey] + [client, close, enabled, gone, handOff, messages, onSendError, render, sessionId, sessionKey] ) const change = useCallback( (text: string) => { @@ -137,25 +175,27 @@ export function useMobileStructuredQueuedEdit(args: { ) const save = useCallback(async (): Promise => { const edit = active.current - if ( - !edit || - edit.saving || - !sessionId || - !queuedMessageWithEdit(edit.body, { text: edit.text }).ok - ) { + if (!edit || edit.saving || !sessionId) { return false } - if (edit.text === queuedMessageEditableText(edit.body)) { + const edited = queuedMessageWithEdit(edit.body, { text: edit.text }) + if (!edited.ok) { + return false + } + if (!queuedEditDirty(edit.body, edit)) { close() return true } + // When Save’s reply is lost or refused, published card state proves whether its body landed. + const desired = agentSessionSendBodyFingerprint(sessionId, edited.body) + const standing = () => queuedEditStanding(latest.current, sessionId, edit.messageId, desired) edit.saving = true render(edit) const result = await mutate( 'agentSession.queuedMessageUpdate', 'agentSession.queuedMessageUpdate', { messageId: edit.messageId, expectedBodyFingerprint: edit.fingerprint, text: edit.text }, - () => isOpen(active, edit) + () => isOpen(active, edit) && standing() === 'differs' ) // A reply during a chat switch belongs to the outgoing chat. if (!isOpen(active, edit) || clientRef.current.sessionKey !== edit.scope) { @@ -163,56 +203,71 @@ export function useMobileStructuredQueuedEdit(args: { } edit.saving = false if (result.status === 'accepted') { - if (result.value.status === 'updated' || result.value.status === 'unchanged') { + const { status } = result.value + if (status === 'updated' || status === 'unchanged') { close() onActionResolved?.() return true } - if (result.value.status === 'gone') { - close() + if (status === 'gone') { + gone(edit) return false } - // Preserve local typing in the composer before reopening a concurrently edited card. - const typed = edit.text.trim() !== '' - if (result.value.status === 'changed' && (!typed || args.appendComposerText?.(edit.text))) { - onSendError( - typed - ? 'This message changed while you were editing. Your edit is in the chat box.' - : 'This message changed while you were editing.' - ) + if (status === 'changed') { + // Preserve typing after a competing edit, then reopen on the newer text. + onSendError(QUEUED_EDIT_NOTICE_COPY[queuedEditChangedNotice(handOff(edit), false)]) reopen.current = { scope: edit.scope, messageId: edit.messageId, stale: edit.fingerprint } close() return false } - onSendError( - result.value.status === 'changed' - ? 'This message changed while you were editing.' - : "This message can't be edited." - ) + onSendError(QUEUED_EDIT_NOTICE_COPY.editFailed) + render(edit) + return false + } + const settled = standing() + if (settled === 'sent' || settled === 'holds') { + close() + onActionResolved?.() + return true + } + if (settled === 'gone') { + gone(edit) + return false } render(edit) return false - }, [args.appendComposerText, close, mutate, onActionResolved, onSendError, render, sessionId]) + }, [close, gone, handOff, mutate, onActionResolved, onSendError, render, sessionId]) + // Let in-flight Save decide delivery races; otherwise reconcile departures, but never infer them from an unloaded list. useEffect(() => { const edit = active.current - const listed = (messageId: string): boolean => - args.messages?.some((message) => message.messageId === messageId) === true && - args.cards.some((card) => card.messageId === messageId) - if (edit && edit.scope === sessionKey && args.messages !== null && !listed(edit.messageId)) { - close() + if (edit && edit.scope === sessionKey && !edit.saving && messages !== null && sessionId) { + const typed = queuedMessageWithEdit(edit.body, { text: edit.text }) + const desired = typed.ok ? agentSessionSendBodyFingerprint(sessionId, typed.body) : null + const standing = queuedEditStanding( + { messages, cards, submissions }, + sessionId, + edit.messageId, + desired + ) + // `holds`: the card already holds what is typed, as after a Save whose answer was lost. + if (standing === 'sent' || (standing === 'holds' && desired !== edit.fingerprint)) { + close() + } else if (standing === 'gone') { + gone(edit) + } } const pending = reopen.current - if (!pending || active.current || args.messages === null || !sessionId) { + if (!pending || active.current || messages === null || !sessionId) { return } - const message = args.messages.find((entry) => entry.messageId === pending.messageId) - if (pending.scope !== sessionKey || !message || !listed(pending.messageId)) { + const listed = messages.find((entry) => entry.messageId === pending.messageId) + if (pending.scope !== sessionKey || !listed || !queuedCardOnScreen(cards, pending.messageId)) { reopen.current = null - } else if (agentSessionSendBodyFingerprint(sessionId, message.body) !== pending.stale) { + } else if (agentSessionSendBodyFingerprint(sessionId, listed.body) !== pending.stale) { void begin(pending.messageId) } - }, [args.cards, args.messages, begin, close, sessionId, sessionKey, shown]) + }, [begin, cards, close, gone, messages, sessionId, sessionKey, shown, submissions]) const editor: MobileQueuedMessageInlineEditor | undefined = shown?.scope === sessionKey diff --git a/mobile/src/session/use-mobile-structured-queued-message-controls.ts b/mobile/src/session/use-mobile-structured-queued-message-controls.ts index bd0b64c977d..f5d46e5e8ad 100644 --- a/mobile/src/session/use-mobile-structured-queued-message-controls.ts +++ b/mobile/src/session/use-mobile-structured-queued-message-controls.ts @@ -10,19 +10,17 @@ import type { import { agentSessionVisibleFailureFacts } from '../../../src/shared/agent-session-visible-failures' import { mapSettledWithConcurrency } from '../../../src/shared/map-with-concurrency' import type { + AgentSessionQueuedMessage, AgentSessionQueuedMessageDeleteResult, AgentSessionQueuedMessagesResumeResult, + AgentSessionQueuePause, AgentSessionSendResult } from '../../../src/shared/agent-session-wire' +import { queuedMessagesQueuePause } from '../../../src/shared/structured-agent-session-queued-cards' import { - mobileQueueHasResumableCard, mobileQueuedMessageCards, type MobileQueuedMessageCard } from './mobile-structured-queued-message-cards' -import type { - MobileQueuedMessageFeed, - MobileQueuePause -} from './mobile-structured-queued-message-feed' import type { MobileStructuredAgentMutate } from './use-mobile-structured-agent-mutation' import type { RpcClient } from '../transport/rpc-client' import { @@ -43,7 +41,7 @@ export type MobileStructuredQueuedMessageControls = { edit?: MobileQueuedMessageEdit editor?: MobileQueuedMessageInlineEditor move?: (move: DragReorderMove) => Promise - pause: MobileQueuePause + pause: AgentSessionQueuePause | null resume: () => Promise sessionKey: string } @@ -58,8 +56,8 @@ export function useMobileStructuredQueuedMessageControls(args: { reorderCapable?: boolean agentName?: string journalItems?: readonly AgentJournalRenderItem[] - queuedMessages: MobileQueuedMessageFeed - queuePause: MobileQueuePause + queuedMessages: readonly AgentSessionQueuedMessage[] | null + queuePause: AgentSessionQueuePause | null submissions: readonly AgentJournalSubmission[] agentWorking?: boolean mutate: MobileStructuredAgentMutate @@ -89,12 +87,21 @@ export function useMobileStructuredQueuedMessageControls(args: { () => mobileQueuedMessageCards(orderedMessages, submissions, { agentWorking, + queuePaused: queuePause !== null, agentName: args.agentName, statedFailures: queuedMessages?.some((draft) => draft.state === 'returned') ? agentSessionVisibleFailureFacts(args.journalItems ?? []) : [] }), - [agentWorking, args.agentName, args.journalItems, orderedMessages, queuedMessages, submissions] + [ + agentWorking, + args.agentName, + args.journalItems, + orderedMessages, + queuePause, + queuedMessages, + submissions + ] ) const { begin: beginInlineEdit, editor } = useMobileStructuredQueuedEdit({ ...args, @@ -226,7 +233,7 @@ export function useMobileStructuredQueuedMessageControls(args: { ), [mutate, resolved] ) - const pause = mobileQueueHasResumableCard(cards) ? queuePause : null + const pause = queuedMessagesQueuePause(cards, queuePause) return { cards, send, diff --git a/mobile/src/session/use-mobile-structured-send-with-outcome.ts b/mobile/src/session/use-mobile-structured-send-with-outcome.ts index d6fe941dbeb..77a1ff5a0e7 100644 --- a/mobile/src/session/use-mobile-structured-send-with-outcome.ts +++ b/mobile/src/session/use-mobile-structured-send-with-outcome.ts @@ -2,20 +2,28 @@ import { selectAgentSessionCurrentRequests } from '../../../src/shared/agent-ses import { useCallback } from 'react' import { runningStructuredAgentSessionTurnId } from '../../../src/shared/structured-agent-session-live-turn' +import { structuredSessionBackgroundTasksView } from '../../../src/shared/structured-session-background-tasks-view' +import { agentSessionVisibleFailureFacts } from '../../../src/shared/agent-session-visible-failures' +import { tuiAgentDisplayName } from '../../../src/shared/tui-agent-display-names' import { pendingPromptsAllUnanswerableHere } from '../../../src/shared/agent-session-approval-subject' import { + SkillRefLimitError, structuredAgentSessionSendBody, type StructuredAgentSessionAttachment } from '../../../src/shared/structured-agent-session-send-mutation' +import type { AgentJournalSkillRef } from '../../../src/shared/agent-session-journal-types' +import { MAX_SKILL_REFS } from '../../../src/shared/rpc-contract/structured-agent-session-message-params' import type { StructuredAgentSessionComposerOptions } from '../../../src/shared/structured-agent-session-composer' import type { AgentSessionSlashCommand } from '../../../src/shared/agent-session-wire' import type { StructuredAgentSessionState } from '../../../src/shared/structured-agent-session-reducer' +import { structuredAgentSessionQueueEnabled } from '../../../src/shared/structured-agent-session-queued-cards' import type { RpcClient } from '../transport/rpc-client' import type { MobileNativeChatSendOutcome } from './native-chat/mobile-native-chat-send' import { dispatchMobileStructuredCommand } from './mobile-structured-composer-command' import type { MobileNativeChatSendErrorReporter } from './native-chat/use-mobile-native-chat-send-error' import { sendMobileStructuredAgentSessionMessage } from './mobile-structured-agent-session-send' import type { MobileStructuredSends } from './use-mobile-structured-sends' +import type { MobileStructuredSendsOut } from './use-mobile-structured-stop-offer' import { structuredSessionOperationId } from './structured-session-operation-id' import { timeoutForDeadline } from './mobile-structured-agent-session-rpc' import { nativeChatTextWithFileReferences } from './native-chat/composer/mobile-native-chat-file-attachment' @@ -24,13 +32,18 @@ import { actionableStructuredQuestion } from './mobile-structured-actionable-prompt' -// An unsupported pending prompt would hold queued sends forever; start a turn instead. export function mobileStructuredSendQueues( queueCapable: boolean, - state: Pick + queueFollowUps: boolean, + state: Pick ): boolean { return ( - queueCapable && !pendingPromptsAllUnanswerableHere(selectAgentSessionCurrentRequests(state)) + queueCapable && + structuredAgentSessionQueueEnabled( + queueFollowUps, + state, + selectAgentSessionCurrentRequests(state) + ) ) } @@ -44,12 +57,17 @@ export function useMobileStructuredSendWithOutcome(args: { sessionId: string | null enabled: boolean queueCapable: boolean + queueFollowUps: boolean commandsWait: boolean stateRef: { readonly current: StructuredAgentSessionState } stopping: boolean + /** The host's queue sends next: commands that cannot wait in line read the chat as working. */ + queueSendsNext: boolean accept: MobileStructuredSends['accept'] hold: MobileStructuredSends['hold'] commandPending: { current: boolean } + /** This chat's message sends still out, which a command waits behind as on desktop. */ + sendsOut: MobileStructuredSendsOut controller: Pick< StructuredAgentSessionComposerOptions, 'snapshot' | 'setOption' | 'invokeAction' | 'conversationCommands' @@ -58,11 +76,14 @@ export function useMobileStructuredSendWithOutcome(args: { onSendError: MobileNativeChatSendErrorReporter // Older hosts accept neither per-chat model nor effort. sendOptions?: () => Readonly> | undefined + // An older host refuses a send body carrying `skills`; there the `$name` text stands alone. + takesSkills?: boolean }): ( text: string, images?: string[], deadline?: number, - attachments?: readonly StructuredMobileSendAttachment[] + attachments?: readonly StructuredMobileSendAttachment[], + skills?: readonly AgentJournalSkillRef[] ) => Promise { const { accept, @@ -75,18 +96,23 @@ export function useMobileStructuredSendWithOutcome(args: { hold, onSendError, queueCapable, + queueFollowUps, + queueSendsNext, sendOptions, + sendsOut, sessionCommands, sessionId, stateRef, - stopping + stopping, + takesSkills } = args return useCallback( async ( text: string, images?: string[], deadline?: number, - attachments?: readonly StructuredMobileSendAttachment[] + attachments?: readonly StructuredMobileSendAttachment[], + pickedSkills?: readonly AgentJournalSkillRef[] ): Promise => { const currentFence = stateRef.current.fence if (!client || !sessionId || !enabled || currentFence === null) { @@ -111,13 +137,22 @@ export function useMobileStructuredSendWithOutcome(args: { fence: currentFence, pending: commandPending, controller: { agent: agent ?? undefined, ...controller, sessionCommands }, - busy: () => - actionableStructuredApproval(stateRef.current) || - actionableStructuredQuestion(stateRef.current) - ? 'prompt' - : runningStructuredAgentSessionTurnId(stateRef.current) - ? 'working' - : null, + agentName: agent ? (tuiAgentDisplayName(agent) ?? agent) : undefined, + holds: () => { + const state = stateRef.current + const turnId = runningStructuredAgentSessionTurnId(state) + const tasks = structuredSessionBackgroundTasksView(state.backgroundTasks, turnId) + return { + working: turnId !== null || queueSendsNext, + prompt: Boolean( + actionableStructuredApproval(state) || actionableStructuredQuestion(state) + ), + background: tasks.show || tasks.isMonitoring, + backgroundTasksRunning: tasks.isMonitoring, + sending: sendsOut.sending() + } + }, + startFailures: () => agentSessionVisibleFailureFacts(stateRef.current.items), waitsInLine: (command) => command === 'compact' && commandsWait && @@ -138,7 +173,19 @@ export function useMobileStructuredSendWithOutcome(args: { } } const wireText = nativeChatTextWithFileReferences(text, files) - const body = structuredAgentSessionSendBody(wireText, imageAttachments) + const skills = takesSkills ? (pickedSkills ?? []) : [] + let body: ReturnType + try { + body = structuredAgentSessionSendBody(wireText, imageAttachments, skills) + } catch (error) { + if (!(error instanceof SkillRefLimitError)) { + throw error + } + onSendError( + `Message not sent. Keep at most ${MAX_SKILL_REFS} selected skill files and try again.` + ) + return 'rejected' + } if (body.blocks.length === 0) { return 'rejected' } @@ -150,13 +197,14 @@ export function useMobileStructuredSendWithOutcome(args: { } // Wait for pending picks so the send carries this chat’s settled options. const options = sendOptions?.() - const outcome = await sendMobileStructuredAgentSessionMessage({ + const outcome = await sendsOut.trackSend(sendMobileStructuredAgentSessionMessage)({ client, sessionId, expectedRuntimeFence: currentFence, text: wireText, attachments: imageAttachments, - ...(mobileStructuredSendQueues(queueCapable, stateRef.current) + skills, + ...(mobileStructuredSendQueues(queueCapable, queueFollowUps, stateRef.current) ? { delivery: 'queue-if-active' as const } : {}), ...(options ? { options } : {}), @@ -182,11 +230,15 @@ export function useMobileStructuredSendWithOutcome(args: { hold, onSendError, queueCapable, + queueFollowUps, + queueSendsNext, sendOptions, + sendsOut, sessionCommands, sessionId, stateRef, - stopping + stopping, + takesSkills ] ) } diff --git a/mobile/src/session/use-mobile-structured-sends.test.tsx b/mobile/src/session/use-mobile-structured-sends.test.tsx index 6695158f6a3..781ed76c7a7 100644 --- a/mobile/src/session/use-mobile-structured-sends.test.tsx +++ b/mobile/src/session/use-mobile-structured-sends.test.tsx @@ -60,7 +60,7 @@ describe('a structured send in the open chat', () => { let listener: ((value: unknown) => void) | null = null let answer: (id: string) => unknown = () => null const onSendError = vi.fn() - const sendRequest = vi.fn() + const sendRequest = vi.fn(async () => ok({})) const client: RpcClient = { sendRequest, subscribe: vi.fn((_method, _params, onData) => { diff --git a/mobile/src/session/use-mobile-structured-sends.ts b/mobile/src/session/use-mobile-structured-sends.ts index fa0e7877c6c..c30c804b898 100644 --- a/mobile/src/session/use-mobile-structured-sends.ts +++ b/mobile/src/session/use-mobile-structured-sends.ts @@ -12,7 +12,7 @@ import { structuredAgentSessionSettledSends } from '../../../src/shared/structured-agent-session-send-settlement' import { mergeSentImagePreviews } from './native-chat/composer/mobile-native-chat-draft-reconcile' -import type { MobileQueuedMessageFeed } from './mobile-structured-queued-message-feed' +import type { AgentSessionQueuedMessage } from '../../../src/shared/agent-session-wire' import type { MobileNativeChatSendErrorReporter } from './native-chat/use-mobile-native-chat-send-error' import { UNCONFIRMED_SEND_DEADLINE_MS, @@ -35,7 +35,7 @@ export function useMobileStructuredSends(args: { sessionKey: string cursor?: AgentJournalCursor | null submissions: readonly AgentJournalSubmission[] - queuedMessages: MobileQueuedMessageFeed + queuedMessages: readonly AgentSessionQueuedMessage[] | null onSendError: MobileNativeChatSendErrorReporter }): MobileStructuredSends { const { cursor, onSendError, queuedMessages, sessionKey, submissions } = args diff --git a/mobile/src/session/use-mobile-structured-stop-offer.test.ts b/mobile/src/session/use-mobile-structured-stop-offer.test.ts new file mode 100644 index 00000000000..87e7088f686 --- /dev/null +++ b/mobile/src/session/use-mobile-structured-stop-offer.test.ts @@ -0,0 +1,69 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, describe, expect, it } from 'vitest' +import { useMobileStructuredStopOffer } from './use-mobile-structured-stop-offer' + +describe('useMobileStructuredStopOffer', () => { + let renderer: ReactTestRenderer | null = null + let offer: { canStop: boolean; sending: () => boolean; send: () => Promise } | null = null + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + }) + + function harness(send: () => Promise, stopsConversation: boolean, sessionKey: string) { + function Harness(): null { + const { canStop, sendsOut } = useMobileStructuredStopOffer({ + sessionKey, + turnId: null, + isWorking: false, + stopsConversation + }) + offer = { canStop, sending: sendsOut.sending, send: sendsOut.trackSend(send) } + return null + } + return createElement(Harness) + } + + it.each([true, false])( + 'offers Stop for a send in flight only where the host stops the conversation (%s)', + async (stopsConversation) => { + let settle!: (value: string) => void + const send = () => new Promise((resolve) => (settle = resolve)) + act(() => { + renderer = create(harness(send, stopsConversation, 'chat')) + }) + expect(offer?.canStop).toBe(false) + + let sent: Promise | undefined + act(() => { + sent = offer?.send() + }) + expect(offer?.canStop).toBe(stopsConversation) + expect(offer?.sending()).toBe(true) + + await act(async () => { + settle('accepted') + await sent + }) + expect(offer?.canStop).toBe(false) + expect(offer?.sending()).toBe(false) + } + ) + + it("keeps one chat's send out of another chat's Stop and command hold", () => { + const send = () => new Promise(() => {}) + act(() => { + renderer = create(harness(send, true, 'first')) + }) + act(() => { + void offer?.send() + }) + expect(offer?.sending()).toBe(true) + + act(() => renderer?.update(harness(send, true, 'second'))) + expect(offer?.canStop).toBe(false) + expect(offer?.sending()).toBe(false) + }) +}) diff --git a/mobile/src/session/use-mobile-structured-stop-offer.ts b/mobile/src/session/use-mobile-structured-stop-offer.ts new file mode 100644 index 00000000000..fad26c894eb --- /dev/null +++ b/mobile/src/session/use-mobile-structured-stop-offer.ts @@ -0,0 +1,66 @@ +import { useCallback, useMemo, useRef, useState } from 'react' +import { structuredAgentSessionCanStop } from '../../../src/shared/structured-agent-session-stop-control' + +type Send = (...args: Args) => Promise + +export type MobileStructuredSendsOut = { + trackSend: (send: Send) => Send + sending: () => boolean +} + +/** Desktop's Stop rule, counting this chat's message sends still awaiting the host as sending. */ +export function useMobileStructuredStopOffer(input: { + sessionKey: string + turnId: string | null + isWorking: boolean + stopsConversation: boolean +}): { canStop: boolean; sendsOut: MobileStructuredSendsOut } { + const { sessionKey } = input + const countsRef = useRef(new Map()) + // Re-render only when a chat starts or stops having a send out, not on every send. + const [sendingKeys, setSendingKeys] = useState>(() => new Set()) + const trackSend = useCallback( + (send: Send): Send => + async (...args) => { + const count = (by: 1 | -1) => { + const next = (countsRef.current.get(sessionKey) ?? 0) + by + if (next === 0) { + countsRef.current.delete(sessionKey) + } else { + countsRef.current.set(sessionKey, next) + } + if (next === 0 || (next === 1 && by === 1)) { + setSendingKeys((previous) => { + const keys = new Set(previous) + if (next === 0) { + keys.delete(sessionKey) + } else { + keys.add(sessionKey) + } + return keys + }) + } + } + count(1) + try { + return await send(...args) + } finally { + count(-1) + } + }, + [sessionKey] + ) + const sendsOut = useMemo( + () => ({ trackSend, sending: () => countsRef.current.has(sessionKey) }), + [sessionKey, trackSend] + ) + return { + canStop: structuredAgentSessionCanStop({ + turnId: input.turnId, + isWorking: input.isWorking, + sending: sendingKeys.has(sessionKey), + stopsConversation: input.stopsConversation + }), + sendsOut + } +} diff --git a/mobile/src/session/use-mobile-structured-turn-indicator.ts b/mobile/src/session/use-mobile-structured-turn-indicator.ts new file mode 100644 index 00000000000..bb0ed58fe07 --- /dev/null +++ b/mobile/src/session/use-mobile-structured-turn-indicator.ts @@ -0,0 +1,44 @@ +import { useMemo } from 'react' +import type { StructuredAgentSessionState } from '../../../src/shared/structured-agent-session-reducer' +import type { NativeChatLiveTurnIndicator } from '../../../src/shared/native-chat-turn-status' +import { isStructuredAgentSessionThinking } from '../../../src/shared/structured-agent-session-live-turn' +import { selectStructuredAgentTurnActivity } from '../../../src/shared/native-chat-turn-activity' +import { agentStopDisplayStatus } from '../../../src/shared/agent-stop-display-status' +import { mobileStructuredSendQueues } from './use-mobile-structured-send-with-outcome' + +export function useMobileStructuredTurnIndicator(input: { + state: StructuredAgentSessionState + turnId: string | null + isWorking: boolean + /** Desktop holds Stop on whether it can stop, which covers a send still in flight. */ + canStop: boolean + hostStopping: boolean + stopPressed: boolean + queueCapable: boolean + queueFollowUps: boolean +}): NativeChatLiveTurnIndicator & { stopping: boolean } { + const { state, turnId, hostStopping, stopPressed, queueCapable, queueFollowUps } = input + const activityText = useMemo( + () => selectStructuredAgentTurnActivity(state.items, turnId, state.activity)?.text ?? null, + [state.items, turnId, state.activity] + ) + const thinking = isStructuredAgentSessionThinking(state) + const working = input.isWorking || input.canStop + const stopping = agentStopDisplayStatus({ working, hostStopping, stopPressed }) === 'stopping' + const stopRequestInFlight = working && stopPressed + const afterStop = stopping + ? mobileStructuredSendQueues(queueCapable, queueFollowUps, state) + ? ('queue' as const) + : ('send' as const) + : undefined + return useMemo( + () => ({ + thinking, + activityText, + stopping, + stopRequestInFlight, + ...(afterStop ? { afterStop } : {}) + }), + [thinking, activityText, stopping, stopRequestInFlight, afterStop] + ) +} diff --git a/mobile/src/tasks/mobile-tasks-host-subtitle.tsx b/mobile/src/tasks/mobile-tasks-host-subtitle.tsx index 2fc014aaef0..7994d6a3d53 100644 --- a/mobile/src/tasks/mobile-tasks-host-subtitle.tsx +++ b/mobile/src/tasks/mobile-tasks-host-subtitle.tsx @@ -1,27 +1,10 @@ -import { useEffect, useState } from 'react' import { StyleSheet, Text } from 'react-native' import { hostScreenStyles } from '../host-screen/host-screen-styles' -import { loadHosts } from '../transport/host-store' -import type { HostProfile } from '../transport/types' +import { useHostProfile } from '../transport/use-host-profile' import { useHostDisplay } from '../transport/use-host-display' export function MobileTasksHostSubtitle({ hostId }: { hostId: string }) { - const [host, setHost] = useState(null) - useEffect(() => { - let stale = false - setHost(null) - loadHosts().then( - (hosts) => { - if (!stale) { - setHost(hosts.find((profile) => profile.id === hostId) ?? null) - } - }, - () => {} - ) - return () => { - stale = true - } - }, [hostId]) + const host = useHostProfile(hostId) const title = useHostDisplay(host).title // Reserve the line before load to prevent a title jump; unknown hosts stay blank instead of showing generic Host. return ( diff --git a/mobile/src/test-support/rpc-recording/adapters/session-screen-read-mount-adapters.ts b/mobile/src/test-support/rpc-recording/adapters/session-screen-read-mount-adapters.ts index b630ac3404d..bdeffe7b99f 100644 --- a/mobile/src/test-support/rpc-recording/adapters/session-screen-read-mount-adapters.ts +++ b/mobile/src/test-support/rpc-recording/adapters/session-screen-read-mount-adapters.ts @@ -171,6 +171,8 @@ export function sessionScreenReadMountAdapters( enabled, handleRef: { current: HANDLE }, deviceTokenRef: { current: DEVICE_TOKEN }, + // OpenCode is the agent whose Stop sends the paced second Escape these recordings pin. + agentRef: { current: 'opencode' }, streamIdentity: 'stream-1', cancelPending: () => effect('cancel-pending', {}), onSendError: (message: string) => errors.push(message) diff --git a/mobile/src/transport/settings-read-operations.ts b/mobile/src/transport/settings-read-operations.ts index ffb2e4265bd..6f43c226342 100644 --- a/mobile/src/transport/settings-read-operations.ts +++ b/mobile/src/transport/settings-read-operations.ts @@ -90,16 +90,20 @@ export const newTabSettingsRead = bindDeferredRpcOperation( }) ) -const copyTrimsGutterReader: RpcCompatibleReader = (raw) => { - const settings = raw == null ? undefined : settingsMember(raw) - const trims: unknown = - settings == null ? undefined : settingsField(settings, 'terminalCopyTrimsGutter') - return { - compatible: true, - variant: 'copy-trims-gutter', - // Older hosts omit this setting; absence inherits the desktop’s enabled default. - value: trims !== false, - salvage: { droppedPaths: [], droppedCount: 0 } +function onByDefaultSettingReader( + key: string, + variant: Variant +): RpcCompatibleReader { + return (raw) => { + const settings = raw == null ? undefined : settingsMember(raw) + const value: unknown = settings == null ? undefined : settingsField(settings, key) + return { + compatible: true, + variant, + // Older hosts omit these settings; absence inherits the desktop’s enabled default. + value: value !== false, + salvage: { droppedPaths: [], droppedCount: 0 } + } } } @@ -109,7 +113,17 @@ export const terminalCopyTrimsGutterRead = bindDeferredRpcOperation( method: 'settings.get', acceptance: 'success-result-or-skip', barrier: 'after-caller-barrier', - read: copyTrimsGutterReader + read: onByDefaultSettingReader('terminalCopyTrimsGutter', 'copy-trims-gutter') + }) +) + +export const nativeChatQueueFollowUpsRead = bindDeferredRpcOperation( + defineRpcOperation({ + name: 'settings.native-chat-queue-follow-ups-or-skip', + method: 'settings.get', + acceptance: 'success-result-or-skip', + barrier: 'after-caller-barrier', + read: onByDefaultSettingReader('nativeChatQueueFollowUps', 'queue-follow-ups') }) ) diff --git a/mobile/src/transport/use-host-profile.ts b/mobile/src/transport/use-host-profile.ts new file mode 100644 index 00000000000..5c51f737616 --- /dev/null +++ b/mobile/src/transport/use-host-profile.ts @@ -0,0 +1,24 @@ +import { useEffect, useState } from 'react' +import { loadHosts } from './host-store' +import type { HostProfile } from './types' + +/** The stored profile for a paired host; null while loading or when unknown. */ +export function useHostProfile(hostId: string): HostProfile | null { + const [host, setHost] = useState(null) + useEffect(() => { + let stale = false + setHost(null) + loadHosts().then( + (hosts) => { + if (!stale) { + setHost(hosts.find((profile) => profile.id === hostId) ?? null) + } + }, + () => {} + ) + return () => { + stale = true + } + }, [hostId]) + return host +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-codex-version-recovery.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-version-recovery.test.ts index 631e41e901d..dc6719dcfbe 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-codex-version-recovery.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-version-recovery.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, expect, it } from 'vitest' import { AgentCliInstallationError } from '../agent-cli-installation-error' import { isStructuredAgentSessionStartFailureRow } from '../../../shared/structured-agent-session-start-failure-row-key' -import { projectQueuedMessageCards } from '../../../renderer/src/components/native-chat/structured-agent-session-queued-cards' +import { projectQueuedMessageCards } from '../../../shared/structured-agent-session-queued-cards' import { HOST_TEST_SESSION, hostTestMessage } from './structured-agent-session-host-test-data' import { createQueuedMessageTestRig, diff --git a/src/main/runtime/files/search/file-search-mobile-paths.test.ts b/src/main/runtime/files/search/file-search-mobile-paths.test.ts new file mode 100644 index 00000000000..c8a176f77c4 --- /dev/null +++ b/src/main/runtime/files/search/file-search-mobile-paths.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it, vi } from 'vitest' + +const listQuickOpenFilesMock = vi.hoisted(() => vi.fn()) +vi.mock('../../../ipc/filesystem/filesystem-list-files', () => ({ + listQuickOpenFiles: listQuickOpenFilesMock +})) + +import { RuntimeFileSearch, type RuntimeFileSearchHost } from './file-search' + +function search(executionHostId: 'local' | `ssh:${string}`) { + const host: RuntimeFileSearchHost = { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: mobile path search hands the store only to the mocked listQuickOpenFiles. + requireStore: () => ({}) as ReturnType, + resolveRuntimeFileTarget: async () => ({ + worktree: { id: 'wt-1', path: '/repo' }, + executionHostId + }) + } + return new RuntimeFileSearch(host) +} + +describe('RuntimeFileSearch.searchMobileFilePaths', () => { + it("ranks the cached workspace listing with desktop's fuzzy Quick Open rule", async () => { + listQuickOpenFilesMock.mockResolvedValue([ + 'docs/composer-notes.md', + 'src/NativeChatComposer.tsx', + 'src/native-chat/use-composer.ts' + ]) + const files = search('local') + + const fuzzy = await files.searchMobileFilePaths('id:wt-1', 'ncomposer', 16) + expect(fuzzy.files.map((file) => file.relativePath)).toContain('src/NativeChatComposer.tsx') + + const bare = await files.searchMobileFilePaths('id:wt-1', '', 2) + expect(bare.files.map((file) => file.relativePath)).toEqual([ + 'docs/composer-notes.md', + 'src/native-chat/use-composer.ts' + ]) + expect(bare).toMatchObject({ totalCount: 3, truncated: true }) + expect(listQuickOpenFilesMock).toHaveBeenCalledOnce() + }) + + it('lists nothing for an SSH workspace', async () => { + listQuickOpenFilesMock.mockClear() + const result = await search('ssh:box').searchMobileFilePaths('id:wt-1', 'a', 16) + expect(result).toMatchObject({ files: [], totalCount: 0, truncated: false }) + expect(listQuickOpenFilesMock).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/runtime/files/search/file-search.ts b/src/main/runtime/files/search/file-search.ts index ee4bf0613d0..0bbefacbbc9 100644 --- a/src/main/runtime/files/search/file-search.ts +++ b/src/main/runtime/files/search/file-search.ts @@ -1,5 +1,6 @@ import { QUICK_OPEN_SEARCH_VERSION, + QuickOpenPathRanker, isQuickOpenQueryTooLarge } from '../../../../shared/quick-open-path-search' import type { RuntimeFileListResult } from '../../../../shared/runtime-types' @@ -18,7 +19,7 @@ import { type ResolvedRuntimeFileTarget } from '../file-target' import { basenameFromRelativePath } from '../file-paths' -import { RuntimeMobileFilePathSearchCache, rankRuntimeMobileFilePaths } from './mobile-path-search' +import { RuntimeMobileFilePathSearchCache } from './mobile-path-search' const MOBILE_FILE_LIST_LIMIT = 5000 @@ -115,7 +116,12 @@ export class RuntimeFileSearch { truncated: safePaths.length > MOBILE_FILE_PATH_SEARCH_CACHE_LIMIT } }) - const matches = rankRuntimeMobileFilePaths(inventory.paths, query, limit) + // Rank the cached inventory with desktop's Quick Open rule, so every phone version gets it without a walk per query. + const ranker = new QuickOpenPathRanker(query, limit) + for (const path of inventory.paths) { + ranker.consider(path) + } + const matches = ranker.result() return { worktree: worktree.id, rootPath: worktree.path, diff --git a/src/main/runtime/files/search/mobile-path-search.test.ts b/src/main/runtime/files/search/mobile-path-search.test.ts index 293fca5e7d4..fbc99ad2246 100644 --- a/src/main/runtime/files/search/mobile-path-search.test.ts +++ b/src/main/runtime/files/search/mobile-path-search.test.ts @@ -1,86 +1,9 @@ import { describe, expect, it, vi } from 'vitest' import { - rankRuntimeMobileFilePaths, type RuntimeMobileFilePathInventory, RuntimeMobileFilePathSearchCache } from './mobile-path-search' -describe('rankRuntimeMobileFilePaths', () => { - it('preserves basename matching, ordering and total counts for unusual paths', () => { - const paths = [ - '', - '/', - 'a/', - 'a//b.ts', - 'b.ts', - 'B.TS', - 'a\\b.ts', - '界/😀.ts', - '.hidden', - 'a/./b.ts' - ] - const rankings = [ - { - query: '', - ranked: [ - '', - '/', - 'a/', - 'a//b.ts', - 'b.ts', - 'B.TS', - 'a\\b.ts', - '界/😀.ts', - '.hidden', - 'a/./b.ts' - ] - }, - { - query: ' ', - ranked: [ - '', - '/', - 'a/', - 'a//b.ts', - 'b.ts', - 'B.TS', - 'a\\b.ts', - '界/😀.ts', - '.hidden', - 'a/./b.ts' - ] - }, - { query: 'b', ranked: ['a//b.ts', 'b.ts', 'B.TS', 'a/./b.ts', 'a\\b.ts'] }, - { query: '.ts', ranked: ['a//b.ts', 'b.ts', 'B.TS', 'a\\b.ts', '界/😀.ts', 'a/./b.ts'] }, - { query: '😀', ranked: ['界/😀.ts'] }, - { query: '/', ranked: ['/', 'a/', 'a//b.ts', '界/😀.ts', 'a/./b.ts'] }, - { query: 'a\\', ranked: ['a\\b.ts'] }, - { query: 'missing', ranked: [] } - ] - for (const { query, ranked } of rankings) { - for (const limit of [0, 1, 3, 100]) { - expect(rankRuntimeMobileFilePaths(paths, query, limit)).toEqual({ - paths: ranked.slice(0, limit), - totalCount: ranked.length - }) - } - } - }) - - it('ranks path and basename prefixes before substrings and caps output', () => { - expect( - rankRuntimeMobileFilePaths( - ['docs/apple.md', 'src/apple.ts', 'src/deep/pineapple.ts', 'z/apple.test.ts'], - 'apple', - 3 - ) - ).toEqual({ - paths: ['docs/apple.md', 'src/apple.ts', 'z/apple.test.ts'], - totalCount: 4 - }) - }) -}) - describe('RuntimeMobileFilePathSearchCache', () => { it('reuses a live entry and evicts the oldest key at capacity', async () => { const cache = new RuntimeMobileFilePathSearchCache(2, 100) diff --git a/src/main/runtime/files/search/mobile-path-search.ts b/src/main/runtime/files/search/mobile-path-search.ts index e5be834e19d..9447b4e4fb6 100644 --- a/src/main/runtime/files/search/mobile-path-search.ts +++ b/src/main/runtime/files/search/mobile-path-search.ts @@ -113,34 +113,3 @@ export class RuntimeMobileFilePathSearchCache { } } } - -/** Keep composer prefix-before-substring ranking while returning a bounded slice. */ -export function rankRuntimeMobileFilePaths( - paths: readonly string[], - query: string, - limit: number -): { paths: string[]; totalCount: number } { - const normalizedQuery = query.trim().toLowerCase() - if (!normalizedQuery) { - return { paths: paths.slice(0, limit), totalCount: paths.length } - } - const prefix: string[] = [] - const substring: string[] = [] - let totalCount = 0 - for (const path of paths) { - const lower = path.toLowerCase() - const basename = lower.slice(lower.lastIndexOf('/') + 1) - if (lower.startsWith(normalizedQuery) || basename.startsWith(normalizedQuery)) { - totalCount++ - if (prefix.length < limit) { - prefix.push(path) - } - } else if (lower.includes(normalizedQuery)) { - totalCount++ - if (substring.length < limit) { - substring.push(path) - } - } - } - return { paths: [...prefix, ...substring].slice(0, limit), totalCount } -} diff --git a/src/main/runtime/rpc/methods/skills.test.ts b/src/main/runtime/rpc/methods/skills.test.ts index dbd056b8011..30a314b48ea 100644 --- a/src/main/runtime/rpc/methods/skills.test.ts +++ b/src/main/runtime/rpc/methods/skills.test.ts @@ -29,13 +29,16 @@ const WSL_RUNTIME = { function makeContext(overrides: { resolveProjectRuntimeForWorktree?: (worktreeId: string | null | undefined) => unknown + resolveRuntimeFileTarget?: (selector: string) => Promise }): RpcContext { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: skills.discover reads only these runtime members. return { runtime: { listRepos: () => [], resolveSkillDiscoveryProviderRoots: async () => ({}), resolveProjectRuntimeForWorktree: - overrides.resolveProjectRuntimeForWorktree ?? (() => undefined) + overrides.resolveProjectRuntimeForWorktree ?? (() => undefined), + resolveRuntimeFileTarget: overrides.resolveRuntimeFileTarget } } as unknown as RpcContext } @@ -77,6 +80,35 @@ describe('skills.discover RPC', () => { ) }) + it('scans a workspace named only by id in its folder, and refuses SSH workspaces', async () => { + const resolveRuntimeFileTarget = vi.fn(async () => ({ + worktree: { path: '/repo/feature' }, + executionHostId: 'local' + })) + await discoverMethod().handler( + { worktreeId: 'repo::/repo/feature' }, + makeContext({ resolveRuntimeFileTarget }) + ) + expect(resolveRuntimeFileTarget).toHaveBeenCalledWith('id:repo::/repo/feature') + expect(vi.mocked(resolveSkillDiscoveryTarget)).toHaveBeenLastCalledWith( + expect.objectContaining({ cwd: '/repo/feature' }) + ) + + const discoveries = vi.mocked(discoverSkillsOnTarget).mock.calls.length + await expect( + discoverMethod().handler( + { worktreeId: 'remote::/srv/app' }, + makeContext({ + resolveRuntimeFileTarget: async () => ({ + worktree: { path: '/srv/app' }, + executionHostId: 'ssh:box' + }) + }) + ) + ).rejects.toThrow('unavailable for SSH hosts') + expect(vi.mocked(discoverSkillsOnTarget).mock.calls).toHaveLength(discoveries) + }) + it('prefers a caller-supplied project runtime over store resolution', async () => { const resolveProjectRuntimeForWorktree = vi.fn() await discoverMethod().handler( diff --git a/src/main/runtime/rpc/methods/skills.ts b/src/main/runtime/rpc/methods/skills.ts index 01c523ed333..333c2e33f15 100644 --- a/src/main/runtime/rpc/methods/skills.ts +++ b/src/main/runtime/rpc/methods/skills.ts @@ -32,6 +32,7 @@ import { } from '../../../skills/skill-discovery-target' import { SKILL_INSTALL_RESULT_V2_CAPABILITY } from '../../../../shared/skill-install-capability' import type { OrcaRuntimeService } from '../../orca-runtime' +import { parseExecutionHostId } from '../../../../shared/execution-host' import { AGENT_SKILL_SHARING_UNSUPPORTED_ENVIRONMENT_CODE, AgentSkillShareRequestSchema, @@ -57,6 +58,21 @@ export function resolveDiscoveryTarget( return resolveSkillDiscoveryTarget(target) } +// A caller naming only the workspace gets its folder from the host, which refuses SSH workspaces like desktop. +async function withWorkspaceCwd( + params: z.infer, + runtime: Pick +): Promise> { + if (params.cwd?.trim() || !params.worktreeId) { + return params + } + const target = await runtime.resolveRuntimeFileTarget(`id:${params.worktreeId}`) + if (parseExecutionHostId(target.executionHostId)?.kind === 'ssh') { + throw new Error('Skill discovery is unavailable for SSH hosts.') + } + return { ...params, cwd: target.worktree.path } +} + function skillDeleteDependencies( runtime: Pick ): SkillDeleteRequestDependencies { @@ -74,7 +90,10 @@ export const SKILL_METHODS = [ params: SkillsDiscoverParams, handler: async (params, { runtime }) => { // Resolve WSL preferences on the executing host; remote clients provide workspace identity, not trusted runtime absence. - const resolvedTarget = await resolveDiscoveryTarget(params, runtime) + const resolvedTarget = await resolveDiscoveryTarget( + await withWorkspaceCwd(params, runtime), + runtime + ) return discoverSkillsOnTarget(resolvedTarget, runtime.listRepos(), { providerRootOverrides: await runtime.resolveSkillDiscoveryProviderRoots(resolvedTarget), refresh: params.refresh === true diff --git a/src/main/runtime/runtime-client-settings-queue-follow-ups-projection.test.ts b/src/main/runtime/runtime-client-settings-queue-follow-ups-projection.test.ts new file mode 100644 index 00000000000..4a6c1ad038c --- /dev/null +++ b/src/main/runtime/runtime-client-settings-queue-follow-ups-projection.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest' +import { RuntimeClientSettingsController } from './runtime-client-settings' +import { createGlobalSettingsFixture } from '../../shared/global-settings-test-fixture' +import type { GlobalSettings } from '../../shared/global-settings-types' + +// Missing nativeChatQueueFollowUps looks like an older host to mobile, which then queues mid-turn sends. +function projectionOf(settings: Partial) { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: get() reads nothing but store.getSettings(); every other RuntimeStore member is unreachable from that path. + return new RuntimeClientSettingsController({ getSettings: () => settings } as never).get() +} + +function hostSettings(overrides: Partial): Partial { + return { ...createGlobalSettingsFixture({ workspaceDir: '/w' }), ...overrides } +} + +describe('RuntimeClientSettingsController queue follow-ups projection', () => { + it('publishes the opt-out to paired clients', () => { + expect( + projectionOf(hostSettings({ nativeChatQueueFollowUps: false })).nativeChatQueueFollowUps + ).toBe(false) + }) + + it('reports on when the host has no persisted preference', () => { + const settings = hostSettings({}) + delete settings.nativeChatQueueFollowUps + expect(projectionOf(settings).nativeChatQueueFollowUps).toBe(true) + }) +}) diff --git a/src/main/runtime/runtime-client-settings-shape.ts b/src/main/runtime/runtime-client-settings-shape.ts index b4cb2e4f199..bebefc05fe0 100644 --- a/src/main/runtime/runtime-client-settings-shape.ts +++ b/src/main/runtime/runtime-client-settings-shape.ts @@ -34,6 +34,7 @@ export type RuntimeClientSettings = Pick< | 'agentSkillSharingEnabled' | 'machineName' | 'nativeChatPermissionMode' + | 'nativeChatQueueFollowUps' | 'sourceControlGroupOrder' | 'sourceControlCompareAgainstUpstream' | 'diffShowWhitespace' diff --git a/src/main/runtime/runtime-client-settings.ts b/src/main/runtime/runtime-client-settings.ts index a2b05aaaef9..52429de09bb 100644 --- a/src/main/runtime/runtime-client-settings.ts +++ b/src/main/runtime/runtime-client-settings.ts @@ -109,6 +109,8 @@ export class RuntimeClientSettingsController { : { agentSkillSharingEnabled: isAgentSkillSharingEnabled(settings) }), machineName: settings.machineName ?? '', nativeChatPermissionMode: publishedLegacyChatPermissionMode(settings), + // Phone sends queue mid-turn only while this is on; absence from older hosts means on. + nativeChatQueueFollowUps: settings.nativeChatQueueFollowUps !== false, // Why projected: a paired client's AI buttons start these actions' agents, and must honour // the agent saved for each one as the desktop does. Absent on older hosts. sourceControlAi: projectSourceControlLaunchRecipes(settings), diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-routing.ts b/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-routing.ts index eaf1276ce10..63c0c3e687e 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-routing.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-routing.ts @@ -156,6 +156,8 @@ export const EXECUTION_HOST_METHODS = [ 'repo.searchRefs', 'repo.sparsePresets', 'repo.update', + // The host that owns the workspace scans its skills. + 'skills.discover', // SSH targets belong to the host that holds them; untargeted calls stay the Mac's own, as on the desktop. 'ssh.connect', 'ssh.getState', diff --git a/src/main/runtime/runtime-store-contract.ts b/src/main/runtime/runtime-store-contract.ts index 0506e10b587..99b729e2f1e 100644 --- a/src/main/runtime/runtime-store-contract.ts +++ b/src/main/runtime/runtime-store-contract.ts @@ -182,6 +182,7 @@ export type RuntimeStore = { agentSkillSharingEnabled?: GlobalSettings['agentSkillSharingEnabled'] nativeChatSessionOptions?: GlobalSettings['nativeChatSessionOptions'] nativeChatPermissionMode?: GlobalSettings['nativeChatPermissionMode'] + nativeChatQueueFollowUps?: GlobalSettings['nativeChatQueueFollowUps'] nativeChatInlineVisuals?: GlobalSettings['nativeChatInlineVisuals'] nativeChatInheritShellEnvironment?: GlobalSettings['nativeChatInheritShellEnvironment'] nativeChatShellEnvironmentVariables?: GlobalSettings['nativeChatShellEnvironmentVariables'] diff --git a/src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.test.tsx b/src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.test.tsx index ce71d7635f0..6f18d06084f 100644 --- a/src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.test.tsx @@ -5,7 +5,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { NativeChatMentionMenu, NativeChatPickerMenu } from './NativeChatAutocompleteMenus' import { buildNativeChatPickerItems } from '../../../../shared/native-chat/input/native-chat-picker-items' import { sessionSlashCommandSuggestions } from '../../../../shared/native-chat/input/native-chat-slash-commands' -import type { ComposerAutocomplete } from './native-chat-composer-state' +import type { ComposerAutocomplete } from '../../../../shared/native-chat-composer-autocomplete' function autocomplete( overrides: Partial> = {} diff --git a/src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.tsx b/src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.tsx index 2ef98069424..f235dbe6f2f 100644 --- a/src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.tsx +++ b/src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.tsx @@ -5,8 +5,10 @@ import { cn } from '@/lib/utils' import type { SkillSourceKind } from '../../../../shared/skills' import { FilenameFirstPath } from '@/components/file-path-cursor-tooltip' import { FileTypeIcon } from '@/components/FileTypeIcon' -import type { ComposerAutocomplete } from './native-chat-composer-state' -import type { NativeChatPickerItem } from '../../../../shared/native-chat/input/native-chat-picker-items' +import type { + ComposerAutocomplete, + NativeChatPickerItem +} from '../../../../shared/native-chat-composer-autocomplete' import type { NativeChatMentionFiles } from './use-native-chat-mention-files' import { LoadingSpinner } from '@/components/LoadingSpinner' diff --git a/src/renderer/src/components/native-chat/NativeChatComposerField.tsx b/src/renderer/src/components/native-chat/NativeChatComposerField.tsx index 3597443e7e3..d9054c27985 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposerField.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposerField.tsx @@ -5,8 +5,10 @@ import { useCallback, useLayoutEffect, useMemo, useRef } from 'react' import { flushSync } from 'react-dom' import type { useImeEnterGestureOwnership } from '@/lib/ime-composition-keyboard-event' import { cn } from '@/lib/utils' -import type { ComposerAutocomplete } from './native-chat-composer-state' -import type { NativeChatPickerItem } from '../../../../shared/native-chat/input/native-chat-picker-items' +import type { + ComposerAutocomplete, + NativeChatPickerItem +} from '../../../../shared/native-chat-composer-autocomplete' import { NativeChatMentionMenu, NativeChatPickerMenu } from './NativeChatAutocompleteMenus' import type { NativeChatMentionFiles } from './use-native-chat-mention-files' import { NativeChatComposerActions } from './NativeChatComposerActions' diff --git a/src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.tsx b/src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.tsx index 33afc5d4954..bd51b237993 100644 --- a/src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.tsx +++ b/src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.tsx @@ -5,10 +5,8 @@ import { basename } from '@/lib/path' import { useLocalImageSrc } from '@/components/editor/useLocalImageSrc' import { PanZoomImageDialog } from '@/components/editor/PanZoomImageDialog' import { copyableNativeChatImageSrc, keepPreviewOpenForChatMenu } from './native-chat-image-copy' -import { - isNativeChatImageAttachmentPath, - isNativeChatPastedImagePath -} from './native-chat-image-paste' +import { isNativeChatImageAttachmentPath } from './native-chat-image-paste' +import { isNativeChatPastedImagePath } from '../../../../shared/native-chat-image-label' import type { NativeChatComposerImageAttachment } from './NativeChatComposerField' import { isAgentSessionAttachmentStorePath } from '../../../../shared/agent-session-attachments' import { chatImageAccess } from '@/lib/local-file-access' diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx index 2ff68221d51..952d165b851 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx @@ -64,9 +64,7 @@ import type { NativeChatSettledTurns } from '../../../../shared/native-chat-turn import type { NativeChatDiffReveal, NativeChatDiffTarget } from './native-chat-turn-diffs' import { useNativeChatTurnDiffs } from './use-native-chat-turn-diffs' import { transcriptTailRow } from './native-chat-transcript-tail-row' - -/** shown prompts have a card in this pane; unshown prompts require input in the agent’s terminal. */ -export type NativeChatAwaitingInput = 'shown' | 'unshown' +import type { NativeChatAwaitingInput } from '../../../../shared/native-chat/native-chat-terminal-turn' type NavigationRequest = | { kind: 'diff'; target: NativeChatDiffReveal } diff --git a/src/renderer/src/components/native-chat/NativeChatNoticeRow.tsx b/src/renderer/src/components/native-chat/NativeChatNoticeRow.tsx index db587a88cc3..b7ff3114f19 100644 --- a/src/renderer/src/components/native-chat/NativeChatNoticeRow.tsx +++ b/src/renderer/src/components/native-chat/NativeChatNoticeRow.tsx @@ -18,7 +18,8 @@ import { } from '../../../../shared/native-chat-notice' import { useNativeChatOrcaStopView } from './native-chat-orca-stop-context' import { NativeChatInterruptedContinueButton } from './NativeChatInterruptedContinue' -import { nativeChatOrcaStopRowText } from './native-chat-orca-stop-words' +import { nativeChatOrcaStopRowText } from '../../../../shared/native-chat/native-chat-orca-stop-words' +import { sayNativeChatOrcaStopTranslated } from './native-chat-orca-stop-words-text' import { ProviderFrameRow } from './NativeChatTranscriptChrome' import { Button } from '@/components/ui/button' import { @@ -140,10 +141,7 @@ export function NativeChatNoticeRow({ const { hostLabel, remoteHost, continueAvailable } = orcaStopView // A paired server with no name to show is still a remote server; its id is not a name. const machine = - hostLabel ?? - (remoteHost - ? translate('components.native-chat.notices.orcaStopUnnamedServer', 'the remote server') - : null) + hostLabel ?? (remoteHost ? sayNativeChatOrcaStopTranslated('unnamedServer') : null) const named = orcaStop !== undefined && machine !== null const continueOffered = orcaStop?.turnItemId !== undefined && orcaStop.turnItemId === orcaStopView.offeredTurnItemId @@ -157,7 +155,12 @@ export function NativeChatNoticeRow({ ) : undefined const text = named - ? nativeChatOrcaStopRowText(orcaStop.cause, machine, { continueAvailable, remoteHost }) + ? nativeChatOrcaStopRowText( + orcaStop.cause, + machine, + { continueAvailable, remoteHost }, + sayNativeChatOrcaStopTranslated + ) : failure?.kind === 'usageLimitReached' ? agentSessionFailureSentence( failure, diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx index 4295b7c2fa3..87e7859dae0 100644 --- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx @@ -1,7 +1,4 @@ -import { - QueuedMessageImageCount, - queuedMessageImageCount -} from './NativeChatQueuedMessageImageCount' +import { QueuedMessageImageCount } from './NativeChatQueuedMessageImageCount' import type { QueuedMessageDragPresentation } from './NativeChatQueuedMessageRows' import { useId, useState } from 'react' import { @@ -25,27 +22,25 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip import { cn } from '@/lib/utils' import { ShortcutKeyCombo } from '@/components/ShortcutKeyCombo' import { translate } from '@/i18n/i18n' -import { structuredAgentSessionAttemptFailureParts } from '../../../../shared/structured-agent-session-rejection-words' -import { classifyDispatchRejection } from '../../../../shared/structured-agent-session-dispatch-rejection' -import { readWholeAgentSessionFailureFact } from '../../../../shared/agent-session-failure' import { agentSessionWriteNoticeText } from './agent-session-write-notice-text' import { QUEUED_MESSAGE_PAUSED_SEND_FAILED } from '../../../../shared/agent-session-wire' import { isMacPlatform } from './native-chat-shortcut' import { + queuedMessageCardLine, + queuedMessageCardNeedsAttention, queuedMessageCardSteers, + queuedMessageReturnedNotice, type QueuedMessageCard -} from './structured-agent-session-queued-cards' +} from '../../../../shared/structured-agent-session-queued-cards' import { NativeChatAgentMessageSenders } from './NativeChatAgentMessageSenders' import { NativeChatPendingIcon } from './NativeChatPendingIcon' import type { QueuedMessagesPendingAction } from './use-structured-agent-session-queued-messages' import { useNativeChatClippedLine } from './use-native-chat-clipped-line' -import { agentSessionFailureStatedByStartRow } from './structured-agent-session-delivery-notices' import type { AgentSessionFailureFact } from '../../../../shared/agent-session-failure' -import { agentSessionWriteNotDoneParts } from '../../../../shared/agent-session-refusal-notice' import { NativeChatQueuedMessageEditor } from './NativeChatQueuedMessageEditor' import type { QueuedEditAttachTarget } from './use-native-chat-queued-edit-image-intake' import type { QueuedMessageInlineEditor } from './use-structured-agent-session-queued-edit' -import { nativeChatImageLabel } from './native-chat-image-label' +import { nativeChatImageLabelCopy } from './native-chat-image-label' /** The visible caption under the text; the default waiting hold needs none. */ export function queuedMessageCardCaption( @@ -55,29 +50,13 @@ export function queuedMessageCardCaption( ): string | null { switch (card.hold) { case 'returned': { - // Read exactly as a rejected submission: the typed fact decides, the reason is the fallback. - const reason = card.returnedReason ?? null - if (agentSessionFailureStatedByStartRow(card.returnedRejection, statedFailures)) { - return agentSessionWriteNoticeText(agentSessionWriteNotDoneParts('send')) - } - // A Stop-withdrawn draft was not a send failure; describe its withdrawal. - if ( - classifyDispatchRejection({ reason, rejection: card.returnedRejection }).category === - 'withdrawn' - ) { - return translate( - 'components.native-chat.queuedMessages.withdrawnHold', - 'Stopped before it was sent' - ) - } - return agentSessionWriteNoticeText( - structuredAgentSessionAttemptFailureParts( - { kind: 'rejected', reason }, - // The card's own Send is the retry, so the words leave out sending again. - { agentName, retryControl: true }, - readWholeAgentSessionFailureFact(card.returnedRejection) - ) - ) + const notice = queuedMessageReturnedNotice(card, agentName, statedFailures) + return notice === 'withdrawn' + ? translate( + 'components.native-chat.queuedMessages.withdrawnHold', + 'Stopped before it was sent' + ) + : agentSessionWriteNoticeText(notice) } case 'paused': // Localize known pause markers; unknown newer-host markers fall back to a plain pause. @@ -108,17 +87,6 @@ export function queuedMessageCardCaption( } } -/** The card's line: its text, or for a card of images alone, the image's name or their count. */ -function queuedMessageCardLine(card: QueuedMessageCard): string { - const images = card.images ?? [] - if (card.text.trim() || images.length === 0) { - return card.text - } - return images.length === 1 - ? nativeChatImageLabel(images[0]) - : queuedMessageImageCount(images.length) -} - /** Steer's or Send's label and tooltip (`queuedMessageCardSteers`). */ export function queuedMessageCardSendNow(card: QueuedMessageCard): { /** Steer's ↳, or Send's paper plane. */ @@ -186,9 +154,9 @@ export function NativeChatQueuedMessageCard({ onTurnOffQueueing?: () => void }): React.JSX.Element { const caption = queuedMessageCardCaption(card, agentName, statedFailures) - const line = queuedMessageCardLine(card) + const line = queuedMessageCardLine(card, nativeChatImageLabelCopy()) const returned = card.state === 'returned' - const attention = returned || card.pausedReason === QUEUED_MESSAGE_PAUSED_SEND_FAILED + const attention = queuedMessageCardNeedsAttention(card) const sendNow = queuedMessageCardSendNow(card) const busy = pending !== null const isMac = isMacPlatform() diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageEditor.test.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageEditor.test.tsx index f1add4b4934..29ca12c1391 100644 --- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageEditor.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageEditor.test.tsx @@ -10,7 +10,7 @@ import { useStructuredAgentSessionQueuedEdit, type QueuedMessageInlineEditor } from './use-structured-agent-session-queued-edit' -import { projectQueuedMessageCards } from './structured-agent-session-queued-cards' +import { projectQueuedMessageCards } from '../../../../shared/structured-agent-session-queued-cards' import type { AgentSessionQueuedMessage } from '../../../../shared/agent-session-wire' import type { StructuredAgentSessionWrite } from './use-structured-agent-session-mutate' import type * as NativeChatAttachmentUpload from './native-chat-attachment-upload' diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageImageCount.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageImageCount.tsx index 19967856e5a..d99c50cb205 100644 --- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageImageCount.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageImageCount.tsx @@ -1,14 +1,7 @@ import { Image as ImageIcon } from 'lucide-react' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import { translate } from '@/i18n/i18n' -import type { QueuedMessageCard } from './structured-agent-session-queued-cards' -import { nativeChatImageLabel } from './native-chat-image-label' - -export function queuedMessageImageCount(count: number): string { - return translate('components.native-chat.queuedMessages.imageCount', '{{count}} images', { - count - }) -} +import type { QueuedMessageCard } from '../../../../shared/structured-agent-session-queued-cards' +import { nativeChatImageLabel, nativeChatImageLabelCopy } from './native-chat-image-label' /** Images are named in the tooltip, not drawn: the row stays one line. */ export function QueuedMessageImageCount({ @@ -22,7 +15,7 @@ export function QueuedMessageImageCount({ diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.arrival.test.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.arrival.test.tsx index 03eb9b040df..271c3d22cec 100644 --- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.arrival.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.arrival.test.tsx @@ -3,7 +3,7 @@ import { cleanup, fireEvent, render } from '@testing-library/react' import { afterEach, expect, it, vi } from 'vitest' import { TooltipProvider } from '@/components/ui/tooltip' -import type { QueuedMessageCard } from './structured-agent-session-queued-cards' +import type { QueuedMessageCard } from '../../../../shared/structured-agent-session-queued-cards' import type { StructuredAgentSessionQueuedMessagesController } from './use-structured-agent-session-queued-messages' vi.mock('../../store', () => { diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.inline-edit.test.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.inline-edit.test.tsx index 52f433da65e..c1c38f5666d 100644 --- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.inline-edit.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.inline-edit.test.tsx @@ -19,7 +19,7 @@ vi.mock('../../store', () => { }) import { TooltipProvider } from '@/components/ui/tooltip' -import type { QueuedMessageCard } from './structured-agent-session-queued-cards' +import type { QueuedMessageCard } from '../../../../shared/structured-agent-session-queued-cards' import { NativeChatQueuedMessageList } from './NativeChatQueuedMessageList' import type { StructuredAgentSessionQueuedMessagesController } from './use-structured-agent-session-queued-messages' diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx index efdabb4f9ff..f423080f699 100644 --- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx @@ -21,7 +21,7 @@ vi.mock('../../store', () => { }) import { TooltipProvider } from '@/components/ui/tooltip' -import type { QueuedMessageCard } from './structured-agent-session-queued-cards' +import type { QueuedMessageCard } from '../../../../shared/structured-agent-session-queued-cards' import { NativeChatQueuedMessageList } from './NativeChatQueuedMessageList' import { queuedMessageCardSendNow } from './NativeChatQueuedMessageCard' import { diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx index 82e46cb92c6..58f878f757f 100644 --- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx @@ -18,7 +18,7 @@ import { NativeChatPendingIcon } from './NativeChatPendingIcon' import type { AgentSessionFailureFact } from '../../../../shared/agent-session-failure' import { NATIVE_CHAT_ROOT_SELECTOR } from '@/lib/native-chat-paste-request' import { focusNativeChatPromptCard } from './use-native-chat-prompt-card-focus' -import { QUEUED_MESSAGE_PAUSED_SEND_FAILED } from '../../../../shared/agent-session-wire' +import { queuedMessageCardNeedsAttention } from '../../../../shared/structured-agent-session-queued-cards' import type { QueuedEditAttachTarget } from './use-native-chat-queued-edit-image-intake' /** Keep host-held drafts outside the transcript until consumed; keep the live region mounted for the first card. */ @@ -54,12 +54,7 @@ export function NativeChatQueuedMessageList({ setExpanded(true) } const newest = cards.at(-1) - const attentionCard = !expanded - ? cards.find( - (card) => - card.state === 'returned' || card.pausedReason === QUEUED_MESSAGE_PAUSED_SEND_FAILED - ) - : undefined + const attentionCard = !expanded ? cards.find(queuedMessageCardNeedsAttention) : undefined const attentionCaption = attentionCard ? queuedMessageCardCaption(attentionCard, agentName, statedFailures) : null diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageRows.test.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageRows.test.tsx index 3745bbff77a..c19c20eb444 100644 --- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageRows.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageRows.test.tsx @@ -5,7 +5,7 @@ import type * as DndKitCore from '@dnd-kit/core' import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react' import { expect, it, vi } from 'vitest' import { NativeChatQueuedMessageRows } from './NativeChatQueuedMessageRows' -import type { QueuedMessageCard } from './structured-agent-session-queued-cards' +import type { QueuedMessageCard } from '../../../../shared/structured-agent-session-queued-cards' const completion = vi.hoisted(() => ({ deferred: false })) vi.mock('@dnd-kit/core', async (importOriginal) => { diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageRows.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageRows.tsx index eac49211628..4de55ad21bb 100644 --- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageRows.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageRows.tsx @@ -18,7 +18,7 @@ import { GripVertical } from 'lucide-react' import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { translate } from '@/i18n/i18n' -import type { QueuedMessageCard } from './structured-agent-session-queued-cards' +import type { QueuedMessageCard } from '../../../../shared/structured-agent-session-queued-cards' import type { QueuedMessageReorder } from './use-structured-agent-session-queued-reorder' export type QueuedMessageDragPresentation = { diff --git a/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx b/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx index 16bbf5884e6..c7b8f9c7f06 100644 --- a/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx +++ b/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx @@ -25,8 +25,8 @@ import { useNativeChatPromptCardPresentation } from './use-native-chat-prompt-ca import { NativeChatPromptStrip } from './NativeChatPromptCollapse' import { NativeChatEmptyState } from './NativeChatEmptyState' import { useNativeChatInteractiveSend } from './use-native-chat-interactive-send' -import { shouldClearNativeChatWorkingSuppression } from './native-chat-working-suppression' -import { resolveNativeChatTerminalTurn } from './native-chat-terminal-turn' +import { shouldClearNativeChatWorkingSuppression } from '../../../../shared/native-chat/native-chat-working-suppression' +import { resolveNativeChatTerminalTurn } from '../../../../shared/native-chat/native-chat-terminal-turn' import { useNativeChatTerminalTurnTiming } from './use-native-chat-terminal-turn-timing' import { launchPromptAsMessage, shouldPruneLaunchPrompt } from './native-chat-pending' import { pendingSendsAsMessages } from '../../../../shared/native-chat-pending-sends' diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx index 78d607bddb7..c08704abcdf 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx @@ -1,7 +1,7 @@ import { useImperativeHandle } from 'react' import { vi } from 'vitest' import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' -import type { QueuedMessageCard } from './structured-agent-session-queued-cards' +import type { QueuedMessageCard } from '../../../../shared/structured-agent-session-queued-cards' import type { AgentSessionBackgroundTask } from '../../../../shared/agent-session-wire' import type { AgentSessionWriteRefusal } from '../../../../shared/agent-session-write-failure' import type { AgentSessionUnavailable } from '../../../../shared/agent-session-availability' diff --git a/src/renderer/src/components/native-chat/NativeChatTranscriptChrome.tsx b/src/renderer/src/components/native-chat/NativeChatTranscriptChrome.tsx index 26a1453bd7b..653c49d3e13 100644 --- a/src/renderer/src/components/native-chat/NativeChatTranscriptChrome.tsx +++ b/src/renderer/src/components/native-chat/NativeChatTranscriptChrome.tsx @@ -17,7 +17,7 @@ import { } from '@/components/editor/useLocalImageSrc' import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client-types' import { copyableNativeChatImageSrc, keepPreviewOpenForChatMenu } from './native-chat-image-copy' -import { isNativeChatPastedImagePath } from './native-chat-image-paste' +import { isNativeChatPastedImagePath } from '../../../../shared/native-chat-image-label' import { nativeChatImageLabel } from './native-chat-image-label' import { chatImageAccess } from '@/lib/local-file-access' diff --git a/src/renderer/src/components/native-chat/agent-session-write-notice-text.ts b/src/renderer/src/components/native-chat/agent-session-write-notice-text.ts index 006b81e4d35..8af1177d589 100644 --- a/src/renderer/src/components/native-chat/agent-session-write-notice-text.ts +++ b/src/renderer/src/components/native-chat/agent-session-write-notice-text.ts @@ -143,6 +143,7 @@ const SENTENCES: Record string> = { translate('components.native-chat.writeNotice.clearAfterSending', COPY.clearAfterSending), compactAfterSending: () => translate('components.native-chat.writeNotice.compactAfterSending', COPY.compactAfterSending), + pendingWork: () => translate('components.native-chat.writeNotice.pendingWork', COPY.pendingWork), queueTooLarge: () => translate('components.native-chat.writeNotice.queueTooLarge', COPY.queueTooLarge), shrinkQueue: () => translate('components.native-chat.writeNotice.shrinkQueue', COPY.shrinkQueue), diff --git a/src/renderer/src/components/native-chat/native-chat-composer-draft-persistence.ts b/src/renderer/src/components/native-chat/native-chat-composer-draft-persistence.ts index 0eca3d9ec2d..82296824abf 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-draft-persistence.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-persistence.ts @@ -1,7 +1,8 @@ // Persist whole memory records, journal unconfirmed changes on unload, and adopt other windows’ confirmed writes. import { basename } from '@/lib/path' -import { isNativeChatKeptPastePath, isNativeChatPastedImagePath } from './native-chat-image-paste' +import { isNativeChatKeptPastePath } from './native-chat-image-paste' +import { isNativeChatPastedImagePath } from '../../../../shared/native-chat-image-label' import { isAgentSessionAttachmentStorePath } from '../../../../shared/agent-session-attachments' import { dirtyScopes, diff --git a/src/renderer/src/components/native-chat/native-chat-image-label.ts b/src/renderer/src/components/native-chat/native-chat-image-label.ts index d9da57a4242..64c496ca568 100644 --- a/src/renderer/src/components/native-chat/native-chat-image-label.ts +++ b/src/renderer/src/components/native-chat/native-chat-image-label.ts @@ -1,16 +1,18 @@ import { translate } from '@/i18n/i18n' -import { basename } from '@/lib/path' import type { NativeChatImageRefBlock } from '../../../../shared/native-chat-types' -import { isNativeChatPastedImagePath } from './native-chat-image-paste' +import { + nativeChatImageLabel as sharedNativeChatImageLabel, + type NativeChatImageLabelCopy +} from '../../../../shared/native-chat-image-label' -/** What a chat image is called where it is not drawn: its alt text, else its file's name. */ +export function nativeChatImageLabelCopy(): NativeChatImageLabelCopy { + return { + pastedImage: translate('components.native-chat.composer.pastedImageLabel', 'Pasted image'), + imageCount: (count) => + translate('components.native-chat.queuedMessages.imageCount', '{{count}} images', { count }) + } +} + export function nativeChatImageLabel(block: NativeChatImageRefBlock): string { - return ( - block.alt?.trim() || - (block.path && isNativeChatPastedImagePath(block.path) - ? translate('components.native-chat.composer.pastedImageLabel', 'Pasted image') - : block.path - ? basename(block.path) - : 'Image') - ) + return sharedNativeChatImageLabel(block, nativeChatImageLabelCopy()) } diff --git a/src/renderer/src/components/native-chat/native-chat-image-paste.ts b/src/renderer/src/components/native-chat/native-chat-image-paste.ts index 0eb6e01e403..3164736b4a2 100644 --- a/src/renderer/src/components/native-chat/native-chat-image-paste.ts +++ b/src/renderer/src/components/native-chat/native-chat-image-paste.ts @@ -1,5 +1,6 @@ import { isImageDropPath } from '../terminal-pane/terminal-drop-image-path' import { NATIVE_CHAT_PASTE_FOLDER } from '../../../../shared/native-chat/input/native-chat-paste-folder' +import { isNativeChatPastedImagePath } from '../../../../shared/native-chat-image-label' export { getAgentImageHandling, type AgentImageHandling @@ -9,12 +10,6 @@ export function isNativeChatImageAttachmentPath(path: string): boolean { return isImageDropPath(path) } -/** Use friendly UI labels for Orca clipboard-image temp filenames. */ -export function isNativeChatPastedImagePath(path: string): boolean { - const base = path.split(/[\\/]/).findLast(Boolean) ?? path - return /^orca-paste-.+\.png$/i.test(base) -} - /** True for a long text paste Orca saved as a file (`orca-paste--.txt`). */ export function isNativeChatPastedTextPath(path: string): boolean { const base = path.split(/[\\/]/).findLast(Boolean) ?? path diff --git a/src/renderer/src/components/native-chat/native-chat-orca-stop-words-text.ts b/src/renderer/src/components/native-chat/native-chat-orca-stop-words-text.ts new file mode 100644 index 00000000000..4edcf639f25 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-orca-stop-words-text.ts @@ -0,0 +1,24 @@ +import { translate } from '@/i18n/i18n' +import { + NATIVE_CHAT_ORCA_STOP_COPY as COPY, + type NativeChatOrcaStopCopyId, + type NativeChatOrcaStopSay +} from '../../../../shared/native-chat/native-chat-orca-stop-words' + +const PIECES: Record string> = { + update: (machine) => + translate('components.native-chat.notices.orcaStopUpdate', COPY.update, { machine }), + quit: (machine) => + translate('components.native-chat.notices.orcaStopQuit', COPY.quit, { machine }), + crash: (machine) => + translate('components.native-chat.notices.orcaStopCrash', COPY.crash, { machine }), + remote: (machine) => + translate('components.native-chat.notices.orcaStopRemote', COPY.remote, { machine }), + canContinue: () => + translate('components.native-chat.notices.orcaStopCanContinue', COPY.canContinue), + unnamedServer: () => + translate('components.native-chat.notices.orcaStopUnnamedServer', COPY.unnamedServer) +} + +export const sayNativeChatOrcaStopTranslated: NativeChatOrcaStopSay = (id, machine) => + PIECES[id](machine) diff --git a/src/renderer/src/components/native-chat/native-chat-orca-stop-words.ts b/src/renderer/src/components/native-chat/native-chat-orca-stop-words.ts deleted file mode 100644 index 5b540ec4be4..00000000000 --- a/src/renderer/src/components/native-chat/native-chat-orca-stop-words.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { translate } from '@/i18n/i18n' -import type { AgentSessionOrcaStopCause } from '../../../../shared/agent-session-orca-stop' -import { joinSentences } from '../../../../shared/sentence-joining' - -const STOPPED: Record string> = { - update: (machine) => - translate( - 'components.native-chat.notices.orcaStopUpdate', - 'Orca on {{machine}} restarted for an update while this response was in progress.', - { machine } - ), - quit: (machine) => - translate( - 'components.native-chat.notices.orcaStopQuit', - 'Orca on {{machine}} was closed while this response was in progress.', - { machine } - ), - crash: (machine) => - translate( - 'components.native-chat.notices.orcaStopCrash', - 'Orca on {{machine}} stopped unexpectedly while this response was in progress.', - { machine } - ) -} - -// A remote graceful stop is a server stop or a shared desktop's quit; "stopped" is true of both. -const remoteQuit = (machine: string): string => - translate( - 'components.native-chat.notices.orcaStopRemote', - 'Orca on {{machine}} stopped while this response was in progress.', - { machine } - ) - -function stoppedSentence( - cause: AgentSessionOrcaStopCause, - machine: string, - remoteHost: boolean -): string { - return cause === 'quit' && remoteHost ? remoteQuit(machine) : STOPPED[cause](machine) -} - -/** Base wording on host continuation support so the sentence stays stable as the button changes. */ -export function nativeChatOrcaStopRowText( - cause: AgentSessionOrcaStopCause, - machine: string, - options: { continueAvailable: boolean; remoteHost: boolean } -): string { - const stopped = stoppedSentence(cause, machine, options.remoteHost) - return options.continueAvailable - ? stopped - : joinSentences([ - stopped, - translate( - 'components.native-chat.notices.orcaStopCanContinue', - 'You can continue in this conversation.' - ) - ]) -} diff --git a/src/renderer/src/components/native-chat/native-chat-preassembled-session-parity.test.ts b/src/renderer/src/components/native-chat/native-chat-preassembled-session-parity.test.ts index 5f43c148591..531474e40ca 100644 --- a/src/renderer/src/components/native-chat/native-chat-preassembled-session-parity.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-preassembled-session-parity.test.ts @@ -7,7 +7,7 @@ import { reset as resetAssembler } from './native-chat-incremental-assembler' import { prepareNativeChatLiveMessages } from './native-chat-live-message-preparation' -import { mergeNativeChatLiveSession } from './native-chat-live-status' +import { mergeNativeChatLiveSession } from '../../../../shared/native-chat/native-chat-live-status' import { assembleNativeChatSession } from './native-chat-session-assembler' const CLAUDE_COMMANDS = new Set( diff --git a/src/renderer/src/components/native-chat/native-chat-runtime-image-send.ts b/src/renderer/src/components/native-chat/native-chat-runtime-image-send.ts index 17a4a3559a7..1567cdf0825 100644 --- a/src/renderer/src/components/native-chat/native-chat-runtime-image-send.ts +++ b/src/renderer/src/components/native-chat/native-chat-runtime-image-send.ts @@ -1,5 +1,6 @@ import { sendNativeChatObservedWrites } from './native-chat-observed-send' -import { agentImagePasteWrites, formatAgentImagePath } from '../../../../shared/agent-image-paste' +import { agentImagePasteWrites } from '../../../../shared/agent-image-paste' +import { agentImagePastePath } from '../../../../shared/agent-image-paste-path' import type { AgentType } from '../../../../shared/agent-status-types' import { sendRuntimePtyInput } from '@/runtime/runtime-terminal-inspection' import { NATIVE_CHAT_SUBMIT_DELAY_MS } from '../../../../shared/native-chat/input/native-chat-answer-stepping' @@ -9,8 +10,6 @@ import { NATIVE_CHAT_SUBMIT } from '../../../../shared/native-chat-paste-bytes' import { enqueueNativeChatPtySend } from './native-chat-pty-send-queue' -import { formatImageDropPasteText } from '../terminal-pane/terminal-drop-image-path' -import { isTerminalDropWindowsPathLike } from '../terminal-pane/terminal-drop-shell' import { clearConfirmDurationMs, clearThenWrite, @@ -21,16 +20,6 @@ import { sendNativeChatMessage, type NativeChatSendHandle } from './native-chat- export const NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS = 300 -/** Escape image paths as terminal drops do; agent TUIs unescape before checking file existence. */ -export function agentImagePastePath(agent: AgentType, path: string): string { - const formatted = formatAgentImagePath(agent, path) - if (formatted !== path) { - return formatted - } - const targetShell = isTerminalDropWindowsPathLike(path) ? 'windows' : 'posix' - return formatImageDropPasteText(path, targetShell) ?? path -} - export function sendNativeChatMessageWithImageAttachments( agent: AgentType, ptyId: string, diff --git a/src/renderer/src/components/native-chat/native-chat-transcript-tail-row.ts b/src/renderer/src/components/native-chat/native-chat-transcript-tail-row.ts index 38edc45df96..04d8c7cbe02 100644 --- a/src/renderer/src/components/native-chat/native-chat-transcript-tail-row.ts +++ b/src/renderer/src/components/native-chat/native-chat-transcript-tail-row.ts @@ -1,4 +1,4 @@ -import type { NativeChatAwaitingInput } from './NativeChatMessageList' +import type { NativeChatAwaitingInput } from '../../../../shared/native-chat/native-chat-terminal-turn' export function transcriptTailRow( isWorking: boolean, diff --git a/src/renderer/src/components/native-chat/native-chat-view-state.test.ts b/src/renderer/src/components/native-chat/native-chat-view-state.test.ts index 2d25b4383fd..cc21b182472 100644 --- a/src/renderer/src/components/native-chat/native-chat-view-state.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-view-state.test.ts @@ -1,6 +1,8 @@ import { describe, it, expect } from 'vitest' import type { NativeChatMessage, NativeChatSession } from '../../../../shared/native-chat-types' import { selectNativeChatViewState, structuredChatHistoryPhase } from './native-chat-view-state' +import { mergeNativeChatLiveSession } from '../../../../shared/native-chat/native-chat-live-status' +import { shouldShowNativeChatWorking } from '../../../../shared/native-chat/native-chat-working-suppression' const message: NativeChatMessage = { id: 'a', @@ -21,6 +23,28 @@ function session(overrides: Partial): NativeChatSession { } describe('selectNativeChatViewState', () => { + // Session ids can arrive before transcript flush; live work must still retain Stop and its activity indicator. + it('keeps the Stop affordance for a working known session mid-flush', () => { + const live = mergeNativeChatLiveSession({ + messages: [{ ...message, id: 'u-1', role: 'user' }], + sessionId: 'sess', + agent: 'claude', + hookState: 'working', + loading: true + }) + const viewState = selectNativeChatViewState(live) + const isConversation = viewState.kind === 'ready' + + expect(viewState).toEqual({ kind: 'ready', isWorking: true }) + expect( + shouldShowNativeChatWorking({ + isConversation, + working: live.status === 'working', + interrupted: false + }) + ).toBe(true) + }) + it('keeps rendering messages while the session reports loading', () => { expect(selectNativeChatViewState(session({ status: 'loading' }))).toEqual({ kind: 'ready', diff --git a/src/renderer/src/components/native-chat/queued-message-edit-notices.ts b/src/renderer/src/components/native-chat/queued-message-edit-notices.ts index 8ab1d90e54d..cf3ae5b5ee8 100644 --- a/src/renderer/src/components/native-chat/queued-message-edit-notices.ts +++ b/src/renderer/src/components/native-chat/queued-message-edit-notices.ts @@ -1,52 +1,25 @@ import { translate } from '@/i18n/i18n' +import { + QUEUED_EDIT_NOTICE_COPY as COPY, + type QueuedEditNotice +} from '../../../../shared/queued-message-edit' -/** A question or approval holds the chat box's place, so the text there is out of sight until then. */ -export function queuedEditNotice( - key: - | 'editChanged' - | 'editChangedPrompt' - | 'editChangedNotSaved' - | 'editFailed' - | 'editGone' - | 'editGonePrompt' -): string { - switch (key) { - case 'editChanged': - return translate( - 'components.native-chat.queuedMessages.editChanged', - 'This message changed while you were editing. Your edit is in the chat box.' - ) - case 'editChangedPrompt': - return translate( - 'components.native-chat.queuedMessages.editChangedPrompt', - 'This message changed while you were editing. Your edit will be in the chat box after you answer the agent.' - ) - case 'editChangedNotSaved': - return translate( - 'components.native-chat.queuedMessages.editChangedNotSaved', - "This message changed while you were editing. Your edit wasn't saved." - ) - case 'editGone': - return translate( - 'components.native-chat.queuedMessages.editGone', - 'This message was already sent or removed. Your edit is in the chat box.' - ) - case 'editGonePrompt': - return translate( - 'components.native-chat.queuedMessages.editGonePrompt', - 'This message was already sent or removed. Your edit will be in the chat box after you answer the agent.' - ) - case 'editFailed': - return translate( - 'components.native-chat.queuedMessages.editFailed', - "This message can't be edited." - ) - } +const NOTICES: Record string> = { + editChanged: () => + translate('components.native-chat.queuedMessages.editChanged', COPY.editChanged), + editChangedPrompt: () => + translate('components.native-chat.queuedMessages.editChangedPrompt', COPY.editChangedPrompt), + editChangedNotSaved: () => + translate( + 'components.native-chat.queuedMessages.editChangedNotSaved', + COPY.editChangedNotSaved + ), + editGone: () => translate('components.native-chat.queuedMessages.editGone', COPY.editGone), + editGonePrompt: () => + translate('components.native-chat.queuedMessages.editGonePrompt', COPY.editGonePrompt), + editFailed: () => translate('components.native-chat.queuedMessages.editFailed', COPY.editFailed) } -export function queuedEditChangedNotice(handedOff: boolean, promptOpen: boolean): string { - if (!handedOff) { - return queuedEditNotice('editChangedNotSaved') - } - return queuedEditNotice(promptOpen ? 'editChangedPrompt' : 'editChanged') +export function queuedEditNotice(key: QueuedEditNotice): string { + return NOTICES[key]() } diff --git a/src/renderer/src/components/native-chat/structured-agent-session-queued-edit-images.ts b/src/renderer/src/components/native-chat/structured-agent-session-queued-edit-images.ts index 6235eae51c1..d3fcaa1f045 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-queued-edit-images.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-queued-edit-images.ts @@ -2,7 +2,7 @@ import type { AgentJournalMessageItem } from '../../../../shared/agent-session-journal-types' import { - queuedEditBaseText, + queuedEditHandOffTyping, queuedEditImagesPending, queuedEditKeptImages, queuedEditTextForKeptImages, @@ -14,7 +14,6 @@ import type { NativeChatComposerImageAttachment } from './NativeChatComposerFiel import { appendNativeChatDraftCache } from './native-chat-draft-cache' import { isNativeChatImageAttachmentPath } from './native-chat-image-paste' import { imageReferenceInsertion } from '../../../../shared/native-chat-image-reference' -import { stripSentImagePromptMarkers } from '../../../../shared/native-chat-image-transcript-markers' export type QueuedEditImageState = { /** The chat box this edit hands off to; the one it started in, never another. */ @@ -93,8 +92,7 @@ export function releaseQueuedEditImages(edit: QueuedEditImageState): void { /** Hand back unsaved text and settled additions atomically, excluding card-owned image markers; discard pending uploads with their edit, as mobile does. */ export function handOffQueuedEdit(edit: QueuedEditImageState): boolean { - const unchanged = edit.text === queuedEditBaseText(edit.body, edit.kept) - const typing = unchanged ? '' : stripSentImagePromptMarkers(edit.text, edit.kept.length) + const typing = queuedEditHandOffTyping(edit.body, edit) const images = edit.added.flatMap((image) => (image.pending ? [] : [settled(image)])) const text = typing + diff --git a/src/renderer/src/components/native-chat/structured-agent-session-queued-edit-standing.ts b/src/renderer/src/components/native-chat/structured-agent-session-queued-edit-standing.ts deleted file mode 100644 index fd78e4922f2..00000000000 --- a/src/renderer/src/components/native-chat/structured-agent-session-queued-edit-standing.ts +++ /dev/null @@ -1,40 +0,0 @@ -// Compare published queue state with the desired edit body, including Saves whose reply was lost. - -import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' -import type { AgentSessionQueuedMessage } from '../../../../shared/agent-session-wire' -import { agentSessionSendBodyFingerprint } from '../../../../shared/structured-agent-session-send-mutation' -import type { QueuedMessageCard } from './structured-agent-session-queued-cards' - -/** Sent means handed off with this body; holds/differs describe the current card, gone its removal; null queue proves nothing. */ -export type QueuedEditStanding = 'sent' | 'gone' | 'holds' | 'differs' - -export function queuedEditStanding( - view: { - messages: readonly AgentSessionQueuedMessage[] | null - cards: readonly QueuedMessageCard[] - submissions: readonly AgentJournalSubmission[] - }, - sessionId: string, - messageId: string, - /** The fingerprint of the body the edit would save; null when it saves none. */ - desired: string | null -): QueuedEditStanding | null { - if (view.messages === null) { - return null - } - const listed = view.messages.find((entry) => entry.messageId === messageId) - if (!listed || !queuedCardOnScreen(view.cards, messageId)) { - const sent = view.submissions.some( - (entry) => entry.queuedMessageId === messageId && entry.payloadFingerprint === desired - ) - return desired !== null && sent ? 'sent' : 'gone' - } - return agentSessionSendBodyFingerprint(sessionId, listed.body) === desired ? 'holds' : 'differs' -} - -export function queuedCardOnScreen( - cards: readonly QueuedMessageCard[], - messageId: string -): boolean { - return cards.some((card) => card.messageId === messageId) -} diff --git a/src/renderer/src/components/native-chat/structured-agent-session-sending-queued-cards.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-sending-queued-cards.test.ts new file mode 100644 index 00000000000..3cbc0c14a7d --- /dev/null +++ b/src/renderer/src/components/native-chat/structured-agent-session-sending-queued-cards.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from 'vitest' +import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' +import type { StructuredAgentSessionPendingSend } from './structured-agent-session-pending-sends' +import { + pendingQueueSendsOnTheirWay, + pendingSendsOutsideQueuedCards +} from './structured-agent-session-sending-queued-cards' + +function submission(clientMessageId: string, queuedMessageId?: string): AgentJournalSubmission { + return { + clientMessageId, + fence: 1, + payloadFingerprint: 'fingerprint', + dispatchState: 'pending', + providerItemId: null, + reason: null, + submittedAt: 1, + resolvedAt: null, + ...(queuedMessageId !== undefined ? { queuedMessageId } : {}) + } +} + +describe('queue sends on their way', () => { + it('a mid-turn queue send on its way is no bubble; a plain or recorded one is, until its row', () => { + const entry = ( + clientMessageId: string, + overrides: Partial = {} + ): StructuredAgentSessionPendingSend => ({ + clientMessageId, + sessionId: 'session-1', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: clientMessageId }] }, + previewUris: [], + queuedAt: 1, + phase: 'sending', + issued: true, + ...overrides + }) + const ids = (entries: readonly StructuredAgentSessionPendingSend[]): string[] => + entries.map((candidate) => candidate.clientMessageId) + const sends = [ + entry('queued', { delivery: 'queue-if-active' }), + entry('plain'), + entry('recorded', { phase: 'recorded' }) + ] + // Pending queued sends use cards while working; plain/recorded sends remain bubbles until host ownership appears. + expect(ids(pendingSendsOutsideQueuedCards(sends, [], true))).toEqual(['plain', 'recorded']) + expect(ids(pendingSendsOutsideQueuedCards(sends, [], false))).toEqual([ + 'queued', + 'plain', + 'recorded' + ]) + expect(ids(pendingSendsOutsideQueuedCards(sends, ['plain'], false))).toEqual([ + 'queued', + 'recorded' + ]) + }) + it('a sending card ends once the host records the send or hands its card off', () => { + const entry: StructuredAgentSessionPendingSend = { + clientMessageId: 'a', + sessionId: 'session-1', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'a' }] }, + previewUris: [], + queuedAt: 1, + phase: 'sending', + issued: true, + delivery: 'queue-if-active' + } + const onItsWay = (submissions: AgentJournalSubmission[]) => + pendingQueueSendsOnTheirWay([entry], [], true, submissions).map( + (candidate) => candidate.clientMessageId + ) + expect(onItsWay([])).toEqual(['a']) + expect(onItsWay([submission('a')])).toEqual([]) + expect(onItsWay([submission('a-hand-off', 'a')])).toEqual([]) + expect(pendingQueueSendsOnTheirWay([entry], ['a'], true, [])).toEqual([]) + expect(pendingQueueSendsOnTheirWay([entry], [], false, [])).toEqual([]) + expect(pendingQueueSendsOnTheirWay([{ ...entry, phase: 'recorded' }], [], true, [])).toEqual([]) + }) +}) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-sending-queued-cards.ts b/src/renderer/src/components/native-chat/structured-agent-session-sending-queued-cards.ts new file mode 100644 index 00000000000..f54973c9d57 --- /dev/null +++ b/src/renderer/src/components/native-chat/structured-agent-session-sending-queued-cards.ts @@ -0,0 +1,56 @@ +import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' +import { handedOffQueuedMessageIds } from '../../../../shared/structured-agent-session-draft-hand-off' +import { + queuedMessageCardImages, + queuedMessageCardText, + type QueuedMessageCard +} from '../../../../shared/structured-agent-session-queued-cards' +import type { StructuredAgentSessionPendingSend } from './structured-agent-session-pending-sends' + +export function sendingQueuedMessageCards( + entries: readonly StructuredAgentSessionPendingSend[] +): QueuedMessageCard[] { + return entries.map((entry, index) => ({ + messageId: entry.clientMessageId, + // After every card the host holds, in send order. + position: Number.MAX_SAFE_INTEGER - entries.length + index, + text: queuedMessageCardText(entry.body), + ...queuedMessageCardImages(entry.body), + state: 'waiting', + hold: 'sending' + })) +} + +/** Host-owned cards and active queue requests stay out of transcript echoes; recorded sends remain until their row arrives. */ +export function pendingSendsOutsideQueuedCards< + Send extends { clientMessageId: string; delivery?: 'queue-if-active' } +>(pending: readonly Send[], heldIds: readonly string[], isWorking: boolean): readonly Send[] { + const held = new Set(heldIds) + const next = pending.filter( + (entry) => + !held.has(entry.clientMessageId) && !(isWorking && entry.delivery === 'queue-if-active') + ) + return next.length === pending.length ? pending : next +} + +export function pendingQueueSendsOnTheirWay( + pending: readonly StructuredAgentSessionPendingSend[], + heldIds: readonly string[], + isWorking: boolean, + submissions: readonly AgentJournalSubmission[] +): StructuredAgentSessionPendingSend[] { + if (!isWorking) { + return [] + } + const recorded = new Set([ + ...heldIds, + ...handedOffQueuedMessageIds(submissions), + ...submissions.map((submission) => submission.clientMessageId) + ]) + return pending.filter( + (entry) => + entry.phase === 'sending' && + entry.delivery === 'queue-if-active' && + !recorded.has(entry.clientMessageId) + ) +} diff --git a/src/renderer/src/components/native-chat/structured-conversation-command-send.test.ts b/src/renderer/src/components/native-chat/structured-conversation-command-send.test.ts index 29d04b53d08..b3dfc5becef 100644 --- a/src/renderer/src/components/native-chat/structured-conversation-command-send.test.ts +++ b/src/renderer/src/components/native-chat/structured-conversation-command-send.test.ts @@ -7,7 +7,6 @@ import { TUI_AGENT_DISPLAY_NAMES } from '../../../../shared/tui-agent-display-na import { structuredAgentLabel } from '@/lib/structured-agent-session-launch-label' import { sendStructuredConversationCommand, - structuredConversationCommandHold, type StructuredConversationCommandCauses } from './structured-conversation-command-send' @@ -197,39 +196,6 @@ describe('a /compact the host holds in line', () => { }) ).toEqual({ accepted: true, error: null }) }) - - const idle = { - waitsInLine: false, - agentWorking: false, - promptPending: false, - backgroundTasksRunning: false, - sendPending: false - } - - it('is held here only by a message the host does not have yet, or background work', () => { - const inLine = { ...idle, waitsInLine: true } - expect( - structuredConversationCommandHold({ - ...inLine, - agentWorking: true, - promptPending: true - }) - ).toBeNull() - expect(structuredConversationCommandHold({ ...inLine, sendPending: true })).toBe('ahead') - expect(structuredConversationCommandHold({ ...inLine, backgroundTasksRunning: true })).toBe( - 'background' - ) - }) - - it('against a host that cannot hold it, and for /clear, keeps every check in true words', () => { - expect(structuredConversationCommandHold(idle)).toBeNull() - expect(structuredConversationCommandHold({ ...idle, agentWorking: true })).toBe('working') - expect(structuredConversationCommandHold({ ...idle, sendPending: true })).toBe('sending') - expect( - structuredConversationCommandHold({ ...idle, agentWorking: true, sendPending: true }) - ).toBe('working') - expect(structuredConversationCommandHold({ ...idle, promptPending: true })).toBe('prompt') - }) }) function held( diff --git a/src/renderer/src/components/native-chat/structured-conversation-command-send.ts b/src/renderer/src/components/native-chat/structured-conversation-command-send.ts index 66142808aba..546d1de3536 100644 --- a/src/renderer/src/components/native-chat/structured-conversation-command-send.ts +++ b/src/renderer/src/components/native-chat/structured-conversation-command-send.ts @@ -1,20 +1,11 @@ -import { - isAgentSessionConversationCommand, - type AgentSessionConversationCommand, - type AgentSessionConversationCommandResult +import type { + AgentSessionConversationCommand, + AgentSessionConversationCommandResult } from '../../../../shared/agent-session/agent-session-conversation-command' -import { - readWholeAgentSessionFailureFact, - type AgentSessionFailureFact -} from '../../../../shared/agent-session-failure' -import { agentSessionFailureSentence } from '../../../../shared/agent-session-failure-words' -import { translate } from '@/i18n/i18n' +import type { AgentSessionFailureFact } from '../../../../shared/agent-session-failure' import { sayAgentSessionFailureTranslated } from './agent-session-failure-words-text' import { agentSessionWriteNoticeText } from './agent-session-write-notice-text' -import { - agentSessionFailureStatedByStartRow, - structuredAgentSessionStartFailureFacts -} from './structured-agent-session-delivery-notices' +import { structuredAgentSessionStartFailureFacts } from './structured-agent-session-delivery-notices' import { pendingPromptsAllUnanswerableHere } from '../../../../shared/agent-session-approval-subject' import type { StructuredPromptItem } from '../../../../shared/structured-agent-session-message-projection' import type { @@ -22,79 +13,24 @@ import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' import type { StructuredAgentSessionPendingSend } from './structured-agent-session-pending-sends' -import { structuredAgentSessionCommandHostRefusalCause } from '../../../../shared/structured-agent-session-command-refusal-cause' import type { StructuredAgentSessionCommandOutcome, StructuredAgentSessionCommandRefusalCause } from '../../../../shared/structured-agent-session-composer' +import { + conversationCommandReply, + heldConversationCommandParts, + structuredConversationCommandHold, + type StructuredConversationCommandHold +} from '../../../../shared/structured-conversation-command-hold' import type { StructuredAgentSessionWriteOutcome } from './use-structured-agent-session-mutate' -/** Ahead leaves command text untouched behind an unanswered local send; other holds return an explicit refusal. */ -export type StructuredConversationCommandHold = - | 'ahead' - | 'working' - | 'prompt' - | 'background' - | 'sending' - -/** Queued commands wait only for earlier unaccepted sends; ordinary commands wait for all in-flight agent work. */ -export function structuredConversationCommandHold(input: { - /** A /compact the host can hold as a card behind the turn or prompt. */ - waitsInLine: boolean - /** A turn runs, or the chat shows the agent working on a message it has not answered. */ - agentWorking: boolean - promptPending: boolean - backgroundTasksRunning: boolean - sendPending: boolean -}): StructuredConversationCommandHold | null { - if (input.backgroundTasksRunning) { - return 'background' - } - if (input.waitsInLine) { - return input.sendPending ? 'ahead' : null - } - if (input.promptPending) { - return 'prompt' - } - if (input.agentWorking) { - return 'working' - } - if (input.sendPending) { - return 'sending' - } - return null -} - type CommandOutcome = Omit export type StructuredConversationCommandCauses = Readonly< Record > -function heldCommandText( - command: AgentSessionConversationCommand, - hold: Exclude -): string { - const clear = command === 'clear' - switch (hold) { - case 'prompt': - return agentSessionWriteNoticeText([clear ? 'clearAfterAnswer' : 'compactAfterAnswer']) - case 'working': - return agentSessionWriteNoticeText([ - 'agentStillWorking', - clear ? 'runClearWhenDone' : 'runCompactWhenDone' - ]) - case 'sending': - return agentSessionWriteNoticeText([clear ? 'clearAfterSending' : 'compactAfterSending']) - case 'background': - break - } - return translate( - 'components.native-chat.conversationCommand.pendingWork', - 'Wait for pending work and messages to finish before using this command.' - ) -} - export async function sendStructuredConversationCommand(input: { command: AgentSessionConversationCommand agentName: string @@ -118,14 +54,20 @@ export async function sendStructuredConversationCommand(input: { }) // A command on its way is the agent's work in flight. if (input.pending.current) { - return { accepted: false, error: heldCommandText(input.command, 'working') } + return { + accepted: false, + error: agentSessionWriteNoticeText(heldConversationCommandParts(input.command, 'working')) + } } // The message ahead reads as sending, and Send is busy until the host has it; nothing is armed. if (input.hold === 'ahead') { return { accepted: false, error: null } } if (input.hold !== null) { - return refused(heldCommandText(input.command, input.hold), input.hold) + return refused( + agentSessionWriteNoticeText(heldConversationCommandParts(input.command, input.hold)), + input.hold + ) } input.pending.current = true try { @@ -137,20 +79,12 @@ export async function sendStructuredConversationCommand(input: { if (outcome.kind === 'dropped') { return { accepted: false, error: null } } - const { value } = outcome - // Suppress start failures already shown in this chat; clear/new-command failures may belong elsewhere and need host wording. - if ( - isAgentSessionConversationCommand(value.command) && - value.command !== 'clear' && - agentSessionFailureStatedByStartRow(value.failure, input.startFailures()) - ) { - return { accepted: false, error: null } - } - const error = conversationCommandFailureText(value, input.agentName) - if (value.state === 'completed' && !error) { - return { accepted: true, error: null } - } - return refused(error, structuredAgentSessionCommandHostRefusalCause(value)) + const reply = conversationCommandReply(outcome.value, { + agentName: input.agentName, + startFailures: input.startFailures(), + say: sayAgentSessionFailureTranslated + }) + return reply.accepted ? { accepted: true, error: null } : refused(reply.error, reply.cause) } finally { input.pending.current = false } @@ -223,22 +157,3 @@ export function structuredConversationCommandRunner(args: { } return { runConversationCommand: run, commandRefusalCauses: causes } } - -/** Translate complete known facts/commands; preserve host text for older/newer payloads this client cannot interpret. */ -function conversationCommandFailureText( - result: AgentSessionConversationCommandResult, - agentName: string -): string | null { - const fact = isAgentSessionConversationCommand(result.command) - ? readWholeAgentSessionFailureFact(result.failure) - : undefined - if (!fact) { - return result.error ?? null - } - return agentSessionFailureSentence( - fact, - 'row', - { agentName, command: result.command }, - sayAgentSessionFailureTranslated - ) -} diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-catalog.ts b/src/renderer/src/components/native-chat/use-native-chat-composer-catalog.ts index 019e0628008..02cd36af679 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-catalog.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-catalog.ts @@ -1,14 +1,11 @@ import { useMemo } from 'react' import type { AgentType } from '../../../../shared/agent-status-types' -import { - getNativeChatAgentProfile, - getVerifiedNativeChatCommands -} from '../../../../shared/native-chat-agent-profiles' -import { - sessionReportedSkills, - type NativeChatSessionSkill, - type SlashCommandSuggestion +import { getVerifiedNativeChatCommands } from '../../../../shared/native-chat-agent-profiles' +import type { + NativeChatSessionSkill, + SlashCommandSuggestion } from '../../../../shared/native-chat/input/native-chat-slash-commands' +import { nativeChatSessionSkills } from '../../../../shared/native-chat-skill-visibility' import { structuredSessionSlashCommands } from '../../../../shared/structured-agent-session-composer' import { withNativeChatResumeCommand } from './use-native-chat-resume-command' import type { NativeChatStructuredComposerTransport } from './native-chat-composer-types' @@ -26,7 +23,6 @@ export function useNativeChatComposerCatalog( const structured = Boolean(structuredTransport) const reported = structuredTransport?.sessionCommands const conversationCommands = structuredTransport?.conversationCommands - const keepsScannedSkills = getNativeChatAgentProfile(agent)?.keepsScannedSkills === true const agentCommands = useMemo( () => structured @@ -36,10 +32,6 @@ export function useNativeChatComposerCatalog( : getVerifiedNativeChatCommands(agent), [agent, conversationCommands, reported, structured] ) - const sessionSkills = useMemo( - () => - reported !== undefined && !keepsScannedSkills ? sessionReportedSkills(reported) : undefined, - [keepsScannedSkills, reported] - ) + const sessionSkills = useMemo(() => nativeChatSessionSkills(agent, reported), [agent, reported]) return { agentCommands, sessionSkills } } diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-keydown.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-composer-keydown.test.tsx index 25bd2195b82..53920e9935f 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-keydown.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-keydown.test.tsx @@ -3,8 +3,11 @@ import { cleanup, fireEvent, render, renderHook, screen } from '@testing-library/react' import { useState, type KeyboardEventHandler } from 'react' import { describe, expect, it, vi } from 'vitest' -import { applyPickerSuggestion } from '../../../../shared/native-chat/input/native-chat-picker-triggers' -import { deriveComposerAutocomplete, type ComposerAutocomplete } from './native-chat-composer-state' +import { + applyPickerSuggestion, + deriveComposerAutocomplete, + type ComposerAutocomplete +} from '../../../../shared/native-chat-composer-autocomplete' import { getNativeChatAgentProfile } from '../../../../shared/native-chat-agent-profiles' import { useNativeChatRecallPosition } from './use-native-chat-composer-recall' import type { NativeChatMessage } from '../../../../shared/native-chat-types' diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-keydown.ts b/src/renderer/src/components/native-chat/use-native-chat-composer-keydown.ts index 89bc3462b3b..d6e54dade27 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-keydown.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-keydown.ts @@ -5,8 +5,10 @@ import { type KeyboardEventHandler, type SetStateAction } from 'react' -import type { ComposerAutocomplete } from './native-chat-composer-state' -import type { NativeChatPickerItem } from '../../../../shared/native-chat/input/native-chat-picker-items' +import type { + ComposerAutocomplete, + NativeChatPickerItem +} from '../../../../shared/native-chat-composer-autocomplete' import { isMacPlatform } from './native-chat-shortcut' import type { NativeChatMentionFiles } from './use-native-chat-mention-files' import { diff --git a/src/renderer/src/components/native-chat/use-native-chat-hook-status.ts b/src/renderer/src/components/native-chat/use-native-chat-hook-status.ts index d973c667231..7ccd49b9afd 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-hook-status.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-hook-status.ts @@ -1,23 +1,9 @@ import { useAppStore } from '../../store' -import { isExplicitAgentStatusFresh } from '@/lib/agent-status' +import type { AgentStatusState } from '../../../../shared/agent-status-types' import { - AGENT_STATUS_STALE_AFTER_MS, - type AgentStatusEntry, - type AgentStatusState -} from '../../../../shared/agent-status-types' - -/** Restored nonterminal rows need a live hook confirmation before they may keep chat busy. */ -export function resolveNativeChatHookState( - entry: - | Pick - | undefined, - now = Date.now() -): AgentStatusState | null { - if (!entry || !isExplicitAgentStatusFresh(entry, now, AGENT_STATUS_STALE_AFTER_MS)) { - return null - } - return entry.state === 'working' && entry.workingMode === 'monitoring' ? null : entry.state -} + nativeChatHookHasWorkingSubagents, + resolveNativeChatHookState +} from '../../../../shared/native-chat/native-chat-live-status' export function useNativeChatHookStatus( paneKey: string @@ -26,23 +12,14 @@ export function useNativeChatHookStatus( const agentStatusEpoch = useAppStore((store) => store.agentStatusEpoch) void agentStatusEpoch // Primitive selectors avoid rerenders for unrelated status changes. - const state = useAppStore((store) => { - const entry = store.agentStatusByPaneKey[paneKey] - return resolveNativeChatHookState(entry) - }) + const state = useAppStore((store) => + resolveNativeChatHookState(store.agentStatusByPaneKey[paneKey]) + ) const stateStartedAt = useAppStore( (store) => store.agentStatusByPaneKey[paneKey]?.stateStartedAt ?? null ) - // Only children started in this parent epoch may delay settlement; missed stops and reused panes leave stale roster entries. - const hasWorkingSubagents = useAppStore((store) => { - const entry = store.agentStatusByPaneKey[paneKey] - const epochStart = entry?.stateStartedAt - return ( - entry?.subagents?.some( - (subagent) => - subagent.state === 'working' && (epochStart == null || subagent.startedAt >= epochStart) - ) ?? false - ) - }) + const hasWorkingSubagents = useAppStore((store) => + nativeChatHookHasWorkingSubagents(store.agentStatusByPaneKey[paneKey]) + ) return [state, stateStartedAt, hasWorkingSubagents] } diff --git a/src/renderer/src/components/native-chat/use-native-chat-interactive-send.ts b/src/renderer/src/components/native-chat/use-native-chat-interactive-send.ts index 2a286d51b8f..843091d67bc 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-interactive-send.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-interactive-send.ts @@ -5,6 +5,7 @@ import { sendRuntimePtyInputVerified } from '@/runtime/runtime-terminal-verified import type { AgentType } from '../../../../shared/native-chat-types' import { nativeChatAskRequiresTerminal, + nativeChatStopSendsSecondEscape, resolveNativeChatTranscriptAgent, shouldStepNativeChatAskAnswer } from '../../../../shared/native-chat-agent-support' @@ -151,8 +152,8 @@ export function useNativeChatInteractiveSend( const cancel = useCallback(() => { cancelInFlight() - if (resolveNativeChatTranscriptAgent(agent) === 'opencode' && targetPtyId) { - // OpenCode confirms interruption with a second Escape; pace writes like mobile Stop. + if (nativeChatStopSendsSecondEscape(agent) && targetPtyId) { + // Pace the second Escape like an answer step so the TUI renders its confirm first. inFlightRef.current = sendNativeChatAskAnswer(targetPtyId, [{ raw: ESC }, { raw: ESC }]) return } diff --git a/src/renderer/src/components/native-chat/use-native-chat-launch-draft-adoption.ts b/src/renderer/src/components/native-chat/use-native-chat-launch-draft-adoption.ts index 2aafa1c897d..c583a75048b 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-launch-draft-adoption.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-launch-draft-adoption.ts @@ -6,7 +6,7 @@ import { launchDraftResolvedByTranscript, nativeChatLaunchDraftTurnBaseline, type NativeChatLaunchDraftTurnBaseline -} from './native-chat-launch-draft-resolution' +} from '../../../../shared/native-chat-launch-draft-resolution' export function useNativeChatLaunchDraftSignal(args: { terminalTabId: string diff --git a/src/renderer/src/components/native-chat/use-native-chat-live-session.ts b/src/renderer/src/components/native-chat/use-native-chat-live-session.ts index 4df2e2082bd..f986f4a19e7 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-live-session.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-live-session.ts @@ -9,7 +9,7 @@ import { createNativeChatMerger, replaceList } from '../../../../shared/native-chat-merge' -import { mergeNativeChatLiveSession, nativeChatHookAwaitsInput } from './native-chat-live-status' +import { mergeNativeChatLiveSession } from '../../../../shared/native-chat/native-chat-live-status' import { hasMoreNativeChatHistory, NATIVE_CHAT_INITIAL_LIMIT, @@ -316,11 +316,6 @@ export function useNativeChatLiveSession( }) return { ...session, - hookAwaitingInput: nativeChatHookAwaitsInput( - hookState, - hookStateStartedAt, - transcriptLifecycle - ), hasMore, loadingEarlier, olderHistoryGeneration, diff --git a/src/renderer/src/components/native-chat/use-native-chat-picker-command-dispatch.ts b/src/renderer/src/components/native-chat/use-native-chat-picker-command-dispatch.ts index e1ef9cae9c0..0e492b320a5 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-picker-command-dispatch.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-picker-command-dispatch.ts @@ -10,7 +10,7 @@ import { nativeChatComposerTargetIsRemote, type NativeChatResolvedTarget } from './native-chat-composer-target' -import type { NativeChatPickerItem } from '../../../../shared/native-chat/input/native-chat-picker-items' +import type { NativeChatPickerItem } from '../../../../shared/native-chat-composer-autocomplete' import type { NativeChatSendLifecycle } from './use-native-chat-send-lifecycle' import type { NativeChatPtySessionOptionsSurface } from '../../../../shared/native-chat-pty-session-options' import { diff --git a/src/renderer/src/components/native-chat/use-native-chat-picker-state.ts b/src/renderer/src/components/native-chat/use-native-chat-picker-state.ts index 5ee339ca4e3..1ce60ec5e8e 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-picker-state.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-picker-state.ts @@ -16,25 +16,22 @@ import { } from 'react' import type { AgentType } from '../../../../shared/agent-status-types' import { getNativeChatAgentProfile } from '../../../../shared/native-chat-agent-profiles' -import { - classifyNativeChatSend, - type NativeChatSendClassification, - type NativeChatSessionSkill, - type SlashCommandSuggestion +import type { + NativeChatSessionSkill, + SlashCommandSuggestion } from '../../../../shared/native-chat/input/native-chat-slash-commands' -import { isStructuredAgentSessionComposerCommand } from '../../../../shared/structured-agent-session-composer' import { applyMentionSuggestion, + applyPickerSuggestion, + classifyNativeChatSend, deriveComposerAutocomplete, editReplacesTriggerToken, isSkillPickerTriggered, - type ComposerAutocomplete -} from './native-chat-composer-state' -import { applyPickerSuggestion } from '../../../../shared/native-chat/input/native-chat-picker-triggers' -import { - classifyPickerCommands, - type NativeChatPickerItem -} from '../../../../shared/native-chat/input/native-chat-picker-items' + type ComposerAutocomplete, + type NativeChatPickerItem, + type NativeChatSendClassification +} from '../../../../shared/native-chat-composer-autocomplete' +import { nativeChatPickerCommands } from '../../../../shared/native-chat-skill-visibility' import { useNativeChatSkills } from './use-native-chat-skills' import { emitNativeChatPickerItemAccepted, @@ -101,13 +98,8 @@ export function useNativeChatPickerState(args: { const skillPickerTriggered = !disabled && isSkillPickerTriggered(draft.slice(0, caret), profile) const discovery = useNativeChatSkills(agent, terminalTabId, skillPickerTriggered) const pickerCommands = useMemo( - () => - classifyPickerCommands(agentCommands, discovery.skills, { - // A chat that keeps scanned skills (OpenCode) may report each as a command too. - reportMayListSkills: profile?.keepsScannedSkills === true, - isSendCommand: (name) => isStructuredAgentSessionComposerCommand(`/${name}`, agent) - }), - [agent, agentCommands, discovery.skills, profile] + () => nativeChatPickerCommands(agent, agentCommands, discovery.skills), + [agent, agentCommands, discovery.skills] ) const listboxId = `native-chat-picker-${useId().replaceAll(':', '')}` const dismissalContext = `${draftScopeKey}:${agent}` diff --git a/src/renderer/src/components/native-chat/use-native-chat-resume-command.ts b/src/renderer/src/components/native-chat/use-native-chat-resume-command.ts index 0bde98a33c7..31551e877ef 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-resume-command.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-resume-command.ts @@ -6,7 +6,7 @@ import type { AgentType } from '../../../../shared/agent-status-types' import { isAiVaultAgent } from '../../../../shared/ai-vault-types' import { selectAiVaultPanelWorkspace } from '../right-sidebar/ai-vault-panel-workspace' import { openAgentSessionHistoryModal } from '../right-sidebar/agent-session-history-modal' -import type { NativeChatPickerItem } from '../../../../shared/native-chat/input/native-chat-picker-items' +import type { NativeChatPickerItem } from '../../../../shared/native-chat-composer-autocomplete' import type { NativeChatPickerState } from './use-native-chat-picker-state' import { findNativeChatTabOwnerWorktreeId } from './native-chat-file-link' import type { NativeChatComposerInput } from './native-chat-composer-input' diff --git a/src/renderer/src/components/native-chat/use-native-chat-skills.react.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-skills.react.test.tsx index f28a7b0a7b7..f473b46eeb3 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-skills.react.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-skills.react.test.tsx @@ -5,7 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { NativeChatSkillDiscovery } from './use-native-chat-skills' import { getNativeChatAgentProfile } from '../../../../shared/native-chat-agent-profiles' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' -import { isSkillPickerTriggered } from './native-chat-composer-state' +import { isSkillPickerTriggered } from '../../../../shared/native-chat-composer-autocomplete' const mocks = vi.hoisted(() => ({ callRuntimeRpc: vi.fn(), diff --git a/src/renderer/src/components/native-chat/use-native-chat-skills.test.ts b/src/renderer/src/components/native-chat/use-native-chat-skills.test.ts index 3a0df082073..1aa2a77e843 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-skills.test.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-skills.test.ts @@ -1,5 +1,4 @@ import { describe, expect, it } from 'vitest' -import type { DiscoveredSkill, SkillDiscoveryResult } from '../../../../shared/skills' import { FLOATING_TERMINAL_WORKTREE_ID, getDefaultSettings } from '../../../../shared/constants' import type { Tab } from '../../../../shared/tab-types' import { @@ -7,120 +6,11 @@ import { type NativeChatSkillStateInputs } from './native-chat-skill-discovery-context' import { - isNativeChatSkillForAgent, resolveNativeChatSkillDiscoveryContext, resolveNativeChatSkillDiscoveryCwd } from './use-native-chat-skills' import { worktreeFixture } from './native-chat-workspace-test-fixtures' -function skill(overrides: Partial): DiscoveredSkill { - return { - id: overrides.name ?? 'skill', - name: 'agent-browser', - description: null, - providers: ['agent-skills'], - sourceKind: 'home', - sourceLabel: 'Agent skills home', - rootPath: '/Users/test/.agents/skills', - directoryPath: '/Users/test/.agents/skills/agent-browser', - skillFilePath: '/Users/test/.agents/skills/agent-browser/SKILL.md', - installed: true, - updatedAt: null, - ...overrides - } -} - -function discovery(owner: string | null, rootPath = '/Users/test/.agents/skills') { - return { - sources: [ - { - id: 'source', - label: 'Source', - path: rootPath, - sourceKind: 'home' as const, - providers: ['agent-skills' as const], - owner, - exists: true - } - ] - } satisfies Pick -} - -describe('isNativeChatSkillForAgent', () => { - it('shows Codex-native and generic agent skills for Codex chat', () => { - expect(isNativeChatSkillForAgent('codex', skill({ providers: ['codex'] }))).toBe(true) - expect(isNativeChatSkillForAgent('codex', skill({ providers: ['agent-skills'] }))).toBe(true) - }) - - it('keeps Claude skills out of the Codex skill picker', () => { - expect(isNativeChatSkillForAgent('codex', skill({ providers: ['claude'] }))).toBe(false) - }) - - it('does not enable skill autocomplete for other agents yet', () => { - expect(isNativeChatSkillForAgent('claude', skill({ providers: ['agent-skills'] }))).toBe(false) - }) - - it('uses explicit source ownership and keeps shared roots visible', () => { - const shared = discovery(null) - expect(isNativeChatSkillForAgent('codex', skill({}), shared)).toBe(true) - expect(isNativeChatSkillForAgent('claude', skill({}), shared)).toBe(true) - expect(isNativeChatSkillForAgent('grok', skill({}), shared)).toBe(true) - }) - - it('aliases OpenClaude to Claude roots without exposing them to other agents', () => { - const claude = discovery('claude') - expect(isNativeChatSkillForAgent('claude', skill({}), claude)).toBe(true) - expect(isNativeChatSkillForAgent('openclaude', skill({}), claude)).toBe(true) - expect(isNativeChatSkillForAgent('codex', skill({}), claude)).toBe(false) - expect(isNativeChatSkillForAgent('grok', skill({}), claude)).toBe(false) - }) - - it('shows OpenCode the Claude skill folders it reads, and not Claude plugins', () => { - const claude = discovery('claude') - expect(isNativeChatSkillForAgent('opencode', skill({}), claude)).toBe(true) - expect(isNativeChatSkillForAgent('opencode2', skill({}), claude)).toBe(true) - const plugin = { sources: [{ ...claude.sources[0], sourceKind: 'plugin' as const }] } - expect(isNativeChatSkillForAgent('opencode2', skill({}), plugin)).toBe(false) - }) - - it('grants visibility through any contributing root, not just the dedup survivor', () => { - const result = { - sources: [ - { - id: 'codex-home', - label: 'Codex home', - path: '/Users/test/.codex/skills', - sourceKind: 'home' as const, - providers: ['codex' as const], - owner: 'codex', - exists: true - }, - { - id: 'shared-home', - label: 'Agent skills home', - path: '/Users/test/.agents/skills', - sourceKind: 'home' as const, - providers: ['agent-skills' as const], - owner: null, - exists: true - } - ] - } satisfies Pick - // Canonical dedup may list the Codex root first; shared-root reachability must still grant other agents visibility. - const merged = skill({ - rootPath: '/Users/test/.codex/skills', - rootPaths: ['/Users/test/.codex/skills', '/Users/test/.agents/skills'] - }) - expect(isNativeChatSkillForAgent('claude', merged, result)).toBe(true) - expect(isNativeChatSkillForAgent('codex', merged, result)).toBe(true) - const codexOnly = skill({ - rootPath: '/Users/test/.codex/skills', - rootPaths: ['/Users/test/.codex/skills'] - }) - expect(isNativeChatSkillForAgent('claude', codexOnly, result)).toBe(false) - }) -}) - describe('resolveNativeChatSkillDiscoveryCwd', () => { const catalog = { floatingWorkspacePath: null, diff --git a/src/renderer/src/components/native-chat/use-native-chat-skills.ts b/src/renderer/src/components/native-chat/use-native-chat-skills.ts index b05e681968d..b5f116b7a27 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-skills.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-skills.ts @@ -3,10 +3,8 @@ import { useShallow } from 'zustand/react/shallow' import { useAppStore } from '../../store' import type { AgentType } from '../../../../shared/agent-status-types' import type { DiscoveredSkill, SkillDiscoveryResult } from '../../../../shared/skills' -import { - getNativeChatAgentProfile, - readsSkillSource -} from '../../../../shared/native-chat-agent-profiles' +import { getNativeChatAgentProfile } from '../../../../shared/native-chat-agent-profiles' +import { isNativeChatSkillForAgent } from '../../../../shared/native-chat-skill-visibility' import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' import { emitNativeChatSkillDiscovery } from '@/lib/native-chat-telemetry' import { @@ -61,29 +59,6 @@ const MISSING_CONTEXT_STATE: StoredDiscoveryState = { const selectNoSkillStateInputs = (): null => null const inFlightDiscovery = new Map>() -export function isNativeChatSkillForAgent( - agent: AgentType, - skill: DiscoveredSkill, - result?: Pick -): boolean { - const profile = getNativeChatAgentProfile(agent) - if (!profile) { - return false - } - if (!result) { - return ( - agent === 'codex' && - (skill.providers.includes('codex') || skill.providers.includes('agent-skills')) - ) - } - // Canonical dedup may hide alternate symlink roots; any shared/agent root grants visibility regardless of scan order. - const rootPaths = skill.rootPaths?.length ? skill.rootPaths : [skill.rootPath] - return rootPaths.some((rootPath) => { - const source = result.sources.find((entry) => entry.path === rootPath) - return source !== undefined && readsSkillSource(profile, source) - }) -} - export function useNativeChatSkills( agent: AgentType, terminalTabId: string, diff --git a/src/renderer/src/components/native-chat/use-native-chat-terminal-turn-timing.ts b/src/renderer/src/components/native-chat/use-native-chat-terminal-turn-timing.ts index 811c41c1141..bccd1957bc5 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-terminal-turn-timing.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-terminal-turn-timing.ts @@ -6,9 +6,10 @@ import { nativeChatHookLatestTurnWorkedSeconds, nativeChatHookTurnStartedAt, nativeChatLatestTurnId, + nativeChatTerminalSettledTurns, nativeChatTranscriptSettledTurns -} from './native-chat-terminal-turn' -import { resolveNativeChatHookState } from './use-native-chat-hook-status' +} from '../../../../shared/native-chat/native-chat-terminal-turn' +import { resolveNativeChatHookState } from '../../../../shared/native-chat/native-chat-live-status' /** Keep host live/latest-turn timing and historical transcript durations across pane remounts. */ export function useNativeChatTerminalTurnTiming( @@ -28,16 +29,16 @@ export function useNativeChatTerminalTurnTiming( }) const transcriptSettled = useMemo(() => nativeChatTranscriptSettledTurns(messages), [messages]) const latestTurnId = useMemo(() => nativeChatLatestTurnId(messages), [messages]) - const settledTurns = useMemo(() => { - if (turnActive || latestWorkedSeconds === undefined || latestTurnId === null) { - return transcriptSettled - } - return new Map(transcriptSettled).set( - latestTurnId, - latestWorkedSeconds === null || turnStartedAt === null - ? null - : { startedAt: turnStartedAt, workedSeconds: latestWorkedSeconds } - ) - }, [latestTurnId, latestWorkedSeconds, transcriptSettled, turnActive, turnStartedAt]) + const settledTurns = useMemo( + () => + nativeChatTerminalSettledTurns({ + transcriptSettled, + latestTurnId, + latestWorkedSeconds, + turnStartedAt, + turnActive + }), + [latestTurnId, latestWorkedSeconds, transcriptSettled, turnActive, turnStartedAt] + ) return { workingStartedAt: turnActive ? turnStartedAt : null, settledTurns } } diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-mutate.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-mutate.ts index 4c1ee7b55a3..7a2ce6c34ca 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-mutate.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-mutate.ts @@ -21,6 +21,10 @@ import { } from '@/runtime/structured-agent-session-client' import { structuredAgentSessionHostKey } from '@/runtime/structured-agent-session-host-capability' import { agentSessionWriteFailureText } from './agent-session-write-notice-text' +import { + joinStructuredAgentSessionStop, + structuredAgentSessionStopIsJoinable +} from '../../../../shared/structured-agent-session-stop-join' export type StructuredAgentSessionWriteOutcome = | { kind: 'done'; value: T } @@ -52,16 +56,6 @@ export type AdmitStructuredAgentSessionAction = () => StructuredAgentSessionActi /** Captures the issuing pane visit for late presentation, never command or draft settlement. */ export type CaptureStructuredAgentSessionPresentation = () => () => boolean -/** Only targeted Stops are joinable; untargeted presses stop whatever is running when each reaches the host. */ -function namesWhatItStops(fingerprintMethod: string, fields: Record): boolean { - if (fingerprintMethod !== 'agentSession.cancel') { - return false - } - return fields.scope === 'background-tasks' - ? fields.taskId !== undefined - : fields.turnId !== undefined -} - function notDone( failure: AgentSessionWriteFailure, fingerprintMethod: string, @@ -175,27 +169,17 @@ export function useStructuredAgentSessionMutate(args: { fields: Record ): Promise> => { if ( - !namesWhatItStops(fingerprintMethod, fields) || + !structuredAgentSessionStopIsJoinable(fingerprintMethod, fields) || (await supportsStructuredAgentSessionQuietRepeatedStop(target)) ) { return send(method, fingerprintMethod, fields, pressedFence) } // Older hosts lack quiet repeated Stops; retain joining until none remain supported. const key = `${hostKey}:${sessionId}:${method}:${JSON.stringify(fields)}` - const joined = inFlightStops.current.get(key) - if (joined) { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the key names the method and fields, so the joined promise is this very call and settles to its T. - return joined as Promise> - } - const stopping = send(method, fingerprintMethod, fields, pressedFence) - inFlightStops.current.set(key, stopping) - // Gone once it settles, so the next press is a new Stop. - void stopping.finally(() => { - if (inFlightStops.current.get(key) === stopping) { - inFlightStops.current.delete(key) - } - }) - return stopping + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the key names the method and fields, so the joined promise is this very call and settles to its T. + return joinStructuredAgentSessionStop(inFlightStops.current, key, () => + send(method, fingerprintMethod, fields, pressedFence) + ) as Promise> } const mutate: StructuredAgentSessionMutate = async (...writeArgs: WriteArgs) => { const outcome = await execute(...writeArgs) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-edit.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-edit.test.tsx index 8f34387ca4a..eb20872d2d6 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-edit.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-edit.test.tsx @@ -14,7 +14,7 @@ import type { } from '../../../../shared/agent-session-wire' import { agentSessionSendBodyFingerprint } from '../../../../shared/structured-agent-session-send-mutation' import { useStructuredAgentSessionQueuedEdit } from './use-structured-agent-session-queued-edit' -import { projectQueuedMessageCards } from './structured-agent-session-queued-cards' +import { projectQueuedMessageCards } from '../../../../shared/structured-agent-session-queued-cards' import { clearNativeChatDraftCacheForTests, readNativeChatDraftCache, diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-edit.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-edit.ts index 4ef6eef0fd2..793e6e6de24 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-edit.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-edit.ts @@ -11,13 +11,16 @@ import type { import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' import { agentSessionSendBodyFingerprint } from '../../../../shared/structured-agent-session-send-mutation' import { + queuedCardOnScreen, + queuedEditChangedNotice, queuedEditDirty, queuedEditInput, queuedEditSaveHeld, + queuedEditStanding, queuedMessageEditableText, queuedMessageWithEdit } from '../../../../shared/queued-message-edit' -import { queuedEditChangedNotice, queuedEditNotice } from './queued-message-edit-notices' +import { queuedEditNotice } from './queued-message-edit-notices' import { handOffQueuedEdit, queuedEditImageFields, @@ -31,11 +34,7 @@ import { startQueuedEditLease, type QueuedEditLease } from './structured-agent-session-queued-edit-lease' -import type { QueuedMessageCard } from './structured-agent-session-queued-cards' -import { - queuedCardOnScreen, - queuedEditStanding -} from './structured-agent-session-queued-edit-standing' +import type { QueuedMessageCard } from '../../../../shared/structured-agent-session-queued-cards' import type { StructuredAgentSessionWrite } from './use-structured-agent-session-mutate' export type QueuedEditTransport = { @@ -251,7 +250,11 @@ export function useStructuredAgentSessionQueuedEdit(args: { gone(edit) } else if (status === 'changed') { // Preserve typing after a competing edit, then reopen on the newer text; removal alone needs no handoff. - toast.error(queuedEditChangedNotice(handOffQueuedEdit(edit), latest.current.promptOpen)) + toast.error( + queuedEditNotice( + queuedEditChangedNotice(handOffQueuedEdit(edit), latest.current.promptOpen) + ) + ) reopen.current = { scope, messageId: edit.messageId, stale: edit.baseFingerprint } close() } else { diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.ts index 3a5cf8f2bbc..b9269eb5861 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.ts @@ -25,11 +25,13 @@ import type { StructuredAgentSessionPendingSend } from './structured-agent-sessi import { newestSteerableQueuedMessageCard, projectQueuedMessageCards, - pendingQueueSendsOnTheirWay, queuedMessagesQueuePause, - sendingQueuedMessageCards, type QueuedMessageCard -} from './structured-agent-session-queued-cards' +} from '../../../../shared/structured-agent-session-queued-cards' +import { + pendingQueueSendsOnTheirWay, + sendingQueuedMessageCards +} from './structured-agent-session-sending-queued-cards' import type { AdmitStructuredAgentSessionAction, CaptureStructuredAgentSessionPresentation, diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-reorder.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-reorder.test.tsx index a7fac99e7f9..422180751aa 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-reorder.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-reorder.test.tsx @@ -3,7 +3,7 @@ import { act, cleanup, renderHook } from '@testing-library/react' import { afterEach, expect, it, vi } from 'vitest' import { toast } from 'sonner' import { useStructuredAgentSessionQueuedReorder } from './use-structured-agent-session-queued-reorder' -import type { QueuedMessageCard } from './structured-agent-session-queued-cards' +import type { QueuedMessageCard } from '../../../../shared/structured-agent-session-queued-cards' import { useStructuredAgentSessionMutate, type StructuredAgentSessionWrite diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-reorder.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-reorder.ts index 2fe3d6b86a3..b7f8e01e72a 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-reorder.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-reorder.ts @@ -3,7 +3,7 @@ import { toast } from 'sonner' import { translate } from '@/i18n/i18n' import type { AgentSessionQueuedMessageMoveResult } from '../../../../shared/agent-session-wire' import { structuredAgentSessionHostKey } from '@/runtime/structured-agent-session-host-capability' -import type { QueuedMessageCard } from './structured-agent-session-queued-cards' +import type { QueuedMessageCard } from '../../../../shared/structured-agent-session-queued-cards' import type { QueuedEditTransport } from './use-structured-agent-session-queued-edit' import type { CaptureStructuredAgentSessionPresentation } from './use-structured-agent-session-mutate' diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-sends.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-sends.ts index e4fb1ed39e5..e9e257e0c42 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-sends.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-sends.ts @@ -25,7 +25,7 @@ import { } from './structured-agent-session-pending-sends' import { noteStructuredAgentSessionFence } from './structured-agent-session-send-attempt' import { recoverLegacyStructuredAgentSessionOutbox } from './structured-agent-session-legacy-outbox' -import { pendingSendsOutsideQueuedCards } from './structured-agent-session-queued-cards' +import { pendingSendsOutsideQueuedCards } from './structured-agent-session-sending-queued-cards' /** The open chat's view of its sends: they live in the sender, which works without this view. */ export function useStructuredAgentSessionSends(args: { diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-transport-state.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-transport-state.ts index 86e48c51a54..035dfa2a6fa 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-transport-state.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-transport-state.ts @@ -1,7 +1,8 @@ import { useMemo } from 'react' import { runningStructuredAgentSessionTurnId, - structuredAgentSessionHostSaysWorking + structuredAgentSessionHostSaysWorking, + structuredAgentSessionQueueSendsNext } from '../../../../shared/structured-agent-session-live-turn' import { isStructuredAgentSessionMainAgentWorking } from '../../../../shared/structured-agent-session-main-agent-working' import type { StructuredAgentSessionState } from '../../../../shared/structured-agent-session-reducer' @@ -43,9 +44,10 @@ export function useStructuredAgentSessionTransportState( const isWorking = structuredAgentSessionHostSaysWorking(hostWorking, () => isStructuredAgentSessionMainAgentWorking(turnId, current.submissions, fence) ) - const nextQueuedMessageId = (enabled ? state.nextQueuedMessageId : null) ?? null - // Keep working through the gap between turn-end/Resume and the host’s next queued-send update. - const queueSendsNext = nextQueuedMessageId !== null && !isWorking + const queueSendsNext = structuredAgentSessionQueueSendsNext( + enabled ? state.nextQueuedMessageId : null, + isWorking + ) const turnActivity = useMemo( () => selectStructuredAgentTurnActivity(journalItems, turnId, enabled ? state.activity : null), [enabled, journalItems, state.activity, turnId] diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.ts b/src/renderer/src/components/native-chat/use-structured-agent-session.ts index 1ad7b9b7101..ca771eb1a2c 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.ts @@ -15,7 +15,7 @@ import { useStructuredAgentSessionHostEditsQueuedImages, useStructuredAgentSessionHostQueuesMessagesState } from '@/runtime/structured-agent-session-host-capability' -import { structuredAgentSessionStopControl } from './structured-agent-session-stop-control' +import { structuredAgentSessionStopControl } from '../../../../shared/structured-agent-session-stop-control' import type { AgentSessionPromptResponse } from '../../../../shared/agent-session-question-answer' import { respondToStructuredAgentSessionPrompt, @@ -35,12 +35,9 @@ import { useStructuredAgentSessionContextUsage } from './use-structured-agent-se import { useStructuredAgentSessionRailOutline } from './use-structured-agent-session-rail-outline' import { useStructuredAgentSessionQueuedMessages } from './use-structured-agent-session-queued-messages' import { structuredConversationCommandRunner } from './structured-conversation-command-send' -import { - commandCardWaiting, - pendingSendsOutsideQueuedCards -} from './structured-agent-session-queued-cards' +import { structuredAgentSessionQueueEnabled } from '../../../../shared/structured-agent-session-queued-cards' +import { pendingSendsOutsideQueuedCards } from './structured-agent-session-sending-queued-cards' import { hostStatesTurnScopes } from '../../../../shared/native-chat-turn-membership' -import { pendingPromptsAllUnanswerableHere } from '../../../../shared/agent-session-approval-subject' import { withNativeChatCutTurnNotices } from '../../../../shared/native-chat-cut-turn-notice' import { useNativeChatContextClearFloor } from './use-native-chat-context-clear-floor' import { useStructuredAgentSessionRewind } from './use-structured-agent-session-rewind' @@ -119,10 +116,8 @@ export function useStructuredAgentSession(args: StructuredAgentSessionViewArgs) const stopControlInput = { sessionId, target, transportState, hostStopping, mutate } const stopControl = useStructuredAgentSessionStop(stopControlInput) const prompts = pendingStructuredSessionPrompts(transportState) - const promptsUnanswerableHere = pendingPromptsAllUnanswerableHere(prompts) - // Queue after waiting commands even with follow-ups off, unless unanswerable prompts would strand the queue; immediate sends enable prompt cancellation. - const commandWaiting = commandCardWaiting(transportState.queuedMessages) - const queueEnabled = (queueFollowUps || commandWaiting) && !promptsUnanswerableHere + // Immediate sends enable prompt cancellation. + const queueEnabled = structuredAgentSessionQueueEnabled(queueFollowUps, transportState, prompts) const queue = useMemo( () => ({ capability: queueCapability, enabled: queueEnabled }), [queueCapability, queueEnabled] diff --git a/src/renderer/src/components/terminal-pane/terminal-drop-shell.ts b/src/renderer/src/components/terminal-pane/terminal-drop-shell.ts index 49857241373..5751b89874c 100644 --- a/src/renderer/src/components/terminal-pane/terminal-drop-shell.ts +++ b/src/renderer/src/components/terminal-pane/terminal-drop-shell.ts @@ -1,4 +1,4 @@ -import { isWindowsAbsolutePathLike } from '../../../../shared/cross-platform-path' +import { isTerminalDropWindowsPathLike } from '../../../../shared/image-path-paste' import { isWslUncPath } from '../../../../shared/wsl-paths' import { isWindowsUserAgent } from './pane-helpers' @@ -34,10 +34,3 @@ export function resolveTerminalDropTargetShell({ } return isWindowsUserAgent(userAgent) ? 'windows' : 'posix' } - -export function isTerminalDropWindowsPathLike(path: string): boolean { - if (isWslUncPath(path)) { - return false - } - return isWindowsAbsolutePathLike(path) || path.includes('\\') -} diff --git a/src/renderer/src/components/terminal-pane/terminal-drop-worktree-path.ts b/src/renderer/src/components/terminal-pane/terminal-drop-worktree-path.ts index 7ee9ca9f167..d33369cba79 100644 --- a/src/renderer/src/components/terminal-pane/terminal-drop-worktree-path.ts +++ b/src/renderer/src/components/terminal-pane/terminal-drop-worktree-path.ts @@ -7,7 +7,7 @@ import { } from '../../../../shared/execution-host' import { DROP_STAGING_RELATIVE_DIR } from '../../../../shared/drop-staging-ignore' import { resolveExactWorktreeRoute } from '@/lib/worktree-owner-route' -import { isTerminalDropWindowsPathLike } from './terminal-drop-shell' +import { isTerminalDropWindowsPathLike } from '../../../../shared/image-path-paste' export function resolveTerminalDropWorktreePath( worktreeId: string, diff --git a/src/renderer/src/components/terminal-pane/terminal-native-file-drop.ts b/src/renderer/src/components/terminal-pane/terminal-native-file-drop.ts index 8800344c28e..67c6481d2d2 100644 --- a/src/renderer/src/components/terminal-pane/terminal-native-file-drop.ts +++ b/src/renderer/src/components/terminal-pane/terminal-native-file-drop.ts @@ -13,9 +13,9 @@ import { } from './terminal-drop-target' import { getTerminalTargetShellForWorktreePath, - isTerminalDropWindowsPathLike, resolveTerminalDropTargetShell } from './terminal-drop-shell' +import { isTerminalDropWindowsPathLike } from '../../../../shared/image-path-paste' import { writeTerminalDropPathsToCapturedTarget } from './terminal-drop-path-writer' import { showTerminalDropWriteFailure } from './terminal-drop-write-failure' import { joinRuntimeTerminalDropDir } from './terminal-drop-worktree-path' diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index 32b9d0ac56c..0965de55129 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -3273,14 +3273,26 @@ "historyItemTooLarge": "This part of the chat was too large to show.", "historyRepaired": "Part of this chat's history couldn't be loaded.", "plan": "Plan", - "stoppedBeforeStart": "Stopped manually" + "stoppedBeforeStart": "Stopped manually", + "orcaStopCanContinue": "You can continue in this conversation.", + "orcaStopCrash": "Orca on {{machine}} stopped unexpectedly while this response was in progress.", + "orcaStopQuit": "Orca on {{machine}} was closed while this response was in progress.", + "orcaStopRemote": "Orca on {{machine}} stopped while this response was in progress.", + "orcaStopUnnamedServer": "the remote server", + "orcaStopUpdate": "Orca on {{machine}} restarted for an update while this response was in progress." }, "question": { "other": "Other…" }, "queuedMessages": { "imageCount_one": "{{count}} image", - "imageCount_other": "{{count}} images" + "imageCount_other": "{{count}} images", + "editChanged": "This message changed while you were editing. Your edit is in the chat box.", + "editChangedNotSaved": "This message changed while you were editing. Your edit wasn't saved.", + "editChangedPrompt": "This message changed while you were editing. Your edit will be in the chat box after you answer the agent.", + "editFailed": "This message can't be edited.", + "editGone": "This message was already sent or removed. Your edit is in the chat box.", + "editGonePrompt": "This message was already sent or removed. Your edit will be in the chat box after you answer the agent." }, "receipt": { "cancelled": "Cancelled", @@ -3437,7 +3449,8 @@ "waitForCommand": "Wait for the /compact or /clear to finish.", "waitForStart": "Wait for the agent to finish starting.", "waitForTurn": "Wait for the agent to finish responding, or stop it.", - "waitMoment": "Wait a moment." + "waitMoment": "Wait a moment.", + "pendingWork": "Wait for pending work and messages to finish before using this command." } }, "onboarding": { diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index e64d801da12..449338be77f 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -19472,7 +19472,8 @@ "clearAfterRetry": "Retry your earlier message, then run /clear.", "compactAfterRetry": "Retry your earlier message, then run /compact.", "clearAfterSending": "Your earlier message is still being sent. Run /clear once it has gone.", - "compactAfterSending": "Your earlier message is still being sent. Run /compact once it has gone." + "compactAfterSending": "Your earlier message is still being sent. Run /compact once it has gone.", + "pendingWork": "Wait for pending work and messages to finish before using this command." }, "taskList": { "title": "Tasks", @@ -19915,9 +19916,6 @@ "tokens": "{{value0}} tokens", "unnamed": "Subagent" }, - "conversationCommand": { - "pendingWork": "Wait for pending work and messages to finish before using this command." - }, "rewind": { "unsupported": "This chat can't go back to an earlier message.", "historyNotPaginated": "This older Codex chat can't go back to an earlier message. Start a new chat to use Rewind to here.", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 531a3462a02..60e5fa6e590 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -18967,7 +18967,8 @@ "clearAfterRetry": "Reintente su mensaje anterior y luego ejecute /clear.", "compactAfterRetry": "Reintente su mensaje anterior y luego ejecute /compact.", "clearAfterSending": "Su mensaje anterior todavía se está enviando. Ejecute /clear cuando se haya enviado.", - "compactAfterSending": "Su mensaje anterior todavía se está enviando. Ejecute /compact cuando se haya enviado." + "compactAfterSending": "Su mensaje anterior todavía se está enviando. Ejecute /compact cuando se haya enviado.", + "pendingWork": "Espera a que terminen el trabajo y los mensajes pendientes antes de usar este comando." }, "composer": { "imageUnavailable": "Vista previa no disponible", @@ -19469,9 +19470,6 @@ "asked": "Preguntó:", "questionCount": "{{value0}} preguntas" }, - "conversationCommand": { - "pendingWork": "Espera a que terminen el trabajo y los mensajes pendientes antes de usar este comando." - }, "drop": { "messageTitle": "Suelta para adjuntar a este mensaje", "title": "Suelta para adjuntar a este chat", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 19097770a81..e07a3775eaf 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -19108,7 +19108,8 @@ "clearAfterRetry": "Réessayez votre message précédent, puis lancez /clear.", "compactAfterRetry": "Réessayez votre message précédent, puis lancez /compact.", "clearAfterSending": "Votre message précédent est encore en cours d'envoi. Lancez /clear une fois qu'il est parti.", - "compactAfterSending": "Votre message précédent est encore en cours d'envoi. Lancez /compact une fois qu'il est parti." + "compactAfterSending": "Votre message précédent est encore en cours d'envoi. Lancez /compact une fois qu'il est parti.", + "pendingWork": "Attendez la fin du travail et des messages en attente avant d'utiliser cette commande." }, "composer": { "imageUnavailable": "Aperçu indisponible", @@ -19550,9 +19551,6 @@ "tokens": "Jetons {{value0}}", "unnamed": "Sous-agent" }, - "conversationCommand": { - "pendingWork": "Attendez la fin du travail et des messages en attente avant d'utiliser cette commande." - }, "drop": { "messageTitle": "Déposez pour joindre à ce message", "title": "Déposer pour joindre à ce chat", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 7483161fcf8..6fb096fa891 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -19059,7 +19059,8 @@ "clearAfterRetry": "以前のメッセージを再試行してから、/clear を実行してください。", "compactAfterRetry": "以前のメッセージを再試行してから、/compact を実行してください。", "clearAfterSending": "以前のメッセージはまだ送信中です。送信が終わったら /clear を実行してください。", - "compactAfterSending": "以前のメッセージはまだ送信中です。送信が終わったら /compact を実行してください。" + "compactAfterSending": "以前のメッセージはまだ送信中です。送信が終わったら /compact を実行してください。", + "pendingWork": "このコマンドを使用する前に、保留中の作業とメッセージが完了するまで待ってください。" }, "composer": { "imageUnsupported": "この Agent では画像の貼り付けはサポートされていません。", @@ -19502,9 +19503,6 @@ "tokens": "{{value0}} トークン", "unnamed": "サブ Agent" }, - "conversationCommand": { - "pendingWork": "このコマンドを使用する前に、保留中の作業とメッセージが完了するまで待ってください。" - }, "drop": { "messageTitle": "ドロップしてこのメッセージに添付", "title": "ドロップしてこのチャットに添付します", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 7c2ee099cde..f8812fd2eaa 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -18997,7 +18997,8 @@ "clearAfterRetry": "이전 메시지를 다시 시도한 다음 /clear를 실행하세요.", "compactAfterRetry": "이전 메시지를 다시 시도한 다음 /compact를 실행하세요.", "clearAfterSending": "이전 메시지를 아직 보내는 중입니다. 전송이 끝나면 /clear를 실행하세요.", - "compactAfterSending": "이전 메시지를 아직 보내는 중입니다. 전송이 끝나면 /compact를 실행하세요." + "compactAfterSending": "이전 메시지를 아직 보내는 중입니다. 전송이 끝나면 /compact를 실행하세요.", + "pendingWork": "이 명령을 사용하기 전에 보류 중인 작업과 메시지가 완료될 때까지 기다리십시오." }, "composer": { "imageUnsupported": "이 에이전트는 이미지 붙여넣기를 지원하지 않습니다.", @@ -19439,9 +19440,6 @@ "tokens": "{{value0}} 토큰", "unnamed": "하위 에이전트" }, - "conversationCommand": { - "pendingWork": "이 명령을 사용하기 전에 보류 중인 작업과 메시지가 완료될 때까지 기다리십시오." - }, "drop": { "messageTitle": "놓아서 이 메시지에 첨부", "title": "이 채팅에 첨부하려면 드롭하세요.", diff --git a/src/renderer/src/i18n/locales/pt.json b/src/renderer/src/i18n/locales/pt.json index 7d6f6591aae..42cf717d06a 100644 --- a/src/renderer/src/i18n/locales/pt.json +++ b/src/renderer/src/i18n/locales/pt.json @@ -19790,9 +19790,6 @@ "tokens": "{{value0}} tokens", "unnamed": "Subagente" }, - "conversationCommand": { - "pendingWork": "Aguarde a conclusão do trabalho e das mensagens pendentes antes de usar este comando." - }, "drop": { "title": "Solte para anexar a este chat", "subtitle": "Os arquivos são adicionados à sua mensagem como caminhos que o agente pode ler.", @@ -19982,7 +19979,8 @@ "clearAfterRetry": "Tente enviar sua mensagem anterior novamente e execute /clear.", "compactAfterRetry": "Tente enviar sua mensagem anterior novamente e execute /compact.", "clearAfterSending": "Sua mensagem anterior ainda está sendo enviada. Execute /clear após o envio.", - "compactAfterSending": "Sua mensagem anterior ainda está sendo enviada. Execute /compact após o envio." + "compactAfterSending": "Sua mensagem anterior ainda está sendo enviada. Execute /compact após o envio.", + "pendingWork": "Aguarde a conclusão do trabalho e das mensagens pendentes antes de usar este comando." }, "newRowsAnnouncement": "Novas mensagens chegaram enquanto você estava ausente", "newRowsMarker": "Novas", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index e7d709557a0..90e71d741a5 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -18962,7 +18962,8 @@ "clearAfterRetry": "请先重试您之前的消息,然后再运行 /clear。", "compactAfterRetry": "请先重试您之前的消息,然后再运行 /compact。", "clearAfterSending": "您之前的消息仍在发送中。发送完成后再运行 /clear。", - "compactAfterSending": "您之前的消息仍在发送中。发送完成后再运行 /compact。" + "compactAfterSending": "您之前的消息仍在发送中。发送完成后再运行 /compact。", + "pendingWork": "在使用此命令之前,请等待挂起的工作和消息完成。" }, "composer": { "imageUnsupported": "此智能体不支持粘贴图片。", @@ -19409,9 +19410,6 @@ "tokens": "{{value0}} 代币", "unnamed": "子智能体" }, - "conversationCommand": { - "pendingWork": "在使用此命令之前,请等待挂起的工作和消息完成。" - }, "drop": { "messageTitle": "拖放以附加到此消息", "title": "拖放以附加到此聊天", diff --git a/src/renderer/src/lib/structured-agent-session-launch-create-message.test.ts b/src/renderer/src/lib/structured-agent-session-launch-create-message.test.ts index 419a4b732f6..bbc35187290 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-create-message.test.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-create-message.test.ts @@ -85,7 +85,7 @@ import { getStructuredAgentSessionPendingSends, getStructuredAgentSessionSendNotice } from '@/components/native-chat/structured-agent-session-pending-sends' -import { structuredAgentSessionStopControl } from '@/components/native-chat/structured-agent-session-stop-control' +import { structuredAgentSessionStopControl } from '../../../shared/structured-agent-session-stop-control' import { clearNativeChatDraftCacheForTests, readNativeChatDraftCache, diff --git a/src/renderer/src/lib/structured-agent-session-launch-prompt-stop.test.ts b/src/renderer/src/lib/structured-agent-session-launch-prompt-stop.test.ts index 6082f6d1ff7..d98c1d30ae2 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-prompt-stop.test.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-prompt-stop.test.ts @@ -30,7 +30,7 @@ import { getStructuredAgentSessionSendNotice, structuredAgentSessionSendOut } from '@/components/native-chat/structured-agent-session-pending-sends' -import { structuredAgentSessionStopControl } from '@/components/native-chat/structured-agent-session-stop-control' +import { structuredAgentSessionStopControl } from '../../../shared/structured-agent-session-stop-control' import { discardStructuredLaunchPrompts, hasStagedStructuredLaunchPrompt, diff --git a/src/shared/agent-image-paste-path.ts b/src/shared/agent-image-paste-path.ts new file mode 100644 index 00000000000..9cb688d326e --- /dev/null +++ b/src/shared/agent-image-paste-path.ts @@ -0,0 +1,12 @@ +import { formatAgentImagePath } from './agent-image-paste' +import { formatImageDropPasteText, isTerminalDropWindowsPathLike } from './image-path-paste' + +/** Escape image paths as terminal drops do; agent TUIs unescape before checking file existence. */ +export function agentImagePastePath(agent: string | null | undefined, path: string): string { + const formatted = formatAgentImagePath(agent, path) + if (formatted !== path) { + return formatted + } + const targetShell = isTerminalDropWindowsPathLike(path) ? 'windows' : 'posix' + return formatImageDropPasteText(path, targetShell) ?? path +} diff --git a/src/shared/agent-session-usage-limit-words.ts b/src/shared/agent-session-usage-limit-words.ts index 63ecddd599c..afc15b3014a 100644 --- a/src/shared/agent-session-usage-limit-words.ts +++ b/src/shared/agent-session-usage-limit-words.ts @@ -1,15 +1,13 @@ // The words for a usage limit an agent hit: which limit and when it resets. -import { - sayAgentSessionFailureEnglish, - type AgentSessionFailureCopyId, - type AgentSessionFailureCopyValues, - type AgentSessionFailureSay +import type { + AgentSessionFailureCopyId, + AgentSessionFailureCopyValues, + AgentSessionFailureSay } from './agent-session-failure-copy' -import { - formatUsageLimitResetClock, - type AgentSessionUsageLimit, - type AgentSessionUsageLimitWindow +import type { + AgentSessionUsageLimit, + AgentSessionUsageLimitWindow } from './agent-session-usage-limit' import { joinSentences } from './sentence-joining' @@ -37,20 +35,3 @@ export function usageLimitWords( const reset = limit?.resetsAt !== undefined ? context.resetWords?.(limit.resetsAt) : null return joinSentences([lead, ...(reset ? [say('usageLimitResetsAt', { when: reset })] : [])]) } - -/** For nameless readers such as mobile, append localized reset time to the host usage-limit sentence; retain other lines unchanged. */ -export function usageLimitLineText(block: { - text: string - failure?: { kind: string; usageLimit?: AgentSessionUsageLimit } -}): string { - const resetsAt = - block.failure?.kind === 'usageLimitReached' ? block.failure.usageLimit?.resetsAt : undefined - return resetsAt === undefined - ? block.text - : joinSentences([ - block.text, - sayAgentSessionFailureEnglish('usageLimitResetsAt', { - when: formatUsageLimitResetClock(resetsAt) - }) - ]) -} diff --git a/src/shared/agent-session-write-notice-copy.ts b/src/shared/agent-session-write-notice-copy.ts index 4ec1d82bc19..6becca3cffa 100644 --- a/src/shared/agent-session-write-notice-copy.ts +++ b/src/shared/agent-session-write-notice-copy.ts @@ -78,6 +78,7 @@ export const AGENT_SESSION_WRITE_NOTICE_COPY = { compactAfterRetry: 'Retry your earlier message, then run /compact.', clearAfterSending: 'Your earlier message is still being sent. Run /clear once it has gone.', compactAfterSending: 'Your earlier message is still being sent. Run /compact once it has gone.', + pendingWork: 'Wait for pending work and messages to finish before using this command.', queueTooLarge: 'Too much text is waiting in the queue.', shrinkQueue: 'Delete a queued message, or wait for one to go through, then try again.', messageTooLarge: 'It is over the 256 KB limit for one message.', diff --git a/src/shared/image-path-paste.ts b/src/shared/image-path-paste.ts index c60749c7a1b..83c59e98169 100644 --- a/src/shared/image-path-paste.ts +++ b/src/shared/image-path-paste.ts @@ -1,5 +1,7 @@ +import { isWindowsAbsolutePathLike } from './cross-platform-path' import { IMAGE_FILE_EXTENSIONS } from './image-file-extensions' import { shellEscapePath } from './shell-path-quoting' +import { isWslUncPath } from './wsl-paths' type TerminalTargetShell = 'posix' | 'windows' @@ -22,6 +24,13 @@ export function isImageDropPath(path: string): boolean { return IMAGE_DROP_EXTENSIONS.has(path.slice(lastDot).toLowerCase()) } +export function isTerminalDropWindowsPathLike(path: string): boolean { + if (isWslUncPath(path)) { + return false + } + return isWindowsAbsolutePathLike(path) || path.includes('\\') +} + export function canPasteImageDropPathRaw(path: string, targetShell: TerminalTargetShell): boolean { if (hasControlByte(path)) { return false diff --git a/src/shared/native-chat-agent-support.ts b/src/shared/native-chat-agent-support.ts index b76a8d4e557..7cc8fc2d369 100644 --- a/src/shared/native-chat-agent-support.ts +++ b/src/shared/native-chat-agent-support.ts @@ -42,6 +42,11 @@ export function nativeChatAskRequiresTerminal(agent: string | null | undefined): return resolveNativeChatTranscriptAgent(agent) === 'omp' } +/** OpenCode confirms an interrupt with a second Escape; Claude Code and Codex read Esc-Esc at an idle prompt as rewind. */ +export function nativeChatStopSendsSecondEscape(agent: string | null | undefined): boolean { + return resolveNativeChatTranscriptAgent(agent) === 'opencode' +} + export function resolveNativeChatTranscriptAgent( agent: string | null | undefined ): NativeChatTranscriptAgent | null { diff --git a/src/shared/native-chat-clock-skew.ts b/src/shared/native-chat-clock-skew.ts new file mode 100644 index 00000000000..a2e9b2fdc18 --- /dev/null +++ b/src/shared/native-chat-clock-skew.ts @@ -0,0 +1,2 @@ +/** Slack for comparing transcript timestamps to hook receipt times across hosts. */ +export const LIFECYCLE_CLOCK_SKEW_SLACK_MS = 2_000 diff --git a/src/renderer/src/components/native-chat/native-chat-composer-state.test.ts b/src/shared/native-chat-composer-autocomplete.test.ts similarity index 97% rename from src/renderer/src/components/native-chat/native-chat-composer-state.test.ts rename to src/shared/native-chat-composer-autocomplete.test.ts index d77c0493dcf..d160ab6a883 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-state.test.ts +++ b/src/shared/native-chat-composer-autocomplete.test.ts @@ -1,24 +1,22 @@ import { describe, expect, it } from 'vitest' import { applyMentionSuggestion, + applyPickerSuggestion, + applySlashSuggestion, + buildNativeChatPickerItems, + classifyNativeChatSend, deriveComposerAutocomplete, editReplacesTriggerToken, - isSkillPickerTriggered -} from './native-chat-composer-state' -import { applyPickerSuggestion } from '../../../../shared/native-chat/input/native-chat-picker-triggers' -import { - applySlashSuggestion, - classifyNativeChatSend, filterSlashCommands, + isSkillPickerTriggered, isSlashCommandDraft, slashCommandDispatchText, - sessionSlashCommandSuggestions, type SlashCommandSuggestion -} from '../../../../shared/native-chat/input/native-chat-slash-commands' -import { buildNativeChatPickerItems } from '../../../../shared/native-chat/input/native-chat-picker-items' -import type { DiscoveredSkill } from '../../../../shared/skills' -import { getNativeChatAgentProfile } from '../../../../shared/native-chat-agent-profiles' -import { pickedSkillsInText } from '../../../../shared/structured-agent-session-send-mutation' +} from './native-chat-composer-autocomplete' +import { sessionSlashCommandSuggestions } from './native-chat/input/native-chat-slash-commands' +import type { DiscoveredSkill } from './skills' +import { getNativeChatAgentProfile } from './native-chat-agent-profiles' +import { pickedSkillsInText } from './structured-agent-session-send-mutation' const COMMANDS: SlashCommandSuggestion[] = [ { name: 'clear' }, diff --git a/src/renderer/src/components/native-chat/native-chat-composer-state.ts b/src/shared/native-chat-composer-autocomplete.ts similarity index 79% rename from src/renderer/src/components/native-chat/native-chat-composer-state.ts rename to src/shared/native-chat-composer-autocomplete.ts index aa4d91f5b97..959f3c72138 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-state.ts +++ b/src/shared/native-chat-composer-autocomplete.ts @@ -1,20 +1,37 @@ -import type { DiscoveredSkill } from '../../../../shared/skills' -import { formatNativeChatFileReference } from '../../../../shared/agent-image-paste' -import type { NativeChatAgentProfile } from '../../../../shared/native-chat-agent-profiles' -import type { - NativeChatSessionSkill, - SlashCommandSuggestion -} from '../../../../shared/native-chat/input/native-chat-slash-commands' +import type { DiscoveredSkill } from './skills' +import { formatNativeChatFileReference } from './agent-image-paste' +import type { NativeChatAgentProfile } from './native-chat-agent-profiles' +import { + filterSlashCommands, + isSlashCommandDraft, + applySlashSuggestion, + slashCommandDispatchText, + type NativeChatSessionSkill, + type SlashCommandSuggestion +} from './native-chat/input/native-chat-slash-commands' import { buildNativeChatPickerItems, type NativeChatPickerItem, type NativeChatSkillDiscoverySnapshot -} from '../../../../shared/native-chat/input/native-chat-picker-items' +} from './native-chat/input/native-chat-picker-items' import { LEADING_SLASH_TRIGGER, MID_PROMPT_SLASH_TRIGGER, matchSkillSigil -} from '../../../../shared/native-chat/input/native-chat-picker-triggers' +} from './native-chat/input/native-chat-picker-triggers' + +export type { SlashCommandSuggestion } +export { filterSlashCommands, isSlashCommandDraft, applySlashSuggestion, slashCommandDispatchText } +export { + classifyNativeChatSend, + type NativeChatSendClassification +} from './native-chat/input/native-chat-slash-commands' +export { applyPickerSuggestion } from './native-chat/input/native-chat-picker-triggers' +export { + buildNativeChatPickerItems, + type NativeChatPickerItem, + type NativeChatSkillDiscoverySnapshot +} from './native-chat/input/native-chat-picker-items' type PickerAutocomplete = { query: string @@ -119,16 +136,9 @@ function deriveSlashAutocomplete( } // Known agent grammar enables skills; the caller owns catalog policy, while this layer formats tokens. const skillsEnabled = profile !== null - const namePrefix = profile?.skillNamePrefix - // Scanned names take the agent's prefix so they match the names its session reports. - const scannedSkills = !skillsEnabled - ? [] - : namePrefix - ? discovery.skills.map((skill) => ({ ...skill, name: `${namePrefix}${skill.name}` })) - : discovery.skills const items = buildNativeChatPickerItems( agentCommands, - scannedSkills, + nativeChatPickerScannedSkills(profile, discovery.skills), query, profile?.skillPrefix ?? '/', skillsEnabled ? sessionSkills : [], @@ -157,6 +167,20 @@ function deriveSlashAutocomplete( } } +/** Scanned names take the agent's prefix so they match the names its session reports. */ +export function nativeChatPickerScannedSkills( + profile: NativeChatAgentProfile | null, + skills: readonly DiscoveredSkill[] +): readonly DiscoveredSkill[] { + if (!profile) { + return [] + } + const namePrefix = profile.skillNamePrefix + return namePrefix + ? skills.map((skill) => ({ ...skill, name: `${namePrefix}${skill.name}` })) + : skills +} + /** Replacing across an entire dismissed token starts a new occurrence; typing/deleting within it does not. */ export function editReplacesTriggerToken( previous: string, diff --git a/src/shared/native-chat-image-label.ts b/src/shared/native-chat-image-label.ts new file mode 100644 index 00000000000..741c96fcd5f --- /dev/null +++ b/src/shared/native-chat-image-label.ts @@ -0,0 +1,33 @@ +import { getRuntimePathBasename } from './cross-platform-path' +import type { NativeChatImageRefBlock } from './native-chat-types' + +export type NativeChatImageLabelCopy = { + pastedImage: string + imageCount: (count: number) => string +} + +export const NATIVE_CHAT_IMAGE_LABEL_ENGLISH: NativeChatImageLabelCopy = { + pastedImage: 'Pasted image', + imageCount: (count) => `${count} images` +} + +/** Use friendly UI labels for Orca clipboard-image temp filenames. */ +export function isNativeChatPastedImagePath(path: string): boolean { + const base = path.split(/[\\/]/).findLast(Boolean) ?? path + return /^orca-paste-.+\.png$/i.test(base) +} + +/** What a chat image is called where it is not drawn: its alt text, else its file's name. */ +export function nativeChatImageLabel( + block: NativeChatImageRefBlock, + copy: NativeChatImageLabelCopy +): string { + return ( + block.alt?.trim() || + (block.path && isNativeChatPastedImagePath(block.path) + ? copy.pastedImage + : block.path + ? getRuntimePathBasename(block.path) + : 'Image') + ) +} diff --git a/src/renderer/src/components/native-chat/native-chat-launch-draft-resolution.test.ts b/src/shared/native-chat-launch-draft-resolution.test.ts similarity index 97% rename from src/renderer/src/components/native-chat/native-chat-launch-draft-resolution.test.ts rename to src/shared/native-chat-launch-draft-resolution.test.ts index 1577030f2d8..6ca98929c34 100644 --- a/src/renderer/src/components/native-chat/native-chat-launch-draft-resolution.test.ts +++ b/src/shared/native-chat-launch-draft-resolution.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import type { NativeChatMessage } from '../../../../shared/native-chat-types' +import type { NativeChatMessage } from './native-chat-types' import { launchDraftResolvedByTranscript, nativeChatLaunchDraftTurnBaseline diff --git a/src/renderer/src/components/native-chat/native-chat-launch-draft-resolution.ts b/src/shared/native-chat-launch-draft-resolution.ts similarity index 79% rename from src/renderer/src/components/native-chat/native-chat-launch-draft-resolution.ts rename to src/shared/native-chat-launch-draft-resolution.ts index a346b7046cc..a0cef6bafc7 100644 --- a/src/renderer/src/components/native-chat/native-chat-launch-draft-resolution.ts +++ b/src/shared/native-chat-launch-draft-resolution.ts @@ -1,6 +1,5 @@ -import type { NativeChatMessage } from '../../../../shared/native-chat-types' -import type { NativeChatLaunchDraft } from '@/lib/native-chat-launch-prompt' -import { LIFECYCLE_CLOCK_SKEW_SLACK_MS } from './native-chat-live-status' +import type { NativeChatMessage } from './native-chat-types' +import { LIFECYCLE_CLOCK_SKEW_SLACK_MS } from './native-chat-clock-skew' /** A new tail turn proves resolution without timestamps; older-history prepends cannot advance it. */ export type NativeChatLaunchDraftTurnBaseline = { @@ -9,7 +8,7 @@ export type NativeChatLaunchDraftTurnBaseline = { } export function nativeChatLaunchDraftTurnBaseline( - messages: NativeChatMessage[] + messages: readonly NativeChatMessage[] ): NativeChatLaunchDraftTurnBaseline { const userTurns = messages.filter((message) => message.role === 'user') return { @@ -20,8 +19,8 @@ export function nativeChatLaunchDraftTurnBaseline( // Resolve on user turns not provably older than the seed, allowing missing timestamps and host-clock slack; text matching misses TUI edits. export function launchDraftResolvedByTranscript( - entry: Pick, - messages: NativeChatMessage[], + entry: { createdAt: number }, + messages: readonly NativeChatMessage[], baseline?: NativeChatLaunchDraftTurnBaseline | null ): boolean { const provablyOlder = (message: NativeChatMessage): boolean => diff --git a/src/shared/native-chat-skill-visibility.test.ts b/src/shared/native-chat-skill-visibility.test.ts new file mode 100644 index 00000000000..942fb3a1749 --- /dev/null +++ b/src/shared/native-chat-skill-visibility.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from 'vitest' +import type { DiscoveredSkill, SkillDiscoveryResult } from './skills' +import { isNativeChatSkillForAgent } from './native-chat-skill-visibility' + +function skill(overrides: Partial): DiscoveredSkill { + return { + id: overrides.name ?? 'skill', + name: 'agent-browser', + description: null, + providers: ['agent-skills'], + sourceKind: 'home', + sourceLabel: 'Agent skills home', + rootPath: '/Users/test/.agents/skills', + directoryPath: '/Users/test/.agents/skills/agent-browser', + skillFilePath: '/Users/test/.agents/skills/agent-browser/SKILL.md', + installed: true, + updatedAt: null, + ...overrides + } +} + +function discovery(owner: string | null, rootPath = '/Users/test/.agents/skills') { + return { + sources: [ + { + id: 'source', + label: 'Source', + path: rootPath, + sourceKind: 'home' as const, + providers: ['agent-skills' as const], + owner, + exists: true + } + ] + } satisfies Pick +} + +describe('isNativeChatSkillForAgent', () => { + it('shows Codex-native and generic agent skills for Codex chat', () => { + expect(isNativeChatSkillForAgent('codex', skill({ providers: ['codex'] }))).toBe(true) + expect(isNativeChatSkillForAgent('codex', skill({ providers: ['agent-skills'] }))).toBe(true) + }) + + it('keeps Claude skills out of the Codex skill picker', () => { + expect(isNativeChatSkillForAgent('codex', skill({ providers: ['claude'] }))).toBe(false) + }) + + it('does not enable skill autocomplete for other agents yet', () => { + expect(isNativeChatSkillForAgent('claude', skill({ providers: ['agent-skills'] }))).toBe(false) + }) + + it('uses explicit source ownership and keeps shared roots visible', () => { + const shared = discovery(null) + expect(isNativeChatSkillForAgent('codex', skill({}), shared)).toBe(true) + expect(isNativeChatSkillForAgent('claude', skill({}), shared)).toBe(true) + expect(isNativeChatSkillForAgent('grok', skill({}), shared)).toBe(true) + }) + + it('aliases OpenClaude to Claude roots without exposing them to other agents', () => { + const claude = discovery('claude') + expect(isNativeChatSkillForAgent('claude', skill({}), claude)).toBe(true) + expect(isNativeChatSkillForAgent('openclaude', skill({}), claude)).toBe(true) + expect(isNativeChatSkillForAgent('codex', skill({}), claude)).toBe(false) + expect(isNativeChatSkillForAgent('grok', skill({}), claude)).toBe(false) + }) + + it('shows OpenCode the Claude skill folders it reads, and not Claude plugins', () => { + const claude = discovery('claude') + expect(isNativeChatSkillForAgent('opencode', skill({}), claude)).toBe(true) + expect(isNativeChatSkillForAgent('opencode2', skill({}), claude)).toBe(true) + const plugin = { sources: [{ ...claude.sources[0], sourceKind: 'plugin' as const }] } + expect(isNativeChatSkillForAgent('opencode2', skill({}), plugin)).toBe(false) + }) + + it('grants visibility through any contributing root, not just the dedup survivor', () => { + const result = { + sources: [ + { + id: 'codex-home', + label: 'Codex home', + path: '/Users/test/.codex/skills', + sourceKind: 'home' as const, + providers: ['codex' as const], + owner: 'codex', + exists: true + }, + { + id: 'shared-home', + label: 'Agent skills home', + path: '/Users/test/.agents/skills', + sourceKind: 'home' as const, + providers: ['agent-skills' as const], + owner: null, + exists: true + } + ] + } satisfies Pick + // Canonical dedup may list the Codex root first; shared-root reachability must still grant other agents visibility. + const merged = skill({ + rootPath: '/Users/test/.codex/skills', + rootPaths: ['/Users/test/.codex/skills', '/Users/test/.agents/skills'] + }) + expect(isNativeChatSkillForAgent('claude', merged, result)).toBe(true) + expect(isNativeChatSkillForAgent('codex', merged, result)).toBe(true) + const codexOnly = skill({ + rootPath: '/Users/test/.codex/skills', + rootPaths: ['/Users/test/.codex/skills'] + }) + expect(isNativeChatSkillForAgent('claude', codexOnly, result)).toBe(false) + }) +}) diff --git a/src/shared/native-chat-skill-visibility.ts b/src/shared/native-chat-skill-visibility.ts new file mode 100644 index 00000000000..c22691f33e6 --- /dev/null +++ b/src/shared/native-chat-skill-visibility.ts @@ -0,0 +1,55 @@ +import type { AgentType } from './agent-status-types' +import type { AgentSessionSlashCommand } from './agent-session-wire' +import type { DiscoveredSkill, SkillDiscoveryResult } from './skills' +import { getNativeChatAgentProfile, readsSkillSource } from './native-chat-agent-profiles' +import { classifyPickerCommands } from './native-chat/input/native-chat-picker-items' +import { + sessionReportedSkills, + type NativeChatSessionSkill, + type SlashCommandSuggestion +} from './native-chat/input/native-chat-slash-commands' +import { isStructuredAgentSessionComposerCommand } from './structured-agent-session-composer' + +export function isNativeChatSkillForAgent( + agent: AgentType, + skill: DiscoveredSkill, + result?: Pick +): boolean { + const profile = getNativeChatAgentProfile(agent) + if (!profile) { + return false + } + if (!result) { + return ( + agent === 'codex' && + (skill.providers.includes('codex') || skill.providers.includes('agent-skills')) + ) + } + // Canonical dedup may hide alternate symlink roots; any shared/agent root grants visibility regardless of scan order. + const rootPaths = skill.rootPaths?.length ? skill.rootPaths : [skill.rootPath] + return rootPaths.some((rootPath) => { + const source = result.sources.find((entry) => entry.path === rootPath) + return source !== undefined && readsSkillSource(profile, source) + }) +} + +/** Skills the session reported; undefined keeps the host disk scan (also for chats that keep scanned skills). */ +export function nativeChatSessionSkills( + agent: AgentType, + reported: readonly AgentSessionSlashCommand[] | undefined +): readonly NativeChatSessionSkill[] | undefined { + const keepsScannedSkills = getNativeChatAgentProfile(agent)?.keepsScannedSkills === true + return reported !== undefined && !keepsScannedSkills ? sessionReportedSkills(reported) : undefined +} + +export function nativeChatPickerCommands( + agent: AgentType, + commands: readonly SlashCommandSuggestion[], + scannedSkills: readonly DiscoveredSkill[] +): readonly SlashCommandSuggestion[] { + return classifyPickerCommands(commands, scannedSkills, { + // A chat that keeps scanned skills (OpenCode) may report each as a command too. + reportMayListSkills: getNativeChatAgentProfile(agent)?.keepsScannedSkills === true, + isSendCommand: (name) => isStructuredAgentSessionComposerCommand(`/${name}`, agent) + }) +} diff --git a/src/renderer/src/components/native-chat/native-chat-live-status.test.ts b/src/shared/native-chat/native-chat-live-status.test.ts similarity index 91% rename from src/renderer/src/components/native-chat/native-chat-live-status.test.ts rename to src/shared/native-chat/native-chat-live-status.test.ts index 6791d76ec87..cccd5cb27d8 100644 --- a/src/renderer/src/components/native-chat/native-chat-live-status.test.ts +++ b/src/shared/native-chat/native-chat-live-status.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it } from 'vitest' -import type { NativeChatMessage } from '../../../../shared/native-chat-types' +import type { NativeChatMessage } from '../native-chat-types' import { mergeNativeChatLiveSession, nativeChatHookAwaitsInput } from './native-chat-live-status' -import { selectNativeChatViewState } from './native-chat-view-state' import { shouldShowNativeChatWorking } from './native-chat-working-suppression' function assistant(id: string, text: string): NativeChatMessage { @@ -269,28 +268,6 @@ describe('mergeNativeChatLiveSession', () => { }) expect(session.status).toBe('empty') }) - - // Session ids can arrive before transcript flush; live work must still retain Stop and its activity indicator. - it('keeps the Stop affordance for a working known session mid-flush', () => { - const session = mergeNativeChatLiveSession({ - messages: [user('u-1', 'run it')], - sessionId: 'sess', - agent: 'claude', - hookState: 'working', - loading: true - }) - const viewState = selectNativeChatViewState(session) - const isConversation = viewState.kind === 'ready' - - expect(viewState).toEqual({ kind: 'ready', isWorking: true }) - expect( - shouldShowNativeChatWorking({ - isConversation, - working: session.status === 'working', - interrupted: false - }) - ).toBe(true) - }) }) describe('nativeChatHookAwaitsInput', () => { diff --git a/src/renderer/src/components/native-chat/native-chat-live-status.ts b/src/shared/native-chat/native-chat-live-status.ts similarity index 74% rename from src/renderer/src/components/native-chat/native-chat-live-status.ts rename to src/shared/native-chat/native-chat-live-status.ts index 49d76bbc879..8028f5fdeeb 100644 --- a/src/renderer/src/components/native-chat/native-chat-live-status.ts +++ b/src/shared/native-chat/native-chat-live-status.ts @@ -1,13 +1,15 @@ // Reconcile live working state with provider turn boundaries so a dropped final hook cannot leave Stop stuck. -import type { AgentStatusState } from '../../../../shared/agent-status-types' +import type { AgentStatusEntry, AgentStatusState } from '../agent-status-types' +import { isAgentStatusFresh } from '../agent-status-freshness' import type { AgentType, NativeChatMessage, NativeChatSession, NativeChatSessionStatus, NativeChatTurnLifecycle -} from '../../../../shared/native-chat-types' +} from '../native-chat-types' +import { LIFECYCLE_CLOCK_SKEW_SLACK_MS } from '../native-chat-clock-skew' export type NativeChatLiveMergeInput = { /** Already deduped, ordered, and transcript-normalized by the live assembler. */ @@ -30,8 +32,12 @@ export type NativeChatLiveMergeInput = { error?: string } +export type NativeChatLiveMergedSession = NativeChatSession & { hookAwaitingInput: boolean } + /** Errors win; working hooks override loading until their turn ends, with provider lifecycle settling dropped Stop hooks. */ -export function mergeNativeChatLiveSession(input: NativeChatLiveMergeInput): NativeChatSession { +export function mergeNativeChatLiveSession( + input: NativeChatLiveMergeInput +): NativeChatLiveMergedSession { const { messages, sessionId, @@ -44,8 +50,13 @@ export function mergeNativeChatLiveSession(input: NativeChatLiveMergeInput): Nat loading, error } = input + const hookAwaitingInput = nativeChatHookAwaitsInput( + hookState, + stateStartedAt, + transcriptLifecycle + ) if (error) { - return { messages, status: 'error', sessionId, agent, error } + return { messages, status: 'error', sessionId, agent, error, hookAwaitingInput } } const status = liveStatusOverride( @@ -58,16 +69,44 @@ export function mergeNativeChatLiveSession(input: NativeChatLiveMergeInput): Nat ) // Keep Stop/activity live while loading; view-state selection separately holds empty transcripts on their loading surface. if (loading && status !== 'working') { - return { messages, status: 'loading', sessionId, agent } + return { messages, status: 'loading', sessionId, agent, hookAwaitingInput } } return { messages, status: status ?? (messages.length === 0 ? 'empty' : 'ready'), sessionId, - agent + agent, + hookAwaitingInput } } +/** Restored nonterminal rows need a live hook confirmation before they may keep chat busy. */ +export function resolveNativeChatHookState( + entry: + | Pick + | undefined + | null, + now = Date.now() +): AgentStatusState | null { + if (!entry || !isAgentStatusFresh(entry, now)) { + return null + } + return entry.state === 'working' && entry.workingMode === 'monitoring' ? null : entry.state +} + +/** Only children started in this parent epoch may delay settlement; missed stops and reused panes leave stale roster entries. */ +export function nativeChatHookHasWorkingSubagents( + entry: Pick | undefined | null +): boolean { + const epochStart = entry?.stateStartedAt + return ( + entry?.subagents?.some( + (subagent) => + subagent.state === 'working' && (epochStart == null || subagent.startedAt >= epochStart) + ) ?? false + ) +} + /** blocked/waiting needs human input; a transcript terminal marker can clear waits whose interrupt emitted no hook. */ export function nativeChatHookAwaitsInput( hookState: AgentStatusState | null, @@ -80,9 +119,6 @@ export function nativeChatHookAwaitsInput( ) } -/** Slack for comparing transcript timestamps to hook receipt times across hosts. */ -export const LIFECYCLE_CLOCK_SKEW_SLACK_MS = 2_000 - /** omp retries its own agent_end hook, so trailing mid-run prose/tool rows cannot stand in for a lost end signal. */ function hookDeliversTurnEnd(agent: AgentType): boolean { return agent === 'omp' diff --git a/src/shared/native-chat/native-chat-orca-stop-words.ts b/src/shared/native-chat/native-chat-orca-stop-words.ts new file mode 100644 index 00000000000..a33dc4e02cc --- /dev/null +++ b/src/shared/native-chat/native-chat-orca-stop-words.ts @@ -0,0 +1,32 @@ +import type { AgentSessionOrcaStopCause } from '../agent-session-orca-stop' +import { joinSentences } from '../sentence-joining' + +/** English pieces; desktop translates each with the same fallback, the phone keeps English. */ +export const NATIVE_CHAT_ORCA_STOP_COPY = { + update: 'Orca on {{machine}} restarted for an update while this response was in progress.', + quit: 'Orca on {{machine}} was closed while this response was in progress.', + crash: 'Orca on {{machine}} stopped unexpectedly while this response was in progress.', + // A remote graceful stop is a server stop or a shared desktop's quit; "stopped" is true of both. + remote: 'Orca on {{machine}} stopped while this response was in progress.', + canContinue: 'You can continue in this conversation.', + unnamedServer: 'the remote server' +} as const + +export type NativeChatOrcaStopCopyId = keyof typeof NATIVE_CHAT_ORCA_STOP_COPY +export type NativeChatOrcaStopSay = (id: NativeChatOrcaStopCopyId, machine?: string) => string + +export const sayNativeChatOrcaStopEnglish: NativeChatOrcaStopSay = (id, machine) => + machine === undefined + ? NATIVE_CHAT_ORCA_STOP_COPY[id] + : NATIVE_CHAT_ORCA_STOP_COPY[id].replace('{{machine}}', () => machine) + +/** Base wording on host continuation support so the sentence stays stable as the button changes. */ +export function nativeChatOrcaStopRowText( + cause: AgentSessionOrcaStopCause, + machine: string, + options: { continueAvailable: boolean; remoteHost: boolean }, + say: NativeChatOrcaStopSay = sayNativeChatOrcaStopEnglish +): string { + const stopped = say(cause === 'quit' && options.remoteHost ? 'remote' : cause, machine) + return options.continueAvailable ? stopped : joinSentences([stopped, say('canContinue')]) +} diff --git a/src/renderer/src/components/native-chat/native-chat-terminal-turn.test.ts b/src/shared/native-chat/native-chat-terminal-turn.test.ts similarity index 98% rename from src/renderer/src/components/native-chat/native-chat-terminal-turn.test.ts rename to src/shared/native-chat/native-chat-terminal-turn.test.ts index 843453645ff..911f60e9a0c 100644 --- a/src/renderer/src/components/native-chat/native-chat-terminal-turn.test.ts +++ b/src/shared/native-chat/native-chat-terminal-turn.test.ts @@ -1,8 +1,5 @@ import { describe, expect, it } from 'vitest' -import { - NATIVE_CHAT_INTERRUPTED_STATUS_TEXT, - type NativeChatMessage -} from '../../../../shared/native-chat-types' +import { NATIVE_CHAT_INTERRUPTED_STATUS_TEXT, type NativeChatMessage } from '../native-chat-types' import { nativeChatHookLatestTurnWorkedSeconds, nativeChatHookTurnStartedAt, diff --git a/src/renderer/src/components/native-chat/native-chat-terminal-turn.ts b/src/shared/native-chat/native-chat-terminal-turn.ts similarity index 76% rename from src/renderer/src/components/native-chat/native-chat-terminal-turn.ts rename to src/shared/native-chat/native-chat-terminal-turn.ts index 5fa974818fc..a6cb9569cdb 100644 --- a/src/renderer/src/components/native-chat/native-chat-terminal-turn.ts +++ b/src/shared/native-chat/native-chat-terminal-turn.ts @@ -1,16 +1,12 @@ -import type { AgentStatusEntry } from '../../../../shared/agent-status-types' -import { - isInterruptedStatusMessage, - type NativeChatMessage -} from '../../../../shared/native-chat-types' -import type { - NativeChatSettledTurn, - NativeChatSettledTurns -} from '../../../../shared/native-chat-turn-status' -import { isNoiseMessage } from '../../../../shared/native-chat-noise' -import type { NativeChatAwaitingInput } from './NativeChatMessageList' +import type { AgentStatusEntry } from '../agent-status-types' +import { isInterruptedStatusMessage, type NativeChatMessage } from '../native-chat-types' +import type { NativeChatSettledTurn, NativeChatSettledTurns } from '../native-chat-turn-status' +import { isNoiseMessage } from '../native-chat-noise' import { shouldShowNativeChatWorking } from './native-chat-working-suppression' +/** shown prompts have a card in this pane; unshown prompts require input in the agent’s terminal. */ +export type NativeChatAwaitingInput = 'shown' | 'unshown' + export type NativeChatTerminalTurn = { /** The agent is generating: drives Stop-vs-Send and the streaming preview. */ isWorking: boolean @@ -110,3 +106,23 @@ export function nativeChatTranscriptSettledTurns( } return settled } + +/** Overlay the host's latest-turn timing on transcript durations once the turn settles. */ +export function nativeChatTerminalSettledTurns(args: { + transcriptSettled: NativeChatSettledTurns + latestTurnId: string | null + latestWorkedSeconds: number | null | undefined + turnStartedAt: number | null + turnActive: boolean +}): NativeChatSettledTurns { + const { transcriptSettled, latestTurnId, latestWorkedSeconds, turnStartedAt, turnActive } = args + if (turnActive || latestWorkedSeconds === undefined || latestTurnId === null) { + return transcriptSettled + } + return new Map(transcriptSettled).set( + latestTurnId, + latestWorkedSeconds === null || turnStartedAt === null + ? null + : { startedAt: turnStartedAt, workedSeconds: latestWorkedSeconds } + ) +} diff --git a/src/renderer/src/components/native-chat/native-chat-working-suppression.ts b/src/shared/native-chat/native-chat-working-suppression.ts similarity index 100% rename from src/renderer/src/components/native-chat/native-chat-working-suppression.ts rename to src/shared/native-chat/native-chat-working-suppression.ts diff --git a/src/shared/queued-message-edit.ts b/src/shared/queued-message-edit.ts index fc4e5bb5635..ff90c348fb1 100644 --- a/src/shared/queued-message-edit.ts +++ b/src/shared/queued-message-edit.ts @@ -1,9 +1,12 @@ -import type { AgentJournalMessageItem } from './agent-session-journal-types' +import type { AgentJournalMessageItem, AgentJournalSubmission } from './agent-session-journal-types' +import type { AgentSessionQueuedMessage } from './agent-session-queued-message-wire' +import { agentSessionSendBodyFingerprint } from './structured-agent-session-send-mutation' import { MAX_BLOCKS, MAX_PROMPT_BYTES } from './rpc-contract/structured-agent-session-params' import { imageReferenceInsertion, imageReferenceToken } from './native-chat-image-reference' import { imageReferencesToPromptMarkers, - promptMarkersToImageReferences + promptMarkersToImageReferences, + stripSentImagePromptMarkers } from './native-chat-image-transcript-markers' export type QueuedMessageImageBlock = Extract< @@ -89,7 +92,7 @@ export function queuedEditTextWithAddedImages(draft: QueuedMessageEditDraft): st /** The text an edit's typing is measured against: the card's own, its markers following the * images removed so far, so a removal alone is not typing. */ -export function queuedEditBaseText( +function queuedEditBaseText( body: AgentJournalMessageItem, kept: readonly { id: string }[] ): string | null { @@ -103,6 +106,17 @@ export function queuedEditBaseText( ) } +/** The typing an edit hands to the chat box: none when only images changed, and without the + * markers of the card's own images, which the chat box does not carry. */ +export function queuedEditHandOffTyping( + body: AgentJournalMessageItem, + draft: Pick +): string { + return draft.text === queuedEditBaseText(body, draft.kept) + ? '' + : stripSentImagePromptMarkers(draft.text, draft.kept.length) +} + function queuedEditImagesChanged( body: AgentJournalMessageItem, draft: QueuedMessageEditDraft @@ -204,3 +218,58 @@ export function queuedMessageWithEdit( } return { ok: true, body: { ...plainBody, blocks } } } + +/** A question or approval holds the chat box's place, so the text there is out of sight until then. */ +export const QUEUED_EDIT_NOTICE_COPY = { + editChanged: 'This message changed while you were editing. Your edit is in the chat box.', + editChangedPrompt: + 'This message changed while you were editing. Your edit will be in the chat box after you answer the agent.', + editChangedNotSaved: "This message changed while you were editing. Your edit wasn't saved.", + editGone: 'This message was already sent or removed. Your edit is in the chat box.', + editGonePrompt: + 'This message was already sent or removed. Your edit will be in the chat box after you answer the agent.', + editFailed: "This message can't be edited." +} as const + +export type QueuedEditNotice = keyof typeof QUEUED_EDIT_NOTICE_COPY + +export function queuedEditChangedNotice(handedOff: boolean, promptOpen: boolean): QueuedEditNotice { + if (!handedOff) { + return 'editChangedNotSaved' + } + return promptOpen ? 'editChangedPrompt' : 'editChanged' +} + +/** Sent means handed off with this body; holds/differs describe the current card, gone its removal; null queue proves nothing. */ +type QueuedEditStanding = 'sent' | 'gone' | 'holds' | 'differs' + +export function queuedEditStanding( + view: { + messages: readonly AgentSessionQueuedMessage[] | null + cards: readonly { messageId: string }[] + submissions: readonly AgentJournalSubmission[] + }, + sessionId: string, + messageId: string, + /** The fingerprint of the body the edit would save; null when it saves none. */ + desired: string | null +): QueuedEditStanding | null { + if (view.messages === null) { + return null + } + const listed = view.messages.find((entry) => entry.messageId === messageId) + if (!listed || !queuedCardOnScreen(view.cards, messageId)) { + const sent = view.submissions.some( + (entry) => entry.queuedMessageId === messageId && entry.payloadFingerprint === desired + ) + return desired !== null && sent ? 'sent' : 'gone' + } + return agentSessionSendBodyFingerprint(sessionId, listed.body) === desired ? 'holds' : 'differs' +} + +export function queuedCardOnScreen( + cards: readonly { messageId: string }[], + messageId: string +): boolean { + return cards.some((card) => card.messageId === messageId) +} diff --git a/src/shared/skill-install-capability.ts b/src/shared/skill-install-capability.ts index 0787da7dd6d..ad613bc6d0f 100644 --- a/src/shared/skill-install-capability.ts +++ b/src/shared/skill-install-capability.ts @@ -23,6 +23,9 @@ export const BUNDLED_ORCA_SKILL_CAPABILITY = 'skills.bundled-orca.v1' as const export const BUNDLED_ORCA_SKILL_UPDATE_REQUIRED_MESSAGE = 'Update Orca on the server to update its Orca skills.' +/** skills.discover scans a workspace named only by worktreeId, so phones without its folder can ask. */ +export const SKILL_DISCOVER_WORKSPACE_CAPABILITY = 'skills.discover-workspace.v1' as const + export const SKILL_RUNTIME_CAPABILITIES = [ SKILL_INSTALL_CAPABILITY, SKILL_BUNDLE_INSTALL_CAPABILITY, @@ -33,5 +36,6 @@ export const SKILL_RUNTIME_CAPABILITIES = [ SKILL_MANAGEMENT_CAPABILITY, SKILL_INSTALL_PROVIDERS_CAPABILITY, SKILL_DELETE_CAPABILITY, - BUNDLED_ORCA_SKILL_CAPABILITY + BUNDLED_ORCA_SKILL_CAPABILITY, + SKILL_DISCOVER_WORKSPACE_CAPABILITY ] as const diff --git a/src/shared/structured-agent-session-live-turn.ts b/src/shared/structured-agent-session-live-turn.ts index 7cf9b8d6ad6..664800cd239 100644 --- a/src/shared/structured-agent-session-live-turn.ts +++ b/src/shared/structured-agent-session-live-turn.ts @@ -195,6 +195,14 @@ export function structuredAgentSessionHostSaysWorking( return hostWorking ?? derived() } +/** Working through the gap between turn end/Resume and the host's next queued-send update. */ +export function structuredAgentSessionQueueSendsNext( + nextQueuedMessageId: string | null | undefined, + isWorking: boolean +): boolean { + return nextQueuedMessageId != null && !isWorking +} + /** Use host-named actionable prompts; older hosts treat every pending prompt as waiting. */ export function isActionableStructuredAgentSessionPrompt( itemId: string, diff --git a/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.test.ts b/src/shared/structured-agent-session-queued-cards.test.ts similarity index 75% rename from src/renderer/src/components/native-chat/structured-agent-session-queued-cards.test.ts rename to src/shared/structured-agent-session-queued-cards.test.ts index b045d8c35de..d62ead6a271 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.test.ts +++ b/src/shared/structured-agent-session-queued-cards.test.ts @@ -1,11 +1,8 @@ import { describe, expect, it } from 'vitest' -import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' -import type { AgentSessionQueuedMessage } from '../../../../shared/agent-session-wire' -import type { StructuredAgentSessionPendingSend } from './structured-agent-session-pending-sends' +import type { AgentJournalSubmission } from './agent-session-journal-types' +import type { AgentSessionQueuedMessage } from './agent-session-wire' import { newestSteerableQueuedMessageCard, - pendingSendsOutsideQueuedCards, - pendingQueueSendsOnTheirWay, projectQueuedMessageCards, queuedMessageCardSteers, queuedMessagesQueuePause @@ -220,62 +217,6 @@ describe('queued message cards', () => { expect(working.map((card) => card.waitsForAgent ?? false)).toEqual([false, true]) expect(cards[1]).not.toHaveProperty('waitsForAgent') }) - - it('a mid-turn queue send on its way is no bubble; a plain or recorded one is, until its row', () => { - const entry = ( - clientMessageId: string, - overrides: Partial = {} - ): StructuredAgentSessionPendingSend => ({ - clientMessageId, - sessionId: 'session-1', - body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: clientMessageId }] }, - previewUris: [], - queuedAt: 1, - phase: 'sending', - issued: true, - ...overrides - }) - const ids = (entries: readonly StructuredAgentSessionPendingSend[]): string[] => - entries.map((candidate) => candidate.clientMessageId) - const sends = [ - entry('queued', { delivery: 'queue-if-active' }), - entry('plain'), - entry('recorded', { phase: 'recorded' }) - ] - // Pending queued sends use cards while working; plain/recorded sends remain bubbles until host ownership appears. - expect(ids(pendingSendsOutsideQueuedCards(sends, [], true))).toEqual(['plain', 'recorded']) - expect(ids(pendingSendsOutsideQueuedCards(sends, [], false))).toEqual([ - 'queued', - 'plain', - 'recorded' - ]) - expect(ids(pendingSendsOutsideQueuedCards(sends, ['plain'], false))).toEqual([ - 'queued', - 'recorded' - ]) - }) - it('a sending card ends once the host records the send or hands its card off', () => { - const entry: StructuredAgentSessionPendingSend = { - clientMessageId: 'a', - sessionId: 'session-1', - body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'a' }] }, - previewUris: [], - queuedAt: 1, - phase: 'sending', - issued: true, - delivery: 'queue-if-active' - } - const onItsWay = (submissions: AgentJournalSubmission[]) => - pendingQueueSendsOnTheirWay([entry], [], true, submissions).map( - (candidate) => candidate.clientMessageId - ) - expect(onItsWay([])).toEqual(['a']) - expect(onItsWay([submission('a')])).toEqual([]) - expect(onItsWay([handOff('a')])).toEqual([]) - expect(pendingQueueSendsOnTheirWay([entry], ['a'], true, [])).toEqual([]) - expect(pendingQueueSendsOnTheirWay([entry], [], false, [])).toEqual([]) - expect(pendingQueueSendsOnTheirWay([{ ...entry, phase: 'recorded' }], [], true, [])).toEqual([]) - }) }) describe("another agent's card", () => { diff --git a/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.ts b/src/shared/structured-agent-session-queued-cards.ts similarity index 57% rename from src/renderer/src/components/native-chat/structured-agent-session-queued-cards.ts rename to src/shared/structured-agent-session-queued-cards.ts index 295aaa69c13..3158fc13a6c 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.ts +++ b/src/shared/structured-agent-session-queued-cards.ts @@ -1,18 +1,33 @@ // Derive hold labels on the client; the wire carries queue state, not presentation wording. -import type { UnreadAgentSessionFailureFact } from '../../../../shared/agent-session-failure' -import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' -import type { NativeChatImageRefBlock } from '../../../../shared/native-chat-types' -import type { - AgentSessionQueuedMessage, - AgentSessionQueuePause -} from '../../../../shared/agent-session-wire' +import { + readWholeAgentSessionFailureFact, + type AgentSessionFailureFact, + type UnreadAgentSessionFailureFact +} from './agent-session-failure' +import { agentSessionFailureStatedByRow } from './agent-session-visible-failures' +import { agentSessionWriteNotDoneParts } from './agent-session-refusal-notice' +import type { AgentSessionWriteNoticePart } from './agent-session-write-notice-copy' +import { dispatchWasWithdrawn } from './structured-agent-session-dispatch-rejection' +import { structuredAgentSessionAttemptFailureParts } from './structured-agent-session-rejection-words' +import type { AgentJournalItemBody, AgentJournalSubmission } from './agent-session-journal-types' +import { pendingPromptsAllUnanswerableHere } from './agent-session-approval-subject' +import type { NativeChatImageRefBlock } from './native-chat-types' +import { + QUEUED_MESSAGE_PAUSED_SEND_FAILED, + type AgentSessionQueuedMessage, + type AgentSessionQueuePause +} from './agent-session-wire' import { readAgentMessageSource, type AgentMessageSource -} from '../../../../shared/agent-session/agent-session-message-source' -import type { StructuredAgentSessionPendingSend } from './structured-agent-session-pending-sends' -import { handedOffQueuedMessageIds } from '../../../../shared/structured-agent-session-draft-hand-off' +} from './agent-session/agent-session-message-source' +import { handedOffQueuedMessageIds } from './structured-agent-session-draft-hand-off' +import { + NATIVE_CHAT_IMAGE_LABEL_ENGLISH, + nativeChatImageLabel, + type NativeChatImageLabelCopy +} from './native-chat-image-label' export type QueuedMessageCardHold = /** Plainly queued, also behind an open question: the question already says the agent waits. */ @@ -44,11 +59,11 @@ export type QueuedMessageCard = { from?: AgentMessageSource } -function queuedMessageCardText(body: AgentSessionQueuedMessage['body']): string { +export function queuedMessageCardText(body: AgentSessionQueuedMessage['body']): string { return body.blocks.flatMap((block) => (block.type === 'text' ? [block.text] : [])).join('\n') } -function queuedMessageCardImages( +export function queuedMessageCardImages( body: AgentSessionQueuedMessage['body'] ): Pick { const images = body.blocks.flatMap((block) => (block.type === 'image-ref' ? [block] : [])) @@ -105,7 +120,7 @@ export function projectQueuedMessageCards( } /** Later sends queue behind commands even with follow-ups off; individually held cards do not block them. */ -export function commandCardWaiting( +function commandCardWaiting( queuedMessages: readonly AgentSessionQueuedMessage[] | null | undefined ): boolean { return (queuedMessages ?? []).some( @@ -114,6 +129,25 @@ export function commandCardWaiting( ) } +/** Sends queue behind a waiting command even with follow-ups off; prompts no one here can answer would strand the queue, so then a send starts a turn. */ +export function structuredAgentSessionQueueEnabled( + queueFollowUps: boolean, + session: { queuedMessages?: readonly AgentSessionQueuedMessage[] | null }, + prompts: readonly { body: AgentJournalItemBody }[] +): boolean { + return ( + (queueFollowUps || commandCardWaiting(session.queuedMessages)) && + !pendingPromptsAllUnanswerableHere(prompts) + ) +} + +/** A returned card, or one whose own send failed, needs the person; the rest wait their turn. */ +export function queuedMessageCardNeedsAttention( + card: Pick +): boolean { + return card.state === 'returned' || card.pausedReason === QUEUED_MESSAGE_PAUSED_SEND_FAILED +} + /** Offer Resume only while the queue pause holds a card it could actually send. */ export function queuedMessagesQueuePause( cards: readonly QueuedMessageCard[], @@ -135,50 +169,38 @@ export function newestSteerableQueuedMessageCard( return newest && !newest.command && newest.hold !== 'sending' ? newest : null } -export function sendingQueuedMessageCards( - entries: readonly StructuredAgentSessionPendingSend[] -): QueuedMessageCard[] { - return entries.map((entry, index) => ({ - messageId: entry.clientMessageId, - // After every card the host holds, in send order. - position: Number.MAX_SAFE_INTEGER - entries.length + index, - text: queuedMessageCardText(entry.body), - ...queuedMessageCardImages(entry.body), - state: 'waiting', - hold: 'sending' - })) -} - -/** Host-owned cards and active queue requests stay out of transcript echoes; recorded sends remain until their row arrives. */ -export function pendingSendsOutsideQueuedCards< - Send extends { clientMessageId: string; delivery?: 'queue-if-active' } ->(pending: readonly Send[], heldIds: readonly string[], isWorking: boolean): readonly Send[] { - const held = new Set(heldIds) - const next = pending.filter( - (entry) => - !held.has(entry.clientMessageId) && !(isWorking && entry.delivery === 'queue-if-active') - ) - return next.length === pending.length ? pending : next -} - -export function pendingQueueSendsOnTheirWay( - pending: readonly StructuredAgentSessionPendingSend[], - heldIds: readonly string[], - isWorking: boolean, - submissions: readonly AgentJournalSubmission[] -): StructuredAgentSessionPendingSend[] { - if (!isWorking) { - return [] +/** The card's line: its text, or for a card of images alone, the image's name or their count. */ +export function queuedMessageCardLine( + card: Pick, + copy: NativeChatImageLabelCopy = NATIVE_CHAT_IMAGE_LABEL_ENGLISH +): string { + const images = card.images ?? [] + if (card.text.trim() || images.length === 0) { + return card.text } - const recorded = new Set([ - ...heldIds, - ...handedOffQueuedMessageIds(submissions), - ...submissions.map((submission) => submission.clientMessageId) - ]) - return pending.filter( - (entry) => - entry.phase === 'sending' && - entry.delivery === 'queue-if-active' && - !recorded.has(entry.clientMessageId) + return images.length === 1 + ? nativeChatImageLabel(images[0], copy) + : copy.imageCount(images.length) +} + +/** A returned card's words: a Stop withdrawal is not a send failure; the card's own Send is the retry, so failure words leave sending again out. */ +export function queuedMessageReturnedNotice( + card: Pick, + agentName: string | undefined, + statedFailures: readonly AgentSessionFailureFact[] +): 'withdrawn' | AgentSessionWriteNoticePart[] { + // Read exactly as a rejected submission: the typed fact decides, the reason is the fallback. + const reason = card.returnedReason ?? null + const rejection = card.returnedRejection + if (agentSessionFailureStatedByRow(rejection, statedFailures)) { + return agentSessionWriteNotDoneParts('send') + } + if (dispatchWasWithdrawn({ dispatchState: 'rejected', reason, rejection })) { + return 'withdrawn' + } + return structuredAgentSessionAttemptFailureParts( + { kind: 'rejected', reason }, + { agentName, retryControl: true }, + readWholeAgentSessionFailureFact(rejection) ) } diff --git a/src/renderer/src/components/native-chat/structured-agent-session-stop-control.ts b/src/shared/structured-agent-session-stop-control.ts similarity index 67% rename from src/renderer/src/components/native-chat/structured-agent-session-stop-control.ts rename to src/shared/structured-agent-session-stop-control.ts index 70532876ec4..2489c157e19 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-stop-control.ts +++ b/src/shared/structured-agent-session-stop-control.ts @@ -1,3 +1,13 @@ +/** A published chat: its running turn, or the whole conversation where the host stops that while working or sending. */ +export function structuredAgentSessionCanStop(input: { + turnId: string | null + isWorking: boolean + sending: boolean + stopsConversation: boolean +}): boolean { + return input.turnId !== null || (input.stopsConversation && (input.isWorking || input.sending)) +} + export function structuredAgentSessionStopControl(input: { published: boolean /** Temporary: hosts without create.message.v1 still leave the prompt on this client. */ @@ -19,7 +29,12 @@ export function structuredAgentSessionStopControl(input: { const { sending, stopSends } = input.sends return { canStop: published - ? turnId !== null || (host.stopsConversation && (isWorking || sending)) + ? structuredAgentSessionCanStop({ + turnId, + isWorking, + sending, + stopsConversation: host.stopsConversation + }) : legacyLaunch !== undefined && sending, stop: () => { if (!published) { diff --git a/src/shared/structured-agent-session-stop-join.ts b/src/shared/structured-agent-session-stop-join.ts new file mode 100644 index 00000000000..bcff6044360 --- /dev/null +++ b/src/shared/structured-agent-session-stop-join.ts @@ -0,0 +1,33 @@ +/** Only targeted Stops are joinable; untargeted presses stop whatever is running when each reaches the host. */ +export function structuredAgentSessionStopIsJoinable( + fingerprintMethod: string, + fields: Record +): boolean { + if (fingerprintMethod !== 'agentSession.cancel') { + return false + } + return fields.scope === 'background-tasks' + ? fields.taskId !== undefined + : fields.turnId !== undefined +} + +/** Older hosts lack quiet repeated Stops: a repeat joins the in-flight Stop instead of sending again. */ +export function joinStructuredAgentSessionStop( + inFlight: Map>, + key: string, + stop: () => Promise +): Promise { + const joined = inFlight.get(key) + if (joined) { + return joined + } + const stopping = stop() + inFlight.set(key, stopping) + // Gone once it settles, so the next press is a new Stop. + void stopping.finally(() => { + if (inFlight.get(key) === stopping) { + inFlight.delete(key) + } + }) + return stopping +} diff --git a/src/shared/structured-conversation-command-hold.test.ts b/src/shared/structured-conversation-command-hold.test.ts new file mode 100644 index 00000000000..1a91f8a62b6 --- /dev/null +++ b/src/shared/structured-conversation-command-hold.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest' +import { structuredConversationCommandHold } from './structured-conversation-command-hold' + +describe('a conversation command held on this side', () => { + const idle = { + waitsInLine: false, + agentWorking: false, + promptPending: false, + backgroundTasksRunning: false, + sendPending: false + } + + it('is held here only by a message the host does not have yet, or background work', () => { + const inLine = { ...idle, waitsInLine: true } + expect( + structuredConversationCommandHold({ + ...inLine, + agentWorking: true, + promptPending: true + }) + ).toBeNull() + expect(structuredConversationCommandHold({ ...inLine, sendPending: true })).toBe('ahead') + expect(structuredConversationCommandHold({ ...inLine, backgroundTasksRunning: true })).toBe( + 'background' + ) + }) + + it('against a host that cannot hold it, and for /clear, keeps every check in true words', () => { + expect(structuredConversationCommandHold(idle)).toBeNull() + expect(structuredConversationCommandHold({ ...idle, agentWorking: true })).toBe('working') + expect(structuredConversationCommandHold({ ...idle, sendPending: true })).toBe('sending') + expect( + structuredConversationCommandHold({ ...idle, agentWorking: true, sendPending: true }) + ).toBe('working') + expect(structuredConversationCommandHold({ ...idle, promptPending: true })).toBe('prompt') + }) +}) diff --git a/src/shared/structured-conversation-command-hold.ts b/src/shared/structured-conversation-command-hold.ts new file mode 100644 index 00000000000..4e0de6686a3 --- /dev/null +++ b/src/shared/structured-conversation-command-hold.ts @@ -0,0 +1,117 @@ +import { + isAgentSessionConversationCommand, + type AgentSessionConversationCommand, + type AgentSessionConversationCommandResult +} from './agent-session/agent-session-conversation-command' +import { + readWholeAgentSessionFailureFact, + type AgentSessionFailureFact +} from './agent-session-failure' +import { + sayAgentSessionFailureEnglish, + type AgentSessionFailureSay +} from './agent-session-failure-copy' +import { agentSessionFailureSentence } from './agent-session-failure-words' +import { agentSessionFailureStatedByRow } from './agent-session-visible-failures' +import type { AgentSessionWriteNoticeSentence } from './agent-session-write-notice-copy' +import { structuredAgentSessionCommandHostRefusalCause } from './structured-agent-session-command-refusal-cause' +import type { StructuredAgentSessionCommandRefusalCause } from './structured-agent-session-composer' + +/** Ahead leaves command text untouched behind an unanswered local send; other holds return an explicit refusal. */ +export type StructuredConversationCommandHold = + | 'ahead' + | 'working' + | 'prompt' + | 'background' + | 'sending' + +/** Queued commands wait only for earlier unaccepted sends; ordinary commands wait for all in-flight agent work. */ +export function structuredConversationCommandHold(input: { + /** A /compact the host can hold as a card behind the turn or prompt. */ + waitsInLine: boolean + /** A turn runs, or the chat shows the agent working on a message it has not answered. */ + agentWorking: boolean + promptPending: boolean + backgroundTasksRunning: boolean + sendPending: boolean +}): StructuredConversationCommandHold | null { + if (input.backgroundTasksRunning) { + return 'background' + } + if (input.waitsInLine) { + return input.sendPending ? 'ahead' : null + } + if (input.promptPending) { + return 'prompt' + } + if (input.agentWorking) { + return 'working' + } + if (input.sendPending) { + return 'sending' + } + return null +} + +/** The sentences a held command shows. */ +export function heldConversationCommandParts( + command: AgentSessionConversationCommand, + hold: Exclude +): AgentSessionWriteNoticeSentence[] { + const clear = command === 'clear' + switch (hold) { + case 'prompt': + return [clear ? 'clearAfterAnswer' : 'compactAfterAnswer'] + case 'working': + return ['agentStillWorking', clear ? 'runClearWhenDone' : 'runCompactWhenDone'] + case 'sending': + return [clear ? 'clearAfterSending' : 'compactAfterSending'] + case 'background': + return ['pendingWork'] + } +} + +/** Translate complete known facts/commands; preserve host text for older/newer payloads this client cannot interpret. */ +function conversationCommandFailureText( + result: AgentSessionConversationCommandResult, + agentName: string | undefined, + say: AgentSessionFailureSay +): string | null { + const fact = isAgentSessionConversationCommand(result.command) + ? readWholeAgentSessionFailureFact(result.failure) + : undefined + if (!fact) { + return result.error ?? null + } + return agentSessionFailureSentence(fact, 'row', { agentName, command: result.command }, say) +} + +/** What a command's reply shows: nothing for a start failure a row already states; otherwise its failure in words that name the agent, and the cause a host refusal names. */ +export function conversationCommandReply( + value: AgentSessionConversationCommandResult, + input: { + agentName?: string + /** What the chat's loaded start-failure rows state when the reply lands. */ + startFailures: readonly AgentSessionFailureFact[] + say?: AgentSessionFailureSay + } +): { accepted: boolean; error: string | null; cause?: StructuredAgentSessionCommandRefusalCause } { + // Clear/new-command failures may belong elsewhere and need host wording. + if ( + isAgentSessionConversationCommand(value.command) && + value.command !== 'clear' && + agentSessionFailureStatedByRow(value.failure, input.startFailures) + ) { + return { accepted: false, error: null } + } + const error = conversationCommandFailureText( + value, + input.agentName, + input.say ?? sayAgentSessionFailureEnglish + ) + if (value.state === 'completed' && !error) { + return { accepted: true, error: null } + } + const cause = structuredAgentSessionCommandHostRefusalCause(value) + return { accepted: false, error, ...(cause ? { cause } : {}) } +} diff --git a/tests/e2e/native-chat-transcript-file-order.unit.test.ts b/tests/e2e/native-chat-transcript-file-order.unit.test.ts index 192d7c11d57..100487bf523 100644 --- a/tests/e2e/native-chat-transcript-file-order.unit.test.ts +++ b/tests/e2e/native-chat-transcript-file-order.unit.test.ts @@ -336,7 +336,7 @@ describe('canonical source positions', () => { it('keeps factual clocks for status, clear boundaries, mixed sources and optimistic tiers', async () => { const { mergeNativeChatLiveSession } = - await import('../../src/renderer/src/components/native-chat/native-chat-live-status') + await import('../../src/shared/native-chat/native-chat-live-status') const { applyCommandMarkerBoundaries, commandMarkersAsMessages } = await import('../../src/renderer/src/components/native-chat/native-chat-command-marker') const { assembleNativeChatSession } =