@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,114 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * `sidebar:activity` — the topic a tool call republishes.
8
+ *
9
+ * Its whole reason to exist is that the STRUCTURE must not be republished per
10
+ * tool call (216 KB over 731 sessions, measured), so the merge has to be exact:
11
+ * it may only ever move the two fields a run advances mid-turn, and only
12
+ * forwards. Each case below is a way that goes wrong.
13
+ */
14
+
15
+ import { afterAll, afterEach, describe, expect, test } from 'bun:test';
16
+
17
+ import { restoreDomGlobals } from '@/test-support/pristine-globals';
18
+ import {
19
+ mountProvider,
20
+ structureBody,
21
+ teardownProvider,
22
+ } from '@/client/hooks/chat/omp/session-list.test-util';
23
+
24
+ afterEach(teardownProvider);
25
+
26
+ afterAll(() => {
27
+ restoreDomGlobals();
28
+ });
29
+
30
+ describe('SidebarDataProvider merges the activity topic', () => {
31
+ test('advances a running row past the stamp the structure shipped', async () => {
32
+ const harness = await mountProvider();
33
+ await harness.push(
34
+ {
35
+ folders: [{
36
+ id: 1, name: 'ws', isExpanded: true, hasMore: false, totalSessions: 1,
37
+ sessions: [{ id: 'a', folder_id: 1, title: 'A', updated_at: '2026-10-24T01:00:00.000Z' }],
38
+ }],
39
+ isMock: false,
40
+ },
41
+ );
42
+ expect(harness.holder.api?.folders[0]?.sessions?.[0].updated_at).toBe('2026-10-24T01:00:00.000Z');
43
+
44
+ await harness.push(undefined, undefined, { a: { updatedAt: '2026-10-24T01:05:00.000Z' } });
45
+ expect(harness.holder.api?.folders[0]?.sessions?.[0].updated_at).toBe('2026-10-24T01:05:00.000Z');
46
+ harness.server.stop();
47
+ });
48
+
49
+ test('never moves a row backwards, so a late frame cannot reorder the list', async () => {
50
+ const harness = await mountProvider();
51
+ // The structure already carries a stamp the activity payload has not caught
52
+ // up with — the normal case right after a turn boundary.
53
+ await harness.push({
54
+ folders: [{
55
+ id: 1, name: 'ws', isExpanded: true, hasMore: false, totalSessions: 1,
56
+ sessions: [{ id: 'a', folder_id: 1, title: 'A', updated_at: '2026-10-24T02:00:00.000Z' }],
57
+ }],
58
+ isMock: false,
59
+ });
60
+
61
+ await harness.push(undefined, undefined, { a: { updatedAt: '2026-10-24T01:59:00.000Z' } });
62
+ expect(harness.holder.api?.folders[0]?.sessions?.[0].updated_at).toBe('2026-10-24T02:00:00.000Z');
63
+ harness.server.stop();
64
+ });
65
+
66
+ test('lights the subagent chevron for a subagent seen mid-run', async () => {
67
+ const harness = await mountProvider();
68
+ await harness.push(structureBody());
69
+ expect(harness.holder.api?.folders[0]?.sessions?.[0].hasSubagents).toBeFalsy();
70
+
71
+ await harness.push(undefined, undefined, { a: { updatedAt: '2026-10-24T01:05:00.000Z', hasSubagents: true } });
72
+ expect(harness.holder.api?.folders[0]?.sessions?.[0].hasSubagents).toBe(true);
73
+ harness.server.stop();
74
+ });
75
+
76
+ test('a live read that saw no subagent does not clear a chevron the scan lit', async () => {
77
+ // The two sources run the SAME presence check, but the structure's answer
78
+ // came from a full scan and this one from a live read of one file. A
79
+ // `false` here is "not seen in this read", so it must not unlight a row —
80
+ // the merge ORs against the structure for exactly that reason.
81
+ const harness = await mountProvider();
82
+ await harness.push({
83
+ folders: [{
84
+ id: 1, name: 'ws', isExpanded: true, hasMore: false, totalSessions: 1,
85
+ sessions: [{ id: 'a', folder_id: 1, title: 'A', hasSubagents: true }],
86
+ }],
87
+ isMock: false,
88
+ });
89
+
90
+ await harness.push(undefined, undefined, { a: { updatedAt: '2026-10-24T01:05:00.000Z', hasSubagents: false } });
91
+ expect(harness.holder.api?.folders[0]?.sessions?.[0].hasSubagents).toBe(true);
92
+ harness.server.stop();
93
+ });
94
+
95
+ test('leaves an id the payload does not name on the structure stamp', async () => {
96
+ const harness = await mountProvider();
97
+ await harness.push({
98
+ folders: [{
99
+ id: 1, name: 'ws', isExpanded: true, hasMore: false, totalSessions: 2,
100
+ sessions: [
101
+ { id: 'a', folder_id: 1, title: 'A', updated_at: '2026-10-24T01:00:00.000Z' },
102
+ { id: 'b', folder_id: 1, title: 'B', updated_at: '2026-10-24T00:30:00.000Z' },
103
+ ],
104
+ }],
105
+ isMock: false,
106
+ });
107
+
108
+ await harness.push(undefined, undefined, { a: { updatedAt: '2026-10-24T01:05:00.000Z' } });
109
+ const sessions = harness.holder.api?.folders[0]?.sessions ?? [];
110
+ expect(sessions[0]?.updated_at).toBe('2026-10-24T01:05:00.000Z');
111
+ expect(sessions[1]?.updated_at).toBe('2026-10-24T00:30:00.000Z');
112
+ harness.server.stop();
113
+ });
114
+ });
@@ -0,0 +1,164 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Shared harness for the `SidebarDataProvider` suites.
8
+ *
9
+ * Driven through a REAL server and a real socket rather than a stubbed fetch,
10
+ * because what the provider does is merge three topics and apply the optimistic
11
+ * overlays — the parts that used to be "does it re-read". A stubbed socket
12
+ * would only prove the fake's timing.
13
+ *
14
+ * Split out when the suites together crossed the repo's per-file ceiling: the
15
+ * harness is setup, and the rules under test belong in their own files.
16
+ */
17
+
18
+ import { Window } from 'happy-dom';
19
+ import { h, render } from 'preact';
20
+ import { act } from 'preact/test-utils';
21
+
22
+ import { SidebarDataProvider, useSidebarData } from '@/client/hooks/chat/omp/session-list';
23
+ import type { SidebarDataHandle } from '@/client/hooks/chat/omp/session-list';
24
+ import { resetRealtimeClient } from '@/shared/lib/realtime/client';
25
+ import { TOPIC_SIDEBAR, TOPIC_SIDEBAR_ACTIVITY, TOPIC_SIDEBAR_STATUS } from '@/shared/lib/realtime/protocol';
26
+ import { startRealtimeTestServer, type RealtimeTestServer } from '@/test-support/realtime-server';
27
+ import type { TopicResolver } from '@/server/lib/realtime/hub.server';
28
+ import { installDomGlobals, pristineWebSocket } from '@/test-support/pristine-globals';
29
+ import type { SidebarActivityPayload, SidebarPayload, SidebarStatusPayload } from '@/client/hooks/chat/omp/session-list';
30
+ import type { WorkspaceFolderData } from '@/shared/types';
31
+
32
+ let container: HTMLElement | undefined;
33
+
34
+ /**
35
+ * Point the DOM globals at a happy-dom window whose origin is the test
36
+ * listener, because the client builds its socket URL from `window.location` —
37
+ * a window on a different port would dial a server that is not there, and the
38
+ * test would be measuring a connection failure rather than the channel.
39
+ */
40
+ function installDom(origin: string): void {
41
+ const win = new Window({ url: origin });
42
+ installDomGlobals(win);
43
+ // happy-dom's WebSocket does not reach a real listener, and the channel is
44
+ // what is under test, so the real implementation wins this global.
45
+ (globalThis as unknown as Record<string, unknown>).WebSocket = pristineWebSocket;
46
+ }
47
+
48
+ /** Unmount and drop the socket between cases. Each suite calls this in its own `afterEach`. */
49
+ export function teardownProvider(): void {
50
+ if (container) render(null, container);
51
+ container?.remove();
52
+ container = undefined;
53
+ resetRealtimeClient();
54
+ }
55
+
56
+ export type Status = 'stream' | 'finish' | 'abort';
57
+
58
+ /** A structure payload carrying one session, `a`. */
59
+ export function structureBody(): SidebarPayload {
60
+ return {
61
+ folders: [{ id: 1, name: 'ws', isExpanded: true, hasMore: false, totalSessions: 1, sessions: [{ id: 'a', folder_id: 1, title: 'A' }] }],
62
+ isMock: false,
63
+ };
64
+ }
65
+
66
+ /** The volatile map for `a`. */
67
+ export function statusBody(status?: Status): SidebarStatusPayload {
68
+ return status ? { a: { streamStatus: status } } : {};
69
+ }
70
+
71
+ /** The live-activity map: empty until a case publishes one. */
72
+ export function activityBody(): SidebarActivityPayload {
73
+ return {};
74
+ }
75
+
76
+ /** One folder holding the given sessions, shaped only as far as the strip reads it. */
77
+ export function folders(sessions: Array<{ id: string; streamStatus?: Status }>): WorkspaceFolderData[] {
78
+ return [{ id: 1, name: 'ws', sessions }] as unknown as WorkspaceFolderData[];
79
+ }
80
+
81
+ /** The status the provider currently renders for session `a`. */
82
+ export function renderedStatus(api: SidebarDataHandle | null): Status | undefined {
83
+ return api?.folders[0]?.sessions?.[0].streamStatus;
84
+ }
85
+
86
+ /**
87
+ * Let Preact's queued effects run.
88
+ *
89
+ * A microtask drain, and deliberately nothing more: the socket's own progress
90
+ * is awaited through the harness, so this only has to flush the render a
91
+ * received frame queued.
92
+ */
93
+ async function flush(): Promise<void> {
94
+ for (let i = 0; i < 8; i += 1) await Promise.resolve();
95
+ }
96
+
97
+ export interface Harness {
98
+ server: RealtimeTestServer;
99
+ holder: { api: SidebarDataHandle | null };
100
+ /** Republish any of the three sidebar topics from a fresh body. */
101
+ push: (structure?: SidebarPayload, status?: SidebarStatusPayload, activity?: SidebarActivityPayload) => Promise<void>;
102
+ }
103
+
104
+ /** Mount the real provider against a real realtime server. */
105
+ export async function mountProvider(): Promise<Harness> {
106
+ const server = await startRealtimeTestServer(new Map<string, TopicResolver>([
107
+ [TOPIC_SIDEBAR, async () => structureBody()],
108
+ [TOPIC_SIDEBAR_STATUS, async () => statusBody()],
109
+ [TOPIC_SIDEBAR_ACTIVITY, async () => activityBody()],
110
+ ]));
111
+ installDom(`http://127.0.0.1:${server.port}`);
112
+
113
+ const holder: { api: SidebarDataHandle | null } = { api: null };
114
+ function Probe() {
115
+ holder.api = useSidebarData();
116
+ return null;
117
+ }
118
+ const el = document.createElement('div');
119
+ document.body.appendChild(el);
120
+ container = el;
121
+ await act(async () => {
122
+ render(h(SidebarDataProvider, { children: h(Probe, null) }), el);
123
+ });
124
+ // The structure snapshot must have landed before the provider can report a
125
+ // value at all; the subscription is opened by the render above, so this
126
+ // awaits the client's own state rather than a duration.
127
+ await server.waitForTopic<SidebarPayload>(TOPIC_SIDEBAR, (value) => value !== null);
128
+ await act(async () => {
129
+ await flush();
130
+ });
131
+
132
+ return {
133
+ server,
134
+ holder,
135
+ push: async (structure, status, activity) => {
136
+ // Await the CLIENT's value, not just the publish: the socket round trip
137
+ // and the provider's merge are the things under test, so resolving on the
138
+ // send would assert against a state that has not happened yet.
139
+ if (structure) {
140
+ server.publish(TOPIC_SIDEBAR, structure);
141
+ await server.waitForTopic(TOPIC_SIDEBAR, (value) => JSON.stringify(value) === JSON.stringify(structure));
142
+ }
143
+ if (status) {
144
+ server.publish(TOPIC_SIDEBAR_STATUS, status);
145
+ await server.waitForTopic(TOPIC_SIDEBAR_STATUS, (value) => JSON.stringify(value) === JSON.stringify(status));
146
+ }
147
+ if (activity) {
148
+ server.publish(TOPIC_SIDEBAR_ACTIVITY, activity);
149
+ await server.waitForTopic(TOPIC_SIDEBAR_ACTIVITY, (value) => JSON.stringify(value) === JSON.stringify(activity));
150
+ }
151
+ await act(async () => {
152
+ await flush();
153
+ });
154
+ },
155
+ };
156
+ }
157
+
158
+ /** Run `body` with Preact's effects flushed around it. */
159
+ export async function withAct(body: () => void | Promise<void>): Promise<void> {
160
+ await act(async () => {
161
+ await body();
162
+ await flush();
163
+ });
164
+ }
@@ -4,159 +4,34 @@
4
4
  */
5
5
 
6
6
  /**
7
- * The mounted `SidebarDataProvider` over the unified realtime channel.
7
+ * The mounted `SidebarDataProvider` over the unified realtime channel: the
8
+ * STATUS half, the seen-strip overlay, and the title hint.
8
9
  *
9
- * Driven through a REAL server and a real socket rather than a stubbed fetch,
10
- * because what the provider does now is merge two topics and apply the
11
- * optimistic overlays — the parts that used to be "does it re-read". A stubbed
12
- * socket would only prove the fake's timing.
13
- *
14
- * Kept: the two-topic merge, the authoritative status map (including a session
15
- * the structure has not scanned), the seen-strip on an acked terminal badge, and
16
- * the title hint.
10
+ * The activity topic's merge rules live in `session-list.activity.test.ts` —
11
+ * separate because the two halves answer different questions and the file was
12
+ * over the repo's ceiling with both.
17
13
  */
18
14
 
19
15
  import { afterAll, afterEach, describe, expect, test } from 'bun:test';
20
- import { Window } from 'happy-dom';
21
- import { h, render } from 'preact';
22
- import { act } from 'preact/test-utils';
23
16
 
24
17
  import { applySeenStrip, SESSION_TITLE_HINT_SIGNAL } from '@/client/hooks/chat/omp/sidebar-overlay';
25
18
  import { publishClientSignal } from '@/client/lib/signals';
26
- import { SidebarDataProvider, useSidebarData } from '@/client/hooks/chat/omp/session-list';
27
- import type { SidebarDataHandle } from '@/client/hooks/chat/omp/session-list';
28
- import { resetRealtimeClient } from '@/shared/lib/realtime/client';
29
- import { TOPIC_SIDEBAR, TOPIC_SIDEBAR_STATUS } from '@/shared/lib/realtime/protocol';
30
- import { startRealtimeTestServer, type RealtimeTestServer } from '@/test-support/realtime-server';
31
- import type { TopicResolver } from '@/server/lib/realtime/hub.server';
32
- import { installDomGlobals, pristineWebSocket, restoreDomGlobals } from '@/test-support/pristine-globals';
33
- import type { SidebarPayload, SidebarStatusPayload } from '@/client/hooks/chat/omp/session-list';
34
- import type { WorkspaceFolderData } from '@/shared/types';
35
-
36
-
37
- let container: HTMLElement | undefined;
38
-
39
- /**
40
- * Point the DOM globals at a happy-dom window whose origin is the test
41
- * listener, because the client builds its socket URL from `window.location` —
42
- * a window on a different port would dial a server that is not there, and the
43
- * test would be measuring a connection failure rather than the channel.
44
- */
45
- function installDom(origin: string): void {
46
- const win = new Window({ url: origin });
47
- installDomGlobals(win);
48
- // happy-dom's WebSocket does not reach a real listener, and the channel is
49
- // what is under test, so the real implementation wins this global.
50
- (globalThis as unknown as Record<string, unknown>).WebSocket = pristineWebSocket;
51
- }
52
-
53
-
54
-
55
- afterEach(() => {
56
- if (container) render(null, container);
57
- container?.remove();
58
- container = undefined;
59
- resetRealtimeClient();
60
- });
19
+ import { restoreDomGlobals } from '@/test-support/pristine-globals';
20
+ import {
21
+ folders,
22
+ mountProvider,
23
+ renderedStatus,
24
+ statusBody,
25
+ teardownProvider,
26
+ withAct,
27
+ } from '@/client/hooks/chat/omp/session-list.test-util';
28
+
29
+ afterEach(teardownProvider);
61
30
 
62
31
  afterAll(() => {
63
32
  restoreDomGlobals();
64
33
  });
65
34
 
66
-
67
-
68
- type Status = 'stream' | 'finish' | 'abort';
69
-
70
- /** A structure payload carrying one session, `a`. */
71
- function structureBody(): SidebarPayload {
72
- return {
73
- folders: [{ id: 1, name: 'ws', isExpanded: true, hasMore: false, totalSessions: 1, sessions: [{ id: 'a', folder_id: 1, title: 'A' }] }],
74
- isMock: false,
75
- };
76
- }
77
-
78
- /** The volatile map for `a`. */
79
- function statusBody(status?: Status): SidebarStatusPayload {
80
- return status ? { a: { streamStatus: status } } : {};
81
- }
82
-
83
- /** One folder holding the given sessions, shaped only as far as the strip reads it. */
84
- function folders(sessions: Array<{ id: string; streamStatus?: Status }>): WorkspaceFolderData[] {
85
- return [{ id: 1, name: 'ws', sessions }] as unknown as WorkspaceFolderData[];
86
- }
87
-
88
- /** The status the provider currently renders for session `a`. */
89
- function renderedStatus(api: SidebarDataHandle | null): Status | undefined {
90
- return api?.folders[0]?.sessions?.[0].streamStatus;
91
- }
92
-
93
- /**
94
- * Let Preact's queued effects run.
95
- *
96
- * A microtask drain, and deliberately nothing more: the socket's own progress
97
- * is awaited through the harness (`waitForFrames`), so this only has to flush
98
- * the render a received frame queued.
99
- */
100
- async function flush(): Promise<void> {
101
- for (let i = 0; i < 8; i += 1) await Promise.resolve();
102
- }
103
-
104
- interface Harness {
105
- server: RealtimeTestServer;
106
- holder: { api: SidebarDataHandle | null };
107
- /** Republish both topics from the current bodies. */
108
- push: (structure?: SidebarPayload, status?: SidebarStatusPayload) => Promise<void>;
109
- }
110
-
111
- /** Mount the real provider against a real realtime server. */
112
- async function mountProvider(): Promise<Harness> {
113
- const server = await startRealtimeTestServer(new Map<string, TopicResolver>([
114
- [TOPIC_SIDEBAR, async () => structureBody()],
115
- [TOPIC_SIDEBAR_STATUS, async () => statusBody()],
116
- ]));
117
- installDom(`http://127.0.0.1:${server.port}`);
118
-
119
- const holder: { api: SidebarDataHandle | null } = { api: null };
120
- function Probe() {
121
- holder.api = useSidebarData();
122
- return null;
123
- }
124
- const el = document.createElement('div');
125
- document.body.appendChild(el);
126
- container = el;
127
- await act(async () => {
128
- render(h(SidebarDataProvider, { children: h(Probe, null) }), el);
129
- });
130
- // The structure snapshot must have landed before the provider can report a
131
- // value at all; the subscription is opened by the render above, so this
132
- // awaits the client's own state rather than a duration.
133
- await server.waitForTopic<SidebarPayload>(TOPIC_SIDEBAR, (value) => value !== null);
134
- await act(async () => {
135
- await flush();
136
- });
137
-
138
- return {
139
- server,
140
- holder,
141
- push: async (structure, status) => {
142
- // Await the CLIENT's value, not just the publish: the socket round trip
143
- // and the provider's merge are the things under test, so resolving on the
144
- // send would assert against a state that has not happened yet.
145
- if (structure) {
146
- server.publish(TOPIC_SIDEBAR, structure);
147
- await server.waitForTopic(TOPIC_SIDEBAR, (value) => JSON.stringify(value) === JSON.stringify(structure));
148
- }
149
- if (status) {
150
- server.publish(TOPIC_SIDEBAR_STATUS, status);
151
- await server.waitForTopic(TOPIC_SIDEBAR_STATUS, (value) => JSON.stringify(value) === JSON.stringify(status));
152
- }
153
- await act(async () => {
154
- await flush();
155
- });
156
- },
157
- };
158
- }
159
-
160
35
  describe('SidebarDataProvider reads both sidebar topics', () => {
161
36
  test('reports a live run for a session the structure has not scanned yet', async () => {
162
37
  // A fresh spawn: omp writes the session file only when the first assistant
@@ -227,9 +102,7 @@ describe('SidebarDataProvider carries the sent text for a placeholder row', () =
227
102
  const harness = await mountProvider();
228
103
  expect(harness.holder.api?.titleHint('omp-1')).toBeUndefined();
229
104
 
230
- await act(async () => {
231
- hint('omp-1', 'Perbaiki tombol stop');
232
- });
105
+ await withAct(() => hint('omp-1', 'Perbaiki tombol stop'));
233
106
 
234
107
  expect(harness.holder.api?.titleHint('omp-1')).toBe('Perbaiki tombol stop');
235
108
  expect(harness.holder.api?.titleHint('omp-2')).toBeUndefined();
@@ -239,14 +112,10 @@ describe('SidebarDataProvider carries the sent text for a placeholder row', () =
239
112
 
240
113
  test('ignores a blank hint and drops one once the session is listed', async () => {
241
114
  const harness = await mountProvider();
242
- await act(async () => {
243
- hint('a', ' ');
244
- });
115
+ await withAct(() => hint('a', ' '));
245
116
  expect(harness.holder.api?.titleHint('a')).toBeUndefined();
246
117
 
247
- await act(async () => {
248
- hint('a', 'sent text');
249
- });
118
+ await withAct(() => hint('a', 'sent text'));
250
119
  expect(harness.holder.api?.titleHint('a')).toBe('sent text');
251
120
 
252
121
  // The session is in the structure, so the placeholder is not drawn and the
@@ -257,3 +126,14 @@ describe('SidebarDataProvider carries the sent text for a placeholder row', () =
257
126
  harness.server.stop();
258
127
  });
259
128
  });
129
+
130
+ /**
131
+ * `sidebar:activity` is the topic a tool call republishes, and the merge rules
132
+ * are what make a payload that carries ONLY the running sessions sufficient.
133
+ *
134
+ * The structure is the authority for every row and is re-scanned at every turn
135
+ * boundary; this topic only ever moves the two fields a run advances mid-turn.
136
+ * Two ways that can go wrong, both pinned below: a late frame moving a row
137
+ * BACKWARDS (which reorders the sidebar under the reader), and the merge
138
+ * re-sorting 731 rows for a stamp that did not move.
139
+ */
@@ -31,7 +31,7 @@ import { useRealtimeTopic } from '@/client/hooks/ui/realtime';
31
31
  import { useInputRequiredAlert } from '@/client/hooks/ui/input-required-alert';
32
32
 
33
33
  import { realtimeClient } from '@/shared/lib/realtime/client';
34
- import { TOPIC_SIDEBAR, TOPIC_SIDEBAR_STATUS } from '@/shared/lib/realtime/protocol';
34
+ import { TOPIC_SIDEBAR, TOPIC_SIDEBAR_ACTIVITY, TOPIC_SIDEBAR_STATUS } from '@/shared/lib/realtime/protocol';
35
35
  import {
36
36
  applySeenStrip,
37
37
  SESSION_TITLE_HINT_SIGNAL,
@@ -39,11 +39,11 @@ import {
39
39
  import { subscribeClientSignal } from '@/client/lib/signals';
40
40
  import { buildSidebarSessionStatus } from '@/client/hooks/chat/omp/session-statuses';
41
41
  import type { WorkspaceFolderData } from '@/shared/types';
42
- import type { SidebarPayload, SidebarStatusPayload } from '@/shared/types/realtime';
42
+ import type { SidebarActivityPayload, SidebarPayload, SidebarStatusPayload } from '@/shared/types/realtime';
43
43
 
44
44
  /** Re-exported so a consumer (and a test) names the shape without reaching
45
45
  * into a server module for it. */
46
- export type { SidebarPayload, SidebarStatusPayload };
46
+ export type { SidebarActivityPayload, SidebarPayload, SidebarStatusPayload };
47
47
 
48
48
  export interface SidebarDataHandle {
49
49
  folders: WorkspaceFolderData[];
@@ -115,9 +115,65 @@ function mergeStatus(
115
115
  }));
116
116
  }
117
117
 
118
+ /**
119
+ * Advance each row's live fields from the activity payload.
120
+ *
121
+ * `sidebar:activity` carries only the RUNNING sessions (see its doc), so an id
122
+ * it does not name keeps whatever the structure gave it. Three rules, each of
123
+ * which is a way this can go wrong:
124
+ *
125
+ * - the `updated_at` comparison is what makes two sources safe to
126
+ * interleave: the structure is re-scanned at every turn boundary and may
127
+ * already carry a stamp this payload has not caught up with, and a late
128
+ * activity frame must never move a row BACKWARDS (that reorders the
129
+ * sidebar under the reader);
130
+ * - `hasSubagents` only ever turns ON, because the payload is a live read of
131
+ * what is on disk and a subagent transcript does not disappear when its run
132
+ * ends — a `false` here is "not seen yet", not "none exist";
133
+ * - nothing else is touched. Every other field of a row changes on a
134
+ * STRUCTURAL event, and those publish the structure for their own reason.
135
+ */
136
+ function mergeActivity(
137
+ folders: WorkspaceFolderData[],
138
+ activity: SidebarActivityPayload | null,
139
+ ): WorkspaceFolderData[] {
140
+ if (!activity) return folders;
141
+ let changed = false;
142
+ const merged = folders.map((folder) => ({
143
+ ...folder,
144
+ sessions: (folder.sessions ?? []).map((session) => {
145
+ const live = activity[String(session.id)];
146
+ if (!live) return session;
147
+ const updated_at = live.updatedAt && isNewerStamp(live.updatedAt, session.updated_at)
148
+ ? live.updatedAt
149
+ : session.updated_at;
150
+ const hasSubagents = session.hasSubagents || live.hasSubagents === true;
151
+ if (updated_at === session.updated_at && hasSubagents === Boolean(session.hasSubagents)) return session;
152
+ changed = true;
153
+ return { ...session, updated_at, hasSubagents };
154
+ }),
155
+ }));
156
+ // A stable identity matters: the sidebar's own `useMemo` chain (filter, sort,
157
+ // pending overlay) keys on this array, and returning a fresh one per activity
158
+ // frame would re-sort 731 rows for a stamp that did not move.
159
+ return changed ? merged : folders;
160
+ }
161
+
162
+ /** Whether `next` is a later ISO stamp than `current`. An absent current is older. */
163
+ function isNewerStamp(next: string, current?: string): boolean {
164
+ if (!current) return true;
165
+ const nextMs = Date.parse(next);
166
+ const currentMs = Date.parse(current);
167
+ // An unparseable value on either side is not evidence of a newer stamp; the
168
+ // structure's own field stays authoritative.
169
+ if (!Number.isFinite(nextMs) || !Number.isFinite(currentMs)) return false;
170
+ return nextMs > currentMs;
171
+ }
172
+
118
173
  export function SidebarDataProvider({ children, initialFolders = [] }: { children: ReactNode; initialFolders?: WorkspaceFolderData[] }) {
119
174
  const structure = useRealtimeTopic<SidebarPayload>(TOPIC_SIDEBAR);
120
175
  const status = useRealtimeTopic<SidebarStatusPayload>(TOPIC_SIDEBAR_STATUS);
176
+ const activity = useRealtimeTopic<SidebarActivityPayload>(TOPIC_SIDEBAR_ACTIVITY);
121
177
 
122
178
  // Session ids whose badge the user has already dismissed on this mount.
123
179
  // Optimistic strip: keeps the click→disappearance instant and stops the
@@ -132,8 +188,8 @@ export function SidebarDataProvider({ children, initialFolders = [] }: { childre
132
188
  const [overlayVersion, setOverlayVersion] = useState(0);
133
189
 
134
190
  const folders = useMemo(
135
- () => mergeStatus(structure.data?.folders ?? initialFolders, status.data),
136
- [structure.data, status.data, initialFolders],
191
+ () => mergeActivity(mergeStatus(structure.data?.folders ?? initialFolders, status.data), activity.data),
192
+ [structure.data, status.data, activity.data, initialFolders],
137
193
  );
138
194
  const isMock = structure.data?.isMock ?? false;
139
195
  const initializing = structure.data === null;
@@ -173,6 +229,10 @@ export function SidebarDataProvider({ children, initialFolders = [] }: { childre
173
229
  const refresh = useCallback(() => {
174
230
  realtimeClient.refresh(TOPIC_SIDEBAR);
175
231
  realtimeClient.refresh(TOPIC_SIDEBAR_STATUS);
232
+ // The activity stamps are what a tool call advances, so a manual refresh
233
+ // that skipped them would re-read the list and leave its labels where they
234
+ // were.
235
+ realtimeClient.refresh(TOPIC_SIDEBAR_ACTIVITY);
176
236
  }, []);
177
237
 
178
238
  // The spinner follows a user's click only: it is raised here and lowered when
@@ -188,10 +248,10 @@ export function SidebarDataProvider({ children, initialFolders = [] }: { childre
188
248
  // eslint-disable-next-line react-hooks/exhaustive-deps
189
249
  }, [structure.data]);
190
250
 
191
- // A background push — every tool call republishes the structure — pulses the
192
- // topic's own indicator, so the toolbar button spins for it too. Without this
193
- // the list moved with nothing on screen saying so.
194
- const isRefreshing = refreshing || structure.refreshing;
251
+ // A background push — every tool call advances a live session's activity —
252
+ // pulses the topic's own indicator, so the toolbar button spins for it too.
253
+ // Without this the list moved with nothing on screen saying so.
254
+ const isRefreshing = refreshing || structure.refreshing || activity.refreshing;
195
255
 
196
256
  // Fires for ANY session, including one this tab never opened — the cue that an
197
257
  // agent is blocked on an answer must not depend on the open timeline.
@@ -35,6 +35,14 @@ export interface GeneratingState {
35
35
  ownRun: boolean;
36
36
  /** Non-reactive mirror of `ownRun` for the session-load guard. */
37
37
  ownRunRef: { current: boolean };
38
+ /**
39
+ * The STREAM has spoken for the run in flight — an `agent_start` or a resume
40
+ * of a live run, both of which call `setGenerating(true)` without ownership.
41
+ * False for a run this page merely DISPATCHED and whose first frame has not
42
+ * arrived, which is what keeps a reconnect in that window from settling a
43
+ * turn that has not started.
44
+ */
45
+ streamRunRef: { current: boolean };
38
46
  /**
39
47
  * Set the generating state. `owned` marks a run THIS PAGE dispatched; every
40
48
  * caller but the send path omits it, so an adopted run never claims ownership
@@ -70,6 +78,7 @@ export function useGeneratingState(): GeneratingState {
70
78
  const [isGenerating, setIsGenerating] = useState(false);
71
79
  const isGeneratingRef = useRef(false);
72
80
  const ownRunRef = useRef(false);
81
+ const streamRunRef = useRef(false);
73
82
  const [ownRun, setOwnRun] = useState(false);
74
83
  const [generatingVerb, setGeneratingVerbState] = useState('');
75
84
  const activityRef = useRef('');
@@ -80,8 +89,15 @@ export function useGeneratingState(): GeneratingState {
80
89
  // then re-rendered again — an unbounded fetch loop.
81
90
  const setGenerating = useCallback((v: boolean, owned = false) => {
82
91
  isGeneratingRef.current = v;
83
- if (!v) ownRunRef.current = false;
84
- else if (owned) ownRunRef.current = true;
92
+ if (!v) {
93
+ ownRunRef.current = false;
94
+ streamRunRef.current = false;
95
+ } else {
96
+ if (owned) ownRunRef.current = true;
97
+ // Not owned: the stream itself announced this run (`agent_start`, or the
98
+ // mount probe resuming a live one).
99
+ else streamRunRef.current = true;
100
+ }
85
101
  // Mirror onto state: `timelineRunning` is computed during render, so a
86
102
  // ref-only ownership flag could not reach the footer's withheld guard.
87
103
  setOwnRun(ownRunRef.current);
@@ -98,6 +114,7 @@ export function useGeneratingState(): GeneratingState {
98
114
  isGeneratingRef,
99
115
  ownRun,
100
116
  ownRunRef,
117
+ streamRunRef,
101
118
  setGenerating,
102
119
  generatingVerb,
103
120
  activityRef,