mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
+132
-50
@@ -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"]
|
||||
|
||||
+116
-114
@@ -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"]
|
||||
|
||||
+266
-264
@@ -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"]
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<typeof createElement> {
|
||||
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<typeof createElement> {
|
||||
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<void> {
|
||||
await act(async () => {
|
||||
renderer = create(overlayElement(tick))
|
||||
})
|
||||
}
|
||||
|
||||
async function update(tick: Tick): Promise<void> {
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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({
|
||||
<MobileNativeChatVisualContext.Provider value={visuals}>
|
||||
<MobileNativeChatRootItemSourceContext.Provider value={detailSource}>
|
||||
<MobileNativeChatReplyRevealsContext.Provider value={replyReveals}>
|
||||
<MobileNativeChatView
|
||||
messages={session.messages}
|
||||
folded={folded}
|
||||
status={session.status}
|
||||
error={session.error}
|
||||
readFailedFinally={session.readFailedFinally === true}
|
||||
agent={controller.nativeChatAgent}
|
||||
agentWorking={controller.nativeChatAgentWorking}
|
||||
canStop={controller.nativeChatCanStop}
|
||||
structuredActivityUi={controller.nativeChatStructured}
|
||||
turnIndicator={controller.nativeChatTurnIndicator}
|
||||
workingStartedAt={controller.nativeChatWorkingStartedAt}
|
||||
settledTurns={controller.nativeChatSettledTurns}
|
||||
turnJournal={controller.nativeChatTurnJournal}
|
||||
receipts={controller.nativeChatReceipts}
|
||||
actionablePromptIds={controller.nativeChatActionablePromptIds}
|
||||
streaming={streaming}
|
||||
onStop={controller.handleNativeChatStop}
|
||||
ask={controller.nativeChatAsk}
|
||||
askKey={controller.nativeChatAskKey}
|
||||
promptKey={controller.nativeChatPromptKey}
|
||||
onDismissAsk={controller.dismissNativeChatAsk}
|
||||
onAnswerAsk={controller.handleNativeChatAnswerAsk}
|
||||
askRequiresTerminal={
|
||||
!controller.nativeChatStructured && nativeChatAskRequiresTerminal(agent)
|
||||
}
|
||||
onAnswerInTerminal={
|
||||
activeSessionTabId
|
||||
? () => 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}
|
||||
/>
|
||||
<MobileNativeChatNoticeContext.Provider value={noticeNames}>
|
||||
<MobileNativeChatView
|
||||
messages={session.messages}
|
||||
folded={folded}
|
||||
status={session.status}
|
||||
error={session.error}
|
||||
readFailedFinally={session.readFailedFinally === true}
|
||||
agent={controller.nativeChatAgent}
|
||||
agentWorking={controller.nativeChatTurnActive}
|
||||
awaitingInput={controller.nativeChatAwaitingInput}
|
||||
canStop={controller.nativeChatCanStop}
|
||||
structuredActivityUi={controller.nativeChatStructured}
|
||||
turnIndicator={controller.nativeChatTurnIndicator}
|
||||
workingStartedAt={controller.nativeChatWorkingStartedAt}
|
||||
settledTurns={controller.nativeChatSettledTurns}
|
||||
turnJournal={controller.nativeChatTurnJournal}
|
||||
receipts={controller.nativeChatReceipts}
|
||||
actionablePromptIds={controller.nativeChatActionablePromptIds}
|
||||
streaming={streaming}
|
||||
onStop={controller.handleNativeChatStop}
|
||||
ask={controller.nativeChatAsk}
|
||||
askKey={controller.nativeChatAskKey}
|
||||
promptKey={controller.nativeChatPromptKey}
|
||||
onDismissAsk={controller.dismissNativeChatAsk}
|
||||
onAnswerAsk={controller.handleNativeChatAnswerAsk}
|
||||
askRequiresTerminal={
|
||||
!controller.nativeChatStructured && nativeChatAskRequiresTerminal(agent)
|
||||
}
|
||||
onAnswerInTerminal={
|
||||
activeSessionTabId
|
||||
? () => 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}
|
||||
/>
|
||||
</MobileNativeChatNoticeContext.Provider>
|
||||
</MobileNativeChatReplyRevealsContext.Provider>
|
||||
</MobileNativeChatRootItemSourceContext.Provider>
|
||||
</MobileNativeChatVisualContext.Provider>
|
||||
|
||||
@@ -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' }))
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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 ? (
|
||||
<MobileNativeChatLiveLine
|
||||
line={turns.liveLine}
|
||||
onToggleReasoning={turns.onToggleReasoning}
|
||||
fontScale={fontScale}
|
||||
onOpenFile={onOpenFile}
|
||||
/>
|
||||
) : null
|
||||
const liveStatus =
|
||||
awaitingInput === 'unshown' ? (
|
||||
<MobileNativeChatAwaitingInputRow subject={null} pending fontScale={fontScale} />
|
||||
) : turns.liveLine ? (
|
||||
<MobileNativeChatLiveLine
|
||||
line={turns.liveLine}
|
||||
onToggleReasoning={turns.onToggleReasoning}
|
||||
fontScale={fontScale}
|
||||
onOpenFile={onOpenFile}
|
||||
/>
|
||||
) : 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
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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<QueuedMessageCardRowProps, 'editor' | 'onEditorLayout'>): 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 (
|
||||
<View testID="queued-card-row" style={[styles.card, styles.divided]}>
|
||||
<View style={styles.row}>
|
||||
@@ -78,7 +79,7 @@ function QueuedMessageCardContents({
|
||||
</Text>
|
||||
) : null}
|
||||
<MobileQueuedCardText
|
||||
text={card.text}
|
||||
text={card.line}
|
||||
expanded={expanded}
|
||||
onToggle={() => setExpanded(!expanded)}
|
||||
/>
|
||||
@@ -161,7 +162,7 @@ function QueuedMessageCardContents({
|
||||
accessibilityHint="Shows less of the message"
|
||||
onPress={() => setExpanded(false)}
|
||||
>
|
||||
<Text style={styles.bodyText}>{card.text}</Text>
|
||||
<Text style={styles.bodyText}>{card.line}</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
{returned && card.caption ? (
|
||||
|
||||
@@ -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 ? (
|
||||
<View style={styles.emptyState}>
|
||||
<ActivityIndicator size="small" color={colors.textSecondary} />
|
||||
@@ -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 && (
|
||||
<Animated.View pointerEvents="none" style={[styles.toast, toastAnimatedStyle]}>
|
||||
|
||||
@@ -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 ?? {}) {
|
||||
|
||||
@@ -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' }))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -212,20 +212,18 @@ async function mountPhone(): Promise<Phone> {
|
||||
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<Phone> {
|
||||
sendSurfaceId: 'host\0workspace\0tab',
|
||||
sessionId: null,
|
||||
getSendCompletionGeneration: () => 0,
|
||||
keyboardInset: 0
|
||||
keyboardInset: 0,
|
||||
hostName: null
|
||||
})
|
||||
}
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
|
||||
@@ -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<MobileNativeChatNoticeNames>({
|
||||
hostName: null
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
)
|
||||
|
||||
@@ -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: {} }))
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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<typeof useMobileStructuredAgent
|
||||
ReturnType<typeof useMobileStructuredAgentTurnTiming> & {
|
||||
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<MobileNativeChatSendOutcome>
|
||||
cancel: () => void
|
||||
receipts: ReadonlyMap<string, NativeChatResolvedPrompt>
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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<MobileStructuredCommandHolds>) => () => ({ ...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()
|
||||
|
||||
@@ -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<Exclude<StructuredAgentSessionCommandRefusalCause, 'retry'>, 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<MobileNativeChatSendOutcome | null> {
|
||||
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<typeof heldConversationCommandParts>[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 {
|
||||
|
||||
@@ -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<typeof agentSessionFailureWords>[0]) {
|
||||
const { reason, rejection } = agentSessionFailureWords(fact, { surface: 'rejection' })
|
||||
@@ -28,77 +24,6 @@ function draft(overrides: Partial<AgentSessionQueuedMessage> & { 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'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<AgentSessionQueuedMessage, 'returnedReason' | 'returnedRejection'>,
|
||||
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<AgentJournalSubmission, 'queuedMessageId' | 'dispatchState'>[],
|
||||
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)
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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<TextInput>(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)
|
||||
|
||||
@@ -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({
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={
|
||||
row.kind === 'skill' && row.location
|
||||
? `${row.token}, ${row.location}`
|
||||
: composerMenuInsertText(row)
|
||||
row.kind === 'file'
|
||||
? row.path
|
||||
: row.kind === 'skill' && row.location
|
||||
? `${row.token}, ${row.location}`
|
||||
: row.token
|
||||
}
|
||||
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
||||
onPress={() => onPick(row)}
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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<SlashMenuInput, 'query'>
|
||||
): ReadonlySet<string> {
|
||||
export function mobileComposerSkillTokens(catalog: MobileComposerCatalog): ReadonlySet<string> {
|
||||
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[],
|
||||
|
||||
@@ -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])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<string, Draft>()
|
||||
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<string>, 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
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
)
|
||||
]) {
|
||||
|
||||
@@ -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<Selection | null>(null)
|
||||
const [menuKind, setMenuKind] = useState<MenuKind | null>(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<ComposerMenuRow[]>(() => {
|
||||
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 => {
|
||||
|
||||
+3
-1
@@ -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
|
||||
|
||||
+29
-1
@@ -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 () => {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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<Record<string, never>>
|
||||
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()) {
|
||||
|
||||
+23
-4
@@ -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)
|
||||
|
||||
@@ -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<typeof useMobileNativeChatSkills> | null = null
|
||||
|
||||
async function mount(client: RpcClient, supported: boolean): Promise<void> {
|
||||
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([])
|
||||
})
|
||||
})
|
||||
@@ -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<typeof skillDiscoveryReplySchema>
|
||||
|
||||
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<string, Scan> }>({
|
||||
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<NativeChatSkillDiscoverySnapshot>(() => {
|
||||
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 }
|
||||
}
|
||||
@@ -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<typeof useMobileNativeChatFileSearch> & ReturnType<typeof useMobileNativeChatSkills> {
|
||||
const { client, worktreeId } = scope
|
||||
const files = useMobileNativeChatFileSearch({ client, worktreeId })
|
||||
const skills = useMobileNativeChatSkills({
|
||||
client,
|
||||
worktreeId,
|
||||
agent,
|
||||
supported: scope.agentSessionHostSupport?.discoverWorkspaceSkills === true
|
||||
})
|
||||
return { ...files, ...skills }
|
||||
}
|
||||
@@ -56,10 +56,13 @@ vi.mock('../../../components/ActionSheetModal', () => ({ ActionSheetModal: 'Acti
|
||||
function card(
|
||||
overrides: Partial<MobileQueuedMessageCard> & { 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())
|
||||
|
||||
@@ -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<boolean>
|
||||
pause?: MobileQueuePause
|
||||
pause?: AgentSessionQueuePause | null
|
||||
onResume?: () => Promise<boolean>
|
||||
steerHeld?: boolean
|
||||
}
|
||||
@@ -212,7 +212,7 @@ export function MobileNativeChatQueuedMessages({
|
||||
</View>
|
||||
<ActionSheetModal
|
||||
visible={menuCard !== undefined}
|
||||
title={menuCard?.text}
|
||||
title={menuCard?.line}
|
||||
actions={[
|
||||
{
|
||||
label: 'Edit message',
|
||||
|
||||
@@ -15,10 +15,10 @@ export function getMobileNativeChatCommands(agent: string): readonly SlashComman
|
||||
return getAgentAnsweredNativeChatCommands(agent)
|
||||
}
|
||||
|
||||
// Terminal sends carry no picker-origin override that could reclassify a slash token as chat.
|
||||
export function classifyMobileNativeChatSend(
|
||||
agent: string | null,
|
||||
text: string
|
||||
text: string,
|
||||
pickerSkillOriginToken: string | null = null
|
||||
): NativeChatSendClassification {
|
||||
if (!agent) {
|
||||
return 'chat'
|
||||
@@ -27,7 +27,7 @@ export function classifyMobileNativeChatSend(
|
||||
return classifyNativeChatSend(
|
||||
text,
|
||||
getMobileNativeChatCommands(agent),
|
||||
null,
|
||||
pickerSkillOriginToken,
|
||||
profile?.skillPrefix ?? null
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { applyAppend, replaceList } from '../../../../src/shared/native-chat-merge'
|
||||
import type { NativeChatMerger } 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'
|
||||
|
||||
export type MobileNativeChatStreamFrame = {
|
||||
type?: string
|
||||
@@ -11,6 +14,8 @@ export type MobileNativeChatStreamFrame = {
|
||||
pending?: boolean
|
||||
error?: string
|
||||
message?: string
|
||||
// Provider turn boundary; hosts before #9121 omit it.
|
||||
lifecycle?: NativeChatTurnLifecycle
|
||||
}
|
||||
|
||||
export type AppliedMobileNativeChatFrame =
|
||||
|
||||
@@ -1,283 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { NativeChatMessage } from '../../../../src/shared/native-chat-types'
|
||||
import {
|
||||
createMobileNativeChatStreamingGate,
|
||||
deriveMobileNativeChatStreaming,
|
||||
mobileNativeChatStreamPreview,
|
||||
type MobileNativeChatStreamingGate
|
||||
} from './mobile-native-chat-streaming-gate'
|
||||
|
||||
function assistant(id: string, text: string): NativeChatMessage {
|
||||
return {
|
||||
id,
|
||||
role: 'assistant',
|
||||
blocks: [{ type: 'text', text }],
|
||||
timestamp: 0,
|
||||
source: 'transcript'
|
||||
}
|
||||
}
|
||||
|
||||
function run(ticks: { folded: NativeChatMessage[]; text?: string; live?: boolean }[]): {
|
||||
gate: MobileNativeChatStreamingGate
|
||||
results: (string | null)[]
|
||||
} {
|
||||
let gate = createMobileNativeChatStreamingGate()
|
||||
const results: (string | null)[] = []
|
||||
for (const tick of ticks) {
|
||||
const step = deriveMobileNativeChatStreaming(gate, tick.folded, tick.text, {
|
||||
streamLive: tick.live
|
||||
})
|
||||
gate = step.gate
|
||||
results.push(step.streaming)
|
||||
}
|
||||
return { gate, results }
|
||||
}
|
||||
|
||||
describe('mobileNativeChatStreamPreview', () => {
|
||||
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])
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
+264
@@ -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<readonly [string, NativeChatMessage, string]>([
|
||||
[
|
||||
'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)
|
||||
})
|
||||
})
|
||||
@@ -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<readonly [string, NativeChatMessage, string]>([
|
||||
[
|
||||
'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')
|
||||
})
|
||||
|
||||
@@ -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<NativeChatNoticeTone, string> = {
|
||||
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({
|
||||
</Text>
|
||||
)
|
||||
case 'line': {
|
||||
const lineText = usageLimitLineText(notice)
|
||||
const lineText = noticeLineText(notice, names)
|
||||
const textStyle = [
|
||||
styles.line,
|
||||
scaledType(TEXT_SIZE, fontScale),
|
||||
|
||||
@@ -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' &&
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
@@ -21,7 +21,7 @@ export function useMobileBridgeChatPromptWrites(args: {
|
||||
answerAsk: MobileNativeChatAnswerSend['answerAsk']
|
||||
cancelAsk: () => Promise<boolean>
|
||||
respondPermission: (send: string) => Promise<boolean>
|
||||
stop: () => void
|
||||
stop: () => Promise<boolean>
|
||||
} {
|
||||
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
|
||||
|
||||
@@ -30,11 +30,9 @@ export function useMobileNativeChatActiveResolution(args: {
|
||||
activeChatSessionId: string | null
|
||||
activeChatStructured: boolean
|
||||
activeChatResolution: ReturnType<typeof resolveMobileNativeChat>
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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<string | undefined>(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<number | null>(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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<boolean>) | 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<boolean> | 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()
|
||||
})
|
||||
|
||||
@@ -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<string | null>
|
||||
deviceTokenRef: MutableRefObject<string | null>
|
||||
agentRef: MutableRefObject<string | null>
|
||||
streamIdentity: string
|
||||
cancelPending: () => void
|
||||
onSendError: (message: string) => void
|
||||
}): () => void {
|
||||
const { client, enabled, handleRef, deviceTokenRef, streamIdentity, cancelPending, onSendError } =
|
||||
args
|
||||
}): () => Promise<boolean> {
|
||||
const {
|
||||
client,
|
||||
enabled,
|
||||
handleRef,
|
||||
deviceTokenRef,
|
||||
agentRef,
|
||||
streamIdentity,
|
||||
cancelPending,
|
||||
onSendError
|
||||
} = args
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | 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<boolean>((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,
|
||||
|
||||
@@ -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<MobileNativeChatResolution, 'agent' | 'sessionId'> | null
|
||||
/** The terminal chat's transcript; null for structured chats. */
|
||||
session: Pick<MobileNativeChatSession, 'messages' | 'transcriptLifecycle'> | 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<boolean>
|
||||
}): 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
|
||||
}
|
||||
}
|
||||
@@ -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<boolean> => {
|
||||
@@ -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<AgentSessionQueuedMessageMoveResult>(
|
||||
'agentSession.queuedMessageMove',
|
||||
|
||||
@@ -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()))
|
||||
|
||||
@@ -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: {} })
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<Map<string, StructuredAgentSessionState>>(
|
||||
() => new Map()
|
||||
)
|
||||
const [queuedBySession, setQueuedBySession] = useState<Map<string, QueuedFeed>>(() => 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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<MobileNativeChatSendOutcome>
|
||||
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
|
||||
},
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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<Edit | null>(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<boolean> => {
|
||||
@@ -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<boolean> => {
|
||||
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<AgentSessionQueuedMessageUpdateResult>(
|
||||
'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
|
||||
|
||||
@@ -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<boolean>
|
||||
pause: MobileQueuePause
|
||||
pause: AgentSessionQueuePause | null
|
||||
resume: () => Promise<boolean>
|
||||
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,
|
||||
|
||||
@@ -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<StructuredAgentSessionState, 'items' | 'currentRequests'>
|
||||
queueFollowUps: boolean,
|
||||
state: Pick<StructuredAgentSessionState, 'items' | 'currentRequests' | 'queuedMessages'>
|
||||
): 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<Record<string, string>> | 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<MobileNativeChatSendOutcome> {
|
||||
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<MobileNativeChatSendOutcome> => {
|
||||
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<typeof structuredAgentSessionSendBody>
|
||||
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
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<RpcClient['sendRequest']>()
|
||||
const sendRequest = vi.fn<RpcClient['sendRequest']>(async () => ok({}))
|
||||
const client: RpcClient = {
|
||||
sendRequest,
|
||||
subscribe: vi.fn<RpcClient['subscribe']>((_method, _params, onData) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<string> } | null = null
|
||||
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
})
|
||||
|
||||
function harness(send: () => Promise<string>, 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<string>((resolve) => (settle = resolve))
|
||||
act(() => {
|
||||
renderer = create(harness(send, stopsConversation, 'chat'))
|
||||
})
|
||||
expect(offer?.canStop).toBe(false)
|
||||
|
||||
let sent: Promise<string> | 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<string>(() => {})
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { structuredAgentSessionCanStop } from '../../../src/shared/structured-agent-session-stop-control'
|
||||
|
||||
type Send<Args extends unknown[], Result> = (...args: Args) => Promise<Result>
|
||||
|
||||
export type MobileStructuredSendsOut = {
|
||||
trackSend: <Args extends unknown[], Result>(send: Send<Args, Result>) => Send<Args, Result>
|
||||
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<string, number>())
|
||||
// Re-render only when a chat starts or stops having a send out, not on every send.
|
||||
const [sendingKeys, setSendingKeys] = useState<ReadonlySet<string>>(() => new Set())
|
||||
const trackSend = useCallback(
|
||||
<Args extends unknown[], Result>(send: Send<Args, Result>): Send<Args, Result> =>
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
)
|
||||
}
|
||||
@@ -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<HostProfile | null>(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 (
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -90,16 +90,20 @@ export const newTabSettingsRead = bindDeferredRpcOperation(
|
||||
})
|
||||
)
|
||||
|
||||
const copyTrimsGutterReader: RpcCompatibleReader<unknown, 'copy-trims-gutter', boolean> = (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<Variant extends string>(
|
||||
key: string,
|
||||
variant: Variant
|
||||
): RpcCompatibleReader<unknown, Variant, boolean> {
|
||||
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')
|
||||
})
|
||||
)
|
||||
|
||||
|
||||
@@ -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<HostProfile | null>(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
|
||||
}
|
||||
+1
-1
@@ -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,
|
||||
|
||||
@@ -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<RuntimeFileSearchHost['requireStore']>,
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user