diff --git a/app/session/[id].tsx b/app/session/[id].tsx index 9c28468..f864d72 100644 --- a/app/session/[id].tsx +++ b/app/session/[id].tsx @@ -1,7 +1,6 @@ import { Redirect, router, useLocalSearchParams } from "expo-router"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { - FlatList, KeyboardAvoidingView, Platform, Pressable, @@ -11,16 +10,20 @@ import { View, } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { identityLabel } from "@codeoid/core"; +import { FlashList } from "@shopify/flash-list"; import type { SessionMessage } from "@codeoid/protocol"; +import { ApprovalBar } from "@/components/ApprovalBar"; +import { MessageRow } from "@/components/MessageRow"; import { getConnection, mintClientMsgId, type Connection } from "@/lib/connection"; import { useConnectionStatus, useTranscript } from "@/lib/hooks"; import { palette } from "@/lib/theme"; // Transcript view: attach on entry (incremental resume when a cursor exists), -// live-render via MessageStore.ingest(), detach on leave. Plain-text rows -// first (design doc §10 P1) — FlashList + streaming markdown arrive in P2. +// live-render via MessageStore.ingest(), detach on leave. P2 upgrades the P1 +// plain-text rows to markdown + tool-call state machine + Edit/Write diffs +// (MessageRow), a docked approve/deny bar (ApprovalBar), and a recycling +// FlashList that sticks to the bottom while streaming. export default function SessionScreen() { const { id, name } = useLocalSearchParams<{ id: string; name?: string }>(); const conn = getConnection(); @@ -43,7 +46,6 @@ function Transcript({ const [draft, setDraft] = useState(""); const [sendError, setSendError] = useState(null); const [attachError, setAttachError] = useState(null); - const listRef = useRef>(null); // (Re-)attach on every `connected` transition — the initial entry and any // reconnect (a dropped socket loses the attachment). The resume cursor @@ -108,16 +110,20 @@ function Transcript({ {attachError ? attach failed: {attachError} : null} - m.messageId} - renderItem={({ item }) => } + renderItem={({ item }: { item: SessionMessage }) => } contentContainerStyle={styles.listContent} - onContentSizeChange={() => listRef.current?.scrollToEnd({ animated: false })} + maintainVisibleContentPosition={{ + startRenderingFromBottom: true, + autoscrollToBottomThreshold: 0.2, + }} ListEmptyComponent={Waiting for scrollback…} /> + + {sendError ? send failed: {sendError} : null} @@ -141,41 +147,6 @@ function Transcript({ ); } -function MessageRow({ msg }: { msg: SessionMessage }) { - const who = identityLabel(msg.identity); - if (msg.role === "tool_call" && msg.tool) { - const { tool } = msg; - const detail = - tool.state.phase === "waiting_confirmation" - ? `awaiting approval — ${tool.state.description}` - : tool.state.phase === "completed" - ? tool.state.success - ? "ok" - : "failed" - : tool.state.phase; - return ( - - - {who} · tool: {tool.name} - - - [{detail}]{msg.content ? `\n${msg.content}` : ""} - - - ); - } - return ( - - - {who} · {msg.role} - - - {msg.content} - - - ); -} - const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: palette.bg }, header: { @@ -190,17 +161,7 @@ const styles = StyleSheet.create({ back: { fontSize: 15, color: palette.accent }, title: { flex: 1, fontSize: 16, fontWeight: "600", color: palette.text }, status: { fontSize: 12, color: palette.amber }, - listContent: { padding: 16, gap: 14 }, - row: { gap: 4 }, - meta: { fontSize: 12, color: palette.textDim }, - content: { fontSize: 15, lineHeight: 21, color: palette.text }, - userContent: { color: palette.accent }, - toolText: { - fontSize: 13, - lineHeight: 18, - color: palette.textDim, - fontFamily: Platform.OS === "ios" ? "Menlo" : "monospace", - }, + listContent: { padding: 16 }, empty: { textAlign: "center", color: palette.textDim, marginTop: 48, fontSize: 14 }, error: { color: palette.red, fontSize: 12, paddingHorizontal: 16, paddingVertical: 4 }, composer: { diff --git a/package-lock.json b/package-lock.json index a5e075c..2d07972 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,8 @@ "@codeoid/core": "^0.1.0", "@codeoid/protocol": "^0.1.0", "@react-native-community/netinfo": "12.0.1", + "@shopify/flash-list": "^2.0.2", + "diff": "^9.0.0", "expo": "~57.0.4", "expo-constants": "~57.0.3", "expo-linking": "~57.0.2", @@ -21,8 +23,10 @@ "react": "19.2.3", "react-dom": "19.2.3", "react-native": "0.86.0", + "react-native-marked": "^8.1.1", "react-native-safe-area-context": "~5.7.0", "react-native-screens": "4.25.2", + "react-native-svg": "^15.15.4", "react-native-web": "~0.21.0" }, "devDependencies": { @@ -2075,6 +2079,23 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@jsamr/counter-style": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@jsamr/counter-style/-/counter-style-2.0.2.tgz", + "integrity": "sha512-2mXudGVtSzVxWEA7B9jZLKjoXUeUFYDDtFrQoC0IFX9/Dszz4t1vZOmafi3JSw/FxD+udMQ+4TAFR8Qs0J3URQ==", + "license": "MIT" + }, + "node_modules/@jsamr/react-native-li": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@jsamr/react-native-li/-/react-native-li-2.3.1.tgz", + "integrity": "sha512-Qbo4NEj48SQ4k8FZJHFE2fgZDKTWaUGmVxcIQh3msg5JezLdTMMHuRRDYctfdHI6L0FZGObmEv3haWbIvmol8w==", + "license": "MIT", + "peerDependencies": { + "@jsamr/counter-style": "^1.0.0 || ^2.0.0", + "react": "*", + "react-native": "*" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", @@ -2903,6 +2924,20 @@ "dev": true, "license": "MIT" }, + "node_modules/@shopify/flash-list": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@shopify/flash-list/-/flash-list-2.0.2.tgz", + "integrity": "sha512-zhlrhA9eiuEzja4wxVvotgXHtqd3qsYbXkQ3rsBfOgbFA9BVeErpDE/yEwtlIviRGEqpuFj/oU5owD6ByaNX+w==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "peerDependencies": { + "@babel/runtime": "*", + "react": "*", + "react-native": "*" + } + }, "node_modules/@sinclair/typebox": { "version": "0.27.10", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", @@ -4261,6 +4296,12 @@ "node": ">=0.6" } }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "license": "ISC" + }, "node_modules/bplist-creator": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/bplist-creator/-/bplist-creator-0.1.0.tgz", @@ -4753,6 +4794,56 @@ "hyphenate-style-name": "^1.0.3" } }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-tree": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.1.3.tgz", + "integrity": "sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==", + "license": "MIT", + "dependencies": { + "mdn-data": "2.0.14", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/css-tree/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, "node_modules/css.escape": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", @@ -4952,6 +5043,15 @@ "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", "license": "MIT" }, + "node_modules/diff": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-9.0.0.tgz", + "integrity": "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, "node_modules/dnssd-advertise": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/dnssd-advertise/-/dnssd-advertise-1.1.6.tgz", @@ -4978,6 +5078,61 @@ "license": "MIT", "peer": true }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -5020,6 +5175,18 @@ "node": ">= 0.8" } }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/error-stack-parser": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/error-stack-parser/-/error-stack-parser-2.1.4.tgz", @@ -6777,6 +6944,12 @@ "node": ">=6" } }, + "node_modules/github-slugger": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/github-slugger/-/github-slugger-2.0.0.tgz", + "integrity": "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==", + "license": "ISC" + }, "node_modules/glob": { "version": "13.0.6", "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", @@ -6987,6 +7160,22 @@ "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "license": "ISC" }, + "node_modules/html-entities": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz", + "integrity": "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/mdevils" + }, + { + "type": "patreon", + "url": "https://patreon.com/mdevils" + } + ], + "license": "MIT" + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -8329,6 +8518,18 @@ "tmpl": "1.0.5" } }, + "node_modules/marked": { + "version": "18.0.6", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.6.tgz", + "integrity": "sha512-MrV5puXBfuiy6wl6DLaq3BtIJQAJToAd5zt/ZKhRfGRAuFPALE7/4Y7jnxRQoEgK/pBgurGqLyAuRgZ2xOjr6w==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/marky": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/marky/-/marky-1.3.0.tgz", @@ -8345,6 +8546,12 @@ "node": ">= 0.4" } }, + "node_modules/mdn-data": { + "version": "2.0.14", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz", + "integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==", + "license": "CC0-1.0" + }, "node_modules/memoize-one": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz", @@ -8931,6 +9138,18 @@ "node": "^16.14.0 || >=18.0.0" } }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, "node_modules/nullthrows": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/nullthrows/-/nullthrows-1.1.1.tgz", @@ -9778,6 +9997,29 @@ "react-native": "*" } }, + "node_modules/react-native-marked": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/react-native-marked/-/react-native-marked-8.1.1.tgz", + "integrity": "sha512-J1f5oB6oq/REYlOXoYUDWGCS++MFjk2dXfiFS7CxolQW14WiMqM/J5A1GKdCrOiRGayRD41sEs9q/rRtAG0kPQ==", + "license": "MIT", + "dependencies": { + "@jsamr/counter-style": "2.0.2", + "@jsamr/react-native-li": "2.3.1", + "github-slugger": "2.0.0", + "html-entities": "2.6.0", + "marked": "18.0.6", + "react-native-reanimated-table": "0.0.2", + "svg-parser": "2.0.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": ">=16.8.6", + "react-native": ">=0.76.0", + "react-native-svg": ">=12.3.0" + } + }, "node_modules/react-native-reanimated": { "version": "4.5.1", "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.5.1.tgz", @@ -9794,6 +10036,16 @@ "react-native-worklets": "0.10.x" } }, + "node_modules/react-native-reanimated-table": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/react-native-reanimated-table/-/react-native-reanimated-table-0.0.2.tgz", + "integrity": "sha512-OeuqfU1AFEmHNTJlEOLWrV78JgAXnM0/ZrCm0Ab+9e5nwYJ+xab/UFXkNKz3Gyf08ZfLSNzwMQRjt3eZWPWoGA==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.8.0", + "react-native": ">=0.6.0" + } + }, "node_modules/react-native-safe-area-context": { "version": "5.7.0", "resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-5.7.0.tgz", @@ -9818,6 +10070,21 @@ "react-native": ">=0.82.0" } }, + "node_modules/react-native-svg": { + "version": "15.15.4", + "resolved": "https://registry.npmjs.org/react-native-svg/-/react-native-svg-15.15.4.tgz", + "integrity": "sha512-boT/vIRgj6zZKBpfTPJJiYWMbZE9duBMOwPK6kCSTgxsS947IFMOq9OgIFkpWZTB7t229H24pDRkh3W9ZK/J1A==", + "license": "MIT", + "dependencies": { + "css-select": "^5.1.0", + "css-tree": "^1.1.3", + "warn-once": "0.1.1" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, "node_modules/react-native-web": { "version": "0.21.2", "resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.21.2.tgz", @@ -10935,6 +11202,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/svg-parser": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/svg-parser/-/svg-parser-2.0.4.tgz", + "integrity": "sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ==", + "license": "MIT" + }, "node_modules/terminal-link": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/terminal-link/-/terminal-link-2.1.1.tgz", diff --git a/package.json b/package.json index 51ef5fb..6398575 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,8 @@ "@codeoid/core": "^0.1.0", "@codeoid/protocol": "^0.1.0", "@react-native-community/netinfo": "12.0.1", + "@shopify/flash-list": "^2.0.2", + "diff": "^9.0.0", "expo": "~57.0.4", "expo-constants": "~57.0.3", "expo-linking": "~57.0.2", @@ -30,8 +32,10 @@ "react": "19.2.3", "react-dom": "19.2.3", "react-native": "0.86.0", + "react-native-marked": "^8.1.1", "react-native-safe-area-context": "~5.7.0", "react-native-screens": "4.25.2", + "react-native-svg": "^15.15.4", "react-native-web": "~0.21.0" }, "devDependencies": { diff --git a/src/components/ApprovalBar.tsx b/src/components/ApprovalBar.tsx new file mode 100644 index 0000000..4525f63 --- /dev/null +++ b/src/components/ApprovalBar.tsx @@ -0,0 +1,155 @@ +/** + * Docked approve/deny bar for the pending tool call, mirroring the web UI's + * ApprovalBar semantics (web/src/components/transcript/ApprovalBar.tsx): + * + * - One approval at a time (the oldest pending in the current turn, resolved + * by the shared `findPendingApproval` kernel via `usePendingApproval`). + * - Deny is `session.approve` with `approved: false` — there is no separate + * deny verb. + * - v1 is BINARY approve/deny only. Input-editing (`updatedInput`) is + * deferred: the daemon doesn't broadcast a tool's `patchableKeys` to + * clients, and codex ignores `updatedInput` entirely, so a generic + * edit-then-approve affordance can't be honored uniformly yet. + * - Provider-agnostic for free: claude / codex / pi / acp all normalize to + * this one wire contract in the daemon; the bar never branches on provider. + * + * In-flight handling: once a decision is sent the buttons STAY disabled — the + * bar dismisses only when the daemon's tool-state delta lands (the tool leaves + * `waiting_confirmation`, so `usePendingApproval` returns null and this + * unmounts). Re-enabling early would let a stale second decision fire. The + * outer keys the inner by `approvalId`, so each new approval starts with fresh + * busy/error state without manual resets. + */ +import { useCallback, useState } from "react"; +import { ActivityIndicator, Pressable, StyleSheet, Text, View } from "react-native"; + +import { asEditInput, asWriteInput } from "@/lib/diff"; +import { usePendingApproval, type PendingApproval } from "@/lib/hooks"; +import { palette } from "@/lib/theme"; +import type { Connection } from "@/lib/connection"; + +export function ApprovalBar({ conn, sessionId }: { conn: Connection; sessionId: string }) { + const pending = usePendingApproval(conn, sessionId); + if (!pending) return null; + return ( + + ); +} + +function targetPath(input: unknown): string | null { + return asEditInput(input)?.file_path ?? asWriteInput(input)?.file_path ?? null; +} + +function ApprovalBarInner({ + conn, + sessionId, + pending, +}: { + conn: Connection; + sessionId: string; + pending: PendingApproval; +}) { + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + // Captured once at mount (keyed by approvalId) so a decision always targets + // the approval this bar was rendered for, even if a delta races in. + const { approvalId } = pending.state; + + const decide = useCallback( + async (approved: boolean) => { + if (busy) return; + setBusy(true); + setError(null); + try { + await conn.client.request({ + type: "session.approve", + id: conn.client.nextId(), + sessionId, + approvalId, + approved, + }); + // Stay disabled — the bar unmounts when the tool-state delta arrives. + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + setBusy(false); + } + }, + [busy, conn, sessionId, approvalId], + ); + + const path = targetPath(pending.tool.input ?? pending.state.input); + + return ( + + + Approval needed + + {pending.tool.name} + {path ? ` · ${path}` : ""} + + + {pending.state.description} + + {error ? approve failed: {error} : null} + + + decide(false)} + accessibilityRole="button" + accessibilityLabel="Deny tool call" + > + Deny + + decide(true)} + accessibilityRole="button" + accessibilityLabel="Approve tool call" + > + {busy ? ( + + ) : ( + Approve + )} + + + + ); +} + +const styles = StyleSheet.create({ + container: { + borderTopWidth: 1, + borderTopColor: palette.accent, + backgroundColor: palette.surface, + paddingHorizontal: 14, + paddingVertical: 10, + gap: 10, + }, + info: { gap: 3 }, + title: { color: palette.accent, fontSize: 12, fontWeight: "700", textTransform: "uppercase" }, + tool: { color: palette.text, fontSize: 14, fontWeight: "600" }, + desc: { color: palette.textDim, fontSize: 13, lineHeight: 18 }, + error: { color: palette.red, fontSize: 12, paddingTop: 2 }, + actions: { flexDirection: "row", gap: 10 }, + button: { + flex: 1, + borderRadius: 10, + paddingVertical: 12, + alignItems: "center", + justifyContent: "center", + }, + deny: { borderWidth: 1, borderColor: palette.red }, + denyLabel: { color: palette.red, fontSize: 15, fontWeight: "600" }, + approve: { backgroundColor: palette.green }, + approveLabel: { color: palette.bg, fontSize: 15, fontWeight: "700" }, + disabled: { opacity: 0.5 }, +}); diff --git a/src/components/DiffView.tsx b/src/components/DiffView.tsx new file mode 100644 index 0000000..a01dd69 --- /dev/null +++ b/src/components/DiffView.tsx @@ -0,0 +1,87 @@ +/** + * Renders the `DiffRow[]` produced by src/lib/diff.ts — a compact, monospace + * before/after view with +/- rails and old/new line numbers, collapsed to ±2 + * context lines. No syntax highlighting (the web uses shiki, which injects + * HTML and doesn't translate to RN); colored rails carry the signal instead. + */ +import React from "react"; +import { Platform, ScrollView, StyleSheet, Text, View } from "react-native"; + +import type { DiffRow } from "@/lib/diff"; +import { palette } from "@/lib/theme"; + +const MONO = Platform.OS === "ios" ? "Menlo" : "monospace"; + +function gutter(row: DiffRow): string { + if (row.type === "add") return "+"; + if (row.type === "del") return "-"; + return " "; +} + +const DiffLine = React.memo(function DiffLine({ row }: { row: DiffRow }) { + if (row.type === "gap") { + return ( + + {row.text} + + ); + } + const rowStyle = + row.type === "add" ? styles.addRow : row.type === "del" ? styles.delRow : styles.ctxRow; + const textStyle = + row.type === "add" ? styles.addText : row.type === "del" ? styles.delText : styles.ctxText; + return ( + + {row.newLine ?? row.oldLine ?? ""} + {gutter(row)} + {row.text === "" ? " " : row.text} + + ); +}); + +export function DiffView({ rows }: { rows: DiffRow[] }) { + return ( + + + {rows.map((row, i) => ( + + ))} + + + ); +} + +const styles = StyleSheet.create({ + container: { + borderWidth: StyleSheet.hairlineWidth, + borderColor: palette.border, + borderRadius: 8, + backgroundColor: palette.bg, + }, + content: { paddingVertical: 4 }, + row: { flexDirection: "row", alignItems: "flex-start", minWidth: "100%" }, + addRow: { backgroundColor: "rgba(63,185,80,0.12)" }, + delRow: { backgroundColor: "rgba(248,81,73,0.12)" }, + ctxRow: { backgroundColor: "transparent" }, + lineNo: { + width: 40, + paddingRight: 8, + textAlign: "right", + color: palette.textDim, + fontFamily: MONO, + fontSize: 11, + lineHeight: 18, + }, + sign: { width: 12, fontFamily: MONO, fontSize: 12, lineHeight: 18 }, + code: { fontFamily: MONO, fontSize: 12, lineHeight: 18, paddingRight: 12 }, + addText: { color: palette.green }, + delText: { color: palette.red }, + ctxText: { color: palette.textDim }, + gapRow: { paddingVertical: 2, paddingLeft: 52 }, + gapText: { color: palette.textDim, fontFamily: MONO, fontSize: 11, fontStyle: "italic" }, +}); diff --git a/src/components/MarkdownText.tsx b/src/components/MarkdownText.tsx new file mode 100644 index 0000000..13b5943 --- /dev/null +++ b/src/components/MarkdownText.tsx @@ -0,0 +1,72 @@ +/** + * Markdown rendering for assistant / thinking content. + * + * Uses react-native-marked's `useMarkdown` hook (not its `` + * component, which renders its own FlatList — we must never nest a virtualized + * list inside the transcript FlashList row). The hook returns an array of + * elements we drop into a plain View. + * + * We deliberately chose a PURE-JS renderer (react-native-marked, whose only + * native peer — react-native-svg — ships inside Expo Go) over the design doc's + * react-native-streamdown, whose native peers (react-native-enriched-markdown) + * are NOT in Expo Go and would force a custom dev build. Keeping this slice + * Expo-Go-runnable is the whole point of shipping approvals + streaming first. + * + * `React.memo` on `text` is load-bearing: transcript deltas mutate the message + * object in place, so every visible row re-renders on each frame. Memoizing by + * the `text` string means only the row whose content actually changed (the one + * being streamed) re-parses; every settled message is parsed exactly once. + */ +import React, { Fragment } from "react"; +import { Platform, StyleSheet, View } from "react-native"; +import { useMarkdown, type MarkedStyles } from "react-native-marked"; + +import { palette } from "@/lib/theme"; + +const MONO = Platform.OS === "ios" ? "Menlo" : "monospace"; + +const mdStyles: MarkedStyles = { + text: { color: palette.text, fontSize: 15, lineHeight: 21 }, + paragraph: { paddingVertical: 4 }, + strong: { color: palette.text, fontWeight: "700" }, + em: { color: palette.text, fontStyle: "italic" }, + link: { color: palette.accent }, + h1: { color: palette.text, fontSize: 20, fontWeight: "700" }, + h2: { color: palette.text, fontSize: 18, fontWeight: "700" }, + h3: { color: palette.text, fontSize: 16, fontWeight: "700" }, + h4: { color: palette.text, fontSize: 15, fontWeight: "700" }, + h5: { color: palette.text, fontSize: 15, fontWeight: "600" }, + h6: { color: palette.text, fontSize: 15, fontWeight: "600" }, + codespan: { + color: palette.text, + backgroundColor: palette.surface, + fontFamily: MONO, + fontSize: 13, + }, + code: { + backgroundColor: palette.surface, + borderColor: palette.border, + borderWidth: StyleSheet.hairlineWidth, + borderRadius: 8, + padding: 10, + }, + blockquote: { + borderLeftColor: palette.border, + borderLeftWidth: 3, + paddingLeft: 10, + opacity: 0.9, + }, + li: { color: palette.text, fontSize: 15, lineHeight: 21 }, + hr: { backgroundColor: palette.border }, +}; + +export const MarkdownText = React.memo(function MarkdownText({ text }: { text: string }) { + const elements = useMarkdown(text, { colorScheme: "dark", styles: mdStyles }); + return ( + + {elements.map((el, i) => ( + {el} + ))} + + ); +}); diff --git a/src/components/MessageRow.tsx b/src/components/MessageRow.tsx new file mode 100644 index 0000000..7846a74 --- /dev/null +++ b/src/components/MessageRow.tsx @@ -0,0 +1,85 @@ +/** + * Transcript row dispatcher. Maps a SessionMessage's role to a renderer: + * - user → plain text (accent) + * - assistant → streaming markdown + * - thinking → dimmed markdown, labelled + * - tool_call → ToolCard (state machine + Edit/Write diff) + * - tool_result → dimmed monospace (output usually shown on the card) + * - system / info → status line + * + * Rich `parts[]` rendering (code/table/tree/image/… beyond plain text) is a + * follow-up; assistant text renders from the authoritative `content` string, + * matching the web UI, which reserves `parts` for genuinely rich content. + */ +import React from "react"; +import { Platform, StyleSheet, Text, View } from "react-native"; +import { identityLabel } from "@codeoid/core"; +import type { SessionMessage } from "@codeoid/protocol"; + +import type { Connection } from "@/lib/connection"; +import { useMessageVersion } from "@/lib/hooks"; +import { palette } from "@/lib/theme"; +import { MarkdownText } from "./MarkdownText"; +import { ToolCard } from "./ToolCard"; + +const MONO = Platform.OS === "ios" ? "Menlo" : "monospace"; + +function Body({ msg }: { msg: SessionMessage }) { + switch (msg.role) { + case "tool_call": + return ; + case "assistant": + return msg.content ? : null; + case "thinking": + return ( + + {msg.content ? : null} + + ); + case "user": + return {msg.content}; + case "tool_result": + return msg.content ? {msg.content} : null; + case "system": + return {msg.content}; + case "info": + default: + return msg.content ? {msg.content} : null; + } +} + +function roleLabel(msg: SessionMessage): string { + return msg.role === "thinking" ? "thinking" : msg.role; +} + +export const MessageRow = React.memo(function MessageRow({ + conn, + msg, +}: { + conn: Connection; + msg: SessionMessage; +}) { + // Re-render this row when its own message mutates in place (streaming append, + // tool-state transition) — the memo skips list churn, this subscription + // drives the live updates. + useMessageVersion(conn, msg.messageId); + const who = identityLabel(msg.identity); + return ( + + + {who} · {roleLabel(msg)} + + + + ); +}); + +const styles = StyleSheet.create({ + row: { gap: 4 }, + meta: { fontSize: 12, color: palette.textDim }, + userText: { fontSize: 15, lineHeight: 21, color: palette.accent }, + thinking: { opacity: 0.7 }, + toolResult: { fontFamily: MONO, fontSize: 12, lineHeight: 17, color: palette.textDim }, + system: { fontSize: 13, lineHeight: 19, color: palette.amber }, + info: { fontSize: 13, lineHeight: 19, color: palette.textDim, fontStyle: "italic" }, +}); diff --git a/src/components/ToolCard.tsx b/src/components/ToolCard.tsx new file mode 100644 index 0000000..60b58fd --- /dev/null +++ b/src/components/ToolCard.tsx @@ -0,0 +1,146 @@ +/** + * A tool_call message rendered across its lifecycle phases, mirroring the web + * UI's ToolBlock (web/src/components/transcript/MessageRow.tsx): + * + * - Edit / Write tools render a persistent before/after DIFF across every + * phase, derived from `tool.input` (which survives phase transitions) with + * a fallback to the waiting-confirmation input. + * - Every other tool renders a phase-specific body: the confirmation + * description, running progress/elapsed, completed output, or the + * cancellation reason. + * + * The pending-approval ACTION lives in the docked ApprovalBar, not here — this + * card is the read-only record of what the tool is/was doing. + */ +import React from "react"; +import { Platform, StyleSheet, Text, View } from "react-native"; +import type { SessionMessage, ToolState } from "@codeoid/protocol"; + +import { asEditInput, asWriteInput, computeEditRows, computeWriteRows } from "@/lib/diff"; +import { palette } from "@/lib/theme"; +import { DiffView } from "./DiffView"; + +const MONO = Platform.OS === "ios" ? "Menlo" : "monospace"; +const MAX_OUTPUT = 4000; + +function phaseColor(phase: ToolState["phase"]): string { + switch (phase) { + case "completed": + return palette.green; + case "executing": + case "streaming": + return palette.amber; + case "waiting_confirmation": + return palette.accent; + case "cancelled": + return palette.red; + default: + return palette.textDim; + } +} + +function phaseLabel(state: ToolState): string { + switch (state.phase) { + case "completed": + return state.success ? "done" : "failed"; + case "waiting_confirmation": + return "awaiting approval"; + default: + return state.phase.replace(/_/g, " "); + } +} + +function formatDuration(ms?: number): string | null { + if (ms == null) return null; + if (ms < 1000) return `${ms}ms`; + return `${(ms / 1000).toFixed(1)}s`; +} + +function truncate(text: string, max: number): string { + return text.length > max ? `${text.slice(0, max)}\n… (${text.length - max} more chars)` : text; +} + +function ToolBody({ state }: { state: ToolState }) { + switch (state.phase) { + case "waiting_confirmation": + return {state.description}; + case "executing": { + const elapsed = formatDuration(state.elapsedMs); + const text = [state.progress, elapsed ? `(${elapsed})` : null].filter(Boolean).join(" "); + return text ? {text} : null; + } + case "completed": + return state.output ? ( + {truncate(state.output, MAX_OUTPUT)} + ) : null; + case "cancelled": + return ( + + {state.reason} + {state.message ? ` — ${state.message}` : ""} + + ); + default: + return null; + } +} + +export const ToolCard = React.memo(function ToolCard({ message }: { message: SessionMessage }) { + const tool = message.tool; + if (!tool) return null; + const { state } = tool; + const input = tool.input ?? (state.phase === "waiting_confirmation" ? state.input : undefined); + const edit = asEditInput(input); + const write = edit ? null : asWriteInput(input); + const failed = state.phase === "completed" && !state.success; + + return ( + + + {tool.name} + {phaseLabel(state)} + + + {edit ? ( + <> + {edit.file_path} + + + ) : write ? ( + <> + {write.file_path} + + + ) : ( + + )} + + {failed ? edit failed : null} + + ); +}); + +const styles = StyleSheet.create({ + card: { + borderWidth: StyleSheet.hairlineWidth, + borderColor: palette.border, + borderRadius: 10, + backgroundColor: palette.surface, + padding: 10, + gap: 8, + }, + cardFailed: { borderColor: palette.red }, + header: { flexDirection: "row", alignItems: "center", gap: 8 }, + name: { flex: 1, color: palette.text, fontFamily: MONO, fontSize: 13, fontWeight: "600" }, + badge: { fontSize: 11, fontWeight: "600", textTransform: "lowercase" }, + path: { color: palette.textDim, fontFamily: MONO, fontSize: 12 }, + desc: { color: palette.text, fontSize: 14, lineHeight: 20 }, + dim: { color: palette.textDim, fontSize: 13, lineHeight: 18 }, + output: { + color: palette.textDim, + fontFamily: MONO, + fontSize: 12, + lineHeight: 17, + }, + failed: { color: palette.red, fontSize: 12, fontWeight: "600" }, +}); diff --git a/src/lib/diff.ts b/src/lib/diff.ts new file mode 100644 index 0000000..a46b034 --- /dev/null +++ b/src/lib/diff.ts @@ -0,0 +1,125 @@ +/** + * Edit/Write tool-input → renderable diff rows. + * + * Mirrors the web UI's approach (web/src/components/transcript/EditDiff.tsx): + * compute a real line diff from the tool's own `input` with jsdiff, then + * collapse long unchanged runs to keep the card readable on a phone. The + * daemon carries the diff summary as a `DiffPart` too, but only as a chip + * (path + counts); the rich before/after view is reconstructed client-side + * from `Edit`/`Write` input, exactly as the web does. + */ +import { diffLines } from "diff"; + +export interface EditInput { + file_path: string; + old_string: string; + new_string: string; +} + +export interface WriteInput { + file_path: string; + content: string; +} + +/** Narrow an unknown tool input to the `Edit` shape, or null. */ +export function asEditInput(input: unknown): EditInput | null { + if (!input || typeof input !== "object") return null; + const o = input as Record; + if ( + typeof o.file_path === "string" && + typeof o.old_string === "string" && + typeof o.new_string === "string" + ) { + return { file_path: o.file_path, old_string: o.old_string, new_string: o.new_string }; + } + return null; +} + +/** Narrow an unknown tool input to the `Write` shape, or null. */ +export function asWriteInput(input: unknown): WriteInput | null { + if (!input || typeof input !== "object") return null; + const o = input as Record; + if (typeof o.file_path === "string" && typeof o.content === "string") { + return { file_path: o.file_path, content: o.content }; + } + return null; +} + +export type DiffRowType = "add" | "del" | "context" | "gap"; + +export interface DiffRow { + type: DiffRowType; + /** Line text (no trailing newline); for `gap`, a "N unchanged lines" summary. */ + text: string; + /** 1-based line number in the old file (context + del rows). */ + oldLine?: number; + /** 1-based line number in the new file (context + add rows). */ + newLine?: number; +} + +function splitLines(value: string): string[] { + const lines = value.split("\n"); + // A value ending in "\n" yields a trailing "" — drop it so we don't render a + // phantom blank line per hunk. + if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop(); + return lines; +} + +function gapLabel(n: number): string { + return `⋯ ${n} unchanged line${n === 1 ? "" : "s"}`; +} + +/** + * Collapse runs of unchanged lines to a single `gap` row, keeping `context` + * lines on each side of every change (the ±2 convention the web uses). + */ +function collapseContext(rows: DiffRow[], context: number): DiffRow[] { + const keep = new Array(rows.length).fill(false); + for (let i = 0; i < rows.length; i++) { + if (rows[i].type !== "context") { + const lo = Math.max(0, i - context); + const hi = Math.min(rows.length - 1, i + context); + for (let j = lo; j <= hi; j++) keep[j] = true; + } + } + const out: DiffRow[] = []; + let hidden = 0; + for (let i = 0; i < rows.length; i++) { + if (keep[i]) { + if (hidden > 0) { + out.push({ type: "gap", text: gapLabel(hidden) }); + hidden = 0; + } + out.push(rows[i]); + } else { + hidden++; + } + } + if (hidden > 0) out.push({ type: "gap", text: gapLabel(hidden) }); + return out; +} + +/** Two-sided line diff for an `Edit`, collapsed to ±`context` lines. */ +export function computeEditRows(oldStr: string, newStr: string, context = 2): DiffRow[] { + const rows: DiffRow[] = []; + let oldNo = 1; + let newNo = 1; + for (const part of diffLines(oldStr, newStr)) { + const kind: DiffRowType = part.added ? "add" : part.removed ? "del" : "context"; + for (const text of splitLines(part.value)) { + if (kind === "add") rows.push({ type: "add", text, newLine: newNo++ }); + else if (kind === "del") rows.push({ type: "del", text, oldLine: oldNo++ }); + else rows.push({ type: "context", text, oldLine: oldNo++, newLine: newNo++ }); + } + } + return collapseContext(rows, context); +} + +/** Single-sided "added" view for a `Write`, capped at `maxLines`. */ +export function computeWriteRows(content: string, maxLines = 400): DiffRow[] { + const lines = splitLines(content); + const shown = lines.slice(0, maxLines); + const rows: DiffRow[] = shown.map((text, i) => ({ type: "add", text, newLine: i + 1 })); + if (lines.length > maxLines) rows.push({ type: "gap", text: gapLabel(lines.length - maxLines) }); + return rows; +} diff --git a/src/lib/hooks.ts b/src/lib/hooks.ts index 70b4ecf..654b74b 100644 --- a/src/lib/hooks.ts +++ b/src/lib/hooks.ts @@ -3,11 +3,14 @@ * The store/client own the state; these hooks only subscribe and re-render. */ import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react"; -import type { ClientStatus } from "@codeoid/core"; +import { findPendingApproval, type ClientStatus } from "@codeoid/core"; import type { SessionInfo, SessionListResultMsg, SessionMessage, + SessionStatus, + ToolInfo, + ToolWaitingConfirmationState, } from "@codeoid/protocol"; import type { Connection } from "./connection"; @@ -87,18 +90,46 @@ export function useSessions(conn: Connection): { return { sessions, error, refresh }; } +/** + * Subscribe to a MessageStore, coalescing bursty streaming notifications to one + * repaint per animation frame (the web UI's `createFrameThrottled` equivalent) + * so a fast token stream can't thrash renders. `match` filters which + * (sessionId, messageId) notifications are relevant. The caller's `getSnapshot` + * still reads fresh state on every render, so there is no tearing — only the + * repaint cadence is bounded. + */ +function subscribeStoreFrame( + store: Connection["store"], + match: (sessionId: string, messageId: string | null) => boolean, + onChange: () => void, +): () => void { + let frame = 0; + const flush = () => { + frame = 0; + onChange(); + }; + const off = store.onChange((sid, mid) => { + if (!match(sid, mid)) return; + if (frame === 0) frame = requestAnimationFrame(flush); + }); + return () => { + if (frame !== 0) cancelAnimationFrame(frame); + off(); + }; +} + /** * A session's transcript, re-read on every store epoch bump (message upsert, - * streaming delta, replay). Returns a fresh array per epoch so list views - * see a new identity and re-render. + * streaming delta, replay). Returns a fresh array per epoch so the list sees a + * new `data` identity when messages are added/replayed. Individual rows track + * their own content via {@link useMessageVersion} — the store mutates message + * objects in place, so array identity alone can't drive per-row updates. */ export function useTranscript(conn: Connection, sessionId: string): SessionMessage[] { const epoch = useSyncExternalStore( useCallback( (onChange: () => void) => - conn.store.onChange((sid) => { - if (sid === sessionId) onChange(); - }), + subscribeStoreFrame(conn.store, (sid) => sid === sessionId, onChange), [conn, sessionId], ), () => conn.store.epochOf(sessionId), @@ -106,3 +137,105 @@ export function useTranscript(conn: Connection, sessionId: string): SessionMessa // eslint-disable-next-line react-hooks/exhaustive-deps -- epoch is the change signal for the store's live array return useMemo(() => [...conn.store.messagesFor(sessionId)], [conn, sessionId, epoch]); } + +/** + * A single message's monotonic version, bumped on every in-place mutation + * (streaming content append, tool-state transition). A memoized row subscribes + * to this so it re-renders as ITS OWN message changes — independent of the + * list's reconciliation. The store mutates message objects in place, so a row + * memoized on the message reference would otherwise freeze mid-stream; the + * version bump is the reliable change signal (mirrors the web `versionOf`). + */ +export function useMessageVersion(conn: Connection, messageId: string): number { + return useSyncExternalStore( + useCallback( + (onChange: () => void) => + subscribeStoreFrame( + conn.store, + (_sid, mid) => mid === messageId || mid === null, + onChange, + ), + [conn, messageId], + ), + () => conn.store.versionOf(messageId), + ); +} + +/** + * A single session's live status, sourced the same way the web UI sources it: + * a `session.list` snapshot on every `connected` transition (initial connect + * and reconnects) kept live by the daemon's canonical `session.status_change` + * / `session.info_update` broadcasts. Returns `undefined` until the first + * snapshot lands. + * + * `findPendingApproval` needs this status to decide whether an approval can be + * pending — see {@link usePendingApproval}. + */ +export function useSessionStatus(conn: Connection, sessionId: string): SessionStatus | undefined { + const [status, setStatus] = useState(undefined); + + useEffect(() => { + let last: string | null = null; + return conn.client.onStatus((s) => { + if (s.kind === "connected" && last !== "connected") { + const id = conn.client.nextId(); + conn.client + .request( + { type: "session.list", id }, + { + waitForResult: (m) => + m.type === "session.list.result" && m.requestId === id ? m : undefined, + }, + ) + .then((result) => { + const found = result.sessions.find((x) => x.id === sessionId); + if (found) setStatus(found.status); + }) + .catch(() => { + // A failed snapshot just leaves status undefined; the next + // status_change broadcast (or reconnect) recovers it. + }); + } + last = s.kind; + }); + }, [conn, sessionId]); + + useEffect( + () => + conn.client.onMessage((msg) => { + if (msg.type === "session.status_change" && msg.sessionId === sessionId) { + setStatus(msg.status); + } else if (msg.type === "session.info_update" && msg.session.id === sessionId) { + setStatus(msg.session.status); + } + }), + [conn, sessionId], + ); + + return status; +} + +/** The tool + narrowed waiting-confirmation state of a resolved pending approval. */ +export interface PendingApproval { + message: SessionMessage; + tool: ToolInfo; + state: ToolWaitingConfirmationState; +} + +/** + * The session's current pending tool approval, or null. Composes the throttled + * transcript (the change signal, incl. in-place tool-state deltas) with the + * live session status and delegates the turn-bounded scan to the shared + * `@codeoid/core` `findPendingApproval` kernel — so mobile stays byte-for-byte + * consistent with the web bar and desktop notifications, and provider-agnostic: + * every backend's native gate is already normalized to this one wire contract. + */ +export function usePendingApproval(conn: Connection, sessionId: string): PendingApproval | null { + const messages = useTranscript(conn, sessionId); + const status = useSessionStatus(conn, sessionId); + return useMemo(() => { + const m = findPendingApproval(messages, status); + if (!m || !m.tool || m.tool.state.phase !== "waiting_confirmation") return null; + return { message: m, tool: m.tool, state: m.tool.state }; + }, [messages, status]); +}