diff --git a/mobile/rpc-foundation/goldens/matrix-session.native-chat-page-nativechat.readsession-1.json b/mobile/rpc-foundation/goldens/matrix-session.native-chat-page-nativechat.readsession-1.json index 1b0aad90967..ddd2bfb0c6a 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.native-chat-page-nativechat.readsession-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.native-chat-page-nativechat.readsession-1.json @@ -22,41 +22,6 @@ "status": "ready", "transcriptLoading": false }, - "1821d5c2646a": { - "name": "nativeChat.readSession#1", - "ordinal": 2, - "args": [ - { - "name": "method", - "value": "nativeChat.readSession" - }, - { - "name": "params", - "value": { - "agent": "claude", - "beforeOffset": 1200, - "limit": 60, - "sessionId": "session-1", - "transcriptPath": "/work/feature/.claude/session-1.jsonl" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-2", - "ok": true - } - } - }, "197fa03857dd": { "crash": { "$rpc": "null" @@ -70,95 +35,17 @@ "status": "ready", "transcriptLoading": false }, - "2dd353a0e048": { - "name": "nativeChat.readSession#1", - "ordinal": 2, - "args": [ - { - "name": "method", - "value": "nativeChat.readSession" - }, - { - "name": "params", - "value": { - "agent": "claude", - "beforeOffset": 1200, - "limit": 60, - "sessionId": "session-1", - "transcriptPath": "/work/feature/.claude/session-1.jsonl" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "error": { - "code": "refused", - "message": "" - }, - "id": "frame-2", - "ok": false - } - } - }, "3e14b1ec5bde": { "name": "nativeChat.unsubscribe#2", "ordinal": 6, "json": "{\"id\":\"frame-5\",\"deviceToken\":\"recording-device\",\"method\":\"nativeChat.unsubscribe\",\"params\":{\"subscriptionId\":\"claude:session-1:00000000-0000-4000-8000-000000000001\"}}" }, - "41f222acdcb2": { - "name": "nativeChat.readSession#1", - "ordinal": 2, - "args": [ - { - "name": "method", - "value": "nativeChat.readSession" - }, - { - "name": "params", - "value": { - "agent": "claude", - "beforeOffset": 1200, - "limit": 60, - "sessionId": "session-1", - "transcriptPath": "/work/feature/.claude/session-1.jsonl" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "error": { - "code": "refused", - "message": "outer refused" - }, - "id": "frame-2", - "ok": false - } - } - }, "456781b97acc": { "name": "nativeChat.subscribe#1", "ordinal": 1, "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"nativeChat.subscribe\",\"params\":{\"agent\":\"claude\",\"sessionId\":\"session-1\",\"limit\":40,\"subscriptionId\":\"claude:session-1:00000000-0000-4000-8000-000000000001\",\"capabilities\":{\"transcriptPending\":1},\"transcriptPath\":\"/work/feature/.claude/session-1.jsonl\"}}" }, - "63d9cc126261": { + "65ff352ca953": { "name": "nativeChat.readSession#1", "ordinal": 2, "args": [ @@ -179,7 +66,8 @@ { "name": "options", "value": { - "$rpc": "absent" + "budgetSpansConnect": true, + "timeoutMs": 15000 } } ], @@ -189,7 +77,7 @@ "settledAt": 0, "error": { "category": "Error", - "message": "transport failure", + "message": "", "isRpcDeliveryUnknown": true } } @@ -199,6 +87,42 @@ "ordinal": 4, "json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"nativeChat.subscribe\",\"params\":{\"agent\":\"claude\",\"sessionId\":\"session-1\",\"limit\":40,\"subscriptionId\":\"claude:session-1:00000000-0000-4000-8000-000000000001\",\"capabilities\":{\"transcriptPending\":1},\"transcriptPath\":\"/work/feature/.claude/session-1.jsonl\"}}" }, + "75842f212e52": { + "name": "nativeChat.readSession#1", + "ordinal": 2, + "args": [ + { + "name": "method", + "value": "nativeChat.readSession" + }, + { + "name": "params", + "value": { + "agent": "claude", + "beforeOffset": 1200, + "limit": 60, + "sessionId": "session-1", + "transcriptPath": "/work/feature/.claude/session-1.jsonl" + } + }, + { + "name": "options", + "value": { + "budgetSpansConnect": true, + "timeoutMs": 15000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-2", + "ok": true + } + } + }, "7b237e9824c8": { "crash": { "$rpc": "null" @@ -225,61 +149,12 @@ "status": "ready", "transcriptLoading": false }, - "7ee0fd6f2bd8": { - "name": "nativeChat.readSession#1", - "ordinal": 2, - "args": [ - { - "name": "method", - "value": "nativeChat.readSession" - }, - { - "name": "params", - "value": { - "agent": "claude", - "beforeOffset": 1200, - "limit": 60, - "sessionId": "session-1", - "transcriptPath": "/work/feature/.claude/session-1.jsonl" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "rejected", - "startedAt": 0, - "settledAt": 0, - "error": { - "category": "Error", - "message": "", - "isRpcDeliveryUnknown": true - } - } - }, "7eedfcf76fd3": { "name": "nativeChat.unsubscribe#1", "ordinal": 5, "json": "{\"id\":\"frame-4\",\"deviceToken\":\"recording-device\",\"method\":\"nativeChat.unsubscribe\",\"params\":{\"subscriptionId\":\"claude:session-1:00000000-0000-4000-8000-000000000001\"}}" }, - "8f91342d7840": { - "crash": { - "$rpc": "null" - }, - "error": { - "$rpc": "null" - }, - "hasMore": false, - "loadingEarlier": false, - "messageIds": ["m-1", "m-2", "m-3", "m-4", "m-5"], - "status": "ready", - "transcriptLoading": false - }, - "9a114b6ce2df": { + "81eaceac1130": { "name": "nativeChat.readSession#1", "ordinal": 2, "args": [ @@ -300,7 +175,8 @@ { "name": "options", "value": { - "$rpc": "absent" + "budgetSpansConnect": true, + "timeoutMs": 15000 } } ], @@ -309,15 +185,16 @@ "startedAt": 0, "settledAt": 0, "value": { + "error": { + "code": "refused", + "message": "outer refused" + }, "id": "frame-2", - "ok": true, - "result": { - "error": "refused" - } + "ok": false } } }, - "9a3f65995021": { + "84f6bcad6466": { "name": "nativeChat.readSession#1", "ordinal": 2, "args": [ @@ -338,7 +215,8 @@ { "name": "options", "value": { - "$rpc": "absent" + "budgetSpansConnect": true, + "timeoutMs": 15000 } } ], @@ -358,45 +236,20 @@ } } }, - "a92af671fd39": { - "name": "nativeChat.readSession#1", - "ordinal": 2, - "args": [ - { - "name": "method", - "value": "nativeChat.readSession" - }, - { - "name": "params", - "value": { - "agent": "claude", - "beforeOffset": 1200, - "limit": 60, - "sessionId": "session-1", - "transcriptPath": "/work/feature/.claude/session-1.jsonl" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-2", - "ok": true, - "result": { - "$rpc": "null" - } - } - } + "8f91342d7840": { + "crash": { + "$rpc": "null" + }, + "error": { + "$rpc": "null" + }, + "hasMore": false, + "loadingEarlier": false, + "messageIds": ["m-1", "m-2", "m-3", "m-4", "m-5"], + "status": "ready", + "transcriptLoading": false }, - "cea1d50a73fa": { + "911b3f485409": { "name": "nativeChat.readSession#1", "ordinal": 2, "args": [ @@ -417,85 +270,8 @@ { "name": "options", "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "error": { - "code": "method_not_found", - "message": "Unknown method" - }, - "id": "frame-2", - "ok": false - } - } - }, - "d163a9801643": { - "name": "nativeChat.readSession#1", - "ordinal": 2, - "args": [ - { - "name": "method", - "value": "nativeChat.readSession" - }, - { - "name": "params", - "value": { - "agent": "claude", - "beforeOffset": 1200, - "limit": 60, - "sessionId": "session-1", - "transcriptPath": "/work/feature/.claude/session-1.jsonl" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-2", - "ok": true, - "result": { - "error": "inner refused", - "ok": false - } - } - } - }, - "d83fbb18104a": { - "name": "nativeChat.readSession#1", - "ordinal": 2, - "args": [ - { - "name": "method", - "value": "nativeChat.readSession" - }, - { - "name": "params", - "value": { - "agent": "claude", - "beforeOffset": 1200, - "limit": 60, - "sessionId": "session-1", - "transcriptPath": "/work/feature/.claude/session-1.jsonl" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" + "budgetSpansConnect": true, + "timeoutMs": 15000 } } ], @@ -539,7 +315,7 @@ } } }, - "dadae0a56731": { + "a3e69f56e5e2": { "name": "nativeChat.readSession#1", "ordinal": 2, "args": [ @@ -560,7 +336,86 @@ { "name": "options", "value": { - "$rpc": "absent" + "budgetSpansConnect": true, + "timeoutMs": 15000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-2", + "ok": true, + "result": { + "$rpc": "null" + } + } + } + }, + "ab5d827d2b71": { + "name": "nativeChat.readSession#1", + "ordinal": 2, + "args": [ + { + "name": "method", + "value": "nativeChat.readSession" + }, + { + "name": "params", + "value": { + "agent": "claude", + "beforeOffset": 1200, + "limit": 60, + "sessionId": "session-1", + "transcriptPath": "/work/feature/.claude/session-1.jsonl" + } + }, + { + "name": "options", + "value": { + "budgetSpansConnect": true, + "timeoutMs": 15000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-2", + "ok": true, + "result": { + "error": "refused" + } + } + } + }, + "e88c218171fa": { + "name": "nativeChat.readSession#1", + "ordinal": 2, + "args": [ + { + "name": "method", + "value": "nativeChat.readSession" + }, + { + "name": "params", + "value": { + "agent": "claude", + "beforeOffset": 1200, + "limit": 60, + "sessionId": "session-1", + "transcriptPath": "/work/feature/.claude/session-1.jsonl" + } + }, + { + "name": "options", + "value": { + "budgetSpansConnect": true, + "timeoutMs": 15000 } } ], @@ -577,6 +432,46 @@ "$rpc": "undefined" } }, + "ee33a7e2ed08": { + "name": "nativeChat.readSession#1", + "ordinal": 2, + "args": [ + { + "name": "method", + "value": "nativeChat.readSession" + }, + { + "name": "params", + "value": { + "agent": "claude", + "beforeOffset": 1200, + "limit": 60, + "sessionId": "session-1", + "transcriptPath": "/work/feature/.claude/session-1.jsonl" + } + }, + { + "name": "options", + "value": { + "budgetSpansConnect": true, + "timeoutMs": 15000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "refused", + "message": "" + }, + "id": "frame-2", + "ok": false + } + } + }, "ee46c03cf1b8": { "crash": { "$rpc": "null" @@ -589,6 +484,123 @@ "messageIds": [], "status": "loading", "transcriptLoading": true + }, + "fa953f37a758": { + "name": "nativeChat.readSession#1", + "ordinal": 2, + "args": [ + { + "name": "method", + "value": "nativeChat.readSession" + }, + { + "name": "params", + "value": { + "agent": "claude", + "beforeOffset": 1200, + "limit": 60, + "sessionId": "session-1", + "transcriptPath": "/work/feature/.claude/session-1.jsonl" + } + }, + { + "name": "options", + "value": { + "budgetSpansConnect": true, + "timeoutMs": 15000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-2", + "ok": true, + "result": { + "error": "inner refused", + "ok": false + } + } + } + }, + "fcea12946cf7": { + "name": "nativeChat.readSession#1", + "ordinal": 2, + "args": [ + { + "name": "method", + "value": "nativeChat.readSession" + }, + { + "name": "params", + "value": { + "agent": "claude", + "beforeOffset": 1200, + "limit": 60, + "sessionId": "session-1", + "transcriptPath": "/work/feature/.claude/session-1.jsonl" + } + }, + { + "name": "options", + "value": { + "budgetSpansConnect": true, + "timeoutMs": 15000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "method_not_found", + "message": "Unknown method" + }, + "id": "frame-2", + "ok": false + } + } + }, + "fd440192b538": { + "name": "nativeChat.readSession#1", + "ordinal": 2, + "args": [ + { + "name": "method", + "value": "nativeChat.readSession" + }, + { + "name": "params", + "value": { + "agent": "claude", + "beforeOffset": 1200, + "limit": 60, + "sessionId": "session-1", + "transcriptPath": "/work/feature/.claude/session-1.jsonl" + } + }, + { + "name": "options", + "value": { + "budgetSpansConnect": true, + "timeoutMs": 15000 + } + } + ], + "settlement": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "transport failure", + "isRpcDeliveryUnknown": true + } + } } }, "recording": { @@ -621,7 +633,7 @@ { "id": "native-chat-page-earlier.prelude:paging", "observation": { - "sender": ["dadae0a56731"], + "sender": ["e88c218171fa"], "payloads": ["456781b97acc", "08ef53d83e8c"], "settlements": { "mount": "eb79a9b3682a", @@ -634,7 +646,7 @@ { "id": "native-chat-page-earlier.normal:paged", "observation": { - "sender": ["d83fbb18104a"], + "sender": ["911b3f485409"], "payloads": ["456781b97acc", "08ef53d83e8c"], "settlements": { "mount": "eb79a9b3682a", @@ -647,7 +659,7 @@ { "id": "native-chat-page-earlier.normal:re-subscribed", "observation": { - "sender": ["d83fbb18104a"], + "sender": ["911b3f485409"], "payloads": ["456781b97acc", "08ef53d83e8c", "663babae1f1d", "7eedfcf76fd3"], "settlements": { "mount": "eb79a9b3682a", @@ -661,7 +673,7 @@ { "id": "native-chat-page-earlier.normal:replayed", "observation": { - "sender": ["d83fbb18104a"], + "sender": ["911b3f485409"], "payloads": ["456781b97acc", "08ef53d83e8c", "663babae1f1d", "7eedfcf76fd3"], "settlements": { "mount": "eb79a9b3682a", @@ -675,7 +687,7 @@ { "id": "native-chat-page-earlier.normal:unmounted", "observation": { - "sender": ["d83fbb18104a"], + "sender": ["911b3f485409"], "payloads": [ "456781b97acc", "08ef53d83e8c", @@ -696,7 +708,7 @@ { "id": "native-chat-page-earlier.result-absent:paged", "observation": { - "sender": ["1821d5c2646a"], + "sender": ["75842f212e52"], "payloads": ["456781b97acc", "08ef53d83e8c"], "settlements": { "mount": "eb79a9b3682a", @@ -709,7 +721,7 @@ { "id": "native-chat-page-earlier.result-absent:re-subscribed", "observation": { - "sender": ["1821d5c2646a"], + "sender": ["75842f212e52"], "payloads": ["456781b97acc", "08ef53d83e8c", "663babae1f1d", "7eedfcf76fd3"], "settlements": { "mount": "eb79a9b3682a", @@ -723,7 +735,7 @@ { "id": "native-chat-page-earlier.result-absent:replayed", "observation": { - "sender": ["1821d5c2646a"], + "sender": ["75842f212e52"], "payloads": ["456781b97acc", "08ef53d83e8c", "663babae1f1d", "7eedfcf76fd3"], "settlements": { "mount": "eb79a9b3682a", @@ -737,7 +749,7 @@ { "id": "native-chat-page-earlier.result-absent:unmounted", "observation": { - "sender": ["1821d5c2646a"], + "sender": ["75842f212e52"], "payloads": [ "456781b97acc", "08ef53d83e8c", @@ -758,7 +770,7 @@ { "id": "native-chat-page-earlier.result-null:paged", "observation": { - "sender": ["a92af671fd39"], + "sender": ["a3e69f56e5e2"], "payloads": ["456781b97acc", "08ef53d83e8c"], "settlements": { "mount": "eb79a9b3682a", @@ -771,7 +783,7 @@ { "id": "native-chat-page-earlier.result-null:re-subscribed", "observation": { - "sender": ["a92af671fd39"], + "sender": ["a3e69f56e5e2"], "payloads": ["456781b97acc", "08ef53d83e8c", "663babae1f1d", "7eedfcf76fd3"], "settlements": { "mount": "eb79a9b3682a", @@ -785,7 +797,7 @@ { "id": "native-chat-page-earlier.result-null:replayed", "observation": { - "sender": ["a92af671fd39"], + "sender": ["a3e69f56e5e2"], "payloads": ["456781b97acc", "08ef53d83e8c", "663babae1f1d", "7eedfcf76fd3"], "settlements": { "mount": "eb79a9b3682a", @@ -799,7 +811,7 @@ { "id": "native-chat-page-earlier.result-null:unmounted", "observation": { - "sender": ["a92af671fd39"], + "sender": ["a3e69f56e5e2"], "payloads": [ "456781b97acc", "08ef53d83e8c", @@ -820,7 +832,7 @@ { "id": "native-chat-page-earlier.inner-ok-missing:paged", "observation": { - "sender": ["9a114b6ce2df"], + "sender": ["ab5d827d2b71"], "payloads": ["456781b97acc", "08ef53d83e8c"], "settlements": { "mount": "eb79a9b3682a", @@ -833,7 +845,7 @@ { "id": "native-chat-page-earlier.inner-ok-missing:re-subscribed", "observation": { - "sender": ["9a114b6ce2df"], + "sender": ["ab5d827d2b71"], "payloads": ["456781b97acc", "08ef53d83e8c", "663babae1f1d", "7eedfcf76fd3"], "settlements": { "mount": "eb79a9b3682a", @@ -847,7 +859,7 @@ { "id": "native-chat-page-earlier.inner-ok-missing:replayed", "observation": { - "sender": ["9a114b6ce2df"], + "sender": ["ab5d827d2b71"], "payloads": ["456781b97acc", "08ef53d83e8c", "663babae1f1d", "7eedfcf76fd3"], "settlements": { "mount": "eb79a9b3682a", @@ -861,7 +873,7 @@ { "id": "native-chat-page-earlier.inner-ok-missing:unmounted", "observation": { - "sender": ["9a114b6ce2df"], + "sender": ["ab5d827d2b71"], "payloads": [ "456781b97acc", "08ef53d83e8c", @@ -882,7 +894,7 @@ { "id": "native-chat-page-earlier.inner-false-string-error:paged", "observation": { - "sender": ["d163a9801643"], + "sender": ["fa953f37a758"], "payloads": ["456781b97acc", "08ef53d83e8c"], "settlements": { "mount": "eb79a9b3682a", @@ -895,7 +907,7 @@ { "id": "native-chat-page-earlier.inner-false-string-error:re-subscribed", "observation": { - "sender": ["d163a9801643"], + "sender": ["fa953f37a758"], "payloads": ["456781b97acc", "08ef53d83e8c", "663babae1f1d", "7eedfcf76fd3"], "settlements": { "mount": "eb79a9b3682a", @@ -909,7 +921,7 @@ { "id": "native-chat-page-earlier.inner-false-string-error:replayed", "observation": { - "sender": ["d163a9801643"], + "sender": ["fa953f37a758"], "payloads": ["456781b97acc", "08ef53d83e8c", "663babae1f1d", "7eedfcf76fd3"], "settlements": { "mount": "eb79a9b3682a", @@ -923,7 +935,7 @@ { "id": "native-chat-page-earlier.inner-false-string-error:unmounted", "observation": { - "sender": ["d163a9801643"], + "sender": ["fa953f37a758"], "payloads": [ "456781b97acc", "08ef53d83e8c", @@ -944,7 +956,7 @@ { "id": "native-chat-page-earlier.inner-false-object-error:paged", "observation": { - "sender": ["9a3f65995021"], + "sender": ["84f6bcad6466"], "payloads": ["456781b97acc", "08ef53d83e8c"], "settlements": { "mount": "eb79a9b3682a", @@ -957,7 +969,7 @@ { "id": "native-chat-page-earlier.inner-false-object-error:re-subscribed", "observation": { - "sender": ["9a3f65995021"], + "sender": ["84f6bcad6466"], "payloads": ["456781b97acc", "08ef53d83e8c", "663babae1f1d", "7eedfcf76fd3"], "settlements": { "mount": "eb79a9b3682a", @@ -971,7 +983,7 @@ { "id": "native-chat-page-earlier.inner-false-object-error:replayed", "observation": { - "sender": ["9a3f65995021"], + "sender": ["84f6bcad6466"], "payloads": ["456781b97acc", "08ef53d83e8c", "663babae1f1d", "7eedfcf76fd3"], "settlements": { "mount": "eb79a9b3682a", @@ -985,7 +997,7 @@ { "id": "native-chat-page-earlier.inner-false-object-error:unmounted", "observation": { - "sender": ["9a3f65995021"], + "sender": ["84f6bcad6466"], "payloads": [ "456781b97acc", "08ef53d83e8c", @@ -1006,7 +1018,7 @@ { "id": "native-chat-page-earlier.outer-refused:paged", "observation": { - "sender": ["41f222acdcb2"], + "sender": ["81eaceac1130"], "payloads": ["456781b97acc", "08ef53d83e8c"], "settlements": { "mount": "eb79a9b3682a", @@ -1019,7 +1031,7 @@ { "id": "native-chat-page-earlier.outer-refused:re-subscribed", "observation": { - "sender": ["41f222acdcb2"], + "sender": ["81eaceac1130"], "payloads": ["456781b97acc", "08ef53d83e8c", "663babae1f1d", "7eedfcf76fd3"], "settlements": { "mount": "eb79a9b3682a", @@ -1033,7 +1045,7 @@ { "id": "native-chat-page-earlier.outer-refused:replayed", "observation": { - "sender": ["41f222acdcb2"], + "sender": ["81eaceac1130"], "payloads": ["456781b97acc", "08ef53d83e8c", "663babae1f1d", "7eedfcf76fd3"], "settlements": { "mount": "eb79a9b3682a", @@ -1047,7 +1059,7 @@ { "id": "native-chat-page-earlier.outer-refused:unmounted", "observation": { - "sender": ["41f222acdcb2"], + "sender": ["81eaceac1130"], "payloads": [ "456781b97acc", "08ef53d83e8c", @@ -1068,7 +1080,7 @@ { "id": "native-chat-page-earlier.outer-refused-no-message:paged", "observation": { - "sender": ["2dd353a0e048"], + "sender": ["ee33a7e2ed08"], "payloads": ["456781b97acc", "08ef53d83e8c"], "settlements": { "mount": "eb79a9b3682a", @@ -1081,7 +1093,7 @@ { "id": "native-chat-page-earlier.outer-refused-no-message:re-subscribed", "observation": { - "sender": ["2dd353a0e048"], + "sender": ["ee33a7e2ed08"], "payloads": ["456781b97acc", "08ef53d83e8c", "663babae1f1d", "7eedfcf76fd3"], "settlements": { "mount": "eb79a9b3682a", @@ -1095,7 +1107,7 @@ { "id": "native-chat-page-earlier.outer-refused-no-message:replayed", "observation": { - "sender": ["2dd353a0e048"], + "sender": ["ee33a7e2ed08"], "payloads": ["456781b97acc", "08ef53d83e8c", "663babae1f1d", "7eedfcf76fd3"], "settlements": { "mount": "eb79a9b3682a", @@ -1109,7 +1121,7 @@ { "id": "native-chat-page-earlier.outer-refused-no-message:unmounted", "observation": { - "sender": ["2dd353a0e048"], + "sender": ["ee33a7e2ed08"], "payloads": [ "456781b97acc", "08ef53d83e8c", @@ -1130,7 +1142,7 @@ { "id": "native-chat-page-earlier.method-not-found:paged", "observation": { - "sender": ["cea1d50a73fa"], + "sender": ["fcea12946cf7"], "payloads": ["456781b97acc", "08ef53d83e8c"], "settlements": { "mount": "eb79a9b3682a", @@ -1143,7 +1155,7 @@ { "id": "native-chat-page-earlier.method-not-found:re-subscribed", "observation": { - "sender": ["cea1d50a73fa"], + "sender": ["fcea12946cf7"], "payloads": ["456781b97acc", "08ef53d83e8c", "663babae1f1d", "7eedfcf76fd3"], "settlements": { "mount": "eb79a9b3682a", @@ -1157,7 +1169,7 @@ { "id": "native-chat-page-earlier.method-not-found:replayed", "observation": { - "sender": ["cea1d50a73fa"], + "sender": ["fcea12946cf7"], "payloads": ["456781b97acc", "08ef53d83e8c", "663babae1f1d", "7eedfcf76fd3"], "settlements": { "mount": "eb79a9b3682a", @@ -1171,7 +1183,7 @@ { "id": "native-chat-page-earlier.method-not-found:unmounted", "observation": { - "sender": ["cea1d50a73fa"], + "sender": ["fcea12946cf7"], "payloads": [ "456781b97acc", "08ef53d83e8c", @@ -1192,7 +1204,7 @@ { "id": "native-chat-page-earlier.transport-rejection:paged", "observation": { - "sender": ["63d9cc126261"], + "sender": ["fd440192b538"], "payloads": ["456781b97acc", "08ef53d83e8c"], "settlements": { "mount": "eb79a9b3682a", @@ -1205,7 +1217,7 @@ { "id": "native-chat-page-earlier.transport-rejection:re-subscribed", "observation": { - "sender": ["63d9cc126261"], + "sender": ["fd440192b538"], "payloads": ["456781b97acc", "08ef53d83e8c", "663babae1f1d", "7eedfcf76fd3"], "settlements": { "mount": "eb79a9b3682a", @@ -1219,7 +1231,7 @@ { "id": "native-chat-page-earlier.transport-rejection:replayed", "observation": { - "sender": ["63d9cc126261"], + "sender": ["fd440192b538"], "payloads": ["456781b97acc", "08ef53d83e8c", "663babae1f1d", "7eedfcf76fd3"], "settlements": { "mount": "eb79a9b3682a", @@ -1233,7 +1245,7 @@ { "id": "native-chat-page-earlier.transport-rejection:unmounted", "observation": { - "sender": ["63d9cc126261"], + "sender": ["fd440192b538"], "payloads": [ "456781b97acc", "08ef53d83e8c", @@ -1254,7 +1266,7 @@ { "id": "native-chat-page-earlier.transport-rejection-no-message:paged", "observation": { - "sender": ["7ee0fd6f2bd8"], + "sender": ["65ff352ca953"], "payloads": ["456781b97acc", "08ef53d83e8c"], "settlements": { "mount": "eb79a9b3682a", @@ -1267,7 +1279,7 @@ { "id": "native-chat-page-earlier.transport-rejection-no-message:re-subscribed", "observation": { - "sender": ["7ee0fd6f2bd8"], + "sender": ["65ff352ca953"], "payloads": ["456781b97acc", "08ef53d83e8c", "663babae1f1d", "7eedfcf76fd3"], "settlements": { "mount": "eb79a9b3682a", @@ -1281,7 +1293,7 @@ { "id": "native-chat-page-earlier.transport-rejection-no-message:replayed", "observation": { - "sender": ["7ee0fd6f2bd8"], + "sender": ["65ff352ca953"], "payloads": ["456781b97acc", "08ef53d83e8c", "663babae1f1d", "7eedfcf76fd3"], "settlements": { "mount": "eb79a9b3682a", @@ -1295,7 +1307,7 @@ { "id": "native-chat-page-earlier.transport-rejection-no-message:unmounted", "observation": { - "sender": ["7ee0fd6f2bd8"], + "sender": ["65ff352ca953"], "payloads": [ "456781b97acc", "08ef53d83e8c", diff --git a/mobile/rpc-foundation/goldens/matrix-session.native-chat-page-nativechat.subscribe-1-1.json b/mobile/rpc-foundation/goldens/matrix-session.native-chat-page-nativechat.subscribe-1-1.json index 430461becfc..859409c0e68 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.native-chat-page-nativechat.subscribe-1-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.native-chat-page-nativechat.subscribe-1-1.json @@ -115,6 +115,72 @@ "status": "ready", "transcriptLoading": false }, + "911b3f485409": { + "name": "nativeChat.readSession#1", + "ordinal": 2, + "args": [ + { + "name": "method", + "value": "nativeChat.readSession" + }, + { + "name": "params", + "value": { + "agent": "claude", + "beforeOffset": 1200, + "limit": 60, + "sessionId": "session-1", + "transcriptPath": "/work/feature/.claude/session-1.jsonl" + } + }, + { + "name": "options", + "value": { + "budgetSpansConnect": true, + "timeoutMs": 15000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-2", + "ok": true, + "result": { + "beforeOffset": 0, + "hasMore": false, + "messages": [ + { + "blocks": [ + { + "text": "first", + "type": "text" + } + ], + "id": "m-1", + "role": "assistant", + "source": "transcript", + "timestamp": 1000 + }, + { + "blocks": [ + { + "text": "second", + "type": "text" + } + ], + "id": "m-2", + "role": "assistant", + "source": "transcript", + "timestamp": 2000 + } + ] + } + } + } + }, "96abfc4cef7e": { "name": "nativeChat.unsubscribe#1", "ordinal": 4, @@ -185,7 +251,7 @@ "status": "error", "transcriptLoading": false }, - "d83fbb18104a": { + "e88c218171fa": { "name": "nativeChat.readSession#1", "ordinal": 2, "args": [ @@ -206,72 +272,8 @@ { "name": "options", "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-2", - "ok": true, - "result": { - "beforeOffset": 0, - "hasMore": false, - "messages": [ - { - "blocks": [ - { - "text": "first", - "type": "text" - } - ], - "id": "m-1", - "role": "assistant", - "source": "transcript", - "timestamp": 1000 - }, - { - "blocks": [ - { - "text": "second", - "type": "text" - } - ], - "id": "m-2", - "role": "assistant", - "source": "transcript", - "timestamp": 2000 - } - ] - } - } - } - }, - "dadae0a56731": { - "name": "nativeChat.readSession#1", - "ordinal": 2, - "args": [ - { - "name": "method", - "value": "nativeChat.readSession" - }, - { - "name": "params", - "value": { - "agent": "claude", - "beforeOffset": 1200, - "limit": 60, - "sessionId": "session-1", - "transcriptPath": "/work/feature/.claude/session-1.jsonl" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" + "budgetSpansConnect": true, + "timeoutMs": 15000 } } ], @@ -361,7 +363,7 @@ { "id": "native-chat-page-earlier.normal:paging", "observation": { - "sender": ["dadae0a56731"], + "sender": ["e88c218171fa"], "payloads": ["456781b97acc", "08ef53d83e8c"], "settlements": { "mount": "eb79a9b3682a", @@ -374,7 +376,7 @@ { "id": "native-chat-page-earlier.normal:paged", "observation": { - "sender": ["d83fbb18104a"], + "sender": ["911b3f485409"], "payloads": ["456781b97acc", "08ef53d83e8c"], "settlements": { "mount": "eb79a9b3682a", @@ -387,7 +389,7 @@ { "id": "native-chat-page-earlier.normal:re-subscribed", "observation": { - "sender": ["d83fbb18104a"], + "sender": ["911b3f485409"], "payloads": ["456781b97acc", "08ef53d83e8c", "663babae1f1d", "7eedfcf76fd3"], "settlements": { "mount": "eb79a9b3682a", @@ -401,7 +403,7 @@ { "id": "native-chat-page-earlier.normal:replayed", "observation": { - "sender": ["d83fbb18104a"], + "sender": ["911b3f485409"], "payloads": ["456781b97acc", "08ef53d83e8c", "663babae1f1d", "7eedfcf76fd3"], "settlements": { "mount": "eb79a9b3682a", @@ -415,7 +417,7 @@ { "id": "native-chat-page-earlier.normal:unmounted", "observation": { - "sender": ["d83fbb18104a"], + "sender": ["911b3f485409"], "payloads": [ "456781b97acc", "08ef53d83e8c", diff --git a/mobile/rpc-foundation/goldens/matrix-session.native-chat-page-nativechat.subscribe-2-1.json b/mobile/rpc-foundation/goldens/matrix-session.native-chat-page-nativechat.subscribe-2-1.json index 516ef620bf7..06f5faea844 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.native-chat-page-nativechat.subscribe-2-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.native-chat-page-nativechat.subscribe-2-1.json @@ -105,40 +105,7 @@ "status": "ready", "transcriptLoading": false }, - "a5609c6d15fc": { - "crash": { - "$rpc": "null" - }, - "error": "Unknown method", - "hasMore": false, - "loadingEarlier": false, - "messageIds": ["m-1", "m-2", "m-3", "m-4"], - "status": "error", - "transcriptLoading": false - }, - "b96b63e4aaf3": { - "crash": { - "$rpc": "null" - }, - "error": "outer refused", - "hasMore": false, - "loadingEarlier": false, - "messageIds": ["m-1", "m-2", "m-3", "m-4"], - "status": "error", - "transcriptLoading": false - }, - "d53372f95573": { - "crash": { - "$rpc": "null" - }, - "error": "", - "hasMore": false, - "loadingEarlier": false, - "messageIds": ["m-1", "m-2", "m-3", "m-4"], - "status": "error", - "transcriptLoading": false - }, - "d83fbb18104a": { + "911b3f485409": { "name": "nativeChat.readSession#1", "ordinal": 2, "args": [ @@ -159,7 +126,8 @@ { "name": "options", "value": { - "$rpc": "absent" + "budgetSpansConnect": true, + "timeoutMs": 15000 } } ], @@ -203,7 +171,45 @@ } } }, - "dadae0a56731": { + "a5609c6d15fc": { + "crash": { + "$rpc": "null" + }, + "error": "Unknown method", + "hasMore": false, + "loadingEarlier": false, + "messageIds": ["m-1", "m-2", "m-3", "m-4"], + "status": "error", + "transcriptLoading": false + }, + "b96b63e4aaf3": { + "crash": { + "$rpc": "null" + }, + "error": "outer refused", + "hasMore": false, + "loadingEarlier": false, + "messageIds": ["m-1", "m-2", "m-3", "m-4"], + "status": "error", + "transcriptLoading": false + }, + "d53372f95573": { + "crash": { + "$rpc": "null" + }, + "error": "", + "hasMore": false, + "loadingEarlier": false, + "messageIds": ["m-1", "m-2", "m-3", "m-4"], + "status": "error", + "transcriptLoading": false + }, + "e4483b4ab66e": { + "name": "nativeChat.unsubscribe#2", + "ordinal": 7, + "json": "{\"id\":\"frame-5\",\"deviceToken\":\"recording-device\",\"method\":\"nativeChat.unsubscribe\",\"params\":{\"subscriptionId\":\"claude:session-1:00000000-0000-4000-8000-000000000001\"}}" + }, + "e88c218171fa": { "name": "nativeChat.readSession#1", "ordinal": 2, "args": [ @@ -224,7 +230,8 @@ { "name": "options", "value": { - "$rpc": "absent" + "budgetSpansConnect": true, + "timeoutMs": 15000 } } ], @@ -233,11 +240,6 @@ "startedAt": 0 } }, - "e4483b4ab66e": { - "name": "nativeChat.unsubscribe#2", - "ordinal": 7, - "json": "{\"id\":\"frame-5\",\"deviceToken\":\"recording-device\",\"method\":\"nativeChat.unsubscribe\",\"params\":{\"subscriptionId\":\"claude:session-1:00000000-0000-4000-8000-000000000001\"}}" - }, "eb79a9b3682a": { "status": "fulfilled", "startedAt": 0, @@ -290,7 +292,7 @@ { "id": "native-chat-page-earlier.prelude:paging", "observation": { - "sender": ["dadae0a56731"], + "sender": ["e88c218171fa"], "payloads": ["456781b97acc", "08ef53d83e8c"], "settlements": { "mount": "eb79a9b3682a", @@ -303,7 +305,7 @@ { "id": "native-chat-page-earlier.prelude:paged", "observation": { - "sender": ["d83fbb18104a"], + "sender": ["911b3f485409"], "payloads": ["456781b97acc", "08ef53d83e8c"], "settlements": { "mount": "eb79a9b3682a", @@ -316,7 +318,7 @@ { "id": "native-chat-page-earlier.prelude:re-subscribed", "observation": { - "sender": ["d83fbb18104a"], + "sender": ["911b3f485409"], "payloads": ["456781b97acc", "08ef53d83e8c", "663babae1f1d", "7eedfcf76fd3"], "settlements": { "mount": "eb79a9b3682a", @@ -330,7 +332,7 @@ { "id": "native-chat-page-earlier.normal:replayed", "observation": { - "sender": ["d83fbb18104a"], + "sender": ["911b3f485409"], "payloads": ["456781b97acc", "08ef53d83e8c", "663babae1f1d", "7eedfcf76fd3"], "settlements": { "mount": "eb79a9b3682a", @@ -344,7 +346,7 @@ { "id": "native-chat-page-earlier.normal:unmounted", "observation": { - "sender": ["d83fbb18104a"], + "sender": ["911b3f485409"], "payloads": [ "456781b97acc", "08ef53d83e8c", @@ -365,7 +367,7 @@ { "id": "native-chat-page-earlier.result-absent:replayed", "observation": { - "sender": ["d83fbb18104a"], + "sender": ["911b3f485409"], "payloads": ["456781b97acc", "08ef53d83e8c", "663babae1f1d", "7eedfcf76fd3"], "settlements": { "mount": "eb79a9b3682a", @@ -379,7 +381,7 @@ { "id": "native-chat-page-earlier.result-absent:unmounted", "observation": { - "sender": ["d83fbb18104a"], + "sender": ["911b3f485409"], "payloads": [ "456781b97acc", "08ef53d83e8c", @@ -400,7 +402,7 @@ { "id": "native-chat-page-earlier.result-null:replayed", "observation": { - "sender": ["d83fbb18104a"], + "sender": ["911b3f485409"], "payloads": ["456781b97acc", "08ef53d83e8c", "663babae1f1d", "7eedfcf76fd3"], "settlements": { "mount": "eb79a9b3682a", @@ -414,7 +416,7 @@ { "id": "native-chat-page-earlier.result-null:unmounted", "observation": { - "sender": ["d83fbb18104a"], + "sender": ["911b3f485409"], "payloads": [ "456781b97acc", "08ef53d83e8c", @@ -435,7 +437,7 @@ { "id": "native-chat-page-earlier.inner-ok-missing:replayed", "observation": { - "sender": ["d83fbb18104a"], + "sender": ["911b3f485409"], "payloads": ["456781b97acc", "08ef53d83e8c", "663babae1f1d", "7eedfcf76fd3"], "settlements": { "mount": "eb79a9b3682a", @@ -449,7 +451,7 @@ { "id": "native-chat-page-earlier.inner-ok-missing:unmounted", "observation": { - "sender": ["d83fbb18104a"], + "sender": ["911b3f485409"], "payloads": [ "456781b97acc", "08ef53d83e8c", @@ -470,7 +472,7 @@ { "id": "native-chat-page-earlier.inner-false-string-error:replayed", "observation": { - "sender": ["d83fbb18104a"], + "sender": ["911b3f485409"], "payloads": ["456781b97acc", "08ef53d83e8c", "663babae1f1d", "7eedfcf76fd3"], "settlements": { "mount": "eb79a9b3682a", @@ -484,7 +486,7 @@ { "id": "native-chat-page-earlier.inner-false-string-error:unmounted", "observation": { - "sender": ["d83fbb18104a"], + "sender": ["911b3f485409"], "payloads": [ "456781b97acc", "08ef53d83e8c", @@ -505,7 +507,7 @@ { "id": "native-chat-page-earlier.inner-false-object-error:replayed", "observation": { - "sender": ["d83fbb18104a"], + "sender": ["911b3f485409"], "payloads": ["456781b97acc", "08ef53d83e8c", "663babae1f1d", "7eedfcf76fd3"], "settlements": { "mount": "eb79a9b3682a", @@ -519,7 +521,7 @@ { "id": "native-chat-page-earlier.inner-false-object-error:unmounted", "observation": { - "sender": ["d83fbb18104a"], + "sender": ["911b3f485409"], "payloads": [ "456781b97acc", "08ef53d83e8c", @@ -540,7 +542,7 @@ { "id": "native-chat-page-earlier.outer-refused:replayed", "observation": { - "sender": ["d83fbb18104a"], + "sender": ["911b3f485409"], "payloads": ["456781b97acc", "08ef53d83e8c", "663babae1f1d", "7eedfcf76fd3"], "settlements": { "mount": "eb79a9b3682a", @@ -554,7 +556,7 @@ { "id": "native-chat-page-earlier.outer-refused:unmounted", "observation": { - "sender": ["d83fbb18104a"], + "sender": ["911b3f485409"], "payloads": ["456781b97acc", "08ef53d83e8c", "663babae1f1d", "7eedfcf76fd3"], "settlements": { "mount": "eb79a9b3682a", @@ -569,7 +571,7 @@ { "id": "native-chat-page-earlier.outer-refused-no-message:replayed", "observation": { - "sender": ["d83fbb18104a"], + "sender": ["911b3f485409"], "payloads": ["456781b97acc", "08ef53d83e8c", "663babae1f1d", "7eedfcf76fd3"], "settlements": { "mount": "eb79a9b3682a", @@ -583,7 +585,7 @@ { "id": "native-chat-page-earlier.outer-refused-no-message:unmounted", "observation": { - "sender": ["d83fbb18104a"], + "sender": ["911b3f485409"], "payloads": ["456781b97acc", "08ef53d83e8c", "663babae1f1d", "7eedfcf76fd3"], "settlements": { "mount": "eb79a9b3682a", @@ -598,7 +600,7 @@ { "id": "native-chat-page-earlier.method-not-found:replayed", "observation": { - "sender": ["d83fbb18104a"], + "sender": ["911b3f485409"], "payloads": ["456781b97acc", "08ef53d83e8c", "663babae1f1d", "7eedfcf76fd3"], "settlements": { "mount": "eb79a9b3682a", @@ -612,7 +614,7 @@ { "id": "native-chat-page-earlier.method-not-found:unmounted", "observation": { - "sender": ["d83fbb18104a"], + "sender": ["911b3f485409"], "payloads": ["456781b97acc", "08ef53d83e8c", "663babae1f1d", "7eedfcf76fd3"], "settlements": { "mount": "eb79a9b3682a", diff --git a/mobile/rpc-foundation/goldens/native-chat-page-earlier.json b/mobile/rpc-foundation/goldens/native-chat-page-earlier.json index e77f69f1b73..a2e8ea89078 100644 --- a/mobile/rpc-foundation/goldens/native-chat-page-earlier.json +++ b/mobile/rpc-foundation/goldens/native-chat-page-earlier.json @@ -81,7 +81,7 @@ "status": "ready", "transcriptLoading": false }, - "d83fbb18104a": { + "911b3f485409": { "name": "nativeChat.readSession#1", "ordinal": 2, "args": [ @@ -102,7 +102,8 @@ { "name": "options", "value": { - "$rpc": "absent" + "budgetSpansConnect": true, + "timeoutMs": 15000 } } ], @@ -146,7 +147,7 @@ } } }, - "dadae0a56731": { + "e88c218171fa": { "name": "nativeChat.readSession#1", "ordinal": 2, "args": [ @@ -167,7 +168,8 @@ { "name": "options", "value": { - "$rpc": "absent" + "budgetSpansConnect": true, + "timeoutMs": 15000 } } ], @@ -228,7 +230,7 @@ { "id": "paging", "observation": { - "sender": ["dadae0a56731"], + "sender": ["e88c218171fa"], "payloads": ["456781b97acc", "08ef53d83e8c"], "settlements": { "mount": "eb79a9b3682a", @@ -241,7 +243,7 @@ { "id": "paged", "observation": { - "sender": ["d83fbb18104a"], + "sender": ["911b3f485409"], "payloads": ["456781b97acc", "08ef53d83e8c"], "settlements": { "mount": "eb79a9b3682a", @@ -254,7 +256,7 @@ { "id": "re-subscribed", "observation": { - "sender": ["d83fbb18104a"], + "sender": ["911b3f485409"], "payloads": ["456781b97acc", "08ef53d83e8c", "663babae1f1d", "7eedfcf76fd3"], "settlements": { "mount": "eb79a9b3682a", @@ -268,7 +270,7 @@ { "id": "replayed", "observation": { - "sender": ["d83fbb18104a"], + "sender": ["911b3f485409"], "payloads": ["456781b97acc", "08ef53d83e8c", "663babae1f1d", "7eedfcf76fd3"], "settlements": { "mount": "eb79a9b3682a", @@ -282,7 +284,7 @@ { "id": "unmounted", "observation": { - "sender": ["d83fbb18104a"], + "sender": ["911b3f485409"], "payloads": [ "456781b97acc", "08ef53d83e8c", diff --git a/mobile/src/session/MobileNativeChatDiffCard.tsx b/mobile/src/session/MobileNativeChatDiffCard.tsx index 080e32fe24a..e7e6eae9cdf 100644 --- a/mobile/src/session/MobileNativeChatDiffCard.tsx +++ b/mobile/src/session/MobileNativeChatDiffCard.tsx @@ -1,5 +1,6 @@ import { useMemo } from 'react' -import { Pressable, ScrollView, Text, View } from 'react-native' +import { Pressable, Text, View } from 'react-native' +import { MobileNativeChatOwnedScrollView } from './MobileNativeChatOwnedScrollView' import { ChevronRight, FileMinus2, FilePen, FilePlus2 } from 'lucide-react-native' import { NATIVE_CHAT_DIFF_CARD_COPY, @@ -83,7 +84,17 @@ function DiffRow({ /** The card's rows in their own bounded box. Its own component so a collapsed card * builds none of them. */ -function DiffCardRows({ file, fontScale }: { file: NativeChatEditFile; fontScale: number }) { +function DiffCardRows({ + file, + fontScale, + disclosureKey, + rootIdentity +}: { + file: NativeChatEditFile + fontScale: number + disclosureKey?: string + rootIdentity?: string +}) { const onLayout = useMobileNativeChatOpenedDetail() const type = toolType(fontScale).code // A snippet-relative number would read as a file position, so the gutter is blank @@ -95,7 +106,9 @@ function DiffCardRows({ file, fontScale }: { file: NativeChatEditFile; fontScale // Desktop's row identity: the line itself, and which repeat of it this is. const seen = new Map() return ( - ) })} - + ) } @@ -125,6 +138,7 @@ export function MobileNativeChatDiffCard({ fontScale, defaultExpanded = false, disclosureKey, + rootIdentity, onOpenFile }: { file: NativeChatEditFile @@ -132,6 +146,7 @@ export function MobileNativeChatDiffCard({ defaultExpanded?: boolean /** Identity this card's open state is remembered under while it is unmounted. */ disclosureKey?: string + rootIdentity?: string onOpenFile?: (path: string) => void }): React.JSX.Element { const [expanded, setExpanded] = useMobileNativeChatDisclosure(disclosureKey, defaultExpanded) @@ -201,7 +216,14 @@ export function MobileNativeChatDiffCard({ accessibilityLabel={NATIVE_CHAT_DIFF_CARD_COPY.copyDiff} /> - {hasBody && expanded ? : null} + {hasBody && expanded ? ( + + ) : null} ) } diff --git a/mobile/src/session/MobileNativeChatLiveLine.tsx b/mobile/src/session/MobileNativeChatLiveLine.tsx index b38c65697f8..c753111a15d 100644 --- a/mobile/src/session/MobileNativeChatLiveLine.tsx +++ b/mobile/src/session/MobileNativeChatLiveLine.tsx @@ -64,6 +64,7 @@ export function MobileNativeChatLiveLine({ {reasoning && open ? ( ({ })) import { MobileNativeChatMessage } from './MobileNativeChatMessage' +import { + MobileNativeChatDisclosureContext, + useMobileNativeChatDisclosures +} from './mobile-native-chat-disclosure-store' function message(blocks: NativeChatMessage['blocks']): NativeChatMessage { return { id: 'source', role: 'assistant', blocks, timestamp: 1, source: 'transcript' } @@ -127,6 +131,64 @@ describe('mobile tool result availability', () => { return renderer! } + // The page's window unmounts a row scrolled past; older messages joining its fold change its id. + it('keeps what the reader opened in a windowed-out row by its row key, per Tools default', () => { + const row = message([ + { type: 'tool-call', callId: 'one', name: 'Bash', input: 'first input' }, + { type: 'tool-result', callId: 'one', output: 'first output' }, + { type: 'tool-call', callId: 'two', name: 'Bash', input: 'second input' }, + { type: 'tool-result', callId: 'two', output: 'second output' } + ]) + function Window({ shown, id, tools }: { shown: boolean; id: string; tools: boolean }) { + const disclosures = useMobileNativeChatDisclosures('chat') + return createElement( + MobileNativeChatDisclosureContext.Provider, + { value: disclosures }, + shown + ? createElement(MobileNativeChatMessage, { + message: { ...row, id }, + rowKey: 'kept', + toolsExpanded: tools + }) + : null + ) + } + const draw = (shown: boolean, id = 'source', tools = false) => + act(() => { + const element = createElement(Window, { shown, id, tools }) + if (renderer) { + renderer.update(element) + } else { + renderer = create(element) + } + }) + const header = () => + renderer!.root.find( + (node) => + String(node.type) === 'Pressable' && node.props.accessibilityLiveRegion === 'polite' + ) + draw(true) + press(header()) + press(button(renderer!.root, 'first input')) + draw(false) + draw(true, 'older-head') + expect(toolLines(renderer!.root)).toHaveLength(2) + expect(textIn(renderer!.root)).toContain('first output') + expect(textIn(renderer!.root)).not.toContain('second output') + + // Tools opens every run and line; the reader closes this run under that default. + draw(true, 'older-head', true) + expect(textIn(renderer!.root)).toContain('second output') + press(header()) + draw(false, 'older-head', true) + draw(true, 'older-head', true) + expect(toolLines(renderer!.root)).toHaveLength(0) + // Back to the old default: the choices made under it come back. + draw(true, 'older-head', false) + expect(textIn(renderer!.root)).toContain('first output') + expect(textIn(renderer!.root)).not.toContain('second output') + }) + it('draws each orphan output as its own Result row under one Result header', () => { const raw = message( Array.from({ length: 8 }, (_, index) => ({ diff --git a/mobile/src/session/MobileNativeChatMessage.tsx b/mobile/src/session/MobileNativeChatMessage.tsx index 02f74d11afc..cea4d2baf74 100644 --- a/mobile/src/session/MobileNativeChatMessage.tsx +++ b/mobile/src/session/MobileNativeChatMessage.tsx @@ -22,6 +22,7 @@ import { MobileSourceCopyButton } from '../components/MobileSourceCopyButton' import { nativeChatMessagePlainText } from './mobile-native-chat-message-plain-text' import { deriveNativeChatRowContent } from '../../../src/shared/native-chat-row-content' import { MobileNativeChatReasoningRow } from './MobileNativeChatReasoningRow' +import { mobileNativeChatRowId } from './MobileNativeChatScrollHold' import { nativeChatWorkRunEntries } from '../../../src/shared/native-chat-work-run' import { nativeChatReasoningDisclosureKey } from '../../../src/shared/native-chat-reasoning-row' import { MobileNativeChatTurnStatus } from './MobileNativeChatTurnStatus' @@ -47,6 +48,8 @@ import { Prose } from './MobileNativeChatProse' function MobileNativeChatMessageImpl({ message, + rowKey, + runOwner, mayStillGrow = false, toolsExpanded = false, previousTodoWrite, @@ -77,6 +80,9 @@ function MobileNativeChatMessageImpl({ onToggleUserMessage }: { message: NativeChatMessage + /** The row's place on the page, kept while older messages join it; tags the row. */ + rowKey?: string + runOwner?: string /** The newest assistant row of a live turn with no prompt open: its last text may still grow. */ mayStillGrow?: boolean toolsExpanded?: boolean @@ -213,7 +219,7 @@ function MobileNativeChatMessageImpl({ return ( <> {turnStatusAbove ? statusRow : null} - + {turnStatusAbove ? statusRow : null} {draws ? ( - + {/* A turn with no user bubble carries its bar above its first row. */} {turnStatusAbove ? statusRow : null} - + foldMobileNativeChatMessages(session.messages, agent), - [session.messages, agent] + const folded = useMobileNativeChatFoldReuse( + useMemo(() => foldMobileNativeChatMessages(session.messages, agent), [session.messages, agent]), + sendSurfaceId ) const streaming = useMobileNativeChatStreamingBubble( folded, @@ -142,7 +143,7 @@ export function MobileNativeChatOverlay({ composerTray={composerTray} onOpenFile={onOpenFile} hasMore={session.hasMore} - loadingEarlier={session.loadingEarlier} + olderPages={session.olderPages} onLoadEarlier={session.loadEarlier} onSend={images.sendNativeChat} sendSurfaceId={sendSurfaceId} diff --git a/mobile/src/session/MobileNativeChatOwnedScrollView.tsx b/mobile/src/session/MobileNativeChatOwnedScrollView.tsx new file mode 100644 index 00000000000..3827843c67e --- /dev/null +++ b/mobile/src/session/MobileNativeChatOwnedScrollView.tsx @@ -0,0 +1,88 @@ +import { + createContext, + useCallback, + useContext, + useLayoutEffect, + useMemo, + useRef, + type ComponentProps, + type Ref +} from 'react' +import { Platform, ScrollView } from 'react-native' +import { MobileNativeChatDisclosureContext } from './mobile-native-chat-disclosure-store' + +const RootAddress = createContext<{ id: string; address: string; owner: string } | null>(null) + +/** Every vertical transcript viewport declares its owner, even before it overflows. */ +export function MobileNativeChatOwnedScrollView({ + positionKey, + rootIdentity = positionKey, + owner, + initialFollow = false, + followKey, + ref, + children, + ...props +}: ComponentProps & { + positionKey: string + rootIdentity?: string + owner?: string + initialFollow?: boolean + followKey?: number + ref?: Ref +}) { + const store = useContext(MobileNativeChatDisclosureContext) + const parent = useContext(RootAddress) + const id = parent ? `${parent.id}/${rootIdentity}` : rootIdentity + const address = parent ? `${parent.address}/${positionKey}` : positionKey + const rootOwner = owner ?? parent?.owner ?? id + const value = useMemo(() => ({ id, address, owner: rootOwner }), [id, address, rootOwner]) + const roots = store?.roots + const scroll = useRef(null) + const release = useRef<(() => void) | undefined>(undefined) + const attach = useCallback( + (node: ScrollView | null) => { + scroll.current = node + if (typeof ref === 'function') { + return ref(node) + } + if (ref) { + ref.current = node + } + }, + [ref] + ) + useLayoutEffect( + () => () => { + release.current?.() + release.current = undefined + }, + [roots, id] + ) + useLayoutEffect(() => { + if (Platform.OS !== 'web' || !roots) { + return + } + const node: unknown = scroll.current?.getScrollableNode() + if ( + typeof HTMLElement !== 'undefined' && + node instanceof HTMLElement && + node.firstElementChild !== null + ) { + release.current = roots.register( + id, + node, + node.firstElementChild, + rootOwner, + address, + initialFollow + ) + roots.layout(id, address, followKey) + } + }) + return ( + + {children} + + ) +} diff --git a/mobile/src/session/MobileNativeChatPageList.tsx b/mobile/src/session/MobileNativeChatPageList.tsx new file mode 100644 index 00000000000..8a0edc28e82 --- /dev/null +++ b/mobile/src/session/MobileNativeChatPageList.tsx @@ -0,0 +1,142 @@ +import { + Fragment, + useCallback, + useImperativeHandle, + useLayoutEffect, + useRef, + type ReactElement, + type Ref +} from 'react' +import { + View, + type NativeScrollEvent, + type NativeSyntheticEvent, + type ScrollView, + type StyleProp, + type ViewStyle +} from 'react-native' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import type { MobileNativeChatRowKeys } from './mobile-native-chat-row-keys' +import { MobileNativeChatPageScroll, outOfAnchoring } from './MobileNativeChatPageScroll' +import type { MobileNativeChatListHandle } from './use-mobile-native-chat-tail-follow' +import type { MobileNativeChatTurnRow } from './use-mobile-native-chat-turn-disclosure' +import type { MobileNativeChatWindowScroller } from './mobile-native-chat-page-window-state' +import { useMobileNativeChatPageWindow } from './use-mobile-native-chat-page-window' +import type { MobileNativeChatScrollRoots } from './mobile-native-chat-scroll-roots' + +type Row = { item: NativeChatMessage; index: number } + +type Props = { + roots?: MobileNativeChatScrollRoots + ref: Ref + endAnchored: boolean + isFollowing: () => boolean + pin: () => void + rows: readonly NativeChatMessage[] + rowKeys: Pick + renderItem: (row: Row) => ReactElement + /** What a row draws besides itself; a height measured for other state no longer holds. */ + rowState: (index: number, row: NativeChatMessage) => MobileNativeChatTurnRow + layoutKey: string + contentContainerStyle: StyleProp + /** `measuring`: rows above are not measured yet, so the Load earlier slot shows loading. */ + header: (measuring: boolean) => ReactElement | null + footer: ReactElement | null + emptyStateView: ReactElement | null + onScroll: (event: NativeSyntheticEvent) => void +} + +/** The page's transcript in one keyed flow: RN Web's list draws by index after paint, so opens climbed + * and older pages went in its header, where iOS stopped scrolling at the seam. */ +export function MobileNativeChatPageList({ + ref, + roots, + endAnchored, + isFollowing, + pin, + rows, + rowKeys, + renderItem, + rowState, + layoutKey, + contentContainerStyle, + header, + footer, + emptyStateView, + onScroll +}: Props): ReactElement { + const scroll = useRef(null) + const scroller = useCallback( + (): MobileNativeChatWindowScroller | null | undefined => scroll.current?.getScrollableNode(), + [] + ) + const { view, rowRef, list } = useMobileNativeChatPageWindow({ + roots, + rows, + rowKeys, + rowState, + following: endAnchored, + isFollowing, + layoutKey, + scroller + }) + useImperativeHandle( + ref, + () => ({ + getScrollableNode: () => scroll.current?.getScrollableNode(), + scrollToOffset: ({ offset, animated }) => { + list.wrote(offset) + scroll.current?.scrollTo({ y: offset, animated }) + }, + scrollToEnd: (params) => scroll.current?.scrollToEnd(params), + ...list + }), + [list] + ) + // After the handle attaches, so a chat's first paint is at its end; later growth the observer pins. + const opened = useRef('') + const shown = `${rowKeys.scope}:${rows.length > 0}` + useLayoutEffect(() => { + if (opened.current !== shown) { + opened.current = shown + pin() + } + }, [shown, pin]) + + const drawRow = (item: NativeChatMessage, index: number) => { + const key = rowKeys.keyOf(item) + return ( + + {renderItem({ item, index })} + + ) + } + return ( + + {/* Never what the browser's anchoring holds: rows land below it, and spacers stand in for rows. */} + {header(view.measuring)} + {rows.length === 0 ? ( + emptyStateView + ) : ( + // Keyed by the chat, so no row carries into another. Rows unmount once scrolled past; what + // the reader opened in them is kept in the chat's disclosure store, above the list. + + + {rows.slice(view.m0, view.m1).map((item, at) => drawRow(item, view.m0 + at))} + + {rows.slice(view.start, view.end).map((item, at) => drawRow(item, view.start + at))} + + + )} + {footer} + + ) +} diff --git a/mobile/src/session/MobileNativeChatPageScroll.tsx b/mobile/src/session/MobileNativeChatPageScroll.tsx new file mode 100644 index 00000000000..edc833f5786 --- /dev/null +++ b/mobile/src/session/MobileNativeChatPageScroll.tsx @@ -0,0 +1,28 @@ +import type { Ref } from 'react' +import { ScrollView, StyleSheet, View, type ScrollViewProps, type ViewStyle } from 'react-native' + +/** The page's scroller. Where the browser has scroll anchoring it keeps a parked reader's place; + * the page turns it off on the scroller itself while it owns the scroll (following, a place-hold). */ +export function MobileNativeChatPageScroll({ + ref, + children, + contentContainerStyle, + ...props +}: ScrollViewProps & { ref?: Ref }) { + return ( + + {children} + + ) +} + +// RN Web passes it through to CSS; ViewStyle doesn't name it. +const anchorless: ViewStyle & { overflowAnchor: 'none' } = { overflowAnchor: 'none' } + +const pageScroll = StyleSheet.create({ + fill: { flexGrow: 1 }, + anchorless +}) + +/** Never the node the browser's anchoring holds. */ +export const outOfAnchoring = pageScroll.anchorless diff --git a/mobile/src/session/MobileNativeChatReasoningRow.tsx b/mobile/src/session/MobileNativeChatReasoningRow.tsx index 2b17f6f866a..07a0ce9b832 100644 --- a/mobile/src/session/MobileNativeChatReasoningRow.tsx +++ b/mobile/src/session/MobileNativeChatReasoningRow.tsx @@ -1,5 +1,7 @@ +import { mobileNativeChatMemberId } from './MobileNativeChatScrollHold' import { useState } from 'react' -import { Pressable, ScrollView, Text, View } from 'react-native' +import { Pressable, Text, View } from 'react-native' +import { MobileNativeChatOwnedScrollView } from './MobileNativeChatOwnedScrollView' import { Brain, ChevronRight } from 'lucide-react-native' import { nativeChatReasoningDisclosureKey, @@ -43,7 +45,7 @@ export function MobileNativeChatReasoningRow({ const headline = nativeChatReasoningHeadlineText(nativeChatReasoningHeadline(message, { live })) const label = nativeChatReasoningHeadlineText({ kind: 'reasoning' }) return ( - + [styles.reasoningToggle, pressed && styles.reasoningPressed]} onPress={toggle} @@ -63,6 +65,7 @@ export function MobileNativeChatReasoningRow({ {expanded ? ( void onLongPress?: () => void }): React.JSX.Element { return ( - + - + ) } diff --git a/mobile/src/session/MobileNativeChatRowList.tsx b/mobile/src/session/MobileNativeChatRowList.tsx new file mode 100644 index 00000000000..4952bca5303 --- /dev/null +++ b/mobile/src/session/MobileNativeChatRowList.tsx @@ -0,0 +1,69 @@ +import type { ComponentProps, ReactElement } from 'react' +import { FlatList, Platform } from 'react-native' +import type { MobileNativeChatRowKeys } from './mobile-native-chat-row-keys' +import { MobileNativeChatPageList } from './MobileNativeChatPageList' +import { + mobileNativeChatRowAnchor, + type MobileNativeChatTailFollow +} from './use-mobile-native-chat-tail-follow' + +type PageProps = ComponentProps + +type Props = Pick< + PageProps, + | 'rows' + | 'roots' + | 'renderItem' + | 'rowState' + | 'layoutKey' + | 'contentContainerStyle' + | 'header' + | 'footer' + | 'emptyStateView' + | 'onScroll' +> & { + tail: MobileNativeChatTailFollow + rowKeys: MobileNativeChatRowKeys + /** The native list's header; with one, the row below it anchors the visible content. */ + nativeHeader: ReactElement | null + hasHeader: boolean +} + +/** The chat's rows: the page draws its own keyed window, a native list recycles through FlatList. */ +export function MobileNativeChatRowList(props: Props): ReactElement { + const { tail, rowKeys, rows, renderItem, contentContainerStyle, onScroll, footer } = props + if (Platform.OS === 'web') { + const { roots, rowState, header, emptyStateView, layoutKey } = props + return ( + + ) + } + return ( + + ) +} diff --git a/mobile/src/session/MobileNativeChatScrollHold.test.tsx b/mobile/src/session/MobileNativeChatScrollHold.test.tsx deleted file mode 100644 index 22cb607d60d..00000000000 --- a/mobile/src/session/MobileNativeChatScrollHold.test.tsx +++ /dev/null @@ -1,114 +0,0 @@ -import { createElement } from 'react' -import { act, create, type ReactTestRenderer } from 'react-test-renderer' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { MobileNativeChatScrollHold } from './MobileNativeChatScrollHold' - -const platform = vi.hoisted(() => ({ OS: 'web' })) - -vi.mock('react-native', () => ({ Platform: platform })) - -type Row = { - isConnected: boolean - children: Row[] - getBoundingClientRect: () => { top: number; bottom: number } -} - -/** A row 100 tall whose top is `tops[0]` when first measured and `tops[1]` from then on. */ -function row(tops: number[], children: Row[] = []): Row { - const answers = [...tops] - return { - isConnected: true, - children, - getBoundingClientRect: () => { - const top = answers.length > 1 ? (answers.shift() ?? 0) : answers[0] - return { top, bottom: top + 100 } - } - } -} - -/** A 600 tall view, 40 from the top of its content, over the given header block and list rows. */ -function scrollerOver(headerRows: Row[], listRows: Row[]) { - const header = row([-40], headerRows) - const heights = [1_000, 3_500] - const node = { - scrollTop: 40, - // Grows by 2,500 where 2,000 lands above the reader: live output lands below in the same update. - get scrollHeight(): number { - return heights.length > 1 ? (heights.shift() ?? 0) : heights[0] - }, - getBoundingClientRect: () => ({ top: 0, bottom: 600 }), - firstElementChild: row([-40], [header, ...listRows]) - } - return { node, listRef: { current: { getScrollableNode: () => node } } } -} - -describe('MobileNativeChatScrollHold', () => { - let renderer: ReactTestRenderer | null = null - - async function show(headKey: string, listRef: ReturnType['listRef']) { - const element = createElement(MobileNativeChatScrollHold, { headKey, listRef }) - await act(async () => { - if (renderer) { - renderer.update(element) - } else { - renderer = create(element) - } - }) - } - - afterEach(() => { - act(() => renderer?.unmount()) - renderer = null - platform.OS = 'web' - }) - - it('moves by what landed above the reader, not by live output landing below it too', async () => { - const onScreen = row([300, 2_300]) - const { node, listRef } = scrollerOver([], [row([100, 2_100]), onScreen, row([700, 3_200])]) - await show('a', listRef) - - await show('older', listRef) - - expect(node.scrollTop).toBe(2_040) - }) - - it('holds a reader who is inside the loaded history, above the list’s own rows', async () => { - const control = row([-40]) - const { node, listRef } = scrollerOver([control, row([200, 2_200])], [row([900, 2_900])]) - await show('a', listRef) - - await show('older', listRef) - - expect(node.scrollTop).toBe(2_040) - }) - - it('leaves the view alone when the row it was holding is gone', async () => { - const onScreen = row([300, 2_300]) - const { node, listRef } = scrollerOver([], [onScreen]) - await show('a', listRef) - onScreen.isConnected = false - - await show('older', listRef) - - expect(node.scrollTop).toBe(40) - }) - - it('leaves the view alone while the first row is the same', async () => { - const { node, listRef } = scrollerOver([], [row([300, 2_300])]) - await show('a', listRef) - - await show('a', listRef) - - expect(node.scrollTop).toBe(40) - }) - - it('does nothing in a native build, where the list holds its own place', async () => { - platform.OS = 'ios' - const { node, listRef } = scrollerOver([], [row([300, 2_300])]) - await show('a', listRef) - - await show('older', listRef) - - expect(node.scrollTop).toBe(40) - }) -}) diff --git a/mobile/src/session/MobileNativeChatScrollHold.tsx b/mobile/src/session/MobileNativeChatScrollHold.tsx index dac6254ac6d..a4f57370863 100644 --- a/mobile/src/session/MobileNativeChatScrollHold.tsx +++ b/mobile/src/session/MobileNativeChatScrollHold.tsx @@ -1,70 +1,268 @@ import { Component, type RefObject } from 'react' import { Platform } from 'react-native' +import { scrollWritesStopMomentum } from './mobile-native-chat-page-motion' +import type { MobileNativeChatRowKeys } from './mobile-native-chat-row-keys' +import { + mobileNativeChatReadingParts, + mobileNativeChatScrollRootKey, + type MobileNativeChatScrollRoots +} from './mobile-native-chat-scroll-roots' type Box = { top: number; bottom: number } /** The parts of a DOM element this reads; on the page the list's scrollable node is one. */ -type RowElement = { - isConnected: boolean +type Tagged = { getBoundingClientRect: () => Box - children: ArrayLike -} -type Scroller = { - scrollTop: number - getBoundingClientRect: () => Box - firstElementChild: RowElement | null + getAttribute: (name: string) => string | null + querySelectorAll: (selector: string) => ArrayLike } +type Scroller = Tagged & { scrollTop: number; scrollHeight: number; clientHeight: number } +export type MobileNativeChatScrollNode = Scroller -type Held = { row: RowElement; top: number } +/** Part of what the reader sees: a tool line or its run (`member`), else a row; where it was, and + * the message that placed its row. */ +type Unit = { key: string; top: number; member?: string; placed: string } type Props = { - /** The first row's key: a change in it is rows changing above the reader. */ - headKey: string | undefined - listRef: RefObject<{ getScrollableNode: () => Scroller | null | undefined } | null> + children?: React.ReactNode + roots?: MobileNativeChatScrollRoots + runPlacements?: ReadonlyMap + rowKeys: Pick + /** How rows draw (tools shown, text size); a change regroups and re-lays them like a new source. */ + layoutKey?: string + /** Whether the reader follows the end, which then owns the scroll. */ + isFollowing: () => boolean + /** On the page, the list makes the correction (`keepInPlace`), mid-scroll only for a regroup. */ + listRef: RefObject<{ + getScrollableNode: () => Scroller | null | undefined + keepInPlace?: (drift: number, regrouped: boolean) => void + holdPlace?: (on: boolean) => void + } | null> } -/** The lowest row on screen. Why the lowest: the "Load earlier" control stays at the top while - * history lands under it, so the topmost element does not move with the rows. */ -function lowestRowOnScreen(scroller: Scroller): Held | null { - const content = scroller.firstElementChild - if (!content) { - return null +const ROW = 'chat-row:' +const MEMBER = 'chat-member:' + +/** A message row's `testID`, which the page draws as `data-testid`. It sets message rows apart from + * the "Load earlier" control and the live status line, which don't move with the messages. */ +export function mobileNativeChatRowId(key: string): string { + return `${ROW}${key}` +} + +/** A tool line's `testID`, by its line identity, or the run's, inside a row. */ +export function mobileNativeChatMemberId(id: string): string { + return `${MEMBER}${id}` +} + +/** Tagged elements in document order; ids are compared, never put in a selector. */ +function tagged(node: Tagged, prefix: string): { id: string; node: Tagged }[] { + return Array.from(node.querySelectorAll(`[data-testid^="${prefix}"]`), (element) => ({ + id: element.getAttribute('data-testid')?.slice(prefix.length) ?? '', + node: element + })) +} + +function onScreen(box: Box, view: Box): boolean { + return box.bottom > view.top && box.top < view.bottom +} + +/** A row's tool lines on screen, else its run when the run's top is (below that top, lines can join + * or leave), else the row itself. */ +function unitsOf(row: Tagged, view: Box, self: Unit): Unit[] { + const shown: Unit[] = [] + const parts = + typeof Element !== 'undefined' && row instanceof Element + ? mobileNativeChatReadingParts(row).map((node) => ({ + node, + id: + mobileNativeChatScrollRootKey(node) !== undefined + ? `root:${mobileNativeChatScrollRootKey(node)}` + : (node.getAttribute('data-testid')?.slice(MEMBER.length) ?? '') + })) + : tagged(row, MEMBER) + for (const { id, node } of parts) { + const box = node.getBoundingClientRect() + if (box.top >= view.bottom) { + break + } + if (id === 'run' ? box.top >= view.top : onScreen(box, view)) { + shown.push({ ...self, top: box.top, member: id }) + } } + const lines = shown.filter((unit) => unit.member !== 'run') + return lines.length > 0 ? lines : [shown[0] ?? self] +} + +/** What the reader is reading, in page order. */ +function placeOnScreen(scroller: Scroller, { placement }: Props['rowKeys']): Unit[] { const view = scroller.getBoundingClientRect() - // The list's own rows, then the rows of the header block that precedes them. - const header = content.children[0] - for (const rows of [content.children, header?.children ?? []]) { - for (let at = rows.length - 1; at >= 0; at -= 1) { - const box = rows[at].getBoundingClientRect() - if (box.top < view.bottom && box.bottom > view.top && rows[at] !== header) { - return { row: rows[at], top: box.top } + const rows = tagged(scroller, ROW) + // Rows in normal flow have ordered bottoms, so the first one on screen is found without + // measuring the up to 2,000 above it. + let low = 0 + let high = rows.length + while (low < high) { + const middle = low + Math.floor((high - low) / 2) + if (rows[middle]!.node.getBoundingClientRect().bottom <= view.top) { + low = middle + 1 + } else { + high = middle + } + } + let end = low + while (end < rows.length && rows[end]!.node.getBoundingClientRect().top < view.bottom) { + end += 1 + } + const self = ({ id, node }: { id: string; node: Tagged }): Unit => { + return { key: id, top: node.getBoundingClientRect().top, placed: placement(id) } + } + const units = rows.slice(low, end).flatMap((row) => unitsOf(row.node, view, self(row))) + // The nearest rows off screen stand in when the update takes away all that was on it. + return units.concat([rows[end], rows[low - 1]].flatMap((row) => (row ? [self(row)] : []))) +} + +/** Where a held part is drawn now, if the update kept it. A line in the row now drawing its old + * row's messages (an orphan result's line is its call's once paired); a run only in the row that + * kept its key; a row only while the same message places it, since a group keeps its key when the + * fold moves it. */ +function drawn( + unit: Unit, + rows: Map, + keys: Props['rowKeys'], + roots?: MobileNativeChatScrollRoots +): Tagged | undefined { + const { key, member } = unit + if (member?.startsWith('root:')) { + return roots?.frame(member.slice(5)) + } + if (member === undefined) { + return keys.placement(key) === unit.placed ? rows.get(key) : undefined + } + const row = rows.get(member === 'run' ? key : (keys.current(key) ?? '')) + const paired = member.startsWith('result:') ? `call:${member.slice('result:'.length)}` : member + return row && tagged(row, MEMBER).find((m) => m.id === member || m.id === paired)?.node +} + +/** The update moves what the reader reads to another row (a line to another group, a row placed by + * another message): the browser's anchoring loses that node, so the hold puts it back. */ +function moves( + unit: Unit | undefined, + keys: Props['rowKeys'], + placements?: ReadonlyMap, + roots?: MobileNativeChatScrollRoots +): boolean { + if (!unit) { + return false + } + if (unit.member === undefined) { + return keys.placement(unit.key) !== unit.placed + } + if (unit.member.startsWith('root:')) { + const owner = roots?.owner(unit.member.slice(5)) + const now = owner === undefined ? undefined : placements?.get(owner) + return now !== undefined && now !== unit.placed + } + // An orphan result's line goes to its call's row once the call lands, and its old row stays. + return ( + unit.member.startsWith('result:') || + (unit.member !== 'run' && keys.current(unit.key) !== unit.key) + ) +} + +/** iOS is still bouncing the list past an end. A write then lands while WebKit owns the scroll, and + * the list stops taking the reader's scrolls until they leave the chat. */ +function overscrolled({ scrollTop, scrollHeight, clientHeight }: Scroller): boolean { + return scrollTop < 0 || scrollTop > scrollHeight - clientHeight + 1 +} + +/** Keeps a parked reader's place on the page when the source window changes above them (an older + * page, a cap trim) or the rows regroup (Tools, text size): the first part of what they were reading that the update kept goes back where + * it was. Where the browser has scroll anchoring it keeps the place through ordinary changes, so the + * hold runs only for a regroup or a move to another row, with the browser's anchoring off from + * before the update until its correction. A following reader is left to the follow owner, which + * holds the end. A class because only `getSnapshotBeforeUpdate` sees the rows before the update is + * in the document. A native list holds its own place (`maintainVisibleContentPosition`). */ +export class MobileNativeChatScrollHold extends Component { + getSnapshotBeforeUpdate(previous: Props): Unit[] | null { + const regrouped = previous.layoutKey !== this.props.layoutKey + const moved = new Set( + Array.from(previous.runPlacements ?? []).flatMap(([owner, row]) => + this.props.runPlacements?.get(owner) !== row ? [owner] : [] + ) + ) + if (Platform.OS === 'web' && previous.rowKeys.scope === this.props.rowKeys.scope) { + this.props.roots?.capture(moved, regrouped) + } + // Another chat opens at its end, which the follow owner resets only after this reads it. + if ( + Platform.OS !== 'web' || + previous.rowKeys.scope !== this.props.rowKeys.scope || + (previous.rowKeys.sourceHead === this.props.rowKeys.sourceHead && + !regrouped && + moved.size === 0) || + this.props.isFollowing() + ) { + return null + } + const list = this.props.listRef.current + const scroller = list?.getScrollableNode() + const held = + scroller && !overscrolled(scroller) ? placeOnScreen(scroller, previous.rowKeys) : null + if ( + !held || + (!scrollWritesStopMomentum() && + !regrouped && + !moves(held[0], this.props.rowKeys, this.props.runPlacements, this.props.roots)) + ) { + return null + } + list?.holdPlace?.(true) + return held + } + + componentDidMount(): void { + this.props.roots?.restoreAll() + } + + componentDidUpdate(previous: Props, _state: unknown, held: Unit[] | null): void { + try { + const regrouped = previous.layoutKey !== this.props.layoutKey + this.props.roots?.restoreAll(regrouped, false) + if (held) { + this.restore(held, regrouped) + } + } finally { + this.props.roots?.releaseHold() + // Whatever the correction did, the scroll goes back to its owner. + if (held) { + this.props.listRef.current?.holdPlace?.(false) } } } - return null -} -/** Keeps the page's view on the same rows when rows above it change, by putting a row that was on - * screen back where it was. A class because only `getSnapshotBeforeUpdate` sees that row before - * the new ones are in the document. A native list holds its own place - * (`maintainVisibleContentPosition`). */ -export class MobileNativeChatScrollHold extends Component { - getSnapshotBeforeUpdate(previous: Props): Held | null { - if (Platform.OS !== 'web' || previous.headKey === this.props.headKey) { - return null + private restore(held: Unit[], regrouped: boolean): void { + const list = this.props.listRef.current + const scroller = held.length ? list?.getScrollableNode() : null + if (!scroller) { + return } - const scroller = this.props.listRef.current?.getScrollableNode() - return scroller ? lowestRowOnScreen(scroller) : null - } - - componentDidUpdate(_previous: Props, _state: unknown, held: Held | null): void { - const scroller = held?.row.isConnected ? this.props.listRef.current?.getScrollableNode() : null - if (held && scroller) { - scroller.scrollTop += held.row.getBoundingClientRect().top - held.top + const rows = new Map(tagged(scroller, ROW).map(({ id, node }) => [id, node])) + for (const unit of held) { + const node = drawn(unit, rows, this.props.rowKeys, this.props.roots) + if (node) { + const drift = node.getBoundingClientRect().top - unit.top + // Sub-pixel drift is layout noise; a write would stop iOS momentum for nothing. + if (Math.abs(drift) >= 0.5 && list?.keepInPlace) { + list.keepInPlace(drift, regrouped) + } else if (Math.abs(drift) >= 0.5) { + scroller.scrollTop += drift + } + return + } } } - render(): null { - return null + render(): React.ReactNode { + return this.props.children ?? null } } diff --git a/mobile/src/session/MobileNativeChatTaskList.tsx b/mobile/src/session/MobileNativeChatTaskList.tsx index 627f0ea6a24..7caa254c9af 100644 --- a/mobile/src/session/MobileNativeChatTaskList.tsx +++ b/mobile/src/session/MobileNativeChatTaskList.tsx @@ -1,4 +1,3 @@ -import { useState } from 'react' import { Pressable, Text, View } from 'react-native' import { ChevronRight, Circle, CircleCheck, CircleDot, ListChecks } from 'lucide-react-native' import { @@ -12,6 +11,7 @@ import { import { colors } from '../theme/mobile-theme' import { useMobileNativeChatOpenedDetail } from './mobile-native-chat-opened-detail' import { toolStyles as styles, toolType } from './mobile-native-chat-tool-styles' +import { useMobileNativeChatDisclosure } from './mobile-native-chat-disclosure-store' const STATUS_WORD: Record = { completed: NATIVE_CHAT_TASK_LIST_COPY.completed, @@ -83,13 +83,21 @@ function Checklist({ list, fontScale }: { list: TaskList; fontScale: number }) { } /** The whole list behind a disclosure, under a list of what changed. */ -function FullList({ list, fontScale }: { list: TaskList; fontScale: number }) { - const [open, setOpen] = useState(false) +function FullList({ + list, + fontScale, + disclosureKey +}: { + list: TaskList + fontScale: number + disclosureKey?: string +}) { + const [open, setOpen] = useMobileNativeChatDisclosure(disclosureKey, false) return ( setOpen((value) => !value)} + onPress={() => setOpen(!open)} accessibilityRole="button" accessibilityState={{ expanded: open }} > @@ -119,11 +127,13 @@ function OpenedChecklist({ list, fontScale }: { list: TaskList; fontScale: numbe export function MobileNativeChatTaskList({ list, previous, - fontScale + fontScale, + disclosureKey }: { list: TaskList previous?: TaskList fontScale: number + disclosureKey?: string }): React.JSX.Element { const completed = list.tasks.filter((task) => task.status === 'completed').length const changes = previous ? diffNativeChatTaskLists(previous, list) : null @@ -163,7 +173,7 @@ export function MobileNativeChatTaskList({ ) : ( {NATIVE_CHAT_TASK_LIST_COPY.unchanged} )} - + ) : ( diff --git a/mobile/src/session/MobileNativeChatToolLine.tsx b/mobile/src/session/MobileNativeChatToolLine.tsx index 05ce18f8a4b..d76bf9a9d66 100644 --- a/mobile/src/session/MobileNativeChatToolLine.tsx +++ b/mobile/src/session/MobileNativeChatToolLine.tsx @@ -1,5 +1,6 @@ import { useMemo } from 'react' -import { Pressable, ScrollView, Text, View } from 'react-native' +import { Pressable, Text, View } from 'react-native' +import { MobileNativeChatOwnedScrollView } from './MobileNativeChatOwnedScrollView' import { ChevronRight } from 'lucide-react-native' import { agentJournalToolCallLifecycle } from '../../../src/shared/agent-journal-tool-call-lifecycle' import { diffFromText, diffFromToolCall } from '../../../src/shared/native-chat-diff' @@ -92,7 +93,9 @@ function ToolLineDetail({ fullCommand, commandLanguage, results, - fontScale + fontScale, + disclosureKey, + memberId }: { call: NativeChatToolCallBlock | undefined body: NativeChatToolResultBlock | undefined @@ -100,6 +103,8 @@ function ToolLineDetail({ commandLanguage: string | null results: NativeChatWebSearchResult[] fontScale: number + disclosureKey?: string + memberId?: string }): React.JSX.Element { const diff = call ? diffFromToolCall(call.name, call.input) @@ -120,7 +125,9 @@ function ToolLineDetail({ {results.length > 0 ? : null} {diff ? : null} {!diff && fullCommand ? ( - {command ? : fullCommand} - + ) : null} {body ? ( - {truncateToolDetail(body.output)} - + ) : null} ) @@ -151,6 +160,7 @@ function ToolLineDetail({ /** Desktop parity: `NativeChatToolLine`. A tool sentence with its output behind its * own disclosure; an orphan result draws as a `Result` row. */ export function MobileNativeChatToolLine({ + memberId, block, result, defaultExpanded, @@ -158,6 +168,8 @@ export function MobileNativeChatToolLine({ fontScale, onOpenFile }: { + /** Tags the line for the page's place-hold. */ + memberId?: string block: NativeChatToolCallBlock | NativeChatToolResultBlock /** This call's output, when the run paired one to it. */ result?: NativeChatToolResultBlock @@ -198,7 +210,7 @@ export function MobileNativeChatToolLine({ const duration = call ? formatToolDuration(call.durationMs) : null return ( - + hasDetail && setExpanded(!expanded)} @@ -248,6 +260,8 @@ export function MobileNativeChatToolLine({ {hasDetail && expanded ? ( - after: readonly React.JSX.Element[] -} - /** Desktop parity: `NativeChatToolRunMembers`. An opened run's members in a bounded * box, so live work fills it instead of lengthening the chat. A live run opens on * what it is doing now and follows it while the reader stays at the end. */ @@ -108,6 +109,7 @@ function ToolRunMembers({ followKey, startsAtEnd, memoryKey, + owner, children }: { /** How much work the run holds: a reader at the end follows it growing. Opening a detail @@ -116,10 +118,13 @@ function ToolRunMembers({ startsAtEnd: boolean /** The run's disclosure key. The list recycles rows; the box comes back as the reader left it. */ memoryKey?: string + owner?: string children: React.ReactNode }): React.JSX.Element { const { height } = useWindowDimensions() - const runScroll = useContext(MobileNativeChatDisclosureContext)?.runScroll + const store = useContext(MobileNativeChatDisclosureContext) + const runScroll = store?.runScroll + const owned = Platform.OS === 'web' && store !== null const [remembered] = useState(() => memoryKey === undefined ? undefined : runScroll?.get(memoryKey) ) @@ -129,10 +134,10 @@ function ToolRunMembers({ // Desktop's rule: follow when the work changes, and only if the reader was at the end before // it. Resolved here, with the update, so a later detail opening never inherits a follow. useLayoutEffect(() => { - if (followsEnd.current) { + if (!owned && followsEnd.current) { scroller.current?.scrollToEnd({ animated: false }) } - }, [followKey]) + }, [followKey, owned]) const details = useRef(new Map()) const [openedHeight, setOpenedHeight] = useState(0) // Room for the list plus what the reader has opened in it, up to half the screen. @@ -152,7 +157,7 @@ function ToolRunMembers({ const { contentOffset, contentSize, layoutMeasurement } = nativeEvent followsEnd.current = contentSize.height - contentOffset.y - layoutMeasurement.height <= FOLLOW_REARM_PX - if (runScroll && memoryKey !== undefined) { + if (!owned && runScroll && memoryKey !== undefined) { runScroll.delete(memoryKey) runScroll.set(memoryKey, { top: contentOffset.y, followsEnd: followsEnd.current }) if (runScroll.size > MAX_DISCLOSURES) { @@ -165,14 +170,19 @@ function ToolRunMembers({ } // The remembered offset needs the rows laid out first, which the box's first size reports. const onContentSizeChange = () => { - if (pendingRestore.current && remembered) { + if (!owned && pendingRestore.current && remembered) { pendingRestore.current = false scroller.current?.scrollTo({ y: remembered.top, animated: false }) } } return ( - {children} - + ) } @@ -206,28 +216,7 @@ export function ToolRun({ previousUpdatePlan, fontScale = 1, onOpenFile -}: { - blocks: NativeChatBlock[] - /** The last plan each task-list tool wrote before this run, so an update reads as its changes. */ - previousTodoWrite?: NativeChatToolCallBlock - previousUpdatePlan?: NativeChatToolCallBlock - defaultExpanded: boolean - /** The row this run belongs to. The list recycles rows, so what the reader opened is - * remembered somewhere that outlives the row. */ - disclosureId?: string - /** Rows drawn among the run's lines, each before the block it is keyed by, or after the last. */ - asides?: MobileNativeChatToolRunAsides - /** Every line's default: open under the Tools toggle. */ - expandChildren: boolean - /** Structured lifecycle state; keeps an orphaned running call from spinning. */ - activeTurnIsWorking?: boolean - /** Whether this run is the working turn's last. Only that run is live. */ - trailing?: boolean - /** The turn is blocked on the reader's answer, so its calls decide liveness. */ - awaitingAnswer?: boolean - fontScale?: number - onOpenFile?: (path: string) => void -}): React.JSX.Element { +}: MobileNativeChatToolRunProps): React.JSX.Element { // A reader's choice belongs to the default it deviated from, so toggling the Tools // default back restores it. const runKey = disclosureId === undefined ? undefined : `run:${disclosureId}:${defaultExpanded}` @@ -271,7 +260,7 @@ export function ToolRun({ const iconSize = 15 * fontScale return ( - + {/* One element for the run's whole life: live and settled are states of this button, not two buttons. */} buildEditCards(blocks), [blocks]) const { resultByCall, pairedResults } = useMemo(() => pairNativeChatToolResults(blocks), [blocks]) const seen = new Map() - const member = (block: NativeChatBlock, blockIndex: number): React.ReactNode => { + // Each drawn member's line identity: the page's place-hold finds it by this, and a card keyed by + // it keeps its state when older calls join the run above it. + const memberSeen = new Map() + const memberIdentity = (block: NativeChatBlock) => + nativeChatToolLineIdentity(block, memberSeen, true) + const member = (block: NativeChatBlock): React.ReactNode => { const taskList = taskLists.rows.get(block) if (taskList) { + const id = memberIdentity(block) return ( - + + + ) } const edit = editCards.get(block) if (edit) { + const id = memberIdentity(block) return ( - + {edit.files.map((file, fileIndex) => ( {/* One flat keyed list with or without asides, so a row gaining its first never remounts a line. */} - {blocks.flatMap((block, blockIndex) => [ - ...(asides?.before.get(block) ?? []), - member(block, blockIndex) - ])} + {blocks.flatMap((block) => [...(asides?.before.get(block) ?? []), member(block)])} {asides?.after} ) diff --git a/mobile/src/session/MobileNativeChatView.harness.test-fixture.ts b/mobile/src/session/MobileNativeChatView.harness.test-fixture.ts index 617e9147405..92bd95df52c 100644 --- a/mobile/src/session/MobileNativeChatView.harness.test-fixture.ts +++ b/mobile/src/session/MobileNativeChatView.harness.test-fixture.ts @@ -75,7 +75,8 @@ export function installChatViewHooks(): void { renderer = null scrollToEnd.mockReset() scrollToOffset.mockReset() - pageScroller.scrollHeight = 0 + Object.assign(pageScroller, { scrollTop: 0, scrollHeight: 0, clientHeight: 500 }) + pageScroller.style.overflowAnchor = '' vi.unstubAllGlobals() }) } @@ -141,6 +142,17 @@ export async function pressSend(): Promise { }) } +/** What the list reports from a scroll, drag or momentum event, in a 500-tall viewport. */ +export function scrollEvent(y: number, contentHeight: number) { + return { + nativeEvent: { + contentOffset: { y }, + contentSize: { height: contentHeight }, + layoutMeasurement: { height: 500 } + } + } +} + export async function scrollAwayFromTail(): Promise { await act(async () => { list().props.onScrollBeginDrag?.({}) diff --git a/mobile/src/session/MobileNativeChatView.history-paging.test.ts b/mobile/src/session/MobileNativeChatView.history-paging.test.ts index 02d061e5d2d..7caf12ef675 100644 --- a/mobile/src/session/MobileNativeChatView.history-paging.test.ts +++ b/mobile/src/session/MobileNativeChatView.history-paging.test.ts @@ -3,11 +3,13 @@ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' import { installChatViewHooks, assistantTurn, + hostType, list, pageScroller, platform, render, root, + scrollEvent, scrollToEnd, scrollToOffset, update @@ -132,8 +134,30 @@ describe('MobileNativeChatView: history paging and anchoring', () => { }) describe('on the page, where the list reports no drag or momentum', () => { + const observers = new Set<(entries: unknown[]) => void>() + // The browser's resize step, before paint; RN Web's size report comes a task later. + const resized = () => act(() => observers.forEach((deliver) => deliver([]))) + const page = () => root().find((node) => hostType(node) === 'ScrollView') + const loadEarlier = () => + root().find( + (node) => + hostType(node) === 'Pressable' && + node.findAll((text) => text.props.children === 'Load earlier messages').length > 0 + ) + beforeEach(() => { platform.OS = 'web' + scrollToOffset.mockImplementation((to: { offset: number }) => { + pageScroller.scrollTop = to.offset + }) + observers.clear() + vi.stubGlobal('ResizeObserver', function (deliver: (entries: unknown[]) => void) { + return { + observe: () => observers.add(deliver), + unobserve: () => {}, + disconnect: () => observers.delete(deliver) + } + }) }) afterEach(() => { @@ -141,77 +165,199 @@ describe('MobileNativeChatView: history paging and anchoring', () => { }) function scrolledTo(y: number, contentHeight: number) { - return { - nativeEvent: { - contentOffset: { y }, - contentSize: { height: contentHeight }, - layoutMeasurement: { height: 500 } - } - } + Object.assign(pageScroller, { scrollTop: y, scrollHeight: contentHeight }) + return scrollEvent(y, contentHeight) } - it('stops following when the reader scrolls up, and resumes at the end', async () => { - await render({ folded: [assistantTurn('a1', 'Latest')] }) + // RN Web's report and the scroller's own event, for one position. + function scrollTo(y: number, contentHeight: number) { act(() => { - list().props.onContentSizeChange(320, 1_000) - list().props.onScroll(scrolledTo(500, 1_000)) + page().props.onScroll(scrolledTo(y, contentHeight)) + pageScroller.scroll() }) - scrollToOffset.mockClear() + } - // No drag event arrives on the page: the move up is all there is to go on. - act(() => { - list().props.onScroll(scrolledTo(300, 1_000)) - list().props.onContentSizeChange(320, 1_200) - }) - expect(scrollToOffset).not.toHaveBeenCalled() + it('holds a following reader at the live end through growth and a smaller viewport', async () => { + const latest = [assistantTurn('a1', 'Latest')] + const landed = [assistantTurn('a0', 'Older'), ...latest] + const onLoadEarlier = vi.fn() + await render({ folded: latest, hasMore: true, onLoadEarlier }) + scrollTo(500, 1_000) + + // Older rows land; the observer pins before paint. + act(() => loadEarlier().props.onPress()) + await update({ folded: landed, hasMore: true, onLoadEarlier }) + pageScroller.scrollHeight = 5_000 + resized() + expect(onLoadEarlier).toHaveBeenCalledOnce() + expect(scrollToOffset).toHaveBeenCalledOnce() + expect(scrollToOffset).toHaveBeenLastCalledWith({ animated: false, offset: 4_500 }) + + // The pin's own move, short of a reply that grew meanwhile, is not the reader's. + scrollTo(4_500, 5_400) + resized() + expect(scrollToOffset).toHaveBeenLastCalledWith({ animated: false, offset: 4_900 }) + + // The browser clamping the scroll to a shorter end is layout's, not the reader's: it still + // follows, and the next growth pins. + scrollTo(3_500, 4_000) + pageScroller.scrollHeight = 5_400 + resized() + expect(scrollToOffset).toHaveBeenCalledTimes(3) + expect(scrollToOffset).toHaveBeenLastCalledWith({ animated: false, offset: 4_900 }) + scrollTo(4_900, 5_400) + + // The reader's wheel moves it up: it leaves the end. + pageScroller.input('wheel') + scrollTo(3_000, 5_400) + pageScroller.scrollHeight = 6_000 + resized() + expect(scrollToOffset).toHaveBeenCalledTimes(3) expect( root().findAll((node) => node.props.accessibilityLabel === 'Scroll to latest') ).toHaveLength(1) - act(() => { - list().props.onScroll(scrolledTo(700, 1_200)) - list().props.onContentSizeChange(320, 1_400) - }) - expect(scrollToOffset).toHaveBeenLastCalledWith({ animated: false, offset: 1_400 }) + // Back at the end it follows again, and a smaller viewport pins to the live end. + scrollTo(5_500, 6_000) + pageScroller.scrollHeight = 6_400 + resized() + expect(scrollToOffset).toHaveBeenLastCalledWith({ animated: false, offset: 5_900 }) + pageScroller.clientHeight = 300 + resized() + expect(scrollToOffset).toHaveBeenLastCalledWith({ animated: false, offset: 6_100 }) + expect(scrollToEnd).not.toHaveBeenCalled() }) - it('does not take its own pin, landing short of a grown end, for the reader', async () => { - await render({ folded: [assistantTurn('a1', 'Latest')] }) - act(() => { - list().props.onContentSizeChange(320, 1_000) - list().props.onScroll(scrolledTo(500, 1_000)) - }) - scrollToOffset.mockClear() - - // The pin reached the end it was sent to; the reply had grown 400 past it by then. - act(() => { - list().props.onScroll(scrolledTo(700, 1_600)) - list().props.onContentSizeChange(320, 1_600) - }) - - expect(scrollToOffset).toHaveBeenLastCalledWith({ animated: false, offset: 1_600 }) + it('resumes an expired downward fling near the end, preserving small upward detachment', async () => { + const now = vi.spyOn(Date, 'now').mockReturnValue(10_000) + vi.stubGlobal('navigator', { userAgent: 'Chrome' }) + const browserHolds = () => pageScroller.style.overflowAnchor === 'auto' + await render({ folded: ['a1', 'a2', 'a3'].map((id) => assistantTurn(id, id)) }) + scrollTo(500, 1_000) + resized() + pageScroller.input('wheel') + scrollTo(497, 1_000) + // Expired input and geometry away from the end cannot detach or attach by themselves. + now.mockReturnValue(11_100) + pageScroller.scrollHeight = 1_200 + resized() + expect(browserHolds()).toBe(true) + scrollTo(632, 1_200) + expect(browserHolds()).toBe(false) + pageScroller.scrollHeight = 1_300 + resized() + expect(scrollToOffset).toHaveBeenLastCalledWith({ animated: false, offset: 800 }) + scrollTo(800, 1_300) // Deliver the pin's own scroll before the next reader movement. + // The first upward movement can arrive in resize before its scroll event. + pageScroller.input('wheel') + pageScroller.scrollTop = 780 + resized() + expect(browserHolds()).toBe(true) + scrollTo(799, 1_300) + expect(browserHolds()).toBe(false) + now.mockRestore() }) - it("opens another chat at its measured end, not the last chat's", async () => { + it('detaches a following reader who drags up slowly, one pixel per scroll event', async () => { + // iOS, where it happened: WebKit has no anchoring, so every move that isn't a clamp is the reader's. + vi.stubGlobal('navigator', { + userAgent: + 'Mozilla/5.0 (iPhone) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/604.1' + }) + await render({ folded: ['a1', 'a2', 'a3'].map((id) => assistantTurn(id, id)) }) + scrollTo(500, 1_000) + resized() + const pins = scrollToOffset.mock.calls.length + // Each step's frame reconciles what it drew, as the browser's resize step does. + for (let top = 499; top >= 470; top -= 1) { + scrollTo(top, 1_000) + resized() + } + // A reply lands: the reader who dragged 30px up is not pulled back to the end. + pageScroller.scrollHeight = 1_063 + resized() + expect(scrollToOffset.mock.calls.length).toBe(pins) + }) + + it('resumes following after a slow one-pixel return to the end on Safari', async () => { + vi.stubGlobal('navigator', { + userAgent: + 'Mozilla/5.0 (iPhone) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/604.1' + }) + await render({ folded: ['a1', 'a2', 'a3'].map((id) => assistantTurn(id, id)) }) + scrollTo(500, 1_000) + resized() + // Deliver the initial pin's own scroll before the reader moves. + scrollTo(500, 1_000) + scrollTo(470, 1_000) + resized() + const pins = scrollToOffset.mock.calls.length + + for (let top = 471; top <= 500; top += 1) { + scrollTo(top, 1_000) + resized() + expect(scrollToOffset.mock.calls.length).toBe(pins) + } + + pageScroller.scrollHeight = 1_063 + resized() + expect(scrollToOffset.mock.calls.length).toBe(pins + 1) + expect(scrollToOffset).toHaveBeenLastCalledWith({ animated: false, offset: 563 }) + expect(pageScroller.scrollTop).toBe(563) + }) + + it.each([false, true])( + 'leaves a parked reader parked when a collapse below them clamps the page onto its end (finger down: %s)', + async (fingerDown) => { + await render({ folded: ['a1', 'a2', 'a3'].map((id) => assistantTurn(id, id)) }) + scrollTo(500, 1_000) + resized() + // Parked 29 px short of the end by the reader's own wheel. + pageScroller.input('wheel') + scrollTo(471, 1_000) + const pins = scrollToOffset.mock.calls.length + + // A run below collapses: the browser clamps onto the shorter end, possibly under the tap. + if (fingerDown) { + pageScroller.input('touchstart') + } + scrollTo(200, 700) + resized() + if (fingerDown) { + pageScroller.input('touchend') + } + // A reply lands. Main leaves the reader where the clamp put them; so does the page. + pageScroller.scrollHeight = 763 + resized() + expect(scrollToOffset.mock.calls.length).toBe(pins) + } + ) + + it("opens another chat at its live end, not the last chat's", async () => { await render({ folded: [assistantTurn('a1', 'Chat A')] }) - act(() => list().props.onContentSizeChange(320, 1_000)) + scrollTo(500, 1_000) + // The next chat is taller; its size report hasn't come yet. pageScroller.scrollHeight = 2_500 - scrollToEnd.mockClear() + scrollToOffset.mockClear() await update({ folded: [assistantTurn('b1', 'Chat B')], sendSurfaceId: 'tab-b' }) expect(scrollToEnd).not.toHaveBeenCalled() - expect(scrollToOffset).toHaveBeenLastCalledWith({ animated: false, offset: 2_500 }) + expect(scrollToOffset).toHaveBeenLastCalledWith({ animated: false, offset: 2_000 }) }) - it('pages in history when the reader scrolls up to the top, not when a pin passes it', async () => { + it('loads older history from the Load earlier control, not from reaching the top', async () => { const onLoadEarlier = vi.fn() await render({ folded: [assistantTurn('a1', 'Latest')], hasMore: true, onLoadEarlier }) - act(() => list().props.onScroll(scrolledTo(40, 540))) + // Down to the top and into iOS's bounce past it. + act(() => { + page().props.onScroll(scrolledTo(40, 540)) + page().props.onScroll(scrolledTo(-15, 540)) + }) expect(onLoadEarlier).not.toHaveBeenCalled() - act(() => list().props.onScroll(scrolledTo(10, 540))) + act(() => loadEarlier().props.onPress()) expect(onLoadEarlier).toHaveBeenCalledOnce() }) }) diff --git a/mobile/src/session/MobileNativeChatView.render-stubs.test-fixture.ts b/mobile/src/session/MobileNativeChatView.render-stubs.test-fixture.ts index 6c4678fce86..484e9de916a 100644 --- a/mobile/src/session/MobileNativeChatView.render-stubs.test-fixture.ts +++ b/mobile/src/session/MobileNativeChatView.render-stubs.test-fixture.ts @@ -3,7 +3,29 @@ import { vi } from 'vitest' const scrollToEnd = vi.hoisted(() => vi.fn()) const scrollToOffset = vi.hoisted(() => vi.fn()) const platform = vi.hoisted(() => ({ OS: 'ios' })) -const pageScroller = vi.hoisted(() => ({ scrollHeight: 0 })) +// The page list's live DOM geometry. +const pageScroller = vi.hoisted(() => { + const listeners = new Map void>>() + return { + scrollTop: 0, + scrollHeight: 0, + clientHeight: 500, + clientWidth: 320, + style: { overflowAnchor: '' }, + getInnerViewNode: () => ({}), + getBoundingClientRect: () => ({ top: 0, bottom: 500 }), + addEventListener: (type: string, listener: (event?: unknown) => void) => { + listeners.set(type, (listeners.get(type) ?? new Set()).add(listener)) + }, + removeEventListener: (type: string, listener: (event?: unknown) => void) => { + listeners.get(type)?.delete(listener) + }, + // The browser's scroll event at the scroller's current position. + scroll: () => listeners.get('scroll')?.forEach((listener) => listener()), + // The reader's hand on the scroller, e.g. a wheel turn. + input: (type: string) => listeners.get(type)?.forEach((listener) => listener()) + } +}) export async function reactNativeStub() { const React = await import('react') @@ -16,6 +38,17 @@ export async function reactNativeStub() { }), Platform: platform, Pressable: 'Pressable', + // The page's list scrolls through it. + ScrollView: React.forwardRef((props, ref) => { + const handle = { + scrollTo: ({ y, animated }: { y: number; animated: boolean }) => + scrollToOffset({ animated, offset: y }), + scrollToEnd, + getScrollableNode: () => pageScroller + } + React.useImperativeHandle(ref, () => handle, []) + return React.createElement('ScrollView', props) + }), StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 }, Text: 'Text', View: 'View' diff --git a/mobile/src/session/MobileNativeChatView.tail-follow.test.ts b/mobile/src/session/MobileNativeChatView.tail-follow.test.ts index 8d78eed4f9a..5e454156777 100644 --- a/mobile/src/session/MobileNativeChatView.tail-follow.test.ts +++ b/mobile/src/session/MobileNativeChatView.tail-follow.test.ts @@ -6,6 +6,7 @@ import { list, pressSend, render, + renderedRow, root, scrollAwayFromTail, scrollToEnd, @@ -335,6 +336,21 @@ describe('MobileNativeChatView: tail-follow', () => { expect(control('Collapse tool runs')).toHaveLength(1) }) + it('starts an initially empty switched-to native chat following, preserving Tools', async () => { + const toolTurn = (id: string): ReturnType => ({ + ...assistantTurn(id, ''), + blocks: [{ type: 'tool-call', name: 'Bash', callId: id, input: { command: 'ls' } }] + }) + await render({ folded: [toolTurn('a')] }) + act(() => root().findAllByProps({ accessibilityLabel: 'Expand tool runs' })[0].props.onPress()) + await scrollAwayFromTail() + await update({ sendSurfaceId: 'tab-b', folded: [] }) + await update({ sendSurfaceId: 'tab-b', folded: [toolTurn('b')] }) + act(() => list().props.onContentSizeChange(320, 1300)) + expect(scrollToOffset).toHaveBeenLastCalledWith({ animated: false, offset: 1300 }) + expect(renderedRow('b').props.toolsExpanded).toBe(true) + }) + it('repins after a keyboard-driven viewport layout only while following', async () => { vi.useFakeTimers() try { diff --git a/mobile/src/session/MobileNativeChatView.tsx b/mobile/src/session/MobileNativeChatView.tsx index 4a811e92b64..39fb68ec1c3 100644 --- a/mobile/src/session/MobileNativeChatView.tsx +++ b/mobile/src/session/MobileNativeChatView.tsx @@ -6,7 +6,6 @@ import { } from './mobile-native-chat-disclosure-store' import { ActivityIndicator, - FlatList, type NativeScrollEvent, type NativeSyntheticEvent, Text, @@ -31,12 +30,9 @@ import { mobileNativeChatEmptyState, type MobileNativeChatPendingItem } from './mobile-native-chat-render-data' -import { useMobileNativeChatPageHistory } from './use-mobile-native-chat-page-history' +import { useMobileNativeChatRowKeys } from './mobile-native-chat-row-keys' import { useMobileNativeChatPinchGesture } from './use-mobile-native-chat-pinch-gesture' -import { - mobileNativeChatRowAnchor, - useMobileNativeChatTailFollow -} from './use-mobile-native-chat-tail-follow' +import { useMobileNativeChatTailFollow } from './use-mobile-native-chat-tail-follow' import { useMobileNativeChatTurnDisclosure } from './use-mobile-native-chat-turn-disclosure' import { mobileNativeChatComposerPlaceholder, @@ -56,8 +52,10 @@ import type { MobileChatPermission } from './mobile-native-chat-permission' import type { MobileChatQuestion } from './mobile-native-chat-question' import type { MobileNativeChatSessionOptionPickersProps } from './MobileNativeChatSessionOptionPickers' import { MobileNativeChatScrollHold } from './MobileNativeChatScrollHold' +import { MobileNativeChatRowList } from './MobileNativeChatRowList' import { useMobileNativeChatRowRenderer } from './use-mobile-native-chat-row-renderer' import type { MobileNativeChatStatus } from './use-mobile-native-chat-session' +import type { OlderPagePhase } from './use-mobile-older-page-loader' /** Why the composer input is locked: the transport is disconnected, or the * terminal subscription has not acknowledged its input lease yet. */ @@ -94,8 +92,9 @@ type Props = ComposerTrayProps & { * by the overlay against the transcript catching up. */ streaming: string | null hasMore?: boolean - loadingEarlier?: boolean - onLoadEarlier?: () => void + olderPages?: OlderPagePhase + /** `settled`: on the page, an older page waits for it so it never lands under a moving reader. */ + onLoadEarlier?: (settled?: () => Promise) => void onSend: (text: string) => Promise /** Route identity used to fence accepted sends that settle after a tab/view switch. */ sendSurfaceId: string @@ -185,7 +184,7 @@ export function MobileNativeChatView({ onStop, streaming, hasMore, - loadingEarlier, + olderPages = 'idle', onLoadEarlier, onSend, sendSurfaceId, @@ -250,19 +249,8 @@ export function MobileNativeChatView({ }), [messages, folded, streaming, pending, imagePreviewsByMessageId] ) - const { - listRef, - showJumpToTail, - pinToTail, - pinToTailAfterContentResize, - jumpToTail, - beginUserScroll, - endUserDrag, - beginMomentum, - endMomentum, - readerIsScrolling, - recordScrollMetrics - } = useMobileNativeChatTailFollow({ items: data, scopeKey: sendSurfaceId }) + const tail = useMobileNativeChatTailFollow({ hasItems: data.length > 0, scopeKey: sendSurfaceId }) + const { jumpToTail, recordScrollMetrics, scrollMayLoadEarlier, pageSettled } = tail const handleSend = useCallback( async (text: string): Promise => { @@ -280,18 +268,19 @@ export function MobileNativeChatView({ [onSend, onClearSendError, jumpToTail] ) - const loadEarlier = useCallback(() => onLoadEarlier?.(), [onLoadEarlier]) + const loadEarlier = useCallback(() => onLoadEarlier?.(pageSettled), [onLoadEarlier, pageSettled]) const onScroll = useCallback( (e: NativeSyntheticEvent) => { const { contentOffset } = e.nativeEvent recordScrollMetrics(e.nativeEvent) - // Near the top — page in older history. Only the reader's scroll: a pin reports offsets too. - if (contentOffset.y < 60 && hasMore && !loadingEarlier && readerIsScrolling()) { + // Near the top — page in older history. Only the reader's drag: a pin reports offsets too. + // After a failure only a tap retries, so a reader still at the top can't loop the spinner. + if (contentOffset.y < 60 && hasMore && olderPages === 'idle' && scrollMayLoadEarlier()) { loadEarlier() } }, - [hasMore, loadingEarlier, loadEarlier, readerIsScrolling, recordScrollMetrics] + [hasMore, olderPages, loadEarlier, scrollMayLoadEarlier, recordScrollMetrics] ) // Per-turn status rows: one live indicator while the turn runs, then a settled @@ -312,10 +301,13 @@ export function MobileNativeChatView({ scopeKey: sendSurfaceId }) - // What the reader opened in this chat's runs, kept above the rows the list recycles. + // What the reader opened in this chat's runs, kept above the rows the list recycles or windows out. + const rowKeys = useMobileNativeChatRowKeys(folded, messages, sendSurfaceId, turns.presentation) const disclosures = useMobileNativeChatDisclosures(sendSurfaceId) const taskListPredecessors = useMemo(() => nativeChatTaskListPredecessors(data), [data]) const renderItem = useMobileNativeChatRowRenderer({ + rowKeyOf: rowKeys.keyOf, + runOwners: rowKeys.runOwners, toolsExpanded, taskListPredecessors, fontScale, @@ -328,8 +320,6 @@ export function MobileNativeChatView({ actionablePromptIds }) - const history = useMobileNativeChatPageHistory(turns.listMessages, renderItem, sendSurfaceId) - const liveStatus = turns.liveLine ? ( mobileNativeChatToolCallCount(folded), [folded]) const toggleTools = useCallback(() => setToolsExpanded((v) => !v), []) + const listContentStyle = + toolCallCount > 0 ? styles.listContentUnderFloatingControls : styles.listContent + const header = mobileNativeChatListHeader(hasMore, olderPages, loadEarlier) + // How rows draw: a change re-lays every row, which the place-hold and the window both follow. + const layoutKey = `${fontScale}:${toolsExpanded}` + const pageHeader = (measuring: boolean) => + mobileNativeChatListHeader(hasMore, olderPages, loadEarlier, measuring) + const footer = mobileNativeChatListFooter(liveStatus, turns.waitingRows, renderItem) const emptyState = mobileNativeChatEmptyState(status, agent ?? null, error) const lockReason = useSettledMobileNativeChatInputLock(inputLockReason) @@ -370,45 +368,30 @@ export function MobileNativeChatView({ ) : ( - - - item.id} - renderItem={history.renderListRow} - contentContainerStyle={ - toolCallCount > 0 ? styles.listContentUnderFloatingControls : styles.listContent - } - // Let link/file taps land while the composer keyboard is up - // instead of being swallowed by the dismiss gesture. - keyboardShouldPersistTaps="handled" - onScroll={onScroll} - onScrollBeginDrag={beginUserScroll} - onScrollEndDrag={endUserDrag} - onMomentumScrollBegin={beginMomentum} - onMomentumScrollEnd={endMomentum} - scrollEventThrottle={32} - onContentSizeChange={pinToTailAfterContentResize} - onLayout={pinToTail} - maintainVisibleContentPosition={mobileNativeChatRowAnchor(hasMore === true)} - ListHeaderComponent={history.withHistory( - mobileNativeChatListHeader(hasMore, loadingEarlier, loadEarlier) - )} - ListFooterComponent={mobileNativeChatListFooter( - liveStatus, - turns.waitingRows, - renderItem - )} - ListEmptyComponent={emptyStateView} - /> - + + + + + diff --git a/mobile/src/session/mobile-native-chat-disclosure-store.ts b/mobile/src/session/mobile-native-chat-disclosure-store.ts index d84f4e62f36..cc07f777dce 100644 --- a/mobile/src/session/mobile-native-chat-disclosure-store.ts +++ b/mobile/src/session/mobile-native-chat-disclosure-store.ts @@ -2,10 +2,14 @@ // disclosures live when the row itself may be unmounted: a run or line the reader opened // stays open after the list recycles its row, on reload, and across the Tools toggle. -import { createContext, useCallback, useContext, useMemo, useState } from 'react' +import { createContext, useCallback, useContext, useLayoutEffect, useMemo, useState } from 'react' +import { + MobileNativeChatScrollRoots, + type MobileNativeChatRootPosition +} from './mobile-native-chat-scroll-roots' /** Where the reader left an opened run's own scroll box. */ -export type MobileNativeChatRunScroll = { top: number; followsEnd: boolean } +export type MobileNativeChatRunScroll = MobileNativeChatRootPosition export type MobileNativeChatDisclosureStore = { read: (key: string) => boolean | undefined @@ -13,6 +17,7 @@ export type MobileNativeChatDisclosureStore = { /** By the run's disclosure key, and forgotten when that run closes. A plain map, not state: * scrolling a run must not re-render the transcript. */ runScroll: Map + roots: MobileNativeChatScrollRoots } export const MobileNativeChatDisclosureContext = @@ -25,10 +30,17 @@ type ScopedDisclosures = { scopeKey: string open: ReadonlyMap runScroll: Map + roots: MobileNativeChatScrollRoots } function emptyDisclosures(scopeKey: string): ScopedDisclosures { - return { scopeKey, open: new Map(), runScroll: new Map() } + const runScroll = new Map() + return { + scopeKey, + open: new Map(), + runScroll, + roots: new MobileNativeChatScrollRoots(runScroll, MAX_DISCLOSURES) + } } /** One store per chat: another chat's choices never leak in. */ @@ -37,12 +49,13 @@ export function useMobileNativeChatDisclosures(scopeKey: string): MobileNativeCh if (state.scopeKey !== scopeKey) { setState(emptyDisclosures(scopeKey)) } - const { open, runScroll } = state + const { open, runScroll, roots } = state + useLayoutEffect(() => () => roots.dispose(), [roots]) const write = useCallback( (key: string, next: boolean) => { // Forgotten with the run that closed, as desktop does. if (!next) { - runScroll.delete(key) + roots.forget(key) } setState((current) => { if (current.open.get(key) === next) { @@ -60,11 +73,11 @@ export function useMobileNativeChatDisclosures(scopeKey: string): MobileNativeCh return { ...current, open: updated } }) }, - [runScroll] + [roots] ) return useMemo( - () => ({ read: (key: string) => open.get(key), write, runScroll }), - [open, write, runScroll] + () => ({ read: (key: string) => open.get(key), write, runScroll, roots }), + [open, write, runScroll, roots] ) } diff --git a/mobile/src/session/mobile-native-chat-fold-reuse.ts b/mobile/src/session/mobile-native-chat-fold-reuse.ts new file mode 100644 index 00000000000..53524cd97a9 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-fold-reuse.ts @@ -0,0 +1,46 @@ +import { useLayoutEffect, useMemo, useRef } from 'react' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' + +/** The fold rebuilt this row with nothing in it changed: the same fields and the same block objects + * in the same order. Its id only names the candidate; content decides. */ +function unchanged(previous: NativeChatMessage, next: NativeChatMessage): boolean { + if ( + previous.blocks.length !== next.blocks.length || + previous.blocks.some((block, at) => block !== next.blocks[at]) + ) { + return false + } + const fields = new Map(Object.entries(previous)) + const nextFields = Object.entries(next) + return ( + fields.size === nextFields.length && + nextFields.every(([name, value]) => name === 'blocks' || fields.get(name) === value) + ) +} + +/** The folded rows, keeping each unchanged row's last committed object: a raw message re-folds the + * whole window, which copies every tool run, so rows that drew nothing new would draw again. Only + * the last committed rows of the chat shown are kept. */ +export function useMobileNativeChatFoldReuse( + folded: NativeChatMessage[], + scope: string +): NativeChatMessage[] { + const committed = useRef<{ scope: string; byId: Map } | null>(null) + const rows = useMemo(() => { + const byId = committed.current?.scope === scope ? committed.current.byId : null + let kept = 0 + const out = folded.map((row) => { + const previous = byId?.get(row.id) + if (previous && previous !== row && unchanged(previous, row)) { + kept += 1 + return previous + } + return row + }) + return kept > 0 ? out : folded + }, [folded, scope]) + useLayoutEffect(() => { + committed.current = { scope, byId: new Map(rows.map((row) => [row.id, row])) } + }, [rows, scope]) + return rows +} diff --git a/mobile/src/session/mobile-native-chat-list-header.tsx b/mobile/src/session/mobile-native-chat-list-header.tsx index bb3e61c374f..30c9a0387a3 100644 --- a/mobile/src/session/mobile-native-chat-list-header.tsx +++ b/mobile/src/session/mobile-native-chat-list-header.tsx @@ -1,17 +1,32 @@ import type { ReactElement } from 'react' -import { ActivityIndicator, Pressable, Text } from 'react-native' +import { ActivityIndicator, Pressable, Text, View } from 'react-native' import { colors } from '../theme/mobile-theme' import { styles } from './mobile-native-chat-view-styles' +import type { OlderPagePhase } from './use-mobile-older-page-loader' -/** The "Load earlier messages" control, while older history remains. */ +const LOAD_EARLIER_FAILED = "Couldn't load earlier messages. Tap to try again." +const EARLIER_UNAVAILABLE = "Earlier messages can't be loaded." + +/** The "Load earlier messages" control, while older history remains. `measuring`: the page has loaded + * rows above it still to draw, so it shows loading until they are in. */ export function mobileNativeChatListHeader( hasMore: boolean | undefined, - loadingEarlier: boolean | undefined, - loadEarlier: () => void + olderPages: OlderPagePhase | undefined, + loadEarlier: () => void, + measuring = false ): ReactElement | null { - if (!hasMore) { + if (olderPages === 'unavailable' && !measuring) { + // No retry gets past it, so the control gives way to a line that says so. + return ( + + {EARLIER_UNAVAILABLE} + + ) + } + if (!hasMore && !measuring) { return null } + const loadingEarlier = measuring || olderPages === 'loading' return ( {loadingEarlier ? ( @@ -19,6 +34,9 @@ export function mobileNativeChatListHeader( ) : ( Load earlier messages )} + {olderPages === 'failed' ? ( + {LOAD_EARLIER_FAILED} + ) : null} ) } diff --git a/mobile/src/session/mobile-native-chat-page-anchoring.ts b/mobile/src/session/mobile-native-chat-page-anchoring.ts new file mode 100644 index 00000000000..5247ab7e01c --- /dev/null +++ b/mobile/src/session/mobile-native-chat-page-anchoring.ts @@ -0,0 +1,67 @@ +import { scrollWritesStopMomentum } from './mobile-native-chat-page-motion' + +type Styled = { + style?: { overflowAnchor: string } + scrollTop: number + getBoundingClientRect: () => unknown +} + +/** Who keeps a parked reader's place where the browser has scroll anchoring (not WebKit): the + * browser, which sees every change inside the rows on screen, except while the page owns the + * scroll: following (its end pin) or a place-hold's own correction. Two owners would each put the + * same move back. */ +export class MobileNativeChatPageAnchoring { + private holds = 0 + private readonly none = scrollWritesStopMomentum() + + constructor( + private readonly scroller: () => Styled | null | undefined, + private readonly following: () => boolean + ) {} + + /** The page takes the scroll for one correction, or gives it back. */ + hold(on: boolean): void { + this.holds = Math.max(0, this.holds + (on ? 1 : -1)) + this.sync() + } + + sync(): void { + const s = this.scroller() + const next = this.holds === 0 && !this.following() ? 'auto' : 'none' + if (this.none || !s?.style || s.style.overflowAnchor === next) { + return + } + // Laid out under the old owner first, and the new one in force before anything changes. + s.getBoundingClientRect() + s.style.overflowAnchor = next + s.getBoundingClientRect() + if (next === 'auto') { + // Not one kept from before the page's turn, whose move the browser would then put back too. + this.rechoose(s) + } + } + + /** After each commit. While the browser holds the place, rows new on screen can't be its anchor + * until laid out, so it kept the container around them, whose top never moves. Laid out now, a + * scroll makes it choose among them. */ + committed(): void { + this.sync() + const s = this.scroller() + if (this.none || this.holds > 0 || s?.style?.overflowAnchor !== 'auto') { + return + } + s.getBoundingClientRect() + this.rechoose(s) + } + + /** A scroll makes the browser choose its anchor afresh; at the very top it keeps none anyway. It + * ends where it started, so the scroll event after it reports only what else moved the scroll; + * taken as the page's own writes, a reader's scroll landing there would be too. */ + private rechoose(s: Styled): void { + const top = s.scrollTop + if (top >= 0.5) { + s.scrollTop = top - 1 + s.scrollTop = top + } + } +} diff --git a/mobile/src/session/mobile-native-chat-page-heights.test.ts b/mobile/src/session/mobile-native-chat-page-heights.test.ts new file mode 100644 index 00000000000..2b17925019b --- /dev/null +++ b/mobile/src/session/mobile-native-chat-page-heights.test.ts @@ -0,0 +1,40 @@ +import { expect, it } from 'vitest' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { MobileNativeChatPageHeights } from './mobile-native-chat-page-heights' +import type { MobileNativeChatTurnRow } from './use-mobile-native-chat-turn-disclosure' + +it('retires replaced source payloads without changing spacers or sweeping same-key content updates', () => { + const cache = new MobileNativeChatPageHeights() + const row = (id: string): NativeChatMessage => ({ + id, + role: 'user', + blocks: [], + source: 'transcript', + timestamp: 1 + }) + const keyOf = (m: NativeChatMessage) => m.id + const state: MobileNativeChatTurnRow = { + turnStatus: null, + turnExpanded: false, + activeTurnIsWorking: false, + trailingRun: false, + mayStillGrow: false, + reasoningExpanded: false, + onToggleReasoning: () => {}, + onToggleSubagentGroup: () => {}, + reasoningIsLive: false, + userMessageOpen: false, + onToggleUserMessage: () => {} + } + cache.remember('head', 80, row('head'), state, 'layout') + cache.remember('retired', 20, row('retired'), state, 'layout') + const before = ['head', 'retired', 'unknown'] + const source = cache.sourceKeys(['head', 'replacement', 'unknown'].map(row), keyOf, before) + expect(source.changed).toBe(true) + const slack = cache.px('retired')! + const geometry = cache.spacers(source.keys, [0, 0], [0, 1], slack) + cache.retain(source.keys) + expect(cache.px('retired')).toBeUndefined() + expect(cache.spacers(source.keys, [0, 0], [0, 1], slack)).toEqual(geometry) + expect(cache.sourceKeys(source.keys.map(row), keyOf, source.keys).changed).toBe(false) +}) diff --git a/mobile/src/session/mobile-native-chat-page-heights.ts b/mobile/src/session/mobile-native-chat-page-heights.ts new file mode 100644 index 00000000000..d1292c0741d --- /dev/null +++ b/mobile/src/session/mobile-native-chat-page-heights.ts @@ -0,0 +1,174 @@ +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import type { MobileNativeChatTurnRow } from './use-mobile-native-chat-turn-disclosure' + +const FIRST_ESTIMATE = 80 + +/** The drawn rows, as the measuring choice reads them. */ +type DrawnRows = { + keys: readonly string[] + rows: readonly NativeChatMessage[] + rowState: (index: number, row: NativeChatMessage) => MobileNativeChatTurnRow + start: number + /** The first row in the document: rows above it were never measured. */ + first: number + following: boolean +} + +type Entry = { px: number; row: NativeChatMessage; state: MobileNativeChatTurnRow; layout: string } + +function sameState(a: MobileNativeChatTurnRow, b: MobileNativeChatTurnRow): boolean { + const before = Object.entries(a) + const after = new Map(Object.entries(b)) + return before.length === after.size && before.every(([name, value]) => after.get(name) === value) +} + +/** Row heights as drawn. One holds while the row would draw the same: the same row object (unchanged + * rows keep theirs across a re-fold), the same row state and the same layout. A stale one keeps + * standing in for its row until it is measured again, so going stale never moves anything. */ +export class MobileNativeChatPageHeights { + private entries = new Map() + private sum = 0 + private samples = 0 + + px(key: string | undefined): number | undefined { + return key === undefined ? undefined : this.entries.get(key)?.px + } + + /** For rows never drawn, which only ever sit below the reader. */ + estimate(): number { + return this.samples > 0 ? this.sum / this.samples : FIRST_ESTIMATE + } + + valid( + key: string, + row: NativeChatMessage, + state: MobileNativeChatTurnRow, + layout: string + ): boolean { + const entry = this.entries.get(key) + return ( + entry !== undefined && + entry.row === row && + entry.layout === layout && + sameState(entry.state, state) + ) + } + + remember( + key: string, + px: number, + row: NativeChatMessage, + state: MobileNativeChatTurnRow, + layout: string + ): void { + if (!this.entries.has(key)) { + this.samples += 1 + } + this.sum += px - (this.entries.get(key)?.px ?? 0) + this.entries.set(key, { px, row, state, layout }) + } + + sourceKeys( + rows: readonly NativeChatMessage[], + keyOf: (row: NativeChatMessage) => string, + before: readonly string[] | undefined + ): { + keys: string[] + changed: boolean + indexOf: (key: string | undefined) => number | undefined + } { + let changed = before?.length !== rows.length + const keys = rows.map((row, index) => { + const key = keyOf(row) + changed ||= before?.[index] !== key + return key + }) + let index: Map | undefined + const indexOf = (key: string | undefined) => + key === undefined ? undefined : (index ??= new Map(keys.map((k, at) => [k, at]))).get(key) + return { keys, changed, indexOf } + } + + /** Retired payloads die after their heights supplied trim slack; estimation stays numeric. */ + retain(keys: readonly string[]): void { + const current = new Set(keys) + for (const key of this.entries.keys()) { + if (!current.has(key)) { + this.entries.delete(key) + } + } + } + + /** Spacer heights around the measuring rows `[m0, m1)` and the window `[start, end)`: above them + * only the measured run (from `first`, plus trim `slack`), between them, and below the window. */ + spacers( + keys: readonly string[], + [m0, m1]: [number, number], + [start, end]: [number, number], + slack: number + ): { first: number; above: number; mid: number; bottom: number } { + let [first, above, mid, bottom] = [m0, slack, 0, 0] + for (let px = this.px(keys[first - 1]); first > 0 && px !== undefined;) { + above += px + first -= 1 + px = this.px(keys[first - 1]) + } + for (let i = m1; i < start; i += 1) { + mid += this.px(keys[i]) ?? 0 + } + for (let i = end; i < keys.length; i += 1) { + bottom += this.px(keys[i]) ?? this.estimate() + } + return { first, above, mid, bottom } + } + + /** The run of rows nearest above `start` whose heights don't hold, `[from, to]`, at most `rows` + * long and within `reach` px of measured rows above the window; null when there is none. */ + nearestStale( + drawn: Pick, + layout: string, + reach: number, + rows: number + ): [number, number] | null { + const { keys, start } = drawn + const valid = (i: number): boolean => + this.valid(keys[i]!, drawn.rows[i]!, drawn.rowState(i, drawn.rows[i]!), layout) + let [to, distance] = [start - 1, 0] + for (; to >= 0 && distance <= reach && valid(to); to -= 1) { + distance += this.px(keys[to]) ?? 0 + } + if (to < 0 || distance > reach) { + return null + } + let from = to + while (from > 0 && to - from + 1 < rows && !valid(from - 1)) { + from -= 1 + } + return [from, to] + } + + /** The rows to measure next: stale ones within `near` px above the window; following, then the + * rows never drawn just above the measured run; then, with `full`, stale ones anywhere above. */ + next( + drawn: DrawnRows, + layout: string, + near: number, + rows: number, + full: boolean + ): [number, number] | null { + const close = this.nearestStale(drawn, layout, near, rows) + if (close || !drawn.following) { + return close + } + if (drawn.first > 0) { + return [Math.max(0, drawn.first - rows), drawn.first - 1] + } + return full ? this.nearestStale(drawn, layout, Infinity, rows) : null + } + + clear(): void { + this.entries.clear() + this.sum = 0 + this.samples = 0 + } +} diff --git a/mobile/src/session/mobile-native-chat-page-motion.ts b/mobile/src/session/mobile-native-chat-page-motion.ts new file mode 100644 index 00000000000..3c34a05ee61 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-page-motion.ts @@ -0,0 +1,295 @@ +/** No touch and no scroll of the reader's for this long: a write any sooner can stop iOS momentum. */ +const REST_MS = 150 +/** Input this recent makes a move the reader's where the browser also moves the scroll. */ +const INTENT_MS = 1_000 +const SCROLL_KEYS = new Set(['ArrowDown', 'ArrowUp', 'End', 'Home', 'PageDown', 'PageUp', ' ']) +type Moved = { by: 'own' | 'layout' | 'reader' | 'none'; moved: number } +export type MobileNativeChatPageObservation = Moved & { fromEnd: number } + +/** WebKit (every iOS browser, and Safari) stops a fling on any scroll write and has no scroll + * anchoring; other engines keep momentum, and their anchoring keeps a parked reader's place. */ +export function scrollWritesStopMomentum(): boolean { + const agent = globalThis.navigator?.userAgent ?? '' + return /AppleWebKit/.test(agent) && !/Chrome|Chromium|Android/.test(agent) +} + +/** Whether the page keeps a parked place itself: on WebKit, which has no scroll anchoring, and at the + * very top, where Chromium's anchoring never adjusts (rows measured above would push the reader). */ +export function pageKeepsPlace(scrollTop: number): boolean { + return scrollWritesStopMomentum() || scrollTop < 0.5 +} + +type Listener = (event: unknown) => void +type Listened = { + addEventListener: (type: string, listener: Listener, options: { passive: true }) => void + removeEventListener: (type: string, listener: Listener) => void +} + +/** Which way a scroll key the page scrolls by moves it (1 down, -1 up); 0 for any other key, or one + * typed into the composer. */ +function keyWay(event: unknown): number { + if (typeof KeyboardEvent === 'undefined' || !(event instanceof KeyboardEvent)) { + return 0 + } + const { key, shiftKey, target } = event + const typing = + typeof HTMLElement !== 'undefined' && + target instanceof HTMLElement && + (/^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName) || target.isContentEditable) + if (typing || !SCROLL_KEYS.has(key)) { + return 0 + } + return /Up|Home/.test(key) || (key === ' ' && shiftKey) ? -1 : 1 +} + +function wheelWay(event: unknown): number { + const deltaY = typeof event === 'object' && event !== null && 'deltaY' in event && event.deltaY + return typeof deltaY === 'number' ? Math.sign(deltaY) : 0 +} + +function touchY(event: unknown): number | undefined { + const touches = typeof event === 'object' && event !== null && 'touches' in event && event.touches + const first = touches && typeof touches === 'object' && 0 in touches ? touches[0] : undefined + const y = typeof first === 'object' && first !== null && 'clientY' in first && first.clientY + return typeof y === 'number' ? y : undefined +} + +/** Whether the reader is moving the page, told apart from the page's own writes. Work that can move + * rows above the reader (a landing, a measured row, a correction) waits until it settles. */ +export class MobileNativeChatPageMotion { + private touching = false + private pointing = false + private lastInput = 0 + /** Which way the reader's last input moves the page: 1 down, -1 up, 0 unknown (the scrollbar). */ + private way = 0 + private lastTouchY: number | undefined + private lastReaderScroll = 0 + /** The browser moves the scroll too (its anchoring), so only input makes a move the reader's. */ + private readonly browserMoves = !scrollWritesStopMomentum() + /** Where the page last accounted for the scroll: its write, an observed scroll, a resize. */ + private seen = 0 + private seenMax = Infinity + /** A commit changed what is drawn and its resize callback hasn't run: a move now is layout's. */ + private unreconciled = false + private drawn = '' + private layouts = 0 + /** Where the page's own writes put the scroller, until their scroll event arrives. */ + private own: number[] = [] + private timer: ReturnType | undefined + private later: ReturnType | undefined + /** Owed work doesn't run again before this, so other work gets frames between its rounds. */ + private quietUntil = 0 + /** Classified before the follower sees geometry, including layout and the page's writes. */ + onMotion: ((observation: MobileNativeChatPageObservation) => void) | null = null + private waiters: (() => void)[] = [] + + constructor( + private readonly onSettled: () => void, + initialTop = 0 + ) { + this.seen = initialTop + } + + /** Follows the reader's hand on the scroller (touch, wheel, scroll keys, the scrollbar); returns + * the detach. */ + attach(scroller: Listened, acceptsKey: (event: unknown) => boolean = () => true): () => void { + const input = (way: number) => { + ;[this.lastInput, this.way] = [Date.now(), way] + } + const key = (event: unknown) => { + const way = keyWay(event) + if (way !== 0 && acceptsKey(event)) { + input(way) + } + } + const move = (event: unknown) => { + const [from, y] = [this.lastTouchY, touchY(event)] + this.lastTouchY = y + // A finger moving up scrolls the page down. + input(from !== undefined && y !== undefined && y !== from ? Math.sign(from - y) : this.way) + } + const start = (event: unknown) => { + this.lastTouchY = touchY(event) + input(0) + this.touch(true) + } + // The scrollbar, which is the scroller's own; a press on a row inside is no scroll. + const point = (event: unknown) => { + if ( + typeof event === 'object' && + event !== null && + 'target' in event && + event.target === scroller + ) { + this.pointing = true + this.way = 0 + } + } + // Released, or moving with no button down: a pointerup lost after a scrollbar drag can't stick. + const up = (event: unknown) => { + if (!(typeof event === 'object' && event !== null && 'buttons' in event && event.buttons)) { + this.pointing = false + } + } + const listeners: [Listened, string, Listener][] = [ + [scroller, 'touchstart', start], + [scroller, 'touchend', () => this.touch(false)], + [scroller, 'touchcancel', () => this.touch(false)], + [scroller, 'touchmove', move], + [scroller, 'wheel', (event) => input(wheelWay(event))], + [scroller, 'pointerdown', point] + ] + // A scrollbar drag can end, and a scroll key arrive, outside the scroller. + if ('addEventListener' in globalThis) { + listeners.push([globalThis, 'pointerup', up], [globalThis, 'pointercancel', up]) + listeners.push([globalThis, 'pointermove', up], [globalThis, 'keydown', key]) + } + listeners.forEach(([target, type, fn]) => target.addEventListener(type, fn, { passive: true })) + return () => listeners.forEach(([target, type, fn]) => target.removeEventListener(type, fn)) + } + + touch(down: boolean): void { + this.touching = down + this.lastInput = Date.now() + if (down) { + this.own = [] + clearTimeout(this.timer) + this.timer = undefined + } else { + this.poke() + } + } + + /** A commit drew `drawn`. When that changed, a move is layout's until its resize callback (or the + * next frame, when nothing resized); scroll events run before that frame's resize callbacks. */ + laidOut(drawn: string): void { + if (drawn === this.drawn) { + return + } + this.drawn = drawn + this.unreconciled = true + const token = ++this.layouts + globalThis.requestAnimationFrame?.(() => { + if (token === this.layouts) { + this.unreconciled = false + } + }) + } + + /** The resize callback reconciled layout: what moved the scroll since the page last accounted + * for it is now told apart (a scroll event may not have reported it yet). */ + reconciled(scrollTop: number, max: number): Moved { + this.unreconciled = false + return this.account(scrollTop, max) + } + + /** What moved the scroll to `scrollTop` since the page last accounted for it, and by how much. The + * page's write; layout while a commit is unreconciled or rows above the reading place moved + * (`placeMoved`), left for the resize callback to tell apart; else as `account`. */ + scrolled(scrollTop: number, max: number, placeMoved: () => boolean): Moved { + const moved = scrollTop - this.seen + const at = this.own.findIndex((target) => Math.abs(target - scrollTop) <= 1) + if (at !== -1) { + this.own.splice(0, at + 1) + ;[this.seen, this.seenMax] = [scrollTop, max] + return this.observed('own', moved, max - scrollTop) + } + if (Math.abs(moved) >= 0.5 && !this.touching && (this.unreconciled || placeMoved())) { + return this.observed('layout', moved, max - scrollTop) + } + return this.account(scrollTop, max) + } + + /** A clamp onto a shorter end is layout's; any other move is the reader's (a finger makes it so), + * except where the browser moves the scroll too: there only input (or the fling it started) does. */ + private account(scrollTop: number, max: number): Moved { + const [from, fromMax, moved] = [this.seen, this.seenMax, scrollTop - this.seen] + ;[this.seen, this.seenMax] = [scrollTop, max] + if (Math.abs(moved) < 0.5) { + return this.observed('none', moved, max - scrollTop) + } + // Pulled back onto a shorter end from where the old one allowed; iOS bouncing past an end is not. + const clamped = Math.abs(scrollTop - max) < 0.5 && from > max + 0.5 && from <= fromMax + 0.5 + if ((clamped && !this.touching) || (this.browserMoves && !this.intended(moved))) { + return this.observed('layout', moved, max - scrollTop) + } + this.lastReaderScroll = Date.now() + this.poke() + return this.observed('reader', moved, max - scrollTop) + } + + private observed(by: Moved['by'], moved: number, fromEnd: number): Moved { + this.onMotion?.({ by, moved, fromEnd }) + return { by, moved } + } + + /** Input is recent (a fling counts from the finger's release) and the move goes its way: the + * browser's anchoring moving against it is the browser's, so it can't detach the reader. */ + private intended(moved: number): boolean { + const recent = this.touching || this.pointing || Date.now() - this.lastInput <= INTENT_MS + return recent && (this.way === 0 || Math.sign(moved) === this.way) + } + + wrote(target: number): void { + this.own = [...this.own.slice(-7), target] + this.seen = target + } + + settled(): boolean { + return !this.touching && Date.now() - this.lastReaderScroll >= REST_MS + } + + /** A place correction may be written now: at once where a write keeps momentum (as the common + * list does), else only at rest. */ + readonly mayCorrect = scrollWritesStopMomentum() ? () => this.settled() : () => true + + /** Resolves once settled, or when the window goes away (`release`), so a wait never strands. */ + whenSettled(): Promise { + if (this.settled()) { + return Promise.resolve() + } + return new Promise((resolve) => { + this.waiters.push(resolve) + this.poke() + }) + } + + poke(): void { + if (this.touching || this.timer !== undefined) { + return + } + const now = Date.now() + const wait = Math.max(0, REST_MS - (now - this.lastReaderScroll), this.quietUntil - now) + this.timer = setTimeout(() => { + this.timer = undefined + if (!this.settled() || Date.now() < this.quietUntil) { + this.poke() + return + } + this.waiters.splice(0).forEach((resolve) => resolve()) + this.onSettled() + }, wait) + } + + /** Owed work waits `ms` before its next round. */ + quiet(ms: number): void { + this.quietUntil = Date.now() + ms + } + + /** Checks for owed work again after `ms`, when nothing else will. */ + pokeIn(ms: number): void { + clearTimeout(this.later) + this.later = setTimeout(() => this.poke(), ms) + } + + release(): void { + this.touching = false + this.pointing = false + this.lastReaderScroll = 0 + this.own = [] + clearTimeout(this.later) + clearTimeout(this.timer) + this.timer = undefined + this.waiters.splice(0).forEach((resolve) => resolve()) + } +} diff --git a/mobile/src/session/mobile-native-chat-page-rows.ts b/mobile/src/session/mobile-native-chat-page-rows.ts new file mode 100644 index 00000000000..98bbca6bb29 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-page-rows.ts @@ -0,0 +1,118 @@ +type Scroller = { getBoundingClientRect: () => { top: number }; scrollTop: number } +import { mobileNativeChatScrollFrame } from './mobile-native-chat-scroll-roots' + +function isElement(node: unknown): node is Element { + return typeof Element !== 'undefined' && node instanceof Element +} + +/** The part of `row` at the screen's top line `view`: the first part starting on screen, or the + * innermost one crossing the line, as the browser's own scroll anchoring would pick. Growth above + * it inside the row then moves it, which the row's own top doesn't show. */ +function partAtTop(row: Element, view: number): Element { + let part = row + for (let child = row.firstElementChild; child;) { + const box = child.getBoundingClientRect() + if (box.bottom <= view || box.height === 0) { + child = child.nextElementSibling + } else if (mobileNativeChatScrollFrame(child, row) || box.top >= view) { + return child + } else { + part = child + child = child.firstElementChild + } + } + return part +} + +/** The page's drawn row nodes by key, each in the one observer while drawn, and the row the reader + * is reading: the first drawn row on screen and its offset from the scroller's top. */ +export class MobileNativeChatPageRows { + private nodes = new Map() + private keys = new WeakMap() + private refs = new Map void>() + private observer: ResizeObserver | null = null + /** The row and the part of it at the screen's top; `top` from the scroller's top, with the + * `scrollTop` it was read at. */ + private anchor: { key: string; part: Element; top: number; scrollTop: number } | null = null + + keyOf(node: Element): string | undefined { + return this.keys.get(node) + } + + node(key: string | undefined): Element | undefined { + return key === undefined ? undefined : this.nodes.get(key) + } + + readonly observeWith = (observer: ResizeObserver | null): void => { + this.observer = observer + this.nodes.forEach((node) => observer?.observe(node)) + } + + /** One ref per key for as long as the row is drawn, so a streamed commit never re-observes it. */ + readonly ref = (key: string): ((node: unknown) => void) => { + let ref = this.refs.get(key) + if (!ref) { + ref = (node: unknown) => { + const was = this.nodes.get(key) + if (was) { + this.observer?.unobserve(was) + this.nodes.delete(key) + } + if (isElement(node)) { + this.nodes.set(key, node) + this.keys.set(node, key) + this.observer?.observe(node) + } else { + this.refs.delete(key) + } + } + this.refs.set(key, ref) + } + return ref + } + + /** `deep`: anchor the part of the row at the screen's top, not the row's own top. */ + takeAnchor( + scroller: Scroller, + keys: readonly string[], + [start, end]: [number, number], + deep: boolean + ): void { + const view = scroller.getBoundingClientRect().top + let [low, high] = [start, end] + while (low < high) { + const middle = low + Math.floor((high - low) / 2) + const node = this.node(keys[middle]) + if (!node || node.getBoundingClientRect().bottom <= view) { + low = middle + 1 + } else { + high = middle + } + } + const key = keys[low] + const node = this.node(key) + const part = node && (deep ? partAtTop(node, view) : node) + const top = part && part.getBoundingClientRect().top - view + this.anchor = + key && part && top !== undefined ? { key, part, top, scrollTop: scroller.scrollTop } : null + } + + /** How far the anchored part moved in the content since it was taken (what is above it changed + * size), apart from any scrolling; null when it isn't drawn. */ + placeShift(scroller: Scroller): number | null { + const part = this.anchor?.part + if (!this.anchor || !part?.isConnected || !this.node(this.anchor.key)) { + return null + } + const now = part.getBoundingClientRect().top - scroller.getBoundingClientRect().top + return now + scroller.scrollTop - this.anchor.top - this.anchor.scrollTop + } + + placeMoved(scroller: Scroller): boolean { + return Math.abs(this.placeShift(scroller) ?? 0) >= 0.5 + } + + forgetAnchor(): void { + this.anchor = null + } +} diff --git a/mobile/src/session/mobile-native-chat-page-window-coverage.ts b/mobile/src/session/mobile-native-chat-page-window-coverage.ts new file mode 100644 index 00000000000..9f38a69c152 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-page-window-coverage.ts @@ -0,0 +1,66 @@ +/** The drawn rows `[start, end)` of `n`, the first drawn row's top in content coordinates, and the + * scroller's viewport. */ +export type MobileNativeChatWindowGeometry = { + n: number + start: number + end: number + following: boolean + windowTop: number + scrollTop: number + viewport: number +} + +/** Rows within one screen of the viewport are drawn, rows past two are not; null when nothing + * changes. Rows above the window come only from the spacer, which holds measured heights, so drawing + * or dropping them never moves what the reader sees. `known` is a row's measured height. */ +export function coverMobileNativeChatWindow( + g: MobileNativeChatWindowGeometry, + known: (index: number) => number | undefined, + estimate: number +): { start: number; end: number } | null { + const { n, following, scrollTop, viewport } = g + const height = (at: number): number => known(at) ?? estimate + const [near, far] = [scrollTop - viewport, scrollTop + 2 * viewport] + let [start, end, top] = [g.start, g.end, g.windowTop] + let bottom = top + for (let at = start; at < end; at += 1) { + bottom += height(at) + } + if (!following && (top > scrollTop + viewport || bottom < scrollTop)) { + // Nothing drawn is on screen, so nothing needs to stay still: draw where the reader is. + if (top > scrollTop) { + while (start > 0 && known(start - 1) !== undefined && top > scrollTop) { + start -= 1 + top -= known(start)! + } + } else { + for (top = bottom, start = end; start < n - 1 && top + height(start) <= scrollTop;) { + top += height(start) + start += 1 + } + } + start = Math.min(start, n - 1) + for (end = start, bottom = top; end < n && bottom < far; end += 1) { + bottom += height(end) + } + } else { + while (start > 0 && top > near && known(start - 1) !== undefined) { + start -= 1 + top -= known(start)! + } + while (start < end - 1 && top + height(start) < scrollTop - 2 * viewport) { + top += height(start) + start += 1 + } + while (end < n && bottom < far) { + bottom += height(end) + end += 1 + } + while (!following && end - 1 > start && bottom - height(end - 1) > scrollTop + 3 * viewport) { + end -= 1 + bottom -= height(end) + } + } + end = following ? n : Math.max(end, start + 1) + return start === g.start && end === g.end ? null : { start, end } +} diff --git a/mobile/src/session/mobile-native-chat-page-window-state.ts b/mobile/src/session/mobile-native-chat-page-window-state.ts new file mode 100644 index 00000000000..95786058b18 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-page-window-state.ts @@ -0,0 +1,66 @@ +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import type { MobileNativeChatTurnRow } from './use-mobile-native-chat-turn-disclosure' +import type { MobileNativeChatRowKeys } from './mobile-native-chat-row-keys' +import type { MobileNativeChatScrollRoots } from './mobile-native-chat-scroll-roots' + +type Box = { top: number; bottom: number } +type Measured = { getBoundingClientRect: () => Box } + +export type MobileNativeChatWindowScroller = Measured & { + scrollTop: number + scrollHeight: number + clientHeight: number + clientWidth: number + style?: { overflowAnchor: string } + addEventListener: ( + type: string, + listener: (event: unknown) => void, + options: { passive: true } + ) => void + removeEventListener: (type: string, listener: (event: unknown) => void) => void +} + +export type MobileNativeChatWindowRowState = ( + index: number, + row: NativeChatMessage +) => MobileNativeChatTurnRow +export type MobileNativeChatWindowGroup = { from: string; fromAt: number; to: string; toAt: number } + +/** Where the window stands, by key, so landings and trims never change which rows are drawn. */ +export type MobileNativeChatWindowSeat = { + scope: string + first: string + firstAt: number + last: string | null + lastAt: number + /** Measured rows trimmed from above a parked reader: their height stays until settled. */ + slack: number + /** Rows drawn in place above the window only so they get measured. */ + measure: MobileNativeChatWindowGroup | null +} + +export type MobileNativeChatWindowDrawn = { + keys: readonly string[] + rows: readonly NativeChatMessage[] + indexOf: (key: string | undefined) => number | undefined + rowState: MobileNativeChatWindowRowState + start: number + end: number + /** The first row in the document: rows above it were never measured. */ + first: number + sourceHead: string | undefined + following: boolean + layoutKey: string +} + +export type MobileNativeChatPageWindowArgs = { + roots?: MobileNativeChatScrollRoots + rows: readonly NativeChatMessage[] + rowKeys: Pick + rowState: MobileNativeChatWindowRowState + following: boolean + /** At event time, before a re-render lands. */ + isFollowing: () => boolean + layoutKey: string + scroller: () => MobileNativeChatWindowScroller | null | undefined +} diff --git a/mobile/src/session/mobile-native-chat-reading-place.test.tsx b/mobile/src/session/mobile-native-chat-reading-place.test.tsx new file mode 100644 index 00000000000..26a37bb12a5 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-reading-place.test.tsx @@ -0,0 +1,395 @@ +import { createElement as h, Fragment, useMemo, type ReactElement, type ReactNode } from 'react' +import { + act, + create, + type ReactTestRenderer, + type ReactTestRendererJSON as Node +} from 'react-test-renderer' +import { expect, it, vi } from 'vitest' +import { splitNativeChatBlocks } from '../../../src/shared/native-chat-tool-fold' +import type { NativeChatBlock, NativeChatMessage } from '../../../src/shared/native-chat-types' +import { foldMobileNativeChatMessages } from './mobile-native-chat-render-data' +import { + useMobileNativeChatRowKeys, + type MobileNativeChatRowKeys +} from './mobile-native-chat-row-keys' +import { MobileNativeChatScrollHold, mobileNativeChatRowId } from './MobileNativeChatScrollHold' +import { ToolRun } from './MobileNativeChatToolRun' + +const platform = vi.hoisted(() => ({ OS: 'web' })) +// The page's own hold, as on iOS: where the browser has scroll anchoring it keeps these places +// itself, and the hold runs only for a regroup or a move to another row. +vi.stubGlobal('navigator', { + userAgent: + 'Mozilla/5.0 (iPhone) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/604.1' +}) + +vi.mock('react-native', async () => { + const React = await import('react') + // A class, so an opened run's box can scroll it the way it scrolls a real ScrollView. + class ScrollView extends React.Component<{ children?: ReactNode }> { + scrollToEnd = (): void => {} + scrollTo = (): void => {} + render(): ReactNode { + return React.createElement('div', null, this.props.children) + } + } + const settled = () => ({ start: (): void => {}, stop: (): void => {} }) + return { + Platform: platform, + Text: 'span', + View: 'div', + Pressable: 'button', + ScrollView, + Animated: { + Text: 'span', + Value: class { + setValue(): void {} + }, + loop: (animation: unknown) => animation, + sequence: settled, + timing: settled + }, + StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 }, + useWindowDimensions: () => ({ width: 400, height: 800 }) + } +}) +vi.mock('lucide-react-native', () => { + const icon = () => null + const names = ['Bot', 'Brain', 'Check', 'ChevronRight', 'Circle', 'CircleCheck', 'CircleDot'] + const more = ['Eye', 'FileMinus2', 'FilePen', 'FilePlus2', 'Folder', 'Globe', 'ListChecks'] + const rest = ['MessageSquareMore', 'Pencil', 'Plug', 'Search', 'SquareTerminal', 'Wrench'] + return Object.fromEntries([...names, ...more, ...rest].map((name) => [name, icon])) +}) +vi.mock('./mobile-native-chat-tool-styles', () => ({ + toolStyles: {}, + toolType: () => ({ row: {}, meta: {}, code: {} }) +})) +vi.mock('../components/MobileSourceCopyButton', () => ({ MobileSourceCopyButton: () => null })) +vi.mock('../platform/external-link', () => ({ openExternalLink: vi.fn() })) +vi.mock('../components/inline-text-selection', () => ({ INLINE_TEXT_SELECTION: false })) + +const say = (id: string, role: 'user' | 'assistant', text: string): NativeChatMessage => ({ + id, + role, + timestamp: null, + source: 'transcript', + blocks: [{ type: 'text', text }] +}) +const edit = (n: number): NativeChatBlock => { + const lines = (word: string): string => Array.from({ length: 30 }, () => word).join('\n') + const input = { file_path: `f${n}.ts`, old_string: lines('old'), new_string: lines('new') } + return { type: 'tool-call', name: 'Edit', callId: `c${n}`, input } +} +const run = (n: number): NativeChatMessage[] => [ + { ...say(`a${n}`, 'assistant', ''), blocks: [edit(n)] }, + { ...say(`r${n}`, 'user', ''), blocks: [{ type: 'tool-result', callId: `c${n}`, output: 'ok' }] } +] +// An earlier turn, then a reply working silently through 40 edits. +const chat = [say('u0', 'user', 'first'), say('x0', 'assistant', 'answer'), say('u1', 'user', 'go')] + .concat({ + ...say('a1', 'assistant', 'On it'), + blocks: [{ type: 'text', text: 'On it' }, edit(1)] + }) + .concat(run(1).slice(1), ...Array.from({ length: 39 }, (_, i) => run(i + 2))) + +type Box = { id: string; top: number; bottom: number; end: number } +type Element = { + getAttribute: () => string + getBoundingClientRect: () => { top: number; bottom: number } + querySelectorAll: (selector: string) => Element[] +} + +const oldHostTail = (): NativeChatMessage[] => JSON.parse(JSON.stringify(chat)) +const from = (id: string) => oldHostTail().slice(chat.findIndex((m) => m.id === id)) + +/** The page with one 20-tall line per text, as the list's scrollable node counting its writes. */ +function page(tree: () => Node | Node[] | null) { + const boxes = (): Box[] => { + const out: Box[] = [] + let y = 0 + const walk = (node: Node | string): void => { + if (typeof node === 'string' || node.type === 'span') { + y += typeof node === 'string' ? 0 : 20 + return + } + const at = out.push({ id: String(node.props.testID ?? ''), top: y, bottom: y, end: 0 }) - 1 + node.children?.forEach(walk) + out[at] = { ...out[at]!, bottom: y, end: out.length } + } + walk({ type: 'page', props: {}, children: [tree() ?? []].flat() }) + return out + } + const select = (all: Box[], from: number, to: number, selector: string): Element[] => { + const prefix = /\^="([^"]+)"/.exec(selector)?.[1] ?? '?' + return all.flatMap((box, at) => + at >= from && at < to && box.id.startsWith(prefix) + ? [ + { + getAttribute: () => box.id, + getBoundingClientRect: () => ({ + top: box.top - node.scrollTop, + bottom: box.bottom - node.scrollTop + }), + querySelectorAll: (inner: string) => select(all, at + 1, box.end, inner) + } + ] + : [] + ) + } + let scrollTop = 0 + const node = { + writes: 0, + get scrollTop(): number { + return scrollTop + }, + set scrollTop(value: number) { + node.writes += 1 + scrollTop = value + }, + clientHeight: 200, + get scrollHeight(): number { + return boxes()[0]?.bottom ?? 0 + }, + getAttribute: () => null, + getBoundingClientRect: () => ({ top: 0, bottom: 200 }), + querySelectorAll: (selector: string): Element[] => select(boxes(), 0, Infinity, selector), + top: (id: string): number => + (boxes().find((box) => box.id === id)?.top ?? Number.NaN) - node.scrollTop + } + return node +} + +function Row({ message, rowKey }: { message: NativeChatMessage; rowKey: string }): ReactElement { + const { prose, tools } = splitNativeChatBlocks(message.blocks) + const words = prose.map((block, i) => + h('span', { key: i }, block.type === 'text' ? block.text : '') + ) + const run = + tools.length > 0 && h(ToolRun, { blocks: tools, defaultExpanded: false, expandChildren: false }) + return h('div', { testID: mobileNativeChatRowId(rowKey) }, words, run) +} + +type Scroller = ReturnType + +let shownKeys!: MobileNativeChatRowKeys + +function Chat(props: { + messages: NativeChatMessage[] + following: boolean + live: boolean + listRef: { current: { getScrollableNode: () => Scroller } } +}): ReactElement { + const folded = useMemo( + () => foldMobileNativeChatMessages(props.messages, 'claude'), + [props.messages] + ) + const keys = useMobileNativeChatRowKeys(folded, props.messages, 'chat') + shownKeys = keys + return h( + Fragment, + null, + h(MobileNativeChatScrollHold, { + rowKeys: keys, + isFollowing: () => props.following, + listRef: props.listRef + }), + h('div', { testID: 'header' }, h('span', null, 'Load earlier')), + folded.map((item) => + h(Row, { key: keys.keyOf(item), message: item, rowKey: keys.keyOf(item) }) + ), + props.live ? h('span', null, 'Working') : null + ) +} + +function mount(messages: NativeChatMessage[], options = { following: false, live: false }) { + let renderer!: ReactTestRenderer + act(() => { + renderer = create(h('div')) + }) + const scroller = page(() => renderer.toJSON()) + const listRef = { current: { getScrollableNode: () => scroller } } + const show = (next: NativeChatMessage[]): number => { + const before = scroller.writes + act(() => renderer.update(h(Chat, { ...options, messages: next, listRef }))) + return scroller.writes - before + } + show(messages) + const find = (id: string) => renderer.root.find((n) => n.props.testID === id) + const press = (id: string) => act(() => find(id).findAllByType('button')[0]!.props.onPress()) + const drawn = (id: string) => renderer.root.findAll((n) => n.props.testID === id).length + const lines = (id: string) => find(id).findAllByType('span').length + const rows = () => + renderer.root + .findAll((n) => n.type === 'div' && String(n.props.testID).startsWith('chat-row:')) + .map((n) => n.props.testID) + return { scroller, show, press, drawn, lines, find, rows } +} + +it('keeps the line a reader was on, its run open, when older messages join the run from an old host', () => { + // On a line inside the run, or at the top beside Load earlier while the window opens on a result + // that the run's older calls take in. + for (const atTop of [false, true]) { + const chat0 = mount(from('r31')) + chat0.press(mobileNativeChatRowId('a32')) + chat0.press('chat-member:call:c34') + const opened = chat0.lines('chat-member:call:c34') + chat0.scroller.scrollTop += chat0.scroller.top('chat-member:call:c34') + // An old host ignores the cursor and sends the whole tail again: new objects, the same ids. + for (const [older, first] of [ + ['r21', 'c32'], + ['u0', 'c22'] + ] as const) { + if (atTop) { + chat0.scroller.scrollTop = 0 + } + const line = `chat-member:call:${atTop ? first : 'c34'}` + const top = chat0.scroller.top(line) + expect(chat0.show(from(older))).toBeLessThanOrEqual(1) + expect(chat0.scroller.top(line)).toBe(top) + } + + // The run keeps its row, so the earlier turn sits above it and the run stays open. + expect(chat0.rows().slice(0, 3)).toEqual(['u0', 'x0', 'u1'].map(mobileNativeChatRowId)) + expect(chat0.lines('chat-member:call:c34')).toBe(opened) + expect(chat0.drawn('chat-member:call:c1')).toBe(1) + // The result row the window opened on is now drawn by the run. + expect(shownKeys.current('r21')).toBe('a32') + + // A reply and a send land after the run: what the reader had loaded stays drawn. + chat0.show([...oldHostTail(), say('done', 'assistant', 'Done'), say('ask', 'user', 'next')]) + expect(chat0.drawn(mobileNativeChatRowId('a32'))).toBe(1) + expect(chat0.drawn('chat-member:call:c34')).toBe(1) + } + + // The rows after the run stay put: collapsed, its header holds where it was, not in the result + // row's place; open, the line on screen holds while older calls join the run above it. + const later = Array.from({ length: 10 }, (_, i) => say(`t${i}`, 'user', 'later')) + for (const open of [false, true]) { + const tail = mount([...from('r21'), ...later]) + if (open) { + tail.press(mobileNativeChatRowId('a22')) + tail.scroller.scrollTop += tail.scroller.top('chat-member:call:c27') + 40 + } + const before = tail.scroller.top(mobileNativeChatRowId('t0')) + tail.show([...oldHostTail(), ...later]) + expect(tail.scroller.top(mobileNativeChatRowId('t0'))).toBe(before) + } +}) + +it('leaves the view to iOS while it bounces the list past the top, and to a native list', () => { + for (const [os, scrollTop] of [ + ['web', -15], + ['ios', 40] + ] as const) { + platform.OS = os + const chat0 = mount(from('r21')) + chat0.press(mobileNativeChatRowId('a22')) + chat0.scroller.scrollTop = scrollTop + expect(chat0.show(oldHostTail())).toBe(0) + } + platform.OS = 'web' +}) + +it('at the cap, a trim and an append of equal height hold a parked reader and leave a following one to follow', () => { + const rows = Array.from({ length: 13 }, (_, i) => + say(`m${i}`, i % 2 ? 'assistant' : 'user', `message ${i}`) + ) + for (const options of [ + { following: true, live: false }, + { following: true, live: true }, + { following: false, live: false } + ]) { + const chat0 = mount(rows.slice(0, 12), options) + chat0.scroller.scrollTop = options.following ? chat0.scroller.scrollHeight - 200 : 50 + const before = { + scrollTop: chat0.scroller.scrollTop, + m4: chat0.scroller.top(mobileNativeChatRowId('m4')) + } + + const writes = chat0.show(rows.slice(1)) + + expect(writes).toBe(options.following ? 0 : 1) + if (options.following) { + expect(chat0.scroller.scrollTop).toBe(before.scrollTop) + } else { + expect(chat0.scroller.top(mobileNativeChatRowId('m4'))).toBe(before.m4) + } + } + + // Trims split the top run: sequential calls leave a result above it; parallel calls (each call, + // then each output, or all outputs in one message) leave results below it. Results never take + // the run's key. + const output = (n: number): NativeChatMessage => ({ + ...say(`o${n}`, 'user', ''), + role: 'tool', + blocks: [{ type: 'tool-result', callId: `c${n}`, output: 'ok' }] + }) + const six = [1, 2, 3, 4, 5, 6] + const calls = (id: string) => + six.map((n) => ({ ...say(`${id}${n}`, 'assistant', ''), blocks: [edit(n)] })) + const outputs = { ...output(1), id: 'U', blocks: six.flatMap((n) => output(n).blocks) } + for (const [source, head, orphan, last, absorbed] of [ + [chat.slice(3, 15), 'a1', 'r1', 11, []], + [calls('p').concat(six.map(output)), 'p1', 'o1', 6, ['o1', 'o2', 'o3', 'o4']], + [calls('q').concat(outputs), 'q1', 'U', 6, ['U']] + ] as const) { + const top = mount([...source, ...rows]) + top.press(mobileNativeChatRowId(head)) + top.scroller.scrollTop = top.scroller.top('chat-member:call:c6') + top.scroller.scrollTop - 10 + const trim = (trimmed: number) => { + top.show([...source.slice(trimmed), ...rows, say(`done${trimmed}`, 'assistant', 'Done')]) + const run = top.find(mobileNativeChatRowId(head)) + expect(run.findAll((n) => n.props.testID === 'chat-member:call:c6')).toHaveLength(1) + expect(top.scroller.top('chat-member:call:c6')).toBe(10) + } + trim(1) + expect(top.drawn(mobileNativeChatRowId(orphan))).toBe(1) + // A second single trim, then two at once. + trim(2) + trim(4) + // Paging back folds every result that was split off into the run again. + top.show([...source, ...rows, say('done4', 'assistant', 'Done')]) + expect(absorbed.map(shownKeys.current)).toEqual(absorbed.map(() => head)) + // Once its last call leaves, the run is gone; its outputs don't take its key. + top.show([...source.slice(last), ...rows, say('done', 'assistant', 'Done')]) + expect(top.drawn(mobileNativeChatRowId(head))).toBe(0) + } + + // A trim takes the very line the reader is on: the next one keeps its place. + const reader = mount([...chat.slice(3, 15), ...rows]) + reader.press(mobileNativeChatRowId('a1')) + reader.scroller.scrollTop += reader.scroller.top('chat-member:call:c1') + const next = reader.scroller.top('chat-member:call:c2') + expect(next).toBeGreaterThan(0) + reader.show([...chat.slice(4, 15), ...rows, say('done', 'assistant', 'Done')]) + expect(reader.scroller.top('chat-member:call:c2')).toBe(next) +}) + +it('a roster keeps its key when the fold moves it, and the rows the reader saw after it stay put', () => { + const sibling = (id: string): NativeChatMessage => ({ + ...say(id, 'user', ''), + role: 'system', + blocks: [ + { type: 'text', text: `Ran subagent ${id}` }, + { type: 'subagent', id: `agent-${id}`, label: id, state: 'completed', groupId: 'turn-1' } + ] + }) + const said = (name: string) => + Array.from({ length: 12 }, (_, i) => say(`${name}${i}`, i % 2 ? 'assistant' : 'user', name)) + const all = [...said('old'), sibling('s1'), ...said('mid'), sibling('s2'), ...said('new')] + const chat0 = mount(all.slice(25)) + const tops = (ids: string[]) => ids.map((id) => chat0.scroller.top(mobileNativeChatRowId(id))) + // The roster first on screen: an older sibling joining moves it up to that sibling's place. + chat0.scroller.scrollTop = 20 + const below = tops(['new0', 'new5']) + expect(chat0.show(all)).toBeLessThanOrEqual(1) + expect(tops(['new0', 'new5'])).toEqual(below) + expect(chat0.drawn(mobileNativeChatRowId('s2'))).toBe(1) + // And back: a trim of the first sibling moves the roster down to the second's place. + chat0.scroller.scrollTop += chat0.scroller.top(mobileNativeChatRowId('s2')) + const after = tops(['mid0', 'mid5']) + expect(chat0.show(all.slice(13))).toBeLessThanOrEqual(1) + expect(tops(['mid0', 'mid5'])).toEqual(after) + expect(chat0.drawn(mobileNativeChatRowId('s2'))).toBe(1) +}) diff --git a/mobile/src/session/mobile-native-chat-row-keys.ts b/mobile/src/session/mobile-native-chat-row-keys.ts new file mode 100644 index 00000000000..80836c1859f --- /dev/null +++ b/mobile/src/session/mobile-native-chat-row-keys.ts @@ -0,0 +1,253 @@ +import { useLayoutEffect, useMemo, useRef } from 'react' +import { + mobileNativeChatRunDescriptors, + type MobileNativeChatRunPresentation +} from './mobile-native-chat-run-descriptors' +import { + isSubagentGroupBlock, + isToolResultBlock, + type NativeChatBlock, + type NativeChatMessage +} from '../../../src/shared/native-chat-types' + +/** Where each drawn row sits on the page. A folded row's id is its first message's, so older + * messages joining a run would replace the row; its key stays while what it is made of draws. */ +export type MobileNativeChatRowKeys = { + /** The chat the keys belong to; nothing they held carries into another. */ + scope: string + /** The source window's first message: it changes when a page, a cap trim or a replacement lands, + * even while every row keeps its key. */ + sourceHead: string | undefined + keyOf: (message: NativeChatMessage) => string + /** The key now drawing what a key of the last render drew; undefined once all of it left. */ + current: (key: string) => string | undefined + /** The folded message a key's row is drawn for, which sets where the row sits: a roster keeps its + * key while the fold moves it to an older sibling's place. */ + placement: (key: string) => string +} + +export type MobileNativeChatRowSources = { identity: string[]; content: string[] } +type Sources = MobileNativeChatRowSources + +export type MobileNativeChatAssignedRows = { + rows: readonly NativeChatMessage[] + messages: readonly NativeChatMessage[] + keyById: ReadonlyMap + /** Each row's sources, when that render worked them out. */ + sources?: readonly Sources[] + prepareSources?: () => readonly Sources[] +} +type Assigned = MobileNativeChatAssignedRows + +/** The fold keeps its sources' block objects, so a block names the message it came from. */ +function blockOwners(messages: readonly NativeChatMessage[]): Map { + const owners = new Map() + for (const message of messages) { + for (const block of message.blocks) { + if (!owners.has(block)) { + owners.set(block, message.id) + } + } + } + return owners +} + +/** What a row draws, by message and kind, and which of it makes the row what it is: a run is + * its assistant and its calls. Its results are content only, so a result left on a row of its + * own never takes the run's key. The fold makes a roster's group block; its group stands in. */ +function sourcesOf(row: NativeChatMessage, owners: Map): Sources { + const own = new Set() + const results = new Set() + for (const block of row.blocks) { + const owner = owners.get(block) + if (isSubagentGroupBlock(block)) { + own.add(`subagent-group:${block.groupId}`) + } else if (owner !== undefined && isToolResultBlock(block)) { + results.add(`result:${owner}`) + } else if (owner !== undefined) { + own.add(`body:${owner}`) + } + } + const content = [...own, ...results] + const identity = own.size > 0 ? [...own] : results.size > 0 ? [...results] : [`body:${row.id}`] + return { identity, content: content.length > 0 ? content : identity } +} + +/** Physical rows and displayed runs attribute against the same raw snapshot, only when needed. */ +export function prepareMobileNativeChatRowSources(messages: readonly NativeChatMessage[]) { + let owners: Map | undefined + const cache = new WeakMap() + return (row: NativeChatMessage): Sources => { + let sources = cache.get(row) + if (!sources) { + sources = sourcesOf(row, (owners ??= blockOwners(messages))) + cache.set(row, sources) + } + return sources + } +} + +function sourcesByRow( + rows: readonly NativeChatMessage[], + messages: readonly NativeChatMessage[], + scopeOf?: (row: NativeChatMessage) => string | undefined +): Sources[] { + const owners = blockOwners(messages) + return rows.map((row) => { + const sources = sourcesOf(row, owners) + if (!scopeOf) { + return sources + } + const scoped = (source: string) => `${source}\0${scopeOf(row) ?? ''}` + return { identity: sources.identity.map(scoped), content: sources.content.map(scoped) } + }) +} + +export function assignMobileNativeChatRowKeys( + previous: Assigned | null, + rows: readonly NativeChatMessage[], + messages: readonly NativeChatMessage[], + fresh?: () => string, + scopeOf?: (row: NativeChatMessage) => string | undefined, + preparedSources?: () => readonly Sources[] +): Pick & { + keyById: Map + moved: Map +} { + const keyById = new Map() + const taken = new Set() + const take = (row: NativeChatMessage, key: string): void => { + keyById.set(row.id, key) + taken.add(key) + } + const ids = new Set(rows.map((row) => row.id)) + let before: readonly Sources[] = [] + let now: readonly Sources[] | undefined + const current = () => (now ??= preparedSources?.() ?? sourcesByRow(rows, messages, scopeOf)) + if (fresh) { + current() + } + if (!fresh && previous?.rows.every((row) => ids.has(row.id))) { + // Nothing left: every row keeps its key. + for (const row of rows) { + const key = previous.keyById.get(row.id) + if (key !== undefined) { + take(row, key) + } + } + } else if (previous) { + before = + previous.sources ?? + previous.prepareSources?.() ?? + sourcesByRow(previous.rows, previous.messages) + current() + const was = new Map() + before.forEach(({ identity }, at) => identity.forEach((source) => was.set(source, at))) + // A row continues the one row its identity came from, if that row's went nowhere else. Two + // would be one key for two rows, so neither inherits rather than one being picked by position. + const origins = current().map( + ({ identity }) => new Set(identity.flatMap((s) => was.get(s) ?? [])) + ) + const heirs = new Map() + origins.forEach((from) => from.forEach((at) => heirs.set(at, (heirs.get(at) ?? 0) + 1))) + origins.forEach((from, at) => { + const [origin] = from + const only = origin !== undefined && from.size === 1 && heirs.get(origin) === 1 + const key = only ? previous.keyById.get(previous.rows[origin]!.id) : undefined + if (key !== undefined) { + take(rows[at]!, key) + } + }) + } + for (const row of rows.filter((unkeyed) => !keyById.has(unkeyed.id))) { + let key = fresh?.() ?? row.id + for (let n = 1; taken.has(key); n += 1) { + key = `${row.id}~${n}` + } + take(row, key) + } + // A key no row kept names the first row that took in what it drew. Results left on rows of their + // own when a run's last call went name none: the run ended, and its key goes with it. + const drawnAt = new Map() + now?.forEach(({ identity, content }, at) => { + if (!identity[0]!.startsWith('result:')) { + content.forEach((s) => { + if (!drawnAt.has(s)) { + drawnAt.set(s, at) + } + }) + } + }) + const moved = new Map() + before.forEach(({ content }, at) => { + const key = previous?.keyById.get(previous.rows[at]!.id) + if (key !== undefined && !taken.has(key)) { + const holder = rows[Math.min(...content.map((s) => drawnAt.get(s) ?? Infinity))] + moved.set(key, holder && keyById.get(holder.id)) + } + }) + return { keyById, moved, ...(now ? { sources: now } : {}) } +} + +/** Row keys for one shown chat, against the last committed render only, so what a key held dies + * with the rows that held it. */ +export function useMobileNativeChatRowKeys( + rows: readonly NativeChatMessage[], + messages: readonly NativeChatMessage[], + scope: string, + presentation?: MobileNativeChatRunPresentation +): MobileNativeChatRowKeys & { + runOwners: ReadonlyMap + runPlacements: ReadonlyMap +} { + // Written at commit: a render React discards must not become the next one's last render. + const committed = useRef< + (Assigned & { scope: string; displayed: Assigned; serial: number }) | null + >(null) + const assigned = useMemo(() => { + const previous = committed.current?.scope === scope ? committed.current : null + const sourceOf = prepareMobileNativeChatRowSources(messages) + const prepareSources = () => rows.map(sourceOf) + const physical = assignMobileNativeChatRowKeys( + previous, + rows, + messages, + undefined, + undefined, + prepareSources + ) + const runs = presentation ? mobileNativeChatRunDescriptors(presentation, sourceOf) : [] + const heads = runs.map((run) => run.row) + let serial = previous?.serial ?? 0 + const displayed = { + rows: heads, + messages, + ...assignMobileNativeChatRowKeys( + previous?.displayed ?? null, + heads, + messages, + () => `display-run:${serial++}`, + undefined, + () => runs.map((run) => run.sources) + ) + } + const runPlacements = new Map(Array.from(displayed.keyById, ([row, owner]) => [owner, row])) + return { scope, rows, messages, prepareSources, ...physical, displayed, serial, runPlacements } + }, [rows, messages, scope, presentation]) + useLayoutEffect(() => { + committed.current = assigned + }, [assigned]) + return useMemo(() => { + let idByKey: Map | undefined + return { + runOwners: assigned.displayed.keyById, + runPlacements: assigned.runPlacements, + scope: assigned.scope, + sourceHead: assigned.messages[0]?.id, + keyOf: (message: NativeChatMessage) => assigned.keyById.get(message.id) ?? message.id, + current: (key: string) => (assigned.moved.has(key) ? assigned.moved.get(key) : key), + placement: (key: string) => + (idByKey ??= new Map(Array.from(assigned.keyById, ([id, k]) => [k, id]))).get(key) ?? key + } + }, [assigned]) +} diff --git a/mobile/src/session/mobile-native-chat-run-descriptors.ts b/mobile/src/session/mobile-native-chat-run-descriptors.ts new file mode 100644 index 00000000000..743cbb6087d --- /dev/null +++ b/mobile/src/session/mobile-native-chat-run-descriptors.ts @@ -0,0 +1,48 @@ +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import type { NativeChatResolvedPrompt } from '../../../src/shared/native-chat-resolution-receipt' +import { splitNativeChatBlocks } from '../../../src/shared/native-chat-tool-fold' +import { nativeChatWorkRunEntries } from '../../../src/shared/native-chat-work-run' +import { nativeChatAskRunBlocks } from '../../../src/shared/native-chat-ask-row' +import type { MobileNativeChatWorkRuns } from './use-mobile-native-chat-transcript-rows' +import type { MobileNativeChatRowSources } from './mobile-native-chat-row-keys' + +export type MobileNativeChatRunPresentation = { + rows: readonly NativeChatMessage[] + workRuns: MobileNativeChatWorkRuns + turnKeys: readonly (string | undefined)[] + receipts?: ReadonlyMap | null +} + +/** Describe the runs the existing presentation draws, sharing physical-row source attribution. */ +export function mobileNativeChatRunDescriptors( + { rows, workRuns, turnKeys, receipts }: MobileNativeChatRunPresentation, + sourceOf: (row: NativeChatMessage) => MobileNativeChatRowSources +) { + return rows.flatMap((row, index) => { + if (workRuns.joined.has(index) || receipts?.has(row.id)) { + return [] + } + const members = workRuns.heads.get(index) + if (row.role === 'reasoning' && !members) { + return [] + } + const tools = members + ? nativeChatWorkRunEntries(members).blocks + : splitNativeChatBlocks(row.blocks).tools + if (nativeChatAskRunBlocks(tools).work.length === 0) { + return [] + } + const content = [...new Set((members ?? [row]).flatMap((member) => sourceOf(member).content))] + const own = content.filter((source) => !source.startsWith('result:')) + const scoped = (source: string) => `${source}\0${turnKeys[index] ?? ''}` + return [ + { + row, + sources: { + identity: (own.length ? own : content).map(scoped), + content: content.map(scoped) + } + } + ] + }) +} diff --git a/mobile/src/session/mobile-native-chat-scroll-ownership.test.tsx b/mobile/src/session/mobile-native-chat-scroll-ownership.test.tsx new file mode 100644 index 00000000000..c3228f22758 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-scroll-ownership.test.tsx @@ -0,0 +1,527 @@ +// @vitest-environment happy-dom +import { + act, + createElement as h, + Fragment, + startTransition, + Suspense, + useLayoutEffect, + useMemo, + useRef, + type ReactNode +} from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, expect, it, vi } from 'vitest' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { foldToolMessages } from '../../../src/shared/native-chat-tool-fold' +import { MobileNativeChatMessage } from './MobileNativeChatMessage' +import { MobileNativeChatScrollHold } from './MobileNativeChatScrollHold' +import { MobileNativeChatPageRows } from './mobile-native-chat-page-rows' +import { MobileNativeChatPageMotion } from './mobile-native-chat-page-motion' +import { + mobileNativeChatScrollFrame, + mobileNativeChatReadingParts, + mobileNativeChatVisible, + MobileNativeChatScrollRoots +} from './mobile-native-chat-scroll-roots' +import { + MobileNativeChatDisclosureContext, + useMobileNativeChatDisclosures, + type MobileNativeChatDisclosureStore +} from './mobile-native-chat-disclosure-store' +import { useMobileNativeChatRowKeys } from './mobile-native-chat-row-keys' +import { useMobileNativeChatTurnDisclosure } from './use-mobile-native-chat-turn-disclosure' + +vi.mock('react-native', async () => { + const React = await import('react') + const View = (p: { + children?: ReactNode + testID?: string + onPress?: () => void + accessibilityLiveRegion?: string + }) => + React.createElement( + 'div', + { 'data-testid': p.testID, onClick: p.onPress, 'data-live': p.accessibilityLiveRegion }, + p.children + ) + const ScrollView = React.forwardRef((p: { children?: ReactNode }, ref) => { + const node = React.useRef(null) + React.useImperativeHandle(ref, () => ({ + getScrollableNode: () => node.current, + getInnerViewNode: () => node.current?.firstElementChild + })) + return React.createElement( + 'div', + { ref: node, className: 'viewport' }, + React.createElement('div', null, p.children) + ) + }) + const animation = () => ({ start() {}, stop() {} }) + return { + View, + Text: View, + Pressable: View, + ScrollView, + Image: View, + Platform: { OS: 'web' }, + StyleSheet: { create: (s: unknown) => s, hairlineWidth: 1 }, + useWindowDimensions: () => ({ width: 400, height: 800 }), + Animated: { + Text: View, + Value: class { + setValue() {} + }, + loop: animation, + sequence: animation, + timing: animation + } + } +}) +vi.mock('lucide-react-native', () => + Object.fromEntries( + 'ArrowUp Bot Brain Check ChevronDown ChevronRight Copy Eye FileMinus2 FilePen FilePlus2 Folder Globe ListChecks MessageSquareMore Pencil Plug Search SquareChevronRight SquareTerminal Wrench Circle CircleCheck CircleDot' + .split(' ') + .map((key) => [key, () => null]) + ) +) +vi.mock('../components/MobileMarkdown', () => ({ MobileMarkdown: () => null })) +vi.mock('../components/MobileSourceCopyButton', () => ({ MobileSourceCopyButton: () => null })) +vi.mock('../platform/external-link', () => ({ openExternalLink: vi.fn() })) +vi.mock('../components/inline-text-selection', () => ({ INLINE_TEXT_SELECTION: false })) + +let renderer: Root | undefined +let container: HTMLDivElement +let page: HTMLElement +let frameShift = 0 +let roots: MobileNativeChatScrollRoots +let owners: (string | undefined)[] +let projected: (string | undefined)[] +let store: MobileNativeChatDisclosureStore +const abandoned = new Promise(() => {}) +const correctPage = vi.fn((drift: number) => { + page.scrollTop += drift +}) +const msg = ( + id: string, + blocks: NativeChatMessage['blocks'], + role: NativeChatMessage['role'] = 'assistant' +): NativeChatMessage => ({ id, blocks, role, timestamp: null, source: 'transcript' }) +const a = msg( + 'a', + Array.from({ length: 6 }, (_, i) => ({ + type: 'tool-call', + callId: `c${i}`, + name: 'Bash', + input: { command: `command ${i} ${'argument '.repeat(20)}` } + })) +) +const thought = msg('r', [{ type: 'text', text: 'reason' }], 'reasoning') +const user = msg('u', [{ type: 'text', text: 'go' }], 'user') +const b = msg('b', [{ type: 'tool-call', callId: 'b', name: 'Bash', input: { command: 'b' } }]) +function reader(node: Element, top: number) { + node.dispatchEvent(new Event('touchstart')) + node.scrollTop = top + node.dispatchEvent(new Event('scroll')) + node.dispatchEvent(new Event('touchend')) +} +function members(root: Element): Element[] { + return Array.from(root.querySelectorAll('[data-testid^="chat-member:"]')).filter( + (node) => !mobileNativeChatScrollFrame(node, root) + ) +} +function rect(node: Element): DOMRect { + const viewport = node.closest('[data-chat-scroll-root]') + let top = 0 + let height = 1000 + if (node === page) { + return DOMRect.fromRect({ y: 100, height: 200, width: 400 }) + } + if (viewport === node) { + const parent = node.parentElement?.closest('[data-chat-scroll-root]') + top = parent + ? rect(node.parentElement!.closest('[data-testid^="chat-member:"]') ?? parent).top + 20 + : 40 + frameShift - page.scrollTop + height = 160 + } else if (viewport) { + const member = node.closest('[data-testid^="chat-member:"]') + top = + rect(viewport).top + + Math.max(0, members(viewport).indexOf(member ?? node)) * 60 - + viewport.scrollTop + height = 60 + } else if (node.closest('[data-live], [data-testid="chat-row:u"]')) { + top = -page.scrollTop + height = 20 + } else { + top = -page.scrollTop + } + return DOMRect.fromRect({ y: top, height, width: 400 }) +} +function Shift({ value }: { value: number }): null { + useLayoutEffect(() => { + frameShift = value + }, [value]) + return null +} +function Chat({ + rows, + tools = true, + shown = true, + scope = 'chat', + shift = 0, + suspend = false, + structured = true, + working = false +}: { + rows: NativeChatMessage[] + tools?: boolean + shown?: boolean + scope?: string + shift?: number + suspend?: boolean + structured?: boolean + working?: boolean +}) { + const current = useMobileNativeChatDisclosures(scope) + const drawn = useMemo(() => (structured ? rows : foldToolMessages(rows)), [rows, structured]) + const turns = useMobileNativeChatTurnDisclosure({ + messages: drawn, + enabled: structured, + isWorking: working, + scopeKey: scope + }) + const keys = useMobileNativeChatRowKeys(drawn, rows, scope, turns.presentation) + const ref = useRef({ + getScrollableNode: () => page, + keepInPlace: correctPage + }) + useLayoutEffect(() => { + store = current + roots = current.roots + owners = drawn.map((row) => keys.runOwners.get(row.id)) + }) + if (suspend) { + projected = drawn.map((row) => keys.runOwners.get(row.id)) + throw abandoned + } + return h( + MobileNativeChatDisclosureContext.Provider, + { value: current }, + h( + MobileNativeChatScrollHold, + { + rowKeys: keys, + roots: current.roots, + runPlacements: keys.runPlacements, + layoutKey: String(tools), + listRef: ref, + isFollowing: () => false + }, + h(Shift, { value: shift }), + h( + Fragment, + { key: scope }, + shown && + drawn.map((message, i) => + h(MobileNativeChatMessage, { + key: keys.keyOf(message), + message, + rowKey: keys.keyOf(message), + ...turns.resolveRow(i, message), + runOwner: keys.runOwners.get(message.id), + structuredActivityUi: structured, + toolsExpanded: tools + }) + ) + ) + ) + ) +} +function mount(structured = true) { + vi.useFakeTimers() + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) + vi.stubGlobal('navigator', { userAgent: 'AppleWebKit Safari' }) + container = document.createElement('div') + page = document.createElement('main') + page.style.overflowY = 'auto' + document.body.append(page) + page.append(container) + vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(function (this: Element) { + return rect(this) + }) + vi.spyOn(HTMLElement.prototype, 'clientHeight', 'get').mockReturnValue(160) + vi.spyOn(Element.prototype, 'scrollHeight', 'get').mockImplementation(function (this: Element) { + return this.getAttribute('data-short') + ? 80 + : members(this).length * 60 + + 400 + + this.querySelectorAll('.viewport').length * 60 + + Number(this.getAttribute('data-growth') ?? 0) + }) + renderer = createRoot(container) + return (rows: NativeChatMessage[], rest: Partial[0]> = {}) => { + const render = () => + renderer!.render(h(Suspense, { fallback: null }, h(Chat, { rows, structured, ...rest }))) + act(() => (rest.suspend ? startTransition(render) : render())) + act(() => vi.advanceTimersByTime(20)) + } +} +afterEach(() => { + act(() => renderer?.unmount()) + page?.remove() + renderer = undefined + frameShift = 0 + correctPage.mockClear() + vi.useRealTimers() + vi.restoreAllMocks() + vi.unstubAllGlobals() +}) + +it('keeps real structured runs and nested offsets under their own viewport across reheading', () => { + const draw = mount() + draw([user, a]) + const owner = owners[1] + const run = container.querySelector('[data-chat-scroll-root]') + if (!(run instanceof HTMLElement)) { + throw new Error('missing run viewport') + } + const detail = run.querySelector('[data-chat-scroll-root]') + if (!(detail instanceof HTMLElement)) { + throw new Error('missing command viewport') + } + const row = run.closest('[data-testid^="chat-row:"]')! + const rows = new MobileNativeChatPageRows() + rows.ref('a')(row) + rows.takeAnchor(page, ['a'], [0, 1], true) + reader(run, 100) + reader(detail, 35) + act(() => vi.advanceTimersByTime(160)) + roots.restoreAll() + expect(rows.placeShift(page)).toBe(0) + expect(page.scrollTop).toBe(0) + expect(correctPage).not.toHaveBeenCalled() + const clipped = run.querySelector('[data-testid="chat-member:call:c0"]')! + expect(mobileNativeChatVisible(clipped, page)).toBe(false) + expect(mobileNativeChatReadingParts(row)).toContain(run) + expect(mobileNativeChatReadingParts(row)).not.toContain(clipped) + const anchor = run.querySelector('[data-testid="chat-member:call:c3"]')! + const offset = rect(anchor).top - rect(run).top + draw([user, thought, a], { shift: 80 }) + expect(owners[1]).toBe(owner) + const restored = container.querySelector('[data-chat-scroll-root]')! + expect( + rect(restored.querySelector('[data-testid="chat-member:call:c3"]')!).top - rect(restored).top + ).toBe(offset) + expect(restored.querySelector('[data-chat-scroll-root]')?.scrollTop).toBe(35) + expect(page.scrollTop).toBe(80) + expect(correctPage).toHaveBeenCalledExactlyOnceWith(80, false) + const top = restored.scrollTop + draw([user, thought, a], { shown: false, shift: 80 }) + draw([user, thought, a], { shift: 80 }) + expect(container.querySelector('[data-chat-scroll-root]')?.scrollTop).toBe(top) + const observer = { observe: vi.fn(), unobserve: vi.fn(), disconnect: vi.fn() } + roots.observeWith(observer) + expect(observer.observe).toHaveBeenCalledWith(container.querySelector('[data-chat-scroll-root]')) + const late = document.createElement('div') + const content = document.createElement('div') + late.append(content) + container.append(late) + late.setAttribute('data-short', 'yes') + store.runScroll.set('late', { top: 37, followsEnd: false, member: 'missing', offset: 10 }) + const stop = roots.register('late', late, content, 'late', 'late', false) + expect(late.scrollHeight).toBeLessThan(late.clientHeight) + expect(observer.observe).toHaveBeenCalledWith(content) + roots.capture(new Set(), false) + expect(store.runScroll.get('late')?.top).toBe(37) + late.removeAttribute('data-short') + roots.restoreAll() + expect(late.scrollTop).toBe(37) + expect(store.runScroll.get('late')?.member).toBeUndefined() + const removed = vi.spyOn(late, 'removeEventListener') + const replacement = document.createElement('div') + late.replaceChildren(replacement) + const replaced = roots.register('late', late, replacement, 'late', 'late', false) + observer.unobserve.mockClear() + stop() + expect(roots.frame('late')).toBe(late) + expect(observer.unobserve.mock.calls.some(([node]) => node === late)).toBe(false) + expect(observer.unobserve.mock.calls.some(([node]) => node === replacement)).toBe(false) + late.remove() + roots.restoreAll() + replaced() + expect(roots.frame('late')).toBeUndefined() + expect(observer.unobserve.mock.calls.some(([node]) => node === content)).toBe(true) + expect(removed).toHaveBeenCalledWith('scroll', expect.any(Function)) +}) + +it.each([true, false])( + 'issues fresh displayed owners through split and merge instead of reviving retired choices (structured=%s)', + (structured) => { + const draw = mount(structured) + draw([user, a, b], { working: true }) + const original = owners[1] + const oldKey = `run:${original}:true` + act(() => store.write(oldKey, false)) + store.runScroll.set(oldKey, { top: 111, followsEnd: false }) + const splitRows = [ + user, + a, + msg('break', [{ type: 'text', text: 'answer' }], structured ? 'assistant' : 'user'), + b + ] + const registry = roots + draw(splitRows, { suspend: true, working: true }) + expect(projected[1]).not.toBe(original) + expect(owners[1]).toBe(original) + expect(roots).toBe(registry) + expect(store.runScroll.get(oldKey)?.top).toBe(111) + const tentative = [...projected] + draw(splitRows, { working: true }) + const split = [owners[1], owners[3]] + expect(new Set([original, ...split]).size).toBe(3) + expect(owners).toEqual(tentative) + const reused = roots.frame(`run:${split[0]}`)! + reader(reused, 40) + draw([user, a, b]) + expect([original, ...split]).not.toContain(owners[1]) + expect(store.read(oldKey)).toBe(false) + expect(store.runScroll.get(oldKey)?.top).toBe(111) + expect(roots.frame(`run:${owners[1]}`)).toBe(reused) + expect(reused.scrollTop).toBe(40) + expect(store.runScroll.get(`run:${owners[1]}:true`)?.followsEnd).toBe(false) + } +) + +it('waits for simultaneous inner rest, releases on close/scope change, and separates work-follow from reader chaining', async () => { + const draw = mount() + draw([user, a]) + const run = container.querySelector('[data-chat-scroll-root]')! + const detail = run.querySelector('[data-chat-scroll-root]')! + vi.stubGlobal('navigator', { userAgent: 'AppleWebKit Chrome' }) + const pageMotion = new MobileNativeChatPageMotion(() => {}) + const detach = pageMotion.attach(page) + pageMotion.reconciled(100, 1000) + run.dispatchEvent(new Event('touchstart')) + run.scrollTop = 100 + run.dispatchEvent(new Event('scroll')) + let settled = false + const waiting = roots.settled(pageMotion).then(() => { + settled = true + }) + await Promise.resolve() + expect(pageMotion.settled()).toBe(true) + expect(settled).toBe(false) + detail.dispatchEvent(new Event('touchstart')) + detail.scrollTop = 35 + detail.dispatchEvent(new Event('scroll')) + run.dispatchEvent(new Event('touchend')) + await vi.advanceTimersByTimeAsync(160) + expect(settled).toBe(false) + detail.dispatchEvent(new Event('touchend')) + await vi.advanceTimersByTimeAsync(0) + await waiting + reader(run, run.scrollHeight - run.clientHeight - 20) + await vi.advanceTimersByTimeAsync(160) + const parked = run.scrollTop + run.setAttribute('data-growth', '60') + roots.restoreAll() + expect(run.scrollTop).toBe(parked) + draw([user, a, b]) + expect(run.scrollTop).toBe(run.scrollHeight - run.clientHeight) + run.dispatchEvent(new Event('scroll')) + await roots.settled(pageMotion) + expect(pageMotion.scrolled(100, 1000, () => false).by).toBe('none') + run.dispatchEvent(new WheelEvent('wheel', { deltaY: -10, bubbles: true })) + expect(pageMotion.scrolled(90, 1000, () => false).by).toBe('reader') + pageMotion.release() + run.dispatchEvent(new Event('touchstart')) + const closing = roots.settled(pageMotion) + act(() => container.querySelector('[data-live]')!.click()) + await closing + expect(run.hasAttribute('data-chat-scroll-root')).toBe(false) + draw([user, a], { scope: 'intermediate' }) + container.querySelector('[data-chat-scroll-root]')!.dispatchEvent(new Event('touchstart')) + const leaving = roots.settled(pageMotion) + draw([user, a], { scope: 'other' }) + await leaving + vi.stubGlobal('navigator', { userAgent: 'AppleWebKit Safari' }) + draw([user, a], { scope: 'other', tools: false, working: true }) + act(() => container.querySelector('[data-live]')!.click()) + const key = `run:${owners[1]}` + const live = roots.frame(key)! + const end = live.scrollTop + expect(end).toBe(live.scrollHeight - live.clientHeight) + const previous = store.runScroll.get(`${key}:false`)! + draw([user, a], { scope: 'other', working: true, shift: 40 }) + expect(roots.frame(key)).toBe(live) + expect(live.scrollTop).toBe(end) + expect(live.scrollTop).toBeLessThan(live.scrollHeight - live.clientHeight) + expect(correctPage).toHaveBeenCalledExactlyOnceWith(40, true) + reader(live, end - 100) + draw([user, a], { scope: 'other', tools: false, working: true, shift: 40 }) + expect(live.scrollTop).toBe(end - 100) + expect(store.runScroll.get(`${key}:false`)?.followsEnd).toBe(false) + expect(previous.followsEnd).toBe(true) + reader(live, live.scrollHeight - live.clientHeight - 20) + await vi.advanceTimersByTimeAsync(160) + draw([user, a, b], { scope: 'other', tools: false, working: true, shift: 40 }) + expect(live.scrollTop).toBe(live.scrollHeight - live.clientHeight) + draw([user, a, b], { scope: 'other', tools: false, working: true, shown: false, shift: 40 }) + store.runScroll.set(`${key}:false`, { top: 37, followsEnd: false }) + draw([user, a, b], { scope: 'other', tools: false, working: true, shift: 40 }) + expect(roots.frame(key)?.scrollTop).toBe(37) + detach() + pageMotion.release() +}) + +it('keeps the actual changed task checklist through eviction and Tools, isolated by chat', () => { + const draw = mount() + const tasks = msg( + 'tasks', + [false, true].map((done) => ({ + type: 'tool-call', + callId: String(done), + name: 'TodoWrite', + input: { + todos: [ + { content: 'Remember', status: done ? 'completed' : 'pending', activeForm: 'Remembering' } + ] + } + })) + ) + draw([user, tasks]) + const count = () => container.textContent?.match(/Remember/g)?.length ?? 0 + const before = count() + const disclosure = Array.from(container.querySelectorAll('div')).findLast( + (node) => node.textContent === 'Full task list' + ) + if (!disclosure) { + throw new Error('missing changed-task disclosure') + } + act(() => disclosure.click()) + expect(count()).toBe(before + 1) + draw([user, tasks], { shown: false }) + draw([user, tasks]) + expect(count()).toBe(before + 1) + draw([user, tasks], { tools: false }) + draw([user, tasks]) + expect(count()).toBe(before + 1) + draw([user, tasks], { scope: 'different' }) + expect(count()).toBe(before) +}) + +it("forgets a closed tool line's nested command and output positions, not its neighbours'", () => { + const at = { top: 30, followsEnd: false } + const memory = new Map( + ['run:o:true', 'run:o:true/line:a:command', 'run:o:true/line:a:output', 'line:a:command'] + .concat(['run:o:true/line:ab:command', 'diff:src/line:a.ts']) + .map((address) => [address, at] as const) + ) + new MobileNativeChatScrollRoots(memory, 10).forget('line:a') + expect([...memory.keys()]).toEqual([ + 'run:o:true', + 'run:o:true/line:ab:command', + 'diff:src/line:a.ts' + ]) +}) diff --git a/mobile/src/session/mobile-native-chat-scroll-root-handle.ts b/mobile/src/session/mobile-native-chat-scroll-root-handle.ts new file mode 100644 index 00000000000..e8f34f44888 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-scroll-root-handle.ts @@ -0,0 +1,104 @@ +import type { MobileNativeChatPageMotion } from './mobile-native-chat-page-motion' +import type { MobileNativeChatPageAnchoring } from './mobile-native-chat-page-anchoring' + +export type MobileNativeChatRootPosition = { + top: number + followsEnd: boolean + member?: string + offset?: number +} + +type Root = { + id: string + address: string + owner: string + node: HTMLElement + content: Element + position: MobileNativeChatRootPosition + work: number | undefined + nextWork: number | undefined + fresh: boolean + motion: MobileNativeChatPageMotion + anchoring: MobileNativeChatPageAnchoring + detach: () => void +} +export type { Root as MobileNativeChatScrollRootHandle } + +const owners = new WeakMap() +const ROOT = 'data-chat-scroll-root' +export const mobileNativeChatScrollRootKey = (node: Element): string | undefined => + owners.get(node)?.id + +/** The first viewport crossed from the requesting owner, never a deeper descendant's frame. */ +export function mobileNativeChatScrollFrame(node: Element, within: Element): Element | undefined { + let frame: Element | undefined + for (let at: Element | null = node; at && at !== within; at = at.parentElement) { + if (owners.has(at)) { + frame = at + } + } + return frame +} + +export function mobileNativeChatVisible(node: Element, root: Element): boolean { + const box = node.getBoundingClientRect() + let { top, bottom } = root.getBoundingClientRect() + for (let at = node.parentElement; at; at = at.parentElement) { + if ( + at === root || + owners.has(at) || + /hidden|clip|auto|scroll/.test(getComputedStyle(at).overflowY) + ) { + const clip = at.getBoundingClientRect() + top = Math.max(top, clip.top) + bottom = Math.min(bottom, clip.bottom) + } + } + return box.bottom > top && box.top < bottom +} + +export function mobileNativeChatReadingParts(root: Element): Element[] { + const parts = Array.from(root.querySelectorAll(`[data-testid^="chat-member:"], [${ROOT}]`)) + return Array.from( + new Set(parts.map((part) => mobileNativeChatScrollFrame(part, root) ?? part)) + ).filter((part) => mobileNativeChatVisible(part, root)) +} + +export { owners as mobileNativeChatScrollRootOwners, ROOT as mobileNativeChatScrollRootAttribute } + +export function mobileNativeChatScrollRootMember( + root: Root, + position = root.position +): Element | undefined { + return position?.member + ? Array.from(root.node.querySelectorAll('[data-testid]')).find( + (part) => + part.getAttribute('data-testid') === position.member && + !mobileNativeChatScrollFrame(part, root.node) + ) + : undefined +} + +export function mobileNativeChatScrollRootShift(root: Root): number { + const position = root.position + const member = mobileNativeChatScrollRootMember(root, position) + return member && position?.offset !== undefined + ? member.getBoundingClientRect().top - + root.node.getBoundingClientRect().top - + position.offset + + root.node.scrollTop - + position.top + : 0 +} + +export function mobileNativeChatRootsDeepestFirst(roots: Iterable) { + return Array.from(roots) + .map((root, index) => { + let depth = 0 + for (let node = root.node.parentElement; node; node = node.parentElement) { + depth += 1 + } + return { root, depth, index } + }) + .sort((a, b) => b.depth - a.depth || a.index - b.index) +} diff --git a/mobile/src/session/mobile-native-chat-scroll-roots.ts b/mobile/src/session/mobile-native-chat-scroll-roots.ts new file mode 100644 index 00000000000..dd20ca245e3 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-scroll-roots.ts @@ -0,0 +1,318 @@ +import { MobileNativeChatPageMotion, pageKeepsPlace } from './mobile-native-chat-page-motion' +import { MobileNativeChatPageAnchoring } from './mobile-native-chat-page-anchoring' +import { + mobileNativeChatScrollRootAttribute as ROOT, + mobileNativeChatScrollRootOwners as owners, + mobileNativeChatScrollRootMember as rootMember, + mobileNativeChatScrollRootShift as shift, + mobileNativeChatReadingParts, + mobileNativeChatRootsDeepestFirst, + type MobileNativeChatRootPosition, + type MobileNativeChatScrollRootHandle as Root +} from './mobile-native-chat-scroll-root-handle' +export { + mobileNativeChatScrollRootKey, + mobileNativeChatScrollFrame, + mobileNativeChatVisible, + mobileNativeChatReadingParts +} from './mobile-native-chat-scroll-root-handle' +export type { MobileNativeChatRootPosition } from './mobile-native-chat-scroll-root-handle' + +/** A disclosure owns its own segment anywhere in an address, its `key:part` leaves, and all below. */ +function disclosureOwns(key: string, address: string): boolean { + for (let at = address.indexOf(key); at >= 0; at = address.indexOf(key, at + 1)) { + const next = address[at + key.length] + if ( + (at === 0 || address[at - 1] === '/') && + (next === undefined || next === '/' || next === ':') + ) { + return true + } + } + return false +} + +/** Mounted handles only; the chat's existing bounded memory owns positions across eviction. */ +export class MobileNativeChatScrollRoots { + private roots = new Map() + private observer: ResizeObserver | null = null + private held = new Set() + private generation = 0 + private page: MobileNativeChatPageMotion | null = null + + constructor( + private readonly memory: Map, + private readonly limit: number + ) {} + + register( + id: string, + node: HTMLElement, + content: Element, + owner: string, + address: string, + initialFollow: boolean + ): () => void { + const was = this.roots.get(id) + if (was?.node === node && was.content === content && was.owner === owner) { + return () => this.unregister(was, true) + } + if (was) { + this.unregister(was, true) + } + const occupant = owners.get(node) + if (occupant) { + this.unregister(occupant, true) + } + const motion = new MobileNativeChatPageMotion(() => {}, node.scrollTop) + const root: Root = { + id, + address, + owner, + node, + content, + position: { + ...(this.memory.get(address) ?? { top: node.scrollTop, followsEnd: initialFollow }) + }, + work: undefined, + nextWork: undefined, + fresh: true, + motion, + anchoring: new MobileNativeChatPageAnchoring( + () => node, + () => root.position.followsEnd + ), + detach: () => {} + } + const input = motion.attach( + node, + (event) => + event instanceof Event && event.target instanceof Node && node.contains(event.target) + ) + const scrolled = () => { + const max = node.scrollHeight - node.clientHeight + const { by } = motion.scrolled(node.scrollTop, max, () => Math.abs(shift(root)) >= 0.5) + if (by === 'reader') { + if (root.nextWork !== undefined) { + root.position.followsEnd = max - node.scrollTop <= 24 + } + this.remember(root) + } + root.anchoring.sync() + } + node.addEventListener('scroll', scrolled, { passive: true }) + root.detach = () => { + input() + node.removeEventListener('scroll', scrolled) + } + owners.set(node, root) + node.setAttribute(ROOT, '') + this.roots.set(id, root) + this.observer?.observe(node) + this.observer?.observe(content) + this.hold(root) + return () => this.unregister(root, true) + } + + private unregister(root: Root, remember: boolean): void { + const current = this.roots.get(root.id) === root + if (current && remember && root.node.isConnected && !this.held.has(root)) { + this.remember(root) + } + root.detach() + root.motion.release() + for (const target of [root.node, root.content]) { + const viewport = owners.get(target) + const parent = target.parentElement + const container = parent ? owners.get(parent) : undefined + if ( + !(viewport && viewport !== root && this.roots.get(viewport.id) === viewport) && + !( + container && + container !== root && + container.content === target && + this.roots.get(container.id) === container + ) + ) { + this.observer?.unobserve(target) + } + } + if (owners.get(root.node) === root) { + owners.delete(root.node) + root.node.removeAttribute(ROOT) + } + if (current) { + this.roots.delete(root.id) + } + if (this.held.delete(root)) { + root.anchoring.hold(false) + } + } + + readonly observeWith = (observer: ResizeObserver | null): void => { + for (const { node, content } of this.roots.values()) { + this.observer?.unobserve(node) + this.observer?.unobserve(content) + observer?.observe(node) + observer?.observe(content) + } + this.observer = observer + } + + layout(id: string, address: string, work?: number): void { + const root = this.roots.get(id) + if (!root) { + return + } + root.address = address + root.nextWork = work + root.motion.laidOut(`${work}:${root.node.scrollHeight}:${root.node.clientHeight}`) + } + + private remember(root: Root): void { + const { node, address } = root + const member = + mobileNativeChatReadingParts(node).find((part) => !owners.has(part)) ?? rootMember(root) + root.position = { + top: node.scrollTop, + followsEnd: root.position.followsEnd, + ...(member + ? { + member: member.getAttribute('data-testid') ?? undefined, + offset: member.getBoundingClientRect().top - node.getBoundingClientRect().top + } + : {}) + } + this.memory.delete(address) + this.memory.set(address, { ...root.position }) + if (this.memory.size > this.limit) { + const oldest = this.memory.keys().next().value + if (oldest !== undefined) { + this.memory.delete(oldest) + } + } + } + + private hold(root: Root): void { + if (!this.held.has(root)) { + this.held.add(root) + root.anchoring.hold(true) + } + } + + capture(movedOwners: ReadonlySet, regrouped: boolean): void { + for (const root of this.roots.values()) { + if (!root.fresh) { + this.remember(root) + } + if (regrouped || movedOwners.has(root.owner)) { + this.hold(root) + } + } + } + + /** Called by the parent commit boundary and the one shared observer, deepest viewport first. */ + restoreAll(regrouped = false, release = true): void { + const roots = mobileNativeChatRootsDeepestFirst(this.roots.values()) + try { + for (const { root } of roots) { + const { node, motion } = root + if (!node.isConnected) { + continue + } + const max = Math.max(0, node.scrollHeight - node.clientHeight) + const { by } = motion.reconciled(node.scrollTop, max) + if (by === 'reader') { + if (root.work !== undefined) { + root.position.followsEnd = max - node.scrollTop <= 24 + } + this.remember(root) + } + const position = root.position + const pin = position.followsEnd && (root.fresh || root.work !== root.nextWork) + const explicit = this.held.has(root) + const member = rootMember(root, position) + const target = pin + ? max + : member && position?.offset !== undefined + ? node.scrollTop + + member.getBoundingClientRect().top - + node.getBoundingClientRect().top - + position.offset + : (position?.top ?? node.scrollTop) + const ordinary = !position.followsEnd && pageKeepsPlace(node.scrollTop) + const clamped = Math.max(0, Math.min(target, max)) + if ( + (pin || explicit || ordinary) && + (regrouped || motion.mayCorrect()) && + node.scrollTop >= 0 && + node.scrollTop <= max + 1 && + Math.abs(clamped - node.scrollTop) >= 0.5 + ) { + node.scrollTop = clamped + motion.wrote(node.scrollTop) + } + root.fresh = false + root.work = root.nextWork + this.remember(root) + root.anchoring.committed() + } + } finally { + if (release) { + this.releaseHold() + } + } + } + + releaseHold(): void { + for (const root of this.held) { + root.anchoring.hold(false) + } + this.held.clear() + } + + frame(key: string): Element | undefined { + return this.roots.get(key)?.node + } + owner(key: string): string | undefined { + return this.roots.get(key)?.owner + } + + forget(key: string): void { + for (const root of this.roots.values()) { + if (disclosureOwns(key, root.address)) { + this.unregister(root, false) + } + } + for (const child of this.memory.keys()) { + if (disclosureOwns(key, child)) { + this.memory.delete(child) + } + } + } + + async settled(page: MobileNativeChatPageMotion): Promise { + const generation = this.generation + this.page = page + do { + await Promise.all([ + page.whenSettled(), + ...Array.from(this.roots.values(), (r) => r.motion.whenSettled()) + ]) + if (generation !== this.generation) { + return + } + } while (!page.settled() || Array.from(this.roots.values()).some((r) => !r.motion.settled())) + } + + dispose(): void { + this.generation += 1 + for (const root of this.roots.values()) { + this.unregister(root, false) + } + this.page?.release() + this.page = null + this.observer = null + this.releaseHold() + } +} diff --git a/mobile/src/session/mobile-native-chat-tool-run-props.ts b/mobile/src/session/mobile-native-chat-tool-run-props.ts new file mode 100644 index 00000000000..2a737d078ae --- /dev/null +++ b/mobile/src/session/mobile-native-chat-tool-run-props.ts @@ -0,0 +1,34 @@ +import type { + NativeChatBlock, + NativeChatToolCallBlock +} from '../../../src/shared/native-chat-types' + +/** Desktop parity: `NativeChatToolRunAsides`. Rows drawn among an open run's lines, each keyed: + * before the block they are keyed by, or after the last. */ +export type MobileNativeChatToolRunAsides = { + before: ReadonlyMap + after: readonly React.JSX.Element[] +} + +export type MobileNativeChatToolRunProps = { + blocks: NativeChatBlock[] + /** The last plan each task-list tool wrote before this run, so an update reads as its changes. */ + previousTodoWrite?: NativeChatToolCallBlock + previousUpdatePlan?: NativeChatToolCallBlock + defaultExpanded: boolean + /** The row this run belongs to. The list recycles rows, so what the reader opened is + * remembered somewhere that outlives the row. */ + disclosureId?: string + /** Rows drawn among the run's lines, each before the block it is keyed by, or after the last. */ + asides?: MobileNativeChatToolRunAsides + /** Every line's default: open under the Tools toggle. */ + expandChildren: boolean + /** Structured lifecycle state; keeps an orphaned running call from spinning. */ + activeTurnIsWorking?: boolean + /** Whether this run is the working turn's last. Only that run is live. */ + trailing?: boolean + /** The turn is blocked on the reader's answer, so its calls decide liveness. */ + awaitingAnswer?: boolean + fontScale?: number + onOpenFile?: (path: string) => void +} diff --git a/mobile/src/session/mobile-native-chat-view-styles.ts b/mobile/src/session/mobile-native-chat-view-styles.ts index 8d35da5d00e..8984d682e75 100644 --- a/mobile/src/session/mobile-native-chat-view-styles.ts +++ b/mobile/src/session/mobile-native-chat-view-styles.ts @@ -98,5 +98,11 @@ export const styles = StyleSheet.create({ color: colors.textMuted, fontSize: typography.metaSize, fontWeight: '600' + }, + loadEarlierFailedText: { + color: colors.textMuted, + fontSize: typography.metaSize, + textAlign: 'center', + marginTop: spacing.xs } }) diff --git a/mobile/src/session/mobile-session-read-operations.ts b/mobile/src/session/mobile-session-read-operations.ts index 3e75554fd1e..2fac18b2ac0 100644 --- a/mobile/src/session/mobile-session-read-operations.ts +++ b/mobile/src/session/mobile-session-read-operations.ts @@ -143,9 +143,9 @@ export const nativeChatFileInventoryRead = bindDeferredRpcOperation( /** * The older-history page native chat asks for when the transcript is scrolled back. * - * A skip rather than a throw: a refused page leaves the window the subscription already delivered - * and the scroll simply does not grow, which is what the call site's `if (!response.ok) return` - * did. There is no screen to raise a host message on — the pane is already showing history. + * A skip rather than a throw: a refused page leaves the window the subscription already delivered, + * and the call site shows its own retry line. The host's message isn't raised; it is an internal + * code the reader can't act on. * * The payload stays whole rather than being narrowed to `messages`, because the reply is a union: * an older runtime answers `{ error }` in place of a window, and the caller discriminates on that diff --git a/mobile/src/session/use-mobile-native-chat-page-history.test.tsx b/mobile/src/session/use-mobile-native-chat-page-history.test.tsx deleted file mode 100644 index 56080cb4d3d..00000000000 --- a/mobile/src/session/use-mobile-native-chat-page-history.test.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import { createElement, type ReactElement } from 'react' -import { act, create, type ReactTestRenderer } from 'react-test-renderer' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { useMobileNativeChatPageHistory } from './use-mobile-native-chat-page-history' - -const platform = vi.hoisted(() => ({ OS: 'web' })) - -vi.mock('react-native', () => ({ Platform: platform })) - -type Item = { id: string } -type History = ReturnType> - -const rows = (...ids: string[]): Item[] => ids.map((id) => ({ id })) -const renderRow = vi.fn(({ item, index }: { item: Item; index: number }): ReactElement => - createElement('Row', { id: item.id, index }) -) - -describe('useMobileNativeChatPageHistory', () => { - let renderer: ReactTestRenderer | null = null - let history: History | null = null - - function Harness({ items }: { items: Item[] }): null { - history = useMobileNativeChatPageHistory(items, renderRow, 'chat') - return null - } - - async function show(items: Item[]): Promise { - await act(async () => { - if (renderer) { - renderer.update(createElement(Harness, { items })) - } else { - renderer = create(createElement(Harness, { items })) - } - }) - } - - /** What the list header draws below the control it was given. */ - function headerRows(): { id: string; index: number }[] { - const drawn: { header?: ReactTestRenderer } = {} - act(() => { - drawn.header = create( - createElement('Header', null, history?.withHistory(createElement('Control'))) - ) - }) - const found = drawn.header?.root.findAll((node) => typeof node.props.index === 'number') ?? [] - return found.map((row) => ({ id: row.props.id, index: row.props.index })) - } - - afterEach(() => { - act(() => renderer?.unmount()) - renderer = null - history = null - platform.OS = 'web' - }) - - it('draws history that loads above the list in its header, leaving the list its rows', async () => { - await show(rows('a', 'b')) - expect(history?.listRows).toEqual(rows('a', 'b')) - expect(headerRows()).toEqual([]) - - await show(rows('x', 'y', 'a', 'b')) - - // RN Web's list draws by index: handing it the new rows would redraw a different set. - expect(history?.listRows).toEqual(rows('a', 'b')) - expect(headerRows()).toEqual([ - { id: 'x', index: 0 }, - { id: 'y', index: 1 } - ]) - // The list's first row is the third row of the chat, and is drawn as that. - history?.renderListRow({ item: { id: 'a' }, index: 0 }) - expect(renderRow).toHaveBeenLastCalledWith({ item: { id: 'a' }, index: 2 }) - }) - - it('gives the list every row again once the row it started at is gone', async () => { - await show(rows('a', 'b')) - await show(rows('x', 'a', 'b')) - - await show(rows('p', 'q')) - - expect(history?.listRows).toEqual(rows('p', 'q')) - expect(headerRows()).toEqual([]) - }) - - it('leaves a native list all its rows: it holds its own place', async () => { - platform.OS = 'ios' - await show(rows('a', 'b')) - const all = rows('x', 'y', 'a', 'b') - - await show(all) - - expect(history?.listRows).toBe(all) - expect(history?.renderListRow).toBe(renderRow) - expect(headerRows()).toEqual([]) - }) -}) diff --git a/mobile/src/session/use-mobile-native-chat-page-history.tsx b/mobile/src/session/use-mobile-native-chat-page-history.tsx deleted file mode 100644 index e1102161d99..00000000000 --- a/mobile/src/session/use-mobile-native-chat-page-history.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import { Fragment, useCallback, useMemo, useState, type ReactElement } from 'react' -import { Platform } from 'react-native' - -const NO_ROWS: readonly never[] = [] - -type Row = { item: TItem; index: number } - -/** RN Web's list draws rows by index, so rows added at the front push the ones a reader is on out - * of what it draws. On the page, rows that arrive above the list's first row go in its header - * instead: the list's own rows keep their indexes and stay drawn. */ -export function useMobileNativeChatPageHistory( - rows: readonly TItem[], - renderRow: (row: Row) => ReactElement, - /** The chat shown: synthetic row ids (the streaming bubble, an echo) repeat across chats. */ - scopeKey: string -): { - listRows: readonly TItem[] - renderListRow: (row: Row) => ReactElement - /** The list header: what was given, then the history loaded above the list's own rows. */ - withHistory: (header: ReactElement | null) => ReactElement | null -} { - const onPage = Platform.OS === 'web' - // The row the list starts at, for as long as that row exists in this chat. - const [head, setHead] = useState<{ scopeKey: string; id: string } | null>(null) - const headId = head?.scopeKey === scopeKey ? head.id : null - const headIndex = useMemo( - () => (onPage && headId !== null ? rows.findIndex((row) => row.id === headId) : -1), - [onPage, headId, rows] - ) - const firstId = rows[0]?.id - if (onPage && headIndex === -1 && firstId !== undefined && firstId !== headId) { - setHead({ scopeKey, id: firstId }) - } - const historyCount = Math.max(headIndex, 0) - const historyRows = useMemo( - () => (historyCount === 0 ? NO_ROWS : rows.slice(0, historyCount)), - [historyCount, rows] - ) - const listRows = useMemo( - () => (historyCount === 0 ? rows : rows.slice(historyCount)), - [historyCount, rows] - ) - const renderOffsetRow = useCallback( - (row: Row) => renderRow({ item: row.item, index: row.index + historyCount }), - [renderRow, historyCount] - ) - const withHistory = useCallback( - (header: ReactElement | null) => - historyRows.length === 0 ? ( - header - ) : ( - <> - {header} - {historyRows.map((item, index) => ( - {renderRow({ item, index })} - ))} - - ), - [historyRows, renderRow] - ) - return { - listRows, - renderListRow: historyCount === 0 ? renderRow : renderOffsetRow, - withHistory - } -} diff --git a/mobile/src/session/use-mobile-native-chat-page-render.ts b/mobile/src/session/use-mobile-native-chat-page-render.ts new file mode 100644 index 00000000000..ea46c77b649 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-page-render.ts @@ -0,0 +1,18 @@ +import { useCallback, useRef, useSyncExternalStore } from 'react' + +/** Renders the caller again from any event source (a scroll, the observer, a timer) synchronously, + * so a window change is in the document before the browser paints. */ +export function useMobileNativeChatPageRender(): () => void { + const store = useRef({ version: 0, notify: () => {} }) + const subscribe = useCallback((notify: () => void) => { + store.current.notify = notify + return () => { + store.current.notify = () => {} + } + }, []) + useSyncExternalStore(subscribe, () => store.current.version) + return useCallback(() => { + store.current.version += 1 + store.current.notify() + }, []) +} diff --git a/mobile/src/session/use-mobile-native-chat-page-window.ts b/mobile/src/session/use-mobile-native-chat-page-window.ts new file mode 100644 index 00000000000..d07064e7e29 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-page-window.ts @@ -0,0 +1,342 @@ +import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react' +import { MobileNativeChatPageHeights } from './mobile-native-chat-page-heights' +import { MobileNativeChatPageAnchoring } from './mobile-native-chat-page-anchoring' +import { + MobileNativeChatPageMotion, + pageKeepsPlace, + scrollWritesStopMomentum +} from './mobile-native-chat-page-motion' +import { MobileNativeChatPageRows } from './mobile-native-chat-page-rows' +import { coverMobileNativeChatWindow } from './mobile-native-chat-page-window-coverage' +import type { + MobileNativeChatWindowDrawn as Drawn, + MobileNativeChatWindowGroup as Group, + MobileNativeChatPageWindowArgs, + MobileNativeChatWindowScroller, + MobileNativeChatWindowSeat as Win +} from './mobile-native-chat-page-window-state' +import { useMobileNativeChatPageRender } from './use-mobile-native-chat-page-render' + +/** Rows measured in one settled moment: few enough not to drop a frame. */ +const MEASURE_ROWS = 8 +/** Between measuring rounds, so a streamed reply still gets frames while rows are measured. */ +const MEASURE_GAP_MS = 32 +/** A parked reader's unmeasured rows are measured only within this many screens above them. */ +const MEASURE_REACH = 3 +/** While following, every measured row is checked for a change at most this often. */ +const STALE_SCAN_MS = 1_000 +/** While following, measuring waits until a streaming reply pauses this long, so its updates keep + * the frames. */ +const STREAM_PAUSE_MS = 300 + +/** The page's rows near the screen, keyed. Above the window the spacer stands only for measured rows: + * rows never measured are left out, and are measured in place only while nothing can move the + * reader (following, or settled), because iOS stops momentum on any scroll write. */ +export function useMobileNativeChatPageWindow(args: MobileNativeChatPageWindowArgs) { + const { rows, rowKeys, rowState, following, isFollowing, layoutKey, scroller, roots } = args + const render = useMobileNativeChatPageRender() + const [heights] = useState(() => new MobileNativeChatPageHeights()) + const w = useRef(null) + const drawn = useRef(null) + const settle = useRef(false) + const [drawnRows] = useState(() => new MobileNativeChatPageRows()) + const anchorStale = useRef(false) + const width = useRef(0) + const viewport = useRef(globalThis.innerHeight || 800) + const lastScan = useRef(0) + const streamedAt = useRef(0) + const onSettled = useRef(() => {}) + const [motion] = useState(() => new MobileNativeChatPageMotion(() => onSettled.current())) + const [anchoring] = useState(() => new MobileNativeChatPageAnchoring(scroller, isFollowing)) + const { keys, changed, indexOf } = useMemo( + () => heights.sourceKeys(rows, rowKeys.keyOf, drawn.current?.keys), + [rows, rowKeys.keyOf, heights] + ) + const n = keys.length + const at = (key: string, fallback: number): number => + keys[fallback] === key + ? fallback + : (indexOf(rowKeys.current(key)) ?? Math.max(0, Math.min(fallback, n - 1))) + const seatAtEnd = (): Win => { + let first = n + for (let filled = 0; first > 0 && filled < viewport.current; first -= 1) { + filled += heights.px(keys[first - 1]) ?? heights.estimate() + } + const seat = { first: keys[first] ?? '', firstAt: first, last: null, lastAt: n - 1 } + return { scope: rowKeys.scope, ...seat, slack: 0, measure: null } + } + + const kept = w.current?.scope === rowKeys.scope && w.current.first !== '' ? w.current : null + let win = kept ?? seatAtEnd() + let end = win.last === null ? n : at(win.last, win.lastAt) + 1 + if (following && win.last !== null) { + win = end >= n ? { ...win, last: null } : seatAtEnd() + end = n + } + const start = Math.min(at(win.first, win.firstAt), Math.max(0, end - 1)) + end = Math.min(n, Math.max(end, start + 1)) + + const d = drawn.current + const sourceChanged = d !== null && d.sourceHead !== rowKeys.sourceHead + let slack = following || !kept || settle.current ? 0 : win.slack + if (d && sourceChanged && !following && kept && d.keys !== keys) { + // A cap trim takes rows from the top; what was drawn of them stays as space until settled. + for (let i = d.first; i < d.end && indexOf(rowKeys.current(d.keys[i]!)) === undefined; i += 1) { + slack += heights.px(d.keys[i]) ?? 0 + } + } + // A source change keeps no measuring rows: the place-hold owns that commit's correction. + let [m0, m1] = [start, start] + const group = sourceChanged || !win.measure ? null : win.measure + if (group) { + const [from, to] = [at(group.from, group.fromAt), at(group.to, group.toAt) + 1] + if (from < to && to <= start) { + ;[m0, m1] = [from, to] + } + } + const { first, above, mid, bottom } = heights.spacers(keys, [m0, m1], [start, end], slack) + const measuring = n > 0 && Math.min(m0, start) > 0 && heights.px(keys[0]) === undefined + + const write = useCallback( + (s: MobileNativeChatWindowScroller, to: number) => { + const max = s.scrollHeight - s.clientHeight + const target = Math.max(0, Math.min(to, max)) + // Above the top only while iOS bounces there. Past the end may be a clamp the browser hasn't + // applied yet; every write here waits for rest, so it isn't a bounce. + if (s.scrollTop >= 0 && Math.abs(target - s.scrollTop) >= 0.5) { + motion.wrote(target) + s.scrollTop = target + } + }, + [motion] + ) + + const takeAnchor = useCallback( + (s: MobileNativeChatWindowScroller) => { + // Elsewhere the browser's anchoring keeps a parked place and the end pin a following one. + if (!pageKeepsPlace(s.scrollTop)) { + drawnRows.forgetAnchor() + return + } + const at = drawn.current + // A following reader is held by the end pin: the row's own top is enough there. + drawnRows.takeAnchor(s, at?.keys ?? [], [at?.start ?? 0, at?.end ?? 0], !at?.following) + }, + [drawnRows] + ) + + const cover = useCallback( + (s: MobileNativeChatWindowScroller) => { + const [at, cur] = [drawn.current, w.current] + const firstNode = at && drawnRows.node(at.keys[at.start]) + // Before the observer reports every drawn row, a guess would move the window off the reader. + const unmeasured = + at && at.keys.slice(at.start, at.end).some((key) => heights.px(key) === undefined) + if (!at || !cur || !firstNode || unmeasured) { + return + } + const next = coverMobileNativeChatWindow( + { + n: at.keys.length, + start: at.start, + end: at.end, + // Drawn to the end only while it is near: a jump away is drawn where it lands, not to the end. + following: at.following && s.scrollTop >= s.scrollHeight - 2 * s.clientHeight, + windowTop: + firstNode.getBoundingClientRect().top - s.getBoundingClientRect().top + s.scrollTop, + scrollTop: s.scrollTop, + viewport: s.clientHeight + }, + (i) => heights.px(at.keys[i]), + heights.estimate() + ) + if (next) { + const last = next.end >= at.keys.length ? null : at.keys[next.end - 1]! + const measure = next.start === at.start ? cur.measure : null + const moved = { first: at.keys[next.start]!, firstAt: next.start, last } + w.current = { ...cur, ...moved, lastAt: next.end - 1, measure } + render() + } + }, + [heights, render, drawnRows] + ) + + // Owed work that can move rows above the reader, done only while settled: measure the nearest rows + // that are unmeasured or may have changed, and give back trimmed space. + const measureOwed = useCallback(() => { + const [at, cur] = [drawn.current, w.current] + if (!at || !cur || at.keys.length === 0) { + return + } + const layout = `${at.layoutKey}:${width.current}` + // A drawn group is measured before paint; choosing again first would take it down unmeasured. + if (cur.measure && heights.px(cur.measure.to) === undefined) { + return + } + const streaming = STREAM_PAUSE_MS - (Date.now() - streamedAt.current) + if (at.following && streaming > 0) { + motion.pokeIn(streaming) + return + } + const since = Date.now() - lastScan.current + const full = at.following && at.first === 0 && since >= STALE_SCAN_MS + const run = heights.next(at, layout, MEASURE_REACH * viewport.current, MEASURE_ROWS, full) + if (full) { + lastScan.current = Date.now() + } else if (!run && at.following && at.first === 0) { + motion.pokeIn(STALE_SCAN_MS - since) + } + const measure: Group | null = run && { + from: at.keys[run[0]]!, + fromAt: run[0], + to: at.keys[run[1]]!, + toAt: run[1] + } + if (measure?.from !== cur.measure?.from || measure?.to !== cur.measure?.to || cur.slack > 0) { + w.current = { ...cur, measure } + settle.current = true + motion.quiet(MEASURE_GAP_MS) + render() + } + }, [heights, motion, render]) + + useLayoutEffect(() => { + onSettled.current = measureOwed + if (w.current && w.current.scope !== rowKeys.scope) { + heights.clear() + motion.release() + drawnRows.forgetAnchor() + } + if (changed && drawn.current?.keys !== keys) { + heights.retain(keys) + } + const last = end >= n ? null : keys[end - 1]! + const m = m0 < m1 ? { from: keys[m0]!, fromAt: m0, to: keys[m1 - 1]!, toAt: m1 - 1 } : null + const seat = { first: keys[start] ?? '', firstAt: start, last, lastAt: end - 1 } + w.current = { ...win, ...seat, slack, measure: m } + if (drawn.current && drawn.current.rows !== rows && following) { + streamedAt.current = Date.now() + } + const fresh = { keys, rows, indexOf, rowState, start, end, first, following, layoutKey } + drawn.current = { ...fresh, sourceHead: rowKeys.sourceHead } + settle.current = false + anchoring.committed() + const s = scroller() + if (s && anchorStale.current) { + anchorStale.current = false + takeAnchor(s) + } + motion.laidOut([n, m0, m1, start, end, above, mid, bottom, layoutKey].join(':')) + if (n > 0) { + motion.poke() + } + }) + + useLayoutEffect(() => { + const s = scroller() + if (!s) { + return + } + const scrolled = () => { + // A write's or layout's event can arrive after rows above changed and before the correction: + // taking the place or covering then would keep the moved position and draw what moved it. + const max = s.scrollHeight - s.clientHeight + const { by } = motion.scrolled(s.scrollTop, max, () => drawnRows.placeMoved(s)) + if (by === 'reader') { + takeAnchor(s) + } + // Where the browser keeps the place, the page writes no correction to wait for. + if (by === 'reader' || (by === 'layout' && !scrollWritesStopMomentum())) { + cover(s) + } + } + const detach = motion.attach(s) + s.addEventListener('scroll', scrolled, { passive: true }) + return () => { + detach() + s.removeEventListener('scroll', scrolled) + motion.release() + } + }, [scroller, motion, takeAnchor, cover, drawnRows, anchoring, roots]) + + /** The one observer's entries: rows measured, then, for a parked reader (on WebKit at rest), + * whatever moved the row they were reading goes back (a measured row, the header, given-back + * space). */ + const afterResize = useCallback( + (entries: readonly ResizeObserverEntry[], pin: () => void) => { + const [s, at] = [scroller(), drawn.current] + if (!s || !at) { + return pin() + } + viewport.current = s.clientHeight + width.current = s.clientWidth + const layout = `${at.layoutKey}:${width.current}` + for (const entry of entries) { + const key = drawnRows.keyOf(entry.target) + const i = at.indexOf(key) + if (key !== undefined && i !== undefined) { + const row = at.rows[i]! + heights.remember(key, entry.contentRect.height, row, at.rowState(i, row), layout) + } + } + // As of the layout, before a move no scroll event has reported yet is counted. + roots?.restoreAll() + const own = !at.following && motion.mayCorrect() && pageKeepsPlace(s.scrollTop) + const shift = own ? drawnRows.placeShift(s) : null + const max = s.scrollHeight - s.clientHeight + const { by, moved } = motion.reconciled(s.scrollTop, max) + if (shift !== null) { + // Rows above moved by `shift`: put that back, keep the reader's own move, undo a clamp. + write(s, s.scrollTop - (by === 'layout' ? moved : 0) + shift) + } + // After the reader's unreported move is counted (it may end following), so the pin can't undo it. + pin() + // The place is where the reader is now: a correction that couldn't land, or a move while + // they scrolled, is not owed later. + takeAnchor(s) + cover(s) + motion.poke() + }, + [scroller, heights, motion, write, cover, drawnRows, takeAnchor, roots] + ) + + /** The place-hold's correction, when one may be written: on WebKit only at rest (mid-motion the + * reader already saw the move, and putting it back at rest would be a second jump), except a + * regroup, which resizes drawn rows at once and nothing redoes later. */ + const keepInPlace = useCallback( + (drift: number, regrouped: boolean) => { + const s = scroller() + if (s && !drawn.current?.following && (regrouped || motion.mayCorrect())) { + write(s, s.scrollTop + drift) + anchorStale.current = true + } + }, + [scroller, motion, write] + ) + + // Stable, so the list's handle (and with it the one observer) is made once. + const list = useMemo( + () => ({ + afterResize, + keepInPlace, + observeWith: (observer: ResizeObserver | null) => { + drawnRows.observeWith(observer) + roots?.observeWith(observer) + }, + settled: () => roots?.settled(motion) ?? motion.whenSettled(), + syncAnchoring: () => anchoring.sync(), + holdPlace: (on: boolean) => anchoring.hold(on), + watchMotion: (fn: typeof motion.onMotion) => { + motion.onMotion = fn + }, + wrote: (target: number) => motion.wrote(target) + }), + [afterResize, keepInPlace, drawnRows, motion, anchoring, roots] + ) + + return { + view: { m0, m1, start, end, above, mid, bottom, measuring }, + rowRef: drawnRows.ref, + list + } +} diff --git a/mobile/src/session/use-mobile-native-chat-row-renderer.tsx b/mobile/src/session/use-mobile-native-chat-row-renderer.tsx index 5618cfa9060..51def9c7ee4 100644 --- a/mobile/src/session/use-mobile-native-chat-row-renderer.tsx +++ b/mobile/src/session/use-mobile-native-chat-row-renderer.tsx @@ -10,6 +10,8 @@ import type { useMobileNativeChatTurnDisclosure } from './use-mobile-native-chat /** Desktop parity: `NativeChatTranscriptRow`'s context. */ export function useMobileNativeChatRowRenderer({ + rowKeyOf, + runOwners, toolsExpanded, taskListPredecessors, fontScale, @@ -21,6 +23,9 @@ export function useMobileNativeChatRowRenderer({ receipts, actionablePromptIds }: { + /** The row's place on the page, kept while older messages join it. */ + rowKeyOf: (message: NativeChatMessage) => string + runOwners: ReadonlyMap toolsExpanded: boolean /** The last plan each task-list tool wrote before each row, by row id. */ taskListPredecessors: ReadonlyMap @@ -41,6 +46,7 @@ export function useMobileNativeChatRowRenderer({ ({ item, index }) => ( ), [ + rowKeyOf, + runOwners, toolsExpanded, taskListPredecessors, fontScale, diff --git a/mobile/src/session/use-mobile-native-chat-session-tool-result-paging.test.tsx b/mobile/src/session/use-mobile-native-chat-session-tool-result-paging.test.tsx index f876ee85aa4..5392718155d 100644 --- a/mobile/src/session/use-mobile-native-chat-session-tool-result-paging.test.tsx +++ b/mobile/src/session/use-mobile-native-chat-session-tool-result-paging.test.tsx @@ -147,12 +147,16 @@ describe('mobile transcript tool result paging', () => { current().loadEarlier() await Promise.resolve() }) - expect(sendRequest).toHaveBeenCalledWith('nativeChat.readSession', { - agent: 'claude', - sessionId: 'remote-session', - limit: hasCursor ? 60 : 100, - ...(hasCursor ? { beforeOffset: 700 } : {}) - }) + expect(sendRequest).toHaveBeenCalledWith( + 'nativeChat.readSession', + { + agent: 'claude', + sessionId: 'remote-session', + limit: hasCursor ? 60 : 100, + ...(hasCursor ? { beforeOffset: 700 } : {}) + }, + expect.objectContaining({ budgetSpansConnect: true }) + ) expect(current().messages.map((row) => row.id)).toEqual([ 'prompt', 'older-calls', diff --git a/mobile/src/session/use-mobile-native-chat-session.test.ts b/mobile/src/session/use-mobile-native-chat-session.test.ts index 19ff76a6df8..da787a91d4a 100644 --- a/mobile/src/session/use-mobile-native-chat-session.test.ts +++ b/mobile/src/session/use-mobile-native-chat-session.test.ts @@ -3,6 +3,7 @@ import { act, create, type ReactTestRenderer } from 'react-test-renderer' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { NativeChatMessage } from '../../../src/shared/native-chat-types' import type { RpcClient } from '../transport/rpc-client' +import { createFakeRpcClient, rpcSuccess } from '../mobile-web-shell/bridge-host-test-fakes' import { useMobileNativeChatSession, type MobileNativeChatSession @@ -49,6 +50,36 @@ describe('useMobileNativeChatSession', () => { }) } + it.each([true, false])( + 'preserves only post-response live changes while history waits (overlap: %s)', + async (overlap) => { + const cached = message('cached') + const partial = message('reply') + const history = { ...cached, blocks: [{ type: 'text' as const, text: 'fresh history' }] } + const final = { ...partial, blocks: [{ type: 'text' as const, text: 'complete' }] } + const response = [message('older'), history, partial, ...(overlap ? [] : [message('absent')])] + const subscribe: RpcClient['subscribe'] = (_method, _params, onData) => { + emit = onData + onData({ type: 'snapshot', messages: [cached, partial], hasMore: true }) + return () => {} + } + const sendRequest = async () => rpcSuccess('older', { messages: response }) + let land = () => {} + const settled = new Promise((resolve) => { + land = resolve + }) + await mount({ ...createFakeRpcClient(), subscribe, sendRequest }) + await act(async () => { + state?.loadEarlier(() => settled) + }) + await act(async () => emit({ type: 'appended', messages: [final, message('suffix')] })) + await act(async () => land()) + expect(state?.messages.find((m) => m.id === 'reply')).toEqual(final) + expect(state?.messages.find((m) => m.id === 'cached')).toEqual(history) + expect(state?.messages.map((m) => m.id)).toEqual(response.map((m) => m.id).concat('suffix')) + } + ) + it('drops an older-page response captured before transcript replacement', async () => { let resolveEarlier: (response: unknown) => void = () => {} const sendRequest = vi.fn( @@ -157,12 +188,11 @@ describe('useMobileNativeChatSession', () => { }) expect(sendRequest).toHaveBeenCalledTimes(requestsAtCap + 1) - expect(sendRequest).toHaveBeenLastCalledWith('nativeChat.readSession', { - agent: 'claude', - sessionId: 'session', - limit: 60, - beforeOffset: 500 - }) + expect(sendRequest).toHaveBeenLastCalledWith( + 'nativeChat.readSession', + { agent: 'claude', sessionId: 'session', limit: 60, beforeOffset: 500 }, + expect.objectContaining({ budgetSpansConnect: true }) + ) } ) @@ -293,11 +323,11 @@ describe('useMobileNativeChatSession', () => { ) act(() => state?.loadEarlier()) - expect(sendRequest).toHaveBeenCalledWith('nativeChat.readSession', { - agent: 'claude', - sessionId: 'session', - limit: 100 - }) + expect(sendRequest).toHaveBeenCalledWith( + 'nativeChat.readSession', + { agent: 'claude', sessionId: 'session', limit: 100 }, + expect.objectContaining({ budgetSpansConnect: true }) + ) }) it('clears hasMore when a replaced window is shorter than the initial page', async () => { @@ -354,6 +384,44 @@ describe('useMobileNativeChatSession', () => { expect(state?.loadingEarlier).toBe(false) }) + it('lands a page across a same-cursor replay, and flags a refused page until the next tap', async () => { + const pages: ((response: unknown) => void)[] = [] + const sendRequest = vi.fn(() => new Promise((resolve) => pages.push(resolve))) + const window = Array.from({ length: 40 }, (_unused, index) => message(`win-${index}`)) + const snapshot = { type: 'snapshot', messages: window, hasMore: true, beforeOffset: 100 } + const subscribe: RpcClient['subscribe'] = vi.fn((_method, _params, onData) => { + emit = onData + onData(snapshot) + return () => {} + }) + await mount({ sendRequest, subscribe } as unknown as RpcClient) + const answer = (response: unknown) => + act(async () => { + pages.at(-1)?.(response) + await Promise.resolve() + }) + + // A reconnect re-subscribes and replays the same newest window while the page is out. + act(() => state?.loadEarlier()) + await act(async () => emit(snapshot)) + await answer({ + ok: true, + result: { messages: [message('older')], hasMore: true, beforeOffset: 40 } + }) + expect(state?.messages.map((entry) => entry.id)).toEqual([ + 'older', + ...window.map((entry) => entry.id) + ]) + + act(() => state?.loadEarlier()) + await answer({ id: 'x', ok: false, error: { code: 'refused', message: 'refused' } }) + expect(state?.olderPages).toBe('failed') + expect(state?.loadingEarlier).toBe(false) + act(() => state?.loadEarlier()) + expect(sendRequest).toHaveBeenCalledTimes(3) + expect(state?.olderPages).toBe('loading') + }) + it('keeps the base snapshot authoritative when a live append arrives first', async () => { const sendRequest = vi.fn() const subscribe: RpcClient['subscribe'] = vi.fn((_method, _params, onData) => { @@ -432,11 +500,11 @@ describe('useMobileNativeChatSession', () => { act(() => state?.loadEarlier()) // 70 rows held plus one page: a read of 100 would come back shorter than the list it replaces. - expect(sendRequest).toHaveBeenLastCalledWith('nativeChat.readSession', { - agent: 'claude', - sessionId: 'session', - limit: 130 - }) + expect(sendRequest).toHaveBeenLastCalledWith( + 'nativeChat.readSession', + { agent: 'claude', sessionId: 'session', limit: 130 }, + expect.objectContaining({ budgetSpansConnect: true }) + ) }) }) diff --git a/mobile/src/session/use-mobile-native-chat-session.ts b/mobile/src/session/use-mobile-native-chat-session.ts index f0017a99ae1..e1936514b4d 100644 --- a/mobile/src/session/use-mobile-native-chat-session.ts +++ b/mobile/src/session/use-mobile-native-chat-session.ts @@ -3,7 +3,11 @@ import { createNativeChatTranscriptRetention, encodeNativeChatTranscriptIdentity } from '../../../src/shared/native-chat-transcript-retention' -import { createNativeChatMerger, replaceList } from '../../../src/shared/native-chat-merge' +import { + createNativeChatMerger, + mergeNativeChatMessages, + replaceList +} from '../../../src/shared/native-chat-merge' import type { NativeChatMessage } from '../../../src/shared/native-chat-types' import { buildNativeChatSubscriptionId } from '../../../src/shared/native-chat-stream-unsubscribe' import type { RpcClient } from '../transport/rpc-client' @@ -13,6 +17,7 @@ import { type MobileNativeChatStreamFrame } from './mobile-native-chat-stream-frame' import { randomV4Uuid } from '../platform/random-v4-uuid' +import { useMobileOlderPageLoader, type OlderPagePhase } from './use-mobile-older-page-loader' export type MobileNativeChatStatus = | 'idle' @@ -40,10 +45,13 @@ export type MobileNativeChatSession = { readFailedFinally?: boolean /** True when an older page may exist (the last read filled the window). */ hasMore: boolean - /** Whether an older-history page is currently loading. */ + /** `olderPages === 'loading'`, kept because recorded RPC fixtures read it. */ loadingEarlier: boolean + /** The Load earlier control's state: reading now, or how the last read ended. */ + olderPages?: OlderPagePhase /** Grow the window to page in older history. */ - loadEarlier: () => void + /** `settled`: the page waits for it before it lands (the phone page, until the reader stops). */ + loadEarlier: (settled?: () => Promise) => void } // Small first page for a fast first paint; grows by a page as the user scrolls. @@ -51,6 +59,17 @@ const INITIAL_LIMIT = 40 const PAGE = 60 const MAX_MESSAGES = 2000 +/** The next older page, or null once the window holds the most it may. */ +function nextPageWindow( + limit: number, + listLength: number +): { nextLimit: number; pageLimit: number } | null { + // Live output grows the list past the last read, so the next page counts from what is held. + const held = Math.max(limit, listLength) + const nextLimit = Math.min(held + PAGE, MAX_MESSAGES) + return nextLimit > held ? { nextLimit, pageLimit: nextLimit - held } : null +} + type ReadSessionResult = | { messages: NativeChatMessage[]; hasMore?: boolean; beforeOffset?: number } | { error: string } @@ -102,8 +121,7 @@ export function useMobileNativeChatSession(args: { const status = settled ? settled.status : initialStatus const [error, setError] = useState(undefined) const [hasMore, setHasMore] = useState(false) - const [loadingEarlier, setLoadingEarlier] = useState(false) - const loadingEarlierRef = useRef(false) + const { phase: olderPages, load: loadOlderPages, supersede } = useMobileOlderPageLoader() const beforeOffsetRef = useRef(null) // Stateful id-dedup merger: caches the id→index map so each live append frame // costs O(incoming), not O(existing+incoming) (#18). `replaceList` resets the @@ -113,7 +131,6 @@ export function useMobileNativeChatSession(args: { // Tracks the live session so a late loadEarlier resolve can detect a swap. const sessionIdRef = useRef(sessionId) sessionIdRef.current = sessionId - const streamGenerationRef = useRef(0) // Whether this subscription already delivered its base snapshot; later // snapshots on the same subscription are reconnect replays, not fresh bases. const snapshotSeenRef = useRef(false) @@ -136,11 +153,9 @@ export function useMobileNativeChatSession(args: { let cancelled = false // Why: disconnect/agent/session loss must invalidate a page request before // the early idle/waiting return can clear the visible source. - streamGenerationRef.current += 1 + supersede() limitRef.current = INITIAL_LIMIT - loadingEarlierRef.current = false snapshotSeenRef.current = false - setLoadingEarlier(false) setList([]) setError(undefined) setHasMore(false) @@ -188,12 +203,14 @@ export function useMobileNativeChatSession(args: { // follows is still this subscription's base, not a reconnect replay. snapshotSeenRef.current = true } - if (applied.windowReplaced || frame.type === 'snapshot') { - // Why: any authoritative window (and any replay merge) invalidates an - // in-flight older-page request; stale results must not land on it. - streamGenerationRef.current += 1 - loadingEarlierRef.current = false - setLoadingEarlier(false) + if ( + applied.windowReplaced || + (frame.type === 'snapshot' && beforeOffsetRef.current === null) + ) { + // Why: a replaced window fences an in-flight older page, and any replay fences a + // cursorless page, which replaces the list. A cursor page survives a contiguous + // replay; one that re-cut the cursor fails the page's own cursor check. + supersede() } if (applied.windowReplaced) { // Only a genuinely fresh window resets the grown read window — an @@ -212,9 +229,7 @@ export function useMobileNativeChatSession(args: { if (applied.cursorInvalidated) { // Fall back to a growing-tail read so history trimmed by live appends // cannot leave a gap between the retained window and the old cursor. - streamGenerationRef.current += 1 - loadingEarlierRef.current = false - setLoadingEarlier(false) + supersede() beforeOffsetRef.current = null } setRead({ client, identity, status: applied.pending ? 'awaiting-transcript' : 'ready' }) @@ -223,89 +238,92 @@ export function useMobileNativeChatSession(args: { return () => { cancelled = true + supersede() unsubscribe() } - }, [client, agent, sessionId, transcriptPath, identity, setList]) + }, [client, agent, sessionId, transcriptPath, identity, setList, supersede]) - const loadEarlier = useCallback(() => { - if (!client || !agent || !sessionId || loadingEarlierRef.current || !hasMore) { - return - } - // Capture the session this page belongs to; a swap underneath us must not - // apply this read's result onto the new session (mirrors desktop's guard). - const requestSessionId = sessionId - const requestGeneration = streamGenerationRef.current - // Live output grows the list past the last read, so the next page counts from what is held. - const held = Math.max(limitRef.current, mergerRef.current.list.length) - const nextLimit = Math.min(held + PAGE, MAX_MESSAGES) - const pageLimit = nextLimit - held - if (pageLimit <= 0) { - setHasMore(false) - return - } - const beforeOffset = beforeOffsetRef.current - loadingEarlierRef.current = true - setLoadingEarlier(true) - void (async () => { - try { - const response = await nativeChatSessionPageRead.request(client, { - agent, - sessionId, - limit: beforeOffset === null ? nextLimit : pageLimit, - ...(beforeOffset === null ? {} : { beforeOffset }), - ...(transcriptPath ? { transcriptPath } : {}) - }) + const loadEarlier = useCallback( + (settled?: () => Promise) => { + if (!client || !agent || !sessionId || !hasMore) { + return + } + // Capture the session this page belongs to; a swap underneath us must not + // apply this read's result onto the new session (mirrors desktop's guard). + const requestSessionId = sessionId + loadOlderPages(async ({ timeoutMs, isCurrent }) => { + const page = nextPageWindow(limitRef.current, mergerRef.current.list.length) + if (!page) { + setHasMore(false) + return 'end' + } + const { nextLimit, pageLimit } = page + const beforeOffset = beforeOffsetRef.current + const response = await nativeChatSessionPageRead.request( + client, + { + agent, + sessionId, + limit: beforeOffset === null ? nextLimit : pageLimit, + ...(beforeOffset === null ? {} : { beforeOffset }), + ...(transcriptPath ? { transcriptPath } : {}) + }, + { timeoutMs, budgetSpansConnect: true } + ) const accepted = nativeChatSessionPageRead.interpret(response) if (!accepted.accepted) { - return + return 'failed' } // The read is `z.unknown()` because the reply is a union, so an accepted success can still // carry no result at all, or null; `'error' in` throws on either. const payload = accepted.value if (payload === null || typeof payload !== 'object') { - return + return 'failed' } // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: main cast this payload unread; the reader hands back the same result. const result = payload as ReadSessionResult if ('error' in result) { - return + return 'failed' } - // Drop a stale resolve from a session that swapped underneath us. + const receivedLive = new Map(mergerRef.current.list.map((message) => [message.id, message])) + // The page lands once the reader stops scrolling: rows growing above them mid-fling would move + // what they read, and iOS stops the fling on the write that would hold it. + if (settled) { + await settled() + } + // Lands only on the window and cursor it was asked from; otherwise the window it + // read against is gone, and the reader pages again from the new one. if ( + !isCurrent() || sessionIdRef.current !== requestSessionId || - streamGenerationRef.current !== requestGeneration + beforeOffsetRef.current !== beforeOffset ) { - return + return 'end' } limitRef.current = nextLimit + let more: boolean if (beforeOffset !== null && result.beforeOffset != null) { beforeOffsetRef.current = result.beforeOffset setList([...result.messages, ...mergerRef.current.list]) - setHasMore( - nextLimit < MAX_MESSAGES && (result.hasMore ?? result.messages.length >= pageLimit) - ) + more = nextLimit < MAX_MESSAGES && (result.hasMore ?? result.messages.length >= pageLimit) } else { - // Older runtimes ignore the cursor and return the growing tail. - setList(result.messages) - setHasMore(result.messages.length >= nextLimit) + // Older runtimes ignore the cursor and return the growing tail; rows that streamed in while + // it waited stay. + const live = mergerRef.current.list + const after = live.findIndex((message) => message.id === result.messages.at(-1)?.id) + const landed = + after === -1 ? result.messages : [...result.messages, ...live.slice(after + 1)] + // Only updates accepted after receipt outrank history; unchanged cached rows may be stale. + const changed = live.filter((message) => receivedLive.get(message.id) !== message) + setList(mergeNativeChatMessages(landed, changed)) + more = result.messages.length >= nextLimit } - } catch { - // Nothing awaits this page, so a rejected request — a transport drop, or the client - // abandoning it at teardown — would otherwise reach the document as an unhandled - // rejection. Swallowed to match the operation's own skip policy: a page that never - // arrives leaves the window the subscription already delivered. - } finally { - // A late page from a prior tab must not unlock the current tab's request. - if ( - sessionIdRef.current === requestSessionId && - streamGenerationRef.current === requestGeneration - ) { - loadingEarlierRef.current = false - setLoadingEarlier(false) - } - } - })() - }, [client, agent, sessionId, transcriptPath, hasMore, setList]) + setHasMore(more) + return more ? 'drew' : 'end' + }) + }, + [client, agent, sessionId, transcriptPath, hasMore, setList, loadOlderPages] + ) // Held for any unsettled read, not just an in-flight one: a stream error or a // dropped client would otherwise trade the conversation for an error card. @@ -323,7 +341,8 @@ export function useMobileNativeChatSession(args: { transcriptLoading: status === 'loading' || status === 'awaiting-transcript', error, hasMore, - loadingEarlier, + loadingEarlier: olderPages === 'loading', + olderPages, loadEarlier } } diff --git a/mobile/src/session/use-mobile-native-chat-tail-follow.ts b/mobile/src/session/use-mobile-native-chat-tail-follow.ts index 92851ab9379..05e7fa561a3 100644 --- a/mobile/src/session/use-mobile-native-chat-tail-follow.ts +++ b/mobile/src/session/use-mobile-native-chat-tail-follow.ts @@ -1,10 +1,7 @@ import { useCallback, useEffect, useLayoutEffect, useRef, useState, type RefObject } from 'react' -import { - Platform, - type FlatList, - type NativeScrollEvent, - type NativeSyntheticEvent -} from 'react-native' +import { Platform, type NativeScrollEvent, type NativeSyntheticEvent } from 'react-native' +import type { MobileNativeChatScrollNode } from './MobileNativeChatScrollHold' +import type { MobileNativeChatPageObservation } from './mobile-native-chat-page-motion' /** Distance from the bottom, in points, still treated as "at the tail". */ const AT_TAIL_SLOP = 80 @@ -26,8 +23,23 @@ export function mobileNativeChatRowAnchor(hasHeader: boolean): { minIndexForVisi return hasHeader ? ANCHOR_BELOW_HEADER : ANCHOR_AT_FIRST_ROW } -/** The part of the page list's scrollable DOM node the pin reads. */ -type PageScroller = { scrollHeight: number } +/** The list's scrollable node on the page, as RN Web's ScrollView decorates it. */ +type PageScroller = Element & MobileNativeChatScrollNode & { getInnerViewNode: () => Element } + +/** RN Web reports content size a task after layout, so a frame can paint between growth and a pin + * driven by that report. An observer on the scroller and its content runs before that paint. */ +function observePageResize( + scroller: PageScroller | null | undefined, + onResize: (entries: readonly ResizeObserverEntry[]) => void +): ResizeObserver | null { + if (!scroller || typeof ResizeObserver === 'undefined') { + return null + } + const observer = new ResizeObserver(onResize) + observer.observe(scroller) + observer.observe(scroller.getInnerViewNode()) + return observer +} function distanceFromTail(event: NativeScrollEvent): number { const { contentOffset, contentSize, layoutMeasurement } = event @@ -38,14 +50,43 @@ function isAtTail(event: NativeScrollEvent): boolean { return distanceFromTail(event) <= AT_TAIL_SLOP } -export type MobileNativeChatTailFollow = { - /** Attach to the transcript list; the hook scrolls through this ref alone. */ - listRef: RefObject | null> +/** What the owner and the place-hold use of a transcript list: native's FlatList, or the page's. */ +export type MobileNativeChatListHandle = { + getScrollableNode: () => PageScroller | null | undefined + scrollToOffset: (params: { animated: boolean; offset: number }) => void + scrollToEnd: (params: { animated: boolean }) => void + /** Page only: each resize, with the observer's entries; runs the end `pin` once it has counted a + * move of the reader's no scroll event reported yet. */ + afterResize?: (entries: readonly ResizeObserverEntry[], pin: () => void) => void + /** Page only: the list's rows join the one observer, so each reports its own height. */ + observeWith?: (observer: ResizeObserver | null) => void + /** Page only: the place-hold's correction, written while the reader moves only for a regroup. */ + keepInPlace?: (drift: number, regrouped: boolean) => void + /** Page only: resolves once the reader stops moving the list, or the list goes away. */ + settled?: () => Promise + /** Page only: each classified move, before any resize correction or end pin. */ + watchMotion?: (fn: ((observation: MobileNativeChatPageObservation) => void) | null) => void + /** Page only: following changed, which decides whether the browser's anchoring may move it. */ + syncAnchoring?: () => void + /** Page only: the place-hold takes the scroll from the browser's anchoring for its correction. */ + holdPlace?: (on: boolean) => void +} + +export type MobileNativeChatTailFollow = { + /** Spread on the native list. `ref`: the hook scrolls through it alone. */ + listProps: { ref: (list: MobileNativeChatListHandle | null) => () => void } + /** Spread on the page list: the same `ref`, which also watches the list resize. */ + pageListProps: { + ref: (list: MobileNativeChatListHandle | null) => () => void + endAnchored: boolean + isFollowing: () => boolean + pin: () => void + } /** Render flag for the jump-to-latest control. */ showJumpToTail: boolean /** Passive maintenance: re-pin after the viewport resizes. */ pinToTail: () => void - /** Content-size maintenance using the list's authoritative measured height. */ + /** Content-size maintenance: native uses the measured height, the page its live end. */ pinToTailAfterContentResize: (_width: number, height: number) => void /** Explicit jump — send, or the jump-to-latest control. Resumes following. */ jumpToTail: () => void @@ -53,9 +94,15 @@ export type MobileNativeChatTailFollow = { endUserDrag: (event: NativeSyntheticEvent) => void beginMomentum: () => void endMomentum: (event: NativeSyntheticEvent) => void - /** Whether a scroll near the top is the reader's own, and so may page in older history. */ - readerIsScrolling: () => boolean + /** Whether a scroll near the top may page in older history: only a native list's own drag. The + * page loads it only from the "Load earlier" control, since a page landing in iOS's top bounce + * gets the place-hold's write while WebKit owns the scroll, and the list stops scrolling. */ + scrollMayLoadEarlier: () => boolean recordScrollMetrics: (event: NativeScrollEvent) => void + /** Page only: an older page waits for it, so it never lands under a reader who is scrolling. */ + pageSettled: (() => Promise) | undefined + /** Read at commit by the page's place-hold: the list, and whether the reader follows the end. */ + placeHold: { listRef: RefObject; isFollowing: () => boolean } } /** Sole owner of transcript scroll position. @@ -69,17 +116,17 @@ export type MobileNativeChatTailFollow = { * Intent (`following`) is kept separate from geometry (at-tail): a programmatic * scroll reports metrics like any other, so letting metrics decide intent let * the view argue with itself. Only the user's own gestures and explicit jumps - * move intent; metrics only decide where a *released* gesture leaves us. + * move native intent; metrics decide where a *released* gesture leaves us. On + * the page, a move toward the end resumes following; only reader input detaches it. */ -export function useMobileNativeChatTailFollow(args: { - /** The list's rows; an empty list is never scrolled. */ - items: readonly TItem[] - /** The chat shown; another one opens following, whatever the reader did in this one. */ +export function useMobileNativeChatTailFollow(args: { + /** Guards `scrollToEnd` against an empty list. */ + hasItems: boolean + /** The chat shown; another one opens following, whatever the reader did here. */ scopeKey: string -}): MobileNativeChatTailFollow { - const { scopeKey } = args - const hasItems = args.items.length > 0 - const listRef = useRef | null>(null) +}): MobileNativeChatTailFollow { + const { hasItems, scopeKey } = args + const listRef = useRef(null) const [following, setFollowingFlag] = useState(true) const [atTail, setAtTailFlag] = useState(true) // Event handlers read intent at event time, before a re-render lands. @@ -87,9 +134,6 @@ export function useMobileNativeChatTailFollow(args: { const atTailRef = useRef(true) const userScrollActiveRef = useRef(false) const userScrollSettleFrameRef = useRef(null) - // Page only: what the offsets last said, in place of the gestures RN Web does not report. - const pageOffsetRef = useRef(0) - const pageReaderMovedUpRef = useRef(false) const scopeKeyRef = useRef(scopeKey) // Single writer, so the event-time ref and the render flag cannot disagree. @@ -98,6 +142,8 @@ export function useMobileNativeChatTailFollow(args: { return } followingRef.current = next + // Before any write or commit: the browser's anchoring and the end pin never both hold the place. + listRef.current?.syncAnchoring?.() setFollowingFlag(next) }, []) @@ -109,30 +155,76 @@ export function useMobileNativeChatTailFollow(args: { setAtTailFlag(next) }, []) + // Live geometry: a size report arrives late and can name an end the view has already left. + // Not `scrollToEnd`: its estimate can land short, and the page reads a move up as the reader's. + const pinPageToEnd = useCallback(() => { + const scroller = listRef.current?.getScrollableNode() + if (!followingRef.current || !scroller) { + return + } + const end = scroller.scrollHeight - scroller.clientHeight + // Already there, or iOS bouncing past an end: a write then lands while WebKit owns the scroll. + if (scroller.scrollTop < 0 || scroller.scrollTop >= end - PAGE_MOVE_SLOP) { + return + } + listRef.current?.scrollToOffset({ animated: false, offset: end }) + }, []) + + const attachList = useCallback( + (list: MobileNativeChatListHandle | null) => { + listRef.current = list + const page = !listReportsGestures() + // Following comes only from classified motion: a resize can land a parked reader on a shorter + // end (a collapse below them), and that clamp is layout's, not the reader asking to follow. + const observer = page + ? observePageResize(list?.getScrollableNode(), (entries) => + list?.afterResize ? list.afterResize(entries, pinPageToEnd) : pinPageToEnd() + ) + : null + list?.observeWith?.(observer) + // Only a move toward the end attaches: a clamp onto a shorter end moves up, even one that + // lands under the reader's finger (a tap on a collapse below them). + list?.watchMotion?.(({ by, moved, fromEnd }) => { + if (followingRef.current) { + // Any upward move of the reader's that leaves the end detaches: a slow drag reports many + // 1px moves, and none of them alone is past a per-event threshold. + if (by === 'reader' && moved < 0 && fromEnd > PAGE_MOVE_SLOP) { + setFollowing(false) + } + } else if ( + (by === 'reader' && moved > 0 && fromEnd <= PAGE_MOVE_SLOP) || + (by !== 'own' && moved > PAGE_MOVE_SLOP && fromEnd <= AT_TAIL_SLOP) + ) { + setFollowing(true) + } + }) + return () => { + list?.watchMotion?.(null) + list?.observeWith?.(null) + observer?.disconnect() + listRef.current = null + } + }, + [pinPageToEnd, setFollowing] + ) + const pinToTail = useCallback(() => { - if (!followingRef.current || !hasItems) { - return - } - if (listReportsGestures()) { + if (!listReportsGestures()) { + pinPageToEnd() + } else if (followingRef.current && hasItems) { listRef.current?.scrollToEnd({ animated: false }) - return } - // Why not `scrollToEnd`: its estimate can land short, and the page reads a move up as the reader's. - // The live height: the last reported one can still be the previous chat's. - const scroller: PageScroller | null | undefined = listRef.current?.getScrollableNode() - if (scroller) { - listRef.current?.scrollToOffset({ animated: false, offset: scroller.scrollHeight }) - } - }, [hasItems]) + }, [hasItems, pinPageToEnd]) const pinToTailAfterContentResize = useCallback( (_width: number, height: number) => { - if (!followingRef.current || !hasItems) { - return + if (!listReportsGestures()) { + pinPageToEnd() + } else if (followingRef.current && hasItems) { + listRef.current?.scrollToOffset({ animated: false, offset: height }) } - listRef.current?.scrollToOffset({ animated: false, offset: height }) }, - [hasItems] + [hasItems, pinPageToEnd] ) const clearUserScrollSettle = useCallback(() => { @@ -144,24 +236,9 @@ export function useMobileNativeChatTailFollow(args: { const recordScrollMetrics = useCallback( (event: NativeScrollEvent) => { - const endsAtTail = isAtTail(event) - setAtTail(endsAtTail) - if (listReportsGestures()) { - return - } - const moved = event.contentOffset.y - pageOffsetRef.current - pageOffsetRef.current = event.contentOffset.y - pageReaderMovedUpRef.current = moved < -PAGE_MOVE_SLOP - if (followingRef.current) { - // A pin only moves toward the end and a clamp lands on it: up and short of it is the reader. - if (pageReaderMovedUpRef.current && distanceFromTail(event) > PAGE_MOVE_SLOP) { - setFollowing(false) - } - } else if (moved > PAGE_MOVE_SLOP && endsAtTail) { - setFollowing(true) - } + setAtTail(isAtTail(event)) }, - [setAtTail, setFollowing] + [setAtTail] ) const jumpToTail = useCallback(() => { @@ -221,25 +298,29 @@ export function useMobileNativeChatTailFollow(args: { [finishUserScroll] ) - const readerIsScrolling = useCallback( - () => (listReportsGestures() ? userScrollActiveRef.current : pageReaderMovedUpRef.current), + const scrollMayLoadEarlier = useCallback( + () => listReportsGestures() && userScrollActiveRef.current, [] ) - // Before paint, so the next chat never shows where the reader left the last one. + const isFollowing = useCallback(() => followingRef.current, []) + const pageSettled = useCallback(() => listRef.current?.settled?.() ?? Promise.resolve(), []) + + // The view stays mounted across chats: before paint, so the next one never shows where the reader + // left the last. useLayoutEffect(() => { - if (scopeKeyRef.current !== scopeKey) { - scopeKeyRef.current = scopeKey - pageOffsetRef.current = 0 - pageReaderMovedUpRef.current = false - jumpToTail() + if (scopeKeyRef.current === scopeKey) { + return } + scopeKeyRef.current = scopeKey + jumpToTail() }, [scopeKey, jumpToTail]) useEffect(() => clearUserScrollSettle, [clearUserScrollSettle]) return { - listRef, + listProps: { ref: attachList }, + pageListProps: { ref: attachList, endAnchored: following, isFollowing, pin: pinPageToEnd }, showJumpToTail: !following && !atTail, pinToTail, pinToTailAfterContentResize, @@ -248,7 +329,9 @@ export function useMobileNativeChatTailFollow(args: { endUserDrag, beginMomentum, endMomentum, - readerIsScrolling, - recordScrollMetrics + scrollMayLoadEarlier, + recordScrollMetrics, + pageSettled: listReportsGestures() ? undefined : pageSettled, + placeHold: { listRef, isFollowing } } } diff --git a/mobile/src/session/use-mobile-native-chat-turn-disclosure.ts b/mobile/src/session/use-mobile-native-chat-turn-disclosure.ts index 4509969d4a8..1fd78cd7cc9 100644 --- a/mobile/src/session/use-mobile-native-chat-turn-disclosure.ts +++ b/mobile/src/session/use-mobile-native-chat-turn-disclosure.ts @@ -15,6 +15,7 @@ import { nativeChatMessagesWaitingBehindLiveTurn } from '../../../src/shared/nat import { NATIVE_CHAT_STREAMING_ID } from '../../../src/shared/native-chat-streaming' import type { NativeChatResolvedPrompt } from '../../../src/shared/native-chat-resolution-receipt' import { useMobileNativeChatTranscriptRows } from './use-mobile-native-chat-transcript-rows' +import type { MobileNativeChatRunPresentation } from './mobile-native-chat-run-descriptors' import { nativeChatRowsInDrawOrder, nativeChatTurnBarRows @@ -132,6 +133,7 @@ export function useMobileNativeChatTurnDisclosure({ scopeKey: string }): { active: NativeChatTurnStatus | null + presentation: MobileNativeChatRunPresentation /** The live turn's provider activity copy, for the footer row. */ activeActivityText: string | null onToggleTurn: (turnKey: string) => void @@ -233,6 +235,10 @@ export function useMobileNativeChatTurnDisclosure({ expandedTurnKeys: expandedTurnIds, liveReasoningId }) + const presentation = useMemo( + () => ({ rows, workRuns, turnKeys, receipts }), + [rows, workRuns, turnKeys, receipts] + ) const latestAssistantId = useMemo( () => waiting.listMessages.findLast((row) => row.role === 'assistant')?.id ?? null, [waiting.listMessages] @@ -316,6 +322,7 @@ export function useMobileNativeChatTurnDisclosure({ return { active, + presentation, activeActivityText, /** Stable for a given chat scope, so it never disturbs a row's memo. */ onToggleTurn: toggleExpandedTurn, diff --git a/mobile/src/session/use-mobile-older-page-loader.ts b/mobile/src/session/use-mobile-older-page-loader.ts new file mode 100644 index 00000000000..b16b0f86ca8 --- /dev/null +++ b/mobile/src/session/use-mobile-older-page-loader.ts @@ -0,0 +1,78 @@ +import { useCallback, useRef, useState } from 'react' +import { RPC_REQUEST_MIN_ACK_MS } from '../transport/rpc-request-budget' + +/** One tap's whole wait, connect included, shared by every page it reads. */ +export const OLDER_PAGE_BUDGET_MS = 15_000 +/** Bounds the pages one tap reads past pages that draw nothing. */ +export const OLDER_PAGES_PER_TAP = 8 + +/** One page of a tap: it drew a row, drew nothing (read on), reached the end, failed in a way a + * retry can get past, or met history that can never be read from there. */ +export type OlderPageStep = 'drew' | 'drew-nothing' | 'end' | 'failed' | 'unavailable' + +/** What the Load earlier control shows. */ +export type OlderPagePhase = 'idle' | 'loading' | 'failed' | 'unavailable' + +/** One request of a tap. `timeoutMs` is what is left of the tap's budget, connect included. */ +export type OlderPageReader = (request: { + timeoutMs: number + isCurrent: () => boolean +}) => Promise + +/** + * The one owner of an older-page read for either chat lane. A read ends by its own settlement or by + * `supersede()`, and both clear the spinner here, so no path can strand it. + */ +export function useMobileOlderPageLoader(): { + phase: OlderPagePhase + /** Starts a tap's read unless one is running. */ + load: (reader: OlderPageReader) => void + /** The window the read was for is gone: ends it silently and clears the spinner and the line. */ + supersede: () => void +} { + const [phase, setPhase] = useState('idle') + // Only the running read's token may settle the phase, so a superseded read's late reply can't. + const tokenRef = useRef(null) + // History that can't be read stays so until the window is replaced, whatever re-triggers a read. + const unavailableRef = useRef(false) + + const load = useCallback((reader: OlderPageReader) => { + if (tokenRef.current || unavailableRef.current) { + return + } + const token = {} + tokenRef.current = token + setPhase('loading') + const isCurrent = (): boolean => tokenRef.current === token + const deadline = Date.now() + OLDER_PAGE_BUDGET_MS + void (async (): Promise => { + let step: OlderPageStep = 'drew-nothing' + for (let page = 0; page < OLDER_PAGES_PER_TAP && step === 'drew-nothing'; page += 1) { + const timeoutMs = deadline - Date.now() + // Too little left to be answered: what landed stays, and the next tap reads on. + if (!isCurrent() || timeoutMs < RPC_REQUEST_MIN_ACK_MS) { + return 'end' + } + step = await reader({ timeoutMs, isCurrent }) + } + return step + })() + // Contained here: nothing awaits this read, so a transport drop would be unhandled. + .catch((): OlderPageStep => 'failed') + .then((step) => { + if (isCurrent()) { + tokenRef.current = null + unavailableRef.current = step === 'unavailable' + setPhase(step === 'failed' || step === 'unavailable' ? step : 'idle') + } + }) + }, []) + + const supersede = useCallback(() => { + tokenRef.current = null + unavailableRef.current = false + setPhase('idle') + }, []) + + return { phase, load, supersede } +} diff --git a/mobile/src/session/use-mobile-structured-agent-session-prompt-cancel.test.tsx b/mobile/src/session/use-mobile-structured-agent-session-prompt-cancel.test.tsx index 9de2d488616..478a4d1e121 100644 --- a/mobile/src/session/use-mobile-structured-agent-session-prompt-cancel.test.tsx +++ b/mobile/src/session/use-mobile-structured-agent-session-prompt-cancel.test.tsx @@ -17,7 +17,7 @@ vi.mock('./use-mobile-structured-agent-state', () => ({ useMobileStructuredAgentState: () => ({ state, stateRef, - loadingOlder: false, + olderPages: 'idle', loadEarlier: vi.fn() }) })) diff --git a/mobile/src/session/use-mobile-structured-agent-session.ts b/mobile/src/session/use-mobile-structured-agent-session.ts index 66fd76fda00..5916715c6b7 100644 --- a/mobile/src/session/use-mobile-structured-agent-session.ts +++ b/mobile/src/session/use-mobile-structured-agent-session.ts @@ -76,7 +76,7 @@ export function useMobileStructuredAgentSession( // Against a host that predates the quiet repeated Stop, a Stop of a turn still being stopped joins it. const inFlightStopsRef = useRef(new Map>()) const stateArgs = { client, sessionId, sessionKey, enabled, connected } - const { state, stateRef, queuedMessages, queuePause, loadingOlder, loadEarlier } = + const { state, stateRef, queuedMessages, queuePause, olderPages, loadEarlier } = useMobileStructuredAgentState(stateArgs) const mutate = useMobileStructuredAgentMutate({ @@ -291,8 +291,10 @@ export function useMobileStructuredAgentSession( transcriptLoading: status === 'loading', error: state.error, readFailedFinally: status === 'error' && isFinalAgentSessionReadRefusal(state.readRefusal), - hasMore: state.hasOlder, - loadingEarlier: loadingOlder, + // History that can never be read from here ends paging; its line says so. + hasMore: state.hasOlder && olderPages !== 'unavailable', + loadingEarlier: olderPages === 'loading', + olderPages, loadEarlier }, isWorking, diff --git a/mobile/src/session/use-mobile-structured-agent-state.test.tsx b/mobile/src/session/use-mobile-structured-agent-state.test.tsx index fd319f12f48..d701c07465b 100644 --- a/mobile/src/session/use-mobile-structured-agent-state.test.tsx +++ b/mobile/src/session/use-mobile-structured-agent-state.test.tsx @@ -3,7 +3,8 @@ import { act, create, type ReactTestRenderer } from 'react-test-renderer' import { afterEach, describe, expect, it, vi } from 'vitest' import type { AgentJournalCursor, - AgentJournalRenderItem + AgentJournalRenderItem, + AgentJournalResetReason } from '../../../src/shared/agent-session-journal-types' import type { AgentSessionHistoryPage } from '../../../src/shared/agent-session-wire' import type { RpcClient } from '../transport/rpc-client' @@ -59,9 +60,10 @@ describe('useMobileStructuredAgentState older history', () => { }) /** Mounts the hook on a session whose newest rows are `newest` (by default one of the - * session's own, at 1000), with the host serving `older` by the cursor each read asks before. */ + * session's own, at 1000), with the host serving `older` by the cursor each read asks before: + * a page, a reset, or a thrown error. */ async function mountWithOlderPages( - older: Record, + older: Record, newest: AgentJournalRenderItem[] = [said(1_000)] ) { let onFrame: ((value: unknown) => void) | null = null @@ -71,10 +73,16 @@ describe('useMobileStructuredAgentState older history', () => { return { ok: true, result: {} } } historyCursors.push(params.cursor.sequence) - const olderPage = older[params.cursor.sequence] + const olderPage = older[params.cursor.sequence] ?? 'cursor_ahead' + if (olderPage instanceof Error) { + throw olderPage + } return { ok: true, - result: olderPage ? { ok: true, page: olderPage } : { ok: false, reset: 'cursor_ahead' } + result: + typeof olderPage === 'string' + ? { ok: false, reset: olderPage } + : { ok: true, page: olderPage } } }) const subscribe = vi.fn( @@ -155,8 +163,34 @@ describe('useMobileStructuredAgentState older history', () => { const { hook, historyCursors } = await mountWithOlderPages({}, range(800, 1_000, 'task-1')) await vi.waitFor(() => expect(historyCursors).toEqual([800])) - await vi.waitFor(() => expect(hook.current!.loadingOlder).toBe(false)) + await vi.waitFor(() => expect(hook.current!.olderPages).toBe('idle')) await new Promise((resolve) => setTimeout(resolve, 20)) expect(historyCursors).toEqual([800]) }) + + it('keeps the chat readable when an older page fails, retries on a tap, and ends at unreadable history', async () => { + const older: Parameters[0] = { 1000: new Error('timed out') } + const { hook } = await mountWithOlderPages(older) + + act(() => hook.current!.loadEarlier()) + await vi.waitFor(() => expect(hook.current!.olderPages).toBe('failed')) + expect(hook.current!.state.status).toBe('ready') + expect(hook.current!.state.items.map((item) => item.sequence)).toEqual([1_000]) + + older[1000] = page(range(900, 1_000), true) + older[900] = Object.assign(new Error('agent_session_journal_unreadable'), { + data: { + refusal: { code: 'agent_session_journal_unreadable', details: { reason: 'journalCorrupt' } } + } + }) + act(() => hook.current!.loadEarlier()) + await vi.waitFor(() => expect(hook.current!.state.items[0]?.sequence).toBe(900)) + expect(hook.current!.olderPages).toBe('idle') + + act(() => hook.current!.loadEarlier()) + await vi.waitFor(() => expect(hook.current!.olderPages).toBe('unavailable')) + expect(hook.current!.state.status).toBe('ready') + act(() => hook.current!.loadEarlier()) + expect(hook.current!.olderPages).toBe('unavailable') + }) }) diff --git a/mobile/src/session/use-mobile-structured-agent-state.ts b/mobile/src/session/use-mobile-structured-agent-state.ts index 208369c7ea4..0521ce41433 100644 --- a/mobile/src/session/use-mobile-structured-agent-state.ts +++ b/mobile/src/session/use-mobile-structured-agent-state.ts @@ -1,15 +1,14 @@ import { openTranscriptAfterHold } from './mobile-structured-transcript-subscription' import { startMobileStructuredTranscriptRead } from './mobile-structured-transcript-read' import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' -import type { AgentJournalCursor } from '../../../src/shared/agent-session-journal-types' import { isRootAgentJournalItem } from '../../../src/shared/agent-session-journal-producer' import type { - AgentSessionHistoryPage, AgentSessionHistoryResult, AgentSessionSubscribeEvent } 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' +import { isFinalAgentSessionReadRefusal } from '../../../src/shared/structured-agent-session-read-refusal' import { EMPTY_STRUCTURED_AGENT_SESSION, oldestStructuredAgentSessionCursor, @@ -29,6 +28,7 @@ import { type MobileQueuedMessageFeed, type MobileQueuePause } from './mobile-structured-queued-message-feed' +import { useMobileOlderPageLoader, type OlderPagePhase } from './use-mobile-older-page-loader' type QueuedFeed = { messages: MobileQueuedMessageFeed @@ -37,10 +37,6 @@ type QueuedFeed = { const NO_QUEUED_FEED: QueuedFeed = { messages: null, pause: null } const MAX_RETAINED_SESSION_STATES = 32 -/** Bounded so a busy stream cannot turn one Load-earlier tap into an endless read chain. */ -const OLDER_PAGE_ANCHOR_ATTEMPTS = 3 -/** Bounds the pages one Load-earlier reads past that hold only subagent rows. */ -const OLDER_PAGES_PER_LOAD = 8 export function useMobileStructuredAgentState(args: { client: RpcClient | null @@ -57,8 +53,9 @@ export function useMobileStructuredAgentState(args: { queuedMessages: MobileQueuedMessageFeed /** The whole queue's pause, published with the drafts. */ queuePause: MobileQueuePause - loadingOlder: boolean - loadEarlier: () => void + olderPages: OlderPagePhase + /** `settled`: the page waits for it before it lands (the phone page, until the reader stops). */ + loadEarlier: (settled?: () => Promise) => void } { const { client, connected, enabled, sessionId, sessionKey } = args // Keep a bounded cache so offline tab switches select the right transcript @@ -74,14 +71,11 @@ export function useMobileStructuredAgentState(args: { const queued = (enabled && sessionKey ? queuedBySession.get(sessionKey) : undefined) ?? NO_QUEUED_FEED const queuedMessages = queued.messages - const [loadingOlder, setLoadingOlder] = useState(false) + const { phase: olderPages, load: loadOlderPages, supersede } = useMobileOlderPageLoader() const stateRef = useRef(state) - const sessionKeyRef = useRef(sessionKey) - const streamGenerationRef = useRef(0) useLayoutEffect(() => { stateRef.current = state - sessionKeyRef.current = sessionKey - }, [sessionKey, state]) + }, [state]) const apply = useCallback( (action: StructuredAgentSessionAction) => { @@ -151,9 +145,7 @@ export function useMobileStructuredAgentState(args: { ) useEffect(() => { - streamGenerationRef.current += 1 - sessionKeyRef.current = sessionKey - setLoadingOlder(false) + supersede() if (!client || !sessionId || !enabled) { return } @@ -163,7 +155,7 @@ export function useMobileStructuredAgentState(args: { return } apply({ type: 'loading' }) - const generation = streamGenerationRef.current + let streamCurrent = true const holderId = structuredAgentSessionHolderId('mobile-chat') const held = callAgentSession(client, 'agentSession.hold', { sessionId, @@ -173,12 +165,23 @@ export function useMobileStructuredAgentState(args: { sessionId, open: (onFrame) => openTranscriptAfterHold(client, sessionId, held, onFrame), read: (request) => callAgentSession(client, 'agentSession.currentRequests', request), - apply, + apply: (action) => { + if ( + action.type === 'event' && + (action.event.type === 'snapshot' || action.event.type === 'reset') + ) { + // A replaced window ends an older read that was for the old one. + supersede() + } + apply(action) + }, applyQueued, error: applyReadFailure, - isCurrent: () => generation === streamGenerationRef.current + isCurrent: () => streamCurrent }) return () => { + streamCurrent = false + supersede() endStream() void held .then(() => @@ -195,86 +198,84 @@ export function useMobileStructuredAgentState(args: { ) .catch(() => undefined) } - }, [apply, applyQueued, applyReadFailure, client, connected, enabled, sessionId, sessionKey]) + }, [ + apply, + applyQueued, + applyReadFailure, + client, + connected, + enabled, + sessionId, + sessionKey, + supersede + ]) - const loadEarlier = useCallback(() => { - const current = stateRef.current - if (!client || !sessionId || !sessionKey || loadingOlder || !current.hasOlder) { - return - } - if (!oldestStructuredAgentSessionCursor(current)) { - return - } - const requestSessionKey = sessionKey - const requestGeneration = streamGenerationRef.current - const isCurrentRead = (): boolean => - sessionKeyRef.current === requestSessionKey && - streamGenerationRef.current === requestGeneration - setLoadingOlder(true) - void (async () => { - // A live batch can head-trim past the anchor mid-read, and the reducer drops that - // page rather than leave a hole in the transcript. Re-anchor and retry. - for (let attempt = 0; attempt < OLDER_PAGE_ANCHOR_ATTEMPTS; attempt += 1) { - const cursor = oldestStructuredAgentSessionCursor(stateRef.current) - if (!cursor || !isCurrentRead()) { - return + const loadEarlier = useCallback( + (settled?: () => Promise) => { + const start = oldestStructuredAgentSessionCursor(stateRef.current) + if (!client || !sessionId || !sessionKey || !stateRef.current.hasOlder || !start) { + return + } + let cursor = start + // Every head this tap produced. Any other head means a live batch trimmed the window mid-tap, + // and the reducer drops a page that no longer abuts it, so the read restarts from that head. + // Holding them all keeps the check right while `stateRef` lags a commit behind a landed page. + let heads = new Set([start.sequence]) + const intact = (): boolean => { + const head = oldestStructuredAgentSessionCursor(stateRef.current) + return head?.epoch === cursor.epoch && heads.has(head.sequence) + } + loadOlderPages(async ({ timeoutMs, isCurrent }) => { + if (!intact()) { + const head = oldestStructuredAgentSessionCursor(stateRef.current) + if (!head) { + return 'end' + } + cursor = head + heads = new Set([head.sequence]) } - // Mobile draws only the session's own rows, so a page of a subagent's rows alone - // would land as nothing; read on until a page adds a row the reader can see. Older - // hosts send one for any burst; current ones when the page's byte bound cuts it short. - const pages: { - requestedCursor: AgentJournalCursor - page: AgentSessionHistoryPage - }[] = [] - let requestedCursor = cursor - while (pages.length < OLDER_PAGES_PER_LOAD) { - const result = await callAgentSession( + let result: AgentSessionHistoryResult + try { + result = await callAgentSession( client, 'agentSession.history', - { - sessionId, - direction: 'before', - cursor: requestedCursor, - limit: AGENT_SESSION_HISTORY_MAX_LIMIT - } + { sessionId, direction: 'before', cursor, limit: AGENT_SESSION_HISTORY_MAX_LIMIT }, + timeoutMs ) - if (!result.ok || !isCurrentRead()) { - break - } - pages.push({ requestedCursor, page: result.page }) - if ( - !result.page.hasOlder || - result.page.items.length === 0 || - result.page.items.some(isRootAgentJournalItem) - ) { - break - } - requestedCursor = result.page.window.nextCursor + } catch (error) { + return isFinalAgentSessionReadRefusal(agentSessionReadFailureRefusal(error)) + ? 'unavailable' + : 'failed' } - if (pages.length === 0 || !isCurrentRead()) { - return + // Lands once the reader stops scrolling, so nothing grows above them mid-fling. + if (settled) { + await settled() } - // The reducer drops a page whose anchor slid, so only an intact anchor lands; - // each later page abuts the one before it. - if (oldestStructuredAgentSessionCursor(stateRef.current)?.sequence === cursor.sequence) { - for (const { requestedCursor: pageCursor, page } of pages) { - apply({ type: 'older-page', requestedCursor: pageCursor, page }) - } - return + if (!isCurrent()) { + return 'end' } - } - })() - .catch((error: unknown) => { - if (isCurrentRead()) { - applyReadFailure(error) + if (!result.ok) { + // Every reset reason makes the subscription reload a fresh window, which replaces this one. + return 'end' } + if (!intact()) { + return 'drew-nothing' + } + const { page } = result + apply({ type: 'older-page', requestedCursor: cursor, page }) + const oldest = page.items[0] + if (!page.hasOlder || !oldest) { + return 'end' + } + heads.add(oldest.sequence) + cursor = page.window.nextCursor + // Mobile draws only the session's own rows, so a page of a subagent's rows alone lands as + // nothing; the loader reads on until a page adds a row the reader can see. + return page.items.some(isRootAgentJournalItem) ? 'drew' : 'drew-nothing' }) - .finally(() => { - if (isCurrentRead()) { - setLoadingOlder(false) - } - }) - }, [apply, applyReadFailure, client, loadingOlder, sessionId, sessionKey]) + }, + [apply, client, loadOlderPages, sessionId, sessionKey] + ) // A window of only a subagent's rows draws nothing, and an empty list cannot be scrolled // to ask for more, so it reads back once from each such head. A first page can be one: an @@ -284,13 +285,18 @@ export function useMobileStructuredAgentState(args: { ? `${sessionKey}:${state.epoch}:${state.items[0]?.sequence}` : null const readBackFromRef = useRef(null) + const loadingEarlier = olderPages === 'loading' useEffect(() => { - if (drawsNothingFrom === null || loadingOlder || readBackFromRef.current === drawsNothingFrom) { + if ( + drawsNothingFrom === null || + loadingEarlier || + readBackFromRef.current === drawsNothingFrom + ) { return } readBackFromRef.current = drawsNothingFrom loadEarlier() - }, [drawsNothingFrom, loadEarlier, loadingOlder]) + }, [drawsNothingFrom, loadEarlier, loadingEarlier]) - return { state, stateRef, queuedMessages, queuePause: queued.pause, loadingOlder, loadEarlier } + return { state, stateRef, queuedMessages, queuePause: queued.pause, olderPages, loadEarlier } } diff --git a/mobile/src/terminal/terminal-web-document-mount.test.ts b/mobile/src/terminal/terminal-web-document-mount.test.ts index 98bef5fbbd7..08833bc9092 100644 --- a/mobile/src/terminal/terminal-web-document-mount.test.ts +++ b/mobile/src/terminal/terminal-web-document-mount.test.ts @@ -170,8 +170,12 @@ describe('the page keeps its own capture buffer', () => { const posted: Array> = [] const mounted = mountTerminalWebDocument(host, (message) => posted.push(message)) - window.dispatchEvent(new ErrorEvent('error', { message: 'first failure' })) - window.dispatchEvent(new ErrorEvent('error', { message: 'second failure' })) + window.dispatchEvent( + new ErrorEvent('error', { message: 'first failure', error: new Error('first failure') }) + ) + window.dispatchEvent( + new ErrorEvent('error', { message: 'second failure', error: new Error('second failure') }) + ) const messages = posted.filter((message) => message.type === 'error').map((m) => m.message) expect(messages[0]).toContain('captured: first failure') @@ -182,12 +186,27 @@ describe('the page keeps its own capture buffer', () => { const nextHost = plantHost() const nextPosted: Array> = [] const next = mountTerminalWebDocument(nextHost, (message) => nextPosted.push(message)) - window.dispatchEvent(new ErrorEvent('error', { message: 'third failure' })) + window.dispatchEvent( + new ErrorEvent('error', { message: 'third failure', error: new Error('third failure') }) + ) const nextMessages = nextPosted.filter((m) => m.type === 'error').map((m) => m.message) expect(nextMessages[0]).toContain('captured: third failure') expect(nextMessages[0]).not.toContain('first failure') next.dispose() }) + + it("leaves a browser notice from elsewhere on the page out, so it can't fail a terminal", () => { + // The chat list's resize observer can make the browser raise its loop notice; no error is behind it. + const host = plantHost() + const posted: Array> = [] + const mounted = mountTerminalWebDocument(host, (message) => posted.push(message)) + + const message = 'ResizeObserver loop completed with undelivered notifications.' + window.dispatchEvent(new ErrorEvent('error', { message })) + + expect(posted.filter((m) => m.type === 'error')).toEqual([]) + mounted.dispose() + }) }) describe('a start that throws gives the host back', () => { diff --git a/mobile/src/terminal/terminal-web-document-mount.ts b/mobile/src/terminal/terminal-web-document-mount.ts index b16afa80095..9ab0dc5b8a4 100644 --- a/mobile/src/terminal/terminal-web-document-mount.ts +++ b/mobile/src/terminal/terminal-web-document-mount.ts @@ -217,6 +217,11 @@ function startPageDocument( // `stopHostNotify` takes it off with everything else. installErrorReporter: (report) => { const errorListener = (event: ErrorEvent) => { + // The window is the whole app's: a browser notice with no error behind it (a ResizeObserver + // loop elsewhere on the page) is not this terminal failing, and before ready it is fatal. + if (!event.error) { + return + } report(event.message, event.filename, event.lineno, event.colno, event.error) } window.addEventListener('error', errorListener)