@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
@@ -0,0 +1,338 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The lost-run trigger: a channel that comes back after frames were missed.
8
+ *
9
+ * The reported failure is a run whose `message_end`/`agent_end` were emitted
10
+ * into a socket nobody was reading (a backgrounded tab, a frozen phone), so the
11
+ * timeline kept the stream's last partial segment and its generating flag never
12
+ * released. What decides whether this hook acts is the CHANNEL, and the two
13
+ * conditions are pinned here because getting either wrong is destructive:
14
+ *
15
+ * - a dial is not a reconnect. A send whose `connect()` opens a closed channel
16
+ * passes through `connecting`/`open` too, and settling there would delete the
17
+ * optimistic turn of a prompt that is not even on the wire yet.
18
+ * - the server's verdict must be asked, and only `runIsOver` settles. An
19
+ * unanswered probe, a `busy` session and a live run all leave the timeline
20
+ * alone.
21
+ *
22
+ * The status publisher driven here is the one the connection layer calls
23
+ * (`setStatus`), so the trigger path is the real one; the socket itself is not
24
+ * what these cases are about.
25
+ */
26
+
27
+ import { afterAll, afterEach, beforeAll, describe, expect, test } from 'bun:test';
28
+ import { Window } from 'happy-dom';
29
+ import { h, render } from 'preact';
30
+ import { useRef } from 'preact/hooks';
31
+ import { act } from 'preact/test-utils';
32
+
33
+ import { installDomGlobals, restoreDomGlobals } from '@/test-support/pristine-globals';
34
+ import { useReconnectSettle } from '@/client/hooks/chat/omp/reconnect';
35
+ import { resetConnection } from '@/shared/lib/realtime/connection';
36
+ import { setStatus } from '@/shared/lib/realtime/state';
37
+
38
+ let container: HTMLElement | undefined;
39
+ const originalFetch = Bun.fetch;
40
+
41
+ beforeAll(() => {
42
+ installDomGlobals(new Window({ url: 'http://localhost' }));
43
+ });
44
+
45
+ afterAll(() => {
46
+ restoreDomGlobals();
47
+ globalThis.fetch = originalFetch;
48
+ });
49
+
50
+ afterEach(() => {
51
+ if (container) render(null, container);
52
+ container?.remove();
53
+ container = undefined;
54
+ resetConnection();
55
+ globalThis.fetch = originalFetch;
56
+ });
57
+
58
+ interface Harness {
59
+ settles: () => number;
60
+ losses: () => number;
61
+ /** Re-render under another session id (a spawn adoption). */
62
+ adopt: (nextId: string) => Promise<void>;
63
+ generating: { current: boolean };
64
+ streamRun: { current: boolean };
65
+ serverRun: { current: boolean };
66
+ /** Drive the connection status the way the socket lifecycle does. */
67
+ setStatus: (status: 'idle' | 'connecting' | 'open' | 'closed') => Promise<void>;
68
+ }
69
+
70
+ /** Answer the attach probe with `answer` (or fail when it is null). */
71
+ function stubProbe(answer: unknown, onCall?: () => void): void {
72
+ globalThis.fetch = (async () => {
73
+ onCall?.();
74
+ if (answer === null) throw new Error('offline');
75
+ return new Response(JSON.stringify(answer), { status: 200, headers: { 'content-type': 'application/json' } });
76
+ }) as unknown as typeof fetch;
77
+ }
78
+
79
+ async function mount(generating: boolean, sessionId = 'omp-1'): Promise<Harness> {
80
+ const seen = {
81
+ settles: 0,
82
+ losses: 0,
83
+ generating: { current: generating },
84
+ streamRun: { current: generating },
85
+ serverRun: { current: generating },
86
+ };
87
+ function Probe({ id = sessionId }: { id?: string }) {
88
+ const generatingRef = useRef(seen.generating.current);
89
+ // The stream has announced the run, which is the state a lost run is in.
90
+ const streamRunRef = useRef(seen.streamRun.current);
91
+ const serverRunRef = useRef(seen.serverRun.current);
92
+ seen.generating = generatingRef;
93
+ seen.streamRun = streamRunRef;
94
+ seen.serverRun = serverRunRef;
95
+ useReconnectSettle({
96
+ sessionId: id,
97
+ isGeneratingRef: generatingRef,
98
+ streamRunRef,
99
+ serverRunRef,
100
+ onSettled: () => { seen.settles += 1; },
101
+ onFramesLost: () => { seen.losses += 1; },
102
+ });
103
+ return null;
104
+ }
105
+ container ??= document.body.appendChild(document.createElement('div'));
106
+ await act(async () => { render(h(Probe, null), container as HTMLElement); });
107
+ return {
108
+ settles: () => seen.settles,
109
+ losses: () => seen.losses,
110
+ generating: seen.generating,
111
+ streamRun: seen.streamRun,
112
+ serverRun: seen.serverRun,
113
+ /** Re-render with another session id — the spawn adoption that re-mounts
114
+ * the effect mid-run. */
115
+ adopt: async (nextId: string) => {
116
+ await act(async () => {
117
+ render(h(Probe, { id: nextId }), container as HTMLElement);
118
+ });
119
+ },
120
+ setStatus: async (status) => {
121
+ await act(async () => {
122
+ setStatus(status);
123
+ // Let the probe's fetch/then chain (and the re-check at the answer) run.
124
+ for (let i = 0; i < 8; i += 1) await Promise.resolve();
125
+ });
126
+ },
127
+ };
128
+ }
129
+
130
+ describe('a channel that comes back after frames were missed', () => {
131
+ test('settles the run the server reports as finished', async () => {
132
+ let probes = 0;
133
+ stubProbe({ running: true, state: { isStreaming: false, isPromptRunning: false, isSettled: true } }, () => { probes += 1; });
134
+ const harness = await mount(true);
135
+
136
+ await harness.setStatus('open');
137
+ await harness.setStatus('closed');
138
+ await harness.setStatus('connecting');
139
+ await harness.setStatus('open');
140
+
141
+ expect(probes).toBe(1);
142
+ expect(harness.settles()).toBe(1);
143
+ });
144
+
145
+ test('a session the server no longer runs is settled too', async () => {
146
+ stubProbe({ running: false });
147
+ const harness = await mount(true);
148
+
149
+ await harness.setStatus('open');
150
+ await harness.setStatus('closed');
151
+ await harness.setStatus('open');
152
+
153
+ expect(harness.settles()).toBe(1);
154
+ });
155
+
156
+ test('a run still in flight is left alone', async () => {
157
+ stubProbe({ running: true, state: { isStreaming: true, isPromptRunning: true } });
158
+ const harness = await mount(true);
159
+
160
+ await harness.setStatus('open');
161
+ await harness.setStatus('closed');
162
+ await harness.setStatus('open');
163
+
164
+ expect(harness.settles()).toBe(0);
165
+ });
166
+
167
+ test('a busy session with no run is left alone', async () => {
168
+ // A live subagent whose parent turn ended: `busy` is not run evidence, and
169
+ // settling it would abandon the work still going.
170
+ stubProbe({ running: true, busy: true, state: { isStreaming: false, isPromptRunning: false } });
171
+ const harness = await mount(true);
172
+
173
+ await harness.setStatus('open');
174
+ await harness.setStatus('closed');
175
+ await harness.setStatus('open');
176
+
177
+ expect(harness.settles()).toBe(0);
178
+ });
179
+
180
+ test('an unanswered probe settles nothing', async () => {
181
+ stubProbe(null);
182
+ const harness = await mount(true);
183
+
184
+ await harness.setStatus('open');
185
+ await harness.setStatus('closed');
186
+ await harness.setStatus('open');
187
+
188
+ expect(harness.settles()).toBe(0);
189
+ });
190
+
191
+ test('a FIRST dial is not a reconnect', async () => {
192
+ // The send path opens a closed channel for a prompt it is about to send:
193
+ // no frames can have been missed, so the optimistic turn must survive.
194
+ stubProbe({ running: true, state: { isStreaming: false, isPromptRunning: false, isSettled: true } });
195
+ const harness = await mount(true);
196
+
197
+ await harness.setStatus('connecting');
198
+ await harness.setStatus('open');
199
+
200
+ expect(harness.settles()).toBe(0);
201
+ });
202
+
203
+ test('a session with NO run at all is never probed', async () => {
204
+ // Nothing has started, so nothing can have been lost: a reconnect on an
205
+ // idle session must not even ask.
206
+ let probes = 0;
207
+ stubProbe({ running: false }, () => { probes += 1; });
208
+ const harness = await mount(false);
209
+
210
+ await harness.setStatus('open');
211
+ await harness.setStatus('closed');
212
+ await harness.setStatus('open');
213
+
214
+ expect(probes).toBe(0);
215
+ expect(harness.settles()).toBe(0);
216
+ });
217
+
218
+ test('a run that FLICKERED off before the drop still counts as lost', async () => {
219
+ // Measured on a real run: the drop landed in a beat where the generating
220
+ // flag and both run flags read false (the spawn-adoption window, a
221
+ // `prompt_result` folded as settled), so the loss was read as "nothing was
222
+ // running" — and the transcript was never re-read. The evidence is
223
+ // LATCHED, so a flicker cannot erase it.
224
+ let probes = 0;
225
+ stubProbe({ running: false }, () => { probes += 1; });
226
+ const harness = await mount(true);
227
+
228
+ await harness.setStatus('open');
229
+ // Every live signal goes quiet before the channel does.
230
+ harness.generating.current = false;
231
+ harness.streamRun.current = false;
232
+ harness.serverRun.current = false;
233
+ await harness.setStatus('closed');
234
+ await harness.setStatus('open');
235
+
236
+ expect(probes).toBe(1);
237
+ expect(harness.settles()).toBe(1);
238
+ });
239
+
240
+ test('the loss is recorded at the DROP, before any probe answers', async () => {
241
+ // The probe is a request, and a run can settle while it is in flight —
242
+ // measured as a flaky e2e where `agent_end` arrived first, read a flag that
243
+ // was still false, and never re-read the transcript. What was lost is a
244
+ // fact about the drop, so it is written then.
245
+ let probes = 0;
246
+ stubProbe({ running: true, state: { isStreaming: true } }, () => { probes += 1; });
247
+ const harness = await mount(true);
248
+
249
+ await harness.setStatus('open');
250
+ await harness.setStatus('closed');
251
+
252
+ // No `open` yet: the probe has not even been issued.
253
+ expect(probes).toBe(0);
254
+ expect(harness.losses()).toBe(1);
255
+ expect(harness.settles()).toBe(0);
256
+ });
257
+
258
+ test('a spawn adoption DURING the outage does not lose the fact', async () => {
259
+ // The effect is keyed on the session id, and that id moves mid-run: a spawn
260
+ // answers with omp's UUID a beat after the first prompt. A tab that went
261
+ // away in that window re-mounted the effect INSIDE the outage, and local
262
+ // variables came back with `wasOpen:false` — so the drop read as a dial
263
+ // that never opened, nothing was recorded, and the transcript was never
264
+ // re-read (measured on a real run; the e2e spec caught it as a flake).
265
+ let probes = 0;
266
+ stubProbe({ running: true, state: { isStreaming: true } }, () => { probes += 1; });
267
+ const harness = await mount(true, 'new-1');
268
+
269
+ await harness.setStatus('open');
270
+ await harness.setStatus('closed');
271
+ // The adoption lands while the channel is down.
272
+ await harness.adopt('omp-1');
273
+ await harness.setStatus('connecting');
274
+ await harness.setStatus('open');
275
+
276
+ expect(harness.losses()).toBe(1);
277
+ expect(probes).toBe(1);
278
+ });
279
+
280
+ test('a probe that answers "still live" settles nothing', async () => {
281
+ stubProbe({ running: true, state: { isStreaming: true } });
282
+ const harness = await mount(true);
283
+
284
+ await harness.setStatus('open');
285
+ await harness.setStatus('closed');
286
+ await harness.setStatus('open');
287
+
288
+ expect(harness.settles()).toBe(0);
289
+ expect(harness.losses()).toBe(1);
290
+ });
291
+
292
+ test('the SERVER\'s stream row alone is enough evidence', async () => {
293
+ // A run whose very FIRST frame (`agent_start`) was the one lost has no
294
+ // stream-side mark on this client, but the server wrote its `stream` row at
295
+ // dispatch — and that row is what proves frames could have been missed.
296
+ // Without this the first-frame loss was unrecoverable: the timeline kept
297
+ // the empty placeholder while the answer streamed into a socket that was
298
+ // gone.
299
+ stubProbe({ running: false });
300
+ const harness = await mount(true);
301
+ harness.streamRun.current = false;
302
+ harness.serverRun.current = true;
303
+
304
+ await harness.setStatus('open');
305
+ await harness.setStatus('closed');
306
+ await harness.setStatus('open');
307
+
308
+ expect(harness.settles()).toBe(1);
309
+ });
310
+
311
+ test('a drop with no run believed live settles nothing', async () => {
312
+ stubProbe({ running: true, state: { isStreaming: false, isSettled: true } });
313
+ const harness = await mount(false);
314
+
315
+ await harness.setStatus('open');
316
+ await harness.setStatus('closed');
317
+ await harness.setStatus('open');
318
+
319
+ expect(harness.settles()).toBe(0);
320
+ });
321
+
322
+ test('a run that finished during the drop is settled once, not on every reconnect', async () => {
323
+ stubProbe({ running: false });
324
+ const harness = await mount(true);
325
+
326
+ await harness.setStatus('open');
327
+ await harness.setStatus('closed');
328
+ await harness.setStatus('open');
329
+ expect(harness.settles()).toBe(1);
330
+
331
+ // The settle path releases the generating flag; a second flap must not
332
+ // re-settle (and must not re-read the transcript again).
333
+ harness.generating.current = false;
334
+ await harness.setStatus('closed');
335
+ await harness.setStatus('open');
336
+ expect(harness.settles()).toBe(1);
337
+ });
338
+ });
@@ -0,0 +1,187 @@
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
+ * Two facts decide when this applies, and both are about the CHANNEL, not about
18
+ * a run's bookkeeping:
19
+ *
20
+ * - frames can only have been missed if the socket was OPEN while a run was
21
+ * believed live and then went away. A send whose `connect()` dials a closed
22
+ * channel never meets that condition, so the optimistic turn it just mounted
23
+ * can never be settled by this path (the prompt is not even on the wire yet).
24
+ * - the run must be over on the SERVER, asked through the same attach probe the
25
+ * mount path uses (`runIsOver`). `busy` is not a verdict there, and an
26
+ * unanswered probe settles nothing.
27
+ *
28
+ * Recovery is two steps and both are needed: settle the local run (release the
29
+ * generating flag, drop the ids only a live stream would consume), then re-read
30
+ * the committed transcript — the rows on screen are the stream's last PARTIAL
31
+ * segment, and the session JSONL is the only record of the rest.
32
+ */
33
+
34
+ import { useEffect, useRef } from 'preact/hooks';
35
+
36
+ import { realtimeClient } from '@/shared/lib/realtime/client';
37
+ import { runIsOver, type AgentRunProbe } from '@/client/hooks/chat/omp/run-liveness';
38
+
39
+ export interface ReconnectSettleDeps {
40
+ /** The session on screen; null for a pending `new-…` chat (no child yet). */
41
+ sessionId: string | null;
42
+ /** Live "this page is showing a run" flag — owned or adopted. */
43
+ isGeneratingRef: { current: boolean };
44
+ /** The stream has spoken for that run (see `streamRunRef`). */
45
+ streamRunRef: { current: boolean };
46
+ /** The SERVER reports a run for this session (`session_stream_state`'s
47
+ * `stream` row, the same source the sidebar spinner reads). This is the
48
+ * evidence that a prompt was DISPATCHED, which is what makes frame loss
49
+ * possible — and it is the only signal available for a run whose own
50
+ * `agent_start` was the first frame lost. A prompt that has not reached the
51
+ * server yet has no row, so it is still excluded. */
52
+ serverRunRef: { current: boolean };
53
+ /** The run is over server-side: settle the local UI and re-read the
54
+ * committed transcript. */
55
+ onSettled: () => void;
56
+ /** Frames were LOST — called SYNCHRONOUSLY at the drop, not at the probe's
57
+ * answer. A run in flight when an open channel goes away has already lost
58
+ * whatever finished during the outage: its `message_end` went into the dead
59
+ * socket, and `agent_end` only materializes turns that stopped abnormally.
60
+ * The caller records it so the transcript is re-read when the run settles.
61
+ *
62
+ * At the DROP, deliberately: the attach probe is a request, so a run can
63
+ * settle while it is in flight — measured as a flaky spec where `agent_end`
64
+ * arrived first, read a flag that was still false, and never reloaded. What
65
+ * is lost is a fact about the drop, not about the probe's answer. */
66
+ onFramesLost: () => void;
67
+ }
68
+
69
+ /**
70
+ * The channel facts this hook needs, held OUTSIDE the effect.
71
+ *
72
+ * The effect is keyed on the session id, and that id MOVES mid-run: a spawn
73
+ * answers with omp's UUID a beat after the first prompt, so the URL — and this
74
+ * effect — re-mounts. Local variables therefore lose the only two facts that
75
+ * make the repair possible, measured on a real run: the tab went away during
76
+ * the spawn adoption, the effect re-mounted inside the outage, `wasOpen` came
77
+ * back `false`, and the drop was read as "a dial that never opened" — so
78
+ * nothing was recorded and the transcript was never re-read.
79
+ */
80
+ interface ChannelFacts {
81
+ /** The channel was OPEN at the last transition we saw. */
82
+ wasOpen: boolean;
83
+ /** A run was in flight when it went away, so frames were lost. */
84
+ missedFrames: boolean;
85
+ /**
86
+ * A run has been seen live for this session since it was last reconciled.
87
+ *
88
+ * A LATCH, not a live read, and that is the whole point: the client's
89
+ * generating flag flickers false during a live run (measured: a `prompt_result`
90
+ * folded as "settled", and the spawn-adoption window), and a drop that landed
91
+ * in one of those beats was read as "no run in flight" — so nothing was
92
+ * recorded and the transcript was never re-read, which is exactly the
93
+ * cut-off answer this hook exists to repair.
94
+ */
95
+ sawRun: boolean;
96
+ /** The session id `sawRun` belongs to, so a real switch resets it while a
97
+ * spawn ADOPTION (same conversation, new id) keeps it. */
98
+ sessionId: string | null;
99
+ }
100
+
101
+ export function useReconnectSettle(deps: ReconnectSettleDeps): void {
102
+ const { sessionId, isGeneratingRef, streamRunRef, serverRunRef, onSettled, onFramesLost } = deps;
103
+ // Held in a ref: the caller rebuilds `onSettled` on every render (it closes
104
+ // over the timeline's own callbacks), and an effect keyed on its identity
105
+ // would re-subscribe constantly.
106
+ const onSettledRef = useRef(onSettled);
107
+ onSettledRef.current = onSettled;
108
+ const onFramesLostRef = useRef(onFramesLost);
109
+ onFramesLostRef.current = onFramesLost;
110
+ // Survives a re-mount, which is the whole point (see `ChannelFacts`).
111
+ const factsRef = useRef<ChannelFacts | null>(null);
112
+
113
+ // Latch the run evidence on EVERY render, before any effect runs: the flags
114
+ // are refs, so a beat that flickers false would otherwise erase the only
115
+ // proof a run existed when the channel went away.
116
+ const facts = (factsRef.current ??= {
117
+ wasOpen: realtimeClient.status() === 'open',
118
+ missedFrames: false,
119
+ sawRun: false,
120
+ sessionId,
121
+ });
122
+ // Re-read the CHANNEL on every render, because this hook re-mounts and the
123
+ // channel does not. Measured: the effect was re-created while the socket was
124
+ // OPEN (a spawn adoption, a session-state re-render), the latch still said
125
+ // `wasOpen:false` from before that, and the following drop was read as a dial
126
+ // that never opened — so the repair never ran. Only the TRANSITION matters,
127
+ // so reconciling here cannot invent a loss: `missedFrames` is still armed by
128
+ // the subscriber alone.
129
+ facts.wasOpen = realtimeClient.status() === 'open';
130
+ if (isGeneratingRef.current || streamRunRef.current || serverRunRef.current) facts.sawRun = true;
131
+ // A genuine switch (nothing live) starts over; an adoption keeps the latch.
132
+ if (facts.sessionId !== sessionId) {
133
+ if (!isGeneratingRef.current && !serverRunRef.current) facts.sawRun = false;
134
+ facts.sessionId = sessionId;
135
+ }
136
+
137
+ useEffect(() => {
138
+ if (!sessionId) return;
139
+ // Lit when an OPEN channel goes away while a run is in flight: the only
140
+ // state in which frames can have been missed. `wasOpen` is what separates
141
+ // that from a dial: a send whose `connect()` opens a closed channel also
142
+ // passes through `connecting`, and settling on that would delete the
143
+ // optimistic turn of a prompt that is not even on the wire yet.
144
+ let cancelled = false;
145
+
146
+ const unsubscribe = realtimeClient.onStatus(() => {
147
+ if (cancelled) return;
148
+ if (realtimeClient.status() !== 'open') {
149
+ // Either piece of evidence that a run is in flight: the stream named it,
150
+ // or the server's `stream` row exists (which also covers a run whose
151
+ // very first frame — `agent_start` — was the one lost).
152
+ if (facts.wasOpen && facts.sawRun) {
153
+ facts.missedFrames = true;
154
+ // Recorded NOW: the probe below is a request, and a run that settles
155
+ // while it is in flight would otherwise settle against a flag that
156
+ // has not been written yet.
157
+ onFramesLostRef.current();
158
+ }
159
+ facts.wasOpen = false;
160
+ return;
161
+ }
162
+ facts.wasOpen = true;
163
+ if (!facts.missedFrames) return;
164
+ facts.missedFrames = false;
165
+ // Deliberately NOT gated on `isGeneratingRef`: the flag can be false for a
166
+ // run that is still live (or one that ended while the tab was away), and
167
+ // the probe below is the authority on which it is.
168
+ void (async () => {
169
+ const probe = await fetch(`/api/agent/${encodeURIComponent(sessionId)}`)
170
+ .then((res) => (res.ok ? (res.json() as Promise<AgentRunProbe>) : null))
171
+ .catch(() => null);
172
+ // Re-checked at the answer, not only at the trigger: the user may have
173
+ // switched sessions while the probe was in flight.
174
+ if (cancelled) return;
175
+ // Still live: nothing to settle, and the loss is already recorded.
176
+ if (!runIsOver(probe)) return;
177
+ facts.sawRun = false;
178
+ onSettledRef.current();
179
+ })();
180
+ });
181
+
182
+ return () => {
183
+ cancelled = true;
184
+ unsubscribe();
185
+ };
186
+ }, [sessionId, isGeneratingRef, streamRunRef, serverRunRef]);
187
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * `runIsOver` — the verdict a reattached client asks before settling a run it
8
+ * was following. Every case here is a shape the server really produces:
9
+ *
10
+ * - `running:false` is the answer for a session this process does not manage
11
+ * (and for one whose child is gone): the run is over, whatever the local
12
+ * generating flag still believes;
13
+ * - `busy` with no run is NOT over — the server sets it for work a probe would
14
+ * queue behind (a live subagent whose parent turn ended, a parked dialog);
15
+ * - a live `isStreaming`/`isPromptRunning` is NOT over;
16
+ * - no answer at all (a failed request) is NOT a verdict.
17
+ *
18
+ * The asymmetry is deliberate: settling a run that is still going throws away
19
+ * the streaming answer, while missing a settle costs a reload.
20
+ */
21
+
22
+ import { describe, expect, test } from 'bun:test';
23
+ import { runIsOver } from '@/client/hooks/chat/omp/run-liveness';
24
+
25
+ describe('runIsOver', () => {
26
+ test('a session this process does not run is over', () => {
27
+ expect(runIsOver({ running: false })).toBe(true);
28
+ });
29
+
30
+ test('no answer is not a verdict', () => {
31
+ expect(runIsOver(null)).toBe(false);
32
+ expect(runIsOver(undefined)).toBe(false);
33
+ });
34
+
35
+ test('a busy session with no run in flight is NOT over', () => {
36
+ // A live subagent whose parent turn ended, or a dialog a reloaded client
37
+ // has not answered: settling here would abandon the work still running.
38
+ expect(runIsOver({ running: true, busy: true, state: { isStreaming: false, isPromptRunning: false } })).toBe(false);
39
+ });
40
+
41
+ test('a streaming or prompting run is NOT over', () => {
42
+ expect(runIsOver({ running: true, state: { isStreaming: true, isPromptRunning: false } })).toBe(false);
43
+ expect(runIsOver({ running: true, state: { isStreaming: false, isPromptRunning: true } })).toBe(false);
44
+ });
45
+
46
+ test('omp\'s verdict is REQUIRED: a child with nothing streaming is not enough', () => {
47
+ // A turn can be admitted but not yet streaming, and a build that does not
48
+ // report `isSettled` cannot tell us which. The server refuses to settle on
49
+ // anything but `isSettled === true` (`RunSettle`), so this must too —
50
+ // otherwise the client reloads a transcript the server still considers live.
51
+ expect(runIsOver({ running: true, state: { isStreaming: false, isPromptRunning: false } })).toBe(false);
52
+ expect(runIsOver({ running: true, state: { isStreaming: false, isPromptRunning: false, isSettled: true } })).toBe(true);
53
+ });
54
+
55
+ test('omp\'s own quiescence verdict decides when it is present', () => {
56
+ expect(runIsOver({ running: true, state: { isSettled: true } })).toBe(true);
57
+ // omp says something is still admitted/scheduled/queued: a retry wait, a
58
+ // background job. Not over.
59
+ expect(runIsOver({ running: true, state: { isSettled: false } })).toBe(false);
60
+ });
61
+
62
+ test('a payload with no state is not read as settled', () => {
63
+ expect(runIsOver({ running: true })).toBe(false);
64
+ });
65
+
66
+ test('a queued follow-up keeps the session open even when it reads settled', () => {
67
+ // omp will wake again, so this is not a finished run: settling would reload
68
+ // the transcript while the next turn is about to open.
69
+ expect(runIsOver({ running: true, state: { isSettled: true, queuedMessageCount: 1 } })).toBe(false);
70
+ });
71
+ });
@@ -0,0 +1,65 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Whether the run a REATTACHED client was following is over.
8
+ *
9
+ * A socket that comes back after a run's terminal frames were lost with it
10
+ * leaves the timeline holding the stream's last `message_update` — a partial
11
+ * answer — and nothing else will ever finalize it: `message_end` and
12
+ * `agent_end` were emitted into a socket nobody was reading. The snapshot that
13
+ * repairs the attachment says only whether a run is live NOW, and that answer
14
+ * has to be read precisely, because the SAME shape ("child alive, nothing
15
+ * running") appears in the beat between a send's `connect()` and its prompt —
16
+ * acting on it there would settle a run that is about to start.
17
+ *
18
+ * `busy` is deliberately NOT evidence either way: the server sets it for work a
19
+ * probe would queue behind (a live subagent whose parent turn ended, a parked
20
+ * dialog), and a session with something else live is left alone rather than
21
+ * settled.
22
+ *
23
+ * The final word is omp's OWN verdict, `state.isSettled`, and it must be
24
+ * literally `true`. The server's `RunSettle` applies exactly that rule
25
+ * (`isSettled !== true` → do not settle), and two answers to "is this run over"
26
+ * is how the indicator and the transcript end up disagreeing. A build that does
27
+ * not report the field therefore settles nothing here — a reload costs one
28
+ * reconnect, while settling a live run throws the streaming answer away.
29
+ */
30
+
31
+ /** The `GET /api/agent/:id` / topic-snapshot payload, narrowed to what this
32
+ * decision reads. */
33
+ export interface AgentRunProbe {
34
+ running?: boolean;
35
+ busy?: boolean;
36
+ state?: {
37
+ isStreaming?: boolean;
38
+ isPromptRunning?: boolean;
39
+ /** omp's own quiescence verdict. Absent on builds that do not report it. */
40
+ isSettled?: boolean;
41
+ /** Follow-ups and steers omp has accepted but not run yet. A non-zero
42
+ * count means the session WILL wake again, so it is not finished — and it
43
+ * is what keeps this from settling a session whose queued prompt was
44
+ * delivered server-side while the tab was away. */
45
+ queuedMessageCount?: number;
46
+ } | null;
47
+ }
48
+
49
+ export function runIsOver(probe: AgentRunProbe | null | undefined): boolean {
50
+ // No answer (a failed request, a refused payload) is not a verdict: leave the
51
+ // timeline alone and let the next reconnect ask again.
52
+ if (!probe) return false;
53
+ if (probe.running === false) return true;
54
+ if (probe.busy) return false;
55
+ const state = probe.state;
56
+ if (!state) return false;
57
+ if (state.isStreaming || state.isPromptRunning) return false;
58
+ if (typeof state.queuedMessageCount === 'number' && state.queuedMessageCount > 0) return false;
59
+ // omp's own quiescence verdict, and the ONLY thing that may settle a run —
60
+ // the same rule the server applies (`RunSettle`: `isSettled !== true` → do
61
+ // not settle). Absent means the build does not report it, which is NOT
62
+ // evidence of quiescence: a turn can be admitted but not yet streaming, and
63
+ // settling there would reload the transcript mid-dispatch.
64
+ return state.isSettled === true;
65
+ }