@tea-agent/loop-agent 0.42.0-next.1 → 0.42.0-next.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (238) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +319 -1
  3. package/dist/application/dag/generate-task-dag.js +75 -19
  4. package/dist/application/dag/run-dag.js +41 -0
  5. package/dist/application/task-lifecycle/advance.js +24 -5
  6. package/dist/application/task-lifecycle/observe.js +171 -17
  7. package/dist/application/task-lifecycle/plan-transitions.js +42 -7
  8. package/dist/application/task-lifecycle/recommendations.js +13 -2
  9. package/dist/build-stamp.json +3 -3
  10. package/dist/cli/command-definitions.js +7 -0
  11. package/dist/cli/program.js +6 -1
  12. package/dist/commands/client-recovery.js +3 -0
  13. package/dist/commands/dag-follow-up.js +138 -0
  14. package/dist/commands/dag-rerun-task.js +2 -0
  15. package/dist/commands/init.js +27 -1
  16. package/dist/commands/task-advance.js +19 -0
  17. package/dist/executors/dag-pi-executor.js +3976 -76
  18. package/dist/executors/pi-executor.js +15 -4
  19. package/dist/executors/pi-read-budget-policy.js +239 -0
  20. package/dist/executors/shell-executor.js +978 -188
  21. package/dist/executors/shell-write-guard.js +7 -0
  22. package/dist/infrastructure/console/operation-store.js +226 -6
  23. package/dist/shared/dag-failure-category.js +12 -0
  24. package/dist/shared/openspec-spec.js +70 -4
  25. package/dist/shared/operator/capabilities.js +7 -0
  26. package/dist/task/config-types.js +105 -5
  27. package/dist/task/contract/adopt.js +4 -0
  28. package/dist/task/contract/import-revision.js +4 -0
  29. package/dist/task/contract/project.js +3 -0
  30. package/dist/task/contract/schema.js +2 -1
  31. package/dist/task/frontend-project-capability.js +203 -20
  32. package/dist/task/runtime.js +5 -2
  33. package/dist/task/source-prepare/build-draft.js +3 -3
  34. package/dist/task/source-prepare/fragment-inventory.js +64 -25
  35. package/dist/task/source-prepare/prepare.js +126 -21
  36. package/dist/task/source-prepare/semantic-intake.js +6 -2
  37. package/dist/task/source-references.js +22 -1
  38. package/dist/worker/console/chat/chat-ui-policy.js +4 -2
  39. package/dist/worker/console/chat/model-resolver.js +7 -4
  40. package/dist/worker/console/chat/pi-runtime.js +258 -11
  41. package/dist/worker/console/chat/repo-browser.js +10 -3
  42. package/dist/worker/console/chat/routes.js +165 -71
  43. package/dist/worker/console/chat/sdd-data-alignment.js +222 -0
  44. package/dist/worker/console/chat/session-catalog.js +2 -0
  45. package/dist/worker/console/chat/session-store.js +159 -70
  46. package/dist/worker/console/chat/shortcuts.js +19 -3
  47. package/dist/worker/console/chat/turn-process.js +111 -56
  48. package/dist/worker/console/frontend-human-decision-adapter.js +19 -0
  49. package/dist/worker/console/frontend-split-operation-adapter.js +20 -0
  50. package/dist/worker/console/index.js +3 -0
  51. package/dist/worker/console/operator-actions.js +169 -7
  52. package/dist/worker/console/pi-readiness.js +3 -2
  53. package/dist/worker/console/server.js +23 -1
  54. package/dist/worker/console/static/assets/{abnfDiagram-N423BO3Z-DZZ8m3PO.js → abnfDiagram-N423BO3Z-CcS17TBr.js} +1 -1
  55. package/dist/worker/console/static/assets/{arc-D6PvaVd-.js → arc-COptKq2S.js} +1 -1
  56. package/dist/worker/console/static/assets/{architectureDiagram-T3A2C74G-B_OTOiI8.js → architectureDiagram-T3A2C74G-h4LKMHjP.js} +1 -1
  57. package/dist/worker/console/static/assets/{blockDiagram-VBNYF7ZC-Bv6rqHBg.js → blockDiagram-VBNYF7ZC-COA1MOH0.js} +1 -1
  58. package/dist/worker/console/static/assets/{c4Diagram-5PPSVZJV-B8eHr0oz.js → c4Diagram-5PPSVZJV-DJUf0QPm.js} +1 -1
  59. package/dist/worker/console/static/assets/channel-DdBCaOJ6.js +1 -0
  60. package/dist/worker/console/static/assets/{chunk-2GRJ4B5K-DYwR0im2.js → chunk-2GRJ4B5K-mtWfKrUX.js} +1 -1
  61. package/dist/worker/console/static/assets/{chunk-2Q5K7J3B-D2WPGqXt.js → chunk-2Q5K7J3B-B8pXsxDQ.js} +1 -1
  62. package/dist/worker/console/static/assets/{chunk-5RXB4S5H-CQISJ_I7.js → chunk-5RXB4S5H-ipKzByl1.js} +1 -1
  63. package/dist/worker/console/static/assets/{chunk-5VM5RSS4-C0o2Du1e.js → chunk-5VM5RSS4-DYi3Ald_.js} +1 -1
  64. package/dist/worker/console/static/assets/{chunk-6Q2QTUOP-4f8kr-U7.js → chunk-6Q2QTUOP-DQtGYoty.js} +1 -1
  65. package/dist/worker/console/static/assets/{chunk-GF5L2VYU-D_OWvXzX.js → chunk-GF5L2VYU-BU0qS2YV.js} +1 -1
  66. package/dist/worker/console/static/assets/{chunk-JWPE2WC7-CasdPz5X.js → chunk-JWPE2WC7-DVH9dIGE.js} +1 -1
  67. package/dist/worker/console/static/assets/{chunk-KBJHAD2P-Cj8lRrla.js → chunk-KBJHAD2P-Dt60SmgM.js} +1 -1
  68. package/dist/worker/console/static/assets/{chunk-RYQCIY6F-CUvD1FSd.js → chunk-RYQCIY6F-BoaUGuEY.js} +1 -1
  69. package/dist/worker/console/static/assets/{chunk-XXDRQBXY-CqLw_eqB.js → chunk-XXDRQBXY-_WHFDTp2.js} +1 -1
  70. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-DZFra1GO.js +1 -0
  71. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-DZFra1GO.js +1 -0
  72. package/dist/worker/console/static/assets/{cose-bilkent-JH36ORCC-ku-WqJPx.js → cose-bilkent-JH36ORCC-BxkbTIRd.js} +1 -1
  73. package/dist/worker/console/static/assets/{cynefin-VYW2F7L2-Bq_PYDhl.js → cynefin-VYW2F7L2-Bf2UVnoG.js} +1 -1
  74. package/dist/worker/console/static/assets/{cynefinDiagram-MW4NZA55-BiGZV641.js → cynefinDiagram-MW4NZA55-Bo23q1J_.js} +1 -1
  75. package/dist/worker/console/static/assets/{dagre-VZM6K2ZE-C4JRlglF.js → dagre-VZM6K2ZE-Dd_UU49i.js} +1 -1
  76. package/dist/worker/console/static/assets/{diagram-7IWD3JNH-B0dNeWYG.js → diagram-7IWD3JNH-ebUa1a9y.js} +1 -1
  77. package/dist/worker/console/static/assets/{diagram-B4RE2ZJO-Cj35YvYM.js → diagram-B4RE2ZJO-BucthU8r.js} +1 -1
  78. package/dist/worker/console/static/assets/{diagram-LBJQPF4R-uzQoJ2-8.js → diagram-LBJQPF4R-BgqNpAkm.js} +1 -1
  79. package/dist/worker/console/static/assets/{diagram-Q27KOJAE-D1a-Buoz.js → diagram-Q27KOJAE-DC4q5NGa.js} +1 -1
  80. package/dist/worker/console/static/assets/{diagram-UB23O5K3-XjRrLRSs.js → diagram-UB23O5K3-CBqCaMeb.js} +1 -1
  81. package/dist/worker/console/static/assets/{ebnfDiagram-BXEA7PRR-Da_O24cW.js → ebnfDiagram-BXEA7PRR-CGtnbQ3-.js} +1 -1
  82. package/dist/worker/console/static/assets/{erDiagram-JOGREHBK-BLJ8jrYU.js → erDiagram-JOGREHBK-CG3LUao5.js} +1 -1
  83. package/dist/worker/console/static/assets/{flowDiagram-UKHOOZJN-B9GrLjM0.js → flowDiagram-UKHOOZJN-D2ZVgoFS.js} +1 -1
  84. package/dist/worker/console/static/assets/{ganttDiagram-PKOTCBZU-CeJ0TqiK.js → ganttDiagram-PKOTCBZU-DoYFZiKt.js} +1 -1
  85. package/dist/worker/console/static/assets/{gitGraphDiagram-DS77QQ5N-Bm2eNNFX.js → gitGraphDiagram-DS77QQ5N-CKoP1s6j.js} +1 -1
  86. package/dist/worker/console/static/assets/index-CAZ2fC_X.css +1 -0
  87. package/dist/worker/console/static/assets/index-vbTcFnFs.js +449 -0
  88. package/dist/worker/console/static/assets/{infoDiagram-6WML65LV-DB26i3d8.js → infoDiagram-6WML65LV-Duofv8p2.js} +1 -1
  89. package/dist/worker/console/static/assets/{ishikawaDiagram-WSZJBQD7-BOGRyeZT.js → ishikawaDiagram-WSZJBQD7-D2nlCkA1.js} +1 -1
  90. package/dist/worker/console/static/assets/{journeyDiagram-NVQOT4AX-CZRoBO_6.js → journeyDiagram-NVQOT4AX-Dd4IHdus.js} +1 -1
  91. package/dist/worker/console/static/assets/{kanban-definition-27J2QSJJ-qlWhJyoB.js → kanban-definition-27J2QSJJ-Bi20AOdt.js} +1 -1
  92. package/dist/worker/console/static/assets/{linear-CfUiDDB3.js → linear-D59YJ9kB.js} +1 -1
  93. package/dist/worker/console/static/assets/{mermaid.core-BQe6fpqj.js → mermaid.core-Bl13LpNa.js} +5 -5
  94. package/dist/worker/console/static/assets/{mindmap-definition-FAOFIHXS-zFzWHw64.js → mindmap-definition-FAOFIHXS-C6DLP5eY.js} +1 -1
  95. package/dist/worker/console/static/assets/{pegDiagram-VL7TDLO6-DFGUqjZO.js → pegDiagram-VL7TDLO6-BjO4cy64.js} +1 -1
  96. package/dist/worker/console/static/assets/{pieDiagram-7S7Q4E2Y-BB5i0l8Z.js → pieDiagram-7S7Q4E2Y-D7i2qZTG.js} +1 -1
  97. package/dist/worker/console/static/assets/{quadrantDiagram-CIZ2JOQS-obbVZ_X5.js → quadrantDiagram-CIZ2JOQS-Dx8o_h0y.js} +1 -1
  98. package/dist/worker/console/static/assets/{railroadDiagram-AXF67PYL-BwdaNzc1.js → railroadDiagram-AXF67PYL-B55orI_t.js} +1 -1
  99. package/dist/worker/console/static/assets/{requirementDiagram-LRYGKXZP-CkTJaugh.js → requirementDiagram-LRYGKXZP-zWaehp6f.js} +1 -1
  100. package/dist/worker/console/static/assets/{sankeyDiagram-W5VNT64P-BwJ-hIgq.js → sankeyDiagram-W5VNT64P-CcA-pjvD.js} +1 -1
  101. package/dist/worker/console/static/assets/{sequenceDiagram-SI44F4Z6-BzSgmm7w.js → sequenceDiagram-SI44F4Z6-BOFbzNHI.js} +1 -1
  102. package/dist/worker/console/static/assets/{sizeCapture-X5ZJPWSS-Bn3oj5Q1.js → sizeCapture-X5ZJPWSS-BjAejah1.js} +1 -1
  103. package/dist/worker/console/static/assets/{stateDiagram-OKZ733FA-dxhGtL8K.js → stateDiagram-OKZ733FA-DXYwxJwZ.js} +1 -1
  104. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-Clg3V9t1.js +1 -0
  105. package/dist/worker/console/static/assets/{swimlanes-SLNWSIFB-BLpEInya.js → swimlanes-SLNWSIFB-OYiOch8n.js} +2 -2
  106. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-DX1dxAAW.js +8 -0
  107. package/dist/worker/console/static/assets/{timeline-definition-Z64GVDOM-ChJGYcXy.js → timeline-definition-Z64GVDOM-cZfH3nmU.js} +1 -1
  108. package/dist/worker/console/static/assets/{vennDiagram-T6HMQDX7-DK-qjRer.js → vennDiagram-T6HMQDX7-BDE7b3E1.js} +1 -1
  109. package/dist/worker/console/static/assets/{wardleyDiagram-T6FBY63Y-3qeaWAg-.js → wardleyDiagram-T6FBY63Y-kyyGy9WJ.js} +1 -1
  110. package/dist/worker/console/static/assets/{xychartDiagram-ELKLHX3M-CootlyP9.js → xychartDiagram-ELKLHX3M-CJj6VTog.js} +1 -1
  111. package/dist/worker/console/static/index.html +2 -2
  112. package/dist/worker/console/static-src/active-run-badge.js +17 -0
  113. package/dist/worker/console/static-src/app/useRecoveryConsole.js +5 -5
  114. package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +19 -8
  115. package/dist/worker/console/static-src/operator-chat/input-history.js +8 -6
  116. package/dist/worker/console/static-src/operator-chat/open-preview-in-browser.js +15 -7
  117. package/dist/worker/console/static-src/operator-chat/runtime-snapshot-store.js +10 -0
  118. package/dist/worker/console/static-src/operator-chat/useChatSessions.js +258 -23
  119. package/dist/worker/console/static-src/operator-chat/useChatThread.js +112 -4
  120. package/dist/worker/console/static-src/operator-chat/useComposer.js +13 -4
  121. package/dist/worker/console/static-src/operator-chat/useRuntimeControls.js +29 -6
  122. package/dist/worker/console/static-src/operator-chat/useRuntimeSnapshot.js +8 -2
  123. package/dist/worker/console/static-src/shell/console-update-reload.js +36 -9
  124. package/dist/worker/console/static-src/shell/workspace-route.js +11 -0
  125. package/dist/worker/console/workspace-context.js +115 -1
  126. package/dist/worker/materialize/frontend-split-task-materializer.js +72 -0
  127. package/dist/worker/observe/routes.js +4 -0
  128. package/dist/worker/observe/static/dag-helpers.js +4 -2
  129. package/dist/worker/observe/static/dag-node-purpose.js +5 -0
  130. package/dist/worker/observe/static/inspect-workspace.js +23 -0
  131. package/dist/worker/observe/static/kpi.js +2 -2
  132. package/dist/worker/observe/static/operator-chrome.d.ts +10 -2
  133. package/dist/worker/observe/static/operator-chrome.js +37 -23
  134. package/dist/worker/observe/static/relations.js +2 -2
  135. package/dist/worker/observe/static/router.d.ts +12 -1
  136. package/dist/worker/observe/static/router.js +48 -6
  137. package/dist/worker/observe/static/run-processing.js +4 -2
  138. package/dist/worker/observe/static/shell-chrome.js +2 -2
  139. package/dist/worker/observe/static/state.js +5 -0
  140. package/dist/worker/observe/static/styles.css +95 -0
  141. package/dist/worker/observe/static/views/dag-inspector.js +14 -7
  142. package/dist/worker/observe/static/views/dag.js +10 -2
  143. package/dist/worker/observe/static/views/dags.js +3 -1
  144. package/dist/worker/observe/static/views/dashboard.js +14 -6
  145. package/dist/worker/observe/static/views/pool.js +7 -2
  146. package/dist/worker/observe/static/views/run.js +12 -3
  147. package/dist/worker/observe/static/views/session-timeline.js +69 -10
  148. package/dist/worker/observe/static/views/task.js +7 -2
  149. package/dist/workflows/dag/backend-test-case-coverage-analysis.js +215 -28
  150. package/dist/workflows/dag/backend-test-markdown-workflow.js +13 -1
  151. package/dist/workflows/dag/backend-test-plan-protocol.js +106 -0
  152. package/dist/workflows/dag/backend-test-scenario-param.js +172 -25
  153. package/dist/workflows/dag/backend-test-scenario-partitions.js +62 -1
  154. package/dist/workflows/dag/backend-test-writer-completeness.js +104 -12
  155. package/dist/workflows/dag/contract-validator-registrations.js +1 -2
  156. package/dist/workflows/dag/dag-retry-schema.js +138 -0
  157. package/dist/workflows/dag/frontend-closeout.js +221 -0
  158. package/dist/workflows/dag/frontend-design-policy.js +400 -0
  159. package/dist/workflows/dag/frontend-human-decision.js +182 -0
  160. package/dist/workflows/dag/frontend-implementation-contract.js +1237 -192
  161. package/dist/workflows/dag/frontend-plan-render.js +2 -1
  162. package/dist/workflows/dag/frontend-prewrite-gate.js +256 -350
  163. package/dist/workflows/dag/frontend-provider-capability-matrix.js +159 -0
  164. package/dist/workflows/dag/frontend-recovery-capsule.js +455 -0
  165. package/dist/workflows/dag/frontend-recovery-controller.js +226 -0
  166. package/dist/workflows/dag/frontend-recovery-lineage.js +178 -0
  167. package/dist/workflows/dag/frontend-recovery-plan.js +21 -10
  168. package/dist/workflows/dag/frontend-recovery-run.js +166 -34
  169. package/dist/workflows/dag/frontend-repair.js +1 -432
  170. package/dist/workflows/dag/frontend-review-context.js +261 -15
  171. package/dist/workflows/dag/frontend-review-findings.js +270 -0
  172. package/dist/workflows/dag/frontend-shadow-dual-write.js +941 -0
  173. package/dist/workflows/dag/frontend-shape-capsule-store.js +191 -0
  174. package/dist/workflows/dag/frontend-shape-facts.js +419 -0
  175. package/dist/workflows/dag/frontend-shape.js +435 -0
  176. package/dist/workflows/dag/frontend-source-fidelity-ledger.js +108 -0
  177. package/dist/workflows/dag/frontend-split-application-service.js +203 -0
  178. package/dist/workflows/dag/frontend-split-orchestrator.js +899 -0
  179. package/dist/workflows/dag/frontend-typed-event-store.js +452 -0
  180. package/dist/workflows/dag/frontend-typed-event-transaction.js +180 -0
  181. package/dist/workflows/dag/frontend-verification-trace.js +252 -25
  182. package/dist/workflows/dag/frontend-worktree-diff.js +250 -17
  183. package/dist/workflows/dag/frontend-writer-admission.js +319 -0
  184. package/dist/workflows/dag/frontend-writer-status.js +256 -0
  185. package/dist/workflows/dag/init-hybrid.js +1028 -562
  186. package/dist/workflows/dag/interrupt-request.js +7 -0
  187. package/dist/workflows/dag/node-execution.js +828 -4
  188. package/dist/workflows/dag/prompt.js +70 -3
  189. package/dist/workflows/dag/recovery-lease.js +170 -0
  190. package/dist/workflows/dag/report.js +37 -1
  191. package/dist/workflows/dag/rerun-feedback.js +315 -1
  192. package/dist/workflows/dag/rerun-plan.js +17 -6
  193. package/dist/workflows/dag/rerun-run.js +44 -6
  194. package/dist/workflows/dag/rerun-task.js +251 -13
  195. package/dist/workflows/dag/retry-policy.js +219 -104
  196. package/dist/workflows/dag/runner.js +580 -124
  197. package/dist/workflows/dag/scheduler.js +133 -20
  198. package/dist/workflows/dag/types.js +244 -16
  199. package/dist/workflows/dag/validate.js +28 -12
  200. package/docs/examples/README.md +5 -0
  201. package/docs/init-surface.manifest.json +30 -12
  202. package/docs/skills/vetted-skill-registry.md +4 -2
  203. package/docs/templates/README.md +2 -0
  204. package/docs/templates/agent-dag-report.schema.json +8 -2
  205. package/docs/templates/agent-dag.schema.json +1 -1
  206. package/docs/templates/backend-test-dag.json +23 -20
  207. package/docs/templates/frontend-implementation-contract.schema.json +4 -1
  208. package/docs/templates/frontend-implementation-dag.json +89 -0
  209. package/docs/templates/spec-registry.schema.json +45 -0
  210. package/harness.json +1 -1
  211. package/package.json +4 -3
  212. package/skills/frontend-bounded-implement/SKILL.md +15 -14
  213. package/skills/frontend-bounded-implement/references/code-standards.md +19 -0
  214. package/skills/frontend-contract/SKILL.md +23 -0
  215. package/skills/frontend-contract/references/contract-protocol.md +34 -0
  216. package/skills/frontend-design-review/SKILL.md +22 -41
  217. package/skills/frontend-plan/SKILL.md +26 -0
  218. package/skills/frontend-plan/references/decision-contract.md +37 -0
  219. package/skills/frontend-plan/references/design-decisions.md +17 -0
  220. package/skills/frontend-review/SKILL.md +20 -15
  221. package/skills/frontend-review/references/review-findings.md +6 -7
  222. package/skills/frontend-scout/SKILL.md +25 -0
  223. package/skills/frontend-scout/references/design-evidence.md +16 -0
  224. package/skills/frontend-scout/references/scout-evidence.md +23 -0
  225. package/skills/frontend-verification/SKILL.md +1 -1
  226. package/skills/loop-agent/references/command-reference.md +1 -0
  227. package/skills/loop-agent/references/hybrid-dag.md +2 -2
  228. package/dist/worker/console/static/assets/channel-BU5gOilw.js +0 -1
  229. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-DIzKJGHr.js +0 -1
  230. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-DIzKJGHr.js +0 -1
  231. package/dist/worker/console/static/assets/index-H9rFJiGL.css +0 -1
  232. package/dist/worker/console/static/assets/index-xwu9GxEc.js +0 -451
  233. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-Cld2qK9v.js +0 -1
  234. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-TIHpiT7w.js +0 -8
  235. package/skills/frontend-implementation/SKILL.md +0 -52
  236. package/skills/frontend-implementation/references/code-standards.md +0 -33
  237. package/skills/frontend-implementation/references/design-spec.md +0 -56
  238. package/skills/frontend-implementation/references/node-contracts.md +0 -31
@@ -3,9 +3,98 @@ import { runtimeSelectionFromRecord } from "../../chat/runtime-selection.js";
3
3
  import { hydrateSessionThread } from "../../chat/turn-process.js";
4
4
  import { forgetActiveChatSession, readActiveChatSessionId, rememberActiveChatSession, } from "../../chat/workspace-landing.js";
5
5
  import { confirmationToken } from "./format.js";
6
+ import { isLostChatSessionError } from "./runtime-snapshot-store.js";
6
7
  import { upsertCompactionMessage } from "./compaction-message.js";
7
8
  import { describeMutationGateFailure, isMutationGateFailureCode, isStaleTokenFailure, } from "./mutation-gate.js";
8
9
  import { SessionTitleWatcher } from "./session-title-watcher.js";
10
+ /** Pure list mutations keep optimistic UI deterministic and easy to verify. */
11
+ export function insertOptimisticSession(list, row) {
12
+ return [row, ...list.filter((item) => item.sessionId !== row.sessionId)];
13
+ }
14
+ export function replaceOptimisticSession(list, temporaryId, durable) {
15
+ return [
16
+ durable,
17
+ ...list.filter((item) => item.sessionId !== durable.sessionId &&
18
+ (!temporaryId || item.sessionId !== temporaryId)),
19
+ ];
20
+ }
21
+ export function removeSessionById(list, sessionId) {
22
+ const index = list.findIndex((item) => item.sessionId === sessionId);
23
+ if (index < 0)
24
+ return { next: list, index: -1 };
25
+ return {
26
+ next: list.filter((item) => item.sessionId !== sessionId),
27
+ removed: list[index],
28
+ index,
29
+ };
30
+ }
31
+ export function restoreSessionAt(list, row, index) {
32
+ if (list.some((item) => item.sessionId === row.sessionId))
33
+ return list;
34
+ const next = [...list];
35
+ next.splice(Math.max(0, Math.min(index, next.length)), 0, row);
36
+ return next;
37
+ }
38
+ function sameQueueSummary(a, b) {
39
+ const left = a.deferredTurns ?? (a.deferredTurn ? [a.deferredTurn] : []);
40
+ const right = b.deferredTurns ?? (b.deferredTurn ? [b.deferredTurn] : []);
41
+ if (left.length !== right.length)
42
+ return false;
43
+ return left.every((item, index) => {
44
+ const other = right[index];
45
+ return Boolean(other &&
46
+ item.clientRequestId === other.clientRequestId &&
47
+ item.revision === other.revision &&
48
+ item.state === other.state &&
49
+ item.updatedAt === other.updatedAt &&
50
+ item.textPreview === other.textPreview &&
51
+ item.imageCount === other.imageCount &&
52
+ item.acceptedTurnId === other.acceptedTurnId &&
53
+ item.lastError?.code === other.lastError?.code &&
54
+ item.lastError?.message === other.lastError?.message);
55
+ });
56
+ }
57
+ function sameSessionSummary(a, b) {
58
+ return (a.sessionId === b.sessionId &&
59
+ a.workspaceId === b.workspaceId &&
60
+ a.state === b.state &&
61
+ a.title === b.title &&
62
+ a.displayTitle === b.displayTitle &&
63
+ a.updatedAt === b.updatedAt &&
64
+ a.activeTurnId === b.activeTurnId &&
65
+ a.turnPhase === b.turnPhase &&
66
+ a.model?.provider === b.model?.provider &&
67
+ a.model?.modelId === b.model?.modelId &&
68
+ a.thinkingLevel === b.thinkingLevel &&
69
+ a.isBlank === b.isBlank &&
70
+ a.optimisticState === b.optimisticState &&
71
+ sameQueueSummary(a, b));
72
+ }
73
+ /** Reuse unchanged row and list references across catalog refreshes. */
74
+ export function stabilizeSessionList(previous, next) {
75
+ const previousById = new Map(previous.map((item) => [item.sessionId, item]));
76
+ let changed = previous.length !== next.length;
77
+ const stabilized = next.map((item, index) => {
78
+ const existing = previousById.get(item.sessionId);
79
+ const value = existing && sameSessionSummary(existing, item) ? existing : item;
80
+ if (value !== previous[index])
81
+ changed = true;
82
+ return value;
83
+ });
84
+ return changed ? stabilized : previous;
85
+ }
86
+ /** Select a server-confirmed blank session owned by the requested workspace. */
87
+ export function findReusableBlankSession(list, workspaceId) {
88
+ const scope = workspaceId.trim();
89
+ if (!scope)
90
+ return undefined;
91
+ return list.find((item) => item.workspaceId === scope &&
92
+ item.state === "active" &&
93
+ item.isBlank === true &&
94
+ item.turnPhase === "idle" &&
95
+ !item.activeTurnId &&
96
+ !item.optimisticState);
97
+ }
9
98
  export async function createSessionAndPrimeTitleWatcher(input) {
10
99
  if (input.creatingRef?.current) {
11
100
  throw new Error("CREATE_SESSION_IN_FLIGHT");
@@ -23,7 +112,10 @@ export async function createSessionAndPrimeTitleWatcher(input) {
23
112
  "x-loop-console-confirmation": confirmationToken(),
24
113
  },
25
114
  credentials: "include",
26
- body: JSON.stringify(input.options ?? {}),
115
+ body: JSON.stringify((() => {
116
+ const { optimisticSessionId: _optimisticSessionId, ...body } = input.options ?? {};
117
+ return body;
118
+ })()),
27
119
  });
28
120
  if (!res.ok) {
29
121
  const body = (await res.json().catch(() => ({})));
@@ -35,7 +127,7 @@ export async function createSessionAndPrimeTitleWatcher(input) {
35
127
  const body = (await res.json());
36
128
  if (!body.session)
37
129
  throw new Error("创建会话响应缺少 durable session");
38
- const durable = body.session;
130
+ const durable = { ...body.session, isBlank: true };
39
131
  const created = {
40
132
  sessionId: durable.sessionId,
41
133
  ...(durable.workspaceId || input.options?.workspaceId
@@ -45,16 +137,12 @@ export async function createSessionAndPrimeTitleWatcher(input) {
45
137
  : {}),
46
138
  ...runtimeSelectionFromRecord(durable),
47
139
  };
48
- const nextList = [
49
- durable,
50
- ...input.sessionListRef.current.filter((item) => item.sessionId !== durable.sessionId),
51
- ];
140
+ const nextList = replaceOptimisticSession(input.sessionListRef.current, input.options?.optimisticSessionId, durable);
52
141
  // Commit ref → state → eligibility → deferred refresh. The ref assignment
53
142
  // makes the durable record observable before React's state commit.
54
143
  input.sessionListRef.current = nextList;
55
144
  input.setSessionList(nextList);
56
145
  input.titleWatcher?.markEligible(durable.sessionId);
57
- void input.refreshSessionList();
58
146
  return created;
59
147
  }
60
148
  finally {
@@ -70,7 +158,7 @@ function mergeCompactionMessages(messages, compactions) {
70
158
  * compact, plus browser-refresh recovery from the durable session store. */
71
159
  export function useChatSessions(params) {
72
160
  const { origin, refs, setSession, thread, composer, browser, setError, setNotice, onCompactionChange, setProcessOpen, setAutoScroll, } = params;
73
- const { applyActivatedSession, resetForNewSession, setMessages } = thread;
161
+ const { applyActivatedSession, resetForNewSession } = thread;
74
162
  const { setInput, setPendingImages } = composer;
75
163
  const { setPreviews, setActivePreviewPath } = browser;
76
164
  const { workspaceId, blocked } = params;
@@ -90,6 +178,11 @@ export function useChatSessions(params) {
90
178
  }, [refs]);
91
179
  const [sessionList, setSessionList] = useState([]);
92
180
  const sessionListRef = useRef([]);
181
+ const pendingDeletedIdsRef = useRef(new Set());
182
+ const pendingCreatedRowsRef = useRef(new Map());
183
+ const sessionMutationVersionRef = useRef(0);
184
+ const deleteRequestVersionsRef = useRef(new Map());
185
+ const activateSessionRef = useRef(null);
93
186
  sessionListRef.current = sessionList;
94
187
  const titleWatcherRef = useRef(null);
95
188
  // Shared busy gate for sidebar + Landing create entries (one render feedback).
@@ -159,9 +252,13 @@ export function useChatSessions(params) {
159
252
  return;
160
253
  }
161
254
  const sessions = body.sessions ?? [];
255
+ const durable = sessions.filter((item) => !pendingDeletedIdsRef.current.has(item.sessionId));
256
+ const optimistic = [...pendingCreatedRowsRef.current.values()].filter((item) => !durable.some((session) => session.sessionId === item.sessionId));
162
257
  // Preserve every workspace's summaries. ChatWorkspaceSidebar applies the
163
258
  // visible recent-item limit independently inside each workspace group.
164
- setSessionList(sessions);
259
+ const next = stabilizeSessionList(sessionListRef.current, [...optimistic, ...durable]);
260
+ sessionListRef.current = next;
261
+ setSessionList(next);
165
262
  if (body.workspaceErrors?.length) {
166
263
  setError(`部分工作区的会话加载失败(${body.workspaceErrors.length} 个)`);
167
264
  }
@@ -171,6 +268,60 @@ export function useChatSessions(params) {
171
268
  }
172
269
  }, [origin, blocked, setError]);
173
270
  const deleteSession = useCallback(async (sessionId) => {
271
+ if (pendingDeletedIdsRef.current.has(sessionId))
272
+ return false;
273
+ const requestId = ++sessionMutationVersionRef.current;
274
+ const currentList = sessionListRef.current;
275
+ const { next: removedList, removed, index: removedIndex } = removeSessionById(currentList, sessionId);
276
+ if (!removed || removedIndex < 0)
277
+ return false;
278
+ pendingDeletedIdsRef.current.add(sessionId);
279
+ deleteRequestVersionsRef.current.set(sessionId, requestId);
280
+ const activeSession = refs.sessionRef.current?.sessionId === sessionId
281
+ ? refs.sessionRef.current
282
+ : null;
283
+ const activeWorkspaceId = activeSession?.workspaceId ?? workspaceId;
284
+ const deletingRow = {
285
+ ...removed,
286
+ optimisticState: "deleting",
287
+ };
288
+ const deletingList = restoreSessionAt(removedList, deletingRow, removedIndex);
289
+ sessionListRef.current = deletingList;
290
+ setSessionList(deletingList);
291
+ window.setTimeout(() => {
292
+ if (!pendingDeletedIdsRef.current.has(sessionId) ||
293
+ deleteRequestVersionsRef.current.get(sessionId) !== requestId)
294
+ return;
295
+ const next = sessionListRef.current.filter((item) => item.sessionId !== sessionId);
296
+ sessionListRef.current = next;
297
+ setSessionList(next);
298
+ }, 150);
299
+ const restore = () => {
300
+ if (deleteRequestVersionsRef.current.get(sessionId) !== requestId)
301
+ return;
302
+ pendingDeletedIdsRef.current.delete(sessionId);
303
+ deleteRequestVersionsRef.current.delete(sessionId);
304
+ const restored = restoreSessionAt(sessionListRef.current, removed, removedIndex);
305
+ if (restored === sessionListRef.current)
306
+ return;
307
+ sessionListRef.current = restored;
308
+ setSessionList(restored);
309
+ };
310
+ let deletionSelectionVersion = null;
311
+ if (activeSession) {
312
+ deletionSelectionVersion = ++sessionSelectionVersionRef.current;
313
+ refs.sessionRef.current = null;
314
+ resetActiveProjection();
315
+ setSession(null);
316
+ forgetActiveChatSession(window.localStorage, activeWorkspaceId, sessionId);
317
+ }
318
+ const restoreActiveSession = async () => {
319
+ if (activeSession &&
320
+ deletionSelectionVersion === sessionSelectionVersionRef.current &&
321
+ !refs.sessionRef.current) {
322
+ await activateSessionRef.current?.(activeSession.sessionId, activeSession.workspaceId);
323
+ }
324
+ };
174
325
  try {
175
326
  // AC-002: delete follows the session's durable owner, not the
176
327
  // global active workspace.
@@ -184,22 +335,21 @@ export function useChatSessions(params) {
184
335
  },
185
336
  });
186
337
  if (!res.ok) {
338
+ restore();
339
+ await restoreActiveSession();
187
340
  setError("删除会话失败");
188
341
  return false;
189
342
  }
190
343
  titleWatcherRef.current?.cancel(sessionId);
191
- if (refs.sessionRef.current?.sessionId === sessionId) {
192
- const activeWorkspaceId = refs.sessionRef.current.workspaceId ?? workspaceId;
193
- sessionSelectionVersionRef.current += 1;
194
- refs.sessionRef.current = null;
195
- resetActiveProjection();
196
- setSession(null);
197
- forgetActiveChatSession(window.localStorage, activeWorkspaceId, sessionId);
198
- }
199
- await refreshSessionList();
344
+ void refreshSessionList().finally(() => {
345
+ pendingDeletedIdsRef.current.delete(sessionId);
346
+ deleteRequestVersionsRef.current.delete(sessionId);
347
+ });
200
348
  return true;
201
349
  }
202
350
  catch (error) {
351
+ restore();
352
+ await restoreActiveSession();
203
353
  setError(error instanceof Error ? error.message : String(error));
204
354
  return false;
205
355
  }
@@ -213,6 +363,23 @@ export function useChatSessions(params) {
213
363
  sessionOwnerQuery,
214
364
  workspaceId,
215
365
  ]);
366
+ const forgetLostSession = useCallback((sessionId, ownerWorkspaceId) => {
367
+ const active = refs.sessionRef.current;
368
+ const listed = sessionListRef.current.find((item) => item.sessionId === sessionId);
369
+ const scope = ownerWorkspaceId ?? listed?.workspaceId ?? active?.workspaceId ?? workspaceId;
370
+ forgetActiveChatSession(window.localStorage, scope, sessionId);
371
+ pendingDeletedIdsRef.current.add(sessionId);
372
+ const { next } = removeSessionById(sessionListRef.current, sessionId);
373
+ sessionListRef.current = next;
374
+ setSessionList(next);
375
+ if (active?.sessionId === sessionId) {
376
+ sessionSelectionVersionRef.current += 1;
377
+ refs.sessionRef.current = null;
378
+ resetActiveProjection();
379
+ setSession(null);
380
+ }
381
+ void refreshSessionList();
382
+ }, [refs, resetActiveProjection, setSession, workspaceId, refreshSessionList]);
216
383
  const activateSession = useCallback(async (sessionId, ownerWorkspaceId) => {
217
384
  const selectionVersion = ++sessionSelectionVersionRef.current;
218
385
  // AC-002: activation targets the session's durable owning workspace —
@@ -222,10 +389,19 @@ export function useChatSessions(params) {
222
389
  try {
223
390
  const res = await fetch(`${origin}/api/operator/v1/chat/sessions/${encodeURIComponent(sessionId)}${ownerQuery}`, { credentials: "include" });
224
391
  const body = (await res.json());
225
- if (!res.ok || !body.session)
392
+ if (!res.ok || !body.session) {
393
+ if (isLostChatSessionError({
394
+ status: res.status,
395
+ code: body.error?.code,
396
+ message: body.error?.message,
397
+ })) {
398
+ forgetLostSession(sessionId, ownerWorkspaceId);
399
+ return false;
400
+ }
226
401
  throw new Error(body.error?.message ?? "会话不可用");
402
+ }
227
403
  if (selectionVersion !== sessionSelectionVersionRef.current)
228
- return;
404
+ return false;
229
405
  const next = {
230
406
  sessionId: body.session.sessionId,
231
407
  ...(body.session.workspaceId ? { workspaceId: body.session.workspaceId } : {}),
@@ -273,14 +449,17 @@ export function useChatSessions(params) {
273
449
  pendingQuestion: body.session.pendingQuestion ?? null,
274
450
  todos: body.session.todos ?? [],
275
451
  goal: body.session.goal ?? null,
452
+ history: body.history,
276
453
  });
277
454
  rememberActiveChatSession(window.localStorage, body.session.workspaceId ?? ownerWorkspaceId ?? workspaceId, sessionId);
278
455
  setActivePreviewPath(null);
456
+ return true;
279
457
  }
280
458
  catch (error) {
281
459
  if (selectionVersion !== sessionSelectionVersionRef.current)
282
- return;
460
+ return false;
283
461
  setError(error instanceof Error ? error.message : String(error));
462
+ return false;
284
463
  }
285
464
  }, [
286
465
  origin,
@@ -295,7 +474,9 @@ export function useChatSessions(params) {
295
474
  setError,
296
475
  sessionOwnerQuery,
297
476
  workspaceId,
477
+ forgetLostSession,
298
478
  ]);
479
+ activateSessionRef.current = activateSession;
299
480
  useEffect(() => {
300
481
  void refreshSessionList();
301
482
  }, [refreshSessionList]);
@@ -405,6 +586,7 @@ export function useChatSessions(params) {
405
586
  pendingQuestion: body.session.pendingQuestion ?? null,
406
587
  todos: body.session.todos ?? [],
407
588
  goal: body.session.goal ?? null,
589
+ history: body.history,
408
590
  });
409
591
  }
410
592
  catch {
@@ -437,15 +619,38 @@ export function useChatSessions(params) {
437
619
  setError("创建会话需要选择一个有效的工作区(不会回退默认工作区)");
438
620
  return Promise.reject(new Error("WORKSPACE_REQUIRED"));
439
621
  }
622
+ const optimisticSessionId = `optimistic-chat-${Date.now()}-${Math.random().toString(16).slice(2)}`;
623
+ const now = new Date().toISOString();
624
+ const optimisticRow = {
625
+ sessionId: optimisticSessionId,
626
+ workspaceId: scope,
627
+ state: "active",
628
+ updatedAt: now,
629
+ turnPhase: "idle",
630
+ optimisticState: "creating",
631
+ };
632
+ pendingCreatedRowsRef.current.set(optimisticSessionId, optimisticRow);
633
+ const optimisticList = insertOptimisticSession(sessionListRef.current, optimisticRow);
634
+ sessionListRef.current = optimisticList;
635
+ setSessionList(optimisticList);
440
636
  return createSessionAndPrimeTitleWatcher({
441
637
  origin,
442
638
  sessionListRef,
443
639
  setSessionList,
444
640
  titleWatcher: titleWatcherRef.current,
445
641
  refreshSessionList,
446
- options: { ...options, workspaceId: scope },
642
+ options: { ...options, workspaceId: scope, optimisticSessionId },
447
643
  setCreatingSession,
448
644
  creatingRef: creatingSessionRef,
645
+ }).then((created) => {
646
+ pendingCreatedRowsRef.current.delete(optimisticSessionId);
647
+ return created;
648
+ }).catch((error) => {
649
+ pendingCreatedRowsRef.current.delete(optimisticSessionId);
650
+ const cleaned = sessionListRef.current.filter((item) => item.sessionId !== optimisticSessionId);
651
+ sessionListRef.current = cleaned;
652
+ setSessionList(cleaned);
653
+ throw error;
449
654
  });
450
655
  }, [origin, refreshSessionList, workspaceId, setError]);
451
656
  const createSession = useCallback(async (options) => {
@@ -456,11 +661,36 @@ export function useChatSessions(params) {
456
661
  return null; // AC-R5-002: unknown workspace — no POST
457
662
  const selectionVersion = ++sessionSelectionVersionRef.current;
458
663
  setError(null);
664
+ const previousSession = refs.sessionRef.current;
665
+ const scope = options?.workspaceId?.trim() ?? workspaceId?.trim();
666
+ if (!scope) {
667
+ setError("创建会话需要选择一个有效的工作区(不会回退默认工作区)");
668
+ return null;
669
+ }
670
+ // A caller-provided snapshot is an explicit new-session boundary; reusing
671
+ // an existing shell would silently drop that context suffix.
672
+ const reusable = options?.snapshot
673
+ ? undefined
674
+ : findReusableBlankSession(sessionListRef.current, scope);
675
+ if (reusable) {
676
+ if (previousSession?.sessionId === reusable.sessionId)
677
+ return previousSession;
678
+ const activated = await activateSession(reusable.sessionId, reusable.workspaceId);
679
+ if (!activated)
680
+ return null;
681
+ return {
682
+ sessionId: reusable.sessionId,
683
+ ...(reusable.workspaceId ? { workspaceId: reusable.workspaceId } : {}),
684
+ ...runtimeSelectionFromRecord(reusable),
685
+ };
686
+ }
459
687
  try {
688
+ resetActiveProjection({ keepStreams: true });
689
+ refs.sessionRef.current = null;
690
+ setSession(null);
460
691
  const created = await createSessionAndPrimeTitleWatcherForHook(options);
461
692
  if (selectionVersion !== sessionSelectionVersionRef.current)
462
693
  return null;
463
- resetActiveProjection({ keepStreams: true });
464
694
  // Set ref synchronously BEFORE setState so a chained
465
695
  // createSession().then(sendPrompt) reads the new session even
466
696
  // though sendPrompt's closure captured the stale (null) value.
@@ -472,6 +702,9 @@ export function useChatSessions(params) {
472
702
  catch (e) {
473
703
  if (selectionVersion !== sessionSelectionVersionRef.current)
474
704
  return null;
705
+ if (previousSession) {
706
+ await activateSession(previousSession.sessionId, previousSession.workspaceId);
707
+ }
475
708
  const message = e instanceof Error ? e.message : String(e);
476
709
  if (message !== "CREATE_SESSION_IN_FLIGHT") {
477
710
  setError(message);
@@ -484,6 +717,7 @@ export function useChatSessions(params) {
484
717
  setError,
485
718
  resetActiveProjection,
486
719
  createSessionAndPrimeTitleWatcherForHook,
720
+ activateSession,
487
721
  blocked,
488
722
  workspaceId,
489
723
  ]);
@@ -595,5 +829,6 @@ export function useChatSessions(params) {
595
829
  updateSession,
596
830
  watchSessionTitle,
597
831
  compactSession,
832
+ forgetLostSession,
598
833
  };
599
834
  }
@@ -1,4 +1,4 @@
1
- import { useCallback, useEffect, useMemo, useState } from "react";
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
2
  import { buildTurnProcessGroups, hydrateSessionThread, selectRecentTurnGroups, } from "../../chat/turn-process.js";
3
3
  import { mergeOperationCardState, } from "../../chat/operation-card.js";
4
4
  import { createChatSseEventApplier, } from "./chat-sse-events.js";
@@ -118,6 +118,10 @@ export function useChatThread(params) {
118
118
  const [operationCards, setOperationCards] = useState([]);
119
119
  const [artifactCards, setArtifactCards] = useState([]);
120
120
  const [historyLimit, setHistoryLimit] = useState(50);
121
+ const [historyHasMore, setHistoryHasMore] = useState(false);
122
+ const [historyNextCursor, setHistoryNextCursor] = useState(null);
123
+ const [historyLoading, setHistoryLoading] = useState(false);
124
+ const historyLoadingRef = useRef(false);
121
125
  const [unread, setUnread] = useState(0);
122
126
  const [humanGateCards, setHumanGateCards] = useState([]);
123
127
  const [interview, setInterview] = useState(null);
@@ -136,6 +140,8 @@ export function useChatThread(params) {
136
140
  /** Assistant bubble id for the in-flight turn; only this row shows the streaming cursor. */
137
141
  const [streamingAssistantId, setStreamingAssistantId] = useState(null);
138
142
  const [showSystemInMessages] = useState(false);
143
+ const completedGroupsRef = useRef([]);
144
+ const previousTimelineRef = useRef(null);
139
145
  const refreshSessionTelemetry = useCallback(async () => {
140
146
  const targetSessionId = refs.sessionRef.current?.sessionId;
141
147
  if (!targetSessionId)
@@ -301,12 +307,55 @@ export function useChatThread(params) {
301
307
  };
302
308
  }, [toolCalls]);
303
309
  const turnGroups = useMemo(() => {
304
- const all = buildTurnProcessGroups({
310
+ const previousTimeline = previousTimelineRef.current;
311
+ let built;
312
+ const activeIndex = streamingAssistantId
313
+ ? timelineMessages.findIndex((message) => message.id === streamingAssistantId)
314
+ : -1;
315
+ const canBuildActiveTail = Boolean(previousTimeline && streaming && streamingAssistantId && activeIndex >= 0) &&
316
+ previousTimeline?.activeAssistantId === streamingAssistantId &&
317
+ (previousTimeline?.messages.every((message, index) => message.id === streamingAssistantId || timelineMessages[index]?.id === message.id) ?? false);
318
+ if (canBuildActiveTail && previousTimeline) {
319
+ let tailStart = activeIndex;
320
+ while (tailStart > 0 && timelineMessages[tailStart - 1]?.role !== "user")
321
+ tailStart -= 1;
322
+ const tailMessages = timelineMessages.slice(tailStart);
323
+ const tailIds = new Set(tailMessages.map((message) => message.id));
324
+ const tailTurnIds = new Set(tailMessages.map((message) => message.turnId).filter(Boolean));
325
+ const tailTools = toolCalls.filter((tool) => tailIds.has(tool.assistantMessageId ?? "") ||
326
+ (Boolean(tool.turnId) && tailTurnIds.has(tool.turnId)) ||
327
+ (tool.createdAt >= (tailMessages[0]?.createdAt ?? Number.POSITIVE_INFINITY)));
328
+ const activeTail = buildTurnProcessGroups({
329
+ messages: tailMessages,
330
+ toolCalls: tailTools,
331
+ streaming,
332
+ activeAssistantId: streamingAssistantId,
333
+ });
334
+ built = [...previousTimeline.groups.filter((group) => !group.live), ...activeTail];
335
+ }
336
+ else {
337
+ built = buildTurnProcessGroups({
338
+ messages: timelineMessages,
339
+ toolCalls,
340
+ streaming,
341
+ activeAssistantId: streamingAssistantId,
342
+ });
343
+ }
344
+ // Preserve completed group objects across streaming tokens. The active
345
+ // tail is rebuilt, while stable historical groups keep their references so
346
+ // memoized rows do not re-render or re-parse the full conversation.
347
+ const previous = new Map(completedGroupsRef.current.map((group) => [group.key, group]));
348
+ const all = built.map((group) => {
349
+ const cached = previous.get(group.key);
350
+ return cached && !group.live ? cached : group;
351
+ });
352
+ completedGroupsRef.current = all.filter((group) => !group.live);
353
+ previousTimelineRef.current = {
305
354
  messages: timelineMessages,
306
355
  toolCalls,
307
- streaming,
308
356
  activeAssistantId: streamingAssistantId,
309
- });
357
+ groups: all,
358
+ };
310
359
  return selectRecentTurnGroups(all, historyLimit);
311
360
  }, [
312
361
  timelineMessages,
@@ -315,6 +364,50 @@ export function useChatThread(params) {
315
364
  streamingAssistantId,
316
365
  historyLimit,
317
366
  ]);
367
+ const loadEarlierMessages = useCallback(async () => {
368
+ const targetSessionId = refs.sessionRef.current?.sessionId;
369
+ const beforeId = historyNextCursor;
370
+ if (!targetSessionId || !historyHasMore || !beforeId || historyLoadingRef.current)
371
+ return false;
372
+ historyLoadingRef.current = true;
373
+ setHistoryLoading(true);
374
+ try {
375
+ const query = new URLSearchParams({ limit: "50", beforeId });
376
+ const res = await fetch(`${origin}/api/operator/v1/chat/sessions/${encodeURIComponent(targetSessionId)}/messages?${query.toString()}`, { credentials: "include" });
377
+ if (!res.ok || refs.sessionRef.current?.sessionId !== targetSessionId)
378
+ return false;
379
+ const body = (await res.json());
380
+ const page = hydrateSessionThread(body.messages ?? []);
381
+ setMessages((current) => {
382
+ const seen = new Set();
383
+ const merged = [...page.messages, ...current].filter((message) => {
384
+ if (seen.has(message.id))
385
+ return false;
386
+ seen.add(message.id);
387
+ return true;
388
+ });
389
+ return merged.sort((a, b) => {
390
+ if (a.eventSeq !== undefined && b.eventSeq !== undefined && a.eventSeq !== b.eventSeq)
391
+ return a.eventSeq - b.eventSeq;
392
+ return a.createdAt - b.createdAt;
393
+ });
394
+ });
395
+ setToolCalls((current) => {
396
+ const byId = new Map(current.map((tool) => [tool.id, tool]));
397
+ for (const tool of page.toolCalls)
398
+ byId.set(tool.id, tool);
399
+ return [...byId.values()].sort((a, b) => a.createdAt - b.createdAt);
400
+ });
401
+ setHistoryHasMore(body.hasMore === true);
402
+ setHistoryNextCursor(body.nextCursor ?? null);
403
+ setHistoryLimit((value) => value + 50);
404
+ return true;
405
+ }
406
+ finally {
407
+ historyLoadingRef.current = false;
408
+ setHistoryLoading(false);
409
+ }
410
+ }, [historyHasMore, historyNextCursor, origin, refs]);
318
411
  const visibleMessages = useMemo(() => {
319
412
  const next = [];
320
413
  for (const group of turnGroups) {
@@ -346,6 +439,11 @@ export function useChatThread(params) {
346
439
  setStreamingAssistantId(null);
347
440
  setUnread(0);
348
441
  setHistoryLimit(50);
442
+ setHistoryHasMore(false);
443
+ setHistoryNextCursor(null);
444
+ historyLoadingRef.current = false;
445
+ completedGroupsRef.current = [];
446
+ previousTimelineRef.current = null;
349
447
  refs.usageResponseKeysRef.current = new Set();
350
448
  }, [refs]);
351
449
  /** Adopt a freshly activated session's hydrated thread + streaming flags. */
@@ -374,6 +472,10 @@ export function useChatThread(params) {
374
472
  setStreamingAssistantId(null);
375
473
  setUnread(0);
376
474
  setHistoryLimit(50);
475
+ setHistoryHasMore(next.history?.hasMore === true);
476
+ setHistoryNextCursor(next.history?.nextCursor ?? null);
477
+ completedGroupsRef.current = [];
478
+ previousTimelineRef.current = null;
377
479
  }, [refs]);
378
480
  /** Reconcile recovery (AC-001/AC-002/AC-003): reload the canonical session
379
481
  * facts (session-store messages + event-ring tail cursor) and REPLACE the
@@ -417,6 +519,8 @@ export function useChatThread(params) {
417
519
  const hydrated = hydrateSessionThread(body.session.messages ?? []);
418
520
  setMessages(mergeCompactionMessages(hydrated.messages, body.compactions));
419
521
  setToolCalls(hydrated.toolCalls);
522
+ setHistoryHasMore(body.history?.hasMore === true);
523
+ setHistoryNextCursor(body.history?.nextCursor ?? null);
420
524
  setStreamingAssistantId(null);
421
525
  return true;
422
526
  }
@@ -446,6 +550,10 @@ export function useChatThread(params) {
446
550
  artifactCards,
447
551
  historyLimit,
448
552
  setHistoryLimit,
553
+ historyHasMore,
554
+ historyNextCursor,
555
+ historyLoading,
556
+ loadEarlierMessages,
449
557
  unread,
450
558
  humanGateCards,
451
559
  interview,