diff --git a/config/scripts/native-chat-live-session-benchmark.ts b/config/scripts/native-chat-live-session-benchmark.ts index 74180423fb3..5323c803e23 100644 --- a/config/scripts/native-chat-live-session-benchmark.ts +++ b/config/scripts/native-chat-live-session-benchmark.ts @@ -7,7 +7,7 @@ import type { NativeChatMessage, NativeChatSession } from '../../src/shared/nati import { getVerifiedNativeChatCommands } from '../../src/shared/native-chat-agent-profiles' import { surfaceSkillInvocationUserTurns } from '../../src/shared/native-chat/native-chat-command-envelope' import { prepareNativeChatLiveMessages } from '../../src/renderer/src/components/native-chat/native-chat-live-message-preparation' -import { mergeNativeChatLiveSession } from '../../src/renderer/src/components/native-chat/native-chat-live-status' +import { mergeNativeChatLiveSession } from '../../src/shared/native-chat/native-chat-live-status' import { assembleNativeChatSession } from '../../src/renderer/src/components/native-chat/native-chat-session-assembler' type Operation = (index: number) => number diff --git a/config/tsconfig.node.json b/config/tsconfig.node.json index ae2325bc2cf..bc9bf87d0d9 100644 --- a/config/tsconfig.node.json +++ b/config/tsconfig.node.json @@ -18,7 +18,6 @@ "../src/renderer/src/components/native-chat/native-chat-resolution-receipt.ts", "../src/renderer/src/components/native-chat/structured-agent-question-projection.ts", "../src/renderer/src/components/native-chat/native-chat-subagent-delegation.ts", - "../src/renderer/src/components/native-chat/structured-agent-session-queued-cards.ts", "../src/renderer/src/components/native-chat/structured-agent-session-pending-sends.ts", "../src/renderer/src/components/native-chat/native-chat-composer-primary-action.ts", "../src/renderer/src/components/terminal-pane/legacy-relay-bridge-panes.ts", diff --git a/mobile/rpc-foundation/goldens/matrix-session.native-chat-stop-orchestration.workerterminaluserinput-1.json b/mobile/rpc-foundation/goldens/matrix-session.native-chat-stop-orchestration.workerterminaluserinput-1.json index ecab8becfb2..d9a97945bc4 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.native-chat-stop-orchestration.workerterminaluserinput-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.native-chat-stop-orchestration.workerterminaluserinput-1.json @@ -4,6 +4,11 @@ "family": "session.native-chat-stop", "namedDeltas": [], "values": { + "0ab481baef7d": { + "name": "terminal.send#2", + "ordinal": 9, + "json": "{\"id\":\"frame-4\",\"deviceToken\":\"recording-device\",\"method\":\"terminal.send\",\"params\":{\"terminal\":\"terminal-1\",\"text\":\"\\u001b\",\"client\":{\"id\":\"device-token-1\",\"type\":\"mobile\"}}}" + }, "23018091278a": { "name": "orchestration.workerTerminalUserInput#1", "ordinal": 4, @@ -37,12 +42,18 @@ } } }, + "3e2d28f3728b": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 1000, + "value": true + }, "5cd1d4a9aed5": { "name": "terminal.send#1", "ordinal": 3, "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"terminal.send\",\"params\":{\"terminal\":\"terminal-1\",\"text\":\"\\u001b\",\"client\":{\"id\":\"device-token-1\",\"type\":\"mobile\"}}}" }, - "650fdd02bab4": { + "5cd6e007c8ca": { "name": "terminal.send#2", "ordinal": 6, "args": [ @@ -65,14 +76,14 @@ "name": "options", "value": { "budgetSpansConnect": true, - "timeoutMs": 14920 + "timeoutMs": 14000 } } ], "settlement": { "status": "fulfilled", - "startedAt": 80, - "settledAt": 120, + "startedAt": 1000, + "settledAt": 1000, "value": { "id": "frame-3", "ok": true, @@ -123,6 +134,48 @@ } } }, + "672e5a827dbd": { + "name": "terminal.send#2", + "ordinal": 8, + "args": [ + { + "name": "method", + "value": "terminal.send" + }, + { + "name": "params", + "value": { + "client": { + "id": "device-token-1", + "type": "mobile" + }, + "terminal": "terminal-1", + "text": "\u001b" + } + }, + { + "name": "options", + "value": { + "budgetSpansConnect": true, + "timeoutMs": 14000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 1000, + "settledAt": 1000, + "value": { + "id": "frame-4", + "ok": true, + "result": { + "send": { + "accepted": true + } + } + } + } + }, "6d6078dd8a8f": { "name": "orchestration.workerTerminalUserInput#1", "ordinal": 4, @@ -164,6 +217,34 @@ "ordinal": 1, "value": {} }, + "8699d16b8581": { + "name": "orchestration.workerTerminalUserInput#2", + "ordinal": 6, + "args": [ + { + "name": "method", + "value": "orchestration.workerTerminalUserInput" + }, + { + "name": "params", + "value": { + "terminal": "terminal-1" + } + }, + { + "name": "options", + "value": { + "budgetSpansConnect": true, + "failWhenDisconnected": true, + "timeoutMs": 5000 + } + } + ], + "settlement": { + "status": "pending", + "startedAt": 250 + } + }, "8c21e926b759": { "name": "orchestration.workerTerminalUserInput#1", "ordinal": 4, @@ -200,6 +281,10 @@ } } }, + "9270aeb7d9c6": { + "status": "pending", + "startedAt": 0 + }, "ab0f3d65723e": { "name": "terminal.send#1", "ordinal": 2, @@ -310,6 +395,11 @@ } } }, + "b81e2536b6b7": { + "name": "orchestration.workerTerminalUserInput#2", + "ordinal": 7, + "json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"orchestration.workerTerminalUserInput\",\"params\":{\"terminal\":\"terminal-1\"}}" + }, "c3dd831302ac": { "name": "orchestration.workerTerminalUserInput#1", "ordinal": 4, @@ -461,14 +551,6 @@ "debf84af8d66": { "errors": [] }, - "eb79a9b3682a": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "$rpc": "undefined" - } - }, "ef422fa31f61": { "name": "terminal.send#2", "ordinal": 7, @@ -525,7 +607,7 @@ "sender": ["ab0f3d65723e", "fcdecc63b46e"], "payloads": ["5cd1d4a9aed5", "ef7005279d33"], "settlements": { - "stop": "eb79a9b3682a" + "stop": "9270aeb7d9c6" }, "state": "debf84af8d66", "effects": ["75263c07c807"] @@ -534,10 +616,10 @@ { "id": "native-chat-stop-accepted.normal:settled", "observation": { - "sender": ["ab0f3d65723e", "fcdecc63b46e", "650fdd02bab4"], + "sender": ["ab0f3d65723e", "fcdecc63b46e", "5cd6e007c8ca"], "payloads": ["5cd1d4a9aed5", "ef7005279d33", "ef422fa31f61"], "settlements": { - "stop": "eb79a9b3682a" + "stop": "3e2d28f3728b" }, "state": "debf84af8d66", "effects": ["75263c07c807"] @@ -549,7 +631,7 @@ "sender": ["ab0f3d65723e", "23018091278a"], "payloads": ["5cd1d4a9aed5", "ef7005279d33"], "settlements": { - "stop": "eb79a9b3682a" + "stop": "9270aeb7d9c6" }, "state": "debf84af8d66", "effects": ["75263c07c807"] @@ -558,10 +640,10 @@ { "id": "native-chat-stop-accepted.result-absent:settled", "observation": { - "sender": ["ab0f3d65723e", "23018091278a", "650fdd02bab4"], + "sender": ["ab0f3d65723e", "23018091278a", "5cd6e007c8ca"], "payloads": ["5cd1d4a9aed5", "ef7005279d33", "ef422fa31f61"], "settlements": { - "stop": "eb79a9b3682a" + "stop": "3e2d28f3728b" }, "state": "debf84af8d66", "effects": ["75263c07c807"] @@ -573,7 +655,7 @@ "sender": ["ab0f3d65723e", "6d6078dd8a8f"], "payloads": ["5cd1d4a9aed5", "ef7005279d33"], "settlements": { - "stop": "eb79a9b3682a" + "stop": "9270aeb7d9c6" }, "state": "debf84af8d66", "effects": ["75263c07c807"] @@ -582,10 +664,10 @@ { "id": "native-chat-stop-accepted.result-null:settled", "observation": { - "sender": ["ab0f3d65723e", "6d6078dd8a8f", "650fdd02bab4"], + "sender": ["ab0f3d65723e", "6d6078dd8a8f", "5cd6e007c8ca"], "payloads": ["5cd1d4a9aed5", "ef7005279d33", "ef422fa31f61"], "settlements": { - "stop": "eb79a9b3682a" + "stop": "3e2d28f3728b" }, "state": "debf84af8d66", "effects": ["75263c07c807"] @@ -597,7 +679,7 @@ "sender": ["ab0f3d65723e", "8c21e926b759"], "payloads": ["5cd1d4a9aed5", "ef7005279d33"], "settlements": { - "stop": "eb79a9b3682a" + "stop": "9270aeb7d9c6" }, "state": "debf84af8d66", "effects": ["75263c07c807"] @@ -606,10 +688,10 @@ { "id": "native-chat-stop-accepted.inner-ok-missing:settled", "observation": { - "sender": ["ab0f3d65723e", "8c21e926b759", "650fdd02bab4"], + "sender": ["ab0f3d65723e", "8c21e926b759", "5cd6e007c8ca"], "payloads": ["5cd1d4a9aed5", "ef7005279d33", "ef422fa31f61"], "settlements": { - "stop": "eb79a9b3682a" + "stop": "3e2d28f3728b" }, "state": "debf84af8d66", "effects": ["75263c07c807"] @@ -621,7 +703,7 @@ "sender": ["ab0f3d65723e", "d8fa0931ea72"], "payloads": ["5cd1d4a9aed5", "ef7005279d33"], "settlements": { - "stop": "eb79a9b3682a" + "stop": "9270aeb7d9c6" }, "state": "debf84af8d66", "effects": ["75263c07c807"] @@ -630,10 +712,10 @@ { "id": "native-chat-stop-accepted.inner-false-string-error:settled", "observation": { - "sender": ["ab0f3d65723e", "d8fa0931ea72", "650fdd02bab4"], + "sender": ["ab0f3d65723e", "d8fa0931ea72", "5cd6e007c8ca"], "payloads": ["5cd1d4a9aed5", "ef7005279d33", "ef422fa31f61"], "settlements": { - "stop": "eb79a9b3682a" + "stop": "3e2d28f3728b" }, "state": "debf84af8d66", "effects": ["75263c07c807"] @@ -645,7 +727,7 @@ "sender": ["ab0f3d65723e", "662c347cb071"], "payloads": ["5cd1d4a9aed5", "ef7005279d33"], "settlements": { - "stop": "eb79a9b3682a" + "stop": "9270aeb7d9c6" }, "state": "debf84af8d66", "effects": ["75263c07c807"] @@ -654,10 +736,10 @@ { "id": "native-chat-stop-accepted.inner-false-object-error:settled", "observation": { - "sender": ["ab0f3d65723e", "662c347cb071", "650fdd02bab4"], + "sender": ["ab0f3d65723e", "662c347cb071", "5cd6e007c8ca"], "payloads": ["5cd1d4a9aed5", "ef7005279d33", "ef422fa31f61"], "settlements": { - "stop": "eb79a9b3682a" + "stop": "3e2d28f3728b" }, "state": "debf84af8d66", "effects": ["75263c07c807"] @@ -669,7 +751,7 @@ "sender": ["ab0f3d65723e", "da27d1e6c936"], "payloads": ["5cd1d4a9aed5", "ef7005279d33"], "settlements": { - "stop": "eb79a9b3682a" + "stop": "9270aeb7d9c6" }, "state": "debf84af8d66", "effects": ["75263c07c807"] @@ -678,10 +760,10 @@ { "id": "native-chat-stop-accepted.outer-refused:settled", "observation": { - "sender": ["ab0f3d65723e", "da27d1e6c936", "650fdd02bab4"], - "payloads": ["5cd1d4a9aed5", "ef7005279d33", "ef422fa31f61"], + "sender": ["ab0f3d65723e", "da27d1e6c936", "8699d16b8581", "672e5a827dbd"], + "payloads": ["5cd1d4a9aed5", "ef7005279d33", "b81e2536b6b7", "0ab481baef7d"], "settlements": { - "stop": "eb79a9b3682a" + "stop": "3e2d28f3728b" }, "state": "debf84af8d66", "effects": ["75263c07c807"] @@ -693,7 +775,7 @@ "sender": ["ab0f3d65723e", "c3dd831302ac"], "payloads": ["5cd1d4a9aed5", "ef7005279d33"], "settlements": { - "stop": "eb79a9b3682a" + "stop": "9270aeb7d9c6" }, "state": "debf84af8d66", "effects": ["75263c07c807"] @@ -702,10 +784,10 @@ { "id": "native-chat-stop-accepted.outer-refused-no-message:settled", "observation": { - "sender": ["ab0f3d65723e", "c3dd831302ac", "650fdd02bab4"], - "payloads": ["5cd1d4a9aed5", "ef7005279d33", "ef422fa31f61"], + "sender": ["ab0f3d65723e", "c3dd831302ac", "8699d16b8581", "672e5a827dbd"], + "payloads": ["5cd1d4a9aed5", "ef7005279d33", "b81e2536b6b7", "0ab481baef7d"], "settlements": { - "stop": "eb79a9b3682a" + "stop": "3e2d28f3728b" }, "state": "debf84af8d66", "effects": ["75263c07c807"] @@ -717,7 +799,7 @@ "sender": ["ab0f3d65723e", "dd4ba833e8b5"], "payloads": ["5cd1d4a9aed5", "ef7005279d33"], "settlements": { - "stop": "eb79a9b3682a" + "stop": "9270aeb7d9c6" }, "state": "debf84af8d66", "effects": ["75263c07c807"] @@ -726,10 +808,10 @@ { "id": "native-chat-stop-accepted.method-not-found:settled", "observation": { - "sender": ["ab0f3d65723e", "dd4ba833e8b5", "650fdd02bab4"], - "payloads": ["5cd1d4a9aed5", "ef7005279d33", "ef422fa31f61"], + "sender": ["ab0f3d65723e", "dd4ba833e8b5", "8699d16b8581", "672e5a827dbd"], + "payloads": ["5cd1d4a9aed5", "ef7005279d33", "b81e2536b6b7", "0ab481baef7d"], "settlements": { - "stop": "eb79a9b3682a" + "stop": "3e2d28f3728b" }, "state": "debf84af8d66", "effects": ["75263c07c807"] @@ -741,7 +823,7 @@ "sender": ["ab0f3d65723e", "b59f001bf55b"], "payloads": ["5cd1d4a9aed5", "ef7005279d33"], "settlements": { - "stop": "eb79a9b3682a" + "stop": "9270aeb7d9c6" }, "state": "debf84af8d66", "effects": ["75263c07c807"] @@ -750,10 +832,10 @@ { "id": "native-chat-stop-accepted.transport-rejection:settled", "observation": { - "sender": ["ab0f3d65723e", "b59f001bf55b", "650fdd02bab4"], - "payloads": ["5cd1d4a9aed5", "ef7005279d33", "ef422fa31f61"], + "sender": ["ab0f3d65723e", "b59f001bf55b", "8699d16b8581", "672e5a827dbd"], + "payloads": ["5cd1d4a9aed5", "ef7005279d33", "b81e2536b6b7", "0ab481baef7d"], "settlements": { - "stop": "eb79a9b3682a" + "stop": "3e2d28f3728b" }, "state": "debf84af8d66", "effects": ["75263c07c807"] @@ -765,7 +847,7 @@ "sender": ["ab0f3d65723e", "ad3d324fcb52"], "payloads": ["5cd1d4a9aed5", "ef7005279d33"], "settlements": { - "stop": "eb79a9b3682a" + "stop": "9270aeb7d9c6" }, "state": "debf84af8d66", "effects": ["75263c07c807"] @@ -774,10 +856,10 @@ { "id": "native-chat-stop-accepted.transport-rejection-no-message:settled", "observation": { - "sender": ["ab0f3d65723e", "ad3d324fcb52", "650fdd02bab4"], - "payloads": ["5cd1d4a9aed5", "ef7005279d33", "ef422fa31f61"], + "sender": ["ab0f3d65723e", "ad3d324fcb52", "8699d16b8581", "672e5a827dbd"], + "payloads": ["5cd1d4a9aed5", "ef7005279d33", "b81e2536b6b7", "0ab481baef7d"], "settlements": { - "stop": "eb79a9b3682a" + "stop": "3e2d28f3728b" }, "state": "debf84af8d66", "effects": ["75263c07c807"] diff --git a/mobile/rpc-foundation/goldens/matrix-session.native-chat-stop-terminal.send-1.json b/mobile/rpc-foundation/goldens/matrix-session.native-chat-stop-terminal.send-1.json index 9008a996265..53a1fcb3a0c 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.native-chat-stop-terminal.send-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.native-chat-stop-terminal.send-1.json @@ -82,47 +82,11 @@ } } }, - "44045fc21677": { - "name": "terminal.send#2", - "ordinal": 4, - "args": [ - { - "name": "method", - "value": "terminal.send" - }, - { - "name": "params", - "value": { - "client": { - "id": "device-token-1", - "type": "mobile" - }, - "terminal": "terminal-1", - "text": "\u001b" - } - }, - { - "name": "options", - "value": { - "budgetSpansConnect": true, - "timeoutMs": 14920 - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 80, - "settledAt": 120, - "value": { - "id": "frame-2", - "ok": true, - "result": { - "send": { - "accepted": true - } - } - } - } + "3e2d28f3728b": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 1000, + "value": true }, "4b3ad1f19363": { "name": "terminal.send#1", @@ -250,7 +214,7 @@ "ordinal": 3, "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"terminal.send\",\"params\":{\"terminal\":\"terminal-1\",\"text\":\"\\u001b\",\"client\":{\"id\":\"device-token-1\",\"type\":\"mobile\"}}}" }, - "650fdd02bab4": { + "5cd6e007c8ca": { "name": "terminal.send#2", "ordinal": 6, "args": [ @@ -273,14 +237,14 @@ "name": "options", "value": { "budgetSpansConnect": true, - "timeoutMs": 14920 + "timeoutMs": 14000 } } ], "settlement": { "status": "fulfilled", - "startedAt": 80, - "settledAt": 120, + "startedAt": 1000, + "settledAt": 1000, "value": { "id": "frame-3", "ok": true, @@ -338,6 +302,38 @@ "ordinal": 1, "value": {} }, + "77b1709caa79": { + "name": "orchestration.workerTerminalUserInput#1", + "ordinal": 6, + "args": [ + { + "name": "method", + "value": "orchestration.workerTerminalUserInput" + }, + { + "name": "params", + "value": { + "terminal": "terminal-1" + } + }, + { + "name": "options", + "value": { + "budgetSpansConnect": true, + "failWhenDisconnected": true, + "timeoutMs": 5000 + } + } + ], + "settlement": { + "status": "pending", + "startedAt": 1000 + } + }, + "9270aeb7d9c6": { + "status": "pending", + "startedAt": 0 + }, "ab0f3d65723e": { "name": "terminal.send#1", "ordinal": 2, @@ -464,34 +460,6 @@ } } }, - "c7702de4a9d5": { - "name": "orchestration.workerTerminalUserInput#1", - "ordinal": 6, - "args": [ - { - "name": "method", - "value": "orchestration.workerTerminalUserInput" - }, - { - "name": "params", - "value": { - "terminal": "terminal-1" - } - }, - { - "name": "options", - "value": { - "budgetSpansConnect": true, - "failWhenDisconnected": true, - "timeoutMs": 5000 - } - } - ], - "settlement": { - "status": "pending", - "startedAt": 120 - } - }, "cfe4beb4c8c9": { "name": "terminal.send#1", "ordinal": 2, @@ -540,6 +508,48 @@ "ordinal": 5, "json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"terminal.send\",\"params\":{\"terminal\":\"terminal-1\",\"text\":\"\\u001b\",\"client\":{\"id\":\"device-token-1\",\"type\":\"mobile\"}}}" }, + "da95ce195845": { + "name": "terminal.send#2", + "ordinal": 4, + "args": [ + { + "name": "method", + "value": "terminal.send" + }, + { + "name": "params", + "value": { + "client": { + "id": "device-token-1", + "type": "mobile" + }, + "terminal": "terminal-1", + "text": "\u001b" + } + }, + { + "name": "options", + "value": { + "budgetSpansConnect": true, + "timeoutMs": 14000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 1000, + "settledAt": 1000, + "value": { + "id": "frame-2", + "ok": true, + "result": { + "send": { + "accepted": true + } + } + } + } + }, "debf84af8d66": { "errors": [] }, @@ -581,14 +591,6 @@ } } }, - "eb79a9b3682a": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "$rpc": "undefined" - } - }, "ef422fa31f61": { "name": "terminal.send#2", "ordinal": 7, @@ -645,7 +647,7 @@ "sender": ["ab0f3d65723e", "fcdecc63b46e"], "payloads": ["5cd1d4a9aed5", "ef7005279d33"], "settlements": { - "stop": "eb79a9b3682a" + "stop": "9270aeb7d9c6" }, "state": "debf84af8d66", "effects": ["75263c07c807"] @@ -654,10 +656,10 @@ { "id": "native-chat-stop-accepted.normal:settled", "observation": { - "sender": ["ab0f3d65723e", "fcdecc63b46e", "650fdd02bab4"], + "sender": ["ab0f3d65723e", "fcdecc63b46e", "5cd6e007c8ca"], "payloads": ["5cd1d4a9aed5", "ef7005279d33", "ef422fa31f61"], "settlements": { - "stop": "eb79a9b3682a" + "stop": "3e2d28f3728b" }, "state": "debf84af8d66", "effects": ["75263c07c807"] @@ -669,7 +671,7 @@ "sender": ["1a7f859d2c49"], "payloads": ["5cd1d4a9aed5"], "settlements": { - "stop": "eb79a9b3682a" + "stop": "9270aeb7d9c6" }, "state": "debf84af8d66", "effects": ["75263c07c807"] @@ -678,10 +680,10 @@ { "id": "native-chat-stop-accepted.result-absent:settled", "observation": { - "sender": ["1a7f859d2c49", "44045fc21677", "c7702de4a9d5"], + "sender": ["1a7f859d2c49", "da95ce195845", "77b1709caa79"], "payloads": ["5cd1d4a9aed5", "d5972c43f562", "b524bdde3f98"], "settlements": { - "stop": "eb79a9b3682a" + "stop": "3e2d28f3728b" }, "state": "debf84af8d66", "effects": ["75263c07c807"] @@ -693,7 +695,7 @@ "sender": ["4c712df25964"], "payloads": ["5cd1d4a9aed5"], "settlements": { - "stop": "eb79a9b3682a" + "stop": "9270aeb7d9c6" }, "state": "debf84af8d66", "effects": ["75263c07c807"] @@ -702,10 +704,10 @@ { "id": "native-chat-stop-accepted.result-null:settled", "observation": { - "sender": ["4c712df25964", "44045fc21677", "c7702de4a9d5"], + "sender": ["4c712df25964", "da95ce195845", "77b1709caa79"], "payloads": ["5cd1d4a9aed5", "d5972c43f562", "b524bdde3f98"], "settlements": { - "stop": "eb79a9b3682a" + "stop": "3e2d28f3728b" }, "state": "debf84af8d66", "effects": ["75263c07c807"] @@ -717,7 +719,7 @@ "sender": ["4b3ad1f19363"], "payloads": ["5cd1d4a9aed5"], "settlements": { - "stop": "eb79a9b3682a" + "stop": "9270aeb7d9c6" }, "state": "debf84af8d66", "effects": ["75263c07c807"] @@ -726,10 +728,10 @@ { "id": "native-chat-stop-accepted.inner-ok-missing:settled", "observation": { - "sender": ["4b3ad1f19363", "44045fc21677", "c7702de4a9d5"], + "sender": ["4b3ad1f19363", "da95ce195845", "77b1709caa79"], "payloads": ["5cd1d4a9aed5", "d5972c43f562", "b524bdde3f98"], "settlements": { - "stop": "eb79a9b3682a" + "stop": "3e2d28f3728b" }, "state": "debf84af8d66", "effects": ["75263c07c807"] @@ -741,7 +743,7 @@ "sender": ["4db98b98a612"], "payloads": ["5cd1d4a9aed5"], "settlements": { - "stop": "eb79a9b3682a" + "stop": "9270aeb7d9c6" }, "state": "debf84af8d66", "effects": ["75263c07c807"] @@ -750,10 +752,10 @@ { "id": "native-chat-stop-accepted.inner-false-string-error:settled", "observation": { - "sender": ["4db98b98a612", "44045fc21677", "c7702de4a9d5"], + "sender": ["4db98b98a612", "da95ce195845", "77b1709caa79"], "payloads": ["5cd1d4a9aed5", "d5972c43f562", "b524bdde3f98"], "settlements": { - "stop": "eb79a9b3682a" + "stop": "3e2d28f3728b" }, "state": "debf84af8d66", "effects": ["75263c07c807"] @@ -765,7 +767,7 @@ "sender": ["cfe4beb4c8c9"], "payloads": ["5cd1d4a9aed5"], "settlements": { - "stop": "eb79a9b3682a" + "stop": "9270aeb7d9c6" }, "state": "debf84af8d66", "effects": ["75263c07c807"] @@ -774,10 +776,10 @@ { "id": "native-chat-stop-accepted.inner-false-object-error:settled", "observation": { - "sender": ["cfe4beb4c8c9", "44045fc21677", "c7702de4a9d5"], + "sender": ["cfe4beb4c8c9", "da95ce195845", "77b1709caa79"], "payloads": ["5cd1d4a9aed5", "d5972c43f562", "b524bdde3f98"], "settlements": { - "stop": "eb79a9b3682a" + "stop": "3e2d28f3728b" }, "state": "debf84af8d66", "effects": ["75263c07c807"] @@ -789,7 +791,7 @@ "sender": ["6669eafe604b"], "payloads": ["5cd1d4a9aed5"], "settlements": { - "stop": "eb79a9b3682a" + "stop": "9270aeb7d9c6" }, "state": "debf84af8d66", "effects": ["75263c07c807"] @@ -798,10 +800,10 @@ { "id": "native-chat-stop-accepted.outer-refused:settled", "observation": { - "sender": ["6669eafe604b", "44045fc21677", "c7702de4a9d5"], + "sender": ["6669eafe604b", "da95ce195845", "77b1709caa79"], "payloads": ["5cd1d4a9aed5", "d5972c43f562", "b524bdde3f98"], "settlements": { - "stop": "eb79a9b3682a" + "stop": "3e2d28f3728b" }, "state": "debf84af8d66", "effects": ["75263c07c807"] @@ -813,7 +815,7 @@ "sender": ["08f063891b6e"], "payloads": ["5cd1d4a9aed5"], "settlements": { - "stop": "eb79a9b3682a" + "stop": "9270aeb7d9c6" }, "state": "debf84af8d66", "effects": ["75263c07c807"] @@ -822,10 +824,10 @@ { "id": "native-chat-stop-accepted.outer-refused-no-message:settled", "observation": { - "sender": ["08f063891b6e", "44045fc21677", "c7702de4a9d5"], + "sender": ["08f063891b6e", "da95ce195845", "77b1709caa79"], "payloads": ["5cd1d4a9aed5", "d5972c43f562", "b524bdde3f98"], "settlements": { - "stop": "eb79a9b3682a" + "stop": "3e2d28f3728b" }, "state": "debf84af8d66", "effects": ["75263c07c807"] @@ -837,7 +839,7 @@ "sender": ["bbb8c9d51c2a"], "payloads": ["5cd1d4a9aed5"], "settlements": { - "stop": "eb79a9b3682a" + "stop": "9270aeb7d9c6" }, "state": "debf84af8d66", "effects": ["75263c07c807"] @@ -846,10 +848,10 @@ { "id": "native-chat-stop-accepted.method-not-found:settled", "observation": { - "sender": ["bbb8c9d51c2a", "44045fc21677", "c7702de4a9d5"], + "sender": ["bbb8c9d51c2a", "da95ce195845", "77b1709caa79"], "payloads": ["5cd1d4a9aed5", "d5972c43f562", "b524bdde3f98"], "settlements": { - "stop": "eb79a9b3682a" + "stop": "3e2d28f3728b" }, "state": "debf84af8d66", "effects": ["75263c07c807"] @@ -861,7 +863,7 @@ "sender": ["e218d5d3a813"], "payloads": ["5cd1d4a9aed5"], "settlements": { - "stop": "eb79a9b3682a" + "stop": "9270aeb7d9c6" }, "state": "debf84af8d66", "effects": ["75263c07c807"] @@ -870,10 +872,10 @@ { "id": "native-chat-stop-accepted.transport-rejection:settled", "observation": { - "sender": ["e218d5d3a813", "44045fc21677", "c7702de4a9d5"], + "sender": ["e218d5d3a813", "da95ce195845", "77b1709caa79"], "payloads": ["5cd1d4a9aed5", "d5972c43f562", "b524bdde3f98"], "settlements": { - "stop": "eb79a9b3682a" + "stop": "3e2d28f3728b" }, "state": "debf84af8d66", "effects": ["75263c07c807"] @@ -885,7 +887,7 @@ "sender": ["b406684d86f2"], "payloads": ["5cd1d4a9aed5"], "settlements": { - "stop": "eb79a9b3682a" + "stop": "9270aeb7d9c6" }, "state": "debf84af8d66", "effects": ["75263c07c807"] @@ -894,10 +896,10 @@ { "id": "native-chat-stop-accepted.transport-rejection-no-message:settled", "observation": { - "sender": ["b406684d86f2", "44045fc21677", "c7702de4a9d5"], + "sender": ["b406684d86f2", "da95ce195845", "77b1709caa79"], "payloads": ["5cd1d4a9aed5", "d5972c43f562", "b524bdde3f98"], "settlements": { - "stop": "eb79a9b3682a" + "stop": "3e2d28f3728b" }, "state": "debf84af8d66", "effects": ["75263c07c807"] diff --git a/mobile/rpc-foundation/goldens/matrix-session.native-chat-stop-terminal.send-2.json b/mobile/rpc-foundation/goldens/matrix-session.native-chat-stop-terminal.send-2.json index 64ae9b3e45e..11273b00bd8 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.native-chat-stop-terminal.send-2.json +++ b/mobile/rpc-foundation/goldens/matrix-session.native-chat-stop-terminal.send-2.json @@ -4,7 +4,7 @@ "family": "session.native-chat-stop", "namedDeltas": [], "values": { - "042114cacf6b": { + "2b73ac4adae4": { "name": "terminal.send#2", "ordinal": 6, "args": [ @@ -27,92 +27,14 @@ "name": "options", "value": { "budgetSpansConnect": true, - "timeoutMs": 14920 + "timeoutMs": 14000 } } ], "settlement": { "status": "fulfilled", - "startedAt": 80, - "settledAt": 120, - "value": { - "id": "frame-3", - "ok": true - } - } - }, - "23fd4ec9cdb3": { - "name": "terminal.send#2", - "ordinal": 6, - "args": [ - { - "name": "method", - "value": "terminal.send" - }, - { - "name": "params", - "value": { - "client": { - "id": "device-token-1", - "type": "mobile" - }, - "terminal": "terminal-1", - "text": "\u001b" - } - }, - { - "name": "options", - "value": { - "budgetSpansConnect": true, - "timeoutMs": 14920 - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 80, - "settledAt": 120, - "value": { - "error": { - "code": "refused", - "message": "" - }, - "id": "frame-3", - "ok": false - } - } - }, - "3bfec3b63b7a": { - "name": "terminal.send#2", - "ordinal": 6, - "args": [ - { - "name": "method", - "value": "terminal.send" - }, - { - "name": "params", - "value": { - "client": { - "id": "device-token-1", - "type": "mobile" - }, - "terminal": "terminal-1", - "text": "\u001b" - } - }, - { - "name": "options", - "value": { - "budgetSpansConnect": true, - "timeoutMs": 14920 - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 80, - "settledAt": 120, + "startedAt": 1000, + "settledAt": 1000, "value": { "id": "frame-3", "ok": true, @@ -122,7 +44,7 @@ } } }, - "4946d9c85c20": { + "2d7a0d50473b": { "name": "terminal.send#2", "ordinal": 6, "args": [ @@ -145,24 +67,25 @@ "name": "options", "value": { "budgetSpansConnect": true, - "timeoutMs": 14920 + "timeoutMs": 14000 } } ], "settlement": { "status": "fulfilled", - "startedAt": 80, - "settledAt": 120, + "startedAt": 1000, + "settledAt": 1000, "value": { + "error": { + "code": "method_not_found", + "message": "Unknown method" + }, "id": "frame-3", - "ok": true, - "result": { - "$rpc": "null" - } + "ok": false } } }, - "5087fb12a234": { + "3c186b43b6de": { "name": "terminal.send#2", "ordinal": 6, "args": [ @@ -185,21 +108,65 @@ "name": "options", "value": { "budgetSpansConnect": true, - "timeoutMs": 14920 + "timeoutMs": 14000 + } + } + ], + "settlement": { + "status": "rejected", + "startedAt": 1000, + "settledAt": 1000, + "error": { + "category": "Error", + "message": "", + "isRpcDeliveryUnknown": true + } + } + }, + "3e2d28f3728b": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 1000, + "value": true + }, + "4e8a6d1de561": { + "name": "terminal.send#2", + "ordinal": 6, + "args": [ + { + "name": "method", + "value": "terminal.send" + }, + { + "name": "params", + "value": { + "client": { + "id": "device-token-1", + "type": "mobile" + }, + "terminal": "terminal-1", + "text": "\u001b" + } + }, + { + "name": "options", + "value": { + "budgetSpansConnect": true, + "timeoutMs": 14000 } } ], "settlement": { "status": "fulfilled", - "startedAt": 80, - "settledAt": 120, + "startedAt": 1000, + "settledAt": 1000, "value": { + "error": { + "code": "refused", + "message": "outer refused" + }, "id": "frame-3", - "ok": true, - "result": { - "error": "inner refused", - "ok": false - } + "ok": false } } }, @@ -208,7 +175,7 @@ "ordinal": 3, "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"terminal.send\",\"params\":{\"terminal\":\"terminal-1\",\"text\":\"\\u001b\",\"client\":{\"id\":\"device-token-1\",\"type\":\"mobile\"}}}" }, - "650fdd02bab4": { + "5cd6e007c8ca": { "name": "terminal.send#2", "ordinal": 6, "args": [ @@ -231,14 +198,14 @@ "name": "options", "value": { "budgetSpansConnect": true, - "timeoutMs": 14920 + "timeoutMs": 14000 } } ], "settlement": { "status": "fulfilled", - "startedAt": 80, - "settledAt": 120, + "startedAt": 1000, + "settledAt": 1000, "value": { "id": "frame-3", "ok": true, @@ -250,89 +217,14 @@ } } }, - "70c7b87bd776": { - "name": "terminal.send#2", - "ordinal": 6, - "args": [ - { - "name": "method", - "value": "terminal.send" - }, - { - "name": "params", - "value": { - "client": { - "id": "device-token-1", - "type": "mobile" - }, - "terminal": "terminal-1", - "text": "\u001b" - } - }, - { - "name": "options", - "value": { - "budgetSpansConnect": true, - "timeoutMs": 14920 - } - } - ], - "settlement": { - "status": "rejected", - "startedAt": 80, - "settledAt": 120, - "error": { - "category": "Error", - "message": "transport failure", - "isRpcDeliveryUnknown": true - } - } - }, "75263c07c807": { "name": "cancel-pending", "ordinal": 1, "value": {} }, - "a5a6027c8212": { - "name": "terminal.send#2", - "ordinal": 6, - "args": [ - { - "name": "method", - "value": "terminal.send" - }, - { - "name": "params", - "value": { - "client": { - "id": "device-token-1", - "type": "mobile" - }, - "terminal": "terminal-1", - "text": "\u001b" - } - }, - { - "name": "options", - "value": { - "budgetSpansConnect": true, - "timeoutMs": 14920 - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 80, - "settledAt": 120, - "value": { - "error": { - "code": "method_not_found", - "message": "Unknown method" - }, - "id": "frame-3", - "ok": false - } - } + "9270aeb7d9c6": { + "status": "pending", + "startedAt": 0 }, "ab0f3d65723e": { "name": "terminal.send#1", @@ -376,7 +268,7 @@ } } }, - "bb85eef75ee8": { + "c3f35523d970": { "name": "terminal.send#2", "ordinal": 6, "args": [ @@ -399,14 +291,136 @@ "name": "options", "value": { "budgetSpansConnect": true, - "timeoutMs": 14920 + "timeoutMs": 14000 } } ], "settlement": { "status": "fulfilled", - "startedAt": 80, - "settledAt": 120, + "startedAt": 1000, + "settledAt": 1000, + "value": { + "error": { + "code": "refused", + "message": "" + }, + "id": "frame-3", + "ok": false + } + } + }, + "d8f0e66070a6": { + "name": "terminal.send#2", + "ordinal": 6, + "args": [ + { + "name": "method", + "value": "terminal.send" + }, + { + "name": "params", + "value": { + "client": { + "id": "device-token-1", + "type": "mobile" + }, + "terminal": "terminal-1", + "text": "\u001b" + } + }, + { + "name": "options", + "value": { + "budgetSpansConnect": true, + "timeoutMs": 14000 + } + } + ], + "settlement": { + "status": "rejected", + "startedAt": 1000, + "settledAt": 1000, + "error": { + "category": "Error", + "message": "transport failure", + "isRpcDeliveryUnknown": true + } + } + }, + "debf84af8d66": { + "errors": [] + }, + "e179d88224b1": { + "name": "terminal.send#2", + "ordinal": 6, + "args": [ + { + "name": "method", + "value": "terminal.send" + }, + { + "name": "params", + "value": { + "client": { + "id": "device-token-1", + "type": "mobile" + }, + "terminal": "terminal-1", + "text": "\u001b" + } + }, + { + "name": "options", + "value": { + "budgetSpansConnect": true, + "timeoutMs": 14000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 1000, + "settledAt": 1000, + "value": { + "id": "frame-3", + "ok": true, + "result": { + "$rpc": "null" + } + } + } + }, + "e46d1bc83f99": { + "name": "terminal.send#2", + "ordinal": 6, + "args": [ + { + "name": "method", + "value": "terminal.send" + }, + { + "name": "params", + "value": { + "client": { + "id": "device-token-1", + "type": "mobile" + }, + "terminal": "terminal-1", + "text": "\u001b" + } + }, + { + "name": "options", + "value": { + "budgetSpansConnect": true, + "timeoutMs": 14000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 1000, + "settledAt": 1000, "value": { "id": "frame-3", "ok": true, @@ -419,7 +433,7 @@ } } }, - "de2c3a23d630": { + "e72c54dda684": { "name": "terminal.send#2", "ordinal": 6, "args": [ @@ -442,33 +456,59 @@ "name": "options", "value": { "budgetSpansConnect": true, - "timeoutMs": 14920 + "timeoutMs": 14000 } } ], "settlement": { "status": "fulfilled", - "startedAt": 80, - "settledAt": 120, + "startedAt": 1000, + "settledAt": 1000, "value": { - "error": { - "code": "refused", - "message": "outer refused" - }, "id": "frame-3", - "ok": false + "ok": true, + "result": { + "error": "inner refused", + "ok": false + } } } }, - "debf84af8d66": { - "errors": [] - }, - "eb79a9b3682a": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "$rpc": "undefined" + "e8dc704e9d67": { + "name": "terminal.send#2", + "ordinal": 6, + "args": [ + { + "name": "method", + "value": "terminal.send" + }, + { + "name": "params", + "value": { + "client": { + "id": "device-token-1", + "type": "mobile" + }, + "terminal": "terminal-1", + "text": "\u001b" + } + }, + { + "name": "options", + "value": { + "budgetSpansConnect": true, + "timeoutMs": 14000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 1000, + "settledAt": 1000, + "value": { + "id": "frame-3", + "ok": true + } } }, "ef422fa31f61": { @@ -516,44 +556,6 @@ } } } - }, - "fe6add283d6d": { - "name": "terminal.send#2", - "ordinal": 6, - "args": [ - { - "name": "method", - "value": "terminal.send" - }, - { - "name": "params", - "value": { - "client": { - "id": "device-token-1", - "type": "mobile" - }, - "terminal": "terminal-1", - "text": "\u001b" - } - }, - { - "name": "options", - "value": { - "budgetSpansConnect": true, - "timeoutMs": 14920 - } - } - ], - "settlement": { - "status": "rejected", - "startedAt": 80, - "settledAt": 120, - "error": { - "category": "Error", - "message": "", - "isRpcDeliveryUnknown": true - } - } } }, "recording": { @@ -565,7 +567,7 @@ "sender": ["ab0f3d65723e", "fcdecc63b46e"], "payloads": ["5cd1d4a9aed5", "ef7005279d33"], "settlements": { - "stop": "eb79a9b3682a" + "stop": "9270aeb7d9c6" }, "state": "debf84af8d66", "effects": ["75263c07c807"] @@ -574,10 +576,10 @@ { "id": "native-chat-stop-accepted.normal:settled", "observation": { - "sender": ["ab0f3d65723e", "fcdecc63b46e", "650fdd02bab4"], + "sender": ["ab0f3d65723e", "fcdecc63b46e", "5cd6e007c8ca"], "payloads": ["5cd1d4a9aed5", "ef7005279d33", "ef422fa31f61"], "settlements": { - "stop": "eb79a9b3682a" + "stop": "3e2d28f3728b" }, "state": "debf84af8d66", "effects": ["75263c07c807"] @@ -586,10 +588,10 @@ { "id": "native-chat-stop-accepted.result-absent:settled", "observation": { - "sender": ["ab0f3d65723e", "fcdecc63b46e", "042114cacf6b"], + "sender": ["ab0f3d65723e", "fcdecc63b46e", "e8dc704e9d67"], "payloads": ["5cd1d4a9aed5", "ef7005279d33", "ef422fa31f61"], "settlements": { - "stop": "eb79a9b3682a" + "stop": "3e2d28f3728b" }, "state": "debf84af8d66", "effects": ["75263c07c807"] @@ -598,10 +600,10 @@ { "id": "native-chat-stop-accepted.result-null:settled", "observation": { - "sender": ["ab0f3d65723e", "fcdecc63b46e", "4946d9c85c20"], + "sender": ["ab0f3d65723e", "fcdecc63b46e", "e179d88224b1"], "payloads": ["5cd1d4a9aed5", "ef7005279d33", "ef422fa31f61"], "settlements": { - "stop": "eb79a9b3682a" + "stop": "3e2d28f3728b" }, "state": "debf84af8d66", "effects": ["75263c07c807"] @@ -610,10 +612,10 @@ { "id": "native-chat-stop-accepted.inner-ok-missing:settled", "observation": { - "sender": ["ab0f3d65723e", "fcdecc63b46e", "3bfec3b63b7a"], + "sender": ["ab0f3d65723e", "fcdecc63b46e", "2b73ac4adae4"], "payloads": ["5cd1d4a9aed5", "ef7005279d33", "ef422fa31f61"], "settlements": { - "stop": "eb79a9b3682a" + "stop": "3e2d28f3728b" }, "state": "debf84af8d66", "effects": ["75263c07c807"] @@ -622,10 +624,10 @@ { "id": "native-chat-stop-accepted.inner-false-string-error:settled", "observation": { - "sender": ["ab0f3d65723e", "fcdecc63b46e", "5087fb12a234"], + "sender": ["ab0f3d65723e", "fcdecc63b46e", "e72c54dda684"], "payloads": ["5cd1d4a9aed5", "ef7005279d33", "ef422fa31f61"], "settlements": { - "stop": "eb79a9b3682a" + "stop": "3e2d28f3728b" }, "state": "debf84af8d66", "effects": ["75263c07c807"] @@ -634,10 +636,10 @@ { "id": "native-chat-stop-accepted.inner-false-object-error:settled", "observation": { - "sender": ["ab0f3d65723e", "fcdecc63b46e", "bb85eef75ee8"], + "sender": ["ab0f3d65723e", "fcdecc63b46e", "e46d1bc83f99"], "payloads": ["5cd1d4a9aed5", "ef7005279d33", "ef422fa31f61"], "settlements": { - "stop": "eb79a9b3682a" + "stop": "3e2d28f3728b" }, "state": "debf84af8d66", "effects": ["75263c07c807"] @@ -646,10 +648,10 @@ { "id": "native-chat-stop-accepted.outer-refused:settled", "observation": { - "sender": ["ab0f3d65723e", "fcdecc63b46e", "de2c3a23d630"], + "sender": ["ab0f3d65723e", "fcdecc63b46e", "4e8a6d1de561"], "payloads": ["5cd1d4a9aed5", "ef7005279d33", "ef422fa31f61"], "settlements": { - "stop": "eb79a9b3682a" + "stop": "3e2d28f3728b" }, "state": "debf84af8d66", "effects": ["75263c07c807"] @@ -658,10 +660,10 @@ { "id": "native-chat-stop-accepted.outer-refused-no-message:settled", "observation": { - "sender": ["ab0f3d65723e", "fcdecc63b46e", "23fd4ec9cdb3"], + "sender": ["ab0f3d65723e", "fcdecc63b46e", "c3f35523d970"], "payloads": ["5cd1d4a9aed5", "ef7005279d33", "ef422fa31f61"], "settlements": { - "stop": "eb79a9b3682a" + "stop": "3e2d28f3728b" }, "state": "debf84af8d66", "effects": ["75263c07c807"] @@ -670,10 +672,10 @@ { "id": "native-chat-stop-accepted.method-not-found:settled", "observation": { - "sender": ["ab0f3d65723e", "fcdecc63b46e", "a5a6027c8212"], + "sender": ["ab0f3d65723e", "fcdecc63b46e", "2d7a0d50473b"], "payloads": ["5cd1d4a9aed5", "ef7005279d33", "ef422fa31f61"], "settlements": { - "stop": "eb79a9b3682a" + "stop": "3e2d28f3728b" }, "state": "debf84af8d66", "effects": ["75263c07c807"] @@ -682,10 +684,10 @@ { "id": "native-chat-stop-accepted.transport-rejection:settled", "observation": { - "sender": ["ab0f3d65723e", "fcdecc63b46e", "70c7b87bd776"], + "sender": ["ab0f3d65723e", "fcdecc63b46e", "d8f0e66070a6"], "payloads": ["5cd1d4a9aed5", "ef7005279d33", "ef422fa31f61"], "settlements": { - "stop": "eb79a9b3682a" + "stop": "3e2d28f3728b" }, "state": "debf84af8d66", "effects": ["75263c07c807"] @@ -694,10 +696,10 @@ { "id": "native-chat-stop-accepted.transport-rejection-no-message:settled", "observation": { - "sender": ["ab0f3d65723e", "fcdecc63b46e", "fe6add283d6d"], + "sender": ["ab0f3d65723e", "fcdecc63b46e", "3c186b43b6de"], "payloads": ["5cd1d4a9aed5", "ef7005279d33", "ef422fa31f61"], "settlements": { - "stop": "eb79a9b3682a" + "stop": "3e2d28f3728b" }, "state": "debf84af8d66", "effects": ["75263c07c807"] diff --git a/mobile/rpc-foundation/goldens/native-chat-stop-accepted.json b/mobile/rpc-foundation/goldens/native-chat-stop-accepted.json index ef83d7f679b..4f22500eade 100644 --- a/mobile/rpc-foundation/goldens/native-chat-stop-accepted.json +++ b/mobile/rpc-foundation/goldens/native-chat-stop-accepted.json @@ -4,12 +4,18 @@ "family": "session.native-chat-stop", "namedDeltas": [], "values": { + "3e2d28f3728b": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 1000, + "value": true + }, "5cd1d4a9aed5": { "name": "terminal.send#1", "ordinal": 3, "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"terminal.send\",\"params\":{\"terminal\":\"terminal-1\",\"text\":\"\\u001b\",\"client\":{\"id\":\"device-token-1\",\"type\":\"mobile\"}}}" }, - "650fdd02bab4": { + "5cd6e007c8ca": { "name": "terminal.send#2", "ordinal": 6, "args": [ @@ -32,14 +38,14 @@ "name": "options", "value": { "budgetSpansConnect": true, - "timeoutMs": 14920 + "timeoutMs": 14000 } } ], "settlement": { "status": "fulfilled", - "startedAt": 80, - "settledAt": 120, + "startedAt": 1000, + "settledAt": 1000, "value": { "id": "frame-3", "ok": true, @@ -56,6 +62,10 @@ "ordinal": 1, "value": {} }, + "9270aeb7d9c6": { + "status": "pending", + "startedAt": 0 + }, "ab0f3d65723e": { "name": "terminal.send#1", "ordinal": 2, @@ -101,14 +111,6 @@ "debf84af8d66": { "errors": [] }, - "eb79a9b3682a": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "$rpc": "undefined" - } - }, "ef422fa31f61": { "name": "terminal.send#2", "ordinal": 7, @@ -165,7 +167,7 @@ "sender": ["ab0f3d65723e", "fcdecc63b46e"], "payloads": ["5cd1d4a9aed5", "ef7005279d33"], "settlements": { - "stop": "eb79a9b3682a" + "stop": "9270aeb7d9c6" }, "state": "debf84af8d66", "effects": ["75263c07c807"] @@ -174,10 +176,10 @@ { "id": "settled", "observation": { - "sender": ["ab0f3d65723e", "fcdecc63b46e", "650fdd02bab4"], + "sender": ["ab0f3d65723e", "fcdecc63b46e", "5cd6e007c8ca"], "payloads": ["5cd1d4a9aed5", "ef7005279d33", "ef422fa31f61"], "settlements": { - "stop": "eb79a9b3682a" + "stop": "3e2d28f3728b" }, "state": "debf84af8d66", "effects": ["75263c07c807"] diff --git a/mobile/rpc-foundation/goldens/native-chat-stop-both-rejected.json b/mobile/rpc-foundation/goldens/native-chat-stop-both-rejected.json index 9d3cd0ca1ef..a0195d1297b 100644 --- a/mobile/rpc-foundation/goldens/native-chat-stop-both-rejected.json +++ b/mobile/rpc-foundation/goldens/native-chat-stop-both-rejected.json @@ -9,47 +9,11 @@ "ordinal": 3, "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"terminal.send\",\"params\":{\"terminal\":\"terminal-1\",\"text\":\"\\u001b\",\"client\":{\"id\":\"device-token-1\",\"type\":\"mobile\"}}}" }, - "6fc7ca31cbf5": { - "name": "terminal.send#2", - "ordinal": 4, - "args": [ - { - "name": "method", - "value": "terminal.send" - }, - { - "name": "params", - "value": { - "client": { - "id": "device-token-1", - "type": "mobile" - }, - "terminal": "terminal-1", - "text": "\u001b" - } - }, - { - "name": "options", - "value": { - "budgetSpansConnect": true, - "timeoutMs": 14920 - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 80, - "settledAt": 120, - "value": { - "id": "frame-2", - "ok": true, - "result": { - "send": { - "accepted": false - } - } - } - } + "6b21adc16f94": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 1000, + "value": false }, "75263c07c807": { "name": "cancel-pending", @@ -103,16 +67,50 @@ "ordinal": 5, "json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"terminal.send\",\"params\":{\"terminal\":\"terminal-1\",\"text\":\"\\u001b\",\"client\":{\"id\":\"device-token-1\",\"type\":\"mobile\"}}}" }, + "db139841535a": { + "name": "terminal.send#2", + "ordinal": 4, + "args": [ + { + "name": "method", + "value": "terminal.send" + }, + { + "name": "params", + "value": { + "client": { + "id": "device-token-1", + "type": "mobile" + }, + "terminal": "terminal-1", + "text": "\u001b" + } + }, + { + "name": "options", + "value": { + "budgetSpansConnect": true, + "timeoutMs": 14000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 1000, + "settledAt": 1000, + "value": { + "id": "frame-2", + "ok": true, + "result": { + "send": { + "accepted": false + } + } + } + } + }, "e6e087b39540": { "errors": ["Stop not sent"] - }, - "eb79a9b3682a": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "$rpc": "undefined" - } } }, "recording": { @@ -121,10 +119,10 @@ { "id": "reported", "observation": { - "sender": ["81225cffe7a1", "6fc7ca31cbf5"], + "sender": ["81225cffe7a1", "db139841535a"], "payloads": ["5cd1d4a9aed5", "d5972c43f562"], "settlements": { - "stop": "eb79a9b3682a" + "stop": "6b21adc16f94" }, "state": "e6e087b39540", "effects": ["75263c07c807"] diff --git a/mobile/rpc-foundation/goldens/native-chat-stop-delivery-unknown.json b/mobile/rpc-foundation/goldens/native-chat-stop-delivery-unknown.json index 3495ee46fe0..108a3db81a7 100644 --- a/mobile/rpc-foundation/goldens/native-chat-stop-delivery-unknown.json +++ b/mobile/rpc-foundation/goldens/native-chat-stop-delivery-unknown.json @@ -4,11 +4,55 @@ "family": "session.native-chat-stop", "namedDeltas": [], "values": { + "3e2d28f3728b": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 1000, + "value": true + }, "5cd1d4a9aed5": { "name": "terminal.send#1", "ordinal": 3, "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"terminal.send\",\"params\":{\"terminal\":\"terminal-1\",\"text\":\"\\u001b\",\"client\":{\"id\":\"device-token-1\",\"type\":\"mobile\"}}}" }, + "619592d7bf39": { + "name": "terminal.send#2", + "ordinal": 4, + "args": [ + { + "name": "method", + "value": "terminal.send" + }, + { + "name": "params", + "value": { + "client": { + "id": "device-token-1", + "type": "mobile" + }, + "terminal": "terminal-1", + "text": "\u001b" + } + }, + { + "name": "options", + "value": { + "budgetSpansConnect": true, + "timeoutMs": 14000 + } + } + ], + "settlement": { + "status": "rejected", + "startedAt": 1000, + "settledAt": 1000, + "error": { + "category": "Error", + "message": "Connection lost", + "isRpcDeliveryUnknown": true + } + } + }, "75263c07c807": { "name": "cancel-pending", "ordinal": 1, @@ -59,52 +103,6 @@ "name": "terminal.send#2", "ordinal": 5, "json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"terminal.send\",\"params\":{\"terminal\":\"terminal-1\",\"text\":\"\\u001b\",\"client\":{\"id\":\"device-token-1\",\"type\":\"mobile\"}}}" - }, - "dc4d354f7e02": { - "name": "terminal.send#2", - "ordinal": 4, - "args": [ - { - "name": "method", - "value": "terminal.send" - }, - { - "name": "params", - "value": { - "client": { - "id": "device-token-1", - "type": "mobile" - }, - "terminal": "terminal-1", - "text": "\u001b" - } - }, - { - "name": "options", - "value": { - "budgetSpansConnect": true, - "timeoutMs": 14920 - } - } - ], - "settlement": { - "status": "rejected", - "startedAt": 80, - "settledAt": 120, - "error": { - "category": "Error", - "message": "Connection lost", - "isRpcDeliveryUnknown": true - } - } - }, - "eb79a9b3682a": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "$rpc": "undefined" - } } }, "recording": { @@ -113,10 +111,10 @@ { "id": "unconfirmed", "observation": { - "sender": ["a9ec80c50d30", "dc4d354f7e02"], + "sender": ["a9ec80c50d30", "619592d7bf39"], "payloads": ["5cd1d4a9aed5", "d5972c43f562"], "settlements": { - "stop": "eb79a9b3682a" + "stop": "3e2d28f3728b" }, "state": "9fe5713ca2d1", "effects": ["75263c07c807"] diff --git a/mobile/rpc-foundation/pilot-scenarios.json b/mobile/rpc-foundation/pilot-scenarios.json index 54f8fc33221..ae927039580 100644 --- a/mobile/rpc-foundation/pilot-scenarios.json +++ b/mobile/rpc-foundation/pilot-scenarios.json @@ -17965,7 +17965,7 @@ "checkpoint": "first-accepted" }, { - "advance": 120 + "advance": 1000 }, { "complete": "terminal.send#2", @@ -18023,7 +18023,7 @@ } }, { - "advance": 120 + "advance": 1000 }, { "complete": "terminal.send#2", @@ -18077,7 +18077,7 @@ } }, { - "advance": 120 + "advance": 1000 }, { "complete": "terminal.send#2", diff --git a/mobile/src/session/MobileNativeChatOverlay.test.ts b/mobile/src/session/MobileNativeChatOverlay.test.ts index fb6593edc0e..0483c9a8cfd 100644 --- a/mobile/src/session/MobileNativeChatOverlay.test.ts +++ b/mobile/src/session/MobileNativeChatOverlay.test.ts @@ -5,7 +5,7 @@ import type { NativeChatMessage } from '../../../src/shared/native-chat-types' import { MobileNativeChatOverlay } from './MobileNativeChatOverlay' import type { MobileNativeChatController } from './mobile-native-chat-controller-contract' import type { MobileImageSource } from '../platform/media-picker-contract' -import { findAllByHostType, findByHostType } from '../test-support/react-test-renderer-host-nodes' +import { findByHostType } from '../test-support/react-test-renderer-host-nodes' vi.mock('react-native', () => ({ StyleSheet: { create: (styles: unknown) => styles, absoluteFillObject: {} }, @@ -27,16 +27,9 @@ vi.mock('./native-chat/transcript/MobileNativeChatRowOverlaysHost', () => ({ MobileNativeChatRowOverlaysHost: 'View' })) -function assistantTurn(id: string, text: string): NativeChatMessage { - return { id, role: 'assistant', blocks: [{ type: 'text', text }], timestamp: 0, source: 'hook' } -} - type Tick = { show?: boolean messages?: NativeChatMessage[] - streamingText?: string - streamLive?: boolean - identity?: string stopping?: boolean acceptsImages?: boolean agent?: string @@ -55,15 +48,11 @@ function overlayElement(tick: Tick): ReturnType { nativeChatStructured: tick.structured ?? false, setTabView: tick.setTabView ?? vi.fn(), nativeChatAcceptsImages: tick.acceptsImages ?? true, - nativeChatAgentWorking: tick.streamLive ?? false, nativeChatTurnIndicator: { thinking: false, activityText: null, stopping: tick.stopping ?? false }, - nativeChatStreamingText: tick.streamingText, - nativeChatStreamLive: tick.streamLive ?? false, - nativeChatStreamScopeKey: tick.identity ?? 'tab-a', chatPending: [], chatImagePreviewsByMessageId: {}, chatDraftKey: null, @@ -97,123 +86,14 @@ function overlayElement(tick: Tick): ReturnType { inputLockReason: null, sendErrorMessage: null, onClearSendError: vi.fn(), - sendSurfaceId: tick.identity ?? 'tab-a', + sendSurfaceId: 'tab-a', sessionId: null, getSendCompletionGeneration: () => 0, - keyboardInset: 0 + keyboardInset: 0, + hostName: null }) } -describe('MobileNativeChatOverlay streaming gate', () => { - let renderer: ReactTestRenderer | null = null - - afterEach(() => { - act(() => renderer?.unmount()) - renderer = null - }) - - async function render(tick: Tick): Promise { - await act(async () => { - renderer = create(overlayElement(tick)) - }) - } - - async function update(tick: Tick): Promise { - await act(async () => { - renderer?.update(overlayElement(tick)) - }) - } - - function streaming(): string | null | 'hidden' { - const views = findAllByHostType(renderer!.root, 'ChatView') - return views.length === 0 ? 'hidden' : (views[0].props.streaming as string | null) - } - - it('keeps streaming a reply that repeats the previous turn as a prefix', async () => { - const prior = [assistantTurn('a1', 'The tests pass.')] - await render({ messages: prior }) - expect(streaming()).toBeNull() - - await update({ messages: prior, streamingText: 'The tests', streamLive: true }) - - expect(streaming()).toBe('The tests') - }) - - it('drops the streaming bubble once the reply lands as its own turn', async () => { - const prior = [assistantTurn('a1', 'Done.')] - await render({ messages: prior }) - await update({ messages: prior, streamingText: 'Done.', streamLive: true }) - expect(streaming()).toBe('Done.') - - await update({ - messages: [...prior, assistantTurn('a2', 'Done.')], - streamingText: 'Done.', - streamLive: true - }) - - expect(streaming()).toBeNull() - }) - - it('keeps the bubble across a peek at the terminal view', async () => { - // Keep the baseline above chat-list unmounts so repeated-prefix replies still stream after terminal toggles. - const prior = [assistantTurn('a1', 'Done.')] - await render({ messages: prior }) - await update({ messages: prior, streamingText: 'Done.', streamLive: true }) - expect(streaming()).toBe('Done.') - - await update({ show: false, messages: [], streamLive: true }) - expect(streaming()).toBe('hidden') - // Status streaming returns before the transcript refresh; that round-trip gap must remain visible. - await update({ messages: [], streamLive: true }) - await update({ messages: [], streamingText: 'Done.', streamLive: true }) - await update({ messages: prior, streamingText: 'Done.', streamLive: true }) - - expect(streaming()).toBe('Done.') - }) - - it('keeps the bubble across a peek at the terminal taken between turns', async () => { - // An idle toggle must not replace the baseline with an empty withheld transcript. - const prior = [assistantTurn('a1', 'Done.')] - await render({ messages: prior }) - - await update({ show: false, messages: [] }) - await update({ show: false, messages: [], streamLive: true }) - await update({ messages: [], streamLive: true }) - await update({ messages: [], streamingText: 'Done.', streamLive: true }) - await update({ messages: prior, streamingText: 'Done.', streamLive: true }) - - expect(streaming()).toBe('Done.') - }) - - it('hides a repeated part whose own turn landed during a mid-turn gap', async () => { - // Textless tool ticks must not adopt the just-finished part as history and draw it twice. - const prior = [assistantTurn('a1', 'Done.')] - await render({ messages: prior }) - await update({ messages: prior, streamingText: 'Done.', streamLive: true }) - expect(streaming()).toBe('Done.') - - const landed = [...prior, assistantTurn('a2', 'Done.')] - await update({ messages: landed, streamLive: true }) - await update({ messages: landed, streamingText: 'Done.', streamLive: true }) - - expect(streaming()).toBeNull() - }) - - it("does not carry one chat's baseline into another stream identity", async () => { - const prior = [assistantTurn('a1', 'Shared answer text')] - await render({ messages: prior, identity: 'tab-a' }) - - await update({ - messages: prior, - streamingText: 'Shared answer', - streamLive: true, - identity: 'tab-b' - }) - - expect(streaming()).toBeNull() - }) -}) - describe("MobileNativeChatOverlay while a person's Stop ends the turn", () => { let renderer: ReactTestRenderer | null = null @@ -231,11 +111,11 @@ describe("MobileNativeChatOverlay while a person's Stop ends the turn", () => { } it("holds the queued cards' Steer while the agent works and the host says Stopping", async () => { - expect(await steerHeld({ streamLive: true, stopping: true })).toBe(true) + expect(await steerHeld({ stopping: true })).toBe(true) }) it('leaves Steer to the cards otherwise', async () => { - expect(await steerHeld({ streamLive: true, stopping: false })).toBe(false) + expect(await steerHeld({ stopping: false })).toBe(false) }) }) diff --git a/mobile/src/session/MobileNativeChatOverlay.tsx b/mobile/src/session/MobileNativeChatOverlay.tsx index b0c0122bcf3..411b3131d6e 100644 --- a/mobile/src/session/MobileNativeChatOverlay.tsx +++ b/mobile/src/session/MobileNativeChatOverlay.tsx @@ -5,7 +5,6 @@ import { foldMobileNativeChatMessages } from './mobile-native-chat-render-data' import type { MobileNativeChatImageAttachments } from './native-chat/composer/use-mobile-native-chat-image-attachments' import type { MobileNativeChatController } from './use-mobile-native-chat-controller' import type { MobileNativeChatStructuredComposer } from './native-chat/composer/MobileNativeChatComposer' -import { useMobileNativeChatStreamingBubble } from './native-chat/use-mobile-native-chat-streaming-bubble' import { useMobileNativeChatComposerTray } from './native-chat/composer/use-mobile-native-chat-composer-tray' import { useMobileNativeChatVisualRenderer } from './native-chat/visual/MobileNativeChatVisual' import { MobileNativeChatVisualContext } from './native-chat/visual/mobile-native-chat-visual-context' @@ -17,6 +16,8 @@ import { } from './native-chat/transcript/mobile-native-chat-reply-reveals' import { MobileNativeChatRowOverlaysHost } from './native-chat/transcript/MobileNativeChatRowOverlaysHost' import { nativeChatAskRequiresTerminal } from '../../../src/shared/native-chat-agent-support' +import { tuiAgentDisplayName } from '../../../src/shared/tui-agent-display-names' +import { MobileNativeChatNoticeContext } from './mobile-native-chat-notice-context' type Props = { controller: MobileNativeChatController @@ -37,6 +38,8 @@ type Props = { sessionId: string | null getSendCompletionGeneration: () => number keyboardInset: number + /** The Orca host that runs this chat, named for notices; null when unnamed. */ + hostName: string | null } // Keep the terminal subscription and streaming gate above view toggles; the chat list unmounts below this boundary. @@ -57,7 +60,8 @@ export function MobileNativeChatOverlay({ sendSurfaceId, sessionId, getSendCompletionGeneration, - keyboardInset + keyboardInset, + hostName }: Props): React.JSX.Element | null { const session = controller.nativeChatSession const agent = controller.nativeChatAgent ?? '' @@ -69,12 +73,7 @@ export function MobileNativeChatOverlay({ () => foldMobileNativeChatMessages(fullContent.messages, agent), [fullContent.messages, agent] ) - const streaming = useMobileNativeChatStreamingBubble( - folded, - controller.nativeChatStreamingText, - controller.nativeChatStreamScopeKey, - controller.nativeChatStreamLive - ) + const streaming = controller.nativeChatStreamingText ?? null const queued = controller.nativeChatQueued const composerTray = useMobileNativeChatComposerTray({ queued: { @@ -122,6 +121,10 @@ export function MobileNativeChatOverlay({ : null, [itemSource, fullContent.detail, load] ) + const noticeNames = useMemo( + () => ({ hostName, ...(agent ? { agentName: tuiAgentDisplayName(agent) ?? agent } : {}) }), + [hostName, agent] + ) if (!controller.showNativeChat) { return null } @@ -134,81 +137,86 @@ export function MobileNativeChatOverlay({ - controller.setTabView(activeSessionTabId, 'terminal') - : undefined - } - onCancelAsk={controller.handleNativeChatCancelAsk} - onCancelPrompt={controller.handleNativeChatCancelPrompt} - onCollapseAsk={controller.collapseNativeChatAsk} - onCollapsePrompt={controller.collapseNativeChatPrompt} - collapsedPrompt={controller.nativeChatCollapsedPrompt} - question={controller.nativeChatQuestion} - onAnswerQuestion={controller.handleNativeChatQuestionAnswer} - permission={controller.nativeChatPermission} - onRespondPermission={controller.handleNativeChatRespondPermission} - composerTray={composerTray} - onOpenFile={onOpenFile} - hasMore={session.hasMore} - olderPages={session.olderPages} - onLoadEarlier={session.loadEarlier} - onSend={images.sendNativeChat} - sendSurfaceId={sendSurfaceId} - getSendCompletionGeneration={getSendCompletionGeneration} - getComposerEditGeneration={controller.getChatComposerEditGeneration} - pending={controller.chatPending} - imagePreviewsByMessageId={controller.chatImagePreviewsByMessageId} - draftKey={controller.chatDraftKey} - onComposerTextChange={controller.setChatComposerText} - onAttachImage={ - controller.nativeChatAcceptsImages - ? () => void images.attachImage('library') - : undefined - } - attachments={images.attachments} - onRemoveAttachment={images.removeAttachment} - isAttaching={images.isAttaching} - onMicPress={onMicPress} - micActive={micActive} - dictationMode={dictationMode} - onMicPressIn={onMicPressIn} - onMicPressOut={onMicPressOut} - inputLockReason={inputLockReason} - sendErrorMessage={sendErrorMessage} - onClearSendError={onClearSendError} - filePaths={controller.nativeChatFilePaths} - onNeedFiles={controller.loadNativeChatFiles} - sessionOptions={controller.nativeChatSessionOptions} - structuredComposer={structuredComposer} - keyboardInset={keyboardInset} - /> + + controller.setTabView(activeSessionTabId, 'terminal') + : undefined + } + onCancelAsk={controller.handleNativeChatCancelAsk} + onCancelPrompt={controller.handleNativeChatCancelPrompt} + onCollapseAsk={controller.collapseNativeChatAsk} + onCollapsePrompt={controller.collapseNativeChatPrompt} + collapsedPrompt={controller.nativeChatCollapsedPrompt} + question={controller.nativeChatQuestion} + onAnswerQuestion={controller.handleNativeChatQuestionAnswer} + permission={controller.nativeChatPermission} + onRespondPermission={controller.handleNativeChatRespondPermission} + composerTray={composerTray} + onOpenFile={onOpenFile} + hasMore={session.hasMore} + olderPages={session.olderPages} + onLoadEarlier={session.loadEarlier} + onSend={images.sendNativeChat} + sendSurfaceId={sendSurfaceId} + getSendCompletionGeneration={getSendCompletionGeneration} + getComposerEditGeneration={controller.getChatComposerEditGeneration} + pending={controller.chatPending} + imagePreviewsByMessageId={controller.chatImagePreviewsByMessageId} + draftKey={controller.chatDraftKey} + onComposerTextChange={controller.setChatComposerText} + onAttachImage={ + controller.nativeChatAcceptsImages + ? () => void images.attachImage('library') + : undefined + } + attachments={images.attachments} + onRemoveAttachment={images.removeAttachment} + isAttaching={images.isAttaching} + onMicPress={onMicPress} + micActive={micActive} + dictationMode={dictationMode} + onMicPressIn={onMicPressIn} + onMicPressOut={onMicPressOut} + inputLockReason={inputLockReason} + sendErrorMessage={sendErrorMessage} + onClearSendError={onClearSendError} + filePaths={controller.nativeChatFilePaths} + onNeedFiles={controller.loadNativeChatFiles} + skillDiscovery={controller.nativeChatSkills} + onNeedSkills={controller.loadNativeChatSkills} + sessionOptions={controller.nativeChatSessionOptions} + structuredComposer={structuredComposer} + keyboardInset={keyboardInset} + /> + diff --git a/mobile/src/session/MobileNativeChatView.prompt-presentation.test.ts b/mobile/src/session/MobileNativeChatView.prompt-presentation.test.ts index 42b45be8832..f435e366c81 100644 --- a/mobile/src/session/MobileNativeChatView.prompt-presentation.test.ts +++ b/mobile/src/session/MobileNativeChatView.prompt-presentation.test.ts @@ -76,8 +76,7 @@ vi.mock('./native-chat/input/MobileNativeChatSessionOptionPickers', () => ({ MobileNativeChatSessionOptionPickers: 'SessionOptions' })) vi.mock('./native-chat/composer/MobileNativeChatComposerMenu', () => ({ - MobileNativeChatComposerMenu: 'Suggestions', - composerMenuInsertText: () => '' + MobileNativeChatComposerMenu: 'Suggestions' })) vi.mock('../components/ActionSheetModal', () => ({ ActionSheetModal: 'ActionSheetModal' })) vi.mock('../components/MobileMarkdown', () => ({ MobileMarkdown: 'Markdown' })) diff --git a/mobile/src/session/MobileNativeChatView.streaming-rows.test.ts b/mobile/src/session/MobileNativeChatView.streaming-rows.test.ts index 1b18c4f7f64..e1312189bc6 100644 --- a/mobile/src/session/MobileNativeChatView.streaming-rows.test.ts +++ b/mobile/src/session/MobileNativeChatView.streaming-rows.test.ts @@ -230,7 +230,7 @@ function Harness({ nativeChatSession: { messages, status: 'ready' }, nativeChatAgent: 'claude', nativeChatStructured: true, - nativeChatAgentWorking: true, + nativeChatTurnActive: true, nativeChatItemSource: source, nativeChatVisualSource: source, nativeChatSettledTurns: timing.settledTurns, @@ -264,7 +264,8 @@ function Harness({ sendSurfaceId: source?.sessionId ?? stable.sendSurfaceId, sessionId: source?.sessionId ?? 'session', getSendCompletionGeneration: stable.getSendCompletionGeneration, - keyboardInset: 0 + keyboardInset: 0, + hostName: null }) } diff --git a/mobile/src/session/MobileNativeChatView.tsx b/mobile/src/session/MobileNativeChatView.tsx index 36dd1d73442..d63c0de4e0e 100644 --- a/mobile/src/session/MobileNativeChatView.tsx +++ b/mobile/src/session/MobileNativeChatView.tsx @@ -1,3 +1,4 @@ +import type { NativeChatSkillDiscoverySnapshot } from '../../../src/shared/native-chat/input/native-chat-picker-items' import { useCallback, useMemo } from 'react' import { nativeChatTaskListPredecessors } from '../../../src/shared/native-chat/input/native-chat-task-list-history' import { @@ -40,6 +41,8 @@ import { useSettledMobileNativeChatInputLock } from './native-chat/use-mobile-native-chat-input-lease' import { MobileNativeChatLiveLine } from './native-chat/transcript/MobileNativeChatLiveLine' +import { MobileNativeChatAwaitingInputRow } from './native-chat/transcript/MobileNativeChatAwaitingInputRow' +import type { NativeChatAwaitingInput } from '../../../src/shared/native-chat/native-chat-terminal-turn' import { MobileNativeChatFloatingControls } from './MobileNativeChatFloatingControls' import type { PendingNativeChatImage } from './native-chat/composer/mobile-native-chat-image-attachment' import { @@ -71,8 +74,10 @@ type Props = ComposerTrayProps & { // Permanent read failure replaces the pane because further chat actions cannot succeed. readFailedFinally?: boolean agent?: string | null + /** The turn is live: working, or waiting on the reader. */ agentWorking?: boolean canStop?: boolean + awaitingInput?: NativeChatAwaitingInput | null structuredActivityUi?: boolean turnIndicator?: NativeChatLiveTurnIndicator | null workingStartedAt?: number | null @@ -111,6 +116,8 @@ type Props = ComposerTrayProps & { onClearSendError?: () => void filePaths?: string[] onNeedFiles?: (query: string) => void + skillDiscovery?: NativeChatSkillDiscoverySnapshot + onNeedSkills?: () => void sessionOptions?: MobileNativeChatSessionOptionPickersProps | null structuredComposer?: MobileNativeChatStructuredComposer | null ask?: AskPrompt | null @@ -143,6 +150,7 @@ export function MobileNativeChatView({ agent, agentWorking, canStop = agentWorking, + awaitingInput = null, structuredActivityUi = false, turnIndicator = null, workingStartedAt, @@ -177,6 +185,8 @@ export function MobileNativeChatView({ onClearSendError, filePaths, onNeedFiles, + skillDiscovery, + onNeedSkills, sessionOptions, structuredComposer = null, ask, @@ -249,7 +259,7 @@ export function MobileNativeChatView({ const turns = useMobileNativeChatTurnDisclosure({ messages: data, - enabled: structuredActivityUi, + enabled: true, isWorking: agentWorking === true, workingStartedAt, settledTurns, @@ -280,14 +290,17 @@ export function MobileNativeChatView({ actionablePromptIds }) - const liveStatus = turns.liveLine ? ( - - ) : null + const liveStatus = + awaitingInput === 'unshown' ? ( + + ) : turns.liveLine ? ( + + ) : null const header = mobileNativeChatListHeader(hasMore, olderPages, loadEarlier) // Changing text scale relays out every row, so preserve the page’s reading anchor. @@ -395,11 +408,13 @@ export function MobileNativeChatView({ placeholder={composerPlaceholder} filePaths={filePaths} onNeedFiles={onNeedFiles} + skillDiscovery={skillDiscovery} + onNeedSkills={onNeedSkills} stop={ canStop ? { onStop, - held: agentWorking === true && turnIndicator?.stopRequestInFlight === true + held: turnIndicator?.stopRequestInFlight === true } : null } diff --git a/mobile/src/session/MobileNativeChatView.turn-status.test.ts b/mobile/src/session/MobileNativeChatView.turn-status.test.ts index 5fdcf0a7bf2..e79590664ec 100644 --- a/mobile/src/session/MobileNativeChatView.turn-status.test.ts +++ b/mobile/src/session/MobileNativeChatView.turn-status.test.ts @@ -4,7 +4,6 @@ import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'rea import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { NativeChatMessage } from '../../../src/shared/native-chat-types' import { MobileNativeChatView } from './MobileNativeChatView' -import { styles } from './mobile-native-chat-view-styles' const scrollToEnd = vi.hoisted(() => vi.fn()) const scrollToOffset = vi.hoisted(() => vi.fn()) @@ -315,16 +314,13 @@ describe('MobileNativeChatView', () => { expect(footerProps()).toMatchObject({ activityText: 'Running pnpm test' }) }) - it('gives the bridge lane the same live Working line, with no turn status', async () => { + it('gives the bridge lane the same turn status and live Working line', async () => { const folded = [userTurn('u1', 'go')] await render({ messages: folded, folded, agentWorking: true }) const props = rowProps('u1') expect(props.structuredActivityUi).toBe(false) - expect(props.turnStatus).toBeNull() - expect(props.activeTurnIsWorking).toBe(false) + expect(props.turnStatus).toMatchObject({ workedSeconds: null }) expect(footerProps()).toEqual({ thinking: false, activityText: null, stopping: false }) - const list = renderer!.root.find((node) => String(node.type) === 'FlatList') - expect(list.props.contentContainerStyle).toBe(styles.listContent) }) it('settles the finished turn to a tappable duration', async () => { diff --git a/mobile/src/session/MobileQueuedMessageCardRow.tsx b/mobile/src/session/MobileQueuedMessageCardRow.tsx index 8948e370d8b..c1035f95765 100644 --- a/mobile/src/session/MobileQueuedMessageCardRow.tsx +++ b/mobile/src/session/MobileQueuedMessageCardRow.tsx @@ -10,6 +10,7 @@ import { Trash2 } from 'lucide-react-native' import { colors } from '../theme/mobile-theme' +import { queuedMessageCardSteers } from '../../../src/shared/structured-agent-session-queued-cards' import type { MobileQueuedMessageCard } from './mobile-structured-queued-message-cards' import type { MobileQueuedMessageInlineEditor } from './use-mobile-structured-queued-edit' import { MobileNativeChatQueuedMessageEditor } from './native-chat/input/MobileNativeChatQueuedMessageEditor' @@ -61,7 +62,7 @@ function QueuedMessageCardContents({ }: Omit): React.JSX.Element { const [expanded, setExpanded] = useState(false) const returned = card.state === 'returned' - const steers = !returned && !card.paused && !card.command + const steers = queuedMessageCardSteers(card) return ( @@ -78,7 +79,7 @@ function QueuedMessageCardContents({ ) : null} setExpanded(!expanded)} /> @@ -161,7 +162,7 @@ function QueuedMessageCardContents({ accessibilityHint="Shows less of the message" onPress={() => setExpanded(false)} > - {card.text} + {card.line} ) : null} {returned && card.caption ? ( diff --git a/mobile/src/session/MobileSessionActiveContent.tsx b/mobile/src/session/MobileSessionActiveContent.tsx index 18cbd8628c3..1250b19f93c 100644 --- a/mobile/src/session/MobileSessionActiveContent.tsx +++ b/mobile/src/session/MobileSessionActiveContent.tsx @@ -8,6 +8,8 @@ import { colors } from '../theme/mobile-theme' import { styles } from './mobile-session-styles' import type { MobileSessionController } from './use-mobile-session-controller' import { useWorkspaceExecutionHost } from '../navigation/workspace-execution-host' +import { useHostProfile } from '../transport/use-host-profile' +import { useHostDisplay } from '../transport/use-host-display' import { FileReader } from './MobileSessionFileReader' import { MarkdownReader } from './MobileSessionMarkdownReader' @@ -90,6 +92,8 @@ export function MobileSessionActiveContent({ toastAnimatedStyle, createTabBusy } = controller + const hostProfile = useHostProfile(hostId) + const hostTitle = useHostDisplay(hostProfile).title const content = showLoadingState ? ( @@ -255,6 +259,8 @@ export function MobileSessionActiveContent({ sessionId={resolveMobileNativeChatSessionId(controller.activeSessionTab)} getSendCompletionGeneration={controller.getSendCompletionGeneration} keyboardInset={keyboardLift} + // A workspace routed to another server stops with that server, whose name the phone lacks. + hostName={executionHost === undefined && hostProfile ? hostTitle : null} /> {toastMessage && ( diff --git a/mobile/src/session/__mocks__/mobile-prompt-controller.ts b/mobile/src/session/__mocks__/mobile-prompt-controller.ts index a26bbf00f0c..5978514a199 100644 --- a/mobile/src/session/__mocks__/mobile-prompt-controller.ts +++ b/mobile/src/session/__mocks__/mobile-prompt-controller.ts @@ -77,9 +77,6 @@ vi.mock('../native-chat/composer/use-mobile-native-chat-composer-tray', () => ({ NO_COMPOSER_TRAY: {}, useMobileNativeChatComposerTray: () => ({}) })) -vi.mock('../native-chat/use-mobile-native-chat-streaming-bubble', () => ({ - useMobileNativeChatStreamingBubble: () => null -})) let tree: ReactTestRenderer | null = null let controller: MobileNativeChatController @@ -166,7 +163,8 @@ function Harness(props: HarnessProps) { sendSurfaceId: 'surface', sessionId: null, getSendCompletionGeneration: () => 0, - keyboardInset: 0 + keyboardInset: 0, + hostName: null }) } export async function render(props: HarnessProps = lastProps ?? {}) { diff --git a/mobile/src/session/__mocks__/mobile-prompt-render-mocks.ts b/mobile/src/session/__mocks__/mobile-prompt-render-mocks.ts index 03e0f793774..97da83855b9 100644 --- a/mobile/src/session/__mocks__/mobile-prompt-render-mocks.ts +++ b/mobile/src/session/__mocks__/mobile-prompt-render-mocks.ts @@ -73,8 +73,7 @@ vi.mock('../native-chat/input/MobileNativeChatSessionOptionPickers', () => ({ MobileNativeChatSessionOptionPickers: 'SessionOptions' })) vi.mock('../native-chat/composer/MobileNativeChatComposerMenu', () => ({ - MobileNativeChatComposerMenu: 'Suggestions', - composerMenuInsertText: () => '' + MobileNativeChatComposerMenu: 'Suggestions' })) vi.mock('../../components/ActionSheetModal', () => ({ ActionSheetModal: 'ActionSheetModal' })) vi.mock('../../components/MobileMarkdown', () => ({ MobileMarkdown: 'Markdown' })) diff --git a/mobile/src/session/mobile-native-chat-controller-contract.ts b/mobile/src/session/mobile-native-chat-controller-contract.ts index f5e9b1dceac..c133489ba35 100644 --- a/mobile/src/session/mobile-native-chat-controller-contract.ts +++ b/mobile/src/session/mobile-native-chat-controller-contract.ts @@ -1,3 +1,4 @@ +import type { NativeChatSkillDiscoverySnapshot } from '../../../src/shared/native-chat/input/native-chat-picker-items' import type { StructuredMobileSendAttachment } from './use-mobile-structured-send-with-outcome' import type { MobileSessionView } from '../storage/session-view-preferences' import type { Dispatch, MutableRefObject, SetStateAction } from 'react' @@ -10,6 +11,7 @@ import type { NativeChatSettledTurns } from '../../../src/shared/native-chat-turn-status' import type { NativeChatTurnJournal } from '../../../src/shared/native-chat-turn-membership' +import type { NativeChatAwaitingInput } from '../../../src/shared/native-chat/native-chat-terminal-turn' import type { NativeChatResolvedPrompt } from '../../../src/shared/native-chat-resolution-receipt' import type { MobileNativeChatSendOutcome } from './native-chat/mobile-native-chat-send' import type { MobileNativeChatCommandRefusalCauses } from './native-chat/use-mobile-native-chat-send-error' @@ -38,7 +40,9 @@ export type MobileNativeChatController = { nativeChatStructured: boolean nativeChatVisualSource: MobileNativeChatVisualSource | null nativeChatItemSource: MobileNativeChatVisualSource | null - nativeChatAgentWorking: boolean + /** Working or waiting on the reader: keeps the turn clock running. */ + nativeChatTurnActive: boolean + nativeChatAwaitingInput: NativeChatAwaitingInput | null nativeChatTurnIndicator: NativeChatLiveTurnIndicator | null nativeChatWorkingStartedAt: number | null nativeChatSettledTurns: NativeChatSettledTurns | null @@ -48,9 +52,6 @@ export type MobileNativeChatController = { nativeChatCommandRefusalCauses: MobileNativeChatCommandRefusalCauses nativeChatCanStop: boolean nativeChatStreamingText?: string - // Keep stream liveness independent of whether chat is visible. - nativeChatStreamLive: boolean - nativeChatStreamScopeKey: string nativeChatPermission: MobileChatPermission | null // A new prompt occurrence resets local card submission state only. nativeChatPromptKey: string | null @@ -79,6 +80,8 @@ export type MobileNativeChatController = { nativeChatBackgroundTasks: MobileStructuredBackgroundTasks | null nativeChatFilePaths: string[] loadNativeChatFiles: (query: string) => void + nativeChatSkills: NativeChatSkillDiscoverySnapshot + loadNativeChatSkills: () => void /** A grouped structured answer names the prompt it was rendered for. */ handleNativeChatQuestionAnswer?: ( text: string, diff --git a/mobile/src/session/mobile-native-chat-merged-snapshot-parity-hooks.test.tsx b/mobile/src/session/mobile-native-chat-merged-snapshot-parity-hooks.test.tsx index 405b024e1ae..afe9e2e45e6 100644 --- a/mobile/src/session/mobile-native-chat-merged-snapshot-parity-hooks.test.tsx +++ b/mobile/src/session/mobile-native-chat-merged-snapshot-parity-hooks.test.tsx @@ -212,20 +212,18 @@ async function mountPhone(): Promise { transcriptSettled: session.session.status === 'ready' }) latest = drafts - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: every member the list's rows, bars and footer read comes from the two hooks above, as the controller passes them; showNativeChat, nativeChatAgent, nativeChatCanStop, nativeChatStructured and the scope key are constants; the overlay's other members stay undefined: the composer's and Stop's handlers, files and options never reach the list, the streaming text is undefined on a structured tab too, and the prompts, which would hide the live status, are absent in this flow. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: every member the list's rows, bars and footer read comes from the two hooks above, as the controller passes them; showNativeChat, nativeChatAgent, nativeChatCanStop, nativeChatStructured are constants; the overlay's other members stay undefined: the composer's and Stop's handlers, files and options never reach the list, the streaming text is undefined on a structured tab too, and the prompts, which would hide the live status, are absent in this flow. const controller = { showNativeChat: true, nativeChatSession: session.session, nativeChatAgent: 'codex', - nativeChatAgentWorking: session.isWorking, nativeChatCanStop: false, nativeChatStructured: true, nativeChatTurnIndicator: session.turnIndicator, nativeChatWorkingStartedAt: session.workingStartedAt, nativeChatSettledTurns: session.settledTurns, nativeChatTurnJournal: session.turnJournal, - nativeChatStreamLive: session.isWorking, - nativeChatStreamScopeKey: 'host\0workspace\0tab', + nativeChatTurnActive: session.isWorking, chatPending: drafts.pending, chatImagePreviewsByMessageId: drafts.imagePreviewsByMessageId, chatDraftKey: drafts.draftKey, @@ -248,7 +246,8 @@ async function mountPhone(): Promise { sendSurfaceId: 'host\0workspace\0tab', sessionId: null, getSendCompletionGeneration: () => 0, - keyboardInset: 0 + keyboardInset: 0, + hostName: null }) } let renderer: ReactTestRenderer | null = null diff --git a/mobile/src/session/mobile-native-chat-notice-context.ts b/mobile/src/session/mobile-native-chat-notice-context.ts new file mode 100644 index 00000000000..6d0b0fbc88d --- /dev/null +++ b/mobile/src/session/mobile-native-chat-notice-context.ts @@ -0,0 +1,8 @@ +import { createContext } from 'react' + +/** Names a notice row needs: the Orca host behind the chat (null when unnamed) and the agent. */ +export type MobileNativeChatNoticeNames = { hostName: string | null; agentName?: string } + +export const MobileNativeChatNoticeContext = createContext({ + hostName: null +}) diff --git a/mobile/src/session/mobile-session-read-operations.ts b/mobile/src/session/mobile-session-read-operations.ts index 68cbad0570b..28b17ad7d40 100644 --- a/mobile/src/session/mobile-session-read-operations.ts +++ b/mobile/src/session/mobile-session-read-operations.ts @@ -8,6 +8,7 @@ import { sessionForwardedReplySchema, sessionTerminalInventorySchema, sessionWorktreeRecordSchema, + skillDiscoveryReplySchema, terminalQuickCommandsSchema, workspaceFilePathsSchema } from './session-read-reply-schema' @@ -163,3 +164,14 @@ export const markdownTabRead = bindDeferredRpcOperation( read: rpcResultVariant('markdown-tab-doc', markdownTabDocumentSchema) }) ) + +// Mobile names the workspace and lets the host resolve and scan it; see SKILL_DISCOVER_WORKSPACE_CAPABILITY. +export const nativeChatSkillDiscoveryRead = bindDeferredRpcOperation( + defineRpcOperation({ + name: 'skills.discover-or-skip', + method: 'skills.discover', + acceptance: 'success-result-or-skip', + barrier: 'after-caller-barrier', + read: rpcResultVariant('skill-discovery', skillDiscoveryReplySchema) + }) +) diff --git a/mobile/src/session/mobile-session-terminal-frame-layout.test.tsx b/mobile/src/session/mobile-session-terminal-frame-layout.test.tsx index 1f4dd8a536f..010275dcb0a 100644 --- a/mobile/src/session/mobile-session-terminal-frame-layout.test.tsx +++ b/mobile/src/session/mobile-session-terminal-frame-layout.test.tsx @@ -33,6 +33,7 @@ vi.mock('../storage/preferences', () => ({ saveTerminalTextScale: () => Promise. vi.mock('../browser/MobileBrowserPane', () => ({ MobileBrowserPane: () => null })) vi.mock('./TerminalPaneView', () => ({ TerminalPaneView: () => null })) vi.mock('./MobileNativeChatOverlay', () => ({ MobileNativeChatOverlay: () => null })) +vi.mock('../transport/use-host-profile', () => ({ useHostProfile: () => null })) vi.mock('./MobileSessionFileReader', () => ({ FileReader: () => null })) vi.mock('./MobileSessionMarkdownReader', () => ({ MarkdownReader: () => null })) vi.mock('./mobile-session-styles', () => ({ styles: {} })) diff --git a/mobile/src/session/mobile-structured-agent-session-cancel.ts b/mobile/src/session/mobile-structured-agent-session-cancel.ts index 8c41da9ec66..78e4b77f4c8 100644 --- a/mobile/src/session/mobile-structured-agent-session-cancel.ts +++ b/mobile/src/session/mobile-structured-agent-session-cancel.ts @@ -1,6 +1,10 @@ import type { AgentSessionCancelResult } from '../../../src/shared/agent-session-wire' import type { StructuredAgentSessionState } from '../../../src/shared/structured-agent-session-reducer' import { runningStructuredAgentSessionTurnId } from '../../../src/shared/structured-agent-session-live-turn' +import { + joinStructuredAgentSessionStop, + structuredAgentSessionStopIsJoinable +} from '../../../src/shared/structured-agent-session-stop-join' import type { RpcClient } from '../transport/rpc-client' import { requestStructuredAgentSessionMutation, @@ -47,23 +51,18 @@ export async function requestMobileStructuredAgentSessionCancel(args: { // Mint an id for each Stop so the ledger cannot replay an earlier press instead of stopping work. const fence = current.fence const stop = () => sendStop({ client, sessionId, fence, fields, onSendError }) - if (args.hostAnswersRepeatedStops === true) { + if ( + args.hostAnswersRepeatedStops === true || + !structuredAgentSessionStopIsJoinable('agentSession.cancel', fields) + ) { return stop() } // Legacy hosts need client-side joining until they support quiet repeated Stop. - const key = `${sessionId}:agentSession.cancel:${JSON.stringify(fields)}` - const joined = inFlight.get(key) - if (joined) { - return joined - } - const stopping = stop() - inFlight.set(key, stopping) - void stopping.finally(() => { - if (inFlight.get(key) === stopping) { - inFlight.delete(key) - } - }) - return stopping + return joinStructuredAgentSessionStop( + inFlight, + `${sessionId}:agentSession.cancel:${JSON.stringify(fields)}`, + stop + ) } async function sendStop(input: { diff --git a/mobile/src/session/mobile-structured-agent-session-contract.ts b/mobile/src/session/mobile-structured-agent-session-contract.ts index f7a71f7047d..2be70f89bfa 100644 --- a/mobile/src/session/mobile-structured-agent-session-contract.ts +++ b/mobile/src/session/mobile-structured-agent-session-contract.ts @@ -11,6 +11,7 @@ import type { NativeChatResolvedPrompt } from '../../../src/shared/native-chat-r import type { useMobileStructuredAgentOptions } from './use-mobile-structured-agent-options' import type { useMobileStructuredAgentTurnTiming } from './use-mobile-structured-agent-turn-timing' import type { StructuredMobileSendAttachment } from './use-mobile-structured-send-with-outcome' +import type { AgentJournalSkillRef } from '../../../src/shared/agent-session-journal-types' import type { MobileNativeChatCommandRefusalCauses, MobileNativeChatSendErrorReporter @@ -22,13 +23,16 @@ export type StructuredMobileSession = ReturnType & { session: MobileNativeChatSession isWorking: boolean + /** Desktop's Stop rule: a running turn, or conversation Stop while working or sending. */ + canStop: boolean turnId: string | null turnIndicator: NativeChatLiveTurnIndicator sendWithOutcome: ( text: string, images?: string[], deadline?: number, - attachments?: readonly StructuredMobileSendAttachment[] + attachments?: readonly StructuredMobileSendAttachment[], + skills?: readonly AgentJournalSkillRef[] ) => Promise cancel: () => void receipts: ReadonlyMap diff --git a/mobile/src/session/mobile-structured-agent-session-host-support.ts b/mobile/src/session/mobile-structured-agent-session-host-support.ts index d689e2e6ebb..97e90b291e6 100644 --- a/mobile/src/session/mobile-structured-agent-session-host-support.ts +++ b/mobile/src/session/mobile-structured-agent-session-host-support.ts @@ -12,12 +12,16 @@ import { AGENT_SESSION_STATUS_FEED_RUNTIME_CAPABILITY, AGENT_SESSION_REPEATED_STOP_RUNTIME_CAPABILITY } from '../../../src/shared/protocol-version' -import { AGENT_SESSION_SEND_OPTIONS_RUNTIME_CAPABILITY } from '../../../src/shared/agent-session-create-capabilities' +import { + AGENT_SESSION_SEND_OPTIONS_RUNTIME_CAPABILITY, + AGENT_SESSION_SEND_SKILLS_RUNTIME_CAPABILITY +} from '../../../src/shared/agent-session-create-capabilities' import { AGENT_SESSION_PERMISSION_MODES_RUNTIME_CAPABILITY, AGENT_SESSION_PERMISSION_OWN_RUNTIME_CAPABILITY } from '../../../src/shared/agent-session-permission-modes-capability' import { AGENT_SESSION_PHONE_ATTACHMENTS_RUNTIME_CAPABILITY } from '../../../src/shared/agent-session-runtime-capabilities' +import { SKILL_DISCOVER_WORKSPACE_CAPABILITY } from '../../../src/shared/skill-install-capability' export type StructuredAgentSessionHostSupport = { coherentModelCatalog?: boolean @@ -36,6 +40,10 @@ export type StructuredAgentSessionHostSupport = { statusFeed: boolean quietRepeatedStop: boolean sendOptions: boolean + /** Older hosts refuse a send body carrying picked skill paths. */ + sendSkills?: boolean + /** Older hosts need the workspace folder to scan skills, which the phone does not know. */ + discoverWorkspaceSkills?: boolean /** A Stop naming no turn stops what the chat has in flight; an older host needs the turn. */ conversationStop: boolean /** The host applies each chat's permission mode; 1.4.225–1.4.226 do without advertising it. */ @@ -64,6 +72,8 @@ export function structuredAgentSessionHostSupport( statusFeed: capabilities.includes(AGENT_SESSION_STATUS_FEED_RUNTIME_CAPABILITY), quietRepeatedStop: capabilities.includes(AGENT_SESSION_REPEATED_STOP_RUNTIME_CAPABILITY), sendOptions: capabilities.includes(AGENT_SESSION_SEND_OPTIONS_RUNTIME_CAPABILITY), + sendSkills: capabilities.includes(AGENT_SESSION_SEND_SKILLS_RUNTIME_CAPABILITY), + discoverWorkspaceSkills: capabilities.includes(SKILL_DISCOVER_WORKSPACE_CAPABILITY), conversationStop: capabilities.includes(AGENT_SESSION_CONVERSATION_STOP_RUNTIME_CAPABILITY), permissionOwn: capabilities.includes(AGENT_SESSION_PERMISSION_OWN_RUNTIME_CAPABILITY), permissionModes: capabilities.includes(AGENT_SESSION_PERMISSION_MODES_RUNTIME_CAPABILITY), diff --git a/mobile/src/session/mobile-structured-agent-session-send.ts b/mobile/src/session/mobile-structured-agent-session-send.ts index b4b3cd089df..c52e7310497 100644 --- a/mobile/src/session/mobile-structured-agent-session-send.ts +++ b/mobile/src/session/mobile-structured-agent-session-send.ts @@ -1,5 +1,8 @@ import type { AgentSessionSendResult } from '../../../src/shared/agent-session-wire' -import type { AgentJournalCursor } from '../../../src/shared/agent-session-journal-types' +import type { + AgentJournalCursor, + AgentJournalSkillRef +} from '../../../src/shared/agent-session-journal-types' import { structuredAgentSessionSendBody, type StructuredAgentSessionAttachment @@ -24,6 +27,8 @@ export async function sendMobileStructuredAgentSessionMessage(input: { expectedRuntimeFence: number text: string attachments: readonly StructuredAgentSessionAttachment[] + /** Sent only when the host advertises `agent-session.send-skills.v1`. */ + skills?: readonly AgentJournalSkillRef[] /** Sent only when the host advertises `agent-session.queued-messages.v1`. */ delivery?: 'queue-if-active' /** Sent only when the host advertises `agent-session.send-options.v1`. */ @@ -38,7 +43,7 @@ export async function sendMobileStructuredAgentSessionMessage(input: { input.onError('Message not sent') return 'rejected' } - const body = structuredAgentSessionSendBody(input.text, input.attachments) + const body = structuredAgentSessionSendBody(input.text, input.attachments, input.skills) // The host refuses it unexplained, so say why here and keep the draft. if (structuredAgentSessionBlocksTooLarge(body.blocks)) { input.onError(agentSessionWriteNoticeEnglish(AGENT_SESSION_MESSAGE_TOO_LARGE_PARTS)) diff --git a/mobile/src/session/mobile-structured-composer-command.test.ts b/mobile/src/session/mobile-structured-composer-command.test.ts index 76fffa44af6..42c2f940ca4 100644 --- a/mobile/src/session/mobile-structured-composer-command.test.ts +++ b/mobile/src/session/mobile-structured-composer-command.test.ts @@ -9,7 +9,19 @@ const asyncStorage = vi.hoisted(() => ({ vi.mock('@react-native-async-storage/async-storage', () => ({ default: asyncStorage })) -import { dispatchMobileStructuredCommand } from './mobile-structured-composer-command' +import { + dispatchMobileStructuredCommand, + type MobileStructuredCommandHolds +} from './mobile-structured-composer-command' + +const IDLE: MobileStructuredCommandHolds = { + working: false, + prompt: false, + background: false, + backgroundTasksRunning: false, + sending: false +} +const holding = (shown: Partial) => () => ({ ...IDLE, ...shown }) function setup() { const sendRequest = vi.fn< @@ -32,7 +44,8 @@ function setup() { setOption: vi.fn(async () => true), conversationCommands: ['clear', 'compact'] }, - busy: () => null, + holds: () => IDLE, + startFailures: () => [], waitsInLine: () => false, onError: vi.fn(), timeoutMs: 15000 @@ -101,7 +114,7 @@ describe('mobile structured conversation commands', () => { input.text = '/compact instructions' } if (reason === 'pending work') { - input.busy = () => 'working' + input.holds = holding({ working: true }) } expect(await dispatchMobileStructuredCommand(input)).toBe('rejected') expect(sendRequest).not.toHaveBeenCalled() @@ -143,7 +156,7 @@ describe('mobile structured conversation commands', () => { } } }) - input.busy = () => 'working' + input.holds = holding({ working: true }) input.waitsInLine = (command) => command === 'compact' expect(await dispatchMobileStructuredCommand(input)).toBe('accepted') const fields = requestFields(sendRequest.mock.calls[0]) @@ -154,19 +167,19 @@ describe('mobile structured conversation commands', () => { }) it('a /clear while the agent works says so in plain words', async () => { const { input, sendRequest } = setup() - input.busy = () => 'working' + input.holds = holding({ working: true }) expect(await dispatchMobileStructuredCommand({ ...input, text: '/clear' })).toBe('rejected') expect(input.onError).toHaveBeenLastCalledWith( "The agent is still working. Run /clear when it's done.", { refusedWhile: 'working' } ) - input.busy = () => 'prompt' + input.holds = holding({ prompt: true }) expect(await dispatchMobileStructuredCommand({ ...input, text: '/clear' })).toBe('rejected') expect(input.onError).toHaveBeenLastCalledWith( "Answer the agent's question or approval, then run /clear.", { refusedWhile: 'prompt' } ) - input.busy = () => 'working' + input.holds = holding({ working: true }) expect(await dispatchMobileStructuredCommand(input)).toBe('rejected') expect(input.onError).toHaveBeenLastCalledWith( "The agent is still working. Run /compact when it's done.", @@ -193,20 +206,40 @@ describe('mobile structured conversation commands', () => { } sendRequest.mockResolvedValue(refused) input.waitsInLine = () => true - input.busy = () => 'working' + input.holds = holding({ working: true }) expect(await dispatchMobileStructuredCommand(input)).toBe('rejected') expect(input.onError).toHaveBeenLastCalledWith( "The agent is still working. Run /compact when it's done.", { refusedWhile: 'working' } ) // Keep a newer host’s unreadable cause as failure rather than claiming which work blocked it. - input.busy = () => null + input.holds = () => IDLE expect(await dispatchMobileStructuredCommand(input)).toBe('rejected') expect(input.onError).toHaveBeenLastCalledWith( "The agent is still working. Run /compact when it's done.", undefined ) }) + it('holds for background tasks or a message still sending, and waits silently behind one in line', async () => { + const { input, sendRequest } = setup() + input.holds = holding({ background: true, backgroundTasksRunning: true }) + expect(await dispatchMobileStructuredCommand(input)).toBe('rejected') + expect(input.onError).toHaveBeenLastCalledWith( + 'Wait for pending work and messages to finish before using this command.', + { refusedWhile: 'background' } + ) + input.holds = holding({ sending: true }) + expect(await dispatchMobileStructuredCommand(input)).toBe('rejected') + expect(input.onError).toHaveBeenLastCalledWith( + 'Your earlier message is still being sent. Run /compact once it has gone.', + { refusedWhile: 'sending' } + ) + input.waitsInLine = () => true + vi.mocked(input.onError).mockClear() + expect(await dispatchMobileStructuredCommand(input)).toBe('rejected') + expect(input.onError).not.toHaveBeenCalled() + expect(sendRequest).not.toHaveBeenCalled() + }) it('keeps ordinary messages on the existing send path', async () => { const { input, sendRequest } = setup() expect(await dispatchMobileStructuredCommand({ ...input, text: 'hello' })).toBeNull() diff --git a/mobile/src/session/mobile-structured-composer-command.ts b/mobile/src/session/mobile-structured-composer-command.ts index c90d091dcb9..db0dd3b5d9e 100644 --- a/mobile/src/session/mobile-structured-composer-command.ts +++ b/mobile/src/session/mobile-structured-composer-command.ts @@ -2,29 +2,30 @@ import type { AgentSessionConversationCommand, AgentSessionConversationCommandResult } from '../../../src/shared/agent-session/agent-session-conversation-command' +import type { AgentSessionFailureFact } from '../../../src/shared/agent-session-failure' import { dispatchStructuredAgentSessionComposerCommand, isStructuredAgentSessionComposerCommand, + type StructuredAgentSessionCommandRefusalCause, type StructuredAgentSessionComposerOptions } from '../../../src/shared/structured-agent-session-composer' -import { structuredAgentSessionCommandHostRefusalCause } from '../../../src/shared/structured-agent-session-command-refusal-cause' +import { + conversationCommandReply, + heldConversationCommandParts, + structuredConversationCommandHold +} from '../../../src/shared/structured-conversation-command-hold' import { agentSessionWriteNoticeEnglish } from '../../../src/shared/agent-session-refusal-notice' import type { RpcClient } from '../transport/rpc-client' import type { MobileNativeChatSendOutcome } from './native-chat/mobile-native-chat-send' import { requestStructuredAgentSessionMutation } from './mobile-structured-agent-session-rpc' import type { MobileNativeChatSendErrorReporter } from './native-chat/use-mobile-native-chat-send-error' -function busyCommandText( - command: AgentSessionConversationCommand, - busy: 'working' | 'prompt' -): string { - const clear = command === 'clear' - return agentSessionWriteNoticeEnglish( - busy === 'prompt' - ? [clear ? 'clearAfterAnswer' : 'compactAfterAnswer'] - : ['agentStillWorking', clear ? 'runClearWhenDone' : 'runCompactWhenDone'] - ) -} +/** What the phone shows at keypress that holds a command; `background` also counts the strip mid-turn. */ +export type MobileStructuredCommandHolds = Readonly< + Record, boolean> & { + backgroundTasksRunning: boolean + } +> export async function dispatchMobileStructuredCommand(input: { text: string @@ -34,16 +35,16 @@ export async function dispatchMobileStructuredCommand(input: { fence: number pending: { current: boolean } controller: StructuredAgentSessionComposerOptions - busy: () => 'working' | 'prompt' | null + agentName?: string + holds: () => MobileStructuredCommandHolds + /** What the chat's loaded start-failure rows state, read when the reply lands. */ + startFailures: () => readonly AgentSessionFailureFact[] // Queued commands are held by the host; client work must not refuse them first. waitsInLine: (command: AgentSessionConversationCommand) => boolean /** `refusedWhile`: what the phone showed the refused command waiting on. */ onError: MobileNativeChatSendErrorReporter timeoutMs: number }): Promise { - if (input.pending.current) { - return 'rejected' - } if ( !isStructuredAgentSessionComposerCommand( input.text, @@ -51,7 +52,7 @@ export async function dispatchMobileStructuredCommand(input: { input.controller.sessionCommands ) ) { - return null + return input.pending.current ? 'rejected' : null } if (input.hasAttachments) { input.onError('Remove attachments before using a chat-session command.') @@ -61,11 +62,31 @@ export async function dispatchMobileStructuredCommand(input: { const outcome = await dispatchStructuredAgentSessionComposerCommand(input.text, { ...input.controller, runConversationCommand: async (command) => { - const shown = input.busy() + const held = (hold: Parameters[1]) => + agentSessionWriteNoticeEnglish(heldConversationCommandParts(command, hold)) + // A command on its way is the agent's work in flight. + if (input.pending.current) { + return { accepted: false, error: held('working') } + } + const shown = input.holds() const waitsInLine = input.waitsInLine(command) - const busy = waitsInLine ? null : shown - if (busy) { - return { accepted: false, error: busyCommandText(command, busy), refusedWhile: busy } + const hold = structuredConversationCommandHold({ + waitsInLine, + agentWorking: shown.working, + promptPending: shown.prompt, + backgroundTasksRunning: shown.backgroundTasksRunning, + sendPending: shown.sending + }) + // The message ahead is still sending; nothing is armed. + if (hold === 'ahead') { + return { accepted: false, error: null } + } + if (hold !== null) { + return { + accepted: false, + error: held(hold), + ...(shown[hold] ? { refusedWhile: hold } : {}) + } } input.pending.current = true try { @@ -92,13 +113,19 @@ export async function dispatchMobileStructuredCommand(input: { if (result.status !== 'accepted') { return { accepted: false, error: result.message } } + const reply = conversationCommandReply(result.value, { + agentName: input.agentName, + startFailures: input.startFailures() + }) // A refusal names its cause only when the phone showed it, as on desktop. - const cause = structuredAgentSessionCommandHostRefusalCause(result.value) return { - accepted: !result.value.error, - error: result.value.error ?? null, - ...(result.value.error && cause !== undefined && cause === shown - ? { refusedWhile: cause } + accepted: reply.accepted, + error: reply.error, + ...(reply.error && + reply.cause !== undefined && + reply.cause !== 'retry' && + shown[reply.cause] + ? { refusedWhile: reply.cause } : {}) } } finally { diff --git a/mobile/src/session/mobile-structured-queued-message-cards.test.ts b/mobile/src/session/mobile-structured-queued-message-cards.test.ts index 013dda3a817..260b329ca6a 100644 --- a/mobile/src/session/mobile-structured-queued-message-cards.test.ts +++ b/mobile/src/session/mobile-structured-queued-message-cards.test.ts @@ -1,13 +1,9 @@ import { describe, expect, it } from 'vitest' import { agentSessionFailureFact } from '../../../src/shared/agent-session-failure' import { agentSessionFailureWords } from '../../../src/shared/agent-session-failure-words' -import { DISPATCH_REJECTED_HOST_RESTARTED } from '../../../src/shared/structured-agent-session-dispatch-rejection' import { QUEUED_MESSAGE_PAUSED_SEND_FAILED } from '../../../src/shared/agent-session-wire' import type { AgentSessionQueuedMessage } from '../../../src/shared/agent-session-wire' -import { - mobileQueueHasResumableCard, - mobileQueuedMessageCards -} from './mobile-structured-queued-message-cards' +import { mobileQueuedMessageCards } from './mobile-structured-queued-message-cards' function returnedAs(fact: Parameters[0]) { const { reason, rejection } = agentSessionFailureWords(fact, { surface: 'rejection' }) @@ -28,77 +24,6 @@ function draft(overrides: Partial & { messageId: stri } describe('mobileQueuedMessageCards', () => { - it.each([ - ['Claude', 'claude auth login'], - ['Codex', 'codex login'], - ['Grok', 'grok login'], - ['OpenCode', 'opencode auth login'], - ['Pi', '/login'], - ['OMP', 'Sign in to OMP.'] - ])('keeps %s identity and its sign-in action on returned cards', (agentName, guidance) => { - const [card] = mobileQueuedMessageCards( - [draft({ messageId: 'auth', ...returnedAs(agentSessionFailureFact('notSignedIn')) })], - [], - { agentName } - ) - expect(card?.caption).toContain(agentName) - expect(card?.caption).toContain(guidance) - expect(card?.caption).not.toContain('send your message again') - expect(card?.needsAttention).toBe(true) - }) - - it.each(['Claude', 'Codex'])( - 'keeps %s managed guidance unless a row already explains it', - (agentName) => { - const fact = agentSessionFailureFact('notSignedIn', { account: 'managed' }) - const drafts = [draft({ messageId: 'auth', ...returnedAs(fact) })] - const [card] = mobileQueuedMessageCards(drafts, [], { agentName }) - expect(card?.caption).toBe( - `This ${agentName} account isn't signed in. Sign in again in ${agentName} Accounts settings.` - ) - const [stated] = mobileQueuedMessageCards(drafts, [], { - agentName, - statedFailures: [fact] - }) - expect(stated?.caption).toBe('Your message was not sent.') - } - ) - - it('marks a /compact card as a command, its text as typed', () => { - const [card] = mobileQueuedMessageCards( - [ - draft({ - messageId: 'c', - body: { - kind: 'message', - role: 'user', - blocks: [{ type: 'text', text: '/compact' }], - command: { name: 'compact' } - } - }) - ], - [] - ) - expect(card).toMatchObject({ text: '/compact', command: true, caption: null }) - expect(card).not.toHaveProperty('waitsForAgent') - expect(mobileQueuedMessageCards([draft({ messageId: 'a' })], [])[0]).not.toHaveProperty( - 'command' - ) - const compact = draft({ - messageId: 'c', - body: { - kind: 'message', - role: 'user', - blocks: [{ type: 'text', text: '/compact' }], - command: { name: 'compact' } - } - }) - expect(mobileQueuedMessageCards([compact], [], { agentWorking: true })[0]).toMatchObject({ - command: true, - waitsForAgent: true - }) - }) - it("a send-failed command card's caption names Send only when Send is there", () => { const failed = draft({ messageId: 'c', @@ -118,25 +43,6 @@ describe('mobileQueuedMessageCards', () => { "Couldn't send — tap Send to retry" ) }) - - it('renders nothing without a published list', () => { - expect(mobileQueuedMessageCards(null, [])).toEqual([]) - expect(mobileQueuedMessageCards([], [])).toEqual([]) - }) - - it('captions nothing on a plain waiting draft, as the desktop row', () => { - const [card] = mobileQueuedMessageCards([draft({ messageId: 'a' })], []) - expect(card).toEqual({ - messageId: 'a', - text: 'body of a', - state: 'waiting', - paused: false, - needsAttention: false, - caption: null, - attribution: null - }) - }) - it("keeps a card's own failed send beside a plainly queued one", () => { const cards = mobileQueuedMessageCards( [ @@ -146,34 +52,11 @@ describe('mobileQueuedMessageCards', () => { [] ) expect(cards.map((card) => card.caption)).toEqual(["Couldn't send — tap Send to retry", null]) - expect(cards[0]?.paused).toBe(true) }) - - it('finds something for Resume to send only in a waiting card with no hold, ahead of a returned one', () => { - const resumable = (drafts: AgentSessionQueuedMessage[]) => - mobileQueueHasResumableCard(mobileQueuedMessageCards(drafts, [])) - const returned = draft({ - messageId: 'r', - ...returnedAs(agentSessionFailureFact('hostRestarted')) - }) - const failed = draft({ - messageId: 'f', - paused: true, - pausedReason: QUEUED_MESSAGE_PAUSED_SEND_FAILED - }) - const waiting = draft({ messageId: 'w', position: 2 }) - expect(resumable([returned])).toBe(false) - expect(resumable([returned, waiting])).toBe(false) - expect(resumable([failed])).toBe(false) - expect(resumable([failed, waiting])).toBe(true) - expect(resumable([waiting, { ...returned, position: 3 }])).toBe(true) - }) - it('captions a reasonless pause as a plain pause, promising no release rule', () => { const [card] = mobileQueuedMessageCards([draft({ messageId: 'a', paused: true })], []) expect(card?.caption).toBe('Paused') }) - it("shows a returned card with the provider's own words and holds drafts behind it", () => { const refused = agentSessionFailureFact('providerRejected', { detail: { text: 'Steering is unavailable', audience: 'person' } @@ -188,40 +71,6 @@ describe('mobileQueuedMessageCards', () => { expect(cards[1]?.caption).toBe('Waiting — a message ahead needs attention') expect(cards[1]?.needsAttention).toBe(false) }) - - it('hides a waiting card once its hand-off arrived, never a returned one', () => { - const cards = mobileQueuedMessageCards( - [ - draft({ messageId: 'returned', ...returnedAs(agentSessionFailureFact('hostRestarted')) }), - draft({ messageId: 'drained', position: 2 }), - draft({ messageId: 'waiting', position: 3 }) - ], - [ - { queuedMessageId: 'returned', dispatchState: 'rejected' }, - { queuedMessageId: 'drained', dispatchState: 'pending' } - ] - ) - expect(cards.map((card) => card.messageId)).toEqual(['returned', 'waiting']) - expect(cards[1]?.caption).toBe('Waiting — a message ahead needs attention') - }) - - it('shows a draft a Stop requeued beside its rejected hand-off', () => { - // Rejected hand-offs stay hidden in history; the requeued card is the only reachable copy of their text. - const cards = mobileQueuedMessageCards( - [ - draft({ messageId: 'requeued' }), - draft({ messageId: 'sending', position: 2 }), - draft({ messageId: 'sent', position: 3 }) - ], - [ - { queuedMessageId: 'requeued', dispatchState: 'rejected' }, - { queuedMessageId: 'sending', dispatchState: 'pending' }, - { queuedMessageId: 'sent', dispatchState: 'accepted' } - ] - ) - expect(cards.map((card) => card.messageId)).toEqual(['requeued']) - }) - it('maps a Stop-withdrawn returned card to its own English copy', () => { const [card] = mobileQueuedMessageCards( [draft({ messageId: 'a', ...returnedAs(agentSessionFailureFact('cancelled')) })], @@ -229,74 +78,6 @@ describe('mobileQueuedMessageCards', () => { ) expect(card?.caption).toBe('Stopped before it was sent') }) - - it('reads a Stop withdrawal from the fact, whatever sentence rides beside it', () => { - const [card] = mobileQueuedMessageCards( - [ - draft({ - messageId: 'a', - state: 'returned', - returnedReason: 'This message was withdrawn before the agent started it.', - returnedRejection: { kind: 'cancelled' } - }) - ], - [] - ) - expect(card?.caption).toBe('Stopped before it was sent') - }) - - it('words a host-restart returned card from its fact, as a rejected send', () => { - const [card] = mobileQueuedMessageCards( - [draft({ messageId: 'a', ...returnedAs(agentSessionFailureFact('hostRestarted')) })], - [] - ) - expect(card?.caption).toBe('Orca restarted before this message was sent.') - }) - - it("keeps a provider's log-only detail off the card", () => { - const refused = agentSessionFailureFact('providerRejected', { - detail: { text: 'stack trace for the log', audience: 'log' } - }) - const [card] = mobileQueuedMessageCards([draft({ messageId: 'a', ...returnedAs(refused) })], []) - expect(card?.caption).not.toContain('stack trace') - }) - - it("words a fact kind this build cannot place with the host's sentence", () => { - const [card] = mobileQueuedMessageCards( - [ - draft({ - messageId: 'a', - state: 'returned', - returnedReason: 'Words for a kind a newer host added.', - returnedRejection: { kind: 'laterKind' } - }) - ], - [] - ) - expect(card?.caption).toBe('Words for a kind a newer host added.') - }) - - it("shows the host's sentence for a fact this build cannot read all of, as the desktop card", () => { - const reason = "Claude couldn't start. Start a new chat to continue." - // As a newer host sends it: a known code with a reason this build doesn't know. - const newer = { - kind: 'startFailed', - refusal: { code: 'agent_session_conflict', details: { reason: 'newerReason' } } - } - const [card] = mobileQueuedMessageCards( - [ - draft({ - messageId: 'a', - state: 'returned', - returnedReason: reason, - returnedRejection: newer - }) - ], - [] - ) - expect(card?.caption).toBe(reason) - }) - it('maps the send-failed pause marker to English and an unknown marker to a plain pause', () => { const cards = mobileQueuedMessageCards( [ @@ -313,18 +94,20 @@ describe('mobileQueuedMessageCards', () => { ]) expect(cards.map((card) => card.needsAttention)).toEqual([true, false]) }) - - it('never shows an internal rejection reason verbatim, even from a host that wrote no fact', () => { - const [card] = mobileQueuedMessageCards( - [ - draft({ - messageId: 'a', - state: 'returned', - returnedReason: DISPATCH_REJECTED_HOST_RESTARTED - }) - ], + it("reads a card of images alone by the image's name or their count", () => { + const images = (...paths: string[]) => + draft({ + messageId: paths.join(), + body: { + kind: 'message', + role: 'user', + blocks: paths.map((path) => ({ type: 'image-ref' as const, path })) + } + }) + const cards = mobileQueuedMessageCards( + [images('/tmp/shot.png'), images('/tmp/orca-paste-1.png'), images('/a.png', '/b.png')], [] ) - expect(card?.caption).toBe('Your message was not sent.') + expect(cards.map((card) => card.line)).toEqual(['shot.png', 'Pasted image', '2 images']) }) }) diff --git a/mobile/src/session/mobile-structured-queued-message-cards.ts b/mobile/src/session/mobile-structured-queued-message-cards.ts index 53d94fe29ec..c6513a05207 100644 --- a/mobile/src/session/mobile-structured-queued-message-cards.ts +++ b/mobile/src/session/mobile-structured-queued-message-cards.ts @@ -1,150 +1,75 @@ -// Derive queue captions from draft/live facts because the host sends no hold copy. +// The shared projection decides each card's hold; this layer words it for the phone. -import { - readWholeAgentSessionFailureFact, - type AgentSessionFailureFact -} from '../../../src/shared/agent-session-failure' -import { agentSessionFailureStatedByRow } from '../../../src/shared/agent-session-visible-failures' -import { - agentSessionWriteNotDoneParts, - agentSessionWriteNoticeEnglish -} from '../../../src/shared/agent-session-refusal-notice' +import type { AgentSessionFailureFact } from '../../../src/shared/agent-session-failure' +import { agentSessionWriteNoticeEnglish } from '../../../src/shared/agent-session-refusal-notice' import type { AgentJournalSubmission } from '../../../src/shared/agent-session-journal-types' -import { dispatchWasWithdrawn } from '../../../src/shared/structured-agent-session-dispatch-rejection' -import { structuredAgentSessionAttemptFailureParts } from '../../../src/shared/structured-agent-session-rejection-words' import { QUEUED_MESSAGE_PAUSED_SEND_FAILED, type AgentSessionQueuedMessage } from '../../../src/shared/agent-session-wire' -import { readAgentMessageSource } from '../../../src/shared/agent-session/agent-session-message-source' +import { + projectQueuedMessageCards, + queuedMessageCardLine, + queuedMessageCardNeedsAttention, + queuedMessageReturnedNotice, + type QueuedMessageCard +} from '../../../src/shared/structured-agent-session-queued-cards' import { agentMessageAttribution } from './mobile-agent-message-attribution' -export type MobileQueuedMessageCard = { - messageId: string - automatic?: true - text: string - state: 'waiting' | 'returned' - paused: boolean +export type MobileQueuedMessageCard = QueuedMessageCard & { + /** The text, or for a card of images alone, the image's name or their count. */ + line: string needsAttention: boolean // Leave plain waits uncaptioned; the question or queue-pause row already explains the hold. caption: string | null attribution: string | null - /** A conversation command such as /compact: it never steers into a running turn. */ - command?: true - /** A command card while the agent works: it offers no send until the agent is idle. */ - waitsForAgent?: true } -function queuedMessageBodyText(body: AgentSessionQueuedMessage['body']): string { - return body.blocks.flatMap((block) => (block.type === 'text' ? [block.text] : [])).join('\n') -} - -function returnedCaption( - draft: Pick, - agentName?: string, - statedFailures: readonly AgentSessionFailureFact[] = [] -): string { - const reason = draft.returnedReason ?? null - const rejection = draft.returnedRejection - if (agentSessionFailureStatedByRow(rejection, statedFailures)) { - return agentSessionWriteNoticeEnglish(agentSessionWriteNotDoneParts('send')) - } - if (dispatchWasWithdrawn({ dispatchState: 'rejected', reason, rejection })) { - return 'Stopped before it was sent' - } - // Reuse desktop failure wording but omit retry instructions: this card’s Send is the retry. - return agentSessionWriteNoticeEnglish( - structuredAgentSessionAttemptFailureParts( - { kind: 'rejected', reason }, - { agentName, retryControl: true }, - readWholeAgentSessionFailureFact(rejection) - ) - ) -} - -/** One card's own hold: a failed conversion; the queue's pause is the list's header. */ -function pausedCaption(reason: string | undefined, waitsForAgent: boolean): string { - if (reason === QUEUED_MESSAGE_PAUSED_SEND_FAILED) { - return waitsForAgent - ? "Couldn't send — tap Send to retry once the agent finishes" - : "Couldn't send — tap Send to retry" - } - // Absent or unknown (newer host) marker: a plain pause, promising no release rule. - return 'Paused' -} - -// Returned cards stop the drain, so waiting cards behind one cannot make Resume useful. -export function mobileQueueHasResumableCard(cards: readonly MobileQueuedMessageCard[]): boolean { - for (const card of cards) { - if (card.state === 'returned') { - return false - } - if (!card.paused) { - return true +function queuedMessageCardCaption( + card: QueuedMessageCard, + agentName: string | undefined, + statedFailures: readonly AgentSessionFailureFact[] +): string | null { + switch (card.hold) { + case 'returned': { + const notice = queuedMessageReturnedNotice(card, agentName, statedFailures) + return notice === 'withdrawn' + ? 'Stopped before it was sent' + : agentSessionWriteNoticeEnglish(notice) } + case 'paused': + // Absent or unknown (newer host) marker: a plain pause, promising no release rule. + if (card.pausedReason !== QUEUED_MESSAGE_PAUSED_SEND_FAILED) { + return 'Paused' + } + return card.waitsForAgent + ? "Couldn't send — tap Send to retry once the agent finishes" + : "Couldn't send — tap Send to retry" + case 'behind-returned': + return 'Waiting — a message ahead needs attention' + // The phone draws no card for a send still on its way. + case 'sending': + case 'turn': + case 'queue-paused': + return null } - return false } -// Hide waiting cards with non-rejected hand-offs until catch-up publishes the final queue; rejected hand-offs and returned cards must remain reachable. export function mobileQueuedMessageCards( - queuedMessages: readonly AgentSessionQueuedMessage[] | null, - submissions: readonly Pick[], + queuedMessages: readonly AgentSessionQueuedMessage[] | null | undefined, + submissions: readonly AgentJournalSubmission[], facts: { agentWorking?: boolean + queuePaused?: boolean agentName?: string statedFailures?: readonly AgentSessionFailureFact[] } = {} ): MobileQueuedMessageCard[] { - if (!queuedMessages || queuedMessages.length === 0) { - return [] - } - const handedOff = new Set( - submissions.flatMap((submission) => - submission.queuedMessageId !== undefined && submission.dispatchState !== 'rejected' - ? [submission.queuedMessageId] - : [] - ) - ) - let behindReturned = false - const cards: MobileQueuedMessageCard[] = [] - for (const draft of queuedMessages) { - if (draft.state !== 'returned' && handedOff.has(draft.messageId)) { - continue - } - const paused = draft.paused === true - const caption = - draft.state === 'returned' - ? returnedCaption(draft, facts.agentName, facts.statedFailures) - : paused - ? pausedCaption( - draft.pausedReason, - draft.body.command !== undefined && facts.agentWorking === true - ) - : behindReturned - ? 'Waiting — a message ahead needs attention' - : null - cards.push({ - messageId: draft.messageId, - ...(draft.body.from !== undefined ? { automatic: true as const } : {}), - text: queuedMessageBodyText(draft.body), - state: draft.state, - paused, - needsAttention: - draft.state === 'returned' || - (paused && draft.pausedReason === QUEUED_MESSAGE_PAUSED_SEND_FAILED), - caption, - attribution: agentMessageAttribution('From', readAgentMessageSource(draft.body.from)), - ...(draft.body.command !== undefined - ? { - command: true as const, - ...(facts.agentWorking ? { waitsForAgent: true as const } : {}) - } - : {}) - }) - if (draft.state === 'returned') { - behindReturned = true - } - } - return cards + return projectQueuedMessageCards(queuedMessages, submissions, facts).map((card) => ({ + ...card, + line: queuedMessageCardLine(card), + needsAttention: queuedMessageCardNeedsAttention(card), + caption: queuedMessageCardCaption(card, facts.agentName, facts.statedFailures ?? []), + attribution: agentMessageAttribution('From', card.from) + })) } diff --git a/mobile/src/session/mobile-structured-queued-message-feed.test.ts b/mobile/src/session/mobile-structured-queued-message-feed.test.ts deleted file mode 100644 index 3fc742b48b8..00000000000 --- a/mobile/src/session/mobile-structured-queued-message-feed.test.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { - AgentSessionQueuedMessage, - AgentSessionQueuePause, - AgentSessionSubscribeEvent -} from '../../../src/shared/agent-session-wire' -import { - reduceMobileQueuePause, - reduceMobileQueuedMessageFeed -} from './mobile-structured-queued-message-feed' - -function draft(messageId: string, position: number): AgentSessionQueuedMessage { - return { - messageId, - position, - body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: `text ${messageId}` }] }, - state: 'waiting' - } -} - -function batch( - queuedMessages?: AgentSessionQueuedMessage[] | null, - queuePause?: AgentSessionQueuePause | null -): AgentSessionSubscribeEvent { - return { - type: 'batch', - sessionId: 'session-1', - batch: { cursor: { epoch: 'e', sequence: 1 }, items: [], removedItemIds: [], submissions: [] }, - ...(queuedMessages !== undefined ? { queuedMessages } : {}), - ...(queuePause !== undefined ? { queuePause } : {}) - } -} - -describe('reduceMobileQueuePause', () => { - it('rides with the list: a frame without the list keeps it, one with the list states it', () => { - const paused = reduceMobileQueuePause(null, batch([draft('a', 1)], { reason: 'stopped' })) - expect(paused).toEqual({ reason: 'stopped' }) - expect(reduceMobileQueuePause(paused, batch())).toBe(paused) - expect(reduceMobileQueuePause(paused, batch([draft('a', 1)], { reason: 'stopped' }))).toBe( - paused - ) - expect(reduceMobileQueuePause(paused, batch([draft('a', 1)]))).toBeNull() - }) - - it('keeps a pause that names no reason', () => { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a malformed or newer host's pause, with no reason this build can read. - const reasonless = {} as AgentSessionQueuePause - const paused = reduceMobileQueuePause(null, batch([draft('a', 1)], reasonless)) - expect(paused).toBe(reasonless) - }) -}) - -describe('reduceMobileQueuedMessageFeed', () => { - it('holds no claim until the host publishes the field', () => { - expect(reduceMobileQueuedMessageFeed(null, batch())).toBeNull() - }) - - it('adopts a published list ordered by position', () => { - const next = reduceMobileQueuedMessageFeed(null, batch([draft('b', 2), draft('a', 1)])) - expect(next?.map((entry) => entry.messageId)).toEqual(['a', 'b']) - }) - - it('keeps the last list when a frame omits the field', () => { - const held = reduceMobileQueuedMessageFeed(null, batch([draft('a', 1)])) - expect(reduceMobileQueuedMessageFeed(held, batch())).toBe(held) - }) - - it('reads null as empty', () => { - const held = reduceMobileQueuedMessageFeed(null, batch([draft('a', 1)])) - expect(reduceMobileQueuedMessageFeed(held, batch(null))).toEqual([]) - }) - - it('never advances on the end frame', () => { - const held = reduceMobileQueuedMessageFeed(null, batch([draft('a', 1)])) - expect(reduceMobileQueuedMessageFeed(held, { type: 'end' })).toBe(held) - }) -}) diff --git a/mobile/src/session/mobile-structured-queued-message-feed.ts b/mobile/src/session/mobile-structured-queued-message-feed.ts deleted file mode 100644 index f5e97ff0b3c..00000000000 --- a/mobile/src/session/mobile-structured-queued-message-feed.ts +++ /dev/null @@ -1,47 +0,0 @@ -// Live lists are authoritative: absent means unchanged and null means empty; history pages must never replace newer queue state. - -import type { - AgentSessionQueuedMessage, - AgentSessionQueuePause, - AgentSessionSubscribeEvent -} from '../../../src/shared/agent-session-wire' - -/** Null = no claim yet (older host, or nothing published on this stream). */ -export type MobileQueuedMessageFeed = AgentSessionQueuedMessage[] | null - -/** The whole queue's pause; null when it sends on its own or nothing was published. */ -export type MobileQueuePause = AgentSessionQueuePause | null - -// List and pause publish together; omitted lists preserve both. -export function reduceMobileQueuePause( - previous: MobileQueuePause, - event: AgentSessionSubscribeEvent -): MobileQueuePause { - if (event.type === 'end' || event.queuedMessages === undefined) { - return previous - } - const next = event.queuePause ?? null - // Presence first: a pause with no reason, or one this build does not know, is still a pause. - if ((next === null) !== (previous === null)) { - return next - } - return next?.reason === previous?.reason ? previous : next -} - -export function reduceMobileQueuedMessageFeed( - previous: MobileQueuedMessageFeed, - event: AgentSessionSubscribeEvent -): MobileQueuedMessageFeed { - if (event.type === 'end') { - return previous - } - if (event.queuedMessages === undefined) { - return previous - } - const list = event.queuedMessages ?? [] - if (list.length === 0 && previous !== null && previous.length === 0) { - return previous - } - // Use host positions so publication order cannot reorder cards. - return [...list].sort((left, right) => left.position - right.position) -} diff --git a/mobile/src/session/native-chat/composer/MobileNativeChatComposer.test.ts b/mobile/src/session/native-chat/composer/MobileNativeChatComposer.test.ts index 1ff76aa5412..890aa689a01 100644 --- a/mobile/src/session/native-chat/composer/MobileNativeChatComposer.test.ts +++ b/mobile/src/session/native-chat/composer/MobileNativeChatComposer.test.ts @@ -512,6 +512,35 @@ describe('MobileNativeChatComposer', () => { expect(input().props.selection).toBeUndefined() }) + it.each([ + ['then /cl', '/clear', 'then /clear '], + ['see @do', 'docs/my notes.md', 'see @"docs/my notes.md" '] + ])('completes the mid-prompt token in %j like desktop', async (value, label, expected) => { + const onChangeText = vi.fn() + await act(async () => { + renderer = create( + createElement(MobileNativeChatComposer, { + value, + onChangeText, + onSend: vi.fn().mockResolvedValue(true), + sendSurfaceId: 'tab-a', + getSendCompletionGeneration: getCurrentSendCompletionGeneration, + agent: 'claude', + filePaths: ['docs/my notes.md'] + }) + ) + }) + const input = findByHostType(renderer!.root, 'TextInput') + await act(async () => + input.props.onSelectionChange({ nativeEvent: { selection: { end: value.length } } }) + ) + const row = renderer!.root.find( + (node) => node.props.accessibilityLabel === label && node.props.onPress !== undefined + ) + await act(async () => row.props.onPress()) + expect(onChangeText).toHaveBeenCalledWith(expected) + }) + it('serves the active agent’s shared command catalog with descriptions', async () => { await act(async () => { renderer = create( diff --git a/mobile/src/session/native-chat/composer/MobileNativeChatComposer.tsx b/mobile/src/session/native-chat/composer/MobileNativeChatComposer.tsx index 72ea58045e6..ac6225c5a4c 100644 --- a/mobile/src/session/native-chat/composer/MobileNativeChatComposer.tsx +++ b/mobile/src/session/native-chat/composer/MobileNativeChatComposer.tsx @@ -1,3 +1,4 @@ +import type { NativeChatSkillDiscoverySnapshot } from '../../../../../src/shared/native-chat/input/native-chat-picker-items' import { useEffect, useLayoutEffect, useRef, useState } from 'react' import { ActivityIndicator, Keyboard, Pressable, type TextInput, View } from 'react-native' import { ArrowUp, Mic, Plus, Square } from 'lucide-react-native' @@ -21,7 +22,10 @@ import { keepHeldPressThroughLongPress } from '../../held-press-long-press' import { mobileNativeChatComposerShowsStop } from './mobile-native-chat-composer-layout' import { MobileNativeChatStopButton } from '../input/MobileNativeChatStopButton' import { mobileNativeChatComposerStyles as styles } from './mobile-native-chat-composer-styles' -import { useMobileNativeChatDraft } from './mobile-native-chat-draft-store' +import { + recordMobileNativeChatSkillPick, + useMobileNativeChatDraft +} from './mobile-native-chat-draft-store' const NO_FILE_PATHS: string[] = [] const NO_ATTACHMENTS: PendingNativeChatImage[] = [] @@ -65,6 +69,9 @@ type Props = { placeholder?: string filePaths?: string[] onNeedFiles?: (query: string) => void + /** The host's disk-scanned skills, loaded once a skill trigger opens. */ + skillDiscovery?: NativeChatSkillDiscoverySnapshot + onNeedSkills?: () => void /** Set while the agent can be stopped; Stop takes Send's slot when Send can't deliver. */ stop?: { onStop?: () => void; held: boolean } | null } @@ -94,6 +101,8 @@ export function MobileNativeChatComposer({ placeholder = NATIVE_CHAT_COMPOSER_PLACEHOLDER, filePaths = NO_FILE_PATHS, onNeedFiles, + skillDiscovery, + onNeedSkills, stop = null }: Props): React.JSX.Element { const fallbackInputRef = useRef(null) @@ -115,7 +124,11 @@ export function MobileNativeChatComposer({ structuredCatalog: structured?.catalog, filePaths, onNeedFiles, + skillDiscovery, + onNeedSkills, onSlashMenuOpen: structured?.onSlashMenuOpen, + // The surface id is the draft key both send paths read picks back from. + onPickSkill: (skill) => recordMobileNativeChatSkillPick(sendSurfaceId, skill), focusField: () => fieldRef.current?.focus() }) const sendingRef = useRef(false) diff --git a/mobile/src/session/native-chat/composer/MobileNativeChatComposerMenu.tsx b/mobile/src/session/native-chat/composer/MobileNativeChatComposerMenu.tsx index 90265aa6c49..7ce65ff3750 100644 --- a/mobile/src/session/native-chat/composer/MobileNativeChatComposerMenu.tsx +++ b/mobile/src/session/native-chat/composer/MobileNativeChatComposerMenu.tsx @@ -5,10 +5,6 @@ import { colors, spacing, typography } from '../../../theme/mobile-theme' export type ComposerMenuRow = NativeChatPickerItem | { kind: 'file'; id: string; path: string } -export function composerMenuInsertText(row: ComposerMenuRow): string { - return row.kind === 'file' ? `@${row.path}` : row.token -} - const HEADINGS = { command: 'Commands', skill: 'Skills', file: 'Files' } as const export function MobileNativeChatComposerMenu({ @@ -47,9 +43,11 @@ function MenuRow({ [styles.row, pressed && styles.rowPressed]} onPress={() => onPick(row)} diff --git a/mobile/src/session/native-chat/composer/mobile-native-chat-autocomplete.test.ts b/mobile/src/session/native-chat/composer/mobile-native-chat-autocomplete.test.ts deleted file mode 100644 index 7cf0f29183f..00000000000 --- a/mobile/src/session/native-chat/composer/mobile-native-chat-autocomplete.test.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { - applyAutocomplete, - detectAutocompleteTrigger, - rankSuggestions -} from './mobile-native-chat-autocomplete' - -describe('detectAutocompleteTrigger', () => { - it('opens nothing while the caret sits before the trigger character', () => { - expect(detectAutocompleteTrigger('/review-pr focus', 0)).toBeNull() - expect(detectAutocompleteTrigger('see @src', 4)).toBeNull() - expect(detectAutocompleteTrigger('/re', 1)).toMatchObject({ kind: 'slash', query: '' }) - }) - - it('detects a slash command only at the start', () => { - expect(detectAutocompleteTrigger('/rev', 4)).toEqual({ - kind: 'slash', - query: 'rev', - start: 0, - end: 4 - }) - expect(detectAutocompleteTrigger('hi /rev', 7)).toBeNull() - }) - - it('detects an @ mention after whitespace or at start', () => { - expect(detectAutocompleteTrigger('@src', 4)).toMatchObject({ kind: 'file', query: 'src' }) - expect(detectAutocompleteTrigger('look at @comp', 13)).toMatchObject({ - kind: 'file', - query: 'comp' - }) - }) - - it('does not trigger @ mid-word (email-like)', () => { - expect(detectAutocompleteTrigger('me@host', 7)).toBeNull() - }) - - it('closes the token once a space is typed', () => { - expect(detectAutocompleteTrigger('@src ', 5)).toBeNull() - }) - - it('returns empty query right after the trigger char', () => { - expect(detectAutocompleteTrigger('@', 1)).toMatchObject({ kind: 'file', query: '' }) - }) -}) - -describe('applyAutocomplete', () => { - it('replaces the trigger span and leaves a trailing space + cursor', () => { - const trigger = detectAutocompleteTrigger('look at @comp', 13)! - const { text, cursor } = applyAutocomplete('look at @comp', trigger, '@src/App.tsx') - expect(text).toBe('look at @src/App.tsx ') - expect(cursor).toBe(text.length) - }) -}) - -describe('rankSuggestions', () => { - it('prefers prefix matches on the basename', () => { - const out = rankSuggestions(['src/app/Main.tsx', 'src/AppBar.tsx', 'lib/zapp.ts'], 'app') - expect(out[0]).toBe('src/AppBar.tsx') - expect(out).toContain('lib/zapp.ts') - }) - - it('returns the head of the list for an empty query', () => { - expect(rankSuggestions(['a', 'b', 'c'], '', 2)).toEqual(['a', 'b']) - }) -}) - -describe.each([{ name: 'file', rank: rankSuggestions }])('$name suggestion bounds', ({ rank }) => { - it('keeps later prefixes ahead of the earliest substring matches', () => { - const candidates = ['team-review', 'team-review', 'pre-review', 'review-a', 'REVIEW-b'] - expect(rank(candidates, 'REVIEW', 4)).toEqual([ - 'review-a', - 'REVIEW-b', - 'team-review', - 'team-review' - ]) - }) - - it('stops substring matching once enough fallback suggestions are retained', () => { - const candidates = Array.from({ length: 10_000 }, (_, index) => `team-review-${index}`) - candidates.push('review-last', 'review-final') - const includes = String.prototype.includes - let substringChecks = 0 - const spy = vi.spyOn(String.prototype, 'includes').mockImplementation(function ( - this: string, - search: string, - position?: number - ) { - substringChecks += 1 - return includes.call(this, search, position) - }) - let result: string[] - try { - result = rank(candidates, 'review', 8) - } finally { - spy.mockRestore() - } - expect(result).toEqual(['review-last', 'review-final', ...candidates.slice(0, 6)]) - expect(substringChecks).toBeLessThanOrEqual(8) - }) - - it.each([0, -0, -1, -0.5, -Infinity, Number.NaN])( - 'preserves empty results for limit %s', - (limit) => { - expect(rank(['team-review', 'review-a', 'review-b'], 'review', limit)).toEqual([]) - } - ) - - it.each([0.5, 1.5, 2.5, Infinity])('preserves slice truncation for limit %s', (limit) => { - const candidates = ['team-review', 'pre-review', 'review-a', 'review-b'] - expect(rank(candidates, 'review', limit)).toEqual( - ['review-a', 'review-b', 'team-review', 'pre-review'].slice(0, limit) - ) - expect(rank(candidates, '', limit)).toEqual(candidates.slice(0, limit)) - }) -}) diff --git a/mobile/src/session/native-chat/composer/mobile-native-chat-autocomplete.ts b/mobile/src/session/native-chat/composer/mobile-native-chat-autocomplete.ts deleted file mode 100644 index 7ab523d047a..00000000000 --- a/mobile/src/session/native-chat/composer/mobile-native-chat-autocomplete.ts +++ /dev/null @@ -1,78 +0,0 @@ -export type AutocompleteKind = 'file' | 'slash' - -export type AutocompleteTrigger = { - kind: AutocompleteKind - query: string - start: number - end: number -} - -const TOKEN_CHAR = /[^\s]/ - -export function detectAutocompleteTrigger( - text: string, - cursor: number -): AutocompleteTrigger | null { - const pos = Math.max(0, Math.min(cursor, text.length)) - let i = pos - 1 - while (i >= 0 && TOKEN_CHAR.test(text[i]!)) { - i-- - } - const triggerIndex = i + 1 - if (pos <= triggerIndex) { - return null - } - const triggerChar = text[triggerIndex] - if (triggerChar !== '@' && triggerChar !== '/') { - return null - } - const before = triggerIndex === 0 ? '' : text[triggerIndex - 1]! - if (triggerChar === '/' && triggerIndex !== 0) { - return null - } - if (triggerChar === '@' && triggerIndex !== 0 && !/\s/.test(before)) { - return null - } - const query = text.slice(triggerIndex + 1, pos) - if (/\s/.test(query)) { - return null - } - return { - kind: triggerChar === '@' ? 'file' : 'slash', - query, - start: triggerIndex, - end: pos - } -} - -export function applyAutocomplete( - text: string, - trigger: AutocompleteTrigger, - value: string -): { text: string; cursor: number } { - const inserted = `${value} ` - const next = text.slice(0, trigger.start) + inserted + text.slice(trigger.end) - return { text: next, cursor: trigger.start + inserted.length } -} - -export function rankSuggestions(candidates: readonly string[], query: string, limit = 8): string[] { - const q = query.toLowerCase() - if (q.length === 0) { - return candidates.slice(0, limit) - } - const prefix: string[] = [] - const substring: string[] = [] - for (const candidate of candidates) { - const lower = candidate.toLowerCase() - const base = lower.slice(lower.lastIndexOf('/') + 1) - if (lower.startsWith(q) || base.startsWith(q)) { - prefix.push(candidate) - } else if (substring.length < limit && lower.includes(q)) { - substring.push(candidate) - } - if (prefix.length >= limit) { - break - } - } - return [...prefix, ...substring].slice(0, limit) -} diff --git a/mobile/src/session/native-chat/composer/mobile-native-chat-composer-menu.test.ts b/mobile/src/session/native-chat/composer/mobile-native-chat-composer-menu.test.ts index d33c0f589da..ec7d3e510be 100644 --- a/mobile/src/session/native-chat/composer/mobile-native-chat-composer-menu.test.ts +++ b/mobile/src/session/native-chat/composer/mobile-native-chat-composer-menu.test.ts @@ -1,12 +1,37 @@ import { describe, expect, it } from 'vitest' +import { deriveComposerAutocomplete } from '../../../../../src/shared/native-chat-composer-autocomplete' +import type { NativeChatSkillDiscoverySnapshot } from '../../../../../src/shared/native-chat/input/native-chat-picker-items' import { + boundedMenuItems, draftAfterSkillEdit, narrowedMenuItems, leadingSkillToken, + mobileComposerCatalog, mobileComposerSkillTokens, - mobileComposerSlashItems + type MobileComposerStructuredCatalog } from './mobile-native-chat-composer-menu' +function mobileComposerSlashItems(input: { + agent: string + structured: MobileComposerStructuredCatalog | undefined + query: string + discovery?: NativeChatSkillDiscoverySnapshot +}) { + const catalog = mobileComposerCatalog(input) + const draft = `/${input.query}` + const result = deriveComposerAutocomplete( + draft, + draft.length, + catalog.commands, + catalog.discovery.skills, + catalog.profile, + catalog.discovery, + null, + catalog.sessionSkills + ) + return result.mode === 'slash' ? boundedMenuItems(result.items) : [] +} + const reported = [ { name: 'compact', kind: 'command' as const, description: 'Compact the chat' }, { name: 'review-pr', kind: 'skill' as const, description: 'Review a pull request' }, @@ -30,15 +55,6 @@ describe('mobileComposerSlashItems', () => { ]) }) - it('narrows by the typed query across both groups', () => { - const items = mobileComposerSlashItems({ - agent: 'claude', - structured: { reported, conversationCommands: [] }, - query: 'rev' - }) - expect(items.map((item) => item.token)).toEqual(['/review-pr']) - }) - it('names skills the way the agent invokes them', () => { const items = mobileComposerSlashItems({ agent: 'codex', @@ -58,6 +74,34 @@ describe('mobileComposerSlashItems', () => { expect(items.some((item) => item.kind === 'skill')).toBe(false) }) + it("lists the host's scanned skills until the session reports its own", () => { + const discovery: NativeChatSkillDiscoverySnapshot = { + status: 'ready', + skills: [ + { + id: 'triage', + name: 'triage', + description: 'Triage an issue', + providers: ['agent-skills'], + sourceKind: 'repo', + sourceLabel: 'Repo', + rootPath: '/repo/.agents/skills', + directoryPath: '/repo/.agents/skills/triage', + skillFilePath: '/repo/.agents/skills/triage/SKILL.md', + installed: true, + updatedAt: null + } + ] + } + const tokens = (structured: MobileComposerStructuredCatalog | undefined) => + mobileComposerSlashItems({ agent: 'claude', structured, query: '', discovery }) + .filter((item) => item.kind === 'skill') + .map((item) => item.token) + expect(tokens(undefined)).toEqual(['/triage']) + expect(tokens({ reported: undefined, conversationCommands: [] })).toEqual(['/triage']) + expect(tokens({ reported, conversationCommands: [] })).toEqual(['/research', '/review-pr']) + }) + it('keeps a terminal-backed chat on its curated commands', () => { const items = mobileComposerSlashItems({ agent: 'claude', structured: undefined, query: '' }) expect(items.length).toBeGreaterThan(0) @@ -74,7 +118,7 @@ describe('mobileComposerSkillTokens', () => { const structured = { reported: many, conversationCommands: [] } const drawn = mobileComposerSlashItems({ agent: 'claude', structured, query: '' }) expect(drawn.filter((item) => item.kind === 'skill')).toHaveLength(10) - const tokens = mobileComposerSkillTokens({ agent: 'claude', structured }) + const tokens = mobileComposerSkillTokens(mobileComposerCatalog({ agent: 'claude', structured })) expect(tokens.size).toBe(60) expect(tokens.has('/skill-59')).toBe(true) }) diff --git a/mobile/src/session/native-chat/composer/mobile-native-chat-composer-menu.ts b/mobile/src/session/native-chat/composer/mobile-native-chat-composer-menu.ts index d17ebb74571..4b1422fb87e 100644 --- a/mobile/src/session/native-chat/composer/mobile-native-chat-composer-menu.ts +++ b/mobile/src/session/native-chat/composer/mobile-native-chat-composer-menu.ts @@ -1,17 +1,29 @@ import type { AgentSessionConversationCommand } from '../../../../../src/shared/agent-session/agent-session-conversation-command' import type { AgentSessionSlashCommand } from '../../../../../src/shared/agent-session-wire' -import { getNativeChatAgentProfile } from '../../../../../src/shared/native-chat-agent-profiles' +import { + getNativeChatAgentProfile, + type NativeChatAgentProfile +} from '../../../../../src/shared/native-chat-agent-profiles' +import { nativeChatPickerScannedSkills } from '../../../../../src/shared/native-chat-composer-autocomplete' import { buildNativeChatPickerItems, - type NativeChatPickerItem + type NativeChatPickerItem, + type NativeChatSkillDiscoverySnapshot } from '../../../../../src/shared/native-chat/input/native-chat-picker-items' -import { sessionReportedSkills } from '../../../../../src/shared/native-chat/input/native-chat-slash-commands' +import { + nativeChatPickerCommands, + nativeChatSessionSkills +} from '../../../../../src/shared/native-chat-skill-visibility' +import type { + NativeChatSessionSkill, + SlashCommandSuggestion +} from '../../../../../src/shared/native-chat/input/native-chat-slash-commands' import { structuredSessionSlashCommands } from '../../../../../src/shared/structured-agent-session-composer' import { getMobileNativeChatCommands } from '../mobile-native-chat-send-classification' // Why: the menu is a plain ScrollView right above a streaming transcript, so it mounts a bounded // number of rows; typing narrows the rest. -const MENU_ROWS_PER_GROUP = 10 +export const MENU_ROWS_PER_GROUP = 10 export type MobileComposerStructuredCatalog = { /** What the session reported for its `/` menu; undefined until read or on an older host. */ @@ -19,48 +31,62 @@ export type MobileComposerStructuredCatalog = { conversationCommands: readonly AgentSessionConversationCommand[] } -type SlashMenuInput = { - agent: string | null - structured: MobileComposerStructuredCatalog | undefined - query: string +export type MobileComposerCatalog = { + profile: NativeChatAgentProfile | null + commands: readonly SlashCommandSuggestion[] + discovery: NativeChatSkillDiscoverySnapshot + /** Undefined keeps the host disk scan, as on desktop. */ + sessionSkills: readonly NativeChatSessionSkill[] | undefined } -// Structured chats use reported commands/skills; terminal-backed chats retain curated commands without skills. -function slashItems( - { agent, structured, query }: SlashMenuInput, - limit: number -): NativeChatPickerItem[] { +const NO_DISCOVERY: NativeChatSkillDiscoverySnapshot = { status: 'ready', skills: [] } + +// Structured chats use reported commands/skills; terminal-backed chats keep curated commands. +export function mobileComposerCatalog({ + agent, + structured, + discovery = NO_DISCOVERY +}: { + agent: string | null + structured: MobileComposerStructuredCatalog | undefined + discovery?: NativeChatSkillDiscoverySnapshot +}): MobileComposerCatalog { if (!agent) { - return [] + return { profile: null, commands: [], discovery: NO_DISCOVERY, sessionSkills: [] } } const reported = structured?.reported const commands = structured ? structuredSessionSlashCommands(agent, structured.conversationCommands, reported) : getMobileNativeChatCommands(agent) - return buildNativeChatPickerItems( - commands, - [], - query, - getNativeChatAgentProfile(agent)?.skillPrefix ?? '/', - reported ? sessionReportedSkills(reported) : [], - limit - ) + return { + profile: getNativeChatAgentProfile(agent), + commands: nativeChatPickerCommands(agent, commands, discovery.skills), + discovery, + sessionSkills: nativeChatSessionSkills(agent, reported) + } } -export function mobileComposerSlashItems(input: SlashMenuInput): NativeChatPickerItem[] { - return slashItems(input, MENU_ROWS_PER_GROUP) -} - -export function mobileComposerSkillTokens( - input: Omit -): ReadonlySet { +export function mobileComposerSkillTokens(catalog: MobileComposerCatalog): ReadonlySet { + if (!catalog.profile) { + return new Set() + } return new Set( - slashItems({ ...input, query: '' }, Infinity).flatMap((item) => - item.kind === 'skill' ? [item.token] : [] - ) + buildNativeChatPickerItems( + catalog.commands, + nativeChatPickerScannedSkills(catalog.profile, catalog.discovery.skills), + '', + catalog.profile.skillPrefix, + catalog.sessionSkills, + Infinity + ).flatMap((item) => (item.kind === 'skill' ? [item.token] : [])) ) } +export function boundedMenuItems(items: readonly NativeChatPickerItem[]): NativeChatPickerItem[] { + const counts = { command: 0, skill: 0 } + return items.filter((item) => ++counts[item.kind] <= MENU_ROWS_PER_GROUP) +} + // If narrowing leaves no rows, show all: idle chats may report skills as plain commands. export function narrowedMenuItems( items: NativeChatPickerItem[], diff --git a/mobile/src/session/native-chat/composer/mobile-native-chat-draft-store.test.ts b/mobile/src/session/native-chat/composer/mobile-native-chat-draft-store.test.ts index 49b1048085c..d709849a245 100644 --- a/mobile/src/session/native-chat/composer/mobile-native-chat-draft-store.test.ts +++ b/mobile/src/session/native-chat/composer/mobile-native-chat-draft-store.test.ts @@ -4,7 +4,13 @@ import { afterEach, describe, expect, it } from 'vitest' import type { NativeChatMessage } from '../../../../../src/shared/native-chat-types' import { useMobileNativeChatDrafts } from './use-mobile-native-chat-drafts' import { + readMobileNativeChatPickedSkills, + editMobileNativeChatDraft, + readMobileNativeChatSkillOrigin, + recordMobileNativeChatSkillPick, + restoreMobileNativeChatPickedSkills, resetMobileNativeChatDraftStoreForTests, + setMobileNativeChatDraftText, useMobileNativeChatDraft } from './mobile-native-chat-draft-store' @@ -143,3 +149,41 @@ describe('mobile native-chat draft store', () => { expect(screenRenders).toBe(rendersBeforeTyping) }) }) + +describe('picked skills', () => { + afterEach(() => resetMobileNativeChatDraftStoreForTests()) + + it('forgets a pick once an edit deletes its token, even if the name is typed again', () => { + const review = { name: 'review', path: '/a/review/SKILL.md' } + setMobileNativeChatDraftText('draft', 'fix $review then ') + recordMobileNativeChatSkillPick('draft', { token: '$review', path: review.path }) + editMobileNativeChatDraft('draft', 'fix it then ') + editMobileNativeChatDraft('draft', 'fix it then $review') + expect(readMobileNativeChatPickedSkills('draft')).toEqual([]) + }) + + it('keeps a picked skill as the send origin through a send clear, until an edit stops it leading', () => { + editMobileNativeChatDraft('draft', '/triage ') + recordMobileNativeChatSkillPick('draft', { token: '/triage' }) + editMobileNativeChatDraft('draft', '/triage the flaky test') + setMobileNativeChatDraftText('draft', '') + setMobileNativeChatDraftText('draft', '/triage the flaky test') + expect(readMobileNativeChatSkillOrigin('draft')).toBe('/triage') + editMobileNativeChatDraft('draft', 'the flaky test') + expect(readMobileNativeChatSkillOrigin('draft')).toBeNull() + }) + + it('replaces the file a re-picked token named, and gives a rejected draft its files back', () => { + const first = { name: 'review', path: '/a/review/SKILL.md' } + const second = { name: 'review', path: '/b/review/SKILL.md' } + editMobileNativeChatDraft('draft', '$review ') + recordMobileNativeChatSkillPick('draft', { token: '$review', path: first.path }) + recordMobileNativeChatSkillPick('draft', { token: '$review', path: second.path }) + expect(readMobileNativeChatPickedSkills('draft')).toEqual([second]) + + editMobileNativeChatDraft('draft', 'something else') + setMobileNativeChatDraftText('draft', '$review ') + restoreMobileNativeChatPickedSkills('draft', [second]) + expect(readMobileNativeChatPickedSkills('draft')).toEqual([second]) + }) +}) diff --git a/mobile/src/session/native-chat/composer/mobile-native-chat-draft-store.ts b/mobile/src/session/native-chat/composer/mobile-native-chat-draft-store.ts index 81313056133..7ae8a24941f 100644 --- a/mobile/src/session/native-chat/composer/mobile-native-chat-draft-store.ts +++ b/mobile/src/session/native-chat/composer/mobile-native-chat-draft-store.ts @@ -1,8 +1,16 @@ import { useSyncExternalStore, type SetStateAction } from 'react' +import type { AgentJournalSkillRef } from '../../../../../src/shared/agent-session-journal-types' +import { pickedSkillsInText } from '../../../../../src/shared/structured-agent-session-send-mutation' import type { MobileNativeChatLaunchDraftSeed } from './use-mobile-native-chat-launch-draft-seed' // Text and its edit revision outlive the screen, including callbacks from an older mount. -type Draft = { text: string; editGeneration: number } +// Picks behave like desktop chips: user edits drop skills the text stops naming and an origin that stops leading. +type Draft = { + text: string + editGeneration: number + skills?: AgentJournalSkillRef[] + skillOrigin?: string +} const drafts = new Map() const listeners = new Set<() => void>() @@ -14,6 +22,42 @@ export const mobileNativeChatLaunchDraftSeeds = new Map< MobileNativeChatLaunchDraftSeed | null >() +export function recordMobileNativeChatSkillPick( + draftKey: string, + skill: { token: string; path?: string } +): void { + const current = drafts.get(draftKey) ?? { text: '', editGeneration: 0 } + const name = skill.token.slice(1) + // A pick replaces whatever file its token named before, like re-picking a desktop chip. + const kept = (current.skills ?? []).filter((held) => held.name !== name) + const skills = skill.path ? [...kept, { name, path: skill.path }] : kept + drafts.set(draftKey, { ...current, skillOrigin: skill.token, skills }) +} + +// A rejected send's restored text gets back the files it named, even if newer edits dropped them. +export function restoreMobileNativeChatPickedSkills( + draftKey: string, + skills: readonly AgentJournalSkillRef[] +): void { + const current = drafts.get(draftKey) + if (!current || skills.length === 0) { + return + } + const paths = new Set((current.skills ?? []).map((skill) => skill.path)) + drafts.set(draftKey, { + ...current, + skills: [...(current.skills ?? []), ...skills.filter((skill) => !paths.has(skill.path))] + }) +} + +export function readMobileNativeChatSkillOrigin(draftKey: string): string | null { + return drafts.get(draftKey)?.skillOrigin ?? null +} + +export function readMobileNativeChatPickedSkills(draftKey: string): AgentJournalSkillRef[] { + return drafts.get(draftKey)?.skills ?? [] +} + function subscribe(listener: () => void): () => void { listeners.add(listener) return () => { @@ -28,7 +72,17 @@ function writeDraft(draftKey: string, update: SetStateAction, userEdit: if (next === text && !userEdit) { return } - drafts.set(draftKey, { text: next, editGeneration: (current?.editGeneration ?? 0) + 1 }) + // Send clears and restores are not edits, so a rejected draft keeps its picks. + const skills = + userEdit && current?.skills ? pickedSkillsInText(next, current.skills) : current?.skills + const skillOrigin = + userEdit && current?.skillOrigin !== next.split(/\s/, 1)[0] ? undefined : current?.skillOrigin + drafts.set(draftKey, { + text: next, + editGeneration: (current?.editGeneration ?? 0) + 1, + ...(skills ? { skills } : {}), + ...(skillOrigin ? { skillOrigin } : {}) + }) if (next === text) { return } diff --git a/mobile/src/session/native-chat/composer/mobile-native-chat-image-send.test.ts b/mobile/src/session/native-chat/composer/mobile-native-chat-image-send.test.ts index 6e462278b18..55bdc2b9d57 100644 --- a/mobile/src/session/native-chat/composer/mobile-native-chat-image-send.test.ts +++ b/mobile/src/session/native-chat/composer/mobile-native-chat-image-send.test.ts @@ -62,6 +62,22 @@ describe('pasteMobileNativeChatImagePaths', () => { }) }) + it.each([ + ['/tmp/my image.png', '/tmp/my\\ image.png'], + ['C:\\Users\\John Doe\\shot.png', '"C:\\Users\\John Doe\\shot.png"'] + ])('escapes attachment-agent path %s like a terminal drop', async (path, pasted) => { + const client = clientWithResponses([sendResult(true), sendResult(true)]) + await pasteMobileNativeChatImagePaths({ + client, + terminal: 'term-1', + agent: 'claude', + deviceToken: null, + imagePaths: [path], + followedByText: false + }) + expect(client.calls[1]?.params.text).toBe(`\x1b[200~${pasted}\x1b[201~`) + }) + it('clears the input line, then pastes each path as a bracketed, non-submitting terminal.send with the mobile client tag', async () => { const client = clientWithResponses([ sendResult(true), diff --git a/mobile/src/session/native-chat/composer/mobile-native-chat-image-send.ts b/mobile/src/session/native-chat/composer/mobile-native-chat-image-send.ts index a6831478783..0445a808489 100644 --- a/mobile/src/session/native-chat/composer/mobile-native-chat-image-send.ts +++ b/mobile/src/session/native-chat/composer/mobile-native-chat-image-send.ts @@ -1,5 +1,6 @@ import { agentImagePasteWrites } from '../../../../../src/shared/agent-image-paste' -import { buildMobileImagePastePayload } from '../../mobile-clipboard-image' +import { agentImagePastePath } from '../../../../../src/shared/agent-image-paste-path' +import { buildNativeChatImagePasteBytes } from '../../../../../src/shared/native-chat-paste-bytes' import { MOBILE_NATIVE_CHAT_MIN_WRITE_TIMEOUT_MS, openMobileNativeChatSendBudget @@ -49,7 +50,7 @@ export async function pasteMobileNativeChatImagePaths({ clearInput ?? MOBILE_NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT, ...agentImagePasteWrites( agent, - imagePaths.map((path) => buildMobileImagePastePayload(path, agent)), + imagePaths.map((path) => buildNativeChatImagePasteBytes(agentImagePastePath(agent, path))), followedByText ) ]) { diff --git a/mobile/src/session/native-chat/composer/use-mobile-native-chat-composer-menu.ts b/mobile/src/session/native-chat/composer/use-mobile-native-chat-composer-menu.ts index 8baceb35cbe..2bb0b3e1539 100644 --- a/mobile/src/session/native-chat/composer/use-mobile-native-chat-composer-menu.ts +++ b/mobile/src/session/native-chat/composer/use-mobile-native-chat-composer-menu.ts @@ -1,18 +1,22 @@ import { useEffect, useMemo, useState } from 'react' import { - applyAutocomplete, - detectAutocompleteTrigger, - rankSuggestions -} from './mobile-native-chat-autocomplete' + applyMentionSuggestion, + applyPickerSuggestion, + deriveComposerAutocomplete, + isSkillPickerTriggered +} from '../../../../../src/shared/native-chat-composer-autocomplete' +import type { NativeChatSkillDiscoverySnapshot } from '../../../../../src/shared/native-chat/input/native-chat-picker-items' import { + boundedMenuItems, draftAfterSkillEdit, narrowedMenuItems, leadingSkillToken, + MENU_ROWS_PER_GROUP, + mobileComposerCatalog, mobileComposerSkillTokens, - mobileComposerSlashItems, type MobileComposerStructuredCatalog } from './mobile-native-chat-composer-menu' -import { composerMenuInsertText, type ComposerMenuRow } from './MobileNativeChatComposerMenu' +import type { ComposerMenuRow } from './MobileNativeChatComposerMenu' type Selection = { start: number; end: number } /** Opening the menu from the + sheet narrows it to what that row named. */ @@ -27,21 +31,27 @@ export function useMobileNativeChatComposerMenu(input: { structuredCatalog: MobileComposerStructuredCatalog | undefined filePaths: readonly string[] onNeedFiles?: (query: string) => void + skillDiscovery?: NativeChatSkillDiscoverySnapshot + onNeedSkills?: () => void /** The `/` menu opened; the session's list may have changed since it was read. */ onSlashMenuOpen?: () => void + /** A picked skill; a send carries its file while its token stays, and treats a leading one as chat. */ + onPickSkill?: (skill: { token: string; path?: string }) => void focusField: () => void }) { - const { value, onChangeText, agent, structuredCatalog, filePaths } = input + const { value, onChangeText, agent, structuredCatalog, filePaths, skillDiscovery } = input const [cursor, setCursor] = useState(0) // Drives the caret only right after an insert; a permanently controlled `selection` breaks // manual caret placement in React Native. const [pendingSelection, setPendingSelection] = useState(null) const [menuKind, setMenuKind] = useState(null) - const skillTokens = useMemo( - () => mobileComposerSkillTokens({ agent, structured: structuredCatalog }), - [agent, structuredCatalog] + const catalog = useMemo( + () => + mobileComposerCatalog({ agent, structured: structuredCatalog, discovery: skillDiscovery }), + [agent, skillDiscovery, structuredCatalog] ) + const skillTokens = useMemo(() => mobileComposerSkillTokens(catalog), [catalog]) const skillToken = leadingSkillToken(value, skillTokens) const setDraft = (next: string): void => { if (!next.startsWith('/')) { @@ -57,36 +67,51 @@ export function useMobileNativeChatComposerMenu(input: { } } - const trigger = useMemo(() => detectAutocompleteTrigger(value, cursor), [value, cursor]) - // Memoize by query content so caret movement within it rebuilds nothing. - const menuMode = trigger?.kind ?? null - const menuQuery = trigger?.query ?? '' + // Triggers read only the text before the caret, so edits after it rebuild nothing. + const beforeCaret = value.slice(0, cursor) + const autocomplete = useMemo( + () => + deriveComposerAutocomplete( + beforeCaret, + beforeCaret.length, + catalog.commands, + catalog.discovery.skills, + catalog.profile, + catalog.discovery, + null, + catalog.sessionSkills + ), + [beforeCaret, catalog] + ) const rows = useMemo(() => { - if (menuMode === 'file') { - return rankSuggestions(filePaths, menuQuery).map((path) => ({ + if (autocomplete.mode === 'mention') { + return filePaths.slice(0, MENU_ROWS_PER_GROUP).map((path) => ({ kind: 'file' as const, id: `file:${path}`, path })) } - if (menuMode !== 'slash') { + if (autocomplete.mode !== 'slash') { return [] } - const items = mobileComposerSlashItems({ - agent, - structured: structuredCatalog, - query: menuQuery - }) - return narrowedMenuItems(items, menuKind) - }, [agent, filePaths, menuKind, menuMode, menuQuery, structuredCatalog]) + return narrowedMenuItems(boundedMenuItems(autocomplete.items), menuKind) + }, [autocomplete, filePaths, menuKind]) - const { onNeedFiles, onSlashMenuOpen } = input + const { onNeedFiles, onNeedSkills, onSlashMenuOpen } = input + // Scan disk skills only once a token needs them, as desktop does. + const skillsTriggered = isSkillPickerTriggered(beforeCaret, catalog.profile) useEffect(() => { - if (trigger?.kind === 'file') { - onNeedFiles?.(trigger.query) + if (skillsTriggered) { + onNeedSkills?.() } - }, [onNeedFiles, trigger?.kind, trigger?.query]) - const slashOpen = trigger?.kind === 'slash' + }, [onNeedSkills, skillsTriggered]) + const mentionQuery = autocomplete.mode === 'mention' ? autocomplete.query : null + useEffect(() => { + if (mentionQuery !== null) { + onNeedFiles?.(mentionQuery) + } + }, [onNeedFiles, mentionQuery]) + const slashOpen = autocomplete.mode === 'slash' useEffect(() => { if (slashOpen) { onSlashMenuOpen?.() @@ -99,21 +124,27 @@ export function useMobileNativeChatComposerMenu(input: { } const pick = (row: ComposerMenuRow): void => { - if (!trigger) { + if (autocomplete.mode === 'none') { return } setMenuKind(null) // Restore page field focus after a menu tap so typing continues at the inserted pick. input.focusField() - if (row.kind === 'skill') { - const rest = value.slice(trigger.end).trimStart() - onChangeText(`${row.token} ${rest}`) - placeCaret(row.token.length + 1) + if (row.kind === 'file') { + const next = applyMentionSuggestion(value, cursor, row.path) + setDraft(next.draft) + placeCaret(next.caret) return } - const next = applyAutocomplete(value, trigger, composerMenuInsertText(row)) - setDraft(next.text) - placeCaret(next.cursor) + const next = applyPickerSuggestion(value, cursor, row) + // A skill at the start becomes the leading chip; mid-prompt it stays plain text like desktop. + const leadingSkill = + row.kind === 'skill' && next.insertedToken && next.caret === row.token.length + 1 + setDraft(leadingSkill ? `${row.token} ${value.slice(cursor).trimStart()}` : next.draft) + if (row.kind === 'skill') { + input.onPickSkill?.(row) + } + placeCaret(next.caret) } const openSlashMenu = (kind: MenuKind): void => { diff --git a/mobile/src/session/native-chat/composer/use-mobile-native-chat-composer-tray.test.tsx b/mobile/src/session/native-chat/composer/use-mobile-native-chat-composer-tray.test.tsx index 86d6b2acf31..4919ccd0fd2 100644 --- a/mobile/src/session/native-chat/composer/use-mobile-native-chat-composer-tray.test.tsx +++ b/mobile/src/session/native-chat/composer/use-mobile-native-chat-composer-tray.test.tsx @@ -57,9 +57,11 @@ vi.mock('../../../hooks/use-now', () => ({ useNow: () => 100_000 })) const CARD = { messageId: 'waiting-1', + position: 1, text: 'next', + line: 'next', state: 'waiting' as const, - paused: false, + hold: 'turn' as const, needsAttention: false, caption: null, attribution: null diff --git a/mobile/src/session/native-chat/composer/use-mobile-native-chat-drafts-launch-draft.test.ts b/mobile/src/session/native-chat/composer/use-mobile-native-chat-drafts-launch-draft.test.ts index f620d0361ea..b1604f5245a 100644 --- a/mobile/src/session/native-chat/composer/use-mobile-native-chat-drafts-launch-draft.test.ts +++ b/mobile/src/session/native-chat/composer/use-mobile-native-chat-drafts-launch-draft.test.ts @@ -117,7 +117,35 @@ describe('useMobileNativeChatDrafts launch draft', () => { ) ) - expect(state?.readSeededLaunchDraftSeed()).toEqual({ text: 'issue link', createdAt: 7 }) + expect(state?.readSeededLaunchDraftSeed()).toMatchObject({ text: 'issue link', createdAt: 7 }) + }) + + it('prefills over user turns older than the launch draft, retiring once a new turn lands', async () => { + const older = { ...userTextMessage('m1', 'earlier prompt'), timestamp: 1_000 } + await mount('a') + await act(async () => + renderer?.update( + createElement(Harness, { + tabId: 'a', + messages: [older], + launchDraft: 'issue link', + launchDraftCreatedAt: 100_000 + }) + ) + ) + expect(state?.composerText).toBe('issue link') + + await act(async () => + renderer?.update( + createElement(Harness, { + tabId: 'a', + messages: [older, { ...userTextMessage('m2', 'sent from the TUI'), timestamp: 2_000 }], + launchDraft: 'issue link', + launchDraftCreatedAt: 100_000 + }) + ) + ) + expect(state?.composerText).toBe('') }) it('does not overwrite typed composer text with a launch draft', async () => { diff --git a/mobile/src/session/native-chat/composer/use-mobile-native-chat-file-search.test.ts b/mobile/src/session/native-chat/composer/use-mobile-native-chat-file-search.test.ts index 154e13779c2..afd3914e466 100644 --- a/mobile/src/session/native-chat/composer/use-mobile-native-chat-file-search.test.ts +++ b/mobile/src/session/native-chat/composer/use-mobile-native-chat-file-search.test.ts @@ -92,7 +92,7 @@ describe('useMobileNativeChatFileSearch', () => { }) expect(state?.nativeChatFilePaths).toEqual(['src/apple.ts']) - act(() => state?.loadNativeChatFiles('readme')) + act(() => state?.loadNativeChatFiles('drm')) await act(async () => { await vi.advanceTimersByTimeAsync(120) }) diff --git a/mobile/src/session/native-chat/composer/use-mobile-native-chat-file-search.ts b/mobile/src/session/native-chat/composer/use-mobile-native-chat-file-search.ts index 72188514d22..9450fe35e46 100644 --- a/mobile/src/session/native-chat/composer/use-mobile-native-chat-file-search.ts +++ b/mobile/src/session/native-chat/composer/use-mobile-native-chat-file-search.ts @@ -8,12 +8,21 @@ import { nativeChatFileInventoryRead, nativeChatFileSearchRead } from '../../mobile-session-read-operations' -import { rankSuggestions } from './mobile-native-chat-autocomplete' +import { + getPreparedQuickOpenFiles, + rankQuickOpenFiles +} from '../../../../../src/shared/quick-open-path-search' const FILE_SEARCH_DEBOUNCE_MS = 120 const FILE_SEARCH_RESULT_LIMIT = 16 const FILE_SEARCH_QUERY_CACHE_LIMIT = 20 +function rankInventory(paths: readonly string[], query: string): string[] { + return rankQuickOpenFiles(query, getPreparedQuickOpenFiles(paths), FILE_SEARCH_RESULT_LIMIT).map( + (result) => result.path + ) +} + /** The legacy inventory is the whole workspace, so its request carries no further parameters. */ type WorkspaceInventoryParameters = Readonly> const WHOLE_WORKSPACE: WorkspaceInventoryParameters = {} @@ -114,7 +123,7 @@ export function useMobileNativeChatFileSearch(args: { const inventoryScope: RequestScope = [client, worktreeId, generation] const held = inventory.read(inventoryScope, WHOLE_WORKSPACE) if (held) { - applyPaths(rankSuggestions(held, normalizedQuery, FILE_SEARCH_RESULT_LIMIT)) + applyPaths(rankInventory(held, normalizedQuery)) return } // Share overlapping legacy full-inventory reads, especially across slow SSH. @@ -128,7 +137,7 @@ export function useMobileNativeChatFileSearch(args: { if (!loaded || inventory.commit(loaded.lease, loaded.value) !== 'committed') { return } - applyPaths(rankSuggestions(loaded.value, normalizedQuery, FILE_SEARCH_RESULT_LIMIT)) + applyPaths(rankInventory(loaded.value, normalizedQuery)) } void (async () => { if (!isCurrentRequest()) { diff --git a/mobile/src/session/native-chat/composer/use-mobile-native-chat-launch-draft-seed.ts b/mobile/src/session/native-chat/composer/use-mobile-native-chat-launch-draft-seed.ts index 4151e1f7f50..cede41ed4a4 100644 --- a/mobile/src/session/native-chat/composer/use-mobile-native-chat-launch-draft-seed.ts +++ b/mobile/src/session/native-chat/composer/use-mobile-native-chat-launch-draft-seed.ts @@ -1,11 +1,29 @@ import { useCallback, useEffect, type SetStateAction } from 'react' import type { NativeChatMessage } from '../../../../../src/shared/native-chat-types' +import { + launchDraftResolvedByTranscript, + nativeChatLaunchDraftTurnBaseline, + type NativeChatLaunchDraftTurnBaseline +} from '../../../../../src/shared/native-chat-launch-draft-resolution' import { normalizedUserText } from './mobile-native-chat-draft-reconcile' import { mobileNativeChatLaunchDraftSeeds } from './mobile-native-chat-draft-store' export type MobileNativeChatLaunchDraftSeed = { text: string createdAt: number | null + baseline?: NativeChatLaunchDraftTurnBaseline +} + +function launchDraftResolved( + createdAt: number | null, + messages: readonly NativeChatMessage[], + baseline?: NativeChatLaunchDraftTurnBaseline +): boolean { + // Old hosts send no creation time, so any user turn retires the draft. + if (createdAt === null) { + return messages.some((message) => normalizedUserText(message) !== null) + } + return launchDraftResolvedByTranscript({ createdAt }, messages, baseline) } export function useMobileNativeChatLaunchDraftSeed(args: { @@ -44,13 +62,15 @@ export function useMobileNativeChatLaunchDraftSeed(args: { if (transcriptLoading) { return } - if (messages.some((message) => normalizedUserText(message) !== null)) { + const createdAt = launchDraftCreatedAt ?? null + if (launchDraftResolved(createdAt, messages)) { mobileNativeChatLaunchDraftSeeds.set(draftKey, null) return } mobileNativeChatLaunchDraftSeeds.set(draftKey, { text: launchDraft, - createdAt: launchDraftCreatedAt ?? null + createdAt, + baseline: nativeChatLaunchDraftTurnBaseline(messages) }) setDraftText(draftKey, (current) => (current === '' ? launchDraft : current)) }, [ @@ -73,8 +93,7 @@ export function useMobileNativeChatLaunchDraftSeed(args: { if (!seeded) { return } - const hasUserTurn = messages.some((message) => normalizedUserText(message) !== null) - if (!hasUserTurn && launchDraft?.trim()) { + if (!launchDraftResolved(seeded.createdAt, messages, seeded.baseline) && launchDraft?.trim()) { return } mobileNativeChatLaunchDraftSeeds.set(draftKey, null) diff --git a/mobile/src/session/native-chat/composer/use-mobile-native-chat-skills.test.ts b/mobile/src/session/native-chat/composer/use-mobile-native-chat-skills.test.ts new file mode 100644 index 00000000000..5f8f0b94e9f --- /dev/null +++ b/mobile/src/session/native-chat/composer/use-mobile-native-chat-skills.test.ts @@ -0,0 +1,90 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { RpcClient } from '../../../transport/rpc-client' +import { useMobileNativeChatSkills } from './use-mobile-native-chat-skills' + +function skill(name: string, rootPath: string) { + return { + id: name, + name, + description: null, + providers: ['agent-skills'], + sourceKind: 'home', + sourceLabel: 'Home', + rootPath, + directoryPath: `${rootPath}/${name}`, + skillFilePath: `${rootPath}/${name}/SKILL.md`, + installed: true, + updatedAt: null + } +} + +function source(path: string, owner: string | null) { + return { + id: path, + label: path, + path, + sourceKind: 'home', + providers: ['agent-skills'], + owner, + exists: true + } +} + +const reply = { + id: 'skills', + ok: true as const, + result: { + skills: [skill('shared', '/home/.agents/skills'), skill('claude-only', '/home/.claude/skills')], + sources: [source('/home/.agents/skills', null), source('/home/.claude/skills', 'claude')], + scannedAt: 1 + }, + _meta: { runtimeId: 'runtime-1' } +} + +function fakeClient(sendRequest: unknown): RpcClient { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The hook calls `sendRequest` and `getGeneration` and nothing else on the client; every other member is unreachable from it. + return { sendRequest } as RpcClient +} + +describe('useMobileNativeChatSkills', () => { + let renderer: ReactTestRenderer | null = null + let state: ReturnType | null = null + + async function mount(client: RpcClient, supported: boolean): Promise { + function Harness(): null { + state = useMobileNativeChatSkills({ client, worktreeId: 'wt-1', agent: 'codex', supported }) + return null + } + await act(async () => { + renderer = create(createElement(Harness)) + }) + } + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + }) + + it("asks the host to scan the workspace once, keeping the agent's own skills", async () => { + const sendRequest = vi.fn(async () => reply) + await mount(fakeClient(sendRequest), true) + + await act(async () => state?.loadNativeChatSkills()) + await act(async () => state?.loadNativeChatSkills()) + + expect(sendRequest).toHaveBeenCalledExactlyOnceWith('skills.discover', { worktreeId: 'wt-1' }) + expect(state?.nativeChatSkills.skills.map((entry) => entry.name)).toEqual(['shared']) + }) + + it('lists none from a host that needs the folder the phone does not know', async () => { + const sendRequest = vi.fn(async () => reply) + await mount(fakeClient(sendRequest), false) + + await act(async () => state?.loadNativeChatSkills()) + + expect(sendRequest).not.toHaveBeenCalled() + expect(state?.nativeChatSkills.skills).toEqual([]) + }) +}) diff --git a/mobile/src/session/native-chat/composer/use-mobile-native-chat-skills.ts b/mobile/src/session/native-chat/composer/use-mobile-native-chat-skills.ts new file mode 100644 index 00000000000..5ad40ae94e2 --- /dev/null +++ b/mobile/src/session/native-chat/composer/use-mobile-native-chat-skills.ts @@ -0,0 +1,79 @@ +import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react' +import type { z } from 'zod' +import type { NativeChatSkillDiscoverySnapshot } from '../../../../../src/shared/native-chat/input/native-chat-picker-items' +import { isNativeChatSkillForAgent } from '../../../../../src/shared/native-chat-skill-visibility' +import type { RpcClient } from '../../../transport/rpc-client' +import { nativeChatSkillDiscoveryRead } from '../../mobile-session-read-operations' +import type { skillDiscoveryReplySchema } from '../../session-read-reply-schema' + +type SkillDiscoveryReply = z.output + +type Scan = { status: 'loading' | 'error' } | { status: 'ready'; reply: SkillDiscoveryReply } + +const NO_SKILLS: NativeChatSkillDiscoverySnapshot = { status: 'ready', skills: [] } + +/** The host scans the workspace named by id (desktop sends the pane's folder); hosts without that scan list none. */ +export function useMobileNativeChatSkills(args: { + client: RpcClient | null + worktreeId: string + agent: string | null + supported: boolean +}): { nativeChatSkills: NativeChatSkillDiscoverySnapshot; loadNativeChatSkills: () => void } { + const { client, worktreeId, agent, supported } = args + const [scans, setScans] = useState<{ client: RpcClient | null; byKey: Map }>({ + client: null, + byKey: new Map() + }) + const key = `${client?.getGeneration?.() ?? 0}\0${worktreeId}` + const scan = scans.client === client ? scans.byKey.get(key) : undefined + // Read through refs so a settled failure cannot recreate the loader and refire the menu's request. + const scanRef = useRef(scan) + const clientRef = useRef(client) + useLayoutEffect(() => { + scanRef.current = scan + clientRef.current = client + }, [client, scan]) + + const loadNativeChatSkills = useCallback(() => { + // The phone has no Retry button, so the next menu opening retries a failed scan. + const held = scanRef.current + if (!client || !supported || (held && held.status !== 'error')) { + return + } + const settle = (next: Scan): void => { + // A reply from a replaced connection must not wipe the current one's scans. + if (clientRef.current !== client) { + return + } + setScans((current) => { + const byKey = new Map(current.client === client ? current.byKey : []) + byKey.set(key, next) + return { client, byKey } + }) + } + settle({ status: 'loading' }) + void nativeChatSkillDiscoveryRead + .request(client, { worktreeId }) + .then((response) => { + const accepted = nativeChatSkillDiscoveryRead.interpret(response) + settle(accepted.accepted ? { status: 'ready', reply: accepted.value } : { status: 'error' }) + }) + .catch(() => settle({ status: 'error' })) + }, [client, key, supported, worktreeId]) + + const nativeChatSkills = useMemo(() => { + if (!agent || !supported) { + return NO_SKILLS + } + if (scan?.status !== 'ready') { + return { status: scan?.status ?? 'idle', skills: [] } + } + const { reply } = scan + return { + status: 'ready', + skills: reply.skills.filter((skill) => isNativeChatSkillForAgent(agent, skill, reply)) + } + }, [agent, scan, supported]) + + return { nativeChatSkills, loadNativeChatSkills } +} diff --git a/mobile/src/session/native-chat/composer/use-mobile-native-chat-workspace-catalog.ts b/mobile/src/session/native-chat/composer/use-mobile-native-chat-workspace-catalog.ts new file mode 100644 index 00000000000..f58b395d1cf --- /dev/null +++ b/mobile/src/session/native-chat/composer/use-mobile-native-chat-workspace-catalog.ts @@ -0,0 +1,24 @@ +import type { RpcClient } from '../../../transport/rpc-client' +import type { StructuredAgentSessionHostSupport } from '../../mobile-structured-agent-session-host-support' +import { useMobileNativeChatFileSearch } from './use-mobile-native-chat-file-search' +import { useMobileNativeChatSkills } from './use-mobile-native-chat-skills' + +/** The workspace's files for `@` and its disk-scanned skills for `/` and `$`. */ +export function useMobileNativeChatWorkspaceCatalog( + scope: { + client: RpcClient | null + worktreeId: string + agentSessionHostSupport?: StructuredAgentSessionHostSupport | null + }, + agent: string | null +): ReturnType & ReturnType { + const { client, worktreeId } = scope + const files = useMobileNativeChatFileSearch({ client, worktreeId }) + const skills = useMobileNativeChatSkills({ + client, + worktreeId, + agent, + supported: scope.agentSessionHostSupport?.discoverWorkspaceSkills === true + }) + return { ...files, ...skills } +} diff --git a/mobile/src/session/native-chat/input/MobileNativeChatQueuedMessages.test.tsx b/mobile/src/session/native-chat/input/MobileNativeChatQueuedMessages.test.tsx index cdf3fc7a219..fc7d0ba144a 100644 --- a/mobile/src/session/native-chat/input/MobileNativeChatQueuedMessages.test.tsx +++ b/mobile/src/session/native-chat/input/MobileNativeChatQueuedMessages.test.tsx @@ -56,10 +56,13 @@ vi.mock('../../../components/ActionSheetModal', () => ({ ActionSheetModal: 'Acti function card( overrides: Partial & { messageId: string } ): MobileQueuedMessageCard { + const text = overrides.text ?? `text of ${overrides.messageId}` return { - text: `text of ${overrides.messageId}`, + position: 1, + text, + line: text, state: 'waiting', - paused: false, + hold: overrides.state === 'returned' ? 'returned' : 'turn', needsAttention: false, caption: null, attribution: null, @@ -315,7 +318,7 @@ describe('MobileNativeChatQueuedMessages', () => { cards: [ card({ messageId: 'failed', - paused: true, + hold: 'paused', needsAttention: true, caption: "Couldn't send — tap Send to retry" }), @@ -798,7 +801,7 @@ describe('MobileNativeChatQueuedMessages', () => { expect(mutate).toHaveBeenCalledTimes(1) expect(ids()).toEqual(['c', 'a', 'b', 'd']) expect(list().props.canMove).toBeUndefined() - expect(list().props.items[0].automatic).toBe(true) + expect(list().props.items[0].from).toBeDefined() expect( list().props.items.every((entry: MobileQueuedMessageCard) => list().props.isMoveDisabled(entry) @@ -825,7 +828,8 @@ describe('MobileNativeChatQueuedMessages', () => { ) ).toBe(true) act(() => expect(list().props.onMove({ movedId: 'b', afterId: 'd' })).toBe(true)) - const published = [messages[0]!, messages[2]!, messages[3]!, messages[1]!] + // The host republishes the moved order with fresh positions; cards sort by position. + const published = [0, 2, 3, 1].map((index, position) => ({ ...messages[index]!, position })) await act(async () => renderer!.update(createElement(Harness, { published }))) expect(ids()).toEqual(['a', 'c', 'd', 'b']) await act(async () => renderer!.unmount()) diff --git a/mobile/src/session/native-chat/input/MobileNativeChatQueuedMessages.tsx b/mobile/src/session/native-chat/input/MobileNativeChatQueuedMessages.tsx index 7b466090df3..938c29432ad 100644 --- a/mobile/src/session/native-chat/input/MobileNativeChatQueuedMessages.tsx +++ b/mobile/src/session/native-chat/input/MobileNativeChatQueuedMessages.tsx @@ -7,7 +7,7 @@ import { ActionSheetModal } from '../../../components/ActionSheetModal' import { colors } from '../../../theme/mobile-theme' import { useLargeSystemText } from '../../../theme/use-large-system-text' import type { MobileQueuedMessageCard } from '../../mobile-structured-queued-message-cards' -import type { MobileQueuePause } from '../../mobile-structured-queued-message-feed' +import type { AgentSessionQueuePause } from '../../../../../src/shared/agent-session-wire' import type { MobileQueuedMessageEdit } from '../../use-mobile-structured-queued-message-controls' import type { MobileQueuedMessageInlineEditor } from '../../use-mobile-structured-queued-edit' import { MobileQueuedMessageCardRow } from '../../MobileQueuedMessageCardRow' @@ -28,7 +28,7 @@ export type MobileNativeChatQueuedMessagesProps = { onEdit?: MobileQueuedMessageEdit editor?: MobileQueuedMessageInlineEditor onMove?: (move: DragReorderMove) => Promise - pause?: MobileQueuePause + pause?: AgentSessionQueuePause | null onResume?: () => Promise steerHeld?: boolean } @@ -212,7 +212,7 @@ export function MobileNativeChatQueuedMessages({ { - it('drops a preview the provider flagged as tool output', () => { - // Tool output never lands as assistant text, so a synthetic reply bubble could not retire it. - expect( - mobileNativeChatStreamPreview( - { - lastAssistantMessage: 'Exit code 1\nimport { Foo }', - lastAssistantMessageIsToolOutput: true - }, - true - ) - ).toBeUndefined() - }) - - it('passes assistant prose through while working', () => { - expect(mobileNativeChatStreamPreview({ lastAssistantMessage: 'Working on it' }, true)).toBe( - 'Working on it' - ) - }) - - it('drops any preview once the turn is not working', () => { - expect( - mobileNativeChatStreamPreview({ lastAssistantMessage: 'Working on it' }, false) - ).toBeUndefined() - }) - - it('tolerates a missing status', () => { - expect(mobileNativeChatStreamPreview(null, true)).toBeUndefined() - expect(mobileNativeChatStreamPreview(undefined, true)).toBeUndefined() - }) -}) - -describe('deriveMobileNativeChatStreaming', () => { - it('shows a genuine reply that repeats the previous turn as a prefix', () => { - const prior = [assistant('a1', 'The tests pass.')] - const { results } = run([ - { folded: prior }, - { folded: prior, text: 'The' }, - { folded: prior, text: 'The tests' }, - { folded: prior, text: 'The tests pass.' } - ]) - expect(results).toEqual([null, 'The', 'The tests', 'The tests pass.']) - }) - - it('hides the bubble once the real turn lands leading with the streamed text', () => { - const prior = [assistant('a1', 'earlier turn')] - const landed = [...prior, assistant('a2', 'fresh answer with a tail')] - const { results } = run([ - { folded: prior }, - { folded: prior, text: 'fresh answer' }, - { folded: landed, text: 'fresh answer' } - ]) - expect(results).toEqual([null, 'fresh answer', null]) - }) - - it('keeps the bubble hidden once a thought lands below the streamed reply', () => { - const asked: NativeChatMessage[] = [ - { - id: 'u1', - role: 'user', - blocks: [{ type: 'text', text: 'look' }], - timestamp: 0, - source: 'transcript' - } - ] - const landed = [...asked, assistant('a1', 'Checking the notes file now.')] - const thought: NativeChatMessage = { - id: 'a1:reasoning-2', - role: 'reasoning', - blocks: [{ type: 'text', text: 'The file is short.' }], - timestamp: 0, - source: 'transcript' - } - const { results } = run([ - { folded: asked }, - { folded: asked, text: 'Checking the notes file now.', live: true }, - { folded: landed, text: 'Checking the notes file now.', live: true }, - { folded: [...landed, thought], text: 'Checking the notes file now.', live: true } - ]) - expect(results).toEqual([null, 'Checking the notes file now.', null, null]) - }) - - it('suppresses an identical repeated reply once its own turn lands', () => { - const prior = [assistant('a1', 'Done.')] - const landed = [...prior, assistant('a2', 'Done.')] - const { results } = run([ - { folded: prior }, - { folded: prior, text: 'Done.' }, - { folded: landed, text: 'Done.' } - ]) - expect(results).toEqual([null, 'Done.', null]) - }) - - it('keeps hiding for the rest of a segment after the turn lands', () => { - const prior = [assistant('a1', 'earlier')] - const landed = [...prior, assistant('a2', 'answer body')] - const { results } = run([ - { folded: prior }, - { folded: prior, text: 'answer' }, - { folded: landed, text: 'answer' }, - { folded: landed, text: 'answer bo' } - ]) - expect(results).toEqual([null, 'answer', null, null]) - }) - - it('keeps the segment baseline through textless ticks while the turn is live', () => { - // Returning from hidden chat can restore status text before transcript history, while the turn remains live. - const prior = [assistant('a1', 'Done.')] - const { results } = run([ - { folded: prior }, - { folded: prior, text: 'Done.', live: true }, - { folded: [], live: true }, - { folded: [], text: 'Done.', live: true }, - { folded: prior, text: 'Done.', live: true } - ]) - expect(results).toEqual([null, 'Done.', null, 'Done.', 'Done.']) - }) - - it('still hides after a hidden gap once the reply landed as its own turn', () => { - const prior = [assistant('a1', 'Done.')] - const landed = [...prior, assistant('a2', 'Done.')] - const { results } = run([ - { folded: prior }, - { folded: prior, text: 'Done.', live: true }, - { folded: [], live: true }, - { folded: landed, text: 'Done.', live: true } - ]) - expect(results).toEqual([null, 'Done.', null, null]) - }) - - it('hides a reply whose own turn landed before its status text arrived', () => { - // Transcript pushes can beat throttled status while background work keeps the pane working; anchoring here would duplicate the reply. - const prior = [assistant('a1', 'Done.')] - const landed = [...prior, assistant('a2', 'Done.')] - const { results } = run([ - { folded: prior, live: true }, - { folded: landed, live: true }, - { folded: landed, text: 'Done.', live: true }, - { folded: landed, text: 'Done.', live: true } - ]) - expect(results).toEqual([null, null, null, null]) - }) - - it('keeps the pre-stream baseline across a hidden gap taken between turns', () => { - // Hidden chat tears the transcript down to empty; anchoring there would swallow the next repeated-prefix reply. - const prior = [assistant('a1', 'Done.')] - const { results } = run([ - { folded: prior }, - { folded: [] }, - { folded: [], live: true }, - { folded: prior, text: 'Done.', live: true } - ]) - expect(results).toEqual([null, null, null, 'Done.']) - }) - - it('anchors on the first tail it sees when mounted mid-turn', () => { - const prior = [assistant('a1', 'Done.')] - const { results } = run([ - { folded: prior, live: true }, - { folded: prior, text: 'Done.', live: true } - ]) - expect(results).toEqual([null, 'Done.']) - }) - - it('anchors on a textless tick once the turn ends', () => { - const prior = [assistant('a1', 'first answer')] - const landed = [...prior, assistant('a2', 'second answer')] - const { results } = run([ - { folded: prior }, - { folded: prior, text: 'second answer', live: true }, - { folded: landed }, - { folded: landed, text: 'second answer', live: true } - ]) - expect(results).toEqual([null, 'second answer', null, 'second answer']) - }) - - it('does not treat the previous turn as a segment start after re-anchoring', () => { - // Retaining previous-turn text after re-anchoring would classify the next opener as a new segment and duplicate its landed reply. - const prior = [assistant('a1', 'context')] - const firstLanded = [...prior, assistant('a2', 'Alpha done')] - const secondLanded = [...firstLanded, assistant('a3', 'Beta reply')] - const { results } = run([ - { folded: prior }, - { folded: prior, text: 'Alpha', live: true }, - { folded: firstLanded }, - { folded: secondLanded, text: 'Beta', live: true } - ]) - expect(results).toEqual([null, 'Alpha', null, null]) - }) - - it('re-anchors when a new reply part replaces the stream mid-turn', () => { - const prior = [assistant('a1', 'context')] - const partOneLanded = [...prior, assistant('a2', 'part one full text')] - const { results } = run([ - { folded: prior }, - { folded: prior, text: 'part one' }, - { folded: partOneLanded, text: 'part one' }, - { folded: partOneLanded, text: 'part' } - ]) - expect(results).toEqual([null, 'part one', null, 'part']) - }) - - it('falls back to suppress-on-prefix when text arrives on the first tick', () => { - // Without prior history, prefer briefly hiding a repeated reply over duplicating one already landed. - const landed = [assistant('a1', 'flushed part still streaming in status')] - const { results } = run([{ folded: landed, text: 'flushed part' }]) - expect(results).toEqual([null]) - }) - - it('is idempotent for a repeated tick', () => { - const prior = [assistant('a1', 'The tests pass.')] - const first = run([{ folded: prior }, { folded: prior, text: 'The tests' }]) - const again = deriveMobileNativeChatStreaming(first.gate, prior, 'The tests') - expect(again.streaming).toBe('The tests') - expect(again.gate).toBe(first.gate) - }) - - it('drops a prior chat baseline when the stream identity changes', () => { - // Another chat’s tail cannot establish which replies landed during this segment. - const repeatedId = [assistant('a1', 'new answer landed')] - let gate = createMobileNativeChatStreamingGate('tab-a') - gate = deriveMobileNativeChatStreaming(gate, repeatedId, undefined, { scopeKey: 'tab-a' }).gate - - const switched = deriveMobileNativeChatStreaming(gate, repeatedId, 'new answer', { - scopeKey: 'tab-b' - }) - - expect(switched.streaming).toBeNull() - expect(switched.gate.scopeKey).toBe('tab-b') - expect(switched.gate.baselineTailId).toBeNull() - }) - - it('returns null for empty or whitespace streaming text', () => { - const prior = [assistant('a1', 'x')] - expect(run([{ folded: prior, text: ' ' }]).results).toEqual([null]) - expect(run([{ folded: prior }]).results).toEqual([null]) - }) - - it('shows the first reply of an empty chat and hides it once the turn lands', () => { - const landed = [assistant('a1', 'Hello there')] - const { results } = run([ - { folded: [] }, - { folded: [], text: 'Hello' }, - { folded: landed, text: 'Hello' } - ]) - expect(results).toEqual([null, 'Hello', null]) - }) -}) diff --git a/mobile/src/session/native-chat/mobile-native-chat-streaming-gate.ts b/mobile/src/session/native-chat/mobile-native-chat-streaming-gate.ts deleted file mode 100644 index c0412eee242..00000000000 --- a/mobile/src/session/native-chat/mobile-native-chat-streaming-gate.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { latestNativeChatReplyText } from '../../../../src/shared/native-chat/native-chat-streaming' -import type { NativeChatMessage } from '../../../../src/shared/native-chat-types' - -// Compare prefixes only after the segment’s captured tail so a previous turn’s identical reply cannot swallow a new preview. -export type MobileNativeChatStreamingGate = { - scopeKey: string | null - prevText: string - // Null means no tail was observed before streaming; only then fall back to suppressing any matching prefix. - baselineTailId: string | null -} - -// Tool output never lands as assistant text; treat it as unobserved so it cannot persist as a reply or become the next segment’s baseline. -export function mobileNativeChatStreamPreview( - status: - | { lastAssistantMessage?: string; lastAssistantMessageIsToolOutput?: boolean } - | null - | undefined, - working: boolean -): string | undefined { - if (!working || status?.lastAssistantMessageIsToolOutput === true) { - return undefined - } - return status?.lastAssistantMessage -} - -export function createMobileNativeChatStreamingGate( - scopeKey: string | null = null -): MobileNativeChatStreamingGate { - return { scopeKey, prevText: '', baselineTailId: null } -} - -// Reuse unchanged gates so render-time state adjustments settle by reference. -function advanceGate( - gate: MobileNativeChatStreamingGate, - prevText: string, - baselineTailId: string | null -): MobileNativeChatStreamingGate { - return gate.prevText === prevText && gate.baselineTailId === baselineTailId - ? gate - : { ...gate, prevText, baselineTailId } -} - -// Repeated text/tail observations must keep the same decision across re-renders. -export function deriveMobileNativeChatStreaming( - gate: MobileNativeChatStreamingGate, - folded: readonly NativeChatMessage[], - streamingText: string | undefined, - options: { - scopeKey?: string | null - // Textless ticks during a live turn are missing observations, not stream endings. - streamLive?: boolean - } = {} -): { gate: MobileNativeChatStreamingGate; streaming: string | null } { - const scopeKey = options.scopeKey === undefined ? gate.scopeKey : options.scopeKey - const scopedGate = - gate.scopeKey === scopeKey ? gate : createMobileNativeChatStreamingGate(scopeKey) - const text = streamingText?.trim() ?? '' - const tail = folded.at(-1) - const tailId = tail?.id ?? null - if (!text) { - // Anchor only settled real history, except on first mount: live gaps may already contain the reply and would duplicate it as a preview. - const canAnchor = tailId !== null && (!options.streamLive || scopedGate.baselineTailId === null) - return { gate: canAnchor ? advanceGate(scopedGate, '', tailId) : scopedGate, streaming: null } - } - // A non-extension begins a new segment with the current tail as its baseline. - const segmentStart = scopedGate.prevText !== '' && !text.startsWith(scopedGate.prevText) - const baselineTailId = segmentStart ? tailId : scopedGate.baselineTailId - // Only post-baseline replies can retire this segment; a missing baseline falls back to scanning the whole turn. - const baselineIndex = - baselineTailId === null ? -1 : folded.findIndex((message) => message.id === baselineTailId) - const caughtUp = latestNativeChatReplyText(folded, baselineIndex).startsWith(text) - return { - gate: advanceGate(scopedGate, text, baselineTailId), - streaming: caughtUp ? null : text - } -} diff --git a/mobile/src/session/native-chat/transcript/MobileNativeChatMessage.status-notices.test.ts b/mobile/src/session/native-chat/transcript/MobileNativeChatMessage.status-notices.test.ts new file mode 100644 index 00000000000..6d49c8e45e8 --- /dev/null +++ b/mobile/src/session/native-chat/transcript/MobileNativeChatMessage.status-notices.test.ts @@ -0,0 +1,264 @@ +import { createElement, type ReactNode } from 'react' +import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { AgentJournalStatusItem } from '../../../../../src/shared/agent-session-journal-types' +import { isAdmissibleAgentJournalItemBody } from '../../../../../src/shared/agent-session-journal-schemas' +import { projectStructuredItemToNativeChat } from '../../../../../src/shared/structured-agent-session-projection' +import { + NATIVE_CHAT_INTERRUPTED_STATUS_TEXT, + type NativeChatMessage +} from '../../../../../src/shared/native-chat-types' +import { + agentSessionFailureFact, + readProviderRetry +} from '../../../../../src/shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../../../src/shared/agent-session-failure-words' +import { colors } from '../../../theme/mobile-theme' +import { + MobileNativeChatNoticeContext, + type MobileNativeChatNoticeNames +} from '../../mobile-native-chat-notice-context' + +vi.mock('react-native', async () => { + const React = await import('react') + const Text = ({ children, ...props }: { children?: ReactNode }): unknown => + React.createElement('Text', props, children) + return { + ActivityIndicator: 'ActivityIndicator', + Animated: { + Text, + Value: class { + setValue(): void {} + }, + loop: (animation: unknown) => animation, + sequence: () => ({ start: vi.fn(), stop: vi.fn() }), + timing: () => ({ start: vi.fn(), stop: vi.fn() }) + }, + Image: 'Image', + Platform: { OS: 'ios' }, + Pressable: 'Pressable', + ScrollView: ({ children, ...props }: { children?: ReactNode }) => + React.createElement('ScrollView', props, children), + Text, + View: ({ children, ...props }: { children?: ReactNode }) => + React.createElement('View', props, children), + StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 }, + useWindowDimensions: () => ({ width: 400, height: 800 }) + } +}) +vi.mock('expo-clipboard', () => ({ setStringAsync: vi.fn() })) +vi.mock('lucide-react-native', () => ({ + AlertCircle: 'AlertCircle', + AlertTriangle: 'AlertTriangle', + ArrowUp: 'ArrowUp', + Bot: 'Bot', + Brain: 'Brain', + Check: 'Check', + ChevronDown: 'ChevronDown', + ChevronRight: 'ChevronRight', + Copy: 'Copy', + Eye: 'Eye', + FileMinus2: 'FileMinus2', + FilePen: 'FilePen', + FilePlus2: 'FilePlus2', + Folder: 'Folder', + Globe: 'Globe', + Info: 'Info', + ListChecks: 'ListChecks', + MessageSquareMore: 'MessageSquareMore', + Pencil: 'Pencil', + Plug: 'Plug', + Search: 'Search', + SquareChevronRight: 'SquareChevronRight', + SquareTerminal: 'SquareTerminal', + Wrench: 'Wrench' +})) +vi.mock('../../../components/MobileMarkdown', () => ({ MobileMarkdown: 'MobileMarkdown' })) +vi.mock('./MobileNativeChatMessageActionsSheet', () => ({ + MobileNativeChatMessageActionsSheet: 'MessageActionsSheet' +})) + +import { MobileNativeChatMessage } from './MobileNativeChatMessage' + +describe('MobileNativeChatMessage status notices', () => { + let renderer: ReactTestRenderer | null = null + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + }) + + function render(message: NativeChatMessage): ReactTestRenderer { + act(() => { + renderer = create(createElement(MobileNativeChatMessage, { message })) + }) + return renderer! + } + + const markdownIn = (node: ReactTestInstance): string[] => + node + .findAll((child) => String(child.type) === 'MobileMarkdown') + .map((markdown) => markdown.props.content) + + const statusRow = (body: AgentJournalStatusItem): NativeChatMessage => + projectStructuredItemToNativeChat({ + itemId: 'status', + revision: 1, + sequence: 2, + observedAt: 2, + body + })! + const renderNotice = (body: AgentJournalStatusItem, names: MobileNativeChatNoticeNames) => { + act(() => { + renderer = create( + createElement( + MobileNativeChatNoticeContext.Provider, + { value: names }, + createElement(MobileNativeChatMessage, { message: statusRow(body) }) + ) + ) + }) + return renderer!.root.find( + (node) => String(node.type) === 'Text' && node.props.selectable === true + ) + } + + it('names the Orca host an Orca stop came from, in desktop words', () => { + const text = renderNotice( + { + kind: 'status', + text: 'Codex stopped while this response was in progress.', + tone: 'error', + presentation: 'orca-stop', + orcaStop: { cause: 'quit' } + }, + { hostName: 'MacBook Pro' } + ) + expect(text.props.children).toBe( + 'Orca on MacBook Pro stopped while this response was in progress. You can continue in this conversation.' + ) + }) + + it.each(['without reset', 'with reset', 'from an old host'] as const)( + 'draws an OpenCode usage warning %s', + (source) => { + const hasReset = source === 'with reset' + const resetsAt = new Date(2026, 9, 15, 15, 42).getTime() + const now = vi.spyOn(Date, 'now').mockReturnValue(new Date(2026, 9, 15, 12).getTime()) + try { + const host = agentSessionFailureWords( + agentSessionFailureFact('usageLimitReached', { + detail: { + text: 'Internal error: Rate limit exceeded. Please try again later.', + audience: 'person' + }, + ...(hasReset ? { usageLimit: { resetsAt } } : {}) + }), + { surface: 'row', agentName: 'OpenCode' } + ) + expect(host.text).toBe('OpenCode hit its usage limit.') + const hostText = + source === 'from an old host' + ? 'OpenCode hit its usage limit. Send a message once it resets to continue.' + : host.text + const body: unknown = { kind: 'status', tone: 'warning', ...host, text: hostText } + if (!isAdmissibleAgentJournalItemBody(body) || body.kind !== 'status') { + throw new Error('Status fixture was not admitted') + } + const text = renderNotice(body, { hostName: null, agentName: 'OpenCode' }) + const tree = renderer! + // An old host's stored advice gives way to desktop's sentence from the fact. + expect(text.props.children).toEqual( + hasReset + ? expect.stringMatching(/^OpenCode hit its usage limit\. It resets at \S.*\.$/u) + : 'OpenCode hit its usage limit.' + ) + expect(Object.assign({}, ...text.props.style)).toMatchObject({ color: colors.statusAmber }) + expect(tree.root.find((node) => String(node.type) === 'AlertTriangle').props.color).toBe( + colors.statusAmber + ) + expect(markdownIn(tree.root)).toEqual([]) + } finally { + now.mockRestore() + } + } + ) + + it.each([ + [ + 'an API retry as a warning', + statusRow({ + kind: 'status', + tone: 'warning', + ...agentSessionFailureWords( + agentSessionFailureFact('providerRetrying', { + retry: readProviderRetry({ + error: 'overloaded', + status: 529, + attempt: 2, + maxRetries: 10 + }) + }), + { surface: 'row', agentName: 'Claude' } + ) + }), + colors.statusAmber + ], + [ + 'a sign-in failure as an error', + statusRow({ + kind: 'status', + tone: 'error', + ...agentSessionFailureWords(agentSessionFailureFact('notSignedIn'), { + agentName: 'Claude', + provider: 'claude', + surface: 'row' + }) + }), + colors.statusRed + ], + ...[ + 'Please add or update your payment method to continue', + 'Your current subscription plan does not yet include access to glm-5' + ].map( + (instruction) => + [ + `an OpenCode corrective instruction: ${instruction}`, + statusRow({ kind: 'status', tone: 'error', text: `Internal error: ${instruction}` }), + colors.statusRed + ] as const + ), + [ + 'an interruption as a faint aside', + { + id: 'interrupted', + role: 'system', + blocks: [{ type: 'text', text: NATIVE_CHAT_INTERRUPTED_STATUS_TEXT }], + timestamp: 1, + source: 'transcript' + } satisfies NativeChatMessage, + colors.textMuted + ] + ])('draws %s, never as the agent speaking', (_, message, color) => { + const tree = render(message) + expect(tree.root.findAll((node) => String(node.type) === 'MobileMarkdown')).toHaveLength(0) + const text = tree.root.find( + (node) => String(node.type) === 'Text' && node.props.selectable === true + ) + expect(text.props.children).toBe(message.blocks.find((block) => block.type === 'text')?.text) + expect(Object.assign({}, ...text.props.style)).toMatchObject({ color }) + }) + + it('draws a compaction as a divider, not as a reply', () => { + const tree = render( + statusRow({ kind: 'status', text: 'Context compacted', presentation: 'compaction' }) + ) + expect(tree.root.findAll((node) => String(node.type) === 'MobileMarkdown')).toHaveLength(0) + expect( + tree.root.findAll( + (node) => + String(node.type) === 'View' && node.props.accessibilityLabel === 'Context compacted' + ) + ).toHaveLength(1) + }) +}) diff --git a/mobile/src/session/native-chat/transcript/MobileNativeChatMessage.test.ts b/mobile/src/session/native-chat/transcript/MobileNativeChatMessage.test.ts index 21a4a8d6842..90bdd92e81f 100644 --- a/mobile/src/session/native-chat/transcript/MobileNativeChatMessage.test.ts +++ b/mobile/src/session/native-chat/transcript/MobileNativeChatMessage.test.ts @@ -1,19 +1,9 @@ import { createElement, type ReactNode } from 'react' import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer' import { afterEach, describe, expect, it, vi } from 'vitest' -import type { AgentJournalStatusItem } from '../../../../../src/shared/agent-session-journal-types' -import { isAdmissibleAgentJournalItemBody } from '../../../../../src/shared/agent-session-journal-schemas' import { projectStructuredItemToNativeChat } from '../../../../../src/shared/structured-agent-session-projection' -import { - NATIVE_CHAT_INTERRUPTED_STATUS_TEXT, - type NativeChatMessage -} from '../../../../../src/shared/native-chat-types' +import type { NativeChatMessage } from '../../../../../src/shared/native-chat-types' import { AGENT_SESSION_HOST_STATUS_COPY } from '../../../../../src/shared/agent-session-host-status-rows' -import { - agentSessionFailureFact, - readProviderRetry -} from '../../../../../src/shared/agent-session-failure' -import { agentSessionFailureWords } from '../../../../../src/shared/agent-session-failure-words' import { structuredQuestionTranscript } from '../../../../../src/shared/structured-agent-question-projection' import type { NativeChatResolvedPrompt } from '../../../../../src/shared/native-chat-resolution-receipt' import { colors, typography } from '../../../theme/mobile-theme' @@ -66,6 +56,7 @@ vi.mock('lucide-react-native', () => ({ FilePlus2: 'FilePlus2', Folder: 'Folder', Globe: 'Globe', + Info: 'Info', ListChecks: 'ListChecks', MessageSquareMore: 'MessageSquareMore', Pencil: 'Pencil', @@ -332,139 +323,6 @@ describe('MobileNativeChatMessage', () => { ) }) - const statusRow = (body: AgentJournalStatusItem): NativeChatMessage => - projectStructuredItemToNativeChat({ - itemId: 'status', - revision: 1, - sequence: 2, - observedAt: 2, - body - })! - - it.each(['without reset', 'with reset', 'from an old host'] as const)( - 'draws an OpenCode usage warning %s', - (source) => { - const hasReset = source === 'with reset' - const resetsAt = new Date(2026, 9, 15, 15, 42).getTime() - const now = vi.spyOn(Date, 'now').mockReturnValue(new Date(2026, 9, 15, 12).getTime()) - try { - const host = agentSessionFailureWords( - agentSessionFailureFact('usageLimitReached', { - detail: { - text: 'Internal error: Rate limit exceeded. Please try again later.', - audience: 'person' - }, - ...(hasReset ? { usageLimit: { resetsAt } } : {}) - }), - { surface: 'row', agentName: 'OpenCode' } - ) - expect(host.text).toBe('OpenCode hit its usage limit.') - const hostText = - source === 'from an old host' - ? 'OpenCode hit its usage limit. Send a message once it resets to continue.' - : host.text - const body: unknown = { kind: 'status', tone: 'warning', ...host, text: hostText } - if (!isAdmissibleAgentJournalItemBody(body) || body.kind !== 'status') { - throw new Error('Status fixture was not admitted') - } - const tree = render(statusRow(body)) - const text = tree.root.find( - (node) => String(node.type) === 'Text' && node.props.selectable === true - ) - expect(text.props.children).toEqual( - hasReset - ? expect.stringMatching(/^OpenCode hit its usage limit\. It resets at \S.*\.$/u) - : hostText - ) - expect(Object.assign({}, ...text.props.style)).toMatchObject({ color: colors.statusAmber }) - expect(tree.root.find((node) => String(node.type) === 'AlertTriangle').props.color).toBe( - colors.statusAmber - ) - expect(markdownIn(tree.root)).toEqual([]) - } finally { - now.mockRestore() - } - } - ) - - it.each([ - [ - 'an API retry as a warning', - statusRow({ - kind: 'status', - tone: 'warning', - ...agentSessionFailureWords( - agentSessionFailureFact('providerRetrying', { - retry: readProviderRetry({ - error: 'overloaded', - status: 529, - attempt: 2, - maxRetries: 10 - }) - }), - { surface: 'row', agentName: 'Claude' } - ) - }), - colors.statusAmber - ], - [ - 'a sign-in failure as an error', - statusRow({ - kind: 'status', - tone: 'error', - ...agentSessionFailureWords(agentSessionFailureFact('notSignedIn'), { - agentName: 'Claude', - provider: 'claude', - surface: 'row' - }) - }), - colors.statusRed - ], - ...[ - 'Please add or update your payment method to continue', - 'Your current subscription plan does not yet include access to glm-5' - ].map( - (instruction) => - [ - `an OpenCode corrective instruction: ${instruction}`, - statusRow({ kind: 'status', tone: 'error', text: `Internal error: ${instruction}` }), - colors.statusRed - ] as const - ), - [ - 'an interruption as a faint aside', - { - id: 'interrupted', - role: 'system', - blocks: [{ type: 'text', text: NATIVE_CHAT_INTERRUPTED_STATUS_TEXT }], - timestamp: 1, - source: 'transcript' - } satisfies NativeChatMessage, - colors.textMuted - ] - ])('draws %s, never as the agent speaking', (_, message, color) => { - const tree = render(message) - expect(tree.root.findAll((node) => String(node.type) === 'MobileMarkdown')).toHaveLength(0) - const text = tree.root.find( - (node) => String(node.type) === 'Text' && node.props.selectable === true - ) - expect(text.props.children).toBe(message.blocks.find((block) => block.type === 'text')?.text) - expect(Object.assign({}, ...text.props.style)).toMatchObject({ color }) - }) - - it('draws a compaction as a divider, not as a reply', () => { - const tree = render( - statusRow({ kind: 'status', text: 'Context compacted', presentation: 'compaction' }) - ) - expect(tree.root.findAll((node) => String(node.type) === 'MobileMarkdown')).toHaveLength(0) - expect( - tree.root.findAll( - (node) => - String(node.type) === 'View' && node.props.accessibilityLabel === 'Context compacted' - ) - ).toHaveLength(1) - }) - it.each(['system', 'user'] as const)( 'renders a %s host notice as selectable muted text rather than a markdown answer', (role) => { @@ -764,7 +622,7 @@ describe('MobileNativeChatMessage', () => { it('renders the settled turn status row under a user message', () => { const tree = render(userMessage([{ type: 'text', text: 'go' }]), { structuredActivityUi: true, - turnStatus: { startedAt: Date.now() - 3_000, thinking: false, workedSeconds: 3 } + turnStatus: { startedAt: 1_700_000_000_000, thinking: false, workedSeconds: 3 } }) expect(textIn(tree.root)).toContain('Worked for 3s') }) diff --git a/mobile/src/session/native-chat/transcript/MobileNativeChatNoticeRow.tsx b/mobile/src/session/native-chat/transcript/MobileNativeChatNoticeRow.tsx index 2ecbd99f97f..0cf1a296d0a 100644 --- a/mobile/src/session/native-chat/transcript/MobileNativeChatNoticeRow.tsx +++ b/mobile/src/session/native-chat/transcript/MobileNativeChatNoticeRow.tsx @@ -1,3 +1,4 @@ +import { useContext } from 'react' import { StyleSheet, Text, View } from 'react-native' import { AlertCircle, AlertTriangle, Info } from 'lucide-react-native' import { AGENT_SESSION_HOST_STATUS_COPY } from '../../../../../src/shared/agent-session-host-status-rows' @@ -7,8 +8,22 @@ import { nativeChatNoticeLook, type NativeChatNoticeTone } from '../../../../../src/shared/native-chat-notice' -import { isTextBlock, type NativeChatBlock } from '../../../../../src/shared/native-chat-types' -import { usageLimitLineText } from '../../../../../src/shared/agent-session-usage-limit-words' +import { + isTextBlock, + type NativeChatBlock, + type NativeChatTextBlock +} from '../../../../../src/shared/native-chat-types' +import { readWholeAgentSessionFailureFact } from '../../../../../src/shared/agent-session-failure' +import { agentSessionFailureSentence } from '../../../../../src/shared/agent-session-failure-words' +import { formatUsageLimitResetClock } from '../../../../../src/shared/agent-session-usage-limit' +import { + nativeChatOrcaStopRowText, + sayNativeChatOrcaStopEnglish +} from '../../../../../src/shared/native-chat/native-chat-orca-stop-words' +import { + MobileNativeChatNoticeContext, + type MobileNativeChatNoticeNames +} from '../../mobile-native-chat-notice-context' import { MobileMarkdown } from '../../../components/MobileMarkdown' import { INLINE_TEXT_SELECTION } from '../../../components/inline-text-selection' import { colors, radii, spacing, typography } from '../../../theme/mobile-theme' @@ -20,6 +35,24 @@ const TONE_COLOR: Record = { notice: colors.textMuted } +// Desktop's no-Continue wording; the phone always reads its Orca host as remote. +function noticeLineText(notice: NativeChatTextBlock, names: MobileNativeChatNoticeNames): string { + if (notice.orcaStop) { + return nativeChatOrcaStopRowText( + notice.orcaStop.cause, + names.hostName ?? sayNativeChatOrcaStopEnglish('unnamedServer'), + { continueAvailable: false, remoteHost: true } + ) + } + const failure = readWholeAgentSessionFailureFact(notice.failure) + return failure?.kind === 'usageLimitReached' + ? agentSessionFailureSentence(failure, 'row', { + ...(names.agentName ? { agentName: names.agentName } : {}), + usageLimitReset: formatUsageLimitResetClock + }) + : notice.text +} + // Render system notices separately so they cannot read as the agent’s own prose. export function MobileNativeChatNoticeRow({ blocks, @@ -32,6 +65,7 @@ export function MobileNativeChatNoticeRow({ onOpenFile?: (relativePath: string) => void onLongPress?: () => void }): React.JSX.Element | null { + const names = useContext(MobileNativeChatNoticeContext) const notice = nativeChatNoticeBlock(blocks) const aside = [styles.faint, scaledType(typography.bodySize, fontScale)] if (!notice) { @@ -95,7 +129,7 @@ export function MobileNativeChatNoticeRow({ ) case 'line': { - const lineText = usageLimitLineText(notice) + const lineText = noticeLineText(notice, names) const textStyle = [ styles.line, scaledType(TEXT_SIZE, fontScale), diff --git a/mobile/src/session/native-chat/transcript/use-mobile-native-chat-message-send.ts b/mobile/src/session/native-chat/transcript/use-mobile-native-chat-message-send.ts index a9e2921da99..9d4a0fe42bc 100644 --- a/mobile/src/session/native-chat/transcript/use-mobile-native-chat-message-send.ts +++ b/mobile/src/session/native-chat/transcript/use-mobile-native-chat-message-send.ts @@ -7,10 +7,11 @@ import { typeMobileNativeChatCommandWithOutcome, type MobileNativeChatSendOutcome } from '../mobile-native-chat-send' -import type { CatalogCommandDelivery } from '../../../../../src/shared/agent-session-option-catalog-types' +import type { CatalogCommandDelivery } from '../../../../../src/shared/agent-session-option-catalog' import { isSlashCommandDraft } from '../../../../../src/shared/native-chat/input/native-chat-slash-commands' import { healMobileNativeChatStaleInput } from '../mobile-native-chat-stale-input' import { classifyMobileNativeChatSend } from '../mobile-native-chat-send-classification' +import { readMobileNativeChatSkillOrigin } from '../composer/mobile-native-chat-draft-store' import { acquireMobileNativeChatTerminalWrite, releaseMobileNativeChatTerminalWrite @@ -109,12 +110,14 @@ export function useMobileNativeChatMessageSend(args: { onSendError('Message not sent') return 'rejected' } + // A picked skill leading the draft is chat, as on desktop; read it before the clear forgets it. + const skillOrigin = readMobileNativeChatSkillOrigin(origin.draftKey) // Clear optimistically before slow/lost acknowledgements; only definite rejection restores the draft. if (syncComposer) { clearDraftForSend(origin, draftText) } const seededLaunchDraft = readSeededLaunchDraftSeed() - const classification = classifyMobileNativeChatSend(agent, text) + const classification = classifyMobileNativeChatSend(agent, text, skillOrigin) const typesCodexCommand = agent === 'codex' && classification !== 'chat' && diff --git a/mobile/src/session/native-chat/use-mobile-native-chat-streaming-bubble.ts b/mobile/src/session/native-chat/use-mobile-native-chat-streaming-bubble.ts deleted file mode 100644 index c4f6435d8cd..00000000000 --- a/mobile/src/session/native-chat/use-mobile-native-chat-streaming-bubble.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { useState } from 'react' -import type { NativeChatMessage } from '../../../../src/shared/native-chat-types' -import { - createMobileNativeChatStreamingGate, - deriveMobileNativeChatStreaming -} from './mobile-native-chat-streaming-gate' - -// Keep the segment baseline above list remounts so view toggles preserve repeated-prefix previews; streamLive distinguishes hidden gaps from turn end. -export function useMobileNativeChatStreamingBubble( - folded: readonly NativeChatMessage[], - streamingText: string | undefined, - scopeKey: string, - streamLive: boolean -): string | null { - const [gate, setGate] = useState(() => createMobileNativeChatStreamingGate(scopeKey)) - const step = deriveMobileNativeChatStreaming(gate, folded, streamingText, { - scopeKey, - streamLive - }) - if (step.gate !== gate) { - // Idempotent derived-state adjustment settles after one render for repeated text/tail. - setGate(step.gate) - } - return step.streaming -} diff --git a/mobile/src/session/session-read-reply-schema.ts b/mobile/src/session/session-read-reply-schema.ts index 82e6b3e44b6..e10afaa6947 100644 --- a/mobile/src/session/session-read-reply-schema.ts +++ b/mobile/src/session/session-read-reply-schema.ts @@ -69,3 +69,37 @@ export const markdownTabDocumentSchema = z.looseObject({ // Keep generic tab snapshots and legacy error/window history unions opaque until callers can select their shape. export const sessionForwardedReplySchema = z.unknown() + +const skillProviders = z.array(z.enum(['codex', 'claude', 'agent-skills'])) +const skillSourceKind = z.enum(['home', 'repo', 'bundled', 'plugin']) + +// Rows a newer host shapes differently drop alone; the rest of the scan still lists. +export const skillDiscoveryReplySchema = z.looseObject({ + skills: salvagingArray( + z.looseObject({ + id: z.string(), + name: z.string(), + description: z.string().nullable(), + providers: skillProviders, + sourceKind: skillSourceKind, + sourceLabel: z.string(), + rootPath: z.string(), + rootPaths: z.array(z.string()).optional(), + directoryPath: z.string(), + skillFilePath: z.string(), + installed: z.boolean(), + updatedAt: z.number().nullable() + }) + ), + sources: salvagingArray( + z.looseObject({ + id: z.string(), + label: z.string(), + path: z.string(), + sourceKind: skillSourceKind, + providers: skillProviders, + owner: z.string().nullable(), + exists: z.boolean() + }) + ) +}) diff --git a/mobile/src/session/use-mobile-bridge-chat-prompt-writes.ts b/mobile/src/session/use-mobile-bridge-chat-prompt-writes.ts index d0d19bd7994..a9cca23f021 100644 --- a/mobile/src/session/use-mobile-bridge-chat-prompt-writes.ts +++ b/mobile/src/session/use-mobile-bridge-chat-prompt-writes.ts @@ -21,7 +21,7 @@ export function useMobileBridgeChatPromptWrites(args: { answerAsk: MobileNativeChatAnswerSend['answerAsk'] cancelAsk: () => Promise respondPermission: (send: string) => Promise - stop: () => void + stop: () => Promise } { const { client, enabled, handleRef, deviceTokenRef, streamIdentity, onSendError } = args const { answerAsk, cancelPending } = useMobileNativeChatAnswerSend({ @@ -55,6 +55,7 @@ export function useMobileBridgeChatPromptWrites(args: { enabled, handleRef, deviceTokenRef, + agentRef: args.agentRef, streamIdentity, cancelPending, onSendError diff --git a/mobile/src/session/use-mobile-native-chat-active-resolution.ts b/mobile/src/session/use-mobile-native-chat-active-resolution.ts index d51b77b7a11..c46f762669c 100644 --- a/mobile/src/session/use-mobile-native-chat-active-resolution.ts +++ b/mobile/src/session/use-mobile-native-chat-active-resolution.ts @@ -30,11 +30,9 @@ export function useMobileNativeChatActiveResolution(args: { activeChatSessionId: string | null activeChatStructured: boolean activeChatResolution: ReturnType - activeTabAgentWorking: boolean nativeChatStatus: MobileNativeChatTab['agentStatus'] | null sourceIdentity: string streamIdentity: string - streamScopeKey: string } { const { activeHandleRef, @@ -71,16 +69,9 @@ export function useMobileNativeChatActiveResolution(args: { const activeChatStructured = activeChatResolution != null && activeSessionTab?.type === 'agent-session' const activeTabStatus = activeSessionTab?.agentStatus - // Why: a turn restored across a host restart is unverifiable, so it must not offer Stop. - const activeTabAgentWorking = - activeTabStatus?.state === 'working' && - activeTabStatus.workingMode !== 'monitoring' && - activeTabStatus.restoredUnconfirmed !== true const nativeChatStatus = activeChatResolution && !activeChatStructured ? activeTabStatus : null const routeKey = `${hostId}\0${worktreeId}\0${activeSessionTabId ?? ''}` const streamIdentity = `${routeKey}\0${activeChatSessionId ?? ''}\0${activeHandleRef.current ?? ''}` - const providerSessionId = activeSessionTab?.agentStatus?.providerSession?.id ?? '' - const streamScopeKey = `${routeKey}\0${activeChatSessionId ?? providerSessionId}\0${activeHandleRef.current ?? ''}` return { sessionViewReady, @@ -95,10 +86,8 @@ export function useMobileNativeChatActiveResolution(args: { activeChatSessionId, activeChatStructured, activeChatResolution, - activeTabAgentWorking, nativeChatStatus, sourceIdentity: encodeNativeChatTranscriptIdentity([hostId, worktreeId]), - streamIdentity, - streamScopeKey + streamIdentity } } diff --git a/mobile/src/session/use-mobile-native-chat-controller.test.ts b/mobile/src/session/use-mobile-native-chat-controller.test.ts index 3bc05bd692c..ff28e619ff5 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.test.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.test.ts @@ -13,6 +13,7 @@ const restoreRejectedDraft = vi.fn() const holdUnconfirmedSend = vi.fn() const viewMode = { isTabChatView: (_tabId: string) => true } +let draftPending: { id: string }[] = [] const sessionState = { messages: [] as unknown[], status: 'ready', transcriptLoading: false } const structuredSendWithOutcome = vi.fn() const structuredCancel = vi.fn() @@ -112,7 +113,7 @@ vi.mock('./native-chat/composer/use-mobile-native-chat-drafts', () => ({ return { draftKey: null, setComposerText: vi.fn(), - pending: [], + pending: draftPending, imagePreviewsByMessageId: {}, captureSendOrigin, readSeededLaunchDraft: () => null, @@ -137,8 +138,9 @@ vi.mock('./native-chat/input/use-mobile-native-chat-answer-send', () => ({ vi.mock('./native-chat/input/mobile-native-chat-permission-send', () => ({ useMobileNativeChatPermissionSend: () => vi.fn() })) +const terminalStop = vi.fn(async () => true) vi.mock('./use-mobile-native-chat-stop', () => ({ - useMobileNativeChatStop: () => vi.fn() + useMobileNativeChatStop: () => terminalStop })) vi.mock('./native-chat/composer/use-mobile-native-chat-file-search', () => ({ useMobileNativeChatFileSearch: () => ({ nativeChatFilePaths: [], loadNativeChatFiles: vi.fn() }) @@ -320,7 +322,13 @@ describe('useMobileNativeChatController handleNativeChatSend', () => { }) expect(accepted).toBe(true) - expect(structuredSendWithOutcome).toHaveBeenCalledWith('look') + expect(structuredSendWithOutcome).toHaveBeenCalledWith( + 'look', + undefined, + undefined, + undefined, + [] + ) expect(sendWithOutcome).not.toHaveBeenCalled() expect(clientStub.sendRequest).not.toHaveBeenCalled() }) @@ -332,32 +340,35 @@ describe('useMobileNativeChatController handleNativeChatSend', () => { await act(async () => { await controller!.handleNativeChatSend('/review') }) - expect(structuredSendWithOutcome).toHaveBeenCalledWith('/review') + expect(structuredSendWithOutcome).toHaveBeenCalledWith( + '/review', + undefined, + undefined, + undefined, + [] + ) expect(acceptSend).not.toHaveBeenCalled() expect(restoreRejectedDraft).not.toHaveBeenCalled() expect(controller!.nativeChatAcceptsImages).toBe(false) }) - it('separates structured working status from provider cancellation availability', async () => { + it('offers prompt Cancel only while a provider turn runs', async () => { const props = structuredTab('codex') structuredActivity.isWorking = true try { await act(async () => { renderer?.update(createElement(Harness, props)) }) - expect(controller?.nativeChatAgentWorking).toBe(true) - expect(controller?.nativeChatCanStop).toBe(false) + expect(controller?.nativeChatTurnActive).toBe(true) expect(controller?.handleNativeChatCancelPrompt).toBeUndefined() structuredActivity.turnId = 'provider-turn' await act(async () => { renderer?.update(createElement(Harness, props)) }) - expect(controller?.nativeChatCanStop).toBe(true) expect(controller?.handleNativeChatCancelPrompt).toEqual(expect.any(Function)) structuredActivity.turnId = null harnessHostSupport = structuredAgentSessionHostSupport(['agent-session.conversation-stop.v1']) await act(async () => renderer?.update(createElement(Harness, props))) - expect(controller?.nativeChatCanStop).toBe(true) expect(controller?.handleNativeChatCancelPrompt).toBeUndefined() } finally { harnessHostSupport = null @@ -684,10 +695,11 @@ describe('useMobileNativeChatController ask dismissal across a transcript reload }) }) -describe('useMobileNativeChatController streaming scope', () => { +describe('useMobileNativeChatController terminal turn', () => { let renderer: ReactTestRenderer | null = null let controller: MobileNativeChatController | null = null const clientStub = { sendRequest: vi.fn() } + const turnStartedAt = 1_700_000_000_000 const workingTab = { type: 'terminal', @@ -699,7 +711,10 @@ describe('useMobileNativeChatController streaming scope', () => { workingMode: undefined as 'monitoring' | undefined, agentType: 'claude', lastAssistantMessage: 'Partial reply', - providerSession: { id: 'session-1' } + providerSession: { id: 'session-1' }, + updatedAt: turnStartedAt, + stateStartedAt: turnStartedAt, + turnStartedAt }, isActive: true } @@ -722,9 +737,17 @@ describe('useMobileNativeChatController streaming scope', () => { return null } + function rerender(): void { + act(() => renderer?.update(createElement(Harness))) + } + beforeEach(() => { + // Hook freshness is read against the clock, so pin it to the hook's own update. + vi.useFakeTimers({ toFake: ['Date'] }) + vi.setSystemTime(turnStartedAt) viewMode.isTabChatView = () => true workingTab.agentStatus.workingMode = undefined + workingTab.agentStatus.stateStartedAt = turnStartedAt act(() => { renderer = create(createElement(Harness)) }) @@ -735,44 +758,72 @@ describe('useMobileNativeChatController streaming scope', () => { renderer = null controller = null viewMode.isTabChatView = () => true + Reflect.deleteProperty(sessionState, 'transcriptLifecycle') + vi.useRealTimers() }) - it('holds the stream scope and liveness while the user peeks at the terminal', () => { - expect(controller?.showNativeChat).toBe(true) - expect(controller?.nativeChatAgentWorking).toBe(true) - const scopeKey = controller?.nativeChatStreamScopeKey - expect(scopeKey).toContain('session-1') - expect(controller?.nativeChatStreamLive).toBe(true) - - viewMode.isTabChatView = () => false - act(() => renderer?.update(createElement(Harness))) - - expect(controller?.showNativeChat).toBe(false) - expect(controller?.nativeChatAgentWorking).toBe(false) - expect(controller?.nativeChatStreamScopeKey).toBe(scopeKey) - expect(controller?.nativeChatStreamLive).toBe(true) + it('runs the turn clock and streams the hook preview while the agent works', () => { + expect(controller?.nativeChatTurnActive).toBe(true) + expect(controller?.nativeChatCanStop).toBe(true) + expect(controller?.nativeChatStreamingText).toBe('Partial reply') + expect(controller?.nativeChatWorkingStartedAt).toBe(turnStartedAt) }) it('treats passive monitoring as outside the foreground turn', () => { workingTab.agentStatus.workingMode = 'monitoring' - act(() => renderer?.update(createElement(Harness))) + rerender() - expect(controller?.nativeChatAgentWorking).toBe(false) - expect(controller?.nativeChatStreamLive).toBe(false) + expect(controller?.nativeChatTurnActive).toBe(false) expect(controller?.nativeChatStreamingText).toBeUndefined() expect(controller?.nativeChatSessionOptions?.isWorking).toBe(false) }) - it('keeps the delayed-send route guard view-gated, unlike the stream scope', () => { + it("ends working at the transcript's turn end when the final hook never came", () => { + Object.assign(sessionState, { + transcriptLifecycle: { state: 'completed', turnId: 't1', timestamp: turnStartedAt + 30_000 } + }) + rerender() + + expect(controller?.nativeChatTurnActive).toBe(false) + expect(controller?.nativeChatCanStop).toBe(false) + }) + + it('holds Stop until the agent starts a newer turn', () => { + act(() => controller?.handleNativeChatStop()) + expect(controller?.nativeChatCanStop).toBe(false) + + workingTab.agentStatus.stateStartedAt = turnStartedAt + 60_000 + rerender() + expect(controller?.nativeChatCanStop).toBe(true) + }) + + it('ends the Stop hold once a new message is sent, as desktop does', () => { + act(() => controller?.handleNativeChatStop()) + expect(controller?.nativeChatCanStop).toBe(false) + + draftPending = [{ id: 'pending-1' }] + try { + rerender() + expect(controller?.nativeChatCanStop).toBe(true) + } finally { + draftPending = [] + } + }) + + it('offers Stop again when the Escape definitely went nowhere', async () => { + terminalStop.mockResolvedValueOnce(false) + await act(async () => controller?.handleNativeChatStop()) + expect(controller?.nativeChatCanStop).toBe(true) + }) + + it('keeps the delayed-send route guard view-gated', () => { const before = answerSendArgs.at(-1)?.streamIdentity - expect(before).toBe(controller?.nativeChatStreamScopeKey) + expect(before).toContain('term-1') viewMode.isTabChatView = () => false - act(() => renderer?.update(createElement(Harness))) + rerender() - const after = answerSendArgs.at(-1)?.streamIdentity - expect(after).not.toBe(before) - expect(after).not.toContain('session-1') - expect(controller?.nativeChatStreamScopeKey).toBe(before) + expect(answerSendArgs.at(-1)?.streamIdentity).not.toBe(before) + expect(controller?.nativeChatTurnActive).toBe(false) }) }) diff --git a/mobile/src/session/use-mobile-native-chat-controller.ts b/mobile/src/session/use-mobile-native-chat-controller.ts index be145f84600..78c33a0529e 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.ts @@ -5,15 +5,14 @@ import type { MobileNativeChatTab } from './native-chat/mobile-native-chat-eligi import type { StructuredAgentSessionHostSupport } from './mobile-structured-agent-session-host-support' import { useMobileNativeChatAskDismiss } from './native-chat/input/use-mobile-native-chat-ask-dismiss' import { useMobileNativeChatDrafts } from './native-chat/composer/use-mobile-native-chat-drafts' -import { useMobileNativeChatFileSearch } from './native-chat/composer/use-mobile-native-chat-file-search' +import { useMobileNativeChatWorkspaceCatalog } from './native-chat/composer/use-mobile-native-chat-workspace-catalog' import { useMobileNativeChatMessageSend } from './native-chat/transcript/use-mobile-native-chat-message-send' -import { mobileNativeChatStreamPreview } from './native-chat/mobile-native-chat-streaming-gate' import { useMobileNativeChatSessionOptionController } from './native-chat/input/use-mobile-native-chat-session-option-controller' import { useMobileNativeChatSessionLane } from './use-mobile-native-chat-session-lane' import { useMobileStructuredNativeChatSendBridge } from './use-mobile-structured-native-chat-send-bridge' import { useMobileNativeChatPrompts } from './native-chat/input/use-mobile-native-chat-prompts' import { useNativeChatAcceptedAction } from './use-native-chat-action-outcomes' -import { useThrottledLatestValue } from './use-throttled-latest-value' +import { useMobileNativeChatTerminalTurn } from './use-mobile-native-chat-terminal-turn' import type { MobileNativeChatController } from './mobile-native-chat-controller-contract' import { useMobileBridgeChatPromptWrites } from './use-mobile-bridge-chat-prompt-writes' import { useMobileNativeChatActiveResolution } from './use-mobile-native-chat-active-resolution' @@ -54,12 +53,12 @@ export function useMobileNativeChatController(args: { onSendResolved } = args const { + activeChatAgent, activeChatAgentConfirmed, activeChatAgentRef, activeChatResolution, activeChatSessionId, activeChatStructured, - activeTabAgentWorking, isTabChatView, sessionViewReady, setTabView, @@ -68,7 +67,6 @@ export function useMobileNativeChatController(args: { showNativeChatRef, sourceIdentity, streamIdentity, - streamScopeKey, toggleTabChatView } = useMobileNativeChatActiveResolution(args) @@ -116,19 +114,6 @@ export function useMobileNativeChatController(args: { transcriptSettled: nativeChatSession.status === 'ready' }) - // Observe turn liveness even off-chat so hidden mid-turn cannot look ended. - const nativeChatStreamLive = activeChatStructured - ? structuredNativeChat.isWorking - : activeTabAgentWorking - const nativeChatAgentWorking = - nativeChatStreamLive && (activeChatStructured || activeChatResolution != null) - // Throttle status-part previews to avoid reparsing the whole accumulated markdown per part. - const nativeChatStreamingText = useThrottledLatestValue( - activeChatStructured - ? undefined - : mobileNativeChatStreamPreview(nativeChatStatus, nativeChatAgentWorking), - 50 - ) const { permission: legacyNativeChatPermission, ask: nativeChatAskPrompt, @@ -175,7 +160,19 @@ export function useMobileNativeChatController(args: { onSendError }) - const nativeChatFiles = useMobileNativeChatFileSearch({ client, worktreeId }) + const terminalTurn = useMobileNativeChatTerminalTurn({ + status: nativeChatStatus, + resolution: activeChatResolution, + session: activeChatStructured ? null : nativeChatSession, + latestPendingId: chatPending.at(-1)?.id ?? null, + hasPromptCard: (legacyNativeChatPermission ?? nativeChatAskPrompt) != null, + scopeKey: streamIdentity, + stop: handleNativeChatStop + }) + const activeTurn = activeChatStructured ? structuredNativeChat : terminalTurn + const nativeChatAgentWorking = activeTurn.isWorking + + const workspaceCatalog = useMobileNativeChatWorkspaceCatalog(args, activeChatAgent) // Break the send/options cycle through a ref so snapshot changes cannot recreate send callbacks. const recordSessionOptionCommandRef = useRef<(command: string) => void>(() => {}) @@ -275,22 +272,19 @@ export function useMobileNativeChatController(args: { activeChatStructured && agentSessionHostSupport?.itemDetail === true ? structuredNativeChat.visualSource : null, - nativeChatAgentWorking, + nativeChatTurnActive: activeChatStructured + ? structuredNativeChat.isWorking + : terminalTurn.turnActive, + nativeChatAwaitingInput: activeChatStructured ? null : terminalTurn.awaitingInput, nativeChatTurnIndicator: activeChatStructured ? structuredNativeChat.turnIndicator : null, - nativeChatWorkingStartedAt: activeChatStructured ? structuredNativeChat.workingStartedAt : null, - nativeChatSettledTurns: activeChatStructured ? structuredNativeChat.settledTurns : null, + nativeChatWorkingStartedAt: activeTurn.workingStartedAt, + nativeChatSettledTurns: activeTurn.settledTurns, nativeChatTurnJournal: activeChatStructured ? structuredNativeChat.turnJournal : null, nativeChatReceipts: activeChatStructured ? structuredNativeChat.receipts : null, nativeChatActionablePromptIds: structuredNativeChat.actionablePromptIds, nativeChatCommandRefusalCauses: structuredNativeChat.commandRefusalCauses, - // A host that stops what the chat has in flight takes Stop before the agent opens a turn. - nativeChatCanStop: activeChatStructured - ? structuredNativeChat.turnId !== null || - (agentSessionHostSupport?.conversationStop === true && structuredNativeChat.isWorking) - : nativeChatAgentWorking, - nativeChatStreamingText, - nativeChatStreamLive, - nativeChatStreamScopeKey: streamScopeKey, + nativeChatCanStop: activeChatStructured ? structuredNativeChat.canStop : nativeChatAgentWorking, + nativeChatStreamingText: terminalTurn.streamingText, ...promptCards, nativeChatAsk: askDismissal.showAsk || askDismissal.collapsedAsk ? nativeChatAskPrompt : null, nativeChatAskKey: askDismissal.askKey, @@ -299,10 +293,10 @@ export function useMobileNativeChatController(args: { nativeChatCollapsedPrompt: askDismissal.collapsedAsk ?? promptCards.nativeChatCollapsedPrompt, handleNativeChatAnswerAsk: answerAsk, handleNativeChatCancelAsk: cancelAsk, - handleNativeChatStop: activeChatStructured ? structuredNativeChat.cancel : handleNativeChatStop, + handleNativeChatStop: activeChatStructured ? structuredNativeChat.cancel : terminalTurn.stop, nativeChatQueued: structuredNativeChat.queued, nativeChatBackgroundTasks: activeChatStructured ? structuredNativeChat.backgroundTasks : null, - ...nativeChatFiles, + ...workspaceCatalog, handleNativeChatSend: activeChatStructured ? structuredNativeChatSend.send : handleNativeChatSend, diff --git a/mobile/src/session/use-mobile-native-chat-queue-follow-ups.ts b/mobile/src/session/use-mobile-native-chat-queue-follow-ups.ts new file mode 100644 index 00000000000..1f0509c0827 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-queue-follow-ups.ts @@ -0,0 +1,33 @@ +import { useEffect, useState } from 'react' +import { nativeChatQueueFollowUpsRead } from '../transport/settings-read-operations' +import type { RpcClient } from '../transport/rpc-client' + +// Read per connection and chat: the phone has no settings stream, and on is today's default. +export function useMobileNativeChatQueueFollowUps( + client: RpcClient | null, + connected: boolean, + sessionId: string | null +): boolean { + const [queueFollowUps, setQueueFollowUps] = useState(true) + useEffect(() => { + if (!client || !connected || !sessionId) { + return + } + let stale = false + void nativeChatQueueFollowUpsRead + .request(client) + .then((response) => { + const preference = nativeChatQueueFollowUpsRead.interpret(response) + if (!stale && preference.accepted) { + setQueueFollowUps(preference.value) + } + }) + .catch(() => { + // Keep the last answer when the host is unreachable. + }) + return () => { + stale = true + } + }, [client, connected, sessionId]) + return queueFollowUps +} diff --git a/mobile/src/session/use-mobile-native-chat-session.ts b/mobile/src/session/use-mobile-native-chat-session.ts index 4765a24d110..a1862ac24ee 100644 --- a/mobile/src/session/use-mobile-native-chat-session.ts +++ b/mobile/src/session/use-mobile-native-chat-session.ts @@ -4,7 +4,10 @@ import { encodeNativeChatTranscriptIdentity } from '../../../src/shared/native-chat-transcript-retention' import { createNativeChatMerger, replaceList } from '../../../src/shared/native-chat-merge' -import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import type { + NativeChatMessage, + NativeChatTurnLifecycle +} from '../../../src/shared/native-chat-types' import { buildNativeChatSubscriptionId } from '../../../src/shared/native-chat/native-chat-stream-unsubscribe' import type { RpcClient } from '../transport/rpc-client' import { nativeChatSessionPageRead } from './mobile-session-read-operations' @@ -38,6 +41,8 @@ export type MobileNativeChatSession = { loadingEarlier: boolean olderPages?: OlderPagePhase loadEarlier: () => void + /** Latest provider turn boundary from the transcript feed; absent from older hosts. */ + transcriptLifecycle?: NativeChatTurnLifecycle } const INITIAL_LIMIT = 40 @@ -93,6 +98,10 @@ export function useMobileNativeChatSession(args: { const settled = initialStatus === 'loading' ? current : null const status = settled ? settled.status : initialStatus const [error, setError] = useState(undefined) + const [lifecycle, setLifecycle] = useState<{ + identity: string + lifecycle: NativeChatTurnLifecycle | undefined + } | null>(null) const [hasMore, setHasMore] = useState(false) const { phase: olderPages, load: loadOlderPages, supersede } = useMobileOlderPageLoader() const beforeOffsetRef = useRef(null) @@ -181,6 +190,10 @@ export function useMobileNativeChatSession(args: { beforeOffsetRef.current = applied.beforeOffset ?? null setHasMore(applied.hasMore ?? applied.messages.length >= INITIAL_LIMIT) } + // Snapshots and replacements restate the boundary; appends only advance it. + if (frame.type === 'appended' ? frame.lifecycle !== undefined : !applied.pending) { + setLifecycle({ identity, lifecycle: frame.lifecycle }) + } setMessages(applied.messages) if (!applied.windowReplaced && applied.hasMore != null) { setHasMore(applied.hasMore) @@ -286,6 +299,7 @@ export function useMobileNativeChatSession(args: { hasMore, loadingEarlier: olderPages === 'loading', olderPages, - loadEarlier + loadEarlier, + transcriptLifecycle: lifecycle?.identity === identity ? lifecycle.lifecycle : undefined } } diff --git a/mobile/src/session/use-mobile-native-chat-stop.test.ts b/mobile/src/session/use-mobile-native-chat-stop.test.ts index c6539e236e4..5bbedea610c 100644 --- a/mobile/src/session/use-mobile-native-chat-stop.test.ts +++ b/mobile/src/session/use-mobile-native-chat-stop.test.ts @@ -13,7 +13,7 @@ vi.mock('../terminal/worker-terminal-takeover-report', () => ({ describe('useMobileNativeChatStop', () => { let renderer: ReactTestRenderer | null = null - let stop: (() => void) | null = null + let stop: (() => Promise) | null = null const sendRequest = vi.fn() const onSendError = vi.fn() @@ -25,6 +25,7 @@ describe('useMobileNativeChatStop', () => { }) onSendError.mockReset() reportWorkerTerminalUserInput.mockReset() + agent = 'opencode' }) afterEach(() => { @@ -34,6 +35,8 @@ describe('useMobileNativeChatStop', () => { vi.useRealTimers() }) + let agent = 'opencode' + function Harness({ enabled, streamIdentity @@ -46,6 +49,7 @@ describe('useMobileNativeChatStop', () => { enabled, handleRef: { current: 'terminal-1' }, deviceTokenRef: { current: 'mobile-1' }, + agentRef: { current: agent }, streamIdentity, cancelPending: vi.fn(), onSendError @@ -70,7 +74,7 @@ describe('useMobileNativeChatStop', () => { ])('cancels the delayed second Escape when %s', async (_case, enabled, streamIdentity) => { await render(true, 'stream-1') - act(() => stop?.()) + act(() => void stop?.()) expect(sendRequest).toHaveBeenCalledTimes(1) await render(enabled as boolean, streamIdentity as string) @@ -81,11 +85,49 @@ describe('useMobileNativeChatStop', () => { expect(sendRequest).toHaveBeenCalledTimes(1) }) + it.each<[string, number]>([ + ['claude', 1], + ['codex', 1], + ['opencode', 2] + ])('sends %s %i Escape(s) per Stop like desktop', async (stopAgent, count) => { + agent = stopAgent + await render(true, 'stream-1') + + act(() => void stop?.()) + await act(async () => { + await vi.runAllTimersAsync() + }) + + expect(sendRequest).toHaveBeenCalledTimes(count) + }) + + it.each([ + ['a definite rejection', new Error('disconnected'), false], + ['a lost ack', markRpcDeliveryUnknown(new Error('rpc timeout')), true] + ])( + 'settles the Stop as possibly sent only when it was not refused: %s', + async (_case, error, sent) => { + agent = 'claude' + sendRequest.mockRejectedValue(error) + await render(true, 'stream-1') + + let verdict: Promise | undefined + act(() => { + verdict = stop?.() + }) + await act(async () => { + await vi.runAllTimersAsync() + }) + + await expect(verdict).resolves.toBe(sent) + } + ) + it('handles a rejected Escape without leaking an unhandled rejection', async () => { sendRequest.mockRejectedValue(new Error('disconnected')) await render(true, 'stream-1') - act(() => stop?.()) + act(() => void stop?.()) await act(async () => { await Promise.resolve() await vi.runAllTimersAsync() @@ -102,7 +144,7 @@ describe('useMobileNativeChatStop', () => { sendRequest.mockResolvedValue(response) await render(true, 'stream-1') - act(() => stop?.()) + act(() => void stop?.()) await act(async () => { await vi.runAllTimersAsync() }) @@ -121,7 +163,7 @@ describe('useMobileNativeChatStop', () => { sendRequest.mockRejectedValue(makeError()) await render(true, 'stream-1') - act(() => stop?.()) + act(() => void stop?.()) await act(async () => { await Promise.resolve() await vi.runAllTimersAsync() @@ -146,7 +188,7 @@ describe('useMobileNativeChatStop', () => { }) await render(true, 'stream-1') - act(() => stop?.()) + act(() => void stop?.()) await act(async () => { await Promise.resolve() await vi.runAllTimersAsync() @@ -167,8 +209,8 @@ describe('useMobileNativeChatStop', () => { .mockResolvedValue({ ok: true, result: { send: { accepted: true } } }) await render(true, 'stream-1') - act(() => stop?.()) - act(() => stop?.()) + act(() => void stop?.()) + act(() => void stop?.()) await act(async () => { await vi.runAllTimersAsync() }) @@ -183,7 +225,7 @@ describe('useMobileNativeChatStop', () => { it('reports the takeover once an Escape is accepted', async () => { await render(true, 'stream-1') - act(() => stop?.()) + act(() => void stop?.()) await act(async () => { await vi.runAllTimersAsync() }) @@ -198,7 +240,7 @@ describe('useMobileNativeChatStop', () => { sendRequest.mockResolvedValue({ ok: true, result: { send: { accepted: false } } }) await render(true, 'stream-1') - act(() => stop?.()) + act(() => void stop?.()) await act(async () => { await vi.runAllTimersAsync() }) diff --git a/mobile/src/session/use-mobile-native-chat-stop.ts b/mobile/src/session/use-mobile-native-chat-stop.ts index a77889ea280..e6478beba2e 100644 --- a/mobile/src/session/use-mobile-native-chat-stop.ts +++ b/mobile/src/session/use-mobile-native-chat-stop.ts @@ -5,18 +5,30 @@ import { isLogicalClientCutoverError } from '../transport/stable-logical-rpc-cli import { nativeChatTerminalWrite } from './mobile-session-write-operations' import { reportWorkerTerminalUserInput } from '../terminal/worker-terminal-takeover-report' import { openMobileNativeChatSendBudget } from './native-chat/mobile-native-chat-send' +import { nativeChatStopSendsSecondEscape } from '../../../src/shared/native-chat-agent-support' +import { NATIVE_CHAT_QUESTION_STEP_MS } from '../../../src/shared/native-chat/input/native-chat-answer-stepping' +// Resolves false only when no Escape could have landed, so the chat may offer Stop again. export function useMobileNativeChatStop(args: { client: RpcClient | null enabled: boolean handleRef: MutableRefObject deviceTokenRef: MutableRefObject + agentRef: MutableRefObject streamIdentity: string cancelPending: () => void onSendError: (message: string) => void -}): () => void { - const { client, enabled, handleRef, deviceTokenRef, streamIdentity, cancelPending, onSendError } = - args +}): () => Promise { + const { + client, + enabled, + handleRef, + deviceTokenRef, + agentRef, + streamIdentity, + cancelPending, + onSendError + } = args const timerRef = useRef | null>(null) const generationRef = useRef(0) // Settle a cancelled second Escape so first-write failure cannot wait forever for an unsent write. @@ -43,7 +55,7 @@ export function useMobileNativeChatStop(args: { const handle = handleRef.current if (!client || !handle || !enabled) { onSendError('Stop not sent (terminal not ready)') - return + return Promise.resolve(false) } cancelPending() generationRef.current += 1 @@ -56,13 +68,16 @@ export function useMobileNativeChatStop(args: { let sawAccepted = false let sawUnknown = false let sawRejected = false + let settle!: (sent: boolean) => void + const verdict = new Promise((resolve) => { + settle = resolve + }) const reportIfSettled = (): void => { - if ( - generationRef.current !== generation || - pending > 0 || - sawAccepted || - (!sawUnknown && !sawRejected) - ) { + if (pending > 0) { + return + } + settle(sawAccepted || sawUnknown || !sawRejected) + if (generationRef.current !== generation || sawAccepted || (!sawUnknown && !sawRejected)) { return } // Lost-ack/cutover Escape may have stopped work; report uncertainty instead of inviting a second key. @@ -122,19 +137,26 @@ export function useMobileNativeChatStop(args: { }) } sendEscape() + if (!nativeChatStopSendsSecondEscape(agentRef.current)) { + pending -= 1 + reportIfSettled() + return verdict + } dropSecondEscapeRef.current = () => { pending -= 1 reportIfSettled() } - // Pace Escape bytes so remote input cannot coalesce them away. + // Pace like desktop's answer steps so remote input cannot coalesce the two Escapes away. timerRef.current = setTimeout(() => { timerRef.current = null dropSecondEscapeRef.current = null sendEscape() pending -= 1 reportIfSettled() - }, 80) + }, NATIVE_CHAT_QUESTION_STEP_MS) + return verdict }, [ + agentRef, cancelPending, cancelSecondEscape, client, diff --git a/mobile/src/session/use-mobile-native-chat-terminal-turn.ts b/mobile/src/session/use-mobile-native-chat-terminal-turn.ts new file mode 100644 index 00000000000..6a921400cbb --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-terminal-turn.ts @@ -0,0 +1,142 @@ +import { useCallback, useMemo, useState } from 'react' +import type { AgentStatusEntry } from '../../../src/shared/agent-status-types' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import type { NativeChatSettledTurns } from '../../../src/shared/native-chat-turn-status' +import { + mergeNativeChatLiveSession, + nativeChatHookHasWorkingSubagents, + resolveNativeChatHookState +} from '../../../src/shared/native-chat/native-chat-live-status' +import { shouldClearNativeChatWorkingSuppression } from '../../../src/shared/native-chat/native-chat-working-suppression' +import { + nativeChatHookLatestTurnWorkedSeconds, + nativeChatHookTurnStartedAt, + nativeChatLatestTurnId, + nativeChatTerminalSettledTurns, + nativeChatTranscriptSettledTurns, + resolveNativeChatTerminalTurn, + type NativeChatTerminalTurn +} from '../../../src/shared/native-chat/native-chat-terminal-turn' +import { deriveNativeChatStreamingText } from '../../../src/shared/native-chat/native-chat-streaming' +import { useThrottledLatestValue } from './use-throttled-latest-value' +import type { MobileNativeChatSession } from './use-mobile-native-chat-session' +import type { MobileNativeChatResolution } from './native-chat/mobile-native-chat-eligibility' + +const NO_MESSAGES: NativeChatMessage[] = [] + +export type MobileNativeChatTerminalTurn = NativeChatTerminalTurn & { + streamingText: string | undefined + workingStartedAt: number | null + settledTurns: NativeChatSettledTurns + /** Stop hides working until the turn ends or a newer working epoch starts. */ + stop: () => void +} + +// Desktop's terminal-chat turn rules (NativeChatResolvedView) over the phone's hook row and transcript feed. +export function useMobileNativeChatTerminalTurn(args: { + /** The terminal chat's hook row; null for structured chats or when no chat shows. */ + status: AgentStatusEntry | null | undefined + resolution: Pick | null + /** The terminal chat's transcript; null for structured chats. */ + session: Pick | null + /** The newest pending echo; a new one is a new send, which ends Stop's suppression like desktop. */ + latestPendingId: string | null + hasPromptCard: boolean + scopeKey: string + stop: () => Promise +}): MobileNativeChatTerminalTurn { + const { status, resolution, latestPendingId, hasPromptCard, scopeKey, stop } = args + const hasPending = latestPendingId !== null + const messages = args.session?.messages ?? NO_MESSAGES + const transcriptLifecycle = args.session?.transcriptLifecycle + const hookState = resolveNativeChatHookState(status) + const stateStartedAt = status?.stateStartedAt ?? null + const live = mergeNativeChatLiveSession({ + messages, + sessionId: resolution?.sessionId ?? null, + agent: resolution?.agent ?? '', + hookState, + stateStartedAt, + transcriptLifecycle, + hookHasWorkingSubagents: nativeChatHookHasWorkingSubagents(status) + }) + const liveWorking = live.status === 'working' + // Throttle status-part previews to avoid reparsing the whole accumulated markdown per part. + const streamingText = useThrottledLatestValue( + deriveNativeChatStreamingText({ + // A pending echo is the transcript's tail on desktop, so no earlier reply can hide the preview. + messages: hasPending ? NO_MESSAGES : messages, + previewText: status?.lastAssistantMessage, + working: liveWorking, + previewIsToolOutput: status?.lastAssistantMessageIsToolOutput === true + }) ?? undefined, + 50 + ) + + // The working epoch a Stop suppressed; a newer epoch, a new send or the turn's end releases it. + const [suppression, setSuppression] = useState<{ + scopeKey: string + epoch: number | null + latestPendingId: string | null + } | null>(null) + const suppressed = suppression?.scopeKey === scopeKey ? suppression : null + const sentSince = hasPending && latestPendingId !== suppressed?.latestPendingId + if ( + suppressed && + (sentSince || + shouldClearNativeChatWorkingSuppression({ + working: liveWorking, + interrupted: true, + workingEpoch: stateStartedAt, + previousWorkingEpoch: suppressed.epoch + })) + ) { + setSuppression(null) + } else if (suppressed && suppressed.epoch === null && stateStartedAt !== null) { + setSuppression({ ...suppressed, epoch: stateStartedAt }) + } + const interrupted = suppressed !== null + const stopTurn = useCallback(() => { + const pressed = { scopeKey, epoch: stateStartedAt, latestPendingId } + setSuppression(pressed) + void stop().then((sent) => { + if (!sent) { + setSuppression((current) => (current === pressed ? null : current)) + } + }) + }, [latestPendingId, scopeKey, stateStartedAt, stop]) + + const turn = resolveNativeChatTerminalTurn({ + isConversation: messages.length > 0 || hasPending || streamingText !== undefined, + working: liveWorking, + hookAwaitingInput: live.hookAwaitingInput, + interrupted, + hasPromptCard + }) + + const turnStartedAt = nativeChatHookTurnStartedAt(status ?? undefined) + const latestWorkedSeconds = nativeChatHookLatestTurnWorkedSeconds( + status ?? undefined, + hookState === null + ) + const transcriptSettled = useMemo(() => nativeChatTranscriptSettledTurns(messages), [messages]) + const latestTurnId = useMemo(() => nativeChatLatestTurnId(messages), [messages]) + const settledTurns = useMemo( + () => + nativeChatTerminalSettledTurns({ + transcriptSettled, + latestTurnId, + latestWorkedSeconds, + turnStartedAt, + turnActive: turn.turnActive + }), + [latestTurnId, latestWorkedSeconds, transcriptSettled, turn.turnActive, turnStartedAt] + ) + return { + ...turn, + streamingText, + workingStartedAt: turn.turnActive ? turnStartedAt : null, + settledTurns, + stop: stopTurn + } +} diff --git a/mobile/src/session/use-mobile-queued-message-composer-actions.ts b/mobile/src/session/use-mobile-queued-message-composer-actions.ts index 70a851ede26..1ea421afe52 100644 --- a/mobile/src/session/use-mobile-queued-message-composer-actions.ts +++ b/mobile/src/session/use-mobile-queued-message-composer-actions.ts @@ -1,13 +1,17 @@ import { useCallback, useLayoutEffect, useRef, useState } from 'react' -import type { AgentSessionQueuedMessageMoveResult } from '../../../src/shared/agent-session-wire' +import type { + AgentSessionQueuedMessage, + AgentSessionQueuedMessageMoveResult +} from '../../../src/shared/agent-session-wire' import type { DragReorderMove } from '../components/drag-reorder-positions' -import type { MobileQueuedMessageFeed } from './mobile-structured-queued-message-feed' import type { MobileStructuredAgentMutate } from './use-mobile-structured-agent-mutation' +type QueuedMessages = readonly AgentSessionQueuedMessage[] | null | undefined + export function useMobileQueuedMessageComposerActions(args: { sessionKey: string sessionId: string | null - messages: MobileQueuedMessageFeed + messages: QueuedMessages reorderCapable: boolean enabled: boolean mutate: MobileStructuredAgentMutate @@ -23,8 +27,8 @@ export function useMobileQueuedMessageComposerActions(args: { }, [sessionKey]) const [preview, setPreview] = useState<{ scope: string - source: MobileQueuedMessageFeed - messages: MobileQueuedMessageFeed + source: QueuedMessages + messages: QueuedMessages } | null>(null) const move = useCallback( async (move: DragReorderMove): Promise => { @@ -39,10 +43,12 @@ export function useMobileQueuedMessageComposerActions(args: { return false } remaining.splice(at + ('afterId' in move ? 1 : 0), 0, source) + // Cards sort by position, so the preview hands the moved order the queue's own positions. + const positions = messages.map((message) => message.position).sort((a, b) => a - b) setPreview({ scope: sessionKey, source: messages, - messages: remaining + messages: remaining.map((message, index) => ({ ...message, position: positions[index]! })) }) const result = await mutate( 'agentSession.queuedMessageMove', diff --git a/mobile/src/session/use-mobile-structured-agent-session-queued.test.tsx b/mobile/src/session/use-mobile-structured-agent-session-queued.test.tsx index 27e1f3a56c1..5f2dc43524e 100644 --- a/mobile/src/session/use-mobile-structured-agent-session-queued.test.tsx +++ b/mobile/src/session/use-mobile-structured-agent-session-queued.test.tsx @@ -175,7 +175,7 @@ describe('mobile structured queued messages', () => { moving = hook!.queued.move!({ movedId: 'mail', afterId: 'b' }) }) expect(hook!.queued.cards.map((card) => card.messageId)).toEqual(['b', 'mail']) - expect(hook!.queued.cards[1]?.automatic).toBe(true) + expect(hook!.queued.cards[1]?.from).toBeDefined() expect(requestOf('agentSession.queuedMessageMove').params).toMatchObject({ messageId: 'mail', afterMessageId: 'b' @@ -234,6 +234,30 @@ describe('mobile structured queued messages', () => { expect(asyncStorage.setItem).not.toHaveBeenCalled() }) + it("sends straight into the turn when the host's Queue follow-ups is off, as the desktop does", async () => { + sendRequest.mockImplementation(async (method) => { + if (method === 'settings.get') { + return ok({ settings: { nativeChatQueueFollowUps: false } }) + } + if (method === 'agentSession.send') { + return mutationOk({ + clientMessageId: 'client-1', + submission: acceptedSubmission('client-1') + }) + } + return method === 'agentSession.options' ? ok({ models: [], current: {} }) : ok({}) + }) + await mountSession(CAPABLE) + await vi.waitFor(() => + expect(sendRequest.mock.calls.map(([method]) => method)).toContain('settings.get') + ) + await act(async () => {}) + await act(async () => { + await hook!.sendWithOutcome('steer me') + }) + expect('delivery' in requestOf('agentSession.send').params).toBe(false) + }) + it('keeps today’s request exactly against an incapable host', async () => { sendRequest.mockImplementation(async (method) => { if (method === 'agentSession.send') { @@ -334,9 +358,11 @@ describe('mobile structured queued messages', () => { expect(hook!.queued.cards).toEqual([ { messageId: 'draft-1', + position: 1, text: 'text of draft-1', + line: 'text of draft-1', state: 'waiting', - paused: false, + hold: 'turn', needsAttention: false, caption: null, attribution: null @@ -622,7 +648,7 @@ describe('mobile structured queued messages', () => { expect(hook!.queued.pause).toEqual({ reason: 'stopped' }) expect(hook!.queued.cards[0]).toMatchObject({ messageId: 'draft-1', - paused: false, + hold: 'queue-paused', caption: null }) act(() => listener?.(batchEvent())) diff --git a/mobile/src/session/use-mobile-structured-agent-session-send.test.tsx b/mobile/src/session/use-mobile-structured-agent-session-send.test.tsx index 23ae08f9da4..86bc376087b 100644 --- a/mobile/src/session/use-mobile-structured-agent-session-send.test.tsx +++ b/mobile/src/session/use-mobile-structured-agent-session-send.test.tsx @@ -12,7 +12,10 @@ import { import { markRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity' import { structuredSendResultFixture } from './structured-agent-send-result.test-fixture' import { useMobileStructuredAgentSession } from './use-mobile-structured-agent-session' -import { AGENT_SESSION_SEND_OPTIONS_RUNTIME_CAPABILITY } from '../../../src/shared/agent-session-create-capabilities' +import { + AGENT_SESSION_SEND_OPTIONS_RUNTIME_CAPABILITY, + AGENT_SESSION_SEND_SKILLS_RUNTIME_CAPABILITY +} from '../../../src/shared/agent-session-create-capabilities' import { structuredAgentSessionHostSupport, type StructuredAgentSessionHostSupport @@ -447,6 +450,29 @@ describe('mobile structured send actions', () => { }) }) + it.each([true, false])( + 'sends the picked skill path only to a host that takes it: %s', + async (takesSkills) => { + hostSupport = structuredAgentSessionHostSupport( + takesSkills ? [AGENT_SESSION_SEND_SKILLS_RUNTIME_CAPABILITY] : [] + ) + sendRequest.mockImplementation(async (method) => + method === 'agentSession.send' ? sendResult('accepted') : ok({ models: [], current: {} }) + ) + await mountSession() + const review = { name: 'review', path: '/repo/.agents/skills/review/SKILL.md' } + await act(async () => { + await hook!.sendWithOutcome('$review this', undefined, undefined, [], [review]) + }) + expect(fieldsOf(calls()[0]?.[1]).body).toEqual({ + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: '$review this' }], + ...(takesSkills ? { skills: [review] } : {}) + }) + } + ) + it('sends an attached file as a reference after the text, and only images as image blocks', async () => { sendRequest.mockImplementation(async (method) => method === 'agentSession.send' ? sendResult('accepted') : ok({ models: [], current: {} }) diff --git a/mobile/src/session/use-mobile-structured-agent-session.ts b/mobile/src/session/use-mobile-structured-agent-session.ts index 0191801e1a9..7a34a3200ab 100644 --- a/mobile/src/session/use-mobile-structured-agent-session.ts +++ b/mobile/src/session/use-mobile-structured-agent-session.ts @@ -11,11 +11,10 @@ import { tuiAgentDisplayName } from '../../../src/shared/tui-agent-display-names import { isStructuredAgentSessionMainAgentWorking } from '../../../src/shared/structured-agent-session-main-agent-working' import { isFinalAgentSessionReadRefusal } from '../../../src/shared/structured-agent-session-read-refusal' import { - isStructuredAgentSessionThinking, runningStructuredAgentSessionTurnId, - structuredAgentSessionHostSaysWorking + structuredAgentSessionHostSaysWorking, + structuredAgentSessionQueueSendsNext } from '../../../src/shared/structured-agent-session-live-turn' -import { selectStructuredAgentTurnActivity } from '../../../src/shared/native-chat-turn-activity' import { actionableStructuredApproval, actionableStructuredQuestion @@ -41,14 +40,13 @@ import { requestMobileStructuredAgentSessionCancel } from './mobile-structured-agent-session-cancel' import { useMobileStructuredAgentMutate } from './use-mobile-structured-agent-mutation' -import { agentStopDisplayStatus } from '../../../src/shared/agent-stop-display-status' -import { - mobileStructuredSendQueues, - useMobileStructuredSendWithOutcome -} from './use-mobile-structured-send-with-outcome' +import { useMobileStructuredSendWithOutcome } from './use-mobile-structured-send-with-outcome' import { useMobileStructuredQueuedMessageControls } from './use-mobile-structured-queued-message-controls' import { useMobileStructuredBackgroundTasks } from './use-mobile-structured-background-tasks' import { useMobileStructuredSends } from './use-mobile-structured-sends' +import { useMobileNativeChatQueueFollowUps } from './use-mobile-native-chat-queue-follow-ups' +import { useMobileStructuredStopOffer } from './use-mobile-structured-stop-offer' +import { useMobileStructuredTurnIndicator } from './use-mobile-structured-turn-indicator' export function useMobileStructuredAgentSession( args: MobileStructuredAgentSessionArgs @@ -68,6 +66,7 @@ export function useMobileStructuredAgentSession( // Gate queueing by capability while accepting published cards from every host. const queueCapable = hostSupport?.queuedMessages === true const commandsWait = queueCapable && hostSupport?.queuedCommands === true + const queueFollowUps = useMobileNativeChatQueueFollowUps(client, connected && enabled, sessionId) const promptCancelSupported = hostSupport?.promptCancel ?? null const hostAnswersRepeatedStops = hostSupport?.quietRepeatedStop ?? null const hostStopsConversation = hostSupport?.conversationStop ?? null @@ -152,12 +151,10 @@ export function useMobileStructuredAgentSession( { ...state, items: transcriptItems }, turnId ) - const activityText = - selectStructuredAgentTurnActivity(state.items, turnId, state.activity)?.text ?? null - const thinking = isStructuredAgentSessionThinking(state) const isWorking = structuredAgentSessionHostSaysWorking(state.working, () => isStructuredAgentSessionMainAgentWorking(turnId, state.submissions, state.fence) ) + const queueSendsNext = structuredAgentSessionQueueSendsNext(state.nextQueuedMessageId, isWorking) const backgroundTasks = useMobileStructuredBackgroundTasks({ sessionKey, state, @@ -171,46 +168,44 @@ export function useMobileStructuredAgentSession( enabled: enabled && connected && hostSupport?.statusFeed === true }) const stopPress = useMobileStructuredStopPress(sessionKey) - const stopping = - agentStopDisplayStatus({ - working: isWorking, - hostStopping, - stopPressed: stopPress.pressed - }) === 'stopping' - const stopRequestInFlight = isWorking && stopPress.pressed + const { canStop, sendsOut } = useMobileStructuredStopOffer({ + sessionKey, + turnId, + isWorking, + stopsConversation: hostStopsConversation === true + }) + const turnIndicator = useMobileStructuredTurnIndicator({ + state, + turnId, + isWorking, + canStop, + hostStopping, + stopPressed: stopPress.pressed, + queueCapable, + queueFollowUps + }) + const { stopping } = turnIndicator const sendWithOutcome = useMobileStructuredSendWithOutcome({ agent, client, sessionId, enabled, queueCapable, + queueFollowUps, commandsWait, stateRef, stopping, accept: sends.accept, hold: sends.hold, commandPending: commandPendingRef, + sendsOut, controller: sendController, sessionCommands: args.sessionCommands, onSendError, - sendOptions: hostSupport?.sendOptions === true ? options.sendSelection : undefined + queueSendsNext, + sendOptions: hostSupport?.sendOptions === true ? options.sendSelection : undefined, + takesSkills: hostSupport?.sendSkills === true }) - const turnIndicator = useMemo( - () => ({ - thinking, - activityText, - stopping, - stopRequestInFlight, - ...(stopping - ? { - afterStop: mobileStructuredSendQueues(queueCapable, state) - ? ('queue' as const) - : ('send' as const) - } - : {}) - }), - [thinking, activityText, stopping, stopRequestInFlight, queueCapable, state] - ) const { items, currentRequests, actionablePromptIds, status } = state // Prefer host actionability: ended-agent prompts must receive no answer. const [approvalPrompt, questionPrompt] = useMemo(() => { @@ -219,10 +214,10 @@ export function useMobileStructuredAgentSession( }, [items, currentRequests, actionablePromptIds]) const commandRefusalCauses = useMemo( () => ({ - working: turnId !== null, + working: turnId !== null || queueSendsNext, prompt: approvalPrompt !== null || questionPrompt !== null }), - [approvalPrompt, questionPrompt, turnId] + [approvalPrompt, questionPrompt, queueSendsNext, turnId] ) const queued = useMobileStructuredQueuedMessageControls({ sessionKey, @@ -290,7 +285,8 @@ export function useMobileStructuredAgentSession( olderPages, loadEarlier }, - isWorking, + isWorking: isWorking || queueSendsNext, + canStop, turnId, turnIndicator, ...turnTiming, diff --git a/mobile/src/session/use-mobile-structured-agent-state.ts b/mobile/src/session/use-mobile-structured-agent-state.ts index c0716d038a6..9b1e9221218 100644 --- a/mobile/src/session/use-mobile-structured-agent-state.ts +++ b/mobile/src/session/use-mobile-structured-agent-state.ts @@ -2,7 +2,8 @@ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react import { isRootAgentJournalItem } from '../../../src/shared/agent-session-journal-producer' import type { AgentSessionHistoryResult, - AgentSessionSubscribeEvent + AgentSessionQueuedMessage, + AgentSessionQueuePause } from '../../../src/shared/agent-session-wire' import { AGENT_SESSION_HISTORY_MAX_LIMIT } from '../../../src/shared/agent-session-wire' import { structuredAgentSessionHolderId } from '../../../src/shared/structured-agent-session-holder' @@ -23,20 +24,8 @@ import { callAgentSession } from './mobile-structured-agent-session-rpc' import { subscribeTranscriptAfterHold } from './mobile-structured-transcript-subscription' -import { - reduceMobileQueuePause, - reduceMobileQueuedMessageFeed, - type MobileQueuedMessageFeed, - type MobileQueuePause -} from './mobile-structured-queued-message-feed' import { useMobileOlderPageLoader, type OlderPagePhase } from './use-mobile-older-page-loader' -type QueuedFeed = { - messages: MobileQueuedMessageFeed - pause: MobileQueuePause -} -const NO_QUEUED_FEED: QueuedFeed = { messages: null, pause: null } - const MAX_RETAINED_SESSION_STATES = 32 export function useMobileStructuredAgentState(args: { @@ -49,8 +38,9 @@ export function useMobileStructuredAgentState(args: { }): { state: StructuredAgentSessionState stateRef: { readonly current: StructuredAgentSessionState } - queuedMessages: MobileQueuedMessageFeed - queuePause: MobileQueuePause + /** Null until the host publishes a queue (older hosts never do). */ + queuedMessages: AgentSessionQueuedMessage[] | null + queuePause: AgentSessionQueuePause | null olderPages: OlderPagePhase loadEarlier: () => void } { @@ -59,14 +49,10 @@ export function useMobileStructuredAgentState(args: { const [sessionStates, setSessionStates] = useState>( () => new Map() ) - const [queuedBySession, setQueuedBySession] = useState>(() => new Map()) const state = enabled && sessionKey ? (sessionStates.get(sessionKey) ?? EMPTY_STRUCTURED_AGENT_SESSION) : EMPTY_STRUCTURED_AGENT_SESSION - const queued = - (enabled && sessionKey ? queuedBySession.get(sessionKey) : undefined) ?? NO_QUEUED_FEED - const queuedMessages = queued.messages const { phase: olderPages, load: loadOlderPages, supersede } = useMobileOlderPageLoader() const stateRef = useRef(state) useLayoutEffect(() => { @@ -99,34 +85,6 @@ export function useMobileStructuredAgentState(args: { }, [sessionKey] ) - const applyQueued = useCallback( - (event: AgentSessionSubscribeEvent) => { - if (!sessionKey) { - return - } - setQueuedBySession((current) => { - const previous = current.get(sessionKey) ?? NO_QUEUED_FEED - const messages = reduceMobileQueuedMessageFeed(previous.messages, event) - const pause = reduceMobileQueuePause(previous.pause, event) - if (messages === previous.messages && pause === previous.pause) { - return current - } - const updated = new Map(current) - updated.delete(sessionKey) - updated.set(sessionKey, { messages, pause }) - while (updated.size > MAX_RETAINED_SESSION_STATES) { - const oldest = updated.keys().next().value - if (oldest === undefined) { - break - } - updated.delete(oldest) - } - return updated - }) - }, - [sessionKey] - ) - useEffect(() => { supersede() if (!client || !sessionId || !enabled) { @@ -146,7 +104,6 @@ export function useMobileStructuredAgentState(args: { applyRequests: apply, applyEvent: (event, options) => { apply({ type: 'event', event, ...options }) - applyQueued(event) }, applyError: (message, refusal) => { if (stateRef.current.epoch !== null && refusal === undefined) { @@ -185,7 +142,7 @@ export function useMobileStructuredAgentState(args: { ) .catch(() => undefined) } - }, [apply, applyQueued, client, connected, enabled, sessionId, sessionKey, supersede]) + }, [apply, client, connected, enabled, sessionId, sessionKey, supersede]) const loadEarlier = useCallback(() => { const start = oldestStructuredAgentSessionCursor(stateRef.current) @@ -262,5 +219,13 @@ export function useMobileStructuredAgentState(args: { loadEarlier() }, [drawsNothingFrom, loadEarlier, loadingEarlier]) - return { state, stateRef, queuedMessages, queuePause: queued.pause, olderPages, loadEarlier } + return { + state, + stateRef, + // A published null is an empty queue; only an absent list is no claim. + queuedMessages: state.queuedMessages === undefined ? null : (state.queuedMessages ?? []), + queuePause: state.queuePause ?? null, + olderPages, + loadEarlier + } } diff --git a/mobile/src/session/use-mobile-structured-native-chat-send-bridge.test.ts b/mobile/src/session/use-mobile-structured-native-chat-send-bridge.test.ts index aba3472c78b..9bc4fba5be6 100644 --- a/mobile/src/session/use-mobile-structured-native-chat-send-bridge.test.ts +++ b/mobile/src/session/use-mobile-structured-native-chat-send-bridge.test.ts @@ -5,6 +5,10 @@ import type { StructuredAgentId } from '../../../src/shared/agent-session-provid import type { MobileNativeChatSendOutcome } from './native-chat/mobile-native-chat-send' import type { MobileNativeChatSendOrigin } from './native-chat/mobile-native-chat-pending-echo' import { useMobileStructuredNativeChatSendBridge } from './use-mobile-structured-native-chat-send-bridge' +import { + recordMobileNativeChatSkillPick, + resetMobileNativeChatDraftStoreForTests +} from './native-chat/composer/mobile-native-chat-draft-store' const ORIGIN: MobileNativeChatSendOrigin = { draftKey: 'draft', @@ -49,6 +53,20 @@ describe('useMobileStructuredNativeChatSendBridge', () => { afterEach(() => { act(() => renderer?.unmount()) renderer = null + resetMobileNativeChatDraftStoreForTests() + }) + + it('sends the skills picked into the draft', async () => { + const review = { name: 'review', path: '/skills/review/SKILL.md' } + recordMobileNativeChatSkillPick(ORIGIN.draftKey, { token: '$review', path: review.path }) + sendStructured.mockResolvedValue('accepted') + mount('codex') + + await sendWithOutcome('$review this') + + expect(sendStructured).toHaveBeenCalledWith('$review this', undefined, undefined, undefined, [ + review + ]) }) // Accepted messages remain host-owned; keep the composer cleared. @@ -60,7 +78,7 @@ describe('useMobileStructuredNativeChatSendBridge', () => { await expect(sendWithOutcome('/review')).resolves.toBe(outcome) - expect(sendStructured).toHaveBeenCalledWith('/review') + expect(sendStructured).toHaveBeenCalledWith('/review', undefined, undefined, undefined, []) expect(clearDraftForSend).toHaveBeenCalledExactlyOnceWith(ORIGIN, '/review') expect(restoreRejectedDraft).not.toHaveBeenCalled() } diff --git a/mobile/src/session/use-mobile-structured-native-chat-send-bridge.ts b/mobile/src/session/use-mobile-structured-native-chat-send-bridge.ts index 834f997c2a3..6c4383e71a2 100644 --- a/mobile/src/session/use-mobile-structured-native-chat-send-bridge.ts +++ b/mobile/src/session/use-mobile-structured-native-chat-send-bridge.ts @@ -5,6 +5,11 @@ import { isStructuredAgentSessionComposerCommand } from '../../../src/shared/str import type { MobileNativeChatSendOutcome } from './native-chat/mobile-native-chat-send' import type { MobileNativeChatSendOrigin } from './native-chat/mobile-native-chat-pending-echo' import type { StructuredMobileSendAttachment } from './use-mobile-structured-send-with-outcome' +import type { AgentJournalSkillRef } from '../../../src/shared/agent-session-journal-types' +import { + readMobileNativeChatPickedSkills, + restoreMobileNativeChatPickedSkills +} from './native-chat/composer/mobile-native-chat-draft-store' export function useMobileStructuredNativeChatSendBridge(args: { agent: StructuredAgentId | undefined @@ -13,7 +18,8 @@ export function useMobileStructuredNativeChatSendBridge(args: { text: string, images?: string[], deadline?: number, - attachments?: readonly StructuredMobileSendAttachment[] + attachments?: readonly StructuredMobileSendAttachment[], + skills?: readonly AgentJournalSkillRef[] ) => Promise captureSendOrigin: (text: string) => MobileNativeChatSendOrigin | null clearDraftForSend: (origin: MobileNativeChatSendOrigin, text: string) => void @@ -50,18 +56,13 @@ export function useMobileStructuredNativeChatSendBridge(args: { return 'rejected' } const isHostCommand = isStructuredAgentSessionComposerCommand(text, agent, sessionCommands) + const skills = readMobileNativeChatPickedSkills(origin.draftKey) clearDraftForSend(origin, text) - const outcome = - attachments !== undefined - ? await sendStructured(text, images, deadline, attachments) - : deadline !== undefined - ? await sendStructured(text, images, deadline) - : images !== undefined - ? await sendStructured(text, images) - : await sendStructured(text) + const outcome = await sendStructured(text, images, deadline, attachments, skills) // Accepted messages remain host-owned; restore commands whose reply was lost. if (outcome === 'rejected' || (outcome === 'unknown' && isHostCommand)) { restoreRejectedDraft(origin, text) + restoreMobileNativeChatPickedSkills(origin.draftKey, skills) } return outcome }, diff --git a/mobile/src/session/use-mobile-structured-queued-edit.test.tsx b/mobile/src/session/use-mobile-structured-queued-edit.test.tsx index 7adc747f603..a93901ec9c3 100644 --- a/mobile/src/session/use-mobile-structured-queued-edit.test.tsx +++ b/mobile/src/session/use-mobile-structured-queued-edit.test.tsx @@ -201,7 +201,7 @@ describe('mobile in-place queued Edit', () => { expect(append).not.toHaveBeenCalled() }) - it('hands a stale Save to the composer and reopens on the newer text; keeps an unconfirmed one', async () => { + it('hands a stale Save to the composer and reopens on the newer text; closes an unconfirmed one the card holds', async () => { const message = queuedDraft({ messageId: 'draft-1' }) await mount(message) const newer = withText(message.body, 'edited elsewhere') @@ -233,7 +233,7 @@ describe('mobile in-place queued Edit', () => { await act(async () => { await hook!.queued.editor!.save() }) - expect(hook!.queued.editor?.text).toBe('saved text') + expect(hook!.queued.editor).toBeUndefined() expect(hook!.queued.cards[0]?.text).toBe('saved text') const retry = sendRequest.mock.calls.findLast( ([method]) => method === 'agentSession.queuedMessageUpdate' @@ -245,11 +245,11 @@ describe('mobile in-place queued Edit', () => { expect(append).toHaveBeenCalledTimes(1) }) - it('abandons unsaved typing and releases the hold when changing chats', async () => { + it('hands unsaved typing to the composer and releases the hold when changing chats', async () => { await mount(queuedDraft({ messageId: 'draft-1' })) act(() => hook!.queued.editor!.change('unsaved')) await act(async () => renderer?.update(createElement(Harness, { sessionId: 'other-chat' }))) - expect(append).not.toHaveBeenCalled() + expect(append).toHaveBeenCalledExactlyOnceWith('unsaved') expect(hook!.queued.editor).toBeUndefined() expect( sendRequest.mock.calls @@ -278,27 +278,23 @@ describe('mobile in-place queued Edit', () => { }) expect(hook!.queued.editor!.saving).toBe(true) await act(async () => renderer?.update(createElement(Harness, { sessionId: 'other-chat' }))) - expect(append).not.toHaveBeenCalled() + expect(append).toHaveBeenCalledExactlyOnceWith('edited in place') await act(async () => { answer?.(mutationOk({ status, messageId: message.messageId, fingerprint: 'new' })) await save }) - expect(append).not.toHaveBeenCalled() + expect(append).toHaveBeenCalledTimes(1) expect(error).not.toHaveBeenCalled() expect(resolved).not.toHaveBeenCalled() expect(hook!.queued.editor).toBeUndefined() } ) - it('retires a removed card during Save and ignores its late refusal while editing another card', async () => { + it('lets an in-flight Save decide a removed card: its refusal hands the typing to the composer', async () => { const message = queuedDraft({ messageId: 'lost-reply-card' }) const nextMessage = queuedDraft({ messageId: 'next-card', position: 2 }) await mount(message) act(() => listener?.(batchEvent([message, nextMessage]))) - expect(hook!.queued.cards.map((card) => card.messageId)).toEqual([ - message.messageId, - nextMessage.messageId - ]) let answer: ((response: RpcResponse) => void) | undefined sendRequest.mockImplementation((method, params) => method === 'agentSession.queuedMessageUpdate' @@ -312,13 +308,8 @@ describe('mobile in-place queued Edit', () => { act(() => { save = hook!.queued.editor!.save() }) - expect(hook!.queued.editor?.saving).toBe(true) act(() => listener?.(batchEvent([nextMessage]))) - expect(hook!.queued.editor).toBeUndefined() - await act(async () => { - expect(await hook!.queued.edit!(nextMessage.messageId)).toBe(true) - }) - act(() => hook!.queued.editor!.change('next edit')) + expect(hook!.queued.editor).toMatchObject({ messageId: message.messageId, saving: true }) await act(async () => { answer?.( ok({ @@ -328,17 +319,15 @@ describe('mobile in-place queued Edit', () => { ) expect(await save).toBe(false) }) - expect(hook!.queued.editor).toMatchObject({ - messageId: nextMessage.messageId, - text: 'next edit', - saving: false - }) - expect(append).not.toHaveBeenCalled() - expect(error).not.toHaveBeenCalled() + expect(hook!.queued.editor).toBeUndefined() + expect(append).toHaveBeenCalledExactlyOnceWith('submitted edit') + expect(error).toHaveBeenCalledExactlyOnceWith( + 'This message was already sent or removed. Your edit is in the chat box.' + ) expect(resolved).not.toHaveBeenCalled() }) - it('keeps a refused Save in the editor and closes only when the published card is gone', async () => { + it('keeps a refused Save in the editor and hands it to the composer once the card is gone', async () => { const message = queuedDraft({ messageId: 'refused-card' }) await mount(message) sendRequest.mockImplementation(async (method, params) => @@ -359,6 +348,10 @@ describe('mobile in-place queued Edit', () => { expect(append).not.toHaveBeenCalled() act(() => listener?.(batchEvent([]))) expect(hook!.queued.editor).toBeUndefined() + expect(append).toHaveBeenCalledExactlyOnceWith('keep this text') + expect(error).toHaveBeenLastCalledWith( + 'This message was already sent or removed. Your edit is in the chat box.' + ) }) it('keeps exact typing through disconnect and reacquires the hold on the replacement transport', async () => { diff --git a/mobile/src/session/use-mobile-structured-queued-edit.ts b/mobile/src/session/use-mobile-structured-queued-edit.ts index e9cbe0b44b0..284608180d8 100644 --- a/mobile/src/session/use-mobile-structured-queued-edit.ts +++ b/mobile/src/session/use-mobile-structured-queued-edit.ts @@ -1,12 +1,21 @@ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' +import type { AgentJournalSubmission } from '../../../src/shared/agent-session-journal-types' import type { AgentSessionQueuedMessage, AgentSessionQueuedMessageUpdateResult } from '../../../src/shared/agent-session-wire' import { agentSessionSendBodyFingerprint } from '../../../src/shared/structured-agent-session-send-mutation' import { + QUEUED_EDIT_NOTICE_COPY, + queuedCardOnScreen, + queuedEditChangedNotice, + queuedEditDirty, + queuedEditHandOffTyping, + queuedEditKeptImages, + queuedEditStanding, queuedMessageEditableText, - queuedMessageWithEdit + queuedMessageWithEdit, + type QueuedMessageEditDraft } from '../../../src/shared/queued-message-edit' import type { RpcClient } from '../transport/rpc-client' import { @@ -26,11 +35,11 @@ export type MobileQueuedMessageInlineEditor = { cancel: () => void } -type Edit = { +// The phone edits text only, so the card's images are all kept. +type Edit = QueuedMessageEditDraft & { scope: string messageId: string body: AgentSessionQueuedMessage['body'] - text: string fingerprint: string saving: boolean lease: MobileQueuedEditLease @@ -44,16 +53,20 @@ export function useMobileStructuredQueuedEdit(args: { enabled: boolean messages: readonly AgentSessionQueuedMessage[] | null cards: readonly MobileQueuedMessageCard[] + submissions: readonly AgentJournalSubmission[] mutate: MobileStructuredAgentMutate appendComposerText: ((text: string) => boolean) | undefined onSendError: (text: string) => void onActionResolved?: () => void }) { const { sessionKey, sessionId, client, enabled, mutate, onSendError, onActionResolved } = args + const { messages, cards, submissions, appendComposerText } = args const clientRef = useRef({ sessionKey, client }) + const latest = useRef({ messages, cards, submissions, appendComposerText }) useLayoutEffect(() => { clientRef.current = { sessionKey, client } - }, [client, sessionKey]) + latest.current = { messages, cards, submissions, appendComposerText } + }, [appendComposerText, cards, client, messages, sessionKey, submissions]) const active = useRef(null) // A changed reply reopens the editor once the card publishes its newer text. const reopen = useRef<{ scope: string; messageId: string; stale: string } | null>(null) @@ -67,16 +80,38 @@ export function useMobileStructuredQueuedEdit(args: { render(null) edit?.lease.end() }, [render]) + /** Unsaved typing goes to the chat box; false when there was none to keep. */ + const handOff = useCallback((edit: Edit): boolean => { + const typing = queuedEditHandOffTyping(edit.body, edit) + return typing !== '' && latest.current.appendComposerText?.(typing) === true + }, []) + const gone = useCallback( + (edit: Edit) => { + if (active.current !== edit) { + return + } + if (queuedEditDirty(edit.body, edit) && handOff(edit)) { + onSendError(QUEUED_EDIT_NOTICE_COPY.editGone) + } + close() + }, + [close, handOff, onSendError] + ) - useEffect(() => { + // On chat changes or unmount, hand unsaved typing back to the chat it came from: a layout + // cleanup runs before the composer's append is repointed at the next chat. + useLayoutEffect(() => { return () => { const edit = active.current active.current = null reopen.current = null setShown(null) + if (edit && queuedEditDirty(edit.body, edit)) { + handOff(edit) + } edit?.lease.end() } - }, [sessionKey, args.chatActive]) + }, [handOff, sessionKey, args.chatActive]) const begin = useCallback( async (messageId: string): Promise => { @@ -84,7 +119,7 @@ export function useMobileStructuredQueuedEdit(args: { if (active.current || !enabled || !client || !sessionId) { return false } - const message = args.messages?.find((entry) => entry.messageId === messageId) + const message = messages?.find((entry) => entry.messageId === messageId) const text = message ? queuedMessageEditableText(message.body) : null if (!message || text === null) { return false @@ -95,6 +130,8 @@ export function useMobileStructuredQueuedEdit(args: { messageId, body: message.body, text, + kept: queuedEditKeptImages(message.body), + added: [], fingerprint, saving: false, lease: startMobileQueuedEditLease({ @@ -111,19 +148,20 @@ export function useMobileStructuredQueuedEdit(args: { if (!isOpen(active, edit) || edit.saving) { return } - if (answer.status === 'gone') { - close() - } else if (answer.status === 'changed') { - onSendError('This message changed while you were editing.') + // Sent, removed or edited elsewhere before the edit began: the card shows what happened. + if (answer.status === 'gone' || answer.status === 'changed') { + gone(edit) } else if (answer.status === 'not-editable') { - onSendError("This message can't be edited.") + handOff(edit) + onSendError(QUEUED_EDIT_NOTICE_COPY.editFailed) + close() } }, () => undefined ) return true }, - [args.messages, client, close, enabled, onSendError, render, sessionId, sessionKey] + [client, close, enabled, gone, handOff, messages, onSendError, render, sessionId, sessionKey] ) const change = useCallback( (text: string) => { @@ -137,25 +175,27 @@ export function useMobileStructuredQueuedEdit(args: { ) const save = useCallback(async (): Promise => { const edit = active.current - if ( - !edit || - edit.saving || - !sessionId || - !queuedMessageWithEdit(edit.body, { text: edit.text }).ok - ) { + if (!edit || edit.saving || !sessionId) { return false } - if (edit.text === queuedMessageEditableText(edit.body)) { + const edited = queuedMessageWithEdit(edit.body, { text: edit.text }) + if (!edited.ok) { + return false + } + if (!queuedEditDirty(edit.body, edit)) { close() return true } + // When Save’s reply is lost or refused, published card state proves whether its body landed. + const desired = agentSessionSendBodyFingerprint(sessionId, edited.body) + const standing = () => queuedEditStanding(latest.current, sessionId, edit.messageId, desired) edit.saving = true render(edit) const result = await mutate( 'agentSession.queuedMessageUpdate', 'agentSession.queuedMessageUpdate', { messageId: edit.messageId, expectedBodyFingerprint: edit.fingerprint, text: edit.text }, - () => isOpen(active, edit) + () => isOpen(active, edit) && standing() === 'differs' ) // A reply during a chat switch belongs to the outgoing chat. if (!isOpen(active, edit) || clientRef.current.sessionKey !== edit.scope) { @@ -163,56 +203,71 @@ export function useMobileStructuredQueuedEdit(args: { } edit.saving = false if (result.status === 'accepted') { - if (result.value.status === 'updated' || result.value.status === 'unchanged') { + const { status } = result.value + if (status === 'updated' || status === 'unchanged') { close() onActionResolved?.() return true } - if (result.value.status === 'gone') { - close() + if (status === 'gone') { + gone(edit) return false } - // Preserve local typing in the composer before reopening a concurrently edited card. - const typed = edit.text.trim() !== '' - if (result.value.status === 'changed' && (!typed || args.appendComposerText?.(edit.text))) { - onSendError( - typed - ? 'This message changed while you were editing. Your edit is in the chat box.' - : 'This message changed while you were editing.' - ) + if (status === 'changed') { + // Preserve typing after a competing edit, then reopen on the newer text. + onSendError(QUEUED_EDIT_NOTICE_COPY[queuedEditChangedNotice(handOff(edit), false)]) reopen.current = { scope: edit.scope, messageId: edit.messageId, stale: edit.fingerprint } close() return false } - onSendError( - result.value.status === 'changed' - ? 'This message changed while you were editing.' - : "This message can't be edited." - ) + onSendError(QUEUED_EDIT_NOTICE_COPY.editFailed) + render(edit) + return false + } + const settled = standing() + if (settled === 'sent' || settled === 'holds') { + close() + onActionResolved?.() + return true + } + if (settled === 'gone') { + gone(edit) + return false } render(edit) return false - }, [args.appendComposerText, close, mutate, onActionResolved, onSendError, render, sessionId]) + }, [close, gone, handOff, mutate, onActionResolved, onSendError, render, sessionId]) + // Let in-flight Save decide delivery races; otherwise reconcile departures, but never infer them from an unloaded list. useEffect(() => { const edit = active.current - const listed = (messageId: string): boolean => - args.messages?.some((message) => message.messageId === messageId) === true && - args.cards.some((card) => card.messageId === messageId) - if (edit && edit.scope === sessionKey && args.messages !== null && !listed(edit.messageId)) { - close() + if (edit && edit.scope === sessionKey && !edit.saving && messages !== null && sessionId) { + const typed = queuedMessageWithEdit(edit.body, { text: edit.text }) + const desired = typed.ok ? agentSessionSendBodyFingerprint(sessionId, typed.body) : null + const standing = queuedEditStanding( + { messages, cards, submissions }, + sessionId, + edit.messageId, + desired + ) + // `holds`: the card already holds what is typed, as after a Save whose answer was lost. + if (standing === 'sent' || (standing === 'holds' && desired !== edit.fingerprint)) { + close() + } else if (standing === 'gone') { + gone(edit) + } } const pending = reopen.current - if (!pending || active.current || args.messages === null || !sessionId) { + if (!pending || active.current || messages === null || !sessionId) { return } - const message = args.messages.find((entry) => entry.messageId === pending.messageId) - if (pending.scope !== sessionKey || !message || !listed(pending.messageId)) { + const listed = messages.find((entry) => entry.messageId === pending.messageId) + if (pending.scope !== sessionKey || !listed || !queuedCardOnScreen(cards, pending.messageId)) { reopen.current = null - } else if (agentSessionSendBodyFingerprint(sessionId, message.body) !== pending.stale) { + } else if (agentSessionSendBodyFingerprint(sessionId, listed.body) !== pending.stale) { void begin(pending.messageId) } - }, [args.cards, args.messages, begin, close, sessionId, sessionKey, shown]) + }, [begin, cards, close, gone, messages, sessionId, sessionKey, shown, submissions]) const editor: MobileQueuedMessageInlineEditor | undefined = shown?.scope === sessionKey diff --git a/mobile/src/session/use-mobile-structured-queued-message-controls.ts b/mobile/src/session/use-mobile-structured-queued-message-controls.ts index bd0b64c977d..f5d46e5e8ad 100644 --- a/mobile/src/session/use-mobile-structured-queued-message-controls.ts +++ b/mobile/src/session/use-mobile-structured-queued-message-controls.ts @@ -10,19 +10,17 @@ import type { import { agentSessionVisibleFailureFacts } from '../../../src/shared/agent-session-visible-failures' import { mapSettledWithConcurrency } from '../../../src/shared/map-with-concurrency' import type { + AgentSessionQueuedMessage, AgentSessionQueuedMessageDeleteResult, AgentSessionQueuedMessagesResumeResult, + AgentSessionQueuePause, AgentSessionSendResult } from '../../../src/shared/agent-session-wire' +import { queuedMessagesQueuePause } from '../../../src/shared/structured-agent-session-queued-cards' import { - mobileQueueHasResumableCard, mobileQueuedMessageCards, type MobileQueuedMessageCard } from './mobile-structured-queued-message-cards' -import type { - MobileQueuedMessageFeed, - MobileQueuePause -} from './mobile-structured-queued-message-feed' import type { MobileStructuredAgentMutate } from './use-mobile-structured-agent-mutation' import type { RpcClient } from '../transport/rpc-client' import { @@ -43,7 +41,7 @@ export type MobileStructuredQueuedMessageControls = { edit?: MobileQueuedMessageEdit editor?: MobileQueuedMessageInlineEditor move?: (move: DragReorderMove) => Promise - pause: MobileQueuePause + pause: AgentSessionQueuePause | null resume: () => Promise sessionKey: string } @@ -58,8 +56,8 @@ export function useMobileStructuredQueuedMessageControls(args: { reorderCapable?: boolean agentName?: string journalItems?: readonly AgentJournalRenderItem[] - queuedMessages: MobileQueuedMessageFeed - queuePause: MobileQueuePause + queuedMessages: readonly AgentSessionQueuedMessage[] | null + queuePause: AgentSessionQueuePause | null submissions: readonly AgentJournalSubmission[] agentWorking?: boolean mutate: MobileStructuredAgentMutate @@ -89,12 +87,21 @@ export function useMobileStructuredQueuedMessageControls(args: { () => mobileQueuedMessageCards(orderedMessages, submissions, { agentWorking, + queuePaused: queuePause !== null, agentName: args.agentName, statedFailures: queuedMessages?.some((draft) => draft.state === 'returned') ? agentSessionVisibleFailureFacts(args.journalItems ?? []) : [] }), - [agentWorking, args.agentName, args.journalItems, orderedMessages, queuedMessages, submissions] + [ + agentWorking, + args.agentName, + args.journalItems, + orderedMessages, + queuePause, + queuedMessages, + submissions + ] ) const { begin: beginInlineEdit, editor } = useMobileStructuredQueuedEdit({ ...args, @@ -226,7 +233,7 @@ export function useMobileStructuredQueuedMessageControls(args: { ), [mutate, resolved] ) - const pause = mobileQueueHasResumableCard(cards) ? queuePause : null + const pause = queuedMessagesQueuePause(cards, queuePause) return { cards, send, diff --git a/mobile/src/session/use-mobile-structured-send-with-outcome.ts b/mobile/src/session/use-mobile-structured-send-with-outcome.ts index d6fe941dbeb..77a1ff5a0e7 100644 --- a/mobile/src/session/use-mobile-structured-send-with-outcome.ts +++ b/mobile/src/session/use-mobile-structured-send-with-outcome.ts @@ -2,20 +2,28 @@ import { selectAgentSessionCurrentRequests } from '../../../src/shared/agent-ses import { useCallback } from 'react' import { runningStructuredAgentSessionTurnId } from '../../../src/shared/structured-agent-session-live-turn' +import { structuredSessionBackgroundTasksView } from '../../../src/shared/structured-session-background-tasks-view' +import { agentSessionVisibleFailureFacts } from '../../../src/shared/agent-session-visible-failures' +import { tuiAgentDisplayName } from '../../../src/shared/tui-agent-display-names' import { pendingPromptsAllUnanswerableHere } from '../../../src/shared/agent-session-approval-subject' import { + SkillRefLimitError, structuredAgentSessionSendBody, type StructuredAgentSessionAttachment } from '../../../src/shared/structured-agent-session-send-mutation' +import type { AgentJournalSkillRef } from '../../../src/shared/agent-session-journal-types' +import { MAX_SKILL_REFS } from '../../../src/shared/rpc-contract/structured-agent-session-message-params' import type { StructuredAgentSessionComposerOptions } from '../../../src/shared/structured-agent-session-composer' import type { AgentSessionSlashCommand } from '../../../src/shared/agent-session-wire' import type { StructuredAgentSessionState } from '../../../src/shared/structured-agent-session-reducer' +import { structuredAgentSessionQueueEnabled } from '../../../src/shared/structured-agent-session-queued-cards' import type { RpcClient } from '../transport/rpc-client' import type { MobileNativeChatSendOutcome } from './native-chat/mobile-native-chat-send' import { dispatchMobileStructuredCommand } from './mobile-structured-composer-command' import type { MobileNativeChatSendErrorReporter } from './native-chat/use-mobile-native-chat-send-error' import { sendMobileStructuredAgentSessionMessage } from './mobile-structured-agent-session-send' import type { MobileStructuredSends } from './use-mobile-structured-sends' +import type { MobileStructuredSendsOut } from './use-mobile-structured-stop-offer' import { structuredSessionOperationId } from './structured-session-operation-id' import { timeoutForDeadline } from './mobile-structured-agent-session-rpc' import { nativeChatTextWithFileReferences } from './native-chat/composer/mobile-native-chat-file-attachment' @@ -24,13 +32,18 @@ import { actionableStructuredQuestion } from './mobile-structured-actionable-prompt' -// An unsupported pending prompt would hold queued sends forever; start a turn instead. export function mobileStructuredSendQueues( queueCapable: boolean, - state: Pick + queueFollowUps: boolean, + state: Pick ): boolean { return ( - queueCapable && !pendingPromptsAllUnanswerableHere(selectAgentSessionCurrentRequests(state)) + queueCapable && + structuredAgentSessionQueueEnabled( + queueFollowUps, + state, + selectAgentSessionCurrentRequests(state) + ) ) } @@ -44,12 +57,17 @@ export function useMobileStructuredSendWithOutcome(args: { sessionId: string | null enabled: boolean queueCapable: boolean + queueFollowUps: boolean commandsWait: boolean stateRef: { readonly current: StructuredAgentSessionState } stopping: boolean + /** The host's queue sends next: commands that cannot wait in line read the chat as working. */ + queueSendsNext: boolean accept: MobileStructuredSends['accept'] hold: MobileStructuredSends['hold'] commandPending: { current: boolean } + /** This chat's message sends still out, which a command waits behind as on desktop. */ + sendsOut: MobileStructuredSendsOut controller: Pick< StructuredAgentSessionComposerOptions, 'snapshot' | 'setOption' | 'invokeAction' | 'conversationCommands' @@ -58,11 +76,14 @@ export function useMobileStructuredSendWithOutcome(args: { onSendError: MobileNativeChatSendErrorReporter // Older hosts accept neither per-chat model nor effort. sendOptions?: () => Readonly> | undefined + // An older host refuses a send body carrying `skills`; there the `$name` text stands alone. + takesSkills?: boolean }): ( text: string, images?: string[], deadline?: number, - attachments?: readonly StructuredMobileSendAttachment[] + attachments?: readonly StructuredMobileSendAttachment[], + skills?: readonly AgentJournalSkillRef[] ) => Promise { const { accept, @@ -75,18 +96,23 @@ export function useMobileStructuredSendWithOutcome(args: { hold, onSendError, queueCapable, + queueFollowUps, + queueSendsNext, sendOptions, + sendsOut, sessionCommands, sessionId, stateRef, - stopping + stopping, + takesSkills } = args return useCallback( async ( text: string, images?: string[], deadline?: number, - attachments?: readonly StructuredMobileSendAttachment[] + attachments?: readonly StructuredMobileSendAttachment[], + pickedSkills?: readonly AgentJournalSkillRef[] ): Promise => { const currentFence = stateRef.current.fence if (!client || !sessionId || !enabled || currentFence === null) { @@ -111,13 +137,22 @@ export function useMobileStructuredSendWithOutcome(args: { fence: currentFence, pending: commandPending, controller: { agent: agent ?? undefined, ...controller, sessionCommands }, - busy: () => - actionableStructuredApproval(stateRef.current) || - actionableStructuredQuestion(stateRef.current) - ? 'prompt' - : runningStructuredAgentSessionTurnId(stateRef.current) - ? 'working' - : null, + agentName: agent ? (tuiAgentDisplayName(agent) ?? agent) : undefined, + holds: () => { + const state = stateRef.current + const turnId = runningStructuredAgentSessionTurnId(state) + const tasks = structuredSessionBackgroundTasksView(state.backgroundTasks, turnId) + return { + working: turnId !== null || queueSendsNext, + prompt: Boolean( + actionableStructuredApproval(state) || actionableStructuredQuestion(state) + ), + background: tasks.show || tasks.isMonitoring, + backgroundTasksRunning: tasks.isMonitoring, + sending: sendsOut.sending() + } + }, + startFailures: () => agentSessionVisibleFailureFacts(stateRef.current.items), waitsInLine: (command) => command === 'compact' && commandsWait && @@ -138,7 +173,19 @@ export function useMobileStructuredSendWithOutcome(args: { } } const wireText = nativeChatTextWithFileReferences(text, files) - const body = structuredAgentSessionSendBody(wireText, imageAttachments) + const skills = takesSkills ? (pickedSkills ?? []) : [] + let body: ReturnType + try { + body = structuredAgentSessionSendBody(wireText, imageAttachments, skills) + } catch (error) { + if (!(error instanceof SkillRefLimitError)) { + throw error + } + onSendError( + `Message not sent. Keep at most ${MAX_SKILL_REFS} selected skill files and try again.` + ) + return 'rejected' + } if (body.blocks.length === 0) { return 'rejected' } @@ -150,13 +197,14 @@ export function useMobileStructuredSendWithOutcome(args: { } // Wait for pending picks so the send carries this chat’s settled options. const options = sendOptions?.() - const outcome = await sendMobileStructuredAgentSessionMessage({ + const outcome = await sendsOut.trackSend(sendMobileStructuredAgentSessionMessage)({ client, sessionId, expectedRuntimeFence: currentFence, text: wireText, attachments: imageAttachments, - ...(mobileStructuredSendQueues(queueCapable, stateRef.current) + skills, + ...(mobileStructuredSendQueues(queueCapable, queueFollowUps, stateRef.current) ? { delivery: 'queue-if-active' as const } : {}), ...(options ? { options } : {}), @@ -182,11 +230,15 @@ export function useMobileStructuredSendWithOutcome(args: { hold, onSendError, queueCapable, + queueFollowUps, + queueSendsNext, sendOptions, + sendsOut, sessionCommands, sessionId, stateRef, - stopping + stopping, + takesSkills ] ) } diff --git a/mobile/src/session/use-mobile-structured-sends.test.tsx b/mobile/src/session/use-mobile-structured-sends.test.tsx index 6695158f6a3..781ed76c7a7 100644 --- a/mobile/src/session/use-mobile-structured-sends.test.tsx +++ b/mobile/src/session/use-mobile-structured-sends.test.tsx @@ -60,7 +60,7 @@ describe('a structured send in the open chat', () => { let listener: ((value: unknown) => void) | null = null let answer: (id: string) => unknown = () => null const onSendError = vi.fn() - const sendRequest = vi.fn() + const sendRequest = vi.fn(async () => ok({})) const client: RpcClient = { sendRequest, subscribe: vi.fn((_method, _params, onData) => { diff --git a/mobile/src/session/use-mobile-structured-sends.ts b/mobile/src/session/use-mobile-structured-sends.ts index fa0e7877c6c..c30c804b898 100644 --- a/mobile/src/session/use-mobile-structured-sends.ts +++ b/mobile/src/session/use-mobile-structured-sends.ts @@ -12,7 +12,7 @@ import { structuredAgentSessionSettledSends } from '../../../src/shared/structured-agent-session-send-settlement' import { mergeSentImagePreviews } from './native-chat/composer/mobile-native-chat-draft-reconcile' -import type { MobileQueuedMessageFeed } from './mobile-structured-queued-message-feed' +import type { AgentSessionQueuedMessage } from '../../../src/shared/agent-session-wire' import type { MobileNativeChatSendErrorReporter } from './native-chat/use-mobile-native-chat-send-error' import { UNCONFIRMED_SEND_DEADLINE_MS, @@ -35,7 +35,7 @@ export function useMobileStructuredSends(args: { sessionKey: string cursor?: AgentJournalCursor | null submissions: readonly AgentJournalSubmission[] - queuedMessages: MobileQueuedMessageFeed + queuedMessages: readonly AgentSessionQueuedMessage[] | null onSendError: MobileNativeChatSendErrorReporter }): MobileStructuredSends { const { cursor, onSendError, queuedMessages, sessionKey, submissions } = args diff --git a/mobile/src/session/use-mobile-structured-stop-offer.test.ts b/mobile/src/session/use-mobile-structured-stop-offer.test.ts new file mode 100644 index 00000000000..87e7088f686 --- /dev/null +++ b/mobile/src/session/use-mobile-structured-stop-offer.test.ts @@ -0,0 +1,69 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, describe, expect, it } from 'vitest' +import { useMobileStructuredStopOffer } from './use-mobile-structured-stop-offer' + +describe('useMobileStructuredStopOffer', () => { + let renderer: ReactTestRenderer | null = null + let offer: { canStop: boolean; sending: () => boolean; send: () => Promise } | null = null + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + }) + + function harness(send: () => Promise, stopsConversation: boolean, sessionKey: string) { + function Harness(): null { + const { canStop, sendsOut } = useMobileStructuredStopOffer({ + sessionKey, + turnId: null, + isWorking: false, + stopsConversation + }) + offer = { canStop, sending: sendsOut.sending, send: sendsOut.trackSend(send) } + return null + } + return createElement(Harness) + } + + it.each([true, false])( + 'offers Stop for a send in flight only where the host stops the conversation (%s)', + async (stopsConversation) => { + let settle!: (value: string) => void + const send = () => new Promise((resolve) => (settle = resolve)) + act(() => { + renderer = create(harness(send, stopsConversation, 'chat')) + }) + expect(offer?.canStop).toBe(false) + + let sent: Promise | undefined + act(() => { + sent = offer?.send() + }) + expect(offer?.canStop).toBe(stopsConversation) + expect(offer?.sending()).toBe(true) + + await act(async () => { + settle('accepted') + await sent + }) + expect(offer?.canStop).toBe(false) + expect(offer?.sending()).toBe(false) + } + ) + + it("keeps one chat's send out of another chat's Stop and command hold", () => { + const send = () => new Promise(() => {}) + act(() => { + renderer = create(harness(send, true, 'first')) + }) + act(() => { + void offer?.send() + }) + expect(offer?.sending()).toBe(true) + + act(() => renderer?.update(harness(send, true, 'second'))) + expect(offer?.canStop).toBe(false) + expect(offer?.sending()).toBe(false) + }) +}) diff --git a/mobile/src/session/use-mobile-structured-stop-offer.ts b/mobile/src/session/use-mobile-structured-stop-offer.ts new file mode 100644 index 00000000000..fad26c894eb --- /dev/null +++ b/mobile/src/session/use-mobile-structured-stop-offer.ts @@ -0,0 +1,66 @@ +import { useCallback, useMemo, useRef, useState } from 'react' +import { structuredAgentSessionCanStop } from '../../../src/shared/structured-agent-session-stop-control' + +type Send = (...args: Args) => Promise + +export type MobileStructuredSendsOut = { + trackSend: (send: Send) => Send + sending: () => boolean +} + +/** Desktop's Stop rule, counting this chat's message sends still awaiting the host as sending. */ +export function useMobileStructuredStopOffer(input: { + sessionKey: string + turnId: string | null + isWorking: boolean + stopsConversation: boolean +}): { canStop: boolean; sendsOut: MobileStructuredSendsOut } { + const { sessionKey } = input + const countsRef = useRef(new Map()) + // Re-render only when a chat starts or stops having a send out, not on every send. + const [sendingKeys, setSendingKeys] = useState>(() => new Set()) + const trackSend = useCallback( + (send: Send): Send => + async (...args) => { + const count = (by: 1 | -1) => { + const next = (countsRef.current.get(sessionKey) ?? 0) + by + if (next === 0) { + countsRef.current.delete(sessionKey) + } else { + countsRef.current.set(sessionKey, next) + } + if (next === 0 || (next === 1 && by === 1)) { + setSendingKeys((previous) => { + const keys = new Set(previous) + if (next === 0) { + keys.delete(sessionKey) + } else { + keys.add(sessionKey) + } + return keys + }) + } + } + count(1) + try { + return await send(...args) + } finally { + count(-1) + } + }, + [sessionKey] + ) + const sendsOut = useMemo( + () => ({ trackSend, sending: () => countsRef.current.has(sessionKey) }), + [sessionKey, trackSend] + ) + return { + canStop: structuredAgentSessionCanStop({ + turnId: input.turnId, + isWorking: input.isWorking, + sending: sendingKeys.has(sessionKey), + stopsConversation: input.stopsConversation + }), + sendsOut + } +} diff --git a/mobile/src/session/use-mobile-structured-turn-indicator.ts b/mobile/src/session/use-mobile-structured-turn-indicator.ts new file mode 100644 index 00000000000..bb0ed58fe07 --- /dev/null +++ b/mobile/src/session/use-mobile-structured-turn-indicator.ts @@ -0,0 +1,44 @@ +import { useMemo } from 'react' +import type { StructuredAgentSessionState } from '../../../src/shared/structured-agent-session-reducer' +import type { NativeChatLiveTurnIndicator } from '../../../src/shared/native-chat-turn-status' +import { isStructuredAgentSessionThinking } from '../../../src/shared/structured-agent-session-live-turn' +import { selectStructuredAgentTurnActivity } from '../../../src/shared/native-chat-turn-activity' +import { agentStopDisplayStatus } from '../../../src/shared/agent-stop-display-status' +import { mobileStructuredSendQueues } from './use-mobile-structured-send-with-outcome' + +export function useMobileStructuredTurnIndicator(input: { + state: StructuredAgentSessionState + turnId: string | null + isWorking: boolean + /** Desktop holds Stop on whether it can stop, which covers a send still in flight. */ + canStop: boolean + hostStopping: boolean + stopPressed: boolean + queueCapable: boolean + queueFollowUps: boolean +}): NativeChatLiveTurnIndicator & { stopping: boolean } { + const { state, turnId, hostStopping, stopPressed, queueCapable, queueFollowUps } = input + const activityText = useMemo( + () => selectStructuredAgentTurnActivity(state.items, turnId, state.activity)?.text ?? null, + [state.items, turnId, state.activity] + ) + const thinking = isStructuredAgentSessionThinking(state) + const working = input.isWorking || input.canStop + const stopping = agentStopDisplayStatus({ working, hostStopping, stopPressed }) === 'stopping' + const stopRequestInFlight = working && stopPressed + const afterStop = stopping + ? mobileStructuredSendQueues(queueCapable, queueFollowUps, state) + ? ('queue' as const) + : ('send' as const) + : undefined + return useMemo( + () => ({ + thinking, + activityText, + stopping, + stopRequestInFlight, + ...(afterStop ? { afterStop } : {}) + }), + [thinking, activityText, stopping, stopRequestInFlight, afterStop] + ) +} diff --git a/mobile/src/tasks/mobile-tasks-host-subtitle.tsx b/mobile/src/tasks/mobile-tasks-host-subtitle.tsx index 2fc014aaef0..7994d6a3d53 100644 --- a/mobile/src/tasks/mobile-tasks-host-subtitle.tsx +++ b/mobile/src/tasks/mobile-tasks-host-subtitle.tsx @@ -1,27 +1,10 @@ -import { useEffect, useState } from 'react' import { StyleSheet, Text } from 'react-native' import { hostScreenStyles } from '../host-screen/host-screen-styles' -import { loadHosts } from '../transport/host-store' -import type { HostProfile } from '../transport/types' +import { useHostProfile } from '../transport/use-host-profile' import { useHostDisplay } from '../transport/use-host-display' export function MobileTasksHostSubtitle({ hostId }: { hostId: string }) { - const [host, setHost] = useState(null) - useEffect(() => { - let stale = false - setHost(null) - loadHosts().then( - (hosts) => { - if (!stale) { - setHost(hosts.find((profile) => profile.id === hostId) ?? null) - } - }, - () => {} - ) - return () => { - stale = true - } - }, [hostId]) + const host = useHostProfile(hostId) const title = useHostDisplay(host).title // Reserve the line before load to prevent a title jump; unknown hosts stay blank instead of showing generic Host. return ( diff --git a/mobile/src/test-support/rpc-recording/adapters/session-screen-read-mount-adapters.ts b/mobile/src/test-support/rpc-recording/adapters/session-screen-read-mount-adapters.ts index b630ac3404d..bdeffe7b99f 100644 --- a/mobile/src/test-support/rpc-recording/adapters/session-screen-read-mount-adapters.ts +++ b/mobile/src/test-support/rpc-recording/adapters/session-screen-read-mount-adapters.ts @@ -171,6 +171,8 @@ export function sessionScreenReadMountAdapters( enabled, handleRef: { current: HANDLE }, deviceTokenRef: { current: DEVICE_TOKEN }, + // OpenCode is the agent whose Stop sends the paced second Escape these recordings pin. + agentRef: { current: 'opencode' }, streamIdentity: 'stream-1', cancelPending: () => effect('cancel-pending', {}), onSendError: (message: string) => errors.push(message) diff --git a/mobile/src/transport/settings-read-operations.ts b/mobile/src/transport/settings-read-operations.ts index ffb2e4265bd..6f43c226342 100644 --- a/mobile/src/transport/settings-read-operations.ts +++ b/mobile/src/transport/settings-read-operations.ts @@ -90,16 +90,20 @@ export const newTabSettingsRead = bindDeferredRpcOperation( }) ) -const copyTrimsGutterReader: RpcCompatibleReader = (raw) => { - const settings = raw == null ? undefined : settingsMember(raw) - const trims: unknown = - settings == null ? undefined : settingsField(settings, 'terminalCopyTrimsGutter') - return { - compatible: true, - variant: 'copy-trims-gutter', - // Older hosts omit this setting; absence inherits the desktop’s enabled default. - value: trims !== false, - salvage: { droppedPaths: [], droppedCount: 0 } +function onByDefaultSettingReader( + key: string, + variant: Variant +): RpcCompatibleReader { + return (raw) => { + const settings = raw == null ? undefined : settingsMember(raw) + const value: unknown = settings == null ? undefined : settingsField(settings, key) + return { + compatible: true, + variant, + // Older hosts omit these settings; absence inherits the desktop’s enabled default. + value: value !== false, + salvage: { droppedPaths: [], droppedCount: 0 } + } } } @@ -109,7 +113,17 @@ export const terminalCopyTrimsGutterRead = bindDeferredRpcOperation( method: 'settings.get', acceptance: 'success-result-or-skip', barrier: 'after-caller-barrier', - read: copyTrimsGutterReader + read: onByDefaultSettingReader('terminalCopyTrimsGutter', 'copy-trims-gutter') + }) +) + +export const nativeChatQueueFollowUpsRead = bindDeferredRpcOperation( + defineRpcOperation({ + name: 'settings.native-chat-queue-follow-ups-or-skip', + method: 'settings.get', + acceptance: 'success-result-or-skip', + barrier: 'after-caller-barrier', + read: onByDefaultSettingReader('nativeChatQueueFollowUps', 'queue-follow-ups') }) ) diff --git a/mobile/src/transport/use-host-profile.ts b/mobile/src/transport/use-host-profile.ts new file mode 100644 index 00000000000..5c51f737616 --- /dev/null +++ b/mobile/src/transport/use-host-profile.ts @@ -0,0 +1,24 @@ +import { useEffect, useState } from 'react' +import { loadHosts } from './host-store' +import type { HostProfile } from './types' + +/** The stored profile for a paired host; null while loading or when unknown. */ +export function useHostProfile(hostId: string): HostProfile | null { + const [host, setHost] = useState(null) + useEffect(() => { + let stale = false + setHost(null) + loadHosts().then( + (hosts) => { + if (!stale) { + setHost(hosts.find((profile) => profile.id === hostId) ?? null) + } + }, + () => {} + ) + return () => { + stale = true + } + }, [hostId]) + return host +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-codex-version-recovery.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-version-recovery.test.ts index 631e41e901d..dc6719dcfbe 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-codex-version-recovery.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-version-recovery.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, expect, it } from 'vitest' import { AgentCliInstallationError } from '../agent-cli-installation-error' import { isStructuredAgentSessionStartFailureRow } from '../../../shared/structured-agent-session-start-failure-row-key' -import { projectQueuedMessageCards } from '../../../renderer/src/components/native-chat/structured-agent-session-queued-cards' +import { projectQueuedMessageCards } from '../../../shared/structured-agent-session-queued-cards' import { HOST_TEST_SESSION, hostTestMessage } from './structured-agent-session-host-test-data' import { createQueuedMessageTestRig, diff --git a/src/main/runtime/files/search/file-search-mobile-paths.test.ts b/src/main/runtime/files/search/file-search-mobile-paths.test.ts new file mode 100644 index 00000000000..c8a176f77c4 --- /dev/null +++ b/src/main/runtime/files/search/file-search-mobile-paths.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it, vi } from 'vitest' + +const listQuickOpenFilesMock = vi.hoisted(() => vi.fn()) +vi.mock('../../../ipc/filesystem/filesystem-list-files', () => ({ + listQuickOpenFiles: listQuickOpenFilesMock +})) + +import { RuntimeFileSearch, type RuntimeFileSearchHost } from './file-search' + +function search(executionHostId: 'local' | `ssh:${string}`) { + const host: RuntimeFileSearchHost = { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: mobile path search hands the store only to the mocked listQuickOpenFiles. + requireStore: () => ({}) as ReturnType, + resolveRuntimeFileTarget: async () => ({ + worktree: { id: 'wt-1', path: '/repo' }, + executionHostId + }) + } + return new RuntimeFileSearch(host) +} + +describe('RuntimeFileSearch.searchMobileFilePaths', () => { + it("ranks the cached workspace listing with desktop's fuzzy Quick Open rule", async () => { + listQuickOpenFilesMock.mockResolvedValue([ + 'docs/composer-notes.md', + 'src/NativeChatComposer.tsx', + 'src/native-chat/use-composer.ts' + ]) + const files = search('local') + + const fuzzy = await files.searchMobileFilePaths('id:wt-1', 'ncomposer', 16) + expect(fuzzy.files.map((file) => file.relativePath)).toContain('src/NativeChatComposer.tsx') + + const bare = await files.searchMobileFilePaths('id:wt-1', '', 2) + expect(bare.files.map((file) => file.relativePath)).toEqual([ + 'docs/composer-notes.md', + 'src/native-chat/use-composer.ts' + ]) + expect(bare).toMatchObject({ totalCount: 3, truncated: true }) + expect(listQuickOpenFilesMock).toHaveBeenCalledOnce() + }) + + it('lists nothing for an SSH workspace', async () => { + listQuickOpenFilesMock.mockClear() + const result = await search('ssh:box').searchMobileFilePaths('id:wt-1', 'a', 16) + expect(result).toMatchObject({ files: [], totalCount: 0, truncated: false }) + expect(listQuickOpenFilesMock).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/runtime/files/search/file-search.ts b/src/main/runtime/files/search/file-search.ts index ee4bf0613d0..0bbefacbbc9 100644 --- a/src/main/runtime/files/search/file-search.ts +++ b/src/main/runtime/files/search/file-search.ts @@ -1,5 +1,6 @@ import { QUICK_OPEN_SEARCH_VERSION, + QuickOpenPathRanker, isQuickOpenQueryTooLarge } from '../../../../shared/quick-open-path-search' import type { RuntimeFileListResult } from '../../../../shared/runtime-types' @@ -18,7 +19,7 @@ import { type ResolvedRuntimeFileTarget } from '../file-target' import { basenameFromRelativePath } from '../file-paths' -import { RuntimeMobileFilePathSearchCache, rankRuntimeMobileFilePaths } from './mobile-path-search' +import { RuntimeMobileFilePathSearchCache } from './mobile-path-search' const MOBILE_FILE_LIST_LIMIT = 5000 @@ -115,7 +116,12 @@ export class RuntimeFileSearch { truncated: safePaths.length > MOBILE_FILE_PATH_SEARCH_CACHE_LIMIT } }) - const matches = rankRuntimeMobileFilePaths(inventory.paths, query, limit) + // Rank the cached inventory with desktop's Quick Open rule, so every phone version gets it without a walk per query. + const ranker = new QuickOpenPathRanker(query, limit) + for (const path of inventory.paths) { + ranker.consider(path) + } + const matches = ranker.result() return { worktree: worktree.id, rootPath: worktree.path, diff --git a/src/main/runtime/files/search/mobile-path-search.test.ts b/src/main/runtime/files/search/mobile-path-search.test.ts index 293fca5e7d4..fbc99ad2246 100644 --- a/src/main/runtime/files/search/mobile-path-search.test.ts +++ b/src/main/runtime/files/search/mobile-path-search.test.ts @@ -1,86 +1,9 @@ import { describe, expect, it, vi } from 'vitest' import { - rankRuntimeMobileFilePaths, type RuntimeMobileFilePathInventory, RuntimeMobileFilePathSearchCache } from './mobile-path-search' -describe('rankRuntimeMobileFilePaths', () => { - it('preserves basename matching, ordering and total counts for unusual paths', () => { - const paths = [ - '', - '/', - 'a/', - 'a//b.ts', - 'b.ts', - 'B.TS', - 'a\\b.ts', - '界/😀.ts', - '.hidden', - 'a/./b.ts' - ] - const rankings = [ - { - query: '', - ranked: [ - '', - '/', - 'a/', - 'a//b.ts', - 'b.ts', - 'B.TS', - 'a\\b.ts', - '界/😀.ts', - '.hidden', - 'a/./b.ts' - ] - }, - { - query: ' ', - ranked: [ - '', - '/', - 'a/', - 'a//b.ts', - 'b.ts', - 'B.TS', - 'a\\b.ts', - '界/😀.ts', - '.hidden', - 'a/./b.ts' - ] - }, - { query: 'b', ranked: ['a//b.ts', 'b.ts', 'B.TS', 'a/./b.ts', 'a\\b.ts'] }, - { query: '.ts', ranked: ['a//b.ts', 'b.ts', 'B.TS', 'a\\b.ts', '界/😀.ts', 'a/./b.ts'] }, - { query: '😀', ranked: ['界/😀.ts'] }, - { query: '/', ranked: ['/', 'a/', 'a//b.ts', '界/😀.ts', 'a/./b.ts'] }, - { query: 'a\\', ranked: ['a\\b.ts'] }, - { query: 'missing', ranked: [] } - ] - for (const { query, ranked } of rankings) { - for (const limit of [0, 1, 3, 100]) { - expect(rankRuntimeMobileFilePaths(paths, query, limit)).toEqual({ - paths: ranked.slice(0, limit), - totalCount: ranked.length - }) - } - } - }) - - it('ranks path and basename prefixes before substrings and caps output', () => { - expect( - rankRuntimeMobileFilePaths( - ['docs/apple.md', 'src/apple.ts', 'src/deep/pineapple.ts', 'z/apple.test.ts'], - 'apple', - 3 - ) - ).toEqual({ - paths: ['docs/apple.md', 'src/apple.ts', 'z/apple.test.ts'], - totalCount: 4 - }) - }) -}) - describe('RuntimeMobileFilePathSearchCache', () => { it('reuses a live entry and evicts the oldest key at capacity', async () => { const cache = new RuntimeMobileFilePathSearchCache(2, 100) diff --git a/src/main/runtime/files/search/mobile-path-search.ts b/src/main/runtime/files/search/mobile-path-search.ts index e5be834e19d..9447b4e4fb6 100644 --- a/src/main/runtime/files/search/mobile-path-search.ts +++ b/src/main/runtime/files/search/mobile-path-search.ts @@ -113,34 +113,3 @@ export class RuntimeMobileFilePathSearchCache { } } } - -/** Keep composer prefix-before-substring ranking while returning a bounded slice. */ -export function rankRuntimeMobileFilePaths( - paths: readonly string[], - query: string, - limit: number -): { paths: string[]; totalCount: number } { - const normalizedQuery = query.trim().toLowerCase() - if (!normalizedQuery) { - return { paths: paths.slice(0, limit), totalCount: paths.length } - } - const prefix: string[] = [] - const substring: string[] = [] - let totalCount = 0 - for (const path of paths) { - const lower = path.toLowerCase() - const basename = lower.slice(lower.lastIndexOf('/') + 1) - if (lower.startsWith(normalizedQuery) || basename.startsWith(normalizedQuery)) { - totalCount++ - if (prefix.length < limit) { - prefix.push(path) - } - } else if (lower.includes(normalizedQuery)) { - totalCount++ - if (substring.length < limit) { - substring.push(path) - } - } - } - return { paths: [...prefix, ...substring].slice(0, limit), totalCount } -} diff --git a/src/main/runtime/rpc/methods/skills.test.ts b/src/main/runtime/rpc/methods/skills.test.ts index dbd056b8011..30a314b48ea 100644 --- a/src/main/runtime/rpc/methods/skills.test.ts +++ b/src/main/runtime/rpc/methods/skills.test.ts @@ -29,13 +29,16 @@ const WSL_RUNTIME = { function makeContext(overrides: { resolveProjectRuntimeForWorktree?: (worktreeId: string | null | undefined) => unknown + resolveRuntimeFileTarget?: (selector: string) => Promise }): RpcContext { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: skills.discover reads only these runtime members. return { runtime: { listRepos: () => [], resolveSkillDiscoveryProviderRoots: async () => ({}), resolveProjectRuntimeForWorktree: - overrides.resolveProjectRuntimeForWorktree ?? (() => undefined) + overrides.resolveProjectRuntimeForWorktree ?? (() => undefined), + resolveRuntimeFileTarget: overrides.resolveRuntimeFileTarget } } as unknown as RpcContext } @@ -77,6 +80,35 @@ describe('skills.discover RPC', () => { ) }) + it('scans a workspace named only by id in its folder, and refuses SSH workspaces', async () => { + const resolveRuntimeFileTarget = vi.fn(async () => ({ + worktree: { path: '/repo/feature' }, + executionHostId: 'local' + })) + await discoverMethod().handler( + { worktreeId: 'repo::/repo/feature' }, + makeContext({ resolveRuntimeFileTarget }) + ) + expect(resolveRuntimeFileTarget).toHaveBeenCalledWith('id:repo::/repo/feature') + expect(vi.mocked(resolveSkillDiscoveryTarget)).toHaveBeenLastCalledWith( + expect.objectContaining({ cwd: '/repo/feature' }) + ) + + const discoveries = vi.mocked(discoverSkillsOnTarget).mock.calls.length + await expect( + discoverMethod().handler( + { worktreeId: 'remote::/srv/app' }, + makeContext({ + resolveRuntimeFileTarget: async () => ({ + worktree: { path: '/srv/app' }, + executionHostId: 'ssh:box' + }) + }) + ) + ).rejects.toThrow('unavailable for SSH hosts') + expect(vi.mocked(discoverSkillsOnTarget).mock.calls).toHaveLength(discoveries) + }) + it('prefers a caller-supplied project runtime over store resolution', async () => { const resolveProjectRuntimeForWorktree = vi.fn() await discoverMethod().handler( diff --git a/src/main/runtime/rpc/methods/skills.ts b/src/main/runtime/rpc/methods/skills.ts index 01c523ed333..333c2e33f15 100644 --- a/src/main/runtime/rpc/methods/skills.ts +++ b/src/main/runtime/rpc/methods/skills.ts @@ -32,6 +32,7 @@ import { } from '../../../skills/skill-discovery-target' import { SKILL_INSTALL_RESULT_V2_CAPABILITY } from '../../../../shared/skill-install-capability' import type { OrcaRuntimeService } from '../../orca-runtime' +import { parseExecutionHostId } from '../../../../shared/execution-host' import { AGENT_SKILL_SHARING_UNSUPPORTED_ENVIRONMENT_CODE, AgentSkillShareRequestSchema, @@ -57,6 +58,21 @@ export function resolveDiscoveryTarget( return resolveSkillDiscoveryTarget(target) } +// A caller naming only the workspace gets its folder from the host, which refuses SSH workspaces like desktop. +async function withWorkspaceCwd( + params: z.infer, + runtime: Pick +): Promise> { + if (params.cwd?.trim() || !params.worktreeId) { + return params + } + const target = await runtime.resolveRuntimeFileTarget(`id:${params.worktreeId}`) + if (parseExecutionHostId(target.executionHostId)?.kind === 'ssh') { + throw new Error('Skill discovery is unavailable for SSH hosts.') + } + return { ...params, cwd: target.worktree.path } +} + function skillDeleteDependencies( runtime: Pick ): SkillDeleteRequestDependencies { @@ -74,7 +90,10 @@ export const SKILL_METHODS = [ params: SkillsDiscoverParams, handler: async (params, { runtime }) => { // Resolve WSL preferences on the executing host; remote clients provide workspace identity, not trusted runtime absence. - const resolvedTarget = await resolveDiscoveryTarget(params, runtime) + const resolvedTarget = await resolveDiscoveryTarget( + await withWorkspaceCwd(params, runtime), + runtime + ) return discoverSkillsOnTarget(resolvedTarget, runtime.listRepos(), { providerRootOverrides: await runtime.resolveSkillDiscoveryProviderRoots(resolvedTarget), refresh: params.refresh === true diff --git a/src/main/runtime/runtime-client-settings-queue-follow-ups-projection.test.ts b/src/main/runtime/runtime-client-settings-queue-follow-ups-projection.test.ts new file mode 100644 index 00000000000..4a6c1ad038c --- /dev/null +++ b/src/main/runtime/runtime-client-settings-queue-follow-ups-projection.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest' +import { RuntimeClientSettingsController } from './runtime-client-settings' +import { createGlobalSettingsFixture } from '../../shared/global-settings-test-fixture' +import type { GlobalSettings } from '../../shared/global-settings-types' + +// Missing nativeChatQueueFollowUps looks like an older host to mobile, which then queues mid-turn sends. +function projectionOf(settings: Partial) { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: get() reads nothing but store.getSettings(); every other RuntimeStore member is unreachable from that path. + return new RuntimeClientSettingsController({ getSettings: () => settings } as never).get() +} + +function hostSettings(overrides: Partial): Partial { + return { ...createGlobalSettingsFixture({ workspaceDir: '/w' }), ...overrides } +} + +describe('RuntimeClientSettingsController queue follow-ups projection', () => { + it('publishes the opt-out to paired clients', () => { + expect( + projectionOf(hostSettings({ nativeChatQueueFollowUps: false })).nativeChatQueueFollowUps + ).toBe(false) + }) + + it('reports on when the host has no persisted preference', () => { + const settings = hostSettings({}) + delete settings.nativeChatQueueFollowUps + expect(projectionOf(settings).nativeChatQueueFollowUps).toBe(true) + }) +}) diff --git a/src/main/runtime/runtime-client-settings-shape.ts b/src/main/runtime/runtime-client-settings-shape.ts index b4cb2e4f199..bebefc05fe0 100644 --- a/src/main/runtime/runtime-client-settings-shape.ts +++ b/src/main/runtime/runtime-client-settings-shape.ts @@ -34,6 +34,7 @@ export type RuntimeClientSettings = Pick< | 'agentSkillSharingEnabled' | 'machineName' | 'nativeChatPermissionMode' + | 'nativeChatQueueFollowUps' | 'sourceControlGroupOrder' | 'sourceControlCompareAgainstUpstream' | 'diffShowWhitespace' diff --git a/src/main/runtime/runtime-client-settings.ts b/src/main/runtime/runtime-client-settings.ts index a2b05aaaef9..52429de09bb 100644 --- a/src/main/runtime/runtime-client-settings.ts +++ b/src/main/runtime/runtime-client-settings.ts @@ -109,6 +109,8 @@ export class RuntimeClientSettingsController { : { agentSkillSharingEnabled: isAgentSkillSharingEnabled(settings) }), machineName: settings.machineName ?? '', nativeChatPermissionMode: publishedLegacyChatPermissionMode(settings), + // Phone sends queue mid-turn only while this is on; absence from older hosts means on. + nativeChatQueueFollowUps: settings.nativeChatQueueFollowUps !== false, // Why projected: a paired client's AI buttons start these actions' agents, and must honour // the agent saved for each one as the desktop does. Absent on older hosts. sourceControlAi: projectSourceControlLaunchRecipes(settings), diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-routing.ts b/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-routing.ts index eaf1276ce10..63c0c3e687e 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-routing.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-routing.ts @@ -156,6 +156,8 @@ export const EXECUTION_HOST_METHODS = [ 'repo.searchRefs', 'repo.sparsePresets', 'repo.update', + // The host that owns the workspace scans its skills. + 'skills.discover', // SSH targets belong to the host that holds them; untargeted calls stay the Mac's own, as on the desktop. 'ssh.connect', 'ssh.getState', diff --git a/src/main/runtime/runtime-store-contract.ts b/src/main/runtime/runtime-store-contract.ts index 0506e10b587..99b729e2f1e 100644 --- a/src/main/runtime/runtime-store-contract.ts +++ b/src/main/runtime/runtime-store-contract.ts @@ -182,6 +182,7 @@ export type RuntimeStore = { agentSkillSharingEnabled?: GlobalSettings['agentSkillSharingEnabled'] nativeChatSessionOptions?: GlobalSettings['nativeChatSessionOptions'] nativeChatPermissionMode?: GlobalSettings['nativeChatPermissionMode'] + nativeChatQueueFollowUps?: GlobalSettings['nativeChatQueueFollowUps'] nativeChatInlineVisuals?: GlobalSettings['nativeChatInlineVisuals'] nativeChatInheritShellEnvironment?: GlobalSettings['nativeChatInheritShellEnvironment'] nativeChatShellEnvironmentVariables?: GlobalSettings['nativeChatShellEnvironmentVariables'] diff --git a/src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.test.tsx b/src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.test.tsx index ce71d7635f0..6f18d06084f 100644 --- a/src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.test.tsx @@ -5,7 +5,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { NativeChatMentionMenu, NativeChatPickerMenu } from './NativeChatAutocompleteMenus' import { buildNativeChatPickerItems } from '../../../../shared/native-chat/input/native-chat-picker-items' import { sessionSlashCommandSuggestions } from '../../../../shared/native-chat/input/native-chat-slash-commands' -import type { ComposerAutocomplete } from './native-chat-composer-state' +import type { ComposerAutocomplete } from '../../../../shared/native-chat-composer-autocomplete' function autocomplete( overrides: Partial> = {} diff --git a/src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.tsx b/src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.tsx index 2ef98069424..f235dbe6f2f 100644 --- a/src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.tsx +++ b/src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.tsx @@ -5,8 +5,10 @@ import { cn } from '@/lib/utils' import type { SkillSourceKind } from '../../../../shared/skills' import { FilenameFirstPath } from '@/components/file-path-cursor-tooltip' import { FileTypeIcon } from '@/components/FileTypeIcon' -import type { ComposerAutocomplete } from './native-chat-composer-state' -import type { NativeChatPickerItem } from '../../../../shared/native-chat/input/native-chat-picker-items' +import type { + ComposerAutocomplete, + NativeChatPickerItem +} from '../../../../shared/native-chat-composer-autocomplete' import type { NativeChatMentionFiles } from './use-native-chat-mention-files' import { LoadingSpinner } from '@/components/LoadingSpinner' diff --git a/src/renderer/src/components/native-chat/NativeChatComposerField.tsx b/src/renderer/src/components/native-chat/NativeChatComposerField.tsx index 3597443e7e3..d9054c27985 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposerField.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposerField.tsx @@ -5,8 +5,10 @@ import { useCallback, useLayoutEffect, useMemo, useRef } from 'react' import { flushSync } from 'react-dom' import type { useImeEnterGestureOwnership } from '@/lib/ime-composition-keyboard-event' import { cn } from '@/lib/utils' -import type { ComposerAutocomplete } from './native-chat-composer-state' -import type { NativeChatPickerItem } from '../../../../shared/native-chat/input/native-chat-picker-items' +import type { + ComposerAutocomplete, + NativeChatPickerItem +} from '../../../../shared/native-chat-composer-autocomplete' import { NativeChatMentionMenu, NativeChatPickerMenu } from './NativeChatAutocompleteMenus' import type { NativeChatMentionFiles } from './use-native-chat-mention-files' import { NativeChatComposerActions } from './NativeChatComposerActions' diff --git a/src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.tsx b/src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.tsx index 33afc5d4954..bd51b237993 100644 --- a/src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.tsx +++ b/src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.tsx @@ -5,10 +5,8 @@ import { basename } from '@/lib/path' import { useLocalImageSrc } from '@/components/editor/useLocalImageSrc' import { PanZoomImageDialog } from '@/components/editor/PanZoomImageDialog' import { copyableNativeChatImageSrc, keepPreviewOpenForChatMenu } from './native-chat-image-copy' -import { - isNativeChatImageAttachmentPath, - isNativeChatPastedImagePath -} from './native-chat-image-paste' +import { isNativeChatImageAttachmentPath } from './native-chat-image-paste' +import { isNativeChatPastedImagePath } from '../../../../shared/native-chat-image-label' import type { NativeChatComposerImageAttachment } from './NativeChatComposerField' import { isAgentSessionAttachmentStorePath } from '../../../../shared/agent-session-attachments' import { chatImageAccess } from '@/lib/local-file-access' diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx index 2ff68221d51..952d165b851 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx @@ -64,9 +64,7 @@ import type { NativeChatSettledTurns } from '../../../../shared/native-chat-turn import type { NativeChatDiffReveal, NativeChatDiffTarget } from './native-chat-turn-diffs' import { useNativeChatTurnDiffs } from './use-native-chat-turn-diffs' import { transcriptTailRow } from './native-chat-transcript-tail-row' - -/** shown prompts have a card in this pane; unshown prompts require input in the agent’s terminal. */ -export type NativeChatAwaitingInput = 'shown' | 'unshown' +import type { NativeChatAwaitingInput } from '../../../../shared/native-chat/native-chat-terminal-turn' type NavigationRequest = | { kind: 'diff'; target: NativeChatDiffReveal } diff --git a/src/renderer/src/components/native-chat/NativeChatNoticeRow.tsx b/src/renderer/src/components/native-chat/NativeChatNoticeRow.tsx index db587a88cc3..b7ff3114f19 100644 --- a/src/renderer/src/components/native-chat/NativeChatNoticeRow.tsx +++ b/src/renderer/src/components/native-chat/NativeChatNoticeRow.tsx @@ -18,7 +18,8 @@ import { } from '../../../../shared/native-chat-notice' import { useNativeChatOrcaStopView } from './native-chat-orca-stop-context' import { NativeChatInterruptedContinueButton } from './NativeChatInterruptedContinue' -import { nativeChatOrcaStopRowText } from './native-chat-orca-stop-words' +import { nativeChatOrcaStopRowText } from '../../../../shared/native-chat/native-chat-orca-stop-words' +import { sayNativeChatOrcaStopTranslated } from './native-chat-orca-stop-words-text' import { ProviderFrameRow } from './NativeChatTranscriptChrome' import { Button } from '@/components/ui/button' import { @@ -140,10 +141,7 @@ export function NativeChatNoticeRow({ const { hostLabel, remoteHost, continueAvailable } = orcaStopView // A paired server with no name to show is still a remote server; its id is not a name. const machine = - hostLabel ?? - (remoteHost - ? translate('components.native-chat.notices.orcaStopUnnamedServer', 'the remote server') - : null) + hostLabel ?? (remoteHost ? sayNativeChatOrcaStopTranslated('unnamedServer') : null) const named = orcaStop !== undefined && machine !== null const continueOffered = orcaStop?.turnItemId !== undefined && orcaStop.turnItemId === orcaStopView.offeredTurnItemId @@ -157,7 +155,12 @@ export function NativeChatNoticeRow({ ) : undefined const text = named - ? nativeChatOrcaStopRowText(orcaStop.cause, machine, { continueAvailable, remoteHost }) + ? nativeChatOrcaStopRowText( + orcaStop.cause, + machine, + { continueAvailable, remoteHost }, + sayNativeChatOrcaStopTranslated + ) : failure?.kind === 'usageLimitReached' ? agentSessionFailureSentence( failure, diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx index 4295b7c2fa3..87e7859dae0 100644 --- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx @@ -1,7 +1,4 @@ -import { - QueuedMessageImageCount, - queuedMessageImageCount -} from './NativeChatQueuedMessageImageCount' +import { QueuedMessageImageCount } from './NativeChatQueuedMessageImageCount' import type { QueuedMessageDragPresentation } from './NativeChatQueuedMessageRows' import { useId, useState } from 'react' import { @@ -25,27 +22,25 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip import { cn } from '@/lib/utils' import { ShortcutKeyCombo } from '@/components/ShortcutKeyCombo' import { translate } from '@/i18n/i18n' -import { structuredAgentSessionAttemptFailureParts } from '../../../../shared/structured-agent-session-rejection-words' -import { classifyDispatchRejection } from '../../../../shared/structured-agent-session-dispatch-rejection' -import { readWholeAgentSessionFailureFact } from '../../../../shared/agent-session-failure' import { agentSessionWriteNoticeText } from './agent-session-write-notice-text' import { QUEUED_MESSAGE_PAUSED_SEND_FAILED } from '../../../../shared/agent-session-wire' import { isMacPlatform } from './native-chat-shortcut' import { + queuedMessageCardLine, + queuedMessageCardNeedsAttention, queuedMessageCardSteers, + queuedMessageReturnedNotice, type QueuedMessageCard -} from './structured-agent-session-queued-cards' +} from '../../../../shared/structured-agent-session-queued-cards' import { NativeChatAgentMessageSenders } from './NativeChatAgentMessageSenders' import { NativeChatPendingIcon } from './NativeChatPendingIcon' import type { QueuedMessagesPendingAction } from './use-structured-agent-session-queued-messages' import { useNativeChatClippedLine } from './use-native-chat-clipped-line' -import { agentSessionFailureStatedByStartRow } from './structured-agent-session-delivery-notices' import type { AgentSessionFailureFact } from '../../../../shared/agent-session-failure' -import { agentSessionWriteNotDoneParts } from '../../../../shared/agent-session-refusal-notice' import { NativeChatQueuedMessageEditor } from './NativeChatQueuedMessageEditor' import type { QueuedEditAttachTarget } from './use-native-chat-queued-edit-image-intake' import type { QueuedMessageInlineEditor } from './use-structured-agent-session-queued-edit' -import { nativeChatImageLabel } from './native-chat-image-label' +import { nativeChatImageLabelCopy } from './native-chat-image-label' /** The visible caption under the text; the default waiting hold needs none. */ export function queuedMessageCardCaption( @@ -55,29 +50,13 @@ export function queuedMessageCardCaption( ): string | null { switch (card.hold) { case 'returned': { - // Read exactly as a rejected submission: the typed fact decides, the reason is the fallback. - const reason = card.returnedReason ?? null - if (agentSessionFailureStatedByStartRow(card.returnedRejection, statedFailures)) { - return agentSessionWriteNoticeText(agentSessionWriteNotDoneParts('send')) - } - // A Stop-withdrawn draft was not a send failure; describe its withdrawal. - if ( - classifyDispatchRejection({ reason, rejection: card.returnedRejection }).category === - 'withdrawn' - ) { - return translate( - 'components.native-chat.queuedMessages.withdrawnHold', - 'Stopped before it was sent' - ) - } - return agentSessionWriteNoticeText( - structuredAgentSessionAttemptFailureParts( - { kind: 'rejected', reason }, - // The card's own Send is the retry, so the words leave out sending again. - { agentName, retryControl: true }, - readWholeAgentSessionFailureFact(card.returnedRejection) - ) - ) + const notice = queuedMessageReturnedNotice(card, agentName, statedFailures) + return notice === 'withdrawn' + ? translate( + 'components.native-chat.queuedMessages.withdrawnHold', + 'Stopped before it was sent' + ) + : agentSessionWriteNoticeText(notice) } case 'paused': // Localize known pause markers; unknown newer-host markers fall back to a plain pause. @@ -108,17 +87,6 @@ export function queuedMessageCardCaption( } } -/** The card's line: its text, or for a card of images alone, the image's name or their count. */ -function queuedMessageCardLine(card: QueuedMessageCard): string { - const images = card.images ?? [] - if (card.text.trim() || images.length === 0) { - return card.text - } - return images.length === 1 - ? nativeChatImageLabel(images[0]) - : queuedMessageImageCount(images.length) -} - /** Steer's or Send's label and tooltip (`queuedMessageCardSteers`). */ export function queuedMessageCardSendNow(card: QueuedMessageCard): { /** Steer's ↳, or Send's paper plane. */ @@ -186,9 +154,9 @@ export function NativeChatQueuedMessageCard({ onTurnOffQueueing?: () => void }): React.JSX.Element { const caption = queuedMessageCardCaption(card, agentName, statedFailures) - const line = queuedMessageCardLine(card) + const line = queuedMessageCardLine(card, nativeChatImageLabelCopy()) const returned = card.state === 'returned' - const attention = returned || card.pausedReason === QUEUED_MESSAGE_PAUSED_SEND_FAILED + const attention = queuedMessageCardNeedsAttention(card) const sendNow = queuedMessageCardSendNow(card) const busy = pending !== null const isMac = isMacPlatform() diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageEditor.test.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageEditor.test.tsx index f1add4b4934..29ca12c1391 100644 --- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageEditor.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageEditor.test.tsx @@ -10,7 +10,7 @@ import { useStructuredAgentSessionQueuedEdit, type QueuedMessageInlineEditor } from './use-structured-agent-session-queued-edit' -import { projectQueuedMessageCards } from './structured-agent-session-queued-cards' +import { projectQueuedMessageCards } from '../../../../shared/structured-agent-session-queued-cards' import type { AgentSessionQueuedMessage } from '../../../../shared/agent-session-wire' import type { StructuredAgentSessionWrite } from './use-structured-agent-session-mutate' import type * as NativeChatAttachmentUpload from './native-chat-attachment-upload' diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageImageCount.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageImageCount.tsx index 19967856e5a..d99c50cb205 100644 --- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageImageCount.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageImageCount.tsx @@ -1,14 +1,7 @@ import { Image as ImageIcon } from 'lucide-react' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import { translate } from '@/i18n/i18n' -import type { QueuedMessageCard } from './structured-agent-session-queued-cards' -import { nativeChatImageLabel } from './native-chat-image-label' - -export function queuedMessageImageCount(count: number): string { - return translate('components.native-chat.queuedMessages.imageCount', '{{count}} images', { - count - }) -} +import type { QueuedMessageCard } from '../../../../shared/structured-agent-session-queued-cards' +import { nativeChatImageLabel, nativeChatImageLabelCopy } from './native-chat-image-label' /** Images are named in the tooltip, not drawn: the row stays one line. */ export function QueuedMessageImageCount({ @@ -22,7 +15,7 @@ export function QueuedMessageImageCount({ diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.arrival.test.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.arrival.test.tsx index 03eb9b040df..271c3d22cec 100644 --- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.arrival.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.arrival.test.tsx @@ -3,7 +3,7 @@ import { cleanup, fireEvent, render } from '@testing-library/react' import { afterEach, expect, it, vi } from 'vitest' import { TooltipProvider } from '@/components/ui/tooltip' -import type { QueuedMessageCard } from './structured-agent-session-queued-cards' +import type { QueuedMessageCard } from '../../../../shared/structured-agent-session-queued-cards' import type { StructuredAgentSessionQueuedMessagesController } from './use-structured-agent-session-queued-messages' vi.mock('../../store', () => { diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.inline-edit.test.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.inline-edit.test.tsx index 52f433da65e..c1c38f5666d 100644 --- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.inline-edit.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.inline-edit.test.tsx @@ -19,7 +19,7 @@ vi.mock('../../store', () => { }) import { TooltipProvider } from '@/components/ui/tooltip' -import type { QueuedMessageCard } from './structured-agent-session-queued-cards' +import type { QueuedMessageCard } from '../../../../shared/structured-agent-session-queued-cards' import { NativeChatQueuedMessageList } from './NativeChatQueuedMessageList' import type { StructuredAgentSessionQueuedMessagesController } from './use-structured-agent-session-queued-messages' diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx index efdabb4f9ff..f423080f699 100644 --- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx @@ -21,7 +21,7 @@ vi.mock('../../store', () => { }) import { TooltipProvider } from '@/components/ui/tooltip' -import type { QueuedMessageCard } from './structured-agent-session-queued-cards' +import type { QueuedMessageCard } from '../../../../shared/structured-agent-session-queued-cards' import { NativeChatQueuedMessageList } from './NativeChatQueuedMessageList' import { queuedMessageCardSendNow } from './NativeChatQueuedMessageCard' import { diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx index 82e46cb92c6..58f878f757f 100644 --- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx @@ -18,7 +18,7 @@ import { NativeChatPendingIcon } from './NativeChatPendingIcon' import type { AgentSessionFailureFact } from '../../../../shared/agent-session-failure' import { NATIVE_CHAT_ROOT_SELECTOR } from '@/lib/native-chat-paste-request' import { focusNativeChatPromptCard } from './use-native-chat-prompt-card-focus' -import { QUEUED_MESSAGE_PAUSED_SEND_FAILED } from '../../../../shared/agent-session-wire' +import { queuedMessageCardNeedsAttention } from '../../../../shared/structured-agent-session-queued-cards' import type { QueuedEditAttachTarget } from './use-native-chat-queued-edit-image-intake' /** Keep host-held drafts outside the transcript until consumed; keep the live region mounted for the first card. */ @@ -54,12 +54,7 @@ export function NativeChatQueuedMessageList({ setExpanded(true) } const newest = cards.at(-1) - const attentionCard = !expanded - ? cards.find( - (card) => - card.state === 'returned' || card.pausedReason === QUEUED_MESSAGE_PAUSED_SEND_FAILED - ) - : undefined + const attentionCard = !expanded ? cards.find(queuedMessageCardNeedsAttention) : undefined const attentionCaption = attentionCard ? queuedMessageCardCaption(attentionCard, agentName, statedFailures) : null diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageRows.test.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageRows.test.tsx index 3745bbff77a..c19c20eb444 100644 --- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageRows.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageRows.test.tsx @@ -5,7 +5,7 @@ import type * as DndKitCore from '@dnd-kit/core' import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react' import { expect, it, vi } from 'vitest' import { NativeChatQueuedMessageRows } from './NativeChatQueuedMessageRows' -import type { QueuedMessageCard } from './structured-agent-session-queued-cards' +import type { QueuedMessageCard } from '../../../../shared/structured-agent-session-queued-cards' const completion = vi.hoisted(() => ({ deferred: false })) vi.mock('@dnd-kit/core', async (importOriginal) => { diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageRows.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageRows.tsx index eac49211628..4de55ad21bb 100644 --- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageRows.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageRows.tsx @@ -18,7 +18,7 @@ import { GripVertical } from 'lucide-react' import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { translate } from '@/i18n/i18n' -import type { QueuedMessageCard } from './structured-agent-session-queued-cards' +import type { QueuedMessageCard } from '../../../../shared/structured-agent-session-queued-cards' import type { QueuedMessageReorder } from './use-structured-agent-session-queued-reorder' export type QueuedMessageDragPresentation = { diff --git a/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx b/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx index 16bbf5884e6..c7b8f9c7f06 100644 --- a/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx +++ b/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx @@ -25,8 +25,8 @@ import { useNativeChatPromptCardPresentation } from './use-native-chat-prompt-ca import { NativeChatPromptStrip } from './NativeChatPromptCollapse' import { NativeChatEmptyState } from './NativeChatEmptyState' import { useNativeChatInteractiveSend } from './use-native-chat-interactive-send' -import { shouldClearNativeChatWorkingSuppression } from './native-chat-working-suppression' -import { resolveNativeChatTerminalTurn } from './native-chat-terminal-turn' +import { shouldClearNativeChatWorkingSuppression } from '../../../../shared/native-chat/native-chat-working-suppression' +import { resolveNativeChatTerminalTurn } from '../../../../shared/native-chat/native-chat-terminal-turn' import { useNativeChatTerminalTurnTiming } from './use-native-chat-terminal-turn-timing' import { launchPromptAsMessage, shouldPruneLaunchPrompt } from './native-chat-pending' import { pendingSendsAsMessages } from '../../../../shared/native-chat-pending-sends' diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx index 78d607bddb7..c08704abcdf 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx @@ -1,7 +1,7 @@ import { useImperativeHandle } from 'react' import { vi } from 'vitest' import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' -import type { QueuedMessageCard } from './structured-agent-session-queued-cards' +import type { QueuedMessageCard } from '../../../../shared/structured-agent-session-queued-cards' import type { AgentSessionBackgroundTask } from '../../../../shared/agent-session-wire' import type { AgentSessionWriteRefusal } from '../../../../shared/agent-session-write-failure' import type { AgentSessionUnavailable } from '../../../../shared/agent-session-availability' diff --git a/src/renderer/src/components/native-chat/NativeChatTranscriptChrome.tsx b/src/renderer/src/components/native-chat/NativeChatTranscriptChrome.tsx index 26a1453bd7b..653c49d3e13 100644 --- a/src/renderer/src/components/native-chat/NativeChatTranscriptChrome.tsx +++ b/src/renderer/src/components/native-chat/NativeChatTranscriptChrome.tsx @@ -17,7 +17,7 @@ import { } from '@/components/editor/useLocalImageSrc' import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client-types' import { copyableNativeChatImageSrc, keepPreviewOpenForChatMenu } from './native-chat-image-copy' -import { isNativeChatPastedImagePath } from './native-chat-image-paste' +import { isNativeChatPastedImagePath } from '../../../../shared/native-chat-image-label' import { nativeChatImageLabel } from './native-chat-image-label' import { chatImageAccess } from '@/lib/local-file-access' diff --git a/src/renderer/src/components/native-chat/agent-session-write-notice-text.ts b/src/renderer/src/components/native-chat/agent-session-write-notice-text.ts index 006b81e4d35..8af1177d589 100644 --- a/src/renderer/src/components/native-chat/agent-session-write-notice-text.ts +++ b/src/renderer/src/components/native-chat/agent-session-write-notice-text.ts @@ -143,6 +143,7 @@ const SENTENCES: Record string> = { translate('components.native-chat.writeNotice.clearAfterSending', COPY.clearAfterSending), compactAfterSending: () => translate('components.native-chat.writeNotice.compactAfterSending', COPY.compactAfterSending), + pendingWork: () => translate('components.native-chat.writeNotice.pendingWork', COPY.pendingWork), queueTooLarge: () => translate('components.native-chat.writeNotice.queueTooLarge', COPY.queueTooLarge), shrinkQueue: () => translate('components.native-chat.writeNotice.shrinkQueue', COPY.shrinkQueue), diff --git a/src/renderer/src/components/native-chat/native-chat-composer-draft-persistence.ts b/src/renderer/src/components/native-chat/native-chat-composer-draft-persistence.ts index 0eca3d9ec2d..82296824abf 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-draft-persistence.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-persistence.ts @@ -1,7 +1,8 @@ // Persist whole memory records, journal unconfirmed changes on unload, and adopt other windows’ confirmed writes. import { basename } from '@/lib/path' -import { isNativeChatKeptPastePath, isNativeChatPastedImagePath } from './native-chat-image-paste' +import { isNativeChatKeptPastePath } from './native-chat-image-paste' +import { isNativeChatPastedImagePath } from '../../../../shared/native-chat-image-label' import { isAgentSessionAttachmentStorePath } from '../../../../shared/agent-session-attachments' import { dirtyScopes, diff --git a/src/renderer/src/components/native-chat/native-chat-image-label.ts b/src/renderer/src/components/native-chat/native-chat-image-label.ts index d9da57a4242..64c496ca568 100644 --- a/src/renderer/src/components/native-chat/native-chat-image-label.ts +++ b/src/renderer/src/components/native-chat/native-chat-image-label.ts @@ -1,16 +1,18 @@ import { translate } from '@/i18n/i18n' -import { basename } from '@/lib/path' import type { NativeChatImageRefBlock } from '../../../../shared/native-chat-types' -import { isNativeChatPastedImagePath } from './native-chat-image-paste' +import { + nativeChatImageLabel as sharedNativeChatImageLabel, + type NativeChatImageLabelCopy +} from '../../../../shared/native-chat-image-label' -/** What a chat image is called where it is not drawn: its alt text, else its file's name. */ +export function nativeChatImageLabelCopy(): NativeChatImageLabelCopy { + return { + pastedImage: translate('components.native-chat.composer.pastedImageLabel', 'Pasted image'), + imageCount: (count) => + translate('components.native-chat.queuedMessages.imageCount', '{{count}} images', { count }) + } +} + export function nativeChatImageLabel(block: NativeChatImageRefBlock): string { - return ( - block.alt?.trim() || - (block.path && isNativeChatPastedImagePath(block.path) - ? translate('components.native-chat.composer.pastedImageLabel', 'Pasted image') - : block.path - ? basename(block.path) - : 'Image') - ) + return sharedNativeChatImageLabel(block, nativeChatImageLabelCopy()) } diff --git a/src/renderer/src/components/native-chat/native-chat-image-paste.ts b/src/renderer/src/components/native-chat/native-chat-image-paste.ts index 0eb6e01e403..3164736b4a2 100644 --- a/src/renderer/src/components/native-chat/native-chat-image-paste.ts +++ b/src/renderer/src/components/native-chat/native-chat-image-paste.ts @@ -1,5 +1,6 @@ import { isImageDropPath } from '../terminal-pane/terminal-drop-image-path' import { NATIVE_CHAT_PASTE_FOLDER } from '../../../../shared/native-chat/input/native-chat-paste-folder' +import { isNativeChatPastedImagePath } from '../../../../shared/native-chat-image-label' export { getAgentImageHandling, type AgentImageHandling @@ -9,12 +10,6 @@ export function isNativeChatImageAttachmentPath(path: string): boolean { return isImageDropPath(path) } -/** Use friendly UI labels for Orca clipboard-image temp filenames. */ -export function isNativeChatPastedImagePath(path: string): boolean { - const base = path.split(/[\\/]/).findLast(Boolean) ?? path - return /^orca-paste-.+\.png$/i.test(base) -} - /** True for a long text paste Orca saved as a file (`orca-paste--.txt`). */ export function isNativeChatPastedTextPath(path: string): boolean { const base = path.split(/[\\/]/).findLast(Boolean) ?? path diff --git a/src/renderer/src/components/native-chat/native-chat-orca-stop-words-text.ts b/src/renderer/src/components/native-chat/native-chat-orca-stop-words-text.ts new file mode 100644 index 00000000000..4edcf639f25 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-orca-stop-words-text.ts @@ -0,0 +1,24 @@ +import { translate } from '@/i18n/i18n' +import { + NATIVE_CHAT_ORCA_STOP_COPY as COPY, + type NativeChatOrcaStopCopyId, + type NativeChatOrcaStopSay +} from '../../../../shared/native-chat/native-chat-orca-stop-words' + +const PIECES: Record string> = { + update: (machine) => + translate('components.native-chat.notices.orcaStopUpdate', COPY.update, { machine }), + quit: (machine) => + translate('components.native-chat.notices.orcaStopQuit', COPY.quit, { machine }), + crash: (machine) => + translate('components.native-chat.notices.orcaStopCrash', COPY.crash, { machine }), + remote: (machine) => + translate('components.native-chat.notices.orcaStopRemote', COPY.remote, { machine }), + canContinue: () => + translate('components.native-chat.notices.orcaStopCanContinue', COPY.canContinue), + unnamedServer: () => + translate('components.native-chat.notices.orcaStopUnnamedServer', COPY.unnamedServer) +} + +export const sayNativeChatOrcaStopTranslated: NativeChatOrcaStopSay = (id, machine) => + PIECES[id](machine) diff --git a/src/renderer/src/components/native-chat/native-chat-orca-stop-words.ts b/src/renderer/src/components/native-chat/native-chat-orca-stop-words.ts deleted file mode 100644 index 5b540ec4be4..00000000000 --- a/src/renderer/src/components/native-chat/native-chat-orca-stop-words.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { translate } from '@/i18n/i18n' -import type { AgentSessionOrcaStopCause } from '../../../../shared/agent-session-orca-stop' -import { joinSentences } from '../../../../shared/sentence-joining' - -const STOPPED: Record string> = { - update: (machine) => - translate( - 'components.native-chat.notices.orcaStopUpdate', - 'Orca on {{machine}} restarted for an update while this response was in progress.', - { machine } - ), - quit: (machine) => - translate( - 'components.native-chat.notices.orcaStopQuit', - 'Orca on {{machine}} was closed while this response was in progress.', - { machine } - ), - crash: (machine) => - translate( - 'components.native-chat.notices.orcaStopCrash', - 'Orca on {{machine}} stopped unexpectedly while this response was in progress.', - { machine } - ) -} - -// A remote graceful stop is a server stop or a shared desktop's quit; "stopped" is true of both. -const remoteQuit = (machine: string): string => - translate( - 'components.native-chat.notices.orcaStopRemote', - 'Orca on {{machine}} stopped while this response was in progress.', - { machine } - ) - -function stoppedSentence( - cause: AgentSessionOrcaStopCause, - machine: string, - remoteHost: boolean -): string { - return cause === 'quit' && remoteHost ? remoteQuit(machine) : STOPPED[cause](machine) -} - -/** Base wording on host continuation support so the sentence stays stable as the button changes. */ -export function nativeChatOrcaStopRowText( - cause: AgentSessionOrcaStopCause, - machine: string, - options: { continueAvailable: boolean; remoteHost: boolean } -): string { - const stopped = stoppedSentence(cause, machine, options.remoteHost) - return options.continueAvailable - ? stopped - : joinSentences([ - stopped, - translate( - 'components.native-chat.notices.orcaStopCanContinue', - 'You can continue in this conversation.' - ) - ]) -} diff --git a/src/renderer/src/components/native-chat/native-chat-preassembled-session-parity.test.ts b/src/renderer/src/components/native-chat/native-chat-preassembled-session-parity.test.ts index 5f43c148591..531474e40ca 100644 --- a/src/renderer/src/components/native-chat/native-chat-preassembled-session-parity.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-preassembled-session-parity.test.ts @@ -7,7 +7,7 @@ import { reset as resetAssembler } from './native-chat-incremental-assembler' import { prepareNativeChatLiveMessages } from './native-chat-live-message-preparation' -import { mergeNativeChatLiveSession } from './native-chat-live-status' +import { mergeNativeChatLiveSession } from '../../../../shared/native-chat/native-chat-live-status' import { assembleNativeChatSession } from './native-chat-session-assembler' const CLAUDE_COMMANDS = new Set( diff --git a/src/renderer/src/components/native-chat/native-chat-runtime-image-send.ts b/src/renderer/src/components/native-chat/native-chat-runtime-image-send.ts index 17a4a3559a7..1567cdf0825 100644 --- a/src/renderer/src/components/native-chat/native-chat-runtime-image-send.ts +++ b/src/renderer/src/components/native-chat/native-chat-runtime-image-send.ts @@ -1,5 +1,6 @@ import { sendNativeChatObservedWrites } from './native-chat-observed-send' -import { agentImagePasteWrites, formatAgentImagePath } from '../../../../shared/agent-image-paste' +import { agentImagePasteWrites } from '../../../../shared/agent-image-paste' +import { agentImagePastePath } from '../../../../shared/agent-image-paste-path' import type { AgentType } from '../../../../shared/agent-status-types' import { sendRuntimePtyInput } from '@/runtime/runtime-terminal-inspection' import { NATIVE_CHAT_SUBMIT_DELAY_MS } from '../../../../shared/native-chat/input/native-chat-answer-stepping' @@ -9,8 +10,6 @@ import { NATIVE_CHAT_SUBMIT } from '../../../../shared/native-chat-paste-bytes' import { enqueueNativeChatPtySend } from './native-chat-pty-send-queue' -import { formatImageDropPasteText } from '../terminal-pane/terminal-drop-image-path' -import { isTerminalDropWindowsPathLike } from '../terminal-pane/terminal-drop-shell' import { clearConfirmDurationMs, clearThenWrite, @@ -21,16 +20,6 @@ import { sendNativeChatMessage, type NativeChatSendHandle } from './native-chat- export const NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS = 300 -/** Escape image paths as terminal drops do; agent TUIs unescape before checking file existence. */ -export function agentImagePastePath(agent: AgentType, path: string): string { - const formatted = formatAgentImagePath(agent, path) - if (formatted !== path) { - return formatted - } - const targetShell = isTerminalDropWindowsPathLike(path) ? 'windows' : 'posix' - return formatImageDropPasteText(path, targetShell) ?? path -} - export function sendNativeChatMessageWithImageAttachments( agent: AgentType, ptyId: string, diff --git a/src/renderer/src/components/native-chat/native-chat-transcript-tail-row.ts b/src/renderer/src/components/native-chat/native-chat-transcript-tail-row.ts index 38edc45df96..04d8c7cbe02 100644 --- a/src/renderer/src/components/native-chat/native-chat-transcript-tail-row.ts +++ b/src/renderer/src/components/native-chat/native-chat-transcript-tail-row.ts @@ -1,4 +1,4 @@ -import type { NativeChatAwaitingInput } from './NativeChatMessageList' +import type { NativeChatAwaitingInput } from '../../../../shared/native-chat/native-chat-terminal-turn' export function transcriptTailRow( isWorking: boolean, diff --git a/src/renderer/src/components/native-chat/native-chat-view-state.test.ts b/src/renderer/src/components/native-chat/native-chat-view-state.test.ts index 2d25b4383fd..cc21b182472 100644 --- a/src/renderer/src/components/native-chat/native-chat-view-state.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-view-state.test.ts @@ -1,6 +1,8 @@ import { describe, it, expect } from 'vitest' import type { NativeChatMessage, NativeChatSession } from '../../../../shared/native-chat-types' import { selectNativeChatViewState, structuredChatHistoryPhase } from './native-chat-view-state' +import { mergeNativeChatLiveSession } from '../../../../shared/native-chat/native-chat-live-status' +import { shouldShowNativeChatWorking } from '../../../../shared/native-chat/native-chat-working-suppression' const message: NativeChatMessage = { id: 'a', @@ -21,6 +23,28 @@ function session(overrides: Partial): NativeChatSession { } describe('selectNativeChatViewState', () => { + // Session ids can arrive before transcript flush; live work must still retain Stop and its activity indicator. + it('keeps the Stop affordance for a working known session mid-flush', () => { + const live = mergeNativeChatLiveSession({ + messages: [{ ...message, id: 'u-1', role: 'user' }], + sessionId: 'sess', + agent: 'claude', + hookState: 'working', + loading: true + }) + const viewState = selectNativeChatViewState(live) + const isConversation = viewState.kind === 'ready' + + expect(viewState).toEqual({ kind: 'ready', isWorking: true }) + expect( + shouldShowNativeChatWorking({ + isConversation, + working: live.status === 'working', + interrupted: false + }) + ).toBe(true) + }) + it('keeps rendering messages while the session reports loading', () => { expect(selectNativeChatViewState(session({ status: 'loading' }))).toEqual({ kind: 'ready', diff --git a/src/renderer/src/components/native-chat/queued-message-edit-notices.ts b/src/renderer/src/components/native-chat/queued-message-edit-notices.ts index 8ab1d90e54d..cf3ae5b5ee8 100644 --- a/src/renderer/src/components/native-chat/queued-message-edit-notices.ts +++ b/src/renderer/src/components/native-chat/queued-message-edit-notices.ts @@ -1,52 +1,25 @@ import { translate } from '@/i18n/i18n' +import { + QUEUED_EDIT_NOTICE_COPY as COPY, + type QueuedEditNotice +} from '../../../../shared/queued-message-edit' -/** A question or approval holds the chat box's place, so the text there is out of sight until then. */ -export function queuedEditNotice( - key: - | 'editChanged' - | 'editChangedPrompt' - | 'editChangedNotSaved' - | 'editFailed' - | 'editGone' - | 'editGonePrompt' -): string { - switch (key) { - case 'editChanged': - return translate( - 'components.native-chat.queuedMessages.editChanged', - 'This message changed while you were editing. Your edit is in the chat box.' - ) - case 'editChangedPrompt': - return translate( - 'components.native-chat.queuedMessages.editChangedPrompt', - 'This message changed while you were editing. Your edit will be in the chat box after you answer the agent.' - ) - case 'editChangedNotSaved': - return translate( - 'components.native-chat.queuedMessages.editChangedNotSaved', - "This message changed while you were editing. Your edit wasn't saved." - ) - case 'editGone': - return translate( - 'components.native-chat.queuedMessages.editGone', - 'This message was already sent or removed. Your edit is in the chat box.' - ) - case 'editGonePrompt': - return translate( - 'components.native-chat.queuedMessages.editGonePrompt', - 'This message was already sent or removed. Your edit will be in the chat box after you answer the agent.' - ) - case 'editFailed': - return translate( - 'components.native-chat.queuedMessages.editFailed', - "This message can't be edited." - ) - } +const NOTICES: Record string> = { + editChanged: () => + translate('components.native-chat.queuedMessages.editChanged', COPY.editChanged), + editChangedPrompt: () => + translate('components.native-chat.queuedMessages.editChangedPrompt', COPY.editChangedPrompt), + editChangedNotSaved: () => + translate( + 'components.native-chat.queuedMessages.editChangedNotSaved', + COPY.editChangedNotSaved + ), + editGone: () => translate('components.native-chat.queuedMessages.editGone', COPY.editGone), + editGonePrompt: () => + translate('components.native-chat.queuedMessages.editGonePrompt', COPY.editGonePrompt), + editFailed: () => translate('components.native-chat.queuedMessages.editFailed', COPY.editFailed) } -export function queuedEditChangedNotice(handedOff: boolean, promptOpen: boolean): string { - if (!handedOff) { - return queuedEditNotice('editChangedNotSaved') - } - return queuedEditNotice(promptOpen ? 'editChangedPrompt' : 'editChanged') +export function queuedEditNotice(key: QueuedEditNotice): string { + return NOTICES[key]() } diff --git a/src/renderer/src/components/native-chat/structured-agent-session-queued-edit-images.ts b/src/renderer/src/components/native-chat/structured-agent-session-queued-edit-images.ts index 6235eae51c1..d3fcaa1f045 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-queued-edit-images.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-queued-edit-images.ts @@ -2,7 +2,7 @@ import type { AgentJournalMessageItem } from '../../../../shared/agent-session-journal-types' import { - queuedEditBaseText, + queuedEditHandOffTyping, queuedEditImagesPending, queuedEditKeptImages, queuedEditTextForKeptImages, @@ -14,7 +14,6 @@ import type { NativeChatComposerImageAttachment } from './NativeChatComposerFiel import { appendNativeChatDraftCache } from './native-chat-draft-cache' import { isNativeChatImageAttachmentPath } from './native-chat-image-paste' import { imageReferenceInsertion } from '../../../../shared/native-chat-image-reference' -import { stripSentImagePromptMarkers } from '../../../../shared/native-chat-image-transcript-markers' export type QueuedEditImageState = { /** The chat box this edit hands off to; the one it started in, never another. */ @@ -93,8 +92,7 @@ export function releaseQueuedEditImages(edit: QueuedEditImageState): void { /** Hand back unsaved text and settled additions atomically, excluding card-owned image markers; discard pending uploads with their edit, as mobile does. */ export function handOffQueuedEdit(edit: QueuedEditImageState): boolean { - const unchanged = edit.text === queuedEditBaseText(edit.body, edit.kept) - const typing = unchanged ? '' : stripSentImagePromptMarkers(edit.text, edit.kept.length) + const typing = queuedEditHandOffTyping(edit.body, edit) const images = edit.added.flatMap((image) => (image.pending ? [] : [settled(image)])) const text = typing + diff --git a/src/renderer/src/components/native-chat/structured-agent-session-queued-edit-standing.ts b/src/renderer/src/components/native-chat/structured-agent-session-queued-edit-standing.ts deleted file mode 100644 index fd78e4922f2..00000000000 --- a/src/renderer/src/components/native-chat/structured-agent-session-queued-edit-standing.ts +++ /dev/null @@ -1,40 +0,0 @@ -// Compare published queue state with the desired edit body, including Saves whose reply was lost. - -import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' -import type { AgentSessionQueuedMessage } from '../../../../shared/agent-session-wire' -import { agentSessionSendBodyFingerprint } from '../../../../shared/structured-agent-session-send-mutation' -import type { QueuedMessageCard } from './structured-agent-session-queued-cards' - -/** Sent means handed off with this body; holds/differs describe the current card, gone its removal; null queue proves nothing. */ -export type QueuedEditStanding = 'sent' | 'gone' | 'holds' | 'differs' - -export function queuedEditStanding( - view: { - messages: readonly AgentSessionQueuedMessage[] | null - cards: readonly QueuedMessageCard[] - submissions: readonly AgentJournalSubmission[] - }, - sessionId: string, - messageId: string, - /** The fingerprint of the body the edit would save; null when it saves none. */ - desired: string | null -): QueuedEditStanding | null { - if (view.messages === null) { - return null - } - const listed = view.messages.find((entry) => entry.messageId === messageId) - if (!listed || !queuedCardOnScreen(view.cards, messageId)) { - const sent = view.submissions.some( - (entry) => entry.queuedMessageId === messageId && entry.payloadFingerprint === desired - ) - return desired !== null && sent ? 'sent' : 'gone' - } - return agentSessionSendBodyFingerprint(sessionId, listed.body) === desired ? 'holds' : 'differs' -} - -export function queuedCardOnScreen( - cards: readonly QueuedMessageCard[], - messageId: string -): boolean { - return cards.some((card) => card.messageId === messageId) -} diff --git a/src/renderer/src/components/native-chat/structured-agent-session-sending-queued-cards.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-sending-queued-cards.test.ts new file mode 100644 index 00000000000..3cbc0c14a7d --- /dev/null +++ b/src/renderer/src/components/native-chat/structured-agent-session-sending-queued-cards.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from 'vitest' +import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' +import type { StructuredAgentSessionPendingSend } from './structured-agent-session-pending-sends' +import { + pendingQueueSendsOnTheirWay, + pendingSendsOutsideQueuedCards +} from './structured-agent-session-sending-queued-cards' + +function submission(clientMessageId: string, queuedMessageId?: string): AgentJournalSubmission { + return { + clientMessageId, + fence: 1, + payloadFingerprint: 'fingerprint', + dispatchState: 'pending', + providerItemId: null, + reason: null, + submittedAt: 1, + resolvedAt: null, + ...(queuedMessageId !== undefined ? { queuedMessageId } : {}) + } +} + +describe('queue sends on their way', () => { + it('a mid-turn queue send on its way is no bubble; a plain or recorded one is, until its row', () => { + const entry = ( + clientMessageId: string, + overrides: Partial = {} + ): StructuredAgentSessionPendingSend => ({ + clientMessageId, + sessionId: 'session-1', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: clientMessageId }] }, + previewUris: [], + queuedAt: 1, + phase: 'sending', + issued: true, + ...overrides + }) + const ids = (entries: readonly StructuredAgentSessionPendingSend[]): string[] => + entries.map((candidate) => candidate.clientMessageId) + const sends = [ + entry('queued', { delivery: 'queue-if-active' }), + entry('plain'), + entry('recorded', { phase: 'recorded' }) + ] + // Pending queued sends use cards while working; plain/recorded sends remain bubbles until host ownership appears. + expect(ids(pendingSendsOutsideQueuedCards(sends, [], true))).toEqual(['plain', 'recorded']) + expect(ids(pendingSendsOutsideQueuedCards(sends, [], false))).toEqual([ + 'queued', + 'plain', + 'recorded' + ]) + expect(ids(pendingSendsOutsideQueuedCards(sends, ['plain'], false))).toEqual([ + 'queued', + 'recorded' + ]) + }) + it('a sending card ends once the host records the send or hands its card off', () => { + const entry: StructuredAgentSessionPendingSend = { + clientMessageId: 'a', + sessionId: 'session-1', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'a' }] }, + previewUris: [], + queuedAt: 1, + phase: 'sending', + issued: true, + delivery: 'queue-if-active' + } + const onItsWay = (submissions: AgentJournalSubmission[]) => + pendingQueueSendsOnTheirWay([entry], [], true, submissions).map( + (candidate) => candidate.clientMessageId + ) + expect(onItsWay([])).toEqual(['a']) + expect(onItsWay([submission('a')])).toEqual([]) + expect(onItsWay([submission('a-hand-off', 'a')])).toEqual([]) + expect(pendingQueueSendsOnTheirWay([entry], ['a'], true, [])).toEqual([]) + expect(pendingQueueSendsOnTheirWay([entry], [], false, [])).toEqual([]) + expect(pendingQueueSendsOnTheirWay([{ ...entry, phase: 'recorded' }], [], true, [])).toEqual([]) + }) +}) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-sending-queued-cards.ts b/src/renderer/src/components/native-chat/structured-agent-session-sending-queued-cards.ts new file mode 100644 index 00000000000..f54973c9d57 --- /dev/null +++ b/src/renderer/src/components/native-chat/structured-agent-session-sending-queued-cards.ts @@ -0,0 +1,56 @@ +import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' +import { handedOffQueuedMessageIds } from '../../../../shared/structured-agent-session-draft-hand-off' +import { + queuedMessageCardImages, + queuedMessageCardText, + type QueuedMessageCard +} from '../../../../shared/structured-agent-session-queued-cards' +import type { StructuredAgentSessionPendingSend } from './structured-agent-session-pending-sends' + +export function sendingQueuedMessageCards( + entries: readonly StructuredAgentSessionPendingSend[] +): QueuedMessageCard[] { + return entries.map((entry, index) => ({ + messageId: entry.clientMessageId, + // After every card the host holds, in send order. + position: Number.MAX_SAFE_INTEGER - entries.length + index, + text: queuedMessageCardText(entry.body), + ...queuedMessageCardImages(entry.body), + state: 'waiting', + hold: 'sending' + })) +} + +/** Host-owned cards and active queue requests stay out of transcript echoes; recorded sends remain until their row arrives. */ +export function pendingSendsOutsideQueuedCards< + Send extends { clientMessageId: string; delivery?: 'queue-if-active' } +>(pending: readonly Send[], heldIds: readonly string[], isWorking: boolean): readonly Send[] { + const held = new Set(heldIds) + const next = pending.filter( + (entry) => + !held.has(entry.clientMessageId) && !(isWorking && entry.delivery === 'queue-if-active') + ) + return next.length === pending.length ? pending : next +} + +export function pendingQueueSendsOnTheirWay( + pending: readonly StructuredAgentSessionPendingSend[], + heldIds: readonly string[], + isWorking: boolean, + submissions: readonly AgentJournalSubmission[] +): StructuredAgentSessionPendingSend[] { + if (!isWorking) { + return [] + } + const recorded = new Set([ + ...heldIds, + ...handedOffQueuedMessageIds(submissions), + ...submissions.map((submission) => submission.clientMessageId) + ]) + return pending.filter( + (entry) => + entry.phase === 'sending' && + entry.delivery === 'queue-if-active' && + !recorded.has(entry.clientMessageId) + ) +} diff --git a/src/renderer/src/components/native-chat/structured-conversation-command-send.test.ts b/src/renderer/src/components/native-chat/structured-conversation-command-send.test.ts index 29d04b53d08..b3dfc5becef 100644 --- a/src/renderer/src/components/native-chat/structured-conversation-command-send.test.ts +++ b/src/renderer/src/components/native-chat/structured-conversation-command-send.test.ts @@ -7,7 +7,6 @@ import { TUI_AGENT_DISPLAY_NAMES } from '../../../../shared/tui-agent-display-na import { structuredAgentLabel } from '@/lib/structured-agent-session-launch-label' import { sendStructuredConversationCommand, - structuredConversationCommandHold, type StructuredConversationCommandCauses } from './structured-conversation-command-send' @@ -197,39 +196,6 @@ describe('a /compact the host holds in line', () => { }) ).toEqual({ accepted: true, error: null }) }) - - const idle = { - waitsInLine: false, - agentWorking: false, - promptPending: false, - backgroundTasksRunning: false, - sendPending: false - } - - it('is held here only by a message the host does not have yet, or background work', () => { - const inLine = { ...idle, waitsInLine: true } - expect( - structuredConversationCommandHold({ - ...inLine, - agentWorking: true, - promptPending: true - }) - ).toBeNull() - expect(structuredConversationCommandHold({ ...inLine, sendPending: true })).toBe('ahead') - expect(structuredConversationCommandHold({ ...inLine, backgroundTasksRunning: true })).toBe( - 'background' - ) - }) - - it('against a host that cannot hold it, and for /clear, keeps every check in true words', () => { - expect(structuredConversationCommandHold(idle)).toBeNull() - expect(structuredConversationCommandHold({ ...idle, agentWorking: true })).toBe('working') - expect(structuredConversationCommandHold({ ...idle, sendPending: true })).toBe('sending') - expect( - structuredConversationCommandHold({ ...idle, agentWorking: true, sendPending: true }) - ).toBe('working') - expect(structuredConversationCommandHold({ ...idle, promptPending: true })).toBe('prompt') - }) }) function held( diff --git a/src/renderer/src/components/native-chat/structured-conversation-command-send.ts b/src/renderer/src/components/native-chat/structured-conversation-command-send.ts index 66142808aba..546d1de3536 100644 --- a/src/renderer/src/components/native-chat/structured-conversation-command-send.ts +++ b/src/renderer/src/components/native-chat/structured-conversation-command-send.ts @@ -1,20 +1,11 @@ -import { - isAgentSessionConversationCommand, - type AgentSessionConversationCommand, - type AgentSessionConversationCommandResult +import type { + AgentSessionConversationCommand, + AgentSessionConversationCommandResult } from '../../../../shared/agent-session/agent-session-conversation-command' -import { - readWholeAgentSessionFailureFact, - type AgentSessionFailureFact -} from '../../../../shared/agent-session-failure' -import { agentSessionFailureSentence } from '../../../../shared/agent-session-failure-words' -import { translate } from '@/i18n/i18n' +import type { AgentSessionFailureFact } from '../../../../shared/agent-session-failure' import { sayAgentSessionFailureTranslated } from './agent-session-failure-words-text' import { agentSessionWriteNoticeText } from './agent-session-write-notice-text' -import { - agentSessionFailureStatedByStartRow, - structuredAgentSessionStartFailureFacts -} from './structured-agent-session-delivery-notices' +import { structuredAgentSessionStartFailureFacts } from './structured-agent-session-delivery-notices' import { pendingPromptsAllUnanswerableHere } from '../../../../shared/agent-session-approval-subject' import type { StructuredPromptItem } from '../../../../shared/structured-agent-session-message-projection' import type { @@ -22,79 +13,24 @@ import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' import type { StructuredAgentSessionPendingSend } from './structured-agent-session-pending-sends' -import { structuredAgentSessionCommandHostRefusalCause } from '../../../../shared/structured-agent-session-command-refusal-cause' import type { StructuredAgentSessionCommandOutcome, StructuredAgentSessionCommandRefusalCause } from '../../../../shared/structured-agent-session-composer' +import { + conversationCommandReply, + heldConversationCommandParts, + structuredConversationCommandHold, + type StructuredConversationCommandHold +} from '../../../../shared/structured-conversation-command-hold' import type { StructuredAgentSessionWriteOutcome } from './use-structured-agent-session-mutate' -/** Ahead leaves command text untouched behind an unanswered local send; other holds return an explicit refusal. */ -export type StructuredConversationCommandHold = - | 'ahead' - | 'working' - | 'prompt' - | 'background' - | 'sending' - -/** Queued commands wait only for earlier unaccepted sends; ordinary commands wait for all in-flight agent work. */ -export function structuredConversationCommandHold(input: { - /** A /compact the host can hold as a card behind the turn or prompt. */ - waitsInLine: boolean - /** A turn runs, or the chat shows the agent working on a message it has not answered. */ - agentWorking: boolean - promptPending: boolean - backgroundTasksRunning: boolean - sendPending: boolean -}): StructuredConversationCommandHold | null { - if (input.backgroundTasksRunning) { - return 'background' - } - if (input.waitsInLine) { - return input.sendPending ? 'ahead' : null - } - if (input.promptPending) { - return 'prompt' - } - if (input.agentWorking) { - return 'working' - } - if (input.sendPending) { - return 'sending' - } - return null -} - type CommandOutcome = Omit export type StructuredConversationCommandCauses = Readonly< Record > -function heldCommandText( - command: AgentSessionConversationCommand, - hold: Exclude -): string { - const clear = command === 'clear' - switch (hold) { - case 'prompt': - return agentSessionWriteNoticeText([clear ? 'clearAfterAnswer' : 'compactAfterAnswer']) - case 'working': - return agentSessionWriteNoticeText([ - 'agentStillWorking', - clear ? 'runClearWhenDone' : 'runCompactWhenDone' - ]) - case 'sending': - return agentSessionWriteNoticeText([clear ? 'clearAfterSending' : 'compactAfterSending']) - case 'background': - break - } - return translate( - 'components.native-chat.conversationCommand.pendingWork', - 'Wait for pending work and messages to finish before using this command.' - ) -} - export async function sendStructuredConversationCommand(input: { command: AgentSessionConversationCommand agentName: string @@ -118,14 +54,20 @@ export async function sendStructuredConversationCommand(input: { }) // A command on its way is the agent's work in flight. if (input.pending.current) { - return { accepted: false, error: heldCommandText(input.command, 'working') } + return { + accepted: false, + error: agentSessionWriteNoticeText(heldConversationCommandParts(input.command, 'working')) + } } // The message ahead reads as sending, and Send is busy until the host has it; nothing is armed. if (input.hold === 'ahead') { return { accepted: false, error: null } } if (input.hold !== null) { - return refused(heldCommandText(input.command, input.hold), input.hold) + return refused( + agentSessionWriteNoticeText(heldConversationCommandParts(input.command, input.hold)), + input.hold + ) } input.pending.current = true try { @@ -137,20 +79,12 @@ export async function sendStructuredConversationCommand(input: { if (outcome.kind === 'dropped') { return { accepted: false, error: null } } - const { value } = outcome - // Suppress start failures already shown in this chat; clear/new-command failures may belong elsewhere and need host wording. - if ( - isAgentSessionConversationCommand(value.command) && - value.command !== 'clear' && - agentSessionFailureStatedByStartRow(value.failure, input.startFailures()) - ) { - return { accepted: false, error: null } - } - const error = conversationCommandFailureText(value, input.agentName) - if (value.state === 'completed' && !error) { - return { accepted: true, error: null } - } - return refused(error, structuredAgentSessionCommandHostRefusalCause(value)) + const reply = conversationCommandReply(outcome.value, { + agentName: input.agentName, + startFailures: input.startFailures(), + say: sayAgentSessionFailureTranslated + }) + return reply.accepted ? { accepted: true, error: null } : refused(reply.error, reply.cause) } finally { input.pending.current = false } @@ -223,22 +157,3 @@ export function structuredConversationCommandRunner(args: { } return { runConversationCommand: run, commandRefusalCauses: causes } } - -/** Translate complete known facts/commands; preserve host text for older/newer payloads this client cannot interpret. */ -function conversationCommandFailureText( - result: AgentSessionConversationCommandResult, - agentName: string -): string | null { - const fact = isAgentSessionConversationCommand(result.command) - ? readWholeAgentSessionFailureFact(result.failure) - : undefined - if (!fact) { - return result.error ?? null - } - return agentSessionFailureSentence( - fact, - 'row', - { agentName, command: result.command }, - sayAgentSessionFailureTranslated - ) -} diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-catalog.ts b/src/renderer/src/components/native-chat/use-native-chat-composer-catalog.ts index 019e0628008..02cd36af679 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-catalog.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-catalog.ts @@ -1,14 +1,11 @@ import { useMemo } from 'react' import type { AgentType } from '../../../../shared/agent-status-types' -import { - getNativeChatAgentProfile, - getVerifiedNativeChatCommands -} from '../../../../shared/native-chat-agent-profiles' -import { - sessionReportedSkills, - type NativeChatSessionSkill, - type SlashCommandSuggestion +import { getVerifiedNativeChatCommands } from '../../../../shared/native-chat-agent-profiles' +import type { + NativeChatSessionSkill, + SlashCommandSuggestion } from '../../../../shared/native-chat/input/native-chat-slash-commands' +import { nativeChatSessionSkills } from '../../../../shared/native-chat-skill-visibility' import { structuredSessionSlashCommands } from '../../../../shared/structured-agent-session-composer' import { withNativeChatResumeCommand } from './use-native-chat-resume-command' import type { NativeChatStructuredComposerTransport } from './native-chat-composer-types' @@ -26,7 +23,6 @@ export function useNativeChatComposerCatalog( const structured = Boolean(structuredTransport) const reported = structuredTransport?.sessionCommands const conversationCommands = structuredTransport?.conversationCommands - const keepsScannedSkills = getNativeChatAgentProfile(agent)?.keepsScannedSkills === true const agentCommands = useMemo( () => structured @@ -36,10 +32,6 @@ export function useNativeChatComposerCatalog( : getVerifiedNativeChatCommands(agent), [agent, conversationCommands, reported, structured] ) - const sessionSkills = useMemo( - () => - reported !== undefined && !keepsScannedSkills ? sessionReportedSkills(reported) : undefined, - [keepsScannedSkills, reported] - ) + const sessionSkills = useMemo(() => nativeChatSessionSkills(agent, reported), [agent, reported]) return { agentCommands, sessionSkills } } diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-keydown.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-composer-keydown.test.tsx index 25bd2195b82..53920e9935f 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-keydown.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-keydown.test.tsx @@ -3,8 +3,11 @@ import { cleanup, fireEvent, render, renderHook, screen } from '@testing-library/react' import { useState, type KeyboardEventHandler } from 'react' import { describe, expect, it, vi } from 'vitest' -import { applyPickerSuggestion } from '../../../../shared/native-chat/input/native-chat-picker-triggers' -import { deriveComposerAutocomplete, type ComposerAutocomplete } from './native-chat-composer-state' +import { + applyPickerSuggestion, + deriveComposerAutocomplete, + type ComposerAutocomplete +} from '../../../../shared/native-chat-composer-autocomplete' import { getNativeChatAgentProfile } from '../../../../shared/native-chat-agent-profiles' import { useNativeChatRecallPosition } from './use-native-chat-composer-recall' import type { NativeChatMessage } from '../../../../shared/native-chat-types' diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-keydown.ts b/src/renderer/src/components/native-chat/use-native-chat-composer-keydown.ts index 89bc3462b3b..d6e54dade27 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-keydown.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-keydown.ts @@ -5,8 +5,10 @@ import { type KeyboardEventHandler, type SetStateAction } from 'react' -import type { ComposerAutocomplete } from './native-chat-composer-state' -import type { NativeChatPickerItem } from '../../../../shared/native-chat/input/native-chat-picker-items' +import type { + ComposerAutocomplete, + NativeChatPickerItem +} from '../../../../shared/native-chat-composer-autocomplete' import { isMacPlatform } from './native-chat-shortcut' import type { NativeChatMentionFiles } from './use-native-chat-mention-files' import { diff --git a/src/renderer/src/components/native-chat/use-native-chat-hook-status.ts b/src/renderer/src/components/native-chat/use-native-chat-hook-status.ts index d973c667231..7ccd49b9afd 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-hook-status.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-hook-status.ts @@ -1,23 +1,9 @@ import { useAppStore } from '../../store' -import { isExplicitAgentStatusFresh } from '@/lib/agent-status' +import type { AgentStatusState } from '../../../../shared/agent-status-types' import { - AGENT_STATUS_STALE_AFTER_MS, - type AgentStatusEntry, - type AgentStatusState -} from '../../../../shared/agent-status-types' - -/** Restored nonterminal rows need a live hook confirmation before they may keep chat busy. */ -export function resolveNativeChatHookState( - entry: - | Pick - | undefined, - now = Date.now() -): AgentStatusState | null { - if (!entry || !isExplicitAgentStatusFresh(entry, now, AGENT_STATUS_STALE_AFTER_MS)) { - return null - } - return entry.state === 'working' && entry.workingMode === 'monitoring' ? null : entry.state -} + nativeChatHookHasWorkingSubagents, + resolveNativeChatHookState +} from '../../../../shared/native-chat/native-chat-live-status' export function useNativeChatHookStatus( paneKey: string @@ -26,23 +12,14 @@ export function useNativeChatHookStatus( const agentStatusEpoch = useAppStore((store) => store.agentStatusEpoch) void agentStatusEpoch // Primitive selectors avoid rerenders for unrelated status changes. - const state = useAppStore((store) => { - const entry = store.agentStatusByPaneKey[paneKey] - return resolveNativeChatHookState(entry) - }) + const state = useAppStore((store) => + resolveNativeChatHookState(store.agentStatusByPaneKey[paneKey]) + ) const stateStartedAt = useAppStore( (store) => store.agentStatusByPaneKey[paneKey]?.stateStartedAt ?? null ) - // Only children started in this parent epoch may delay settlement; missed stops and reused panes leave stale roster entries. - const hasWorkingSubagents = useAppStore((store) => { - const entry = store.agentStatusByPaneKey[paneKey] - const epochStart = entry?.stateStartedAt - return ( - entry?.subagents?.some( - (subagent) => - subagent.state === 'working' && (epochStart == null || subagent.startedAt >= epochStart) - ) ?? false - ) - }) + const hasWorkingSubagents = useAppStore((store) => + nativeChatHookHasWorkingSubagents(store.agentStatusByPaneKey[paneKey]) + ) return [state, stateStartedAt, hasWorkingSubagents] } diff --git a/src/renderer/src/components/native-chat/use-native-chat-interactive-send.ts b/src/renderer/src/components/native-chat/use-native-chat-interactive-send.ts index 2a286d51b8f..843091d67bc 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-interactive-send.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-interactive-send.ts @@ -5,6 +5,7 @@ import { sendRuntimePtyInputVerified } from '@/runtime/runtime-terminal-verified import type { AgentType } from '../../../../shared/native-chat-types' import { nativeChatAskRequiresTerminal, + nativeChatStopSendsSecondEscape, resolveNativeChatTranscriptAgent, shouldStepNativeChatAskAnswer } from '../../../../shared/native-chat-agent-support' @@ -151,8 +152,8 @@ export function useNativeChatInteractiveSend( const cancel = useCallback(() => { cancelInFlight() - if (resolveNativeChatTranscriptAgent(agent) === 'opencode' && targetPtyId) { - // OpenCode confirms interruption with a second Escape; pace writes like mobile Stop. + if (nativeChatStopSendsSecondEscape(agent) && targetPtyId) { + // Pace the second Escape like an answer step so the TUI renders its confirm first. inFlightRef.current = sendNativeChatAskAnswer(targetPtyId, [{ raw: ESC }, { raw: ESC }]) return } diff --git a/src/renderer/src/components/native-chat/use-native-chat-launch-draft-adoption.ts b/src/renderer/src/components/native-chat/use-native-chat-launch-draft-adoption.ts index 2aafa1c897d..c583a75048b 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-launch-draft-adoption.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-launch-draft-adoption.ts @@ -6,7 +6,7 @@ import { launchDraftResolvedByTranscript, nativeChatLaunchDraftTurnBaseline, type NativeChatLaunchDraftTurnBaseline -} from './native-chat-launch-draft-resolution' +} from '../../../../shared/native-chat-launch-draft-resolution' export function useNativeChatLaunchDraftSignal(args: { terminalTabId: string diff --git a/src/renderer/src/components/native-chat/use-native-chat-live-session.ts b/src/renderer/src/components/native-chat/use-native-chat-live-session.ts index 4df2e2082bd..f986f4a19e7 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-live-session.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-live-session.ts @@ -9,7 +9,7 @@ import { createNativeChatMerger, replaceList } from '../../../../shared/native-chat-merge' -import { mergeNativeChatLiveSession, nativeChatHookAwaitsInput } from './native-chat-live-status' +import { mergeNativeChatLiveSession } from '../../../../shared/native-chat/native-chat-live-status' import { hasMoreNativeChatHistory, NATIVE_CHAT_INITIAL_LIMIT, @@ -316,11 +316,6 @@ export function useNativeChatLiveSession( }) return { ...session, - hookAwaitingInput: nativeChatHookAwaitsInput( - hookState, - hookStateStartedAt, - transcriptLifecycle - ), hasMore, loadingEarlier, olderHistoryGeneration, diff --git a/src/renderer/src/components/native-chat/use-native-chat-picker-command-dispatch.ts b/src/renderer/src/components/native-chat/use-native-chat-picker-command-dispatch.ts index e1ef9cae9c0..0e492b320a5 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-picker-command-dispatch.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-picker-command-dispatch.ts @@ -10,7 +10,7 @@ import { nativeChatComposerTargetIsRemote, type NativeChatResolvedTarget } from './native-chat-composer-target' -import type { NativeChatPickerItem } from '../../../../shared/native-chat/input/native-chat-picker-items' +import type { NativeChatPickerItem } from '../../../../shared/native-chat-composer-autocomplete' import type { NativeChatSendLifecycle } from './use-native-chat-send-lifecycle' import type { NativeChatPtySessionOptionsSurface } from '../../../../shared/native-chat-pty-session-options' import { diff --git a/src/renderer/src/components/native-chat/use-native-chat-picker-state.ts b/src/renderer/src/components/native-chat/use-native-chat-picker-state.ts index 5ee339ca4e3..1ce60ec5e8e 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-picker-state.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-picker-state.ts @@ -16,25 +16,22 @@ import { } from 'react' import type { AgentType } from '../../../../shared/agent-status-types' import { getNativeChatAgentProfile } from '../../../../shared/native-chat-agent-profiles' -import { - classifyNativeChatSend, - type NativeChatSendClassification, - type NativeChatSessionSkill, - type SlashCommandSuggestion +import type { + NativeChatSessionSkill, + SlashCommandSuggestion } from '../../../../shared/native-chat/input/native-chat-slash-commands' -import { isStructuredAgentSessionComposerCommand } from '../../../../shared/structured-agent-session-composer' import { applyMentionSuggestion, + applyPickerSuggestion, + classifyNativeChatSend, deriveComposerAutocomplete, editReplacesTriggerToken, isSkillPickerTriggered, - type ComposerAutocomplete -} from './native-chat-composer-state' -import { applyPickerSuggestion } from '../../../../shared/native-chat/input/native-chat-picker-triggers' -import { - classifyPickerCommands, - type NativeChatPickerItem -} from '../../../../shared/native-chat/input/native-chat-picker-items' + type ComposerAutocomplete, + type NativeChatPickerItem, + type NativeChatSendClassification +} from '../../../../shared/native-chat-composer-autocomplete' +import { nativeChatPickerCommands } from '../../../../shared/native-chat-skill-visibility' import { useNativeChatSkills } from './use-native-chat-skills' import { emitNativeChatPickerItemAccepted, @@ -101,13 +98,8 @@ export function useNativeChatPickerState(args: { const skillPickerTriggered = !disabled && isSkillPickerTriggered(draft.slice(0, caret), profile) const discovery = useNativeChatSkills(agent, terminalTabId, skillPickerTriggered) const pickerCommands = useMemo( - () => - classifyPickerCommands(agentCommands, discovery.skills, { - // A chat that keeps scanned skills (OpenCode) may report each as a command too. - reportMayListSkills: profile?.keepsScannedSkills === true, - isSendCommand: (name) => isStructuredAgentSessionComposerCommand(`/${name}`, agent) - }), - [agent, agentCommands, discovery.skills, profile] + () => nativeChatPickerCommands(agent, agentCommands, discovery.skills), + [agent, agentCommands, discovery.skills] ) const listboxId = `native-chat-picker-${useId().replaceAll(':', '')}` const dismissalContext = `${draftScopeKey}:${agent}` diff --git a/src/renderer/src/components/native-chat/use-native-chat-resume-command.ts b/src/renderer/src/components/native-chat/use-native-chat-resume-command.ts index 0bde98a33c7..31551e877ef 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-resume-command.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-resume-command.ts @@ -6,7 +6,7 @@ import type { AgentType } from '../../../../shared/agent-status-types' import { isAiVaultAgent } from '../../../../shared/ai-vault-types' import { selectAiVaultPanelWorkspace } from '../right-sidebar/ai-vault-panel-workspace' import { openAgentSessionHistoryModal } from '../right-sidebar/agent-session-history-modal' -import type { NativeChatPickerItem } from '../../../../shared/native-chat/input/native-chat-picker-items' +import type { NativeChatPickerItem } from '../../../../shared/native-chat-composer-autocomplete' import type { NativeChatPickerState } from './use-native-chat-picker-state' import { findNativeChatTabOwnerWorktreeId } from './native-chat-file-link' import type { NativeChatComposerInput } from './native-chat-composer-input' diff --git a/src/renderer/src/components/native-chat/use-native-chat-skills.react.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-skills.react.test.tsx index f28a7b0a7b7..f473b46eeb3 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-skills.react.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-skills.react.test.tsx @@ -5,7 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { NativeChatSkillDiscovery } from './use-native-chat-skills' import { getNativeChatAgentProfile } from '../../../../shared/native-chat-agent-profiles' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' -import { isSkillPickerTriggered } from './native-chat-composer-state' +import { isSkillPickerTriggered } from '../../../../shared/native-chat-composer-autocomplete' const mocks = vi.hoisted(() => ({ callRuntimeRpc: vi.fn(), diff --git a/src/renderer/src/components/native-chat/use-native-chat-skills.test.ts b/src/renderer/src/components/native-chat/use-native-chat-skills.test.ts index 3a0df082073..1aa2a77e843 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-skills.test.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-skills.test.ts @@ -1,5 +1,4 @@ import { describe, expect, it } from 'vitest' -import type { DiscoveredSkill, SkillDiscoveryResult } from '../../../../shared/skills' import { FLOATING_TERMINAL_WORKTREE_ID, getDefaultSettings } from '../../../../shared/constants' import type { Tab } from '../../../../shared/tab-types' import { @@ -7,120 +6,11 @@ import { type NativeChatSkillStateInputs } from './native-chat-skill-discovery-context' import { - isNativeChatSkillForAgent, resolveNativeChatSkillDiscoveryContext, resolveNativeChatSkillDiscoveryCwd } from './use-native-chat-skills' import { worktreeFixture } from './native-chat-workspace-test-fixtures' -function skill(overrides: Partial): DiscoveredSkill { - return { - id: overrides.name ?? 'skill', - name: 'agent-browser', - description: null, - providers: ['agent-skills'], - sourceKind: 'home', - sourceLabel: 'Agent skills home', - rootPath: '/Users/test/.agents/skills', - directoryPath: '/Users/test/.agents/skills/agent-browser', - skillFilePath: '/Users/test/.agents/skills/agent-browser/SKILL.md', - installed: true, - updatedAt: null, - ...overrides - } -} - -function discovery(owner: string | null, rootPath = '/Users/test/.agents/skills') { - return { - sources: [ - { - id: 'source', - label: 'Source', - path: rootPath, - sourceKind: 'home' as const, - providers: ['agent-skills' as const], - owner, - exists: true - } - ] - } satisfies Pick -} - -describe('isNativeChatSkillForAgent', () => { - it('shows Codex-native and generic agent skills for Codex chat', () => { - expect(isNativeChatSkillForAgent('codex', skill({ providers: ['codex'] }))).toBe(true) - expect(isNativeChatSkillForAgent('codex', skill({ providers: ['agent-skills'] }))).toBe(true) - }) - - it('keeps Claude skills out of the Codex skill picker', () => { - expect(isNativeChatSkillForAgent('codex', skill({ providers: ['claude'] }))).toBe(false) - }) - - it('does not enable skill autocomplete for other agents yet', () => { - expect(isNativeChatSkillForAgent('claude', skill({ providers: ['agent-skills'] }))).toBe(false) - }) - - it('uses explicit source ownership and keeps shared roots visible', () => { - const shared = discovery(null) - expect(isNativeChatSkillForAgent('codex', skill({}), shared)).toBe(true) - expect(isNativeChatSkillForAgent('claude', skill({}), shared)).toBe(true) - expect(isNativeChatSkillForAgent('grok', skill({}), shared)).toBe(true) - }) - - it('aliases OpenClaude to Claude roots without exposing them to other agents', () => { - const claude = discovery('claude') - expect(isNativeChatSkillForAgent('claude', skill({}), claude)).toBe(true) - expect(isNativeChatSkillForAgent('openclaude', skill({}), claude)).toBe(true) - expect(isNativeChatSkillForAgent('codex', skill({}), claude)).toBe(false) - expect(isNativeChatSkillForAgent('grok', skill({}), claude)).toBe(false) - }) - - it('shows OpenCode the Claude skill folders it reads, and not Claude plugins', () => { - const claude = discovery('claude') - expect(isNativeChatSkillForAgent('opencode', skill({}), claude)).toBe(true) - expect(isNativeChatSkillForAgent('opencode2', skill({}), claude)).toBe(true) - const plugin = { sources: [{ ...claude.sources[0], sourceKind: 'plugin' as const }] } - expect(isNativeChatSkillForAgent('opencode2', skill({}), plugin)).toBe(false) - }) - - it('grants visibility through any contributing root, not just the dedup survivor', () => { - const result = { - sources: [ - { - id: 'codex-home', - label: 'Codex home', - path: '/Users/test/.codex/skills', - sourceKind: 'home' as const, - providers: ['codex' as const], - owner: 'codex', - exists: true - }, - { - id: 'shared-home', - label: 'Agent skills home', - path: '/Users/test/.agents/skills', - sourceKind: 'home' as const, - providers: ['agent-skills' as const], - owner: null, - exists: true - } - ] - } satisfies Pick - // Canonical dedup may list the Codex root first; shared-root reachability must still grant other agents visibility. - const merged = skill({ - rootPath: '/Users/test/.codex/skills', - rootPaths: ['/Users/test/.codex/skills', '/Users/test/.agents/skills'] - }) - expect(isNativeChatSkillForAgent('claude', merged, result)).toBe(true) - expect(isNativeChatSkillForAgent('codex', merged, result)).toBe(true) - const codexOnly = skill({ - rootPath: '/Users/test/.codex/skills', - rootPaths: ['/Users/test/.codex/skills'] - }) - expect(isNativeChatSkillForAgent('claude', codexOnly, result)).toBe(false) - }) -}) - describe('resolveNativeChatSkillDiscoveryCwd', () => { const catalog = { floatingWorkspacePath: null, diff --git a/src/renderer/src/components/native-chat/use-native-chat-skills.ts b/src/renderer/src/components/native-chat/use-native-chat-skills.ts index b05e681968d..b5f116b7a27 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-skills.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-skills.ts @@ -3,10 +3,8 @@ import { useShallow } from 'zustand/react/shallow' import { useAppStore } from '../../store' import type { AgentType } from '../../../../shared/agent-status-types' import type { DiscoveredSkill, SkillDiscoveryResult } from '../../../../shared/skills' -import { - getNativeChatAgentProfile, - readsSkillSource -} from '../../../../shared/native-chat-agent-profiles' +import { getNativeChatAgentProfile } from '../../../../shared/native-chat-agent-profiles' +import { isNativeChatSkillForAgent } from '../../../../shared/native-chat-skill-visibility' import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' import { emitNativeChatSkillDiscovery } from '@/lib/native-chat-telemetry' import { @@ -61,29 +59,6 @@ const MISSING_CONTEXT_STATE: StoredDiscoveryState = { const selectNoSkillStateInputs = (): null => null const inFlightDiscovery = new Map>() -export function isNativeChatSkillForAgent( - agent: AgentType, - skill: DiscoveredSkill, - result?: Pick -): boolean { - const profile = getNativeChatAgentProfile(agent) - if (!profile) { - return false - } - if (!result) { - return ( - agent === 'codex' && - (skill.providers.includes('codex') || skill.providers.includes('agent-skills')) - ) - } - // Canonical dedup may hide alternate symlink roots; any shared/agent root grants visibility regardless of scan order. - const rootPaths = skill.rootPaths?.length ? skill.rootPaths : [skill.rootPath] - return rootPaths.some((rootPath) => { - const source = result.sources.find((entry) => entry.path === rootPath) - return source !== undefined && readsSkillSource(profile, source) - }) -} - export function useNativeChatSkills( agent: AgentType, terminalTabId: string, diff --git a/src/renderer/src/components/native-chat/use-native-chat-terminal-turn-timing.ts b/src/renderer/src/components/native-chat/use-native-chat-terminal-turn-timing.ts index 811c41c1141..bccd1957bc5 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-terminal-turn-timing.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-terminal-turn-timing.ts @@ -6,9 +6,10 @@ import { nativeChatHookLatestTurnWorkedSeconds, nativeChatHookTurnStartedAt, nativeChatLatestTurnId, + nativeChatTerminalSettledTurns, nativeChatTranscriptSettledTurns -} from './native-chat-terminal-turn' -import { resolveNativeChatHookState } from './use-native-chat-hook-status' +} from '../../../../shared/native-chat/native-chat-terminal-turn' +import { resolveNativeChatHookState } from '../../../../shared/native-chat/native-chat-live-status' /** Keep host live/latest-turn timing and historical transcript durations across pane remounts. */ export function useNativeChatTerminalTurnTiming( @@ -28,16 +29,16 @@ export function useNativeChatTerminalTurnTiming( }) const transcriptSettled = useMemo(() => nativeChatTranscriptSettledTurns(messages), [messages]) const latestTurnId = useMemo(() => nativeChatLatestTurnId(messages), [messages]) - const settledTurns = useMemo(() => { - if (turnActive || latestWorkedSeconds === undefined || latestTurnId === null) { - return transcriptSettled - } - return new Map(transcriptSettled).set( - latestTurnId, - latestWorkedSeconds === null || turnStartedAt === null - ? null - : { startedAt: turnStartedAt, workedSeconds: latestWorkedSeconds } - ) - }, [latestTurnId, latestWorkedSeconds, transcriptSettled, turnActive, turnStartedAt]) + const settledTurns = useMemo( + () => + nativeChatTerminalSettledTurns({ + transcriptSettled, + latestTurnId, + latestWorkedSeconds, + turnStartedAt, + turnActive + }), + [latestTurnId, latestWorkedSeconds, transcriptSettled, turnActive, turnStartedAt] + ) return { workingStartedAt: turnActive ? turnStartedAt : null, settledTurns } } diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-mutate.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-mutate.ts index 4c1ee7b55a3..7a2ce6c34ca 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-mutate.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-mutate.ts @@ -21,6 +21,10 @@ import { } from '@/runtime/structured-agent-session-client' import { structuredAgentSessionHostKey } from '@/runtime/structured-agent-session-host-capability' import { agentSessionWriteFailureText } from './agent-session-write-notice-text' +import { + joinStructuredAgentSessionStop, + structuredAgentSessionStopIsJoinable +} from '../../../../shared/structured-agent-session-stop-join' export type StructuredAgentSessionWriteOutcome = | { kind: 'done'; value: T } @@ -52,16 +56,6 @@ export type AdmitStructuredAgentSessionAction = () => StructuredAgentSessionActi /** Captures the issuing pane visit for late presentation, never command or draft settlement. */ export type CaptureStructuredAgentSessionPresentation = () => () => boolean -/** Only targeted Stops are joinable; untargeted presses stop whatever is running when each reaches the host. */ -function namesWhatItStops(fingerprintMethod: string, fields: Record): boolean { - if (fingerprintMethod !== 'agentSession.cancel') { - return false - } - return fields.scope === 'background-tasks' - ? fields.taskId !== undefined - : fields.turnId !== undefined -} - function notDone( failure: AgentSessionWriteFailure, fingerprintMethod: string, @@ -175,27 +169,17 @@ export function useStructuredAgentSessionMutate(args: { fields: Record ): Promise> => { if ( - !namesWhatItStops(fingerprintMethod, fields) || + !structuredAgentSessionStopIsJoinable(fingerprintMethod, fields) || (await supportsStructuredAgentSessionQuietRepeatedStop(target)) ) { return send(method, fingerprintMethod, fields, pressedFence) } // Older hosts lack quiet repeated Stops; retain joining until none remain supported. const key = `${hostKey}:${sessionId}:${method}:${JSON.stringify(fields)}` - const joined = inFlightStops.current.get(key) - if (joined) { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the key names the method and fields, so the joined promise is this very call and settles to its T. - return joined as Promise> - } - const stopping = send(method, fingerprintMethod, fields, pressedFence) - inFlightStops.current.set(key, stopping) - // Gone once it settles, so the next press is a new Stop. - void stopping.finally(() => { - if (inFlightStops.current.get(key) === stopping) { - inFlightStops.current.delete(key) - } - }) - return stopping + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the key names the method and fields, so the joined promise is this very call and settles to its T. + return joinStructuredAgentSessionStop(inFlightStops.current, key, () => + send(method, fingerprintMethod, fields, pressedFence) + ) as Promise> } const mutate: StructuredAgentSessionMutate = async (...writeArgs: WriteArgs) => { const outcome = await execute(...writeArgs) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-edit.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-edit.test.tsx index 8f34387ca4a..eb20872d2d6 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-edit.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-edit.test.tsx @@ -14,7 +14,7 @@ import type { } from '../../../../shared/agent-session-wire' import { agentSessionSendBodyFingerprint } from '../../../../shared/structured-agent-session-send-mutation' import { useStructuredAgentSessionQueuedEdit } from './use-structured-agent-session-queued-edit' -import { projectQueuedMessageCards } from './structured-agent-session-queued-cards' +import { projectQueuedMessageCards } from '../../../../shared/structured-agent-session-queued-cards' import { clearNativeChatDraftCacheForTests, readNativeChatDraftCache, diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-edit.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-edit.ts index 4ef6eef0fd2..793e6e6de24 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-edit.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-edit.ts @@ -11,13 +11,16 @@ import type { import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' import { agentSessionSendBodyFingerprint } from '../../../../shared/structured-agent-session-send-mutation' import { + queuedCardOnScreen, + queuedEditChangedNotice, queuedEditDirty, queuedEditInput, queuedEditSaveHeld, + queuedEditStanding, queuedMessageEditableText, queuedMessageWithEdit } from '../../../../shared/queued-message-edit' -import { queuedEditChangedNotice, queuedEditNotice } from './queued-message-edit-notices' +import { queuedEditNotice } from './queued-message-edit-notices' import { handOffQueuedEdit, queuedEditImageFields, @@ -31,11 +34,7 @@ import { startQueuedEditLease, type QueuedEditLease } from './structured-agent-session-queued-edit-lease' -import type { QueuedMessageCard } from './structured-agent-session-queued-cards' -import { - queuedCardOnScreen, - queuedEditStanding -} from './structured-agent-session-queued-edit-standing' +import type { QueuedMessageCard } from '../../../../shared/structured-agent-session-queued-cards' import type { StructuredAgentSessionWrite } from './use-structured-agent-session-mutate' export type QueuedEditTransport = { @@ -251,7 +250,11 @@ export function useStructuredAgentSessionQueuedEdit(args: { gone(edit) } else if (status === 'changed') { // Preserve typing after a competing edit, then reopen on the newer text; removal alone needs no handoff. - toast.error(queuedEditChangedNotice(handOffQueuedEdit(edit), latest.current.promptOpen)) + toast.error( + queuedEditNotice( + queuedEditChangedNotice(handOffQueuedEdit(edit), latest.current.promptOpen) + ) + ) reopen.current = { scope, messageId: edit.messageId, stale: edit.baseFingerprint } close() } else { diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.ts index 3a5cf8f2bbc..b9269eb5861 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.ts @@ -25,11 +25,13 @@ import type { StructuredAgentSessionPendingSend } from './structured-agent-sessi import { newestSteerableQueuedMessageCard, projectQueuedMessageCards, - pendingQueueSendsOnTheirWay, queuedMessagesQueuePause, - sendingQueuedMessageCards, type QueuedMessageCard -} from './structured-agent-session-queued-cards' +} from '../../../../shared/structured-agent-session-queued-cards' +import { + pendingQueueSendsOnTheirWay, + sendingQueuedMessageCards +} from './structured-agent-session-sending-queued-cards' import type { AdmitStructuredAgentSessionAction, CaptureStructuredAgentSessionPresentation, diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-reorder.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-reorder.test.tsx index a7fac99e7f9..422180751aa 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-reorder.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-reorder.test.tsx @@ -3,7 +3,7 @@ import { act, cleanup, renderHook } from '@testing-library/react' import { afterEach, expect, it, vi } from 'vitest' import { toast } from 'sonner' import { useStructuredAgentSessionQueuedReorder } from './use-structured-agent-session-queued-reorder' -import type { QueuedMessageCard } from './structured-agent-session-queued-cards' +import type { QueuedMessageCard } from '../../../../shared/structured-agent-session-queued-cards' import { useStructuredAgentSessionMutate, type StructuredAgentSessionWrite diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-reorder.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-reorder.ts index 2fe3d6b86a3..b7f8e01e72a 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-reorder.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-reorder.ts @@ -3,7 +3,7 @@ import { toast } from 'sonner' import { translate } from '@/i18n/i18n' import type { AgentSessionQueuedMessageMoveResult } from '../../../../shared/agent-session-wire' import { structuredAgentSessionHostKey } from '@/runtime/structured-agent-session-host-capability' -import type { QueuedMessageCard } from './structured-agent-session-queued-cards' +import type { QueuedMessageCard } from '../../../../shared/structured-agent-session-queued-cards' import type { QueuedEditTransport } from './use-structured-agent-session-queued-edit' import type { CaptureStructuredAgentSessionPresentation } from './use-structured-agent-session-mutate' diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-sends.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-sends.ts index e4fb1ed39e5..e9e257e0c42 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-sends.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-sends.ts @@ -25,7 +25,7 @@ import { } from './structured-agent-session-pending-sends' import { noteStructuredAgentSessionFence } from './structured-agent-session-send-attempt' import { recoverLegacyStructuredAgentSessionOutbox } from './structured-agent-session-legacy-outbox' -import { pendingSendsOutsideQueuedCards } from './structured-agent-session-queued-cards' +import { pendingSendsOutsideQueuedCards } from './structured-agent-session-sending-queued-cards' /** The open chat's view of its sends: they live in the sender, which works without this view. */ export function useStructuredAgentSessionSends(args: { diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-transport-state.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-transport-state.ts index 86e48c51a54..035dfa2a6fa 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-transport-state.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-transport-state.ts @@ -1,7 +1,8 @@ import { useMemo } from 'react' import { runningStructuredAgentSessionTurnId, - structuredAgentSessionHostSaysWorking + structuredAgentSessionHostSaysWorking, + structuredAgentSessionQueueSendsNext } from '../../../../shared/structured-agent-session-live-turn' import { isStructuredAgentSessionMainAgentWorking } from '../../../../shared/structured-agent-session-main-agent-working' import type { StructuredAgentSessionState } from '../../../../shared/structured-agent-session-reducer' @@ -43,9 +44,10 @@ export function useStructuredAgentSessionTransportState( const isWorking = structuredAgentSessionHostSaysWorking(hostWorking, () => isStructuredAgentSessionMainAgentWorking(turnId, current.submissions, fence) ) - const nextQueuedMessageId = (enabled ? state.nextQueuedMessageId : null) ?? null - // Keep working through the gap between turn-end/Resume and the host’s next queued-send update. - const queueSendsNext = nextQueuedMessageId !== null && !isWorking + const queueSendsNext = structuredAgentSessionQueueSendsNext( + enabled ? state.nextQueuedMessageId : null, + isWorking + ) const turnActivity = useMemo( () => selectStructuredAgentTurnActivity(journalItems, turnId, enabled ? state.activity : null), [enabled, journalItems, state.activity, turnId] diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.ts b/src/renderer/src/components/native-chat/use-structured-agent-session.ts index 1ad7b9b7101..ca771eb1a2c 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.ts @@ -15,7 +15,7 @@ import { useStructuredAgentSessionHostEditsQueuedImages, useStructuredAgentSessionHostQueuesMessagesState } from '@/runtime/structured-agent-session-host-capability' -import { structuredAgentSessionStopControl } from './structured-agent-session-stop-control' +import { structuredAgentSessionStopControl } from '../../../../shared/structured-agent-session-stop-control' import type { AgentSessionPromptResponse } from '../../../../shared/agent-session-question-answer' import { respondToStructuredAgentSessionPrompt, @@ -35,12 +35,9 @@ import { useStructuredAgentSessionContextUsage } from './use-structured-agent-se import { useStructuredAgentSessionRailOutline } from './use-structured-agent-session-rail-outline' import { useStructuredAgentSessionQueuedMessages } from './use-structured-agent-session-queued-messages' import { structuredConversationCommandRunner } from './structured-conversation-command-send' -import { - commandCardWaiting, - pendingSendsOutsideQueuedCards -} from './structured-agent-session-queued-cards' +import { structuredAgentSessionQueueEnabled } from '../../../../shared/structured-agent-session-queued-cards' +import { pendingSendsOutsideQueuedCards } from './structured-agent-session-sending-queued-cards' import { hostStatesTurnScopes } from '../../../../shared/native-chat-turn-membership' -import { pendingPromptsAllUnanswerableHere } from '../../../../shared/agent-session-approval-subject' import { withNativeChatCutTurnNotices } from '../../../../shared/native-chat-cut-turn-notice' import { useNativeChatContextClearFloor } from './use-native-chat-context-clear-floor' import { useStructuredAgentSessionRewind } from './use-structured-agent-session-rewind' @@ -119,10 +116,8 @@ export function useStructuredAgentSession(args: StructuredAgentSessionViewArgs) const stopControlInput = { sessionId, target, transportState, hostStopping, mutate } const stopControl = useStructuredAgentSessionStop(stopControlInput) const prompts = pendingStructuredSessionPrompts(transportState) - const promptsUnanswerableHere = pendingPromptsAllUnanswerableHere(prompts) - // Queue after waiting commands even with follow-ups off, unless unanswerable prompts would strand the queue; immediate sends enable prompt cancellation. - const commandWaiting = commandCardWaiting(transportState.queuedMessages) - const queueEnabled = (queueFollowUps || commandWaiting) && !promptsUnanswerableHere + // Immediate sends enable prompt cancellation. + const queueEnabled = structuredAgentSessionQueueEnabled(queueFollowUps, transportState, prompts) const queue = useMemo( () => ({ capability: queueCapability, enabled: queueEnabled }), [queueCapability, queueEnabled] diff --git a/src/renderer/src/components/terminal-pane/terminal-drop-shell.ts b/src/renderer/src/components/terminal-pane/terminal-drop-shell.ts index 49857241373..5751b89874c 100644 --- a/src/renderer/src/components/terminal-pane/terminal-drop-shell.ts +++ b/src/renderer/src/components/terminal-pane/terminal-drop-shell.ts @@ -1,4 +1,4 @@ -import { isWindowsAbsolutePathLike } from '../../../../shared/cross-platform-path' +import { isTerminalDropWindowsPathLike } from '../../../../shared/image-path-paste' import { isWslUncPath } from '../../../../shared/wsl-paths' import { isWindowsUserAgent } from './pane-helpers' @@ -34,10 +34,3 @@ export function resolveTerminalDropTargetShell({ } return isWindowsUserAgent(userAgent) ? 'windows' : 'posix' } - -export function isTerminalDropWindowsPathLike(path: string): boolean { - if (isWslUncPath(path)) { - return false - } - return isWindowsAbsolutePathLike(path) || path.includes('\\') -} diff --git a/src/renderer/src/components/terminal-pane/terminal-drop-worktree-path.ts b/src/renderer/src/components/terminal-pane/terminal-drop-worktree-path.ts index 7ee9ca9f167..d33369cba79 100644 --- a/src/renderer/src/components/terminal-pane/terminal-drop-worktree-path.ts +++ b/src/renderer/src/components/terminal-pane/terminal-drop-worktree-path.ts @@ -7,7 +7,7 @@ import { } from '../../../../shared/execution-host' import { DROP_STAGING_RELATIVE_DIR } from '../../../../shared/drop-staging-ignore' import { resolveExactWorktreeRoute } from '@/lib/worktree-owner-route' -import { isTerminalDropWindowsPathLike } from './terminal-drop-shell' +import { isTerminalDropWindowsPathLike } from '../../../../shared/image-path-paste' export function resolveTerminalDropWorktreePath( worktreeId: string, diff --git a/src/renderer/src/components/terminal-pane/terminal-native-file-drop.ts b/src/renderer/src/components/terminal-pane/terminal-native-file-drop.ts index 8800344c28e..67c6481d2d2 100644 --- a/src/renderer/src/components/terminal-pane/terminal-native-file-drop.ts +++ b/src/renderer/src/components/terminal-pane/terminal-native-file-drop.ts @@ -13,9 +13,9 @@ import { } from './terminal-drop-target' import { getTerminalTargetShellForWorktreePath, - isTerminalDropWindowsPathLike, resolveTerminalDropTargetShell } from './terminal-drop-shell' +import { isTerminalDropWindowsPathLike } from '../../../../shared/image-path-paste' import { writeTerminalDropPathsToCapturedTarget } from './terminal-drop-path-writer' import { showTerminalDropWriteFailure } from './terminal-drop-write-failure' import { joinRuntimeTerminalDropDir } from './terminal-drop-worktree-path' diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index 32b9d0ac56c..0965de55129 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -3273,14 +3273,26 @@ "historyItemTooLarge": "This part of the chat was too large to show.", "historyRepaired": "Part of this chat's history couldn't be loaded.", "plan": "Plan", - "stoppedBeforeStart": "Stopped manually" + "stoppedBeforeStart": "Stopped manually", + "orcaStopCanContinue": "You can continue in this conversation.", + "orcaStopCrash": "Orca on {{machine}} stopped unexpectedly while this response was in progress.", + "orcaStopQuit": "Orca on {{machine}} was closed while this response was in progress.", + "orcaStopRemote": "Orca on {{machine}} stopped while this response was in progress.", + "orcaStopUnnamedServer": "the remote server", + "orcaStopUpdate": "Orca on {{machine}} restarted for an update while this response was in progress." }, "question": { "other": "Other…" }, "queuedMessages": { "imageCount_one": "{{count}} image", - "imageCount_other": "{{count}} images" + "imageCount_other": "{{count}} images", + "editChanged": "This message changed while you were editing. Your edit is in the chat box.", + "editChangedNotSaved": "This message changed while you were editing. Your edit wasn't saved.", + "editChangedPrompt": "This message changed while you were editing. Your edit will be in the chat box after you answer the agent.", + "editFailed": "This message can't be edited.", + "editGone": "This message was already sent or removed. Your edit is in the chat box.", + "editGonePrompt": "This message was already sent or removed. Your edit will be in the chat box after you answer the agent." }, "receipt": { "cancelled": "Cancelled", @@ -3437,7 +3449,8 @@ "waitForCommand": "Wait for the /compact or /clear to finish.", "waitForStart": "Wait for the agent to finish starting.", "waitForTurn": "Wait for the agent to finish responding, or stop it.", - "waitMoment": "Wait a moment." + "waitMoment": "Wait a moment.", + "pendingWork": "Wait for pending work and messages to finish before using this command." } }, "onboarding": { diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index e64d801da12..449338be77f 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -19472,7 +19472,8 @@ "clearAfterRetry": "Retry your earlier message, then run /clear.", "compactAfterRetry": "Retry your earlier message, then run /compact.", "clearAfterSending": "Your earlier message is still being sent. Run /clear once it has gone.", - "compactAfterSending": "Your earlier message is still being sent. Run /compact once it has gone." + "compactAfterSending": "Your earlier message is still being sent. Run /compact once it has gone.", + "pendingWork": "Wait for pending work and messages to finish before using this command." }, "taskList": { "title": "Tasks", @@ -19915,9 +19916,6 @@ "tokens": "{{value0}} tokens", "unnamed": "Subagent" }, - "conversationCommand": { - "pendingWork": "Wait for pending work and messages to finish before using this command." - }, "rewind": { "unsupported": "This chat can't go back to an earlier message.", "historyNotPaginated": "This older Codex chat can't go back to an earlier message. Start a new chat to use Rewind to here.", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 531a3462a02..60e5fa6e590 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -18967,7 +18967,8 @@ "clearAfterRetry": "Reintente su mensaje anterior y luego ejecute /clear.", "compactAfterRetry": "Reintente su mensaje anterior y luego ejecute /compact.", "clearAfterSending": "Su mensaje anterior todavía se está enviando. Ejecute /clear cuando se haya enviado.", - "compactAfterSending": "Su mensaje anterior todavía se está enviando. Ejecute /compact cuando se haya enviado." + "compactAfterSending": "Su mensaje anterior todavía se está enviando. Ejecute /compact cuando se haya enviado.", + "pendingWork": "Espera a que terminen el trabajo y los mensajes pendientes antes de usar este comando." }, "composer": { "imageUnavailable": "Vista previa no disponible", @@ -19469,9 +19470,6 @@ "asked": "Preguntó:", "questionCount": "{{value0}} preguntas" }, - "conversationCommand": { - "pendingWork": "Espera a que terminen el trabajo y los mensajes pendientes antes de usar este comando." - }, "drop": { "messageTitle": "Suelta para adjuntar a este mensaje", "title": "Suelta para adjuntar a este chat", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 19097770a81..e07a3775eaf 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -19108,7 +19108,8 @@ "clearAfterRetry": "Réessayez votre message précédent, puis lancez /clear.", "compactAfterRetry": "Réessayez votre message précédent, puis lancez /compact.", "clearAfterSending": "Votre message précédent est encore en cours d'envoi. Lancez /clear une fois qu'il est parti.", - "compactAfterSending": "Votre message précédent est encore en cours d'envoi. Lancez /compact une fois qu'il est parti." + "compactAfterSending": "Votre message précédent est encore en cours d'envoi. Lancez /compact une fois qu'il est parti.", + "pendingWork": "Attendez la fin du travail et des messages en attente avant d'utiliser cette commande." }, "composer": { "imageUnavailable": "Aperçu indisponible", @@ -19550,9 +19551,6 @@ "tokens": "Jetons {{value0}}", "unnamed": "Sous-agent" }, - "conversationCommand": { - "pendingWork": "Attendez la fin du travail et des messages en attente avant d'utiliser cette commande." - }, "drop": { "messageTitle": "Déposez pour joindre à ce message", "title": "Déposer pour joindre à ce chat", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 7483161fcf8..6fb096fa891 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -19059,7 +19059,8 @@ "clearAfterRetry": "以前のメッセージを再試行してから、/clear を実行してください。", "compactAfterRetry": "以前のメッセージを再試行してから、/compact を実行してください。", "clearAfterSending": "以前のメッセージはまだ送信中です。送信が終わったら /clear を実行してください。", - "compactAfterSending": "以前のメッセージはまだ送信中です。送信が終わったら /compact を実行してください。" + "compactAfterSending": "以前のメッセージはまだ送信中です。送信が終わったら /compact を実行してください。", + "pendingWork": "このコマンドを使用する前に、保留中の作業とメッセージが完了するまで待ってください。" }, "composer": { "imageUnsupported": "この Agent では画像の貼り付けはサポートされていません。", @@ -19502,9 +19503,6 @@ "tokens": "{{value0}} トークン", "unnamed": "サブ Agent" }, - "conversationCommand": { - "pendingWork": "このコマンドを使用する前に、保留中の作業とメッセージが完了するまで待ってください。" - }, "drop": { "messageTitle": "ドロップしてこのメッセージに添付", "title": "ドロップしてこのチャットに添付します", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 7c2ee099cde..f8812fd2eaa 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -18997,7 +18997,8 @@ "clearAfterRetry": "이전 메시지를 다시 시도한 다음 /clear를 실행하세요.", "compactAfterRetry": "이전 메시지를 다시 시도한 다음 /compact를 실행하세요.", "clearAfterSending": "이전 메시지를 아직 보내는 중입니다. 전송이 끝나면 /clear를 실행하세요.", - "compactAfterSending": "이전 메시지를 아직 보내는 중입니다. 전송이 끝나면 /compact를 실행하세요." + "compactAfterSending": "이전 메시지를 아직 보내는 중입니다. 전송이 끝나면 /compact를 실행하세요.", + "pendingWork": "이 명령을 사용하기 전에 보류 중인 작업과 메시지가 완료될 때까지 기다리십시오." }, "composer": { "imageUnsupported": "이 에이전트는 이미지 붙여넣기를 지원하지 않습니다.", @@ -19439,9 +19440,6 @@ "tokens": "{{value0}} 토큰", "unnamed": "하위 에이전트" }, - "conversationCommand": { - "pendingWork": "이 명령을 사용하기 전에 보류 중인 작업과 메시지가 완료될 때까지 기다리십시오." - }, "drop": { "messageTitle": "놓아서 이 메시지에 첨부", "title": "이 채팅에 첨부하려면 드롭하세요.", diff --git a/src/renderer/src/i18n/locales/pt.json b/src/renderer/src/i18n/locales/pt.json index 7d6f6591aae..42cf717d06a 100644 --- a/src/renderer/src/i18n/locales/pt.json +++ b/src/renderer/src/i18n/locales/pt.json @@ -19790,9 +19790,6 @@ "tokens": "{{value0}} tokens", "unnamed": "Subagente" }, - "conversationCommand": { - "pendingWork": "Aguarde a conclusão do trabalho e das mensagens pendentes antes de usar este comando." - }, "drop": { "title": "Solte para anexar a este chat", "subtitle": "Os arquivos são adicionados à sua mensagem como caminhos que o agente pode ler.", @@ -19982,7 +19979,8 @@ "clearAfterRetry": "Tente enviar sua mensagem anterior novamente e execute /clear.", "compactAfterRetry": "Tente enviar sua mensagem anterior novamente e execute /compact.", "clearAfterSending": "Sua mensagem anterior ainda está sendo enviada. Execute /clear após o envio.", - "compactAfterSending": "Sua mensagem anterior ainda está sendo enviada. Execute /compact após o envio." + "compactAfterSending": "Sua mensagem anterior ainda está sendo enviada. Execute /compact após o envio.", + "pendingWork": "Aguarde a conclusão do trabalho e das mensagens pendentes antes de usar este comando." }, "newRowsAnnouncement": "Novas mensagens chegaram enquanto você estava ausente", "newRowsMarker": "Novas", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index e7d709557a0..90e71d741a5 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -18962,7 +18962,8 @@ "clearAfterRetry": "请先重试您之前的消息,然后再运行 /clear。", "compactAfterRetry": "请先重试您之前的消息,然后再运行 /compact。", "clearAfterSending": "您之前的消息仍在发送中。发送完成后再运行 /clear。", - "compactAfterSending": "您之前的消息仍在发送中。发送完成后再运行 /compact。" + "compactAfterSending": "您之前的消息仍在发送中。发送完成后再运行 /compact。", + "pendingWork": "在使用此命令之前,请等待挂起的工作和消息完成。" }, "composer": { "imageUnsupported": "此智能体不支持粘贴图片。", @@ -19409,9 +19410,6 @@ "tokens": "{{value0}} 代币", "unnamed": "子智能体" }, - "conversationCommand": { - "pendingWork": "在使用此命令之前,请等待挂起的工作和消息完成。" - }, "drop": { "messageTitle": "拖放以附加到此消息", "title": "拖放以附加到此聊天", diff --git a/src/renderer/src/lib/structured-agent-session-launch-create-message.test.ts b/src/renderer/src/lib/structured-agent-session-launch-create-message.test.ts index 419a4b732f6..bbc35187290 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-create-message.test.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-create-message.test.ts @@ -85,7 +85,7 @@ import { getStructuredAgentSessionPendingSends, getStructuredAgentSessionSendNotice } from '@/components/native-chat/structured-agent-session-pending-sends' -import { structuredAgentSessionStopControl } from '@/components/native-chat/structured-agent-session-stop-control' +import { structuredAgentSessionStopControl } from '../../../shared/structured-agent-session-stop-control' import { clearNativeChatDraftCacheForTests, readNativeChatDraftCache, diff --git a/src/renderer/src/lib/structured-agent-session-launch-prompt-stop.test.ts b/src/renderer/src/lib/structured-agent-session-launch-prompt-stop.test.ts index 6082f6d1ff7..d98c1d30ae2 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-prompt-stop.test.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-prompt-stop.test.ts @@ -30,7 +30,7 @@ import { getStructuredAgentSessionSendNotice, structuredAgentSessionSendOut } from '@/components/native-chat/structured-agent-session-pending-sends' -import { structuredAgentSessionStopControl } from '@/components/native-chat/structured-agent-session-stop-control' +import { structuredAgentSessionStopControl } from '../../../shared/structured-agent-session-stop-control' import { discardStructuredLaunchPrompts, hasStagedStructuredLaunchPrompt, diff --git a/src/shared/agent-image-paste-path.ts b/src/shared/agent-image-paste-path.ts new file mode 100644 index 00000000000..9cb688d326e --- /dev/null +++ b/src/shared/agent-image-paste-path.ts @@ -0,0 +1,12 @@ +import { formatAgentImagePath } from './agent-image-paste' +import { formatImageDropPasteText, isTerminalDropWindowsPathLike } from './image-path-paste' + +/** Escape image paths as terminal drops do; agent TUIs unescape before checking file existence. */ +export function agentImagePastePath(agent: string | null | undefined, path: string): string { + const formatted = formatAgentImagePath(agent, path) + if (formatted !== path) { + return formatted + } + const targetShell = isTerminalDropWindowsPathLike(path) ? 'windows' : 'posix' + return formatImageDropPasteText(path, targetShell) ?? path +} diff --git a/src/shared/agent-session-usage-limit-words.ts b/src/shared/agent-session-usage-limit-words.ts index 63ecddd599c..afc15b3014a 100644 --- a/src/shared/agent-session-usage-limit-words.ts +++ b/src/shared/agent-session-usage-limit-words.ts @@ -1,15 +1,13 @@ // The words for a usage limit an agent hit: which limit and when it resets. -import { - sayAgentSessionFailureEnglish, - type AgentSessionFailureCopyId, - type AgentSessionFailureCopyValues, - type AgentSessionFailureSay +import type { + AgentSessionFailureCopyId, + AgentSessionFailureCopyValues, + AgentSessionFailureSay } from './agent-session-failure-copy' -import { - formatUsageLimitResetClock, - type AgentSessionUsageLimit, - type AgentSessionUsageLimitWindow +import type { + AgentSessionUsageLimit, + AgentSessionUsageLimitWindow } from './agent-session-usage-limit' import { joinSentences } from './sentence-joining' @@ -37,20 +35,3 @@ export function usageLimitWords( const reset = limit?.resetsAt !== undefined ? context.resetWords?.(limit.resetsAt) : null return joinSentences([lead, ...(reset ? [say('usageLimitResetsAt', { when: reset })] : [])]) } - -/** For nameless readers such as mobile, append localized reset time to the host usage-limit sentence; retain other lines unchanged. */ -export function usageLimitLineText(block: { - text: string - failure?: { kind: string; usageLimit?: AgentSessionUsageLimit } -}): string { - const resetsAt = - block.failure?.kind === 'usageLimitReached' ? block.failure.usageLimit?.resetsAt : undefined - return resetsAt === undefined - ? block.text - : joinSentences([ - block.text, - sayAgentSessionFailureEnglish('usageLimitResetsAt', { - when: formatUsageLimitResetClock(resetsAt) - }) - ]) -} diff --git a/src/shared/agent-session-write-notice-copy.ts b/src/shared/agent-session-write-notice-copy.ts index 4ec1d82bc19..6becca3cffa 100644 --- a/src/shared/agent-session-write-notice-copy.ts +++ b/src/shared/agent-session-write-notice-copy.ts @@ -78,6 +78,7 @@ export const AGENT_SESSION_WRITE_NOTICE_COPY = { compactAfterRetry: 'Retry your earlier message, then run /compact.', clearAfterSending: 'Your earlier message is still being sent. Run /clear once it has gone.', compactAfterSending: 'Your earlier message is still being sent. Run /compact once it has gone.', + pendingWork: 'Wait for pending work and messages to finish before using this command.', queueTooLarge: 'Too much text is waiting in the queue.', shrinkQueue: 'Delete a queued message, or wait for one to go through, then try again.', messageTooLarge: 'It is over the 256 KB limit for one message.', diff --git a/src/shared/image-path-paste.ts b/src/shared/image-path-paste.ts index c60749c7a1b..83c59e98169 100644 --- a/src/shared/image-path-paste.ts +++ b/src/shared/image-path-paste.ts @@ -1,5 +1,7 @@ +import { isWindowsAbsolutePathLike } from './cross-platform-path' import { IMAGE_FILE_EXTENSIONS } from './image-file-extensions' import { shellEscapePath } from './shell-path-quoting' +import { isWslUncPath } from './wsl-paths' type TerminalTargetShell = 'posix' | 'windows' @@ -22,6 +24,13 @@ export function isImageDropPath(path: string): boolean { return IMAGE_DROP_EXTENSIONS.has(path.slice(lastDot).toLowerCase()) } +export function isTerminalDropWindowsPathLike(path: string): boolean { + if (isWslUncPath(path)) { + return false + } + return isWindowsAbsolutePathLike(path) || path.includes('\\') +} + export function canPasteImageDropPathRaw(path: string, targetShell: TerminalTargetShell): boolean { if (hasControlByte(path)) { return false diff --git a/src/shared/native-chat-agent-support.ts b/src/shared/native-chat-agent-support.ts index b76a8d4e557..7cc8fc2d369 100644 --- a/src/shared/native-chat-agent-support.ts +++ b/src/shared/native-chat-agent-support.ts @@ -42,6 +42,11 @@ export function nativeChatAskRequiresTerminal(agent: string | null | undefined): return resolveNativeChatTranscriptAgent(agent) === 'omp' } +/** OpenCode confirms an interrupt with a second Escape; Claude Code and Codex read Esc-Esc at an idle prompt as rewind. */ +export function nativeChatStopSendsSecondEscape(agent: string | null | undefined): boolean { + return resolveNativeChatTranscriptAgent(agent) === 'opencode' +} + export function resolveNativeChatTranscriptAgent( agent: string | null | undefined ): NativeChatTranscriptAgent | null { diff --git a/src/shared/native-chat-clock-skew.ts b/src/shared/native-chat-clock-skew.ts new file mode 100644 index 00000000000..a2e9b2fdc18 --- /dev/null +++ b/src/shared/native-chat-clock-skew.ts @@ -0,0 +1,2 @@ +/** Slack for comparing transcript timestamps to hook receipt times across hosts. */ +export const LIFECYCLE_CLOCK_SKEW_SLACK_MS = 2_000 diff --git a/src/renderer/src/components/native-chat/native-chat-composer-state.test.ts b/src/shared/native-chat-composer-autocomplete.test.ts similarity index 97% rename from src/renderer/src/components/native-chat/native-chat-composer-state.test.ts rename to src/shared/native-chat-composer-autocomplete.test.ts index d77c0493dcf..d160ab6a883 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-state.test.ts +++ b/src/shared/native-chat-composer-autocomplete.test.ts @@ -1,24 +1,22 @@ import { describe, expect, it } from 'vitest' import { applyMentionSuggestion, + applyPickerSuggestion, + applySlashSuggestion, + buildNativeChatPickerItems, + classifyNativeChatSend, deriveComposerAutocomplete, editReplacesTriggerToken, - isSkillPickerTriggered -} from './native-chat-composer-state' -import { applyPickerSuggestion } from '../../../../shared/native-chat/input/native-chat-picker-triggers' -import { - applySlashSuggestion, - classifyNativeChatSend, filterSlashCommands, + isSkillPickerTriggered, isSlashCommandDraft, slashCommandDispatchText, - sessionSlashCommandSuggestions, type SlashCommandSuggestion -} from '../../../../shared/native-chat/input/native-chat-slash-commands' -import { buildNativeChatPickerItems } from '../../../../shared/native-chat/input/native-chat-picker-items' -import type { DiscoveredSkill } from '../../../../shared/skills' -import { getNativeChatAgentProfile } from '../../../../shared/native-chat-agent-profiles' -import { pickedSkillsInText } from '../../../../shared/structured-agent-session-send-mutation' +} from './native-chat-composer-autocomplete' +import { sessionSlashCommandSuggestions } from './native-chat/input/native-chat-slash-commands' +import type { DiscoveredSkill } from './skills' +import { getNativeChatAgentProfile } from './native-chat-agent-profiles' +import { pickedSkillsInText } from './structured-agent-session-send-mutation' const COMMANDS: SlashCommandSuggestion[] = [ { name: 'clear' }, diff --git a/src/renderer/src/components/native-chat/native-chat-composer-state.ts b/src/shared/native-chat-composer-autocomplete.ts similarity index 79% rename from src/renderer/src/components/native-chat/native-chat-composer-state.ts rename to src/shared/native-chat-composer-autocomplete.ts index aa4d91f5b97..959f3c72138 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-state.ts +++ b/src/shared/native-chat-composer-autocomplete.ts @@ -1,20 +1,37 @@ -import type { DiscoveredSkill } from '../../../../shared/skills' -import { formatNativeChatFileReference } from '../../../../shared/agent-image-paste' -import type { NativeChatAgentProfile } from '../../../../shared/native-chat-agent-profiles' -import type { - NativeChatSessionSkill, - SlashCommandSuggestion -} from '../../../../shared/native-chat/input/native-chat-slash-commands' +import type { DiscoveredSkill } from './skills' +import { formatNativeChatFileReference } from './agent-image-paste' +import type { NativeChatAgentProfile } from './native-chat-agent-profiles' +import { + filterSlashCommands, + isSlashCommandDraft, + applySlashSuggestion, + slashCommandDispatchText, + type NativeChatSessionSkill, + type SlashCommandSuggestion +} from './native-chat/input/native-chat-slash-commands' import { buildNativeChatPickerItems, type NativeChatPickerItem, type NativeChatSkillDiscoverySnapshot -} from '../../../../shared/native-chat/input/native-chat-picker-items' +} from './native-chat/input/native-chat-picker-items' import { LEADING_SLASH_TRIGGER, MID_PROMPT_SLASH_TRIGGER, matchSkillSigil -} from '../../../../shared/native-chat/input/native-chat-picker-triggers' +} from './native-chat/input/native-chat-picker-triggers' + +export type { SlashCommandSuggestion } +export { filterSlashCommands, isSlashCommandDraft, applySlashSuggestion, slashCommandDispatchText } +export { + classifyNativeChatSend, + type NativeChatSendClassification +} from './native-chat/input/native-chat-slash-commands' +export { applyPickerSuggestion } from './native-chat/input/native-chat-picker-triggers' +export { + buildNativeChatPickerItems, + type NativeChatPickerItem, + type NativeChatSkillDiscoverySnapshot +} from './native-chat/input/native-chat-picker-items' type PickerAutocomplete = { query: string @@ -119,16 +136,9 @@ function deriveSlashAutocomplete( } // Known agent grammar enables skills; the caller owns catalog policy, while this layer formats tokens. const skillsEnabled = profile !== null - const namePrefix = profile?.skillNamePrefix - // Scanned names take the agent's prefix so they match the names its session reports. - const scannedSkills = !skillsEnabled - ? [] - : namePrefix - ? discovery.skills.map((skill) => ({ ...skill, name: `${namePrefix}${skill.name}` })) - : discovery.skills const items = buildNativeChatPickerItems( agentCommands, - scannedSkills, + nativeChatPickerScannedSkills(profile, discovery.skills), query, profile?.skillPrefix ?? '/', skillsEnabled ? sessionSkills : [], @@ -157,6 +167,20 @@ function deriveSlashAutocomplete( } } +/** Scanned names take the agent's prefix so they match the names its session reports. */ +export function nativeChatPickerScannedSkills( + profile: NativeChatAgentProfile | null, + skills: readonly DiscoveredSkill[] +): readonly DiscoveredSkill[] { + if (!profile) { + return [] + } + const namePrefix = profile.skillNamePrefix + return namePrefix + ? skills.map((skill) => ({ ...skill, name: `${namePrefix}${skill.name}` })) + : skills +} + /** Replacing across an entire dismissed token starts a new occurrence; typing/deleting within it does not. */ export function editReplacesTriggerToken( previous: string, diff --git a/src/shared/native-chat-image-label.ts b/src/shared/native-chat-image-label.ts new file mode 100644 index 00000000000..741c96fcd5f --- /dev/null +++ b/src/shared/native-chat-image-label.ts @@ -0,0 +1,33 @@ +import { getRuntimePathBasename } from './cross-platform-path' +import type { NativeChatImageRefBlock } from './native-chat-types' + +export type NativeChatImageLabelCopy = { + pastedImage: string + imageCount: (count: number) => string +} + +export const NATIVE_CHAT_IMAGE_LABEL_ENGLISH: NativeChatImageLabelCopy = { + pastedImage: 'Pasted image', + imageCount: (count) => `${count} images` +} + +/** Use friendly UI labels for Orca clipboard-image temp filenames. */ +export function isNativeChatPastedImagePath(path: string): boolean { + const base = path.split(/[\\/]/).findLast(Boolean) ?? path + return /^orca-paste-.+\.png$/i.test(base) +} + +/** What a chat image is called where it is not drawn: its alt text, else its file's name. */ +export function nativeChatImageLabel( + block: NativeChatImageRefBlock, + copy: NativeChatImageLabelCopy +): string { + return ( + block.alt?.trim() || + (block.path && isNativeChatPastedImagePath(block.path) + ? copy.pastedImage + : block.path + ? getRuntimePathBasename(block.path) + : 'Image') + ) +} diff --git a/src/renderer/src/components/native-chat/native-chat-launch-draft-resolution.test.ts b/src/shared/native-chat-launch-draft-resolution.test.ts similarity index 97% rename from src/renderer/src/components/native-chat/native-chat-launch-draft-resolution.test.ts rename to src/shared/native-chat-launch-draft-resolution.test.ts index 1577030f2d8..6ca98929c34 100644 --- a/src/renderer/src/components/native-chat/native-chat-launch-draft-resolution.test.ts +++ b/src/shared/native-chat-launch-draft-resolution.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import type { NativeChatMessage } from '../../../../shared/native-chat-types' +import type { NativeChatMessage } from './native-chat-types' import { launchDraftResolvedByTranscript, nativeChatLaunchDraftTurnBaseline diff --git a/src/renderer/src/components/native-chat/native-chat-launch-draft-resolution.ts b/src/shared/native-chat-launch-draft-resolution.ts similarity index 79% rename from src/renderer/src/components/native-chat/native-chat-launch-draft-resolution.ts rename to src/shared/native-chat-launch-draft-resolution.ts index a346b7046cc..a0cef6bafc7 100644 --- a/src/renderer/src/components/native-chat/native-chat-launch-draft-resolution.ts +++ b/src/shared/native-chat-launch-draft-resolution.ts @@ -1,6 +1,5 @@ -import type { NativeChatMessage } from '../../../../shared/native-chat-types' -import type { NativeChatLaunchDraft } from '@/lib/native-chat-launch-prompt' -import { LIFECYCLE_CLOCK_SKEW_SLACK_MS } from './native-chat-live-status' +import type { NativeChatMessage } from './native-chat-types' +import { LIFECYCLE_CLOCK_SKEW_SLACK_MS } from './native-chat-clock-skew' /** A new tail turn proves resolution without timestamps; older-history prepends cannot advance it. */ export type NativeChatLaunchDraftTurnBaseline = { @@ -9,7 +8,7 @@ export type NativeChatLaunchDraftTurnBaseline = { } export function nativeChatLaunchDraftTurnBaseline( - messages: NativeChatMessage[] + messages: readonly NativeChatMessage[] ): NativeChatLaunchDraftTurnBaseline { const userTurns = messages.filter((message) => message.role === 'user') return { @@ -20,8 +19,8 @@ export function nativeChatLaunchDraftTurnBaseline( // Resolve on user turns not provably older than the seed, allowing missing timestamps and host-clock slack; text matching misses TUI edits. export function launchDraftResolvedByTranscript( - entry: Pick, - messages: NativeChatMessage[], + entry: { createdAt: number }, + messages: readonly NativeChatMessage[], baseline?: NativeChatLaunchDraftTurnBaseline | null ): boolean { const provablyOlder = (message: NativeChatMessage): boolean => diff --git a/src/shared/native-chat-skill-visibility.test.ts b/src/shared/native-chat-skill-visibility.test.ts new file mode 100644 index 00000000000..942fb3a1749 --- /dev/null +++ b/src/shared/native-chat-skill-visibility.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from 'vitest' +import type { DiscoveredSkill, SkillDiscoveryResult } from './skills' +import { isNativeChatSkillForAgent } from './native-chat-skill-visibility' + +function skill(overrides: Partial): DiscoveredSkill { + return { + id: overrides.name ?? 'skill', + name: 'agent-browser', + description: null, + providers: ['agent-skills'], + sourceKind: 'home', + sourceLabel: 'Agent skills home', + rootPath: '/Users/test/.agents/skills', + directoryPath: '/Users/test/.agents/skills/agent-browser', + skillFilePath: '/Users/test/.agents/skills/agent-browser/SKILL.md', + installed: true, + updatedAt: null, + ...overrides + } +} + +function discovery(owner: string | null, rootPath = '/Users/test/.agents/skills') { + return { + sources: [ + { + id: 'source', + label: 'Source', + path: rootPath, + sourceKind: 'home' as const, + providers: ['agent-skills' as const], + owner, + exists: true + } + ] + } satisfies Pick +} + +describe('isNativeChatSkillForAgent', () => { + it('shows Codex-native and generic agent skills for Codex chat', () => { + expect(isNativeChatSkillForAgent('codex', skill({ providers: ['codex'] }))).toBe(true) + expect(isNativeChatSkillForAgent('codex', skill({ providers: ['agent-skills'] }))).toBe(true) + }) + + it('keeps Claude skills out of the Codex skill picker', () => { + expect(isNativeChatSkillForAgent('codex', skill({ providers: ['claude'] }))).toBe(false) + }) + + it('does not enable skill autocomplete for other agents yet', () => { + expect(isNativeChatSkillForAgent('claude', skill({ providers: ['agent-skills'] }))).toBe(false) + }) + + it('uses explicit source ownership and keeps shared roots visible', () => { + const shared = discovery(null) + expect(isNativeChatSkillForAgent('codex', skill({}), shared)).toBe(true) + expect(isNativeChatSkillForAgent('claude', skill({}), shared)).toBe(true) + expect(isNativeChatSkillForAgent('grok', skill({}), shared)).toBe(true) + }) + + it('aliases OpenClaude to Claude roots without exposing them to other agents', () => { + const claude = discovery('claude') + expect(isNativeChatSkillForAgent('claude', skill({}), claude)).toBe(true) + expect(isNativeChatSkillForAgent('openclaude', skill({}), claude)).toBe(true) + expect(isNativeChatSkillForAgent('codex', skill({}), claude)).toBe(false) + expect(isNativeChatSkillForAgent('grok', skill({}), claude)).toBe(false) + }) + + it('shows OpenCode the Claude skill folders it reads, and not Claude plugins', () => { + const claude = discovery('claude') + expect(isNativeChatSkillForAgent('opencode', skill({}), claude)).toBe(true) + expect(isNativeChatSkillForAgent('opencode2', skill({}), claude)).toBe(true) + const plugin = { sources: [{ ...claude.sources[0], sourceKind: 'plugin' as const }] } + expect(isNativeChatSkillForAgent('opencode2', skill({}), plugin)).toBe(false) + }) + + it('grants visibility through any contributing root, not just the dedup survivor', () => { + const result = { + sources: [ + { + id: 'codex-home', + label: 'Codex home', + path: '/Users/test/.codex/skills', + sourceKind: 'home' as const, + providers: ['codex' as const], + owner: 'codex', + exists: true + }, + { + id: 'shared-home', + label: 'Agent skills home', + path: '/Users/test/.agents/skills', + sourceKind: 'home' as const, + providers: ['agent-skills' as const], + owner: null, + exists: true + } + ] + } satisfies Pick + // Canonical dedup may list the Codex root first; shared-root reachability must still grant other agents visibility. + const merged = skill({ + rootPath: '/Users/test/.codex/skills', + rootPaths: ['/Users/test/.codex/skills', '/Users/test/.agents/skills'] + }) + expect(isNativeChatSkillForAgent('claude', merged, result)).toBe(true) + expect(isNativeChatSkillForAgent('codex', merged, result)).toBe(true) + const codexOnly = skill({ + rootPath: '/Users/test/.codex/skills', + rootPaths: ['/Users/test/.codex/skills'] + }) + expect(isNativeChatSkillForAgent('claude', codexOnly, result)).toBe(false) + }) +}) diff --git a/src/shared/native-chat-skill-visibility.ts b/src/shared/native-chat-skill-visibility.ts new file mode 100644 index 00000000000..c22691f33e6 --- /dev/null +++ b/src/shared/native-chat-skill-visibility.ts @@ -0,0 +1,55 @@ +import type { AgentType } from './agent-status-types' +import type { AgentSessionSlashCommand } from './agent-session-wire' +import type { DiscoveredSkill, SkillDiscoveryResult } from './skills' +import { getNativeChatAgentProfile, readsSkillSource } from './native-chat-agent-profiles' +import { classifyPickerCommands } from './native-chat/input/native-chat-picker-items' +import { + sessionReportedSkills, + type NativeChatSessionSkill, + type SlashCommandSuggestion +} from './native-chat/input/native-chat-slash-commands' +import { isStructuredAgentSessionComposerCommand } from './structured-agent-session-composer' + +export function isNativeChatSkillForAgent( + agent: AgentType, + skill: DiscoveredSkill, + result?: Pick +): boolean { + const profile = getNativeChatAgentProfile(agent) + if (!profile) { + return false + } + if (!result) { + return ( + agent === 'codex' && + (skill.providers.includes('codex') || skill.providers.includes('agent-skills')) + ) + } + // Canonical dedup may hide alternate symlink roots; any shared/agent root grants visibility regardless of scan order. + const rootPaths = skill.rootPaths?.length ? skill.rootPaths : [skill.rootPath] + return rootPaths.some((rootPath) => { + const source = result.sources.find((entry) => entry.path === rootPath) + return source !== undefined && readsSkillSource(profile, source) + }) +} + +/** Skills the session reported; undefined keeps the host disk scan (also for chats that keep scanned skills). */ +export function nativeChatSessionSkills( + agent: AgentType, + reported: readonly AgentSessionSlashCommand[] | undefined +): readonly NativeChatSessionSkill[] | undefined { + const keepsScannedSkills = getNativeChatAgentProfile(agent)?.keepsScannedSkills === true + return reported !== undefined && !keepsScannedSkills ? sessionReportedSkills(reported) : undefined +} + +export function nativeChatPickerCommands( + agent: AgentType, + commands: readonly SlashCommandSuggestion[], + scannedSkills: readonly DiscoveredSkill[] +): readonly SlashCommandSuggestion[] { + return classifyPickerCommands(commands, scannedSkills, { + // A chat that keeps scanned skills (OpenCode) may report each as a command too. + reportMayListSkills: getNativeChatAgentProfile(agent)?.keepsScannedSkills === true, + isSendCommand: (name) => isStructuredAgentSessionComposerCommand(`/${name}`, agent) + }) +} diff --git a/src/renderer/src/components/native-chat/native-chat-live-status.test.ts b/src/shared/native-chat/native-chat-live-status.test.ts similarity index 91% rename from src/renderer/src/components/native-chat/native-chat-live-status.test.ts rename to src/shared/native-chat/native-chat-live-status.test.ts index 6791d76ec87..cccd5cb27d8 100644 --- a/src/renderer/src/components/native-chat/native-chat-live-status.test.ts +++ b/src/shared/native-chat/native-chat-live-status.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it } from 'vitest' -import type { NativeChatMessage } from '../../../../shared/native-chat-types' +import type { NativeChatMessage } from '../native-chat-types' import { mergeNativeChatLiveSession, nativeChatHookAwaitsInput } from './native-chat-live-status' -import { selectNativeChatViewState } from './native-chat-view-state' import { shouldShowNativeChatWorking } from './native-chat-working-suppression' function assistant(id: string, text: string): NativeChatMessage { @@ -269,28 +268,6 @@ describe('mergeNativeChatLiveSession', () => { }) expect(session.status).toBe('empty') }) - - // Session ids can arrive before transcript flush; live work must still retain Stop and its activity indicator. - it('keeps the Stop affordance for a working known session mid-flush', () => { - const session = mergeNativeChatLiveSession({ - messages: [user('u-1', 'run it')], - sessionId: 'sess', - agent: 'claude', - hookState: 'working', - loading: true - }) - const viewState = selectNativeChatViewState(session) - const isConversation = viewState.kind === 'ready' - - expect(viewState).toEqual({ kind: 'ready', isWorking: true }) - expect( - shouldShowNativeChatWorking({ - isConversation, - working: session.status === 'working', - interrupted: false - }) - ).toBe(true) - }) }) describe('nativeChatHookAwaitsInput', () => { diff --git a/src/renderer/src/components/native-chat/native-chat-live-status.ts b/src/shared/native-chat/native-chat-live-status.ts similarity index 74% rename from src/renderer/src/components/native-chat/native-chat-live-status.ts rename to src/shared/native-chat/native-chat-live-status.ts index 49d76bbc879..8028f5fdeeb 100644 --- a/src/renderer/src/components/native-chat/native-chat-live-status.ts +++ b/src/shared/native-chat/native-chat-live-status.ts @@ -1,13 +1,15 @@ // Reconcile live working state with provider turn boundaries so a dropped final hook cannot leave Stop stuck. -import type { AgentStatusState } from '../../../../shared/agent-status-types' +import type { AgentStatusEntry, AgentStatusState } from '../agent-status-types' +import { isAgentStatusFresh } from '../agent-status-freshness' import type { AgentType, NativeChatMessage, NativeChatSession, NativeChatSessionStatus, NativeChatTurnLifecycle -} from '../../../../shared/native-chat-types' +} from '../native-chat-types' +import { LIFECYCLE_CLOCK_SKEW_SLACK_MS } from '../native-chat-clock-skew' export type NativeChatLiveMergeInput = { /** Already deduped, ordered, and transcript-normalized by the live assembler. */ @@ -30,8 +32,12 @@ export type NativeChatLiveMergeInput = { error?: string } +export type NativeChatLiveMergedSession = NativeChatSession & { hookAwaitingInput: boolean } + /** Errors win; working hooks override loading until their turn ends, with provider lifecycle settling dropped Stop hooks. */ -export function mergeNativeChatLiveSession(input: NativeChatLiveMergeInput): NativeChatSession { +export function mergeNativeChatLiveSession( + input: NativeChatLiveMergeInput +): NativeChatLiveMergedSession { const { messages, sessionId, @@ -44,8 +50,13 @@ export function mergeNativeChatLiveSession(input: NativeChatLiveMergeInput): Nat loading, error } = input + const hookAwaitingInput = nativeChatHookAwaitsInput( + hookState, + stateStartedAt, + transcriptLifecycle + ) if (error) { - return { messages, status: 'error', sessionId, agent, error } + return { messages, status: 'error', sessionId, agent, error, hookAwaitingInput } } const status = liveStatusOverride( @@ -58,16 +69,44 @@ export function mergeNativeChatLiveSession(input: NativeChatLiveMergeInput): Nat ) // Keep Stop/activity live while loading; view-state selection separately holds empty transcripts on their loading surface. if (loading && status !== 'working') { - return { messages, status: 'loading', sessionId, agent } + return { messages, status: 'loading', sessionId, agent, hookAwaitingInput } } return { messages, status: status ?? (messages.length === 0 ? 'empty' : 'ready'), sessionId, - agent + agent, + hookAwaitingInput } } +/** Restored nonterminal rows need a live hook confirmation before they may keep chat busy. */ +export function resolveNativeChatHookState( + entry: + | Pick + | undefined + | null, + now = Date.now() +): AgentStatusState | null { + if (!entry || !isAgentStatusFresh(entry, now)) { + return null + } + return entry.state === 'working' && entry.workingMode === 'monitoring' ? null : entry.state +} + +/** Only children started in this parent epoch may delay settlement; missed stops and reused panes leave stale roster entries. */ +export function nativeChatHookHasWorkingSubagents( + entry: Pick | undefined | null +): boolean { + const epochStart = entry?.stateStartedAt + return ( + entry?.subagents?.some( + (subagent) => + subagent.state === 'working' && (epochStart == null || subagent.startedAt >= epochStart) + ) ?? false + ) +} + /** blocked/waiting needs human input; a transcript terminal marker can clear waits whose interrupt emitted no hook. */ export function nativeChatHookAwaitsInput( hookState: AgentStatusState | null, @@ -80,9 +119,6 @@ export function nativeChatHookAwaitsInput( ) } -/** Slack for comparing transcript timestamps to hook receipt times across hosts. */ -export const LIFECYCLE_CLOCK_SKEW_SLACK_MS = 2_000 - /** omp retries its own agent_end hook, so trailing mid-run prose/tool rows cannot stand in for a lost end signal. */ function hookDeliversTurnEnd(agent: AgentType): boolean { return agent === 'omp' diff --git a/src/shared/native-chat/native-chat-orca-stop-words.ts b/src/shared/native-chat/native-chat-orca-stop-words.ts new file mode 100644 index 00000000000..a33dc4e02cc --- /dev/null +++ b/src/shared/native-chat/native-chat-orca-stop-words.ts @@ -0,0 +1,32 @@ +import type { AgentSessionOrcaStopCause } from '../agent-session-orca-stop' +import { joinSentences } from '../sentence-joining' + +/** English pieces; desktop translates each with the same fallback, the phone keeps English. */ +export const NATIVE_CHAT_ORCA_STOP_COPY = { + update: 'Orca on {{machine}} restarted for an update while this response was in progress.', + quit: 'Orca on {{machine}} was closed while this response was in progress.', + crash: 'Orca on {{machine}} stopped unexpectedly while this response was in progress.', + // A remote graceful stop is a server stop or a shared desktop's quit; "stopped" is true of both. + remote: 'Orca on {{machine}} stopped while this response was in progress.', + canContinue: 'You can continue in this conversation.', + unnamedServer: 'the remote server' +} as const + +export type NativeChatOrcaStopCopyId = keyof typeof NATIVE_CHAT_ORCA_STOP_COPY +export type NativeChatOrcaStopSay = (id: NativeChatOrcaStopCopyId, machine?: string) => string + +export const sayNativeChatOrcaStopEnglish: NativeChatOrcaStopSay = (id, machine) => + machine === undefined + ? NATIVE_CHAT_ORCA_STOP_COPY[id] + : NATIVE_CHAT_ORCA_STOP_COPY[id].replace('{{machine}}', () => machine) + +/** Base wording on host continuation support so the sentence stays stable as the button changes. */ +export function nativeChatOrcaStopRowText( + cause: AgentSessionOrcaStopCause, + machine: string, + options: { continueAvailable: boolean; remoteHost: boolean }, + say: NativeChatOrcaStopSay = sayNativeChatOrcaStopEnglish +): string { + const stopped = say(cause === 'quit' && options.remoteHost ? 'remote' : cause, machine) + return options.continueAvailable ? stopped : joinSentences([stopped, say('canContinue')]) +} diff --git a/src/renderer/src/components/native-chat/native-chat-terminal-turn.test.ts b/src/shared/native-chat/native-chat-terminal-turn.test.ts similarity index 98% rename from src/renderer/src/components/native-chat/native-chat-terminal-turn.test.ts rename to src/shared/native-chat/native-chat-terminal-turn.test.ts index 843453645ff..911f60e9a0c 100644 --- a/src/renderer/src/components/native-chat/native-chat-terminal-turn.test.ts +++ b/src/shared/native-chat/native-chat-terminal-turn.test.ts @@ -1,8 +1,5 @@ import { describe, expect, it } from 'vitest' -import { - NATIVE_CHAT_INTERRUPTED_STATUS_TEXT, - type NativeChatMessage -} from '../../../../shared/native-chat-types' +import { NATIVE_CHAT_INTERRUPTED_STATUS_TEXT, type NativeChatMessage } from '../native-chat-types' import { nativeChatHookLatestTurnWorkedSeconds, nativeChatHookTurnStartedAt, diff --git a/src/renderer/src/components/native-chat/native-chat-terminal-turn.ts b/src/shared/native-chat/native-chat-terminal-turn.ts similarity index 76% rename from src/renderer/src/components/native-chat/native-chat-terminal-turn.ts rename to src/shared/native-chat/native-chat-terminal-turn.ts index 5fa974818fc..a6cb9569cdb 100644 --- a/src/renderer/src/components/native-chat/native-chat-terminal-turn.ts +++ b/src/shared/native-chat/native-chat-terminal-turn.ts @@ -1,16 +1,12 @@ -import type { AgentStatusEntry } from '../../../../shared/agent-status-types' -import { - isInterruptedStatusMessage, - type NativeChatMessage -} from '../../../../shared/native-chat-types' -import type { - NativeChatSettledTurn, - NativeChatSettledTurns -} from '../../../../shared/native-chat-turn-status' -import { isNoiseMessage } from '../../../../shared/native-chat-noise' -import type { NativeChatAwaitingInput } from './NativeChatMessageList' +import type { AgentStatusEntry } from '../agent-status-types' +import { isInterruptedStatusMessage, type NativeChatMessage } from '../native-chat-types' +import type { NativeChatSettledTurn, NativeChatSettledTurns } from '../native-chat-turn-status' +import { isNoiseMessage } from '../native-chat-noise' import { shouldShowNativeChatWorking } from './native-chat-working-suppression' +/** shown prompts have a card in this pane; unshown prompts require input in the agent’s terminal. */ +export type NativeChatAwaitingInput = 'shown' | 'unshown' + export type NativeChatTerminalTurn = { /** The agent is generating: drives Stop-vs-Send and the streaming preview. */ isWorking: boolean @@ -110,3 +106,23 @@ export function nativeChatTranscriptSettledTurns( } return settled } + +/** Overlay the host's latest-turn timing on transcript durations once the turn settles. */ +export function nativeChatTerminalSettledTurns(args: { + transcriptSettled: NativeChatSettledTurns + latestTurnId: string | null + latestWorkedSeconds: number | null | undefined + turnStartedAt: number | null + turnActive: boolean +}): NativeChatSettledTurns { + const { transcriptSettled, latestTurnId, latestWorkedSeconds, turnStartedAt, turnActive } = args + if (turnActive || latestWorkedSeconds === undefined || latestTurnId === null) { + return transcriptSettled + } + return new Map(transcriptSettled).set( + latestTurnId, + latestWorkedSeconds === null || turnStartedAt === null + ? null + : { startedAt: turnStartedAt, workedSeconds: latestWorkedSeconds } + ) +} diff --git a/src/renderer/src/components/native-chat/native-chat-working-suppression.ts b/src/shared/native-chat/native-chat-working-suppression.ts similarity index 100% rename from src/renderer/src/components/native-chat/native-chat-working-suppression.ts rename to src/shared/native-chat/native-chat-working-suppression.ts diff --git a/src/shared/queued-message-edit.ts b/src/shared/queued-message-edit.ts index fc4e5bb5635..ff90c348fb1 100644 --- a/src/shared/queued-message-edit.ts +++ b/src/shared/queued-message-edit.ts @@ -1,9 +1,12 @@ -import type { AgentJournalMessageItem } from './agent-session-journal-types' +import type { AgentJournalMessageItem, AgentJournalSubmission } from './agent-session-journal-types' +import type { AgentSessionQueuedMessage } from './agent-session-queued-message-wire' +import { agentSessionSendBodyFingerprint } from './structured-agent-session-send-mutation' import { MAX_BLOCKS, MAX_PROMPT_BYTES } from './rpc-contract/structured-agent-session-params' import { imageReferenceInsertion, imageReferenceToken } from './native-chat-image-reference' import { imageReferencesToPromptMarkers, - promptMarkersToImageReferences + promptMarkersToImageReferences, + stripSentImagePromptMarkers } from './native-chat-image-transcript-markers' export type QueuedMessageImageBlock = Extract< @@ -89,7 +92,7 @@ export function queuedEditTextWithAddedImages(draft: QueuedMessageEditDraft): st /** The text an edit's typing is measured against: the card's own, its markers following the * images removed so far, so a removal alone is not typing. */ -export function queuedEditBaseText( +function queuedEditBaseText( body: AgentJournalMessageItem, kept: readonly { id: string }[] ): string | null { @@ -103,6 +106,17 @@ export function queuedEditBaseText( ) } +/** The typing an edit hands to the chat box: none when only images changed, and without the + * markers of the card's own images, which the chat box does not carry. */ +export function queuedEditHandOffTyping( + body: AgentJournalMessageItem, + draft: Pick +): string { + return draft.text === queuedEditBaseText(body, draft.kept) + ? '' + : stripSentImagePromptMarkers(draft.text, draft.kept.length) +} + function queuedEditImagesChanged( body: AgentJournalMessageItem, draft: QueuedMessageEditDraft @@ -204,3 +218,58 @@ export function queuedMessageWithEdit( } return { ok: true, body: { ...plainBody, blocks } } } + +/** A question or approval holds the chat box's place, so the text there is out of sight until then. */ +export const QUEUED_EDIT_NOTICE_COPY = { + editChanged: 'This message changed while you were editing. Your edit is in the chat box.', + editChangedPrompt: + 'This message changed while you were editing. Your edit will be in the chat box after you answer the agent.', + editChangedNotSaved: "This message changed while you were editing. Your edit wasn't saved.", + editGone: 'This message was already sent or removed. Your edit is in the chat box.', + editGonePrompt: + 'This message was already sent or removed. Your edit will be in the chat box after you answer the agent.', + editFailed: "This message can't be edited." +} as const + +export type QueuedEditNotice = keyof typeof QUEUED_EDIT_NOTICE_COPY + +export function queuedEditChangedNotice(handedOff: boolean, promptOpen: boolean): QueuedEditNotice { + if (!handedOff) { + return 'editChangedNotSaved' + } + return promptOpen ? 'editChangedPrompt' : 'editChanged' +} + +/** Sent means handed off with this body; holds/differs describe the current card, gone its removal; null queue proves nothing. */ +type QueuedEditStanding = 'sent' | 'gone' | 'holds' | 'differs' + +export function queuedEditStanding( + view: { + messages: readonly AgentSessionQueuedMessage[] | null + cards: readonly { messageId: string }[] + submissions: readonly AgentJournalSubmission[] + }, + sessionId: string, + messageId: string, + /** The fingerprint of the body the edit would save; null when it saves none. */ + desired: string | null +): QueuedEditStanding | null { + if (view.messages === null) { + return null + } + const listed = view.messages.find((entry) => entry.messageId === messageId) + if (!listed || !queuedCardOnScreen(view.cards, messageId)) { + const sent = view.submissions.some( + (entry) => entry.queuedMessageId === messageId && entry.payloadFingerprint === desired + ) + return desired !== null && sent ? 'sent' : 'gone' + } + return agentSessionSendBodyFingerprint(sessionId, listed.body) === desired ? 'holds' : 'differs' +} + +export function queuedCardOnScreen( + cards: readonly { messageId: string }[], + messageId: string +): boolean { + return cards.some((card) => card.messageId === messageId) +} diff --git a/src/shared/skill-install-capability.ts b/src/shared/skill-install-capability.ts index 0787da7dd6d..ad613bc6d0f 100644 --- a/src/shared/skill-install-capability.ts +++ b/src/shared/skill-install-capability.ts @@ -23,6 +23,9 @@ export const BUNDLED_ORCA_SKILL_CAPABILITY = 'skills.bundled-orca.v1' as const export const BUNDLED_ORCA_SKILL_UPDATE_REQUIRED_MESSAGE = 'Update Orca on the server to update its Orca skills.' +/** skills.discover scans a workspace named only by worktreeId, so phones without its folder can ask. */ +export const SKILL_DISCOVER_WORKSPACE_CAPABILITY = 'skills.discover-workspace.v1' as const + export const SKILL_RUNTIME_CAPABILITIES = [ SKILL_INSTALL_CAPABILITY, SKILL_BUNDLE_INSTALL_CAPABILITY, @@ -33,5 +36,6 @@ export const SKILL_RUNTIME_CAPABILITIES = [ SKILL_MANAGEMENT_CAPABILITY, SKILL_INSTALL_PROVIDERS_CAPABILITY, SKILL_DELETE_CAPABILITY, - BUNDLED_ORCA_SKILL_CAPABILITY + BUNDLED_ORCA_SKILL_CAPABILITY, + SKILL_DISCOVER_WORKSPACE_CAPABILITY ] as const diff --git a/src/shared/structured-agent-session-live-turn.ts b/src/shared/structured-agent-session-live-turn.ts index 7cf9b8d6ad6..664800cd239 100644 --- a/src/shared/structured-agent-session-live-turn.ts +++ b/src/shared/structured-agent-session-live-turn.ts @@ -195,6 +195,14 @@ export function structuredAgentSessionHostSaysWorking( return hostWorking ?? derived() } +/** Working through the gap between turn end/Resume and the host's next queued-send update. */ +export function structuredAgentSessionQueueSendsNext( + nextQueuedMessageId: string | null | undefined, + isWorking: boolean +): boolean { + return nextQueuedMessageId != null && !isWorking +} + /** Use host-named actionable prompts; older hosts treat every pending prompt as waiting. */ export function isActionableStructuredAgentSessionPrompt( itemId: string, diff --git a/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.test.ts b/src/shared/structured-agent-session-queued-cards.test.ts similarity index 75% rename from src/renderer/src/components/native-chat/structured-agent-session-queued-cards.test.ts rename to src/shared/structured-agent-session-queued-cards.test.ts index b045d8c35de..d62ead6a271 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.test.ts +++ b/src/shared/structured-agent-session-queued-cards.test.ts @@ -1,11 +1,8 @@ import { describe, expect, it } from 'vitest' -import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' -import type { AgentSessionQueuedMessage } from '../../../../shared/agent-session-wire' -import type { StructuredAgentSessionPendingSend } from './structured-agent-session-pending-sends' +import type { AgentJournalSubmission } from './agent-session-journal-types' +import type { AgentSessionQueuedMessage } from './agent-session-wire' import { newestSteerableQueuedMessageCard, - pendingSendsOutsideQueuedCards, - pendingQueueSendsOnTheirWay, projectQueuedMessageCards, queuedMessageCardSteers, queuedMessagesQueuePause @@ -220,62 +217,6 @@ describe('queued message cards', () => { expect(working.map((card) => card.waitsForAgent ?? false)).toEqual([false, true]) expect(cards[1]).not.toHaveProperty('waitsForAgent') }) - - it('a mid-turn queue send on its way is no bubble; a plain or recorded one is, until its row', () => { - const entry = ( - clientMessageId: string, - overrides: Partial = {} - ): StructuredAgentSessionPendingSend => ({ - clientMessageId, - sessionId: 'session-1', - body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: clientMessageId }] }, - previewUris: [], - queuedAt: 1, - phase: 'sending', - issued: true, - ...overrides - }) - const ids = (entries: readonly StructuredAgentSessionPendingSend[]): string[] => - entries.map((candidate) => candidate.clientMessageId) - const sends = [ - entry('queued', { delivery: 'queue-if-active' }), - entry('plain'), - entry('recorded', { phase: 'recorded' }) - ] - // Pending queued sends use cards while working; plain/recorded sends remain bubbles until host ownership appears. - expect(ids(pendingSendsOutsideQueuedCards(sends, [], true))).toEqual(['plain', 'recorded']) - expect(ids(pendingSendsOutsideQueuedCards(sends, [], false))).toEqual([ - 'queued', - 'plain', - 'recorded' - ]) - expect(ids(pendingSendsOutsideQueuedCards(sends, ['plain'], false))).toEqual([ - 'queued', - 'recorded' - ]) - }) - it('a sending card ends once the host records the send or hands its card off', () => { - const entry: StructuredAgentSessionPendingSend = { - clientMessageId: 'a', - sessionId: 'session-1', - body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'a' }] }, - previewUris: [], - queuedAt: 1, - phase: 'sending', - issued: true, - delivery: 'queue-if-active' - } - const onItsWay = (submissions: AgentJournalSubmission[]) => - pendingQueueSendsOnTheirWay([entry], [], true, submissions).map( - (candidate) => candidate.clientMessageId - ) - expect(onItsWay([])).toEqual(['a']) - expect(onItsWay([submission('a')])).toEqual([]) - expect(onItsWay([handOff('a')])).toEqual([]) - expect(pendingQueueSendsOnTheirWay([entry], ['a'], true, [])).toEqual([]) - expect(pendingQueueSendsOnTheirWay([entry], [], false, [])).toEqual([]) - expect(pendingQueueSendsOnTheirWay([{ ...entry, phase: 'recorded' }], [], true, [])).toEqual([]) - }) }) describe("another agent's card", () => { diff --git a/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.ts b/src/shared/structured-agent-session-queued-cards.ts similarity index 57% rename from src/renderer/src/components/native-chat/structured-agent-session-queued-cards.ts rename to src/shared/structured-agent-session-queued-cards.ts index 295aaa69c13..3158fc13a6c 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.ts +++ b/src/shared/structured-agent-session-queued-cards.ts @@ -1,18 +1,33 @@ // Derive hold labels on the client; the wire carries queue state, not presentation wording. -import type { UnreadAgentSessionFailureFact } from '../../../../shared/agent-session-failure' -import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' -import type { NativeChatImageRefBlock } from '../../../../shared/native-chat-types' -import type { - AgentSessionQueuedMessage, - AgentSessionQueuePause -} from '../../../../shared/agent-session-wire' +import { + readWholeAgentSessionFailureFact, + type AgentSessionFailureFact, + type UnreadAgentSessionFailureFact +} from './agent-session-failure' +import { agentSessionFailureStatedByRow } from './agent-session-visible-failures' +import { agentSessionWriteNotDoneParts } from './agent-session-refusal-notice' +import type { AgentSessionWriteNoticePart } from './agent-session-write-notice-copy' +import { dispatchWasWithdrawn } from './structured-agent-session-dispatch-rejection' +import { structuredAgentSessionAttemptFailureParts } from './structured-agent-session-rejection-words' +import type { AgentJournalItemBody, AgentJournalSubmission } from './agent-session-journal-types' +import { pendingPromptsAllUnanswerableHere } from './agent-session-approval-subject' +import type { NativeChatImageRefBlock } from './native-chat-types' +import { + QUEUED_MESSAGE_PAUSED_SEND_FAILED, + type AgentSessionQueuedMessage, + type AgentSessionQueuePause +} from './agent-session-wire' import { readAgentMessageSource, type AgentMessageSource -} from '../../../../shared/agent-session/agent-session-message-source' -import type { StructuredAgentSessionPendingSend } from './structured-agent-session-pending-sends' -import { handedOffQueuedMessageIds } from '../../../../shared/structured-agent-session-draft-hand-off' +} from './agent-session/agent-session-message-source' +import { handedOffQueuedMessageIds } from './structured-agent-session-draft-hand-off' +import { + NATIVE_CHAT_IMAGE_LABEL_ENGLISH, + nativeChatImageLabel, + type NativeChatImageLabelCopy +} from './native-chat-image-label' export type QueuedMessageCardHold = /** Plainly queued, also behind an open question: the question already says the agent waits. */ @@ -44,11 +59,11 @@ export type QueuedMessageCard = { from?: AgentMessageSource } -function queuedMessageCardText(body: AgentSessionQueuedMessage['body']): string { +export function queuedMessageCardText(body: AgentSessionQueuedMessage['body']): string { return body.blocks.flatMap((block) => (block.type === 'text' ? [block.text] : [])).join('\n') } -function queuedMessageCardImages( +export function queuedMessageCardImages( body: AgentSessionQueuedMessage['body'] ): Pick { const images = body.blocks.flatMap((block) => (block.type === 'image-ref' ? [block] : [])) @@ -105,7 +120,7 @@ export function projectQueuedMessageCards( } /** Later sends queue behind commands even with follow-ups off; individually held cards do not block them. */ -export function commandCardWaiting( +function commandCardWaiting( queuedMessages: readonly AgentSessionQueuedMessage[] | null | undefined ): boolean { return (queuedMessages ?? []).some( @@ -114,6 +129,25 @@ export function commandCardWaiting( ) } +/** Sends queue behind a waiting command even with follow-ups off; prompts no one here can answer would strand the queue, so then a send starts a turn. */ +export function structuredAgentSessionQueueEnabled( + queueFollowUps: boolean, + session: { queuedMessages?: readonly AgentSessionQueuedMessage[] | null }, + prompts: readonly { body: AgentJournalItemBody }[] +): boolean { + return ( + (queueFollowUps || commandCardWaiting(session.queuedMessages)) && + !pendingPromptsAllUnanswerableHere(prompts) + ) +} + +/** A returned card, or one whose own send failed, needs the person; the rest wait their turn. */ +export function queuedMessageCardNeedsAttention( + card: Pick +): boolean { + return card.state === 'returned' || card.pausedReason === QUEUED_MESSAGE_PAUSED_SEND_FAILED +} + /** Offer Resume only while the queue pause holds a card it could actually send. */ export function queuedMessagesQueuePause( cards: readonly QueuedMessageCard[], @@ -135,50 +169,38 @@ export function newestSteerableQueuedMessageCard( return newest && !newest.command && newest.hold !== 'sending' ? newest : null } -export function sendingQueuedMessageCards( - entries: readonly StructuredAgentSessionPendingSend[] -): QueuedMessageCard[] { - return entries.map((entry, index) => ({ - messageId: entry.clientMessageId, - // After every card the host holds, in send order. - position: Number.MAX_SAFE_INTEGER - entries.length + index, - text: queuedMessageCardText(entry.body), - ...queuedMessageCardImages(entry.body), - state: 'waiting', - hold: 'sending' - })) -} - -/** Host-owned cards and active queue requests stay out of transcript echoes; recorded sends remain until their row arrives. */ -export function pendingSendsOutsideQueuedCards< - Send extends { clientMessageId: string; delivery?: 'queue-if-active' } ->(pending: readonly Send[], heldIds: readonly string[], isWorking: boolean): readonly Send[] { - const held = new Set(heldIds) - const next = pending.filter( - (entry) => - !held.has(entry.clientMessageId) && !(isWorking && entry.delivery === 'queue-if-active') - ) - return next.length === pending.length ? pending : next -} - -export function pendingQueueSendsOnTheirWay( - pending: readonly StructuredAgentSessionPendingSend[], - heldIds: readonly string[], - isWorking: boolean, - submissions: readonly AgentJournalSubmission[] -): StructuredAgentSessionPendingSend[] { - if (!isWorking) { - return [] +/** The card's line: its text, or for a card of images alone, the image's name or their count. */ +export function queuedMessageCardLine( + card: Pick, + copy: NativeChatImageLabelCopy = NATIVE_CHAT_IMAGE_LABEL_ENGLISH +): string { + const images = card.images ?? [] + if (card.text.trim() || images.length === 0) { + return card.text } - const recorded = new Set([ - ...heldIds, - ...handedOffQueuedMessageIds(submissions), - ...submissions.map((submission) => submission.clientMessageId) - ]) - return pending.filter( - (entry) => - entry.phase === 'sending' && - entry.delivery === 'queue-if-active' && - !recorded.has(entry.clientMessageId) + return images.length === 1 + ? nativeChatImageLabel(images[0], copy) + : copy.imageCount(images.length) +} + +/** A returned card's words: a Stop withdrawal is not a send failure; the card's own Send is the retry, so failure words leave sending again out. */ +export function queuedMessageReturnedNotice( + card: Pick, + agentName: string | undefined, + statedFailures: readonly AgentSessionFailureFact[] +): 'withdrawn' | AgentSessionWriteNoticePart[] { + // Read exactly as a rejected submission: the typed fact decides, the reason is the fallback. + const reason = card.returnedReason ?? null + const rejection = card.returnedRejection + if (agentSessionFailureStatedByRow(rejection, statedFailures)) { + return agentSessionWriteNotDoneParts('send') + } + if (dispatchWasWithdrawn({ dispatchState: 'rejected', reason, rejection })) { + return 'withdrawn' + } + return structuredAgentSessionAttemptFailureParts( + { kind: 'rejected', reason }, + { agentName, retryControl: true }, + readWholeAgentSessionFailureFact(rejection) ) } diff --git a/src/renderer/src/components/native-chat/structured-agent-session-stop-control.ts b/src/shared/structured-agent-session-stop-control.ts similarity index 67% rename from src/renderer/src/components/native-chat/structured-agent-session-stop-control.ts rename to src/shared/structured-agent-session-stop-control.ts index 70532876ec4..2489c157e19 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-stop-control.ts +++ b/src/shared/structured-agent-session-stop-control.ts @@ -1,3 +1,13 @@ +/** A published chat: its running turn, or the whole conversation where the host stops that while working or sending. */ +export function structuredAgentSessionCanStop(input: { + turnId: string | null + isWorking: boolean + sending: boolean + stopsConversation: boolean +}): boolean { + return input.turnId !== null || (input.stopsConversation && (input.isWorking || input.sending)) +} + export function structuredAgentSessionStopControl(input: { published: boolean /** Temporary: hosts without create.message.v1 still leave the prompt on this client. */ @@ -19,7 +29,12 @@ export function structuredAgentSessionStopControl(input: { const { sending, stopSends } = input.sends return { canStop: published - ? turnId !== null || (host.stopsConversation && (isWorking || sending)) + ? structuredAgentSessionCanStop({ + turnId, + isWorking, + sending, + stopsConversation: host.stopsConversation + }) : legacyLaunch !== undefined && sending, stop: () => { if (!published) { diff --git a/src/shared/structured-agent-session-stop-join.ts b/src/shared/structured-agent-session-stop-join.ts new file mode 100644 index 00000000000..bcff6044360 --- /dev/null +++ b/src/shared/structured-agent-session-stop-join.ts @@ -0,0 +1,33 @@ +/** Only targeted Stops are joinable; untargeted presses stop whatever is running when each reaches the host. */ +export function structuredAgentSessionStopIsJoinable( + fingerprintMethod: string, + fields: Record +): boolean { + if (fingerprintMethod !== 'agentSession.cancel') { + return false + } + return fields.scope === 'background-tasks' + ? fields.taskId !== undefined + : fields.turnId !== undefined +} + +/** Older hosts lack quiet repeated Stops: a repeat joins the in-flight Stop instead of sending again. */ +export function joinStructuredAgentSessionStop( + inFlight: Map>, + key: string, + stop: () => Promise +): Promise { + const joined = inFlight.get(key) + if (joined) { + return joined + } + const stopping = stop() + inFlight.set(key, stopping) + // Gone once it settles, so the next press is a new Stop. + void stopping.finally(() => { + if (inFlight.get(key) === stopping) { + inFlight.delete(key) + } + }) + return stopping +} diff --git a/src/shared/structured-conversation-command-hold.test.ts b/src/shared/structured-conversation-command-hold.test.ts new file mode 100644 index 00000000000..1a91f8a62b6 --- /dev/null +++ b/src/shared/structured-conversation-command-hold.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest' +import { structuredConversationCommandHold } from './structured-conversation-command-hold' + +describe('a conversation command held on this side', () => { + const idle = { + waitsInLine: false, + agentWorking: false, + promptPending: false, + backgroundTasksRunning: false, + sendPending: false + } + + it('is held here only by a message the host does not have yet, or background work', () => { + const inLine = { ...idle, waitsInLine: true } + expect( + structuredConversationCommandHold({ + ...inLine, + agentWorking: true, + promptPending: true + }) + ).toBeNull() + expect(structuredConversationCommandHold({ ...inLine, sendPending: true })).toBe('ahead') + expect(structuredConversationCommandHold({ ...inLine, backgroundTasksRunning: true })).toBe( + 'background' + ) + }) + + it('against a host that cannot hold it, and for /clear, keeps every check in true words', () => { + expect(structuredConversationCommandHold(idle)).toBeNull() + expect(structuredConversationCommandHold({ ...idle, agentWorking: true })).toBe('working') + expect(structuredConversationCommandHold({ ...idle, sendPending: true })).toBe('sending') + expect( + structuredConversationCommandHold({ ...idle, agentWorking: true, sendPending: true }) + ).toBe('working') + expect(structuredConversationCommandHold({ ...idle, promptPending: true })).toBe('prompt') + }) +}) diff --git a/src/shared/structured-conversation-command-hold.ts b/src/shared/structured-conversation-command-hold.ts new file mode 100644 index 00000000000..4e0de6686a3 --- /dev/null +++ b/src/shared/structured-conversation-command-hold.ts @@ -0,0 +1,117 @@ +import { + isAgentSessionConversationCommand, + type AgentSessionConversationCommand, + type AgentSessionConversationCommandResult +} from './agent-session/agent-session-conversation-command' +import { + readWholeAgentSessionFailureFact, + type AgentSessionFailureFact +} from './agent-session-failure' +import { + sayAgentSessionFailureEnglish, + type AgentSessionFailureSay +} from './agent-session-failure-copy' +import { agentSessionFailureSentence } from './agent-session-failure-words' +import { agentSessionFailureStatedByRow } from './agent-session-visible-failures' +import type { AgentSessionWriteNoticeSentence } from './agent-session-write-notice-copy' +import { structuredAgentSessionCommandHostRefusalCause } from './structured-agent-session-command-refusal-cause' +import type { StructuredAgentSessionCommandRefusalCause } from './structured-agent-session-composer' + +/** Ahead leaves command text untouched behind an unanswered local send; other holds return an explicit refusal. */ +export type StructuredConversationCommandHold = + | 'ahead' + | 'working' + | 'prompt' + | 'background' + | 'sending' + +/** Queued commands wait only for earlier unaccepted sends; ordinary commands wait for all in-flight agent work. */ +export function structuredConversationCommandHold(input: { + /** A /compact the host can hold as a card behind the turn or prompt. */ + waitsInLine: boolean + /** A turn runs, or the chat shows the agent working on a message it has not answered. */ + agentWorking: boolean + promptPending: boolean + backgroundTasksRunning: boolean + sendPending: boolean +}): StructuredConversationCommandHold | null { + if (input.backgroundTasksRunning) { + return 'background' + } + if (input.waitsInLine) { + return input.sendPending ? 'ahead' : null + } + if (input.promptPending) { + return 'prompt' + } + if (input.agentWorking) { + return 'working' + } + if (input.sendPending) { + return 'sending' + } + return null +} + +/** The sentences a held command shows. */ +export function heldConversationCommandParts( + command: AgentSessionConversationCommand, + hold: Exclude +): AgentSessionWriteNoticeSentence[] { + const clear = command === 'clear' + switch (hold) { + case 'prompt': + return [clear ? 'clearAfterAnswer' : 'compactAfterAnswer'] + case 'working': + return ['agentStillWorking', clear ? 'runClearWhenDone' : 'runCompactWhenDone'] + case 'sending': + return [clear ? 'clearAfterSending' : 'compactAfterSending'] + case 'background': + return ['pendingWork'] + } +} + +/** Translate complete known facts/commands; preserve host text for older/newer payloads this client cannot interpret. */ +function conversationCommandFailureText( + result: AgentSessionConversationCommandResult, + agentName: string | undefined, + say: AgentSessionFailureSay +): string | null { + const fact = isAgentSessionConversationCommand(result.command) + ? readWholeAgentSessionFailureFact(result.failure) + : undefined + if (!fact) { + return result.error ?? null + } + return agentSessionFailureSentence(fact, 'row', { agentName, command: result.command }, say) +} + +/** What a command's reply shows: nothing for a start failure a row already states; otherwise its failure in words that name the agent, and the cause a host refusal names. */ +export function conversationCommandReply( + value: AgentSessionConversationCommandResult, + input: { + agentName?: string + /** What the chat's loaded start-failure rows state when the reply lands. */ + startFailures: readonly AgentSessionFailureFact[] + say?: AgentSessionFailureSay + } +): { accepted: boolean; error: string | null; cause?: StructuredAgentSessionCommandRefusalCause } { + // Clear/new-command failures may belong elsewhere and need host wording. + if ( + isAgentSessionConversationCommand(value.command) && + value.command !== 'clear' && + agentSessionFailureStatedByRow(value.failure, input.startFailures) + ) { + return { accepted: false, error: null } + } + const error = conversationCommandFailureText( + value, + input.agentName, + input.say ?? sayAgentSessionFailureEnglish + ) + if (value.state === 'completed' && !error) { + return { accepted: true, error: null } + } + const cause = structuredAgentSessionCommandHostRefusalCause(value) + return { accepted: false, error, ...(cause ? { cause } : {}) } +} diff --git a/tests/e2e/native-chat-transcript-file-order.unit.test.ts b/tests/e2e/native-chat-transcript-file-order.unit.test.ts index 192d7c11d57..100487bf523 100644 --- a/tests/e2e/native-chat-transcript-file-order.unit.test.ts +++ b/tests/e2e/native-chat-transcript-file-order.unit.test.ts @@ -336,7 +336,7 @@ describe('canonical source positions', () => { it('keeps factual clocks for status, clear boundaries, mixed sources and optimistic tiers', async () => { const { mergeNativeChatLiveSession } = - await import('../../src/renderer/src/components/native-chat/native-chat-live-status') + await import('../../src/shared/native-chat/native-chat-live-status') const { applyCommandMarkerBoundaries, commandMarkersAsMessages } = await import('../../src/renderer/src/components/native-chat/native-chat-command-marker') const { assembleNativeChatSession } =