@kabmo-labs/kabmo 0.7.0 → 0.9.0

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 (351) hide show
  1. package/README.md +7 -0
  2. package/dist/client/index.html +1 -1
  3. package/dist/client/index.js +403 -384
  4. package/dist/client/static/js/{index-0wvbca0b.js → index-bpptfkz2.js} +905 -875
  5. package/dist/client/static/js/index-k315p1e8.css +1 -0
  6. package/package.json +10 -1
  7. package/src/client/components/common/code-surface/documents.test.ts +120 -0
  8. package/src/client/components/common/code-surface/documents.ts +201 -0
  9. package/src/client/components/common/code-surface/extensions.test.ts +110 -0
  10. package/src/client/components/common/code-surface/extensions.ts +162 -0
  11. package/src/client/components/common/code-surface/handle.ts +102 -0
  12. package/src/client/components/common/code-surface/index.tsx +253 -314
  13. package/src/client/components/common/code-surface/keymap.test.ts +133 -0
  14. package/src/client/components/common/code-surface/keymap.ts +267 -0
  15. package/src/client/components/common/code-surface/marks.ts +74 -0
  16. package/src/client/components/common/code-surface/shiki.ts +234 -0
  17. package/src/client/components/common/code-surface/sync.test.ts +185 -0
  18. package/src/client/components/common/code-surface/sync.ts +58 -0
  19. package/src/client/components/common/code-surface/theme.test.ts +202 -0
  20. package/src/client/components/common/code-surface/theme.ts +111 -0
  21. package/src/client/components/common/editor-view-buttons/index.test.tsx +148 -0
  22. package/src/client/components/common/editor-view-buttons/index.tsx +109 -0
  23. package/src/client/components/common/header-controls/index.tsx +24 -1
  24. package/src/client/components/common/header-controls/shell-chrome.test.tsx +272 -0
  25. package/src/client/components/common/shell-update-button/Modal.tsx +163 -0
  26. package/src/client/components/common/shell-update-button/index.test.tsx +251 -0
  27. package/src/client/components/common/shell-update-button/index.tsx +125 -0
  28. package/src/client/components/common/toolbar-row/index.test.tsx +166 -0
  29. package/src/client/components/common/toolbar-row/index.tsx +189 -0
  30. package/src/client/components/common/workspace-options-menu/index.tsx +19 -1
  31. package/src/client/components/layout/desktop-layout/TopNavbar.tsx +5 -6
  32. package/src/client/components/layout/desktop-layout/WorkspacePanels.tsx +41 -78
  33. package/src/client/components/layout/desktop-layout/index.tsx +55 -69
  34. package/src/client/components/layout/desktop-layout/resizer/index.tsx +6 -3
  35. package/src/client/components/layout/session-sidebar/CategoryHeader.tsx +24 -0
  36. package/src/client/components/layout/session-sidebar/CategoryHeader.worktree.test.tsx +126 -0
  37. package/src/client/components/layout/session-sidebar/CategoryItem.tsx +4 -0
  38. package/src/client/components/layout/session-sidebar/Footer.tsx +8 -15
  39. package/src/client/components/layout/session-sidebar/SessionItem.tsx +12 -0
  40. package/src/client/components/layout/session-sidebar/SessionList.tsx +4 -0
  41. package/src/client/components/layout/session-sidebar/SessionsView.tsx +24 -0
  42. package/src/client/components/layout/session-sidebar/WorktreeBadge.test.ts +48 -0
  43. package/src/client/components/layout/session-sidebar/WorktreeBadge.tsx +55 -0
  44. package/src/client/components/mobile/LayoutWrapper.tsx +23 -16
  45. package/src/client/components/mobile/RightSidebar.tsx +21 -10
  46. package/src/client/components/mobile/SessionSidebar.tsx +8 -0
  47. package/src/client/components/mobile/header-titlebar.test.tsx +172 -0
  48. package/src/client/components/mobile/mobile-main-view/Header.tsx +10 -1
  49. package/src/client/components/mobile/mobile-right-sidebar/FullDiff.tsx +3 -0
  50. package/src/client/components/mobile/mobile-right-sidebar/FullEditor.tsx +63 -38
  51. package/src/client/components/mobile/mobile-right-sidebar/OutlineButton.test.tsx +137 -0
  52. package/src/client/components/mobile/mobile-right-sidebar/OutlineButton.tsx +14 -3
  53. package/src/client/components/mobile/mobile-session-sidebar/Footer.tsx +9 -21
  54. package/src/client/components/mobile/mobile-session-sidebar/Header.tsx +6 -2
  55. package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +16 -0
  56. package/src/client/components/mobile/mobile-session-sidebar/List.tsx +4 -0
  57. package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +5 -0
  58. package/src/client/components/mobile/mobile-session-sidebar/WorktreeDialog.tsx +49 -0
  59. package/src/client/components/mobile/worktree-session.ts +51 -0
  60. package/src/client/components/settings/Modal.tsx +8 -0
  61. package/src/client/components/settings/categories/AppearanceSettings.tsx +3 -0
  62. package/src/client/components/settings/categories/ProjectSettings.tsx +22 -8
  63. package/src/client/components/settings/categories/appearance-settings/EditorThemeSection.test.tsx +167 -0
  64. package/src/client/components/settings/categories/appearance-settings/EditorThemeSection.tsx +184 -0
  65. package/src/client/components/settings/categories/project-settings/WorktreeSettings.tsx +146 -0
  66. package/src/client/components/settings/categories/project-settings/worktree-row.test.ts +83 -0
  67. package/src/client/components/settings/categories/project-settings/worktree-row.ts +47 -0
  68. package/src/client/components/workspace/chat-timeline/EmptyWorkspacePrompt.tsx +36 -55
  69. package/src/client/components/workspace/chat-timeline/PendingChatView.tsx +8 -0
  70. package/src/client/components/workspace/chat-timeline/SystemNotice.tsx +9 -4
  71. package/src/client/components/workspace/chat-timeline/TaskResultContent.tsx +9 -7
  72. package/src/client/components/workspace/chat-timeline/index.tsx +5 -0
  73. package/src/client/components/workspace/chat-timeline/target-picker/WorkspacePicker.test.tsx +132 -0
  74. package/src/client/components/workspace/chat-timeline/target-picker/WorkspacePicker.tsx +120 -0
  75. package/src/client/components/workspace/chat-timeline/target-picker/WorktreePicker.test.tsx +319 -0
  76. package/src/client/components/workspace/chat-timeline/target-picker/WorktreePicker.tsx +285 -0
  77. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/index.test.ts +2 -0
  78. package/src/client/components/workspace/chat-timeline/tool-renderers/hashline-patch/index.tsx +34 -29
  79. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/AstEdit.tsx +11 -9
  80. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Bash.tsx +10 -7
  81. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Edit.tsx +25 -8
  82. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Eval.test.tsx +146 -0
  83. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Eval.tsx +32 -17
  84. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/FileMatches.test.ts +114 -0
  85. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/FileMatches.tsx +111 -0
  86. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Read.tsx +7 -7
  87. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/SearchTool.tsx +9 -59
  88. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/ArtifactBody.tsx +2 -7
  89. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/DiffView.test.ts +27 -28
  90. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/DiffView.tsx +39 -26
  91. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/ExcerptCode.tsx +8 -8
  92. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/HighlightedCode.tsx +35 -0
  93. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/JsonCodeBlock.tsx +4 -4
  94. package/src/client/components/workspace/diff-panel/MergeDiffView.tsx +179 -0
  95. package/src/client/components/workspace/diff-panel/ThemedMergeDiffView.tsx +24 -0
  96. package/src/client/components/workspace/diff-panel/Toolbar.tsx +224 -186
  97. package/src/client/components/workspace/diff-panel/ToolbarSegments.tsx +98 -0
  98. package/src/client/components/workspace/diff-panel/index.tsx +76 -145
  99. package/src/client/components/workspace/diff-panel/merge.ts +315 -0
  100. package/src/client/components/workspace/editor/Body.tsx +4 -7
  101. package/src/client/components/workspace/editor/CommandPalette.tsx +2 -2
  102. package/src/client/components/workspace/editor/FileDocument.tsx +58 -63
  103. package/src/client/components/workspace/editor/FilePane.tsx +4 -7
  104. package/src/client/components/workspace/editor/FindWidget.tsx +7 -2
  105. package/src/client/components/workspace/editor/Toolbar.tsx +220 -123
  106. package/src/client/components/workspace/editor/index.tsx +7 -9
  107. package/src/client/components/workspace/file-explorer/ContextMenu.tsx +2 -1
  108. package/src/client/components/workspace/file-explorer/Modals.tsx +40 -12
  109. package/src/client/components/workspace/file-explorer/OutlineSection.test.tsx +18 -0
  110. package/src/client/components/workspace/file-explorer/OutlineSection.tsx +10 -3
  111. package/src/client/components/workspace/file-explorer/RootMenu.tsx +14 -5
  112. package/src/client/components/workspace/file-explorer/Toolbar.tsx +74 -0
  113. package/src/client/components/workspace/file-explorer/TreeItem.tsx +38 -3
  114. package/src/client/components/workspace/file-explorer/filter.ts +47 -0
  115. package/src/client/components/workspace/file-explorer/index.tsx +77 -64
  116. package/src/client/components/workspace/file-explorer/local-edit.test.ts +135 -0
  117. package/src/client/components/workspace/search-panel/Body.tsx +1 -1
  118. package/src/client/components/workspace/worktree-modal/index.test.tsx +118 -0
  119. package/src/client/components/workspace/worktree-modal/index.tsx +261 -0
  120. package/src/client/hooks/chat/omp/reconnect.test.ts +338 -0
  121. package/src/client/hooks/chat/omp/reconnect.ts +187 -0
  122. package/src/client/hooks/chat/omp/run-liveness.test.ts +71 -0
  123. package/src/client/hooks/chat/omp/run-liveness.ts +65 -0
  124. package/src/client/hooks/chat/omp/session-list.activity.test.ts +114 -0
  125. package/src/client/hooks/chat/omp/session-list.test-util.ts +164 -0
  126. package/src/client/hooks/chat/omp/session-list.test.ts +30 -150
  127. package/src/client/hooks/chat/omp/session-list.tsx +69 -9
  128. package/src/client/hooks/chat/timeline/generating.ts +19 -2
  129. package/src/client/hooks/chat/timeline/index.ts +27 -28
  130. package/src/client/hooks/chat/timeline/lost-run.test.ts +329 -0
  131. package/src/client/hooks/chat/timeline/lost-run.ts +177 -0
  132. package/src/client/hooks/chat/timeline/send-execute.test.ts +2 -0
  133. package/src/client/hooks/chat/timeline/send-steer.test.ts +2 -0
  134. package/src/client/hooks/chat/timeline/send.test.ts +2 -0
  135. package/src/client/hooks/chat/timeline/send.ts +11 -1
  136. package/src/client/hooks/chat/timeline/session-load.test-util.ts +205 -0
  137. package/src/client/hooks/chat/timeline/session-load.test.ts +61 -162
  138. package/src/client/hooks/chat/timeline/session-load.ts +69 -37
  139. package/src/client/hooks/chat/timeline/session-switch.test.ts +65 -0
  140. package/src/client/hooks/chat/timeline/session-switch.ts +63 -0
  141. package/src/client/hooks/chat/timeline/spawn-target.ts +52 -0
  142. package/src/client/hooks/chat/timeline/tool-open.test.ts +74 -0
  143. package/src/client/hooks/chat/timeline/tool-open.ts +52 -8
  144. package/src/client/hooks/code/deferred-highlight.test.ts +182 -0
  145. package/src/client/hooks/code/deferred-highlight.ts +129 -0
  146. package/src/client/hooks/editor/find-state.ts +1 -1
  147. package/src/client/hooks/editor/theme.test.ts +206 -0
  148. package/src/client/hooks/editor/theme.ts +82 -0
  149. package/src/client/hooks/editor/use-editor-drop.test.ts +101 -0
  150. package/src/client/hooks/editor/use-editor-drop.ts +67 -14
  151. package/src/client/hooks/editor/use-editor-find.ts +7 -7
  152. package/src/client/hooks/editor/use-outline-view.test.ts +182 -0
  153. package/src/client/hooks/editor/use-outline-view.ts +94 -7
  154. package/src/client/hooks/editor/use-scroll-restore.test.ts +55 -2
  155. package/src/client/hooks/editor/use-scroll-restore.ts +124 -4
  156. package/src/client/hooks/editor/use-search-reveal.ts +1 -1
  157. package/src/client/hooks/ui/long-press.test.ts +174 -0
  158. package/src/client/hooks/ui/long-press.ts +112 -0
  159. package/src/client/hooks/ui/settings-modal.ts +56 -0
  160. package/src/client/hooks/ui/updates/shell-download.test.ts +75 -0
  161. package/src/client/hooks/ui/updates/shell-download.ts +153 -28
  162. package/src/client/hooks/ui/updates/shell-offer.test.ts +213 -0
  163. package/src/client/hooks/ui/updates/shell-update.test.ts +140 -0
  164. package/src/client/hooks/ui/updates/shell-update.ts +119 -0
  165. package/src/client/hooks/workspace/diff-file.ts +29 -10
  166. package/src/client/hooks/workspace/diff-git-actions.ts +147 -0
  167. package/src/client/hooks/workspace/editor-view.ts +61 -0
  168. package/src/client/hooks/workspace/file-create.ts +41 -11
  169. package/src/client/hooks/workspace/file-listing.ts +56 -1
  170. package/src/client/hooks/workspace/file-tabs.test.ts +62 -5
  171. package/src/client/hooks/workspace/file-tabs.ts +24 -11
  172. package/src/client/hooks/workspace/file-tree-actions.ts +37 -5
  173. package/src/client/hooks/workspace/navbar-session-actions.ts +72 -0
  174. package/src/client/hooks/workspace/worktree/index.ts +165 -0
  175. package/src/client/lib/desktop/bridge.ts +45 -0
  176. package/src/client/lib/search-reveal.ts +1 -1
  177. package/src/client/main.tsx +5 -1
  178. package/src/client/tailwind.css +56 -8
  179. package/src/server/index.ts +7 -2
  180. package/src/server/lib/auth/guard.test.ts +28 -0
  181. package/src/server/lib/auth/guard.ts +10 -0
  182. package/src/server/lib/bundler/titlebar-css.test.ts +42 -1
  183. package/src/server/lib/db/schema.ts +4 -0
  184. package/src/server/lib/db/worktree-registry.server.ts +91 -0
  185. package/src/server/lib/fs/canonical.ts +52 -0
  186. package/src/server/lib/fs/relative-path.ts +48 -0
  187. package/src/server/lib/fs/root-worktree.test.ts +116 -0
  188. package/src/server/lib/fs/root.ts +39 -10
  189. package/src/server/lib/fs/worktree/carry.ts +134 -0
  190. package/src/server/lib/fs/worktree/create.test.ts +249 -0
  191. package/src/server/lib/fs/worktree/create.ts +262 -0
  192. package/src/server/lib/fs/worktree/errors.ts +43 -0
  193. package/src/server/lib/fs/worktree/list.test.ts +207 -0
  194. package/src/server/lib/fs/worktree/list.ts +142 -0
  195. package/src/server/lib/fs/worktree/location.test.ts +206 -0
  196. package/src/server/lib/fs/worktree/location.ts +114 -0
  197. package/src/server/lib/fs/worktree/project-id.ts +53 -0
  198. package/src/server/lib/fs/worktree/remove.test.ts +242 -0
  199. package/src/server/lib/fs/worktree/remove.ts +152 -0
  200. package/src/server/lib/fs/worktree/settings.server.ts +56 -0
  201. package/src/server/lib/fs/worktree/topology.ts +142 -0
  202. package/src/server/lib/lifecycle/claim-port.test.ts +156 -0
  203. package/src/server/lib/lifecycle/port-guard.test.ts +20 -164
  204. package/src/server/lib/lifecycle/port-guard.ts +39 -7
  205. package/src/server/lib/lifecycle/probe-health.test.ts +166 -0
  206. package/src/server/lib/lifecycle/probe.test.ts +77 -164
  207. package/src/server/lib/lifecycle/probe.ts +121 -13
  208. package/src/server/lib/omp/rpc/frame-fold-signals.test.ts +112 -0
  209. package/src/server/lib/omp/rpc/frame-fold.ts +10 -5
  210. package/src/server/lib/omp/session/sidebar-activity.server.test.ts +162 -0
  211. package/src/server/lib/omp/session/sidebar-activity.server.ts +70 -0
  212. package/src/server/lib/omp/session/sidebar-data.canonical.test.ts +155 -0
  213. package/src/server/lib/omp/session/sidebar-data.server.ts +28 -1
  214. package/src/server/lib/omp/session/telemetry/compute.ts +13 -1
  215. package/src/server/lib/omp/session/telemetry.test.ts +33 -0
  216. package/src/server/lib/panels/local-edits.server.ts +33 -0
  217. package/src/server/lib/panels/sync.fixture.test-util.ts +92 -0
  218. package/src/server/lib/panels/sync.local-edits.test.ts +150 -0
  219. package/src/server/lib/panels/sync.server.test.ts +125 -147
  220. package/src/server/lib/panels/sync.server.ts +153 -48
  221. package/src/server/lib/realtime/signals.server.ts +10 -0
  222. package/src/server/lib/realtime/topics/catalog.server.ts +4 -1
  223. package/src/server/lib/realtime/topics.server.ts +16 -0
  224. package/src/server/lib/updates/shell-assets.test.ts +114 -0
  225. package/src/server/lib/updates/shell-assets.ts +200 -0
  226. package/src/server/lib/updates/shell-cache.ts +105 -0
  227. package/src/server/lib/updates/shell-pages.test.ts +187 -0
  228. package/src/server/lib/updates/shell-pages.ts +157 -0
  229. package/src/server/lib/updates/shell-release-desktop.test.ts +166 -0
  230. package/src/server/lib/updates/shell-release.test.ts +59 -71
  231. package/src/server/lib/updates/shell-release.ts +178 -198
  232. package/src/server/routes/fs/action-create.test.ts +18 -13
  233. package/src/server/routes/fs/action-nested.test.ts +184 -0
  234. package/src/server/routes/fs/action.ts +47 -16
  235. package/src/server/routes/fs/git-merge.test.ts +11 -1
  236. package/src/server/routes/fs/read.test.ts +24 -7
  237. package/src/server/routes/fs/scope.test.ts +15 -10
  238. package/src/server/routes/fs/scope.ts +3 -14
  239. package/src/server/routes/index.ts +2 -0
  240. package/src/server/routes/updates/android.ts +6 -1
  241. package/src/server/routes/updates/desktop.ts +59 -0
  242. package/src/server/routes/updates/index.ts +2 -0
  243. package/src/server/routes/updates/ios.ts +5 -1
  244. package/src/server/routes/worktrees/create.ts +138 -0
  245. package/src/server/routes/worktrees/index.ts +25 -0
  246. package/src/server/routes/worktrees/list.ts +134 -0
  247. package/src/server/routes/worktrees/remove.ts +102 -0
  248. package/src/server/routes/worktrees/routes.test.ts +326 -0
  249. package/src/server/routes/worktrees/shared.ts +90 -0
  250. package/src/shared/lib/chat/timeline/omp-callbacks.test.ts +36 -0
  251. package/src/shared/lib/chat/timeline/omp-callbacks.ts +18 -0
  252. package/src/shared/lib/chat/timeline/run-signals.test.ts +2 -0
  253. package/src/shared/lib/code/editor/comment-syntax.test.ts +37 -0
  254. package/src/shared/lib/code/editor/comment-syntax.ts +55 -0
  255. package/src/shared/lib/code/editor/find-replace.test.ts +134 -0
  256. package/src/shared/lib/code/editor/{find.ts → find-replace.ts} +23 -107
  257. package/src/shared/lib/code/editor/{find.test.ts → find-scan.test.ts} +19 -73
  258. package/src/shared/lib/code/editor/find-scan.ts +177 -0
  259. package/src/shared/lib/code/editor/keymap.ts +3 -3
  260. package/src/shared/lib/code/editor/lines.test.ts +7 -55
  261. package/src/shared/lib/code/editor/lines.ts +8 -48
  262. package/src/shared/lib/code/editor/marks.test.ts +4 -5
  263. package/src/shared/lib/code/editor/marks.ts +13 -25
  264. package/src/shared/lib/code/editor/theme-loader.ts +97 -0
  265. package/src/shared/lib/code/editor/themes.generated.ts +156 -0
  266. package/src/shared/lib/code/editor/themes.test.ts +165 -0
  267. package/src/shared/lib/code/editor/themes.ts +165 -0
  268. package/src/shared/lib/code/highlight-memo.test.ts +103 -0
  269. package/src/shared/lib/code/highlight-memo.ts +149 -0
  270. package/src/shared/lib/code/highlighter-warmup.ts +112 -0
  271. package/src/shared/lib/code/highlighter.ts +3 -1
  272. package/src/shared/lib/code/idle.ts +37 -0
  273. package/src/shared/lib/code/outline/rows.test.ts +96 -0
  274. package/src/shared/lib/code/outline/rows.ts +94 -0
  275. package/src/shared/lib/code/syntax-highlight.test.ts +1 -79
  276. package/src/shared/lib/code/syntax-highlight.ts +2 -3
  277. package/src/shared/lib/fs/file-kind-classify.test.ts +25 -11
  278. package/src/shared/lib/fs/file-kind.ts +47 -13
  279. package/src/shared/lib/fs/file-tree.test.ts +183 -0
  280. package/src/shared/lib/fs/file-tree.ts +159 -0
  281. package/src/shared/lib/fs/file-url.test.ts +52 -0
  282. package/src/shared/lib/fs/file-url.ts +37 -0
  283. package/src/shared/lib/fs/patch-sides.test.ts +114 -0
  284. package/src/shared/lib/fs/patch-sides.ts +139 -0
  285. package/src/shared/lib/fs/paths.test.ts +24 -1
  286. package/src/shared/lib/fs/paths.ts +17 -0
  287. package/src/shared/lib/fs/worktree/settings-keys.ts +18 -0
  288. package/src/shared/lib/omp/session/prewarm.ts +7 -0
  289. package/src/shared/lib/realtime/protocol.ts +16 -0
  290. package/src/shared/lib/ui/toolbar-overflow.test.ts +83 -0
  291. package/src/shared/lib/ui/toolbar-overflow.ts +86 -0
  292. package/src/shared/lib/updates/shell-arch.ts +54 -0
  293. package/src/shared/lib/updates/shell-ua.ts +24 -4
  294. package/src/shared/lib/workspace/active-project.ts +23 -0
  295. package/src/shared/lib/workspace/editor-view-mode.test.ts +70 -0
  296. package/src/shared/lib/workspace/editor-view-mode.ts +48 -0
  297. package/src/shared/lib/workspace/panel-widths.test.ts +30 -0
  298. package/src/shared/lib/workspace/panel-widths.ts +25 -5
  299. package/src/shared/lib/workspace/session-state/cache.ts +159 -0
  300. package/src/shared/lib/workspace/session-state/harness.test-util.ts +55 -0
  301. package/src/shared/lib/workspace/session-state/merge.ts +100 -0
  302. package/src/shared/lib/workspace/session-state/store-load.test.ts +206 -0
  303. package/src/shared/lib/workspace/session-state/store.test.ts +17 -139
  304. package/src/shared/lib/workspace/session-state/store.ts +92 -130
  305. package/src/shared/lib/workspace/workspace-modules.test.ts +49 -1
  306. package/src/shared/lib/workspace/workspace-panel-widths.ts +124 -0
  307. package/src/shared/lib/workspace/worktree/branch-name.test.ts +95 -0
  308. package/src/shared/lib/workspace/worktree/branch-name.ts +115 -0
  309. package/src/shared/lib/workspace/worktree/types.ts +104 -0
  310. package/src/shared/types/fs.ts +10 -0
  311. package/src/shared/types/realtime.ts +29 -0
  312. package/src/shared/types/settings/state.ts +10 -0
  313. package/src/shared/types/workspace.ts +14 -0
  314. package/src/test-support/file-explorer-disk.test-util.ts +76 -0
  315. package/src/test-support/file-explorer.test-util.ts +71 -40
  316. package/src/test-support/loopback.ts +183 -0
  317. package/dist/client/static/js/index-nzyjf92m.css +0 -1
  318. package/src/client/components/common/code-editor/handler.test.ts +0 -274
  319. package/src/client/components/common/code-editor/handler.ts +0 -175
  320. package/src/client/components/common/code-editor/index.tsx +0 -314
  321. package/src/client/components/common/code-surface/measure.ts +0 -148
  322. package/src/client/components/workspace/diff-panel/SplitView.tsx +0 -209
  323. package/src/client/components/workspace/diff-panel/UnifiedView.tsx +0 -150
  324. package/src/client/hooks/editor/scroll-container.test.ts +0 -102
  325. package/src/client/hooks/editor/scroll-container.ts +0 -26
  326. package/src/client/hooks/editor/use-code-window.ts +0 -100
  327. package/src/client/hooks/editor/use-editor-history.test.ts +0 -216
  328. package/src/client/hooks/editor/use-editor-history.ts +0 -134
  329. package/src/client/hooks/editor/use-occurrences.test.ts +0 -236
  330. package/src/client/hooks/editor/use-occurrences.ts +0 -98
  331. package/src/shared/lib/code/editor/commands.test.ts +0 -155
  332. package/src/shared/lib/code/editor/commands.ts +0 -260
  333. package/src/shared/lib/code/editor/edits.test.ts +0 -100
  334. package/src/shared/lib/code/editor/edits.ts +0 -122
  335. package/src/shared/lib/code/editor/history.test.ts +0 -118
  336. package/src/shared/lib/code/editor/history.ts +0 -101
  337. package/src/shared/lib/code/editor/layers.test.ts +0 -77
  338. package/src/shared/lib/code/editor/layers.ts +0 -67
  339. package/src/shared/lib/code/editor/line-edits.test.ts +0 -131
  340. package/src/shared/lib/code/editor/line-edits.ts +0 -159
  341. package/src/shared/lib/code/editor/occurrences.test.ts +0 -117
  342. package/src/shared/lib/code/editor/occurrences.ts +0 -111
  343. package/src/shared/lib/code/editor/reset-css.test.ts +0 -35
  344. package/src/shared/lib/code/editor/reset-css.ts +0 -32
  345. package/src/shared/lib/code/editor/text-commands.test.ts +0 -178
  346. package/src/shared/lib/code/editor/text-commands.ts +0 -154
  347. package/src/shared/lib/code/lazy-window.test.ts +0 -161
  348. package/src/shared/lib/code/lazy-window.ts +0 -156
  349. package/src/shared/lib/code/windowed-highlight.ts +0 -114
  350. package/src/shared/lib/fs/diff-parser.test.ts +0 -116
  351. package/src/shared/lib/fs/diff-parser.ts +0 -213
@@ -1,5 +1,6 @@
1
1
  import { useCallback, useRef, useState } from 'preact/hooks';
2
2
  import { useTimelineScope } from '@/client/hooks/chat/timeline/scope';
3
+ import { useSpawnTarget } from '@/client/hooks/chat/timeline/spawn-target';
3
4
  import type { ChatMessageData } from '@/shared/types';
4
5
  import { useOmpAgent } from '@/client/hooks/chat/omp';
5
6
  import { useChatTimelineQueue } from '@/client/hooks/chat/timeline/queue';
@@ -17,7 +18,7 @@ import { cancelStreamingCoalescer } from '@/shared/lib/chat/timeline/stream-coal
17
18
  import { useChatTimelineAccessMode } from '@/client/hooks/chat/timeline/access-mode';
18
19
  import { useComposerModes } from '@/client/hooks/chat/timeline/composer-modes';
19
20
  import { useComposerDraft } from '@/client/hooks/chat/composer/use-draft';
20
- import { isChatRunning } from '@/client/hooks/chat/omp/session-statuses';
21
+ import { isChatRunning, isSessionStreaming } from '@/client/hooks/chat/omp/session-statuses';
21
22
  import { useWaitingDependencyChoice } from '@/client/hooks/chat/waiting/choice';
22
23
  import { useComposerRefs } from '@/client/hooks/chat/timeline/composer-refs';
23
24
 
@@ -40,10 +41,8 @@ export function useChatTimeline({
40
41
  }: UseChatTimelineOptions = {}) {
41
42
  const { sessionId, folderId, selectedFolderId, selectContextFolder, setSearchParams } = useTimelineScope();
42
43
  // Plan/goal toggles plus the plan-review surface. PER SESSION, unlike the
43
- // access mode: they describe what this conversation is doing, and a global
44
- // toggle would drag every other open chat into the same mode. One hook
45
- // returns both halves, because a mode command can produce a proposal and
46
- // answering that proposal is itself a mode command.
44
+ // access mode: they describe what this conversation is doing, and one hook
45
+ // returns both halves because a mode command can produce a proposal.
47
46
  const { modes, planReview } = useComposerModes(sessionId);
48
47
 
49
48
  // Ref mirror of loadOlder so the scroll handler can call the latest one
@@ -57,8 +56,7 @@ export function useChatTimeline({
57
56
  const { inputValue, setInputValue, inputAttachments, setInputAttachments } = useComposerDraft();
58
57
  const [localMessages, setLocalMessages] = useState<ChatMessageData[]>([]);
59
58
 
60
- // A pending "new-…" session renders the workspace picker, which owns its own
61
- // timeline and scroll handling.
59
+ // A pending "new-…" session renders the workspace picker with its own timeline.
62
60
  const isPendingSession = Boolean(sessionId?.startsWith('new-'));
63
61
  // Committed history lands asynchronously (session-load.ts), so the container
64
62
  // paints at the top first; jump to the tail once per session when it does.
@@ -76,6 +74,7 @@ export function useChatTimeline({
76
74
  isGeneratingRef,
77
75
  ownRun,
78
76
  ownRunRef,
77
+ streamRunRef,
79
78
  setGenerating,
80
79
  generatingVerb,
81
80
  activityRef,
@@ -125,11 +124,16 @@ export function useChatTimeline({
125
124
  // not start is still reported by the server row and never by client state.
126
125
  const timelineRunning = isChatRunning(streamStatusMap, sessionId, ownRun);
127
126
 
127
+ // SERVER evidence a prompt was dispatched (the `stream` row), mirrored for
128
+ // the reconnect guard: a run whose FIRST frame was lost has no `agent_start`.
129
+ const serverRunRef = useRef(isSessionStreaming(streamStatusMap, sessionId));
130
+ serverRunRef.current = isSessionStreaming(streamStatusMap, sessionId);
131
+
128
132
  const aiPlaceholderIdRef = useRef<string | null>(null);
129
133
 
130
134
  const {
131
135
  sessionData,
132
- adoptedSessionIdRef, refreshSessionMeta, seedSession,
136
+ adoptedSessionIdRef, refreshSessionMeta, seedSession, reloadMessages, needsReloadRef,
133
137
  hasMore,
134
138
  loadingOlder, loadOlderError, sessionLoading, loadOlder, jumpToTurn,
135
139
  } = useSessionLoad({
@@ -138,8 +142,11 @@ export function useChatTimeline({
138
142
  setGenerating,
139
143
  isGeneratingRef,
140
144
  ownRunRef,
145
+ streamRunRef,
146
+ serverRunRef,
141
147
  aiPlaceholderIdRef,
142
148
  optimisticUserIdRef,
149
+ pendingUserDisplaysRef,
143
150
  cancelStreamingCoalescer,
144
151
  metaRefreshedRef,
145
152
  scrollRef,
@@ -147,15 +154,10 @@ export function useChatTimeline({
147
154
  jumpCountRef,
148
155
  });
149
156
  loadOlderRef.current = loadOlder;
150
-
151
157
  // Jump rail: the full-history turn index plus the paging walk that reaches a
152
158
  // turn outside the mounted window. The walk is wrapped so follow mode stands
153
159
  // down until the jump lands (see rail-jump.ts).
154
- const { turns: userTurns, jumping: jumpingTurn, jumpToTurn: pagedJumpToTurn } = useUserTurns({
155
- sessionId,
156
- messages: localMessages,
157
- jumpToTurn,
158
- });
160
+ const { turns: userTurns, jumping: jumpingTurn, jumpToTurn: pagedJumpToTurn } = useUserTurns({ sessionId, messages: localMessages, jumpToTurn });
159
161
  const jumpToUserTurn = useRailJump(pagedJumpToTurn, { followRef, scrollRef });
160
162
 
161
163
  // The composer's model/thinking mirrors and the mid-run stash (see the hook).
@@ -210,7 +212,11 @@ export function useChatTimeline({
210
212
  appSettings,
211
213
  enqueueExtensionDialog,
212
214
  withdrawExtensionDialog,
215
+ reloadMessages,
216
+ needsReloadRef,
213
217
  }), activityRef);
218
+ const spawn = useSpawnTarget(sessionId);
219
+
214
220
  const { steerOmpAgent, executeSend } = useChatTimelineSend({
215
221
  folders,
216
222
  selectedFolderId,
@@ -234,27 +240,18 @@ export function useChatTimeline({
234
240
  deferredComposerPickRef,
235
241
  accessModeRef,
236
242
  spawnSelectionRef: modes.spawnSelectionRef,
243
+ spawnWorktreeRef: spawn.worktreePathRef,
237
244
  abortControllerRef,
238
245
  setInputValue,
239
246
  setSearchParams,
240
247
  });
241
248
 
242
249
  // Queue auto-delivery moved server-side: the wrapper's terminal `agent_end`
243
- // schedules the next queued prompt (`lib/queue/delivery.server.ts`), with a
244
- // transactional head claim so no reload/tab race can double-send. The client
245
- // panel is a view; `stopHoldRef` remains only for the mock path below.
250
+ // schedules the next queued prompt with a transactional head claim, so no
251
+ // reload/tab race can double-send. `stopHoldRef` serves only the mock path.
246
252
 
247
- const {
248
- handleSend,
249
- handleEditQueueItem,
250
- handleSendNowQueueItem,
251
- handleUndo,
252
- handleRetry,
253
- submitNewChat,
254
- stopGenerating,
255
- handleThinkingLevelChange,
256
- handleModelChange,
257
- } = useChatTimelineActions({
253
+ const { handleSend, handleEditQueueItem, handleSendNowQueueItem, handleUndo, handleRetry,
254
+ submitNewChat, stopGenerating, handleThinkingLevelChange, handleModelChange } = useChatTimelineActions({
258
255
  inputValue,
259
256
  setInputValue,
260
257
  setInputAttachments,
@@ -339,6 +336,8 @@ export function useChatTimeline({
339
336
  composerModelRef,
340
337
  deferredComposerPickRef,
341
338
  waitingDependency,
339
+ spawnTarget: spawn.target,
340
+ setSpawnTarget: spawn.setTarget,
342
341
  extensionDialogs,
343
342
  resolveExtensionDialog,
344
343
  respondToExtensionUi: ompAgent.respondToExtensionUi,
@@ -0,0 +1,329 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Recovery of a run whose terminal frames were lost with the channel.
8
+ *
9
+ * The reported failure, end to end: a streaming chat left in a background tab,
10
+ * the socket torn down, the turn finished with nobody reading. The timeline
11
+ * kept the stream's last PARTIAL segment and its generating flag never
12
+ * released, while the server's `stream` row had already settled — so the
13
+ * indicator stopped and the answer stayed cut off mid-sentence.
14
+ *
15
+ * Mounted through the REAL `useSessionLoad`, because the repair is a reload of
16
+ * that hook's own state: asserting on the recovery hook's calls would pass
17
+ * while the timeline kept the partial row.
18
+ */
19
+
20
+ import { afterAll, afterEach, beforeAll, describe, expect, test } from 'bun:test';
21
+ import { Window } from 'happy-dom';
22
+ import { h, render } from 'preact';
23
+ import { act } from 'preact/test-utils';
24
+ import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
25
+
26
+ import { installDomGlobals, restoreDomGlobals } from '@/test-support/pristine-globals';
27
+ import { useSessionLoad } from '@/client/hooks/chat/timeline/session-load';
28
+ import { resetConnection } from '@/shared/lib/realtime/connection';
29
+ import { setStatus } from '@/shared/lib/realtime/state';
30
+ import type { ChatMessageData } from '@/shared/types';
31
+
32
+ const originalFetch = Bun.fetch;
33
+ const PARTIAL: ChatMessageData = { id: 'omp-ai', role: 'ai', content: 'The answer stops mid-sent' };
34
+ const COMMITTED: ChatMessageData[] = [
35
+ { id: 'omp-user', role: 'user', content: 'tolong jelaskan' },
36
+ { id: 'omp-ai', role: 'ai', content: 'The answer, in full.' },
37
+ ];
38
+
39
+ let container: HTMLElement | undefined;
40
+ /** Render-triggered waiters, so a case can await the state it asserts on. */
41
+ const messageWaiters: { match: (rows: ChatMessageData[]) => boolean; resolve: () => void }[] = [];
42
+
43
+ beforeAll(() => {
44
+ installDomGlobals(new Window({ url: 'http://localhost' }));
45
+ });
46
+
47
+ afterAll(() => {
48
+ restoreDomGlobals();
49
+ globalThis.fetch = originalFetch;
50
+ });
51
+
52
+ afterEach(() => {
53
+ if (container) render(null, container);
54
+ container?.remove();
55
+ container = undefined;
56
+ resetConnection();
57
+ globalThis.fetch = originalFetch;
58
+ });
59
+
60
+ interface Api {
61
+ messages: ChatMessageData[];
62
+ generating: boolean;
63
+ setMessages: (next: ChatMessageData[]) => void;
64
+ /** Arm the state a dropped run leaves behind. */
65
+ loseRun: () => void;
66
+ /** The recovery's own flag: set when a still-live run lost segments, which
67
+ * is what schedules the read at its settle. A ref, not a render value — the
68
+ * flag is written without a state change, and no consumer renders for it. */
69
+ needsReloadRef: { current: boolean };
70
+ /** Run the reconnect: open → closed → open. */
71
+ reconnect: () => Promise<void>;
72
+ /** Resolves on the render where the timeline holds rows matching `match`:
73
+ * the retry is timer-driven, so this is the signal to wait on. */
74
+ awaitMessages: (match: (rows: ChatMessageData[]) => boolean) => Promise<void>;
75
+ }
76
+
77
+ interface ProbeProps {
78
+ api: Api;
79
+ }
80
+
81
+ function Probe({ api }: ProbeProps) {
82
+ const [messages, setMessages] = useState<ChatMessageData[]>([]);
83
+ const isGeneratingRef = useRef(false);
84
+ const ownRunRef = useRef(false);
85
+ const streamRunRef = useRef(false);
86
+ const serverRunRef = useRef(false);
87
+ const aiPlaceholderIdRef = useRef<string | null>(null);
88
+ const optimisticUserIdRef = useRef<string | null>(null);
89
+ const pendingUserDisplaysRef = useRef<{ sent: string; display: string }[]>([]);
90
+ const metaRefreshedRef = useRef<string | null>(null);
91
+ const scrollRef = useRef<HTMLDivElement>(null);
92
+ const jumpActiveRef = useRef(false);
93
+ const jumpCountRef = useRef(0);
94
+ const [generating, setGeneratingState] = useState(false);
95
+ useEffect(() => {
96
+ for (const waiter of [...messageWaiters]) {
97
+ if (!waiter.match(messages)) continue;
98
+ messageWaiters.splice(messageWaiters.indexOf(waiter), 1);
99
+ waiter.resolve();
100
+ }
101
+ }, [messages]);
102
+ const setGenerating = useCallback((v: boolean) => {
103
+ isGeneratingRef.current = v;
104
+ setGeneratingState(v);
105
+ }, []);
106
+
107
+ api.messages = messages;
108
+ api.awaitMessages = (match) => {
109
+ if (match(messages)) return Promise.resolve();
110
+ const { promise, resolve } = Promise.withResolvers<void>();
111
+ messageWaiters.push({ match, resolve });
112
+ return promise;
113
+ };
114
+ api.generating = generating;
115
+ api.setMessages = setMessages;
116
+ api.loseRun = () => {
117
+ isGeneratingRef.current = true;
118
+ // The stream had announced this run — that is the state a lost run is in
119
+ // (a dispatch whose first frame never arrived is a different case, and the
120
+ // recovery deliberately refuses it).
121
+ streamRunRef.current = true;
122
+ serverRunRef.current = true;
123
+ setGeneratingState(true);
124
+ aiPlaceholderIdRef.current = 'omp-ai';
125
+ setMessages([PARTIAL]);
126
+ };
127
+ api.reconnect = async () => {
128
+ await act(async () => {
129
+ setStatus('open');
130
+ setStatus('closed');
131
+ setStatus('connecting');
132
+ setStatus('open');
133
+ for (let i = 0; i < 10; i += 1) await Promise.resolve();
134
+ });
135
+ };
136
+
137
+ const load = useSessionLoad({
138
+ sessionId: 'omp-1',
139
+ setLocalMessages: setMessages,
140
+ setGenerating,
141
+ isGeneratingRef,
142
+ ownRunRef,
143
+ streamRunRef,
144
+ serverRunRef,
145
+ aiPlaceholderIdRef,
146
+ optimisticUserIdRef,
147
+ pendingUserDisplaysRef,
148
+ cancelStreamingCoalescer: () => {},
149
+ metaRefreshedRef,
150
+ scrollRef,
151
+ jumpActiveRef,
152
+ jumpCountRef,
153
+ });
154
+ api.needsReloadRef = load.needsReloadRef;
155
+ return null;
156
+ }
157
+
158
+ /** Route the two reads this path makes: the transcript, and the attach probe.
159
+ * The transcript is EMPTY until `arm()` — a run still in flight has not
160
+ * committed the turn it is streaming, and arming only after the drop is what
161
+ * makes a reload observable (an always-populated route would let a broken
162
+ * path pass by having the initial load paint the same rows). */
163
+ interface StubRoutes {
164
+ transcriptReads: number;
165
+ arm: () => void;
166
+ /** Answer the next N transcript reads with an EMPTY list — omp buffers its
167
+ * writes, so the turn that just finished is not on disk yet. */
168
+ emptyReads: (count: number) => void;
169
+ /** Resolves once the transcript has been read `count` times: the retry is
170
+ * timer-driven, so this is the signal to wait on rather than a duration. */
171
+ readsReached: (count: number) => Promise<void>;
172
+ /** Resolves on the FIRST transcript read, so the harness can wait for the
173
+ * initial load to land instead of guessing a delay. */
174
+ firstRead: Promise<void>;
175
+ }
176
+
177
+ function stubRoutes(probeAnswer: unknown): StubRoutes {
178
+ const seen = { transcriptReads: 0, armed: false, empty: 0 };
179
+ const waiters: { count: number; resolve: () => void }[] = [];
180
+ const settleWaiters = (): void => {
181
+ for (const waiter of [...waiters]) {
182
+ if (seen.transcriptReads < waiter.count) continue;
183
+ waiters.splice(waiters.indexOf(waiter), 1);
184
+ waiter.resolve();
185
+ }
186
+ };
187
+ const firstRead = Promise.withResolvers<void>();
188
+ globalThis.fetch = (async (input: unknown) => {
189
+ const url = String(input);
190
+ if (url.includes('/api/agent/')) {
191
+ return new Response(JSON.stringify(probeAnswer), { status: 200, headers: { 'content-type': 'application/json' } });
192
+ }
193
+ seen.transcriptReads += 1;
194
+ firstRead.resolve();
195
+ settleWaiters();
196
+ const empty = !seen.armed || seen.empty > 0;
197
+ if (seen.empty > 0) seen.empty -= 1;
198
+ return new Response(JSON.stringify({
199
+ session: { id: 'omp-1', messages: empty ? [] : COMMITTED },
200
+ hasMore: false,
201
+ oldestIndex: 0,
202
+ }), { status: 200, headers: { 'content-type': 'application/json' } });
203
+ }) as unknown as typeof fetch;
204
+ return {
205
+ get transcriptReads() { return seen.transcriptReads; },
206
+ arm: () => { seen.armed = true; },
207
+ emptyReads: (count) => { seen.empty = count; },
208
+ readsReached: (count) => {
209
+ if (seen.transcriptReads >= count) return Promise.resolve();
210
+ const { promise, resolve } = Promise.withResolvers<void>();
211
+ waiters.push({ count, resolve });
212
+ return promise;
213
+ },
214
+ firstRead: firstRead.promise,
215
+ };
216
+ }
217
+
218
+ async function mount(probeAnswer: unknown): Promise<{ api: Api; routes: StubRoutes }> {
219
+ const routes = stubRoutes(probeAnswer);
220
+ const api = {} as Api;
221
+ container ??= document.body.appendChild(document.createElement('div'));
222
+ await act(async () => {
223
+ render(h(Probe, { api }), container as HTMLElement);
224
+ });
225
+ // The initial history read is a real promise chain (`json()` included), so a
226
+ // bare microtask flush is not enough — and a load landing AFTER the test arms
227
+ // its partial row would clear it, which is the harness racing the behaviour
228
+ // under test. Waiting on the read itself is deterministic. It is awaited
229
+ // OUTSIDE the render's `act`: effects flush when that callback returns, so a
230
+ // fetch issued by one cannot settle inside it.
231
+ await act(async () => {
232
+ await routes.firstRead;
233
+ for (let i = 0; i < 8; i += 1) await Promise.resolve();
234
+ });
235
+ return { api, routes };
236
+ }
237
+
238
+ describe('a run whose terminal frames were lost with the channel', () => {
239
+ test('settles the local run and replaces the partial answer with the transcript', async () => {
240
+ const { api, routes } = await mount({ running: false });
241
+ await act(async () => { api.loseRun(); routes.arm(); });
242
+ expect(api.messages).toEqual([PARTIAL]);
243
+ expect(api.generating).toBe(true);
244
+
245
+ await api.reconnect();
246
+
247
+ // The rows on screen were the stream's last PARTIAL segment; the session
248
+ // JSONL is the only record of the rest.
249
+ expect(api.messages).toEqual(COMMITTED);
250
+ expect(api.generating).toBe(false);
251
+ });
252
+
253
+ test('leaves a run the server still reports as live alone — but remembers it', async () => {
254
+ // Reloading mid-turn would replace the stream's own rows with a lagging
255
+ // snapshot, so nothing is applied here. What must NOT be lost is the fact
256
+ // that segments finished during the outage: their `message_end` went into
257
+ // the dead socket, and only a full read once the run is over recovers them.
258
+ const { api, routes } = await mount({ running: true, state: { isStreaming: true, isPromptRunning: true } });
259
+ await act(async () => { api.loseRun(); routes.arm(); });
260
+
261
+ await api.reconnect();
262
+
263
+ expect(api.messages).toEqual([PARTIAL]);
264
+ expect(api.generating).toBe(true);
265
+ expect(api.needsReloadRef.current).toBe(true);
266
+ });
267
+
268
+ test('a first dial is not a reconnect and does not settle the run', async () => {
269
+ const { api, routes } = await mount({ running: false });
270
+ await act(async () => { api.loseRun(); routes.arm(); });
271
+
272
+ await act(async () => {
273
+ setStatus('connecting');
274
+ setStatus('open');
275
+ for (let i = 0; i < 10; i += 1) await Promise.resolve();
276
+ });
277
+
278
+ expect(api.messages).toEqual([PARTIAL]);
279
+ expect(api.generating).toBe(true);
280
+ });
281
+
282
+ test('a transcript omp has not flushed yet is retried until the turn lands', async () => {
283
+ // omp buffers its transcript writes: the turn that just finished can be
284
+ // absent from the file for a beat after the channel returns. One read and
285
+ // stop would leave the partial answer on screen — the state this recovery
286
+ // exists to clear — so an EMPTY answer is retried on the same front-loaded
287
+ // schedule the metadata refresh uses, and stops at the first real one.
288
+ //
289
+ // Fake timers, not a sleep: the retry is a real `setTimeout` chain, and the
290
+ // schedule is what this case is about.
291
+ const { api, routes } = await mount({ running: false });
292
+ await act(async () => {
293
+ api.loseRun();
294
+ routes.arm();
295
+ routes.emptyReads(2);
296
+ });
297
+
298
+ await api.reconnect();
299
+ // The first read came back empty, so the rows are still the partial answer.
300
+ expect(api.messages).toEqual([PARTIAL]);
301
+
302
+ // The retry is a real timer chain; wait on the RENDER that carries the
303
+ // committed rows, not on a guessed delay. Two empty answers, so the third
304
+ // read is the one with the turn in it.
305
+ // The retry is a real timer chain, and the rows land from a render it
306
+ // triggers. Awaited OUTSIDE `act`: preact defers effects until the act
307
+ // callback returns, so waiting for a render-driven signal inside one
308
+ // deadlocks.
309
+ await api.awaitMessages((rows) => rows.length === COMMITTED.length);
310
+
311
+ expect(api.messages).toEqual(COMMITTED);
312
+ expect(routes.transcriptReads).toBeGreaterThanOrEqual(4);
313
+
314
+ expect(api.messages).toEqual(COMMITTED);
315
+ expect(routes.transcriptReads).toBeGreaterThanOrEqual(3);
316
+ });
317
+
318
+ test('a lost run on a session this server does not own still settles', async () => {
319
+ // `running:false` is also the answer for a session owned by another
320
+ // instance; the frames are gone either way and the transcript is readable.
321
+ const { api, routes } = await mount({ running: false });
322
+ await act(async () => { api.loseRun(); routes.arm(); });
323
+
324
+ await api.reconnect();
325
+
326
+ expect(api.generating).toBe(false);
327
+ expect(api.messages).toEqual(COMMITTED);
328
+ });
329
+ });
@@ -0,0 +1,177 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Recover a run whose terminal frames were lost with the realtime channel.
8
+ *
9
+ * The reported failure: a streaming chat was left in a background tab, the
10
+ * socket was torn down, the turn finished while nothing was reading, and the
11
+ * user came back to an answer cut off mid-sentence with the docked indicator
12
+ * already gone. Both halves of that state come from the same missing frames —
13
+ * `message_end` never replaced the streaming row, and `agent_end` never
14
+ * released the local generating flag — while the SERVER's `stream` row settled
15
+ * normally, which is why the indicator stopped but the transcript did not.
16
+ *
17
+ * The trigger is the channel reopening while this page still believes a run is
18
+ * live (`useReconnectSettle`, which also asks the server whether the run is
19
+ * really over). Recovery is two steps and both are needed:
20
+ *
21
+ * 1. settle the local run — release the generating flag and drop the ids that
22
+ * only a live stream would consume (the AI placeholder, the optimistic user
23
+ * mark, pending steer displays);
24
+ * 2. re-read the committed transcript, because the rows on screen are the
25
+ * stream's last PARTIAL segment and the session JSONL is the only record of
26
+ * the rest.
27
+ *
28
+ * The reload is deliberately unconditional about ownership: a run this page
29
+ * dispatched is over by the time this fires, so the guard in `session-load.ts`
30
+ * (which exists to keep a LAGGING snapshot away from a live run) does not
31
+ * apply here.
32
+ *
33
+ * A run that is STILL LIVE at reconnect is a different case and is NOT
34
+ * reloaded here — its rows are the stream's, and replacing them mid-turn is the
35
+ * lagging-snapshot bug the guard exists for. But its earlier segments are gone:
36
+ * their `message_end` went into the dead socket, and `agent_end` only
37
+ * materializes turns that stopped abnormally (`materializeTerminalMessages`).
38
+ * So the loss is REMEMBERED (`needsReloadRef`) and the transcript is re-read
39
+ * when the run settles, which is the only point where a full read is safe.
40
+ */
41
+
42
+ import type { Dispatch, SetStateAction } from 'preact/compat';
43
+ import { useCallback, useRef } from 'preact/hooks';
44
+
45
+ import { SESSION_META_RETRY_SCHEDULE_MS } from '@/shared/lib/workspace/refresh-cadence';
46
+ import type { ChatMessageData } from '@/shared/types';
47
+ import { useReconnectSettle } from '@/client/hooks/chat/omp/reconnect';
48
+
49
+ export interface LostRunRecoveryDeps {
50
+ sessionId: string | null;
51
+ sessionIdRef: { current: string | null };
52
+ /** The id a spawn adopted, while the URL still carries the pending one. */
53
+ adoptedSessionIdRef: { current: string | null };
54
+ isGeneratingRef: { current: boolean };
55
+ /** The stream has announced the run in flight — see `useReconnectSettle`. */
56
+ streamRunRef: { current: boolean };
57
+ /** The server reports a run for this session (its `stream` row). */
58
+ serverRunRef: { current: boolean };
59
+ setGenerating: (v: boolean) => void;
60
+ setLocalMessages: Dispatch<SetStateAction<ChatMessageData[]>>;
61
+ /** Pagination cursor for the window the reload applies. */
62
+ applyWindow: (hasMore: unknown, oldestIndex: unknown) => void;
63
+ aiPlaceholderIdRef: { current: string | null };
64
+ optimisticUserIdRef: { current: string | null };
65
+ pendingUserDisplaysRef: { current: { sent: string; display: string }[] };
66
+ cancelStreamingCoalescer: () => void;
67
+ }
68
+
69
+ export interface LostRunRecovery {
70
+ /** Re-read the committed transcript now (used by the run's own settle path
71
+ * when `needsReloadRef` is set — see `omp-callbacks.ts`). */
72
+ reloadMessages: () => void;
73
+ /** Set when a run that lost frames is still streaming: its earlier segments
74
+ * can only be recovered by a full read once the run is over. */
75
+ needsReloadRef: { current: boolean };
76
+ }
77
+
78
+ export function useLostRunRecovery(deps: LostRunRecoveryDeps): LostRunRecovery {
79
+ const {
80
+ sessionId,
81
+ sessionIdRef,
82
+ adoptedSessionIdRef,
83
+ isGeneratingRef,
84
+ streamRunRef,
85
+ serverRunRef,
86
+ setGenerating,
87
+ setLocalMessages,
88
+ applyWindow,
89
+ aiPlaceholderIdRef,
90
+ optimisticUserIdRef,
91
+ pendingUserDisplaysRef,
92
+ cancelStreamingCoalescer,
93
+ } = deps;
94
+
95
+ /** Replace the timeline with the session's committed transcript.
96
+ *
97
+ * A FAILED read leaves the rows alone. An EMPTY one is retried on the same
98
+ * front-loaded schedule `refreshSessionMeta` uses, and for the same reason:
99
+ * omp buffers its transcript writes, so the turn that just finished may not
100
+ * be on disk yet when the channel comes back — reading once and stopping
101
+ * would leave the partial answer in place, which is the very state this
102
+ * recovery exists to clear. The retry is bounded and stops at the first
103
+ * non-empty answer. */
104
+ const reloadMessages = useCallback(() => {
105
+ const current = sessionIdRef.current;
106
+ // A spawn answers with omp's id a beat before the URL carries it, so the
107
+ // adopted id is the one with a transcript when the pending id is still in
108
+ // the URL.
109
+ const target = current && !current.startsWith('new-')
110
+ ? current
111
+ : adoptedSessionIdRef.current;
112
+ if (!target) return;
113
+ let attempt = 0;
114
+ const tryRead = (): void => {
115
+ fetch(`/api/chat/${encodeURIComponent(target)}`)
116
+ .then(res => res.json())
117
+ .then(data => {
118
+ // The session may have moved while the read was in flight; applying
119
+ // it would paint one conversation's rows into another.
120
+ const now = sessionIdRef.current;
121
+ if (now !== target && adoptedSessionIdRef.current !== target) return;
122
+ if (!data?.session) return;
123
+ const fetched: ChatMessageData[] = data.session.messages || [];
124
+ if (fetched.length > 0) {
125
+ setLocalMessages(fetched);
126
+ applyWindow(data.hasMore, data.oldestIndex);
127
+ return;
128
+ }
129
+ const delay = SESSION_META_RETRY_SCHEDULE_MS[attempt];
130
+ attempt += 1;
131
+ if (delay !== undefined) setTimeout(tryRead, delay);
132
+ })
133
+ .catch(() => {});
134
+ };
135
+ tryRead();
136
+ }, [sessionIdRef, adoptedSessionIdRef, setLocalMessages, applyWindow]);
137
+
138
+ const needsReloadRef = useRef(false);
139
+
140
+ useReconnectSettle({
141
+ // A pending `new-…` chat has no omp child and no committed transcript: its
142
+ // run is covered by the pending view, and settling it here would drop the
143
+ // optimistic rows of a spawn still in flight.
144
+ sessionId: sessionId?.startsWith('new-') ? null : sessionId,
145
+ isGeneratingRef,
146
+ streamRunRef,
147
+ serverRunRef,
148
+ // Recorded at the DROP: the segments that finished during the outage have
149
+ // no other way back, and the read that recovers them is only safe once the
150
+ // run is over — so the flag is consumed by `onAgentEnd` (`omp-callbacks.ts`)
151
+ // rather than read here.
152
+ onFramesLost: () => { needsReloadRef.current = true; },
153
+ onSettled: () => {
154
+ setGenerating(false);
155
+ // Queued updates are the same lost segment's tail: the reload is
156
+ // authoritative, and applying them would paint a partial answer over the
157
+ // committed one.
158
+ cancelStreamingCoalescer();
159
+ optimisticUserIdRef.current = null;
160
+ pendingUserDisplaysRef.current = [];
161
+ // The placeholder mark is released, but its ROW is not deleted here: the
162
+ // committed read below is what replaces the rows, and dropping the only
163
+ // row first would blank the transcript for the retry window — and leave
164
+ // it blank for good if omp never flushed the turn. A row the stream left
165
+ // mid-flight also carries `thinking.isGenerating`, which is what pulses
166
+ // the collapsed accordion; the run is over, so nothing is generating.
167
+ aiPlaceholderIdRef.current = null;
168
+ setLocalMessages(prev => prev.map(m => (typeof m.thinking === 'object' && m.thinking?.isGenerating
169
+ ? { ...m, thinking: { ...m.thinking, isGenerating: false } }
170
+ : m)));
171
+ needsReloadRef.current = false;
172
+ reloadMessages();
173
+ },
174
+ });
175
+
176
+ return { reloadMessages, needsReloadRef };
177
+ }
@@ -160,6 +160,7 @@ function makeSendHarness(options: {
160
160
  const params: string[] = [];
161
161
  const seeds: SessionSeed[] = [];
162
162
  const spawnSelectionRef = { current: { plan: true, goal: false } };
163
+ const spawnWorktreeRef = { current: null as string | null };
163
164
  const adoptedSessionIdRef = { current: null };
164
165
  const deps: ChatTimelineSendDeps = {
165
166
  folders: options.folders ?? [],
@@ -190,6 +191,7 @@ function makeSendHarness(options: {
190
191
  deferredComposerPickRef: options.deferredPick ?? { current: null },
191
192
  accessModeRef: { current: 'always-ask' },
192
193
  spawnSelectionRef,
194
+ spawnWorktreeRef,
193
195
  abortControllerRef: { current: null },
194
196
  setInputValue: (v) => {
195
197
  drafts.push(v);
@@ -151,6 +151,7 @@ function makeSendHarness(options: {
151
151
  const params: string[] = [];
152
152
  const seeds: SessionSeed[] = [];
153
153
  const spawnSelectionRef = { current: { plan: true, goal: false } };
154
+ const spawnWorktreeRef = { current: null as string | null };
154
155
  const adoptedSessionIdRef = { current: null };
155
156
  const deps: ChatTimelineSendDeps = {
156
157
  folders: options.folders ?? [],
@@ -181,6 +182,7 @@ function makeSendHarness(options: {
181
182
  deferredComposerPickRef: options.deferredPick ?? { current: null },
182
183
  accessModeRef: { current: 'always-ask' },
183
184
  spawnSelectionRef,
185
+ spawnWorktreeRef,
184
186
  abortControllerRef: { current: null },
185
187
  setInputValue: (v) => {
186
188
  drafts.push(v);