@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
@@ -23,6 +23,8 @@ import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
23
23
  import type { ChatMessageData } from '@/shared/types';
24
24
  import { SESSION_META_RETRY_SCHEDULE_MS } from '@/shared/lib/workspace/refresh-cadence';
25
25
  import { useSessionPagination } from '@/client/hooks/chat/timeline/session-pagination';
26
+ import { useLostRunRecovery } from '@/client/hooks/chat/timeline/lost-run';
27
+ import { isOwnSpawn, isSpawnArrival } from '@/client/hooks/chat/timeline/session-switch';
26
28
 
27
29
  export interface SessionDataShape {
28
30
  id?: string;
@@ -52,6 +54,12 @@ export interface UseSessionLoadDeps {
52
54
  * UI sets `isGeneratingRef` too, and treating that as ownership discarded
53
55
  * the whole transcript, leaving an empty timeline over a running session. */
54
56
  ownRunRef: { current: boolean };
57
+ /** The stream has announced the run in flight (see `streamRunRef`). */
58
+ streamRunRef: { current: boolean };
59
+ /** The SERVER reports a run for this session (the `stream` row). Together
60
+ * with `streamRunRef` this is the evidence the reconnect recovery needs: a
61
+ * run whose FIRST frame was lost has no `agent_start` on this client. */
62
+ serverRunRef: { current: boolean };
55
63
  /** Live AI placeholder ref: the row the streaming answer is filling. Read on
56
64
  * a session switch (cleared with the optimistic user id) and by the stream
57
65
  * callbacks; it is NOT the timeline-ownership signal — the placeholder is
@@ -60,6 +68,10 @@ export interface UseSessionLoadDeps {
60
68
  /** Live optimistic user bubble ref: cleared together with the placeholder on
61
69
  * a session switch so stale ids cannot survive into the next session. */
62
70
  optimisticUserIdRef: { current: string | null };
71
+ /** Raw composer text of steer/follow-up turns awaiting omp's echo. Cleared
72
+ * when a lost run is settled, or a later echo could be re-labelled with an
73
+ * earlier message's text. */
74
+ pendingUserDisplaysRef: { current: { sent: string; display: string }[] };
63
75
  /** Drop queued stream updates belonging to the previous session (coalescer). */
64
76
  cancelStreamingCoalescer: () => void;
65
77
  metaRefreshedRef: { current: string | null };
@@ -73,7 +85,7 @@ export interface UseSessionLoadDeps {
73
85
  }
74
86
 
75
87
  export function useSessionLoad(deps: UseSessionLoadDeps) {
76
- const { sessionId, setLocalMessages, setGenerating, isGeneratingRef, ownRunRef, aiPlaceholderIdRef, optimisticUserIdRef, cancelStreamingCoalescer, metaRefreshedRef, scrollRef, jumpActiveRef, jumpCountRef } = deps;
88
+ const { sessionId, setLocalMessages, setGenerating, isGeneratingRef, ownRunRef, streamRunRef, serverRunRef, aiPlaceholderIdRef, optimisticUserIdRef, pendingUserDisplaysRef, cancelStreamingCoalescer, metaRefreshedRef, scrollRef, jumpActiveRef, jumpCountRef } = deps;
77
89
 
78
90
  const [sessionData, setSessionData] = useState<SessionDataShape | null>(null);
79
91
  const [sessionLoading, setSessionLoading] = useState(false);
@@ -163,19 +175,16 @@ export function useSessionLoad(deps: UseSessionLoadDeps) {
163
175
  * A committed fetch is a LAGGING snapshot while a run is live. For a fresh
164
176
  * spawn there may be no transcript on disk at all (omp buffers its writes
165
177
  * until the first assistant message settles), and it never carries a segment
166
- * still streaming. Applying one mid-run costs the operator their own turn in
167
- * either direction — measured on real sessions: an empty payload emptied the
168
- * list (the just-sent turn vanished), and a payload carrying the file's own
169
- * copy of that turn was merged UNDER the live rows, whose ids differ
170
- * (`msg-…-user` vs the echoed id), so the turn rendered twice.
178
+ * still streaming. Applying one mid-run costs the operator their own turn —
179
+ * measured: an empty payload emptied the list, and a payload carrying the
180
+ * file's own copy of that turn was merged UNDER the live rows, whose ids
181
+ * differ, so the turn rendered twice.
171
182
  *
172
183
  * Keyed on the RUN THIS PAGE STARTED, never on `isGeneratingRef` alone: the
173
- * mount probe that resumes a live run's generating UI sets that flag too, so
174
- * keying on it made OPENING a streaming session discard its entire committed
175
- * transcript — measured on a real 333-message run: the timeline held 0 rows
176
- * with only a "Load earlier messages" button, and the button paged a window
177
- * into a list whose head was empty. A run adopted that way must still load
178
- * its history; the stream resume continues from there. */
184
+ * mount probe that resumes a live run sets that flag too, so keying on it
185
+ * made OPENING a streaming session discard its whole committed transcript
186
+ * (measured: 0 rows, only a "Load earlier messages" button). An adopted run
187
+ * must still load its history; the resume continues from there. */
179
188
  const timelineOwnedByLiveRun = useCallback(
180
189
  () => ownRunRef.current && isGeneratingRef.current,
181
190
  [isGeneratingRef, ownRunRef],
@@ -193,40 +202,57 @@ export function useSessionLoad(deps: UseSessionLoadDeps) {
193
202
  [isGeneratingRef],
194
203
  );
195
204
 
205
+ // A channel that comes back after the run's `message_end`/`agent_end` were
206
+ // lost with it: the timeline holds the stream's last partial segment and
207
+ // nothing else will ever finalize it (see `lost-run.ts`). A run that is
208
+ // STILL LIVE at reconnect is left alone here, but its lost segments are
209
+ // remembered and re-read when it settles.
210
+ const { reloadMessages, needsReloadRef } = useLostRunRecovery({
211
+ sessionId,
212
+ sessionIdRef,
213
+ adoptedSessionIdRef,
214
+ isGeneratingRef,
215
+ streamRunRef,
216
+ serverRunRef,
217
+ setGenerating,
218
+ setLocalMessages,
219
+ applyWindow,
220
+ aiPlaceholderIdRef,
221
+ optimisticUserIdRef,
222
+ pendingUserDisplaysRef,
223
+ cancelStreamingCoalescer,
224
+ });
225
+
196
226
  // Fetch session messages and details from API
197
227
  useEffect(() => {
198
228
  let active = true;
199
229
  setSessionLoading(true);
200
- // Track the previous session id so a session switch (including "New
201
- // Session" → new-…) clears the timeline, while the optimistic spawn
202
- // transition (new-… → real UUID) keeps its bubbles.
230
+ // Track the previous id so a real switch clears the timeline while a
231
+ // spawn's arrival keeps its bubbles (see `session-switch.ts`).
203
232
  if (sessionId !== prevSessionIdRef.current) {
204
- const isSpawnAdopt = sessionId && !sessionId.startsWith('new-') && prevSessionIdRef.current?.startsWith('new-');
205
- // Keep the optimistic bubbles while a local send is in flight and this
206
- // session is the one it spawned (a fresh spawn may not have passed
207
- // through "new-…"). A reload mid-run has no adopted id, so history loads.
208
- const isAdoptingInFlight = isGeneratingRef.current && adoptedSessionIdRef.current === sessionId;
209
- if (!isSpawnAdopt && !isAdoptingInFlight) {
233
+ // A spawn's arrival keeps the optimistic rows; anything else is a switch
234
+ // to another session. The two shapes — `new-…` → UUID and a chat opened
235
+ // from a workspace whose URL moves straight to the child's UUID — and why
236
+ // the second one used to clear a finished turn are in `session-switch.ts`.
237
+ const spawnArrival = isSpawnArrival({
238
+ sessionId,
239
+ previousSessionId: prevSessionIdRef.current,
240
+ adoptedSessionId: adoptedSessionIdRef.current,
241
+ });
242
+ if (!spawnArrival) {
210
243
  setLocalMessages([]);
211
244
  setGenerating(false);
212
245
  adoptedSessionIdRef.current = null;
213
246
  seededModelRef.current = null;
214
- // Metadata (model / thinking level) belongs to ONE session: leaving it
215
- // in place let a pending "new-…" composer — and any other session's
216
- // composer — display a level that session never ran with, until the
217
- // fetch replaced it.
247
+ // Metadata and the thinking seed belong to ONE session: leaving either
248
+ // in place let a composer display a level its session never ran with.
218
249
  setSessionData(null);
219
- // The thinking seed belongs to the previous session's spawn; keeping it
220
- // would report that level for the newly opened session.
221
250
  seededThinkingLevelRef.current = null;
222
251
  // The placeholder/optimistic ids belong to the PREVIOUS session's
223
- // in-flight send; message_end never arrives after a switch away (the
224
- // stream is disconnected), so they must be dropped here. A stale
225
- // placeholder would let the next session's stream reconcile into a row
226
- // that is no longer on screen. (`setGenerating(false)` above releases
227
- // the run ownership this page held — the guard reads `ownRunRef`, so a
228
- // stale value there is what used to discard a mid-run session's
229
- // committed history on return.)
252
+ // in-flight send, and `message_end` never arrives after a switch away.
253
+ // (`setGenerating(false)` above releases the run ownership this page
254
+ // held — the guard reads `ownRunRef`, so a stale value there is what
255
+ // discarded a mid-run session's committed history on return.)
230
256
  aiPlaceholderIdRef.current = null;
231
257
  optimisticUserIdRef.current = null;
232
258
  // Drop coalesced message_update frames queued by the previous
@@ -259,6 +285,10 @@ export function useSessionLoad(deps: UseSessionLoadDeps) {
259
285
  // means this payload is a lagging snapshot of a timeline the stream owns.
260
286
  const ownedAtIssue = timelineOwnedByLiveRun();
261
287
  const ownedByLiveRun = () => ownedAtIssue || timelineOwnedByLiveRun();
288
+ /** Its transcript may legitimately be missing — omp buffers its writes
289
+ * until the first assistant message settles — so an empty payload is
290
+ * "not written yet", never "nothing happened". */
291
+ const ownSpawn = () => isOwnSpawn(sessionId, adoptedSessionIdRef.current);
262
292
  fetch(`/api/chat/${sessionId}`)
263
293
  .then(res => res.json())
264
294
  .then(data => {
@@ -273,13 +303,13 @@ export function useSessionLoad(deps: UseSessionLoadDeps) {
273
303
  // operator's turn (see `timelineOwnedByLiveRun`).
274
304
  if (!ownedByLiveRun()) {
275
305
  if (fetched.length > 0) setLocalMessages(fetched);
276
- // A pending "new-…" session has nothing committed by definition.
277
- else if (!sessionId.startsWith('new-')) setLocalMessages([]);
306
+ // An own spawn has nothing committed YET (see `isOwnSpawn`).
307
+ else if (!ownSpawn()) setLocalMessages([]);
278
308
  }
279
309
  applyWindow(data.hasMore, data.oldestIndex);
280
310
  } else {
281
311
  setSessionData(seededSessionData());
282
- if (!ownedByLiveRun()) setLocalMessages([]);
312
+ if (!ownedByLiveRun() && !ownSpawn()) setLocalMessages([]);
283
313
  }
284
314
  })
285
315
  .catch(err => {
@@ -313,5 +343,7 @@ export function useSessionLoad(deps: UseSessionLoadDeps) {
313
343
  adoptedSessionIdRef,
314
344
  refreshSessionMeta,
315
345
  seedSession,
346
+ reloadMessages,
347
+ needsReloadRef,
316
348
  };
317
349
  }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * `isSpawnArrival` / `isOwnSpawn` — the two questions a session-id change asks.
8
+ *
9
+ * Both are pure so every ordering can be stated directly. The orderings are the
10
+ * point: the browser is where they were found (three CI specs at once showed an
11
+ * answer on screen and then an empty timeline), but reproducing a child that
12
+ * answers before the URL flips is not something a browser spec can be asked to
13
+ * do on demand.
14
+ */
15
+
16
+ import { describe, expect, test } from 'bun:test';
17
+ import { isOwnSpawn, isSpawnArrival } from '@/client/hooks/chat/timeline/session-switch';
18
+
19
+ describe('isSpawnArrival', () => {
20
+ test('the id the send path recorded is an arrival, whatever the previous id was', () => {
21
+ // A chat opened from a WORKSPACE: no `?sessionId=` at all, so the previous
22
+ // id is null and the URL moves straight to the child's UUID. This is the
23
+ // shape that used to clear a finished turn.
24
+ expect(
25
+ isSpawnArrival({ sessionId: 'uuid-1', previousSessionId: null, adoptedSessionId: 'uuid-1' }),
26
+ ).toBe(true);
27
+ // A pending chat, mid-run or after it: same fact.
28
+ expect(
29
+ isSpawnArrival({ sessionId: 'uuid-1', previousSessionId: 'new-9', adoptedSessionId: 'uuid-1' }),
30
+ ).toBe(true);
31
+ });
32
+
33
+ test('`new-…` → UUID is still an arrival when the record was already consumed', () => {
34
+ // The pending store hands the adoption to the session-state provider once,
35
+ // so the URL's own shape is the only evidence left.
36
+ expect(
37
+ isSpawnArrival({ sessionId: 'uuid-1', previousSessionId: 'new-9', adoptedSessionId: null }),
38
+ ).toBe(true);
39
+ });
40
+
41
+ test('opening another session is not an arrival', () => {
42
+ expect(
43
+ isSpawnArrival({ sessionId: 'uuid-2', previousSessionId: 'uuid-1', adoptedSessionId: 'uuid-1' }),
44
+ ).toBe(false);
45
+ });
46
+
47
+ test('a null or unchanged id is not an arrival', () => {
48
+ expect(isSpawnArrival({ sessionId: null, previousSessionId: 'uuid-1', adoptedSessionId: null })).toBe(false);
49
+ expect(
50
+ isSpawnArrival({ sessionId: 'uuid-1', previousSessionId: 'uuid-1', adoptedSessionId: 'uuid-1' }),
51
+ ).toBe(true);
52
+ });
53
+ });
54
+
55
+ describe('isOwnSpawn', () => {
56
+ test('a pending id and the adopted UUID are both own spawns', () => {
57
+ expect(isOwnSpawn('new-9', null)).toBe(true);
58
+ expect(isOwnSpawn('uuid-1', 'uuid-1')).toBe(true);
59
+ });
60
+
61
+ test('a session this page did not spawn is not one', () => {
62
+ expect(isOwnSpawn('uuid-2', 'uuid-1')).toBe(false);
63
+ expect(isOwnSpawn(null, 'uuid-1')).toBe(false);
64
+ });
65
+ });
@@ -0,0 +1,63 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Which rows a session-switch keeps, and which a committed payload may replace.
8
+ *
9
+ * Split out of `session-load.ts` for the file-size ceiling, and it is one
10
+ * subject rather than an arbitrary cut: both functions answer "does this arrival
11
+ * belong to the session the timeline is already showing?".
12
+ *
13
+ * The distinction they encode was paid for twice, in opposite directions.
14
+ *
15
+ * - **A spawn's arrival is not a session switch.** The optimistic bubbles are
16
+ * the operator's own turn and the row the stream is filling; clearing them
17
+ * deleted a turn that had already rendered. Two shapes reach here and only one
18
+ * was ever recognised: `new-…` → UUID, and a chat opened from a WORKSPACE
19
+ * (`?folderId=` and no `?sessionId=`), whose previous id is `null` and whose
20
+ * URL moves straight to the child's UUID. The second was keyed on the
21
+ * generating flag, so a FAST child — the scripted one answers in milliseconds
22
+ * — finished before the URL flipped and the reset ran anyway. Measured in CI
23
+ * on three specs at once: the answer was on screen, then the timeline was
24
+ * empty. The id the send path recorded is the fact that survives either
25
+ * ordering.
26
+ * - **A committed payload is a lagging snapshot** while a run this page started
27
+ * owns the rows, and an own spawn's transcript may legitimately be empty (omp
28
+ * buffers its writes until the first assistant message settles). Either way it
29
+ * must not empty the timeline.
30
+ */
31
+
32
+ /** The ids a session-switch decision needs, all of them refs. */
33
+ export interface SessionSwitchFacts {
34
+ sessionId: string | null;
35
+ previousSessionId: string | null;
36
+ /** The id a spawn answered with, recorded by the send path. */
37
+ adoptedSessionId: string | null;
38
+ }
39
+
40
+ /**
41
+ * Whether this change is a spawn's arrival rather than the user opening another
42
+ * session — in which case the optimistic rows stay.
43
+ */
44
+ export function isSpawnArrival({ sessionId, previousSessionId, adoptedSessionId }: SessionSwitchFacts): boolean {
45
+ if (!sessionId) return false;
46
+ // The recorded id is checked FIRST and covers both orderings: it is written
47
+ // before the URL moves, so a child that answered mid-run and one that answered
48
+ // after the run ended are the same fact here.
49
+ if (adoptedSessionId === sessionId) return true;
50
+ // `new-…` → UUID. Kept as a fallback for a spawn whose record was already
51
+ // consumed (the pending store hands the adoption to the session-state
52
+ // provider once), where the URL's own shape is the only evidence left.
53
+ return previousSessionId?.startsWith('new-') === true && !sessionId.startsWith('new-');
54
+ }
55
+
56
+ /**
57
+ * Whether `sessionId` is one THIS PAGE spawned, so an empty committed payload
58
+ * means "not written yet" rather than "nothing happened".
59
+ */
60
+ export function isOwnSpawn(sessionId: string | null, adoptedSessionId: string | null): boolean {
61
+ if (!sessionId) return false;
62
+ return sessionId.startsWith('new-') || adoptedSessionId === sessionId;
63
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The worktree a pending chat picked, as a ref the SEND path can read.
8
+ *
9
+ * The composer is remounted by a stretch, a tab switch and a reload, while the
10
+ * send handler is created once per deps record — so a pick read from render
11
+ * state there would be missed, which is the one outcome the pick exists to
12
+ * prevent. The persisted copy is `chat.spawnTarget` (it survives a reload, and
13
+ * a second tab sees it); the ref is its render-visible mirror.
14
+ *
15
+ * Same shape as the waiting pick's ref (`waiting/choice.ts`), for the same
16
+ * reason: a value written before the send and read at send time cannot travel
17
+ * through props that rebuild.
18
+ */
19
+
20
+ import { useEffect, useRef } from 'preact/hooks';
21
+ import { useSessionState } from '@/client/hooks/workspace/session-state/index';
22
+
23
+ /** Where the chat that has not started will run. */
24
+ export interface SpawnTarget {
25
+ folderId: number | null;
26
+ /** Absolute worktree path, or null for the folder's own checkout. */
27
+ worktreePath: string | null;
28
+ }
29
+
30
+ /** `chat.spawnTarget` — the persisted pick, shared by both layouts' composers. */
31
+ export const SPAWN_TARGET_KEY = 'chat.spawnTarget';
32
+
33
+ export interface SpawnTargetBinding {
34
+ /** The pick, for a control that draws it (the composer's target picker). */
35
+ target: SpawnTarget | null;
36
+ setTarget: (next: SpawnTarget | null) => void;
37
+ /** The send path's view: set before the request goes out. */
38
+ worktreePathRef: { current: string | null };
39
+ }
40
+
41
+ export function useSpawnTarget(sessionId: string | null): SpawnTargetBinding {
42
+ const [target, setTarget] = useSessionState<SpawnTarget | null>(SPAWN_TARGET_KEY, null);
43
+ const worktreePathRef = useRef<string | null>(null);
44
+
45
+ useEffect(() => {
46
+ // A pick for ANOTHER session must not leak into this one: the store is keyed
47
+ // by session, so a change of session id re-reads and this effect re-mirrors.
48
+ worktreePathRef.current = target?.worktreePath ?? null;
49
+ }, [target, sessionId]);
50
+
51
+ return { target, setTarget, worktreePathRef };
52
+ }
@@ -20,6 +20,7 @@
20
20
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from 'bun:test';
21
21
  import { Window } from 'happy-dom';
22
22
  import { h, render } from 'preact';
23
+ import { useState } from 'preact/hooks';
23
24
  import { act } from 'preact/test-utils';
24
25
  import { useToolOpenState, TOOL_OPEN_STATE_KEY, type ToolOpenState } from '@/client/hooks/chat/timeline/tool-open';
25
26
  import { SessionStateProvider } from '@/client/components/common/session-state-provider';
@@ -139,3 +140,76 @@ describe('useToolOpenState', () => {
139
140
  expect(remounted.current?.openMap.a).toBe(true);
140
141
  });
141
142
  });
143
+
144
+ describe('useToolOpenState — a streaming eval opens itself', () => {
145
+ /**
146
+ * Mount a probe whose tools can be REPLACED, so a status transition is
147
+ * observable: the reported bug is about a card that was open for the run and
148
+ * must not snap shut when the call returns.
149
+ */
150
+ async function mountMutable(initial: ToolCallData[]) {
151
+ const api: { current: ToolOpenState | null; setTools: (next: ToolCallData[]) => void } = {
152
+ current: null,
153
+ setTools: () => {},
154
+ };
155
+ function Probe() {
156
+ const [tools, setTools] = useState(initial);
157
+ api.setTools = setTools;
158
+ api.current = useToolOpenState(tools, false);
159
+ return null;
160
+ }
161
+ container = document.createElement('div');
162
+ document.body.appendChild(container);
163
+ await act(async () => {
164
+ render(h(SessionStateProvider, { sessionId: SESSION, children: h(Probe, {}) }), container);
165
+ });
166
+ await act(async () => {
167
+ for (let i = 0; i < 8; i++) await Promise.resolve();
168
+ });
169
+ return api;
170
+ }
171
+
172
+ const evalTool = (partial: Partial<ToolCallData> = {}) =>
173
+ tool('e1', { type: 'eval', name: 'eval', title: 'Counting lines', ...partial });
174
+
175
+ test('a running eval is open, so its streamed output is visible', async () => {
176
+ const api = await mountMutable([evalTool({ status: 'running' })]);
177
+ expect(api.current?.openMap.e1).toBe(true);
178
+ });
179
+
180
+ test('it STAYS open once the call settles', async () => {
181
+ const api = await mountMutable([evalTool({ status: 'running' })]);
182
+ await act(async () => {
183
+ api.setTools([evalTool({ status: 'success', output: 'line 1' })]);
184
+ });
185
+ // Re-deriving from `status` alone closed the card at the exact moment the
186
+ // reader wants the result — the `wait` lesson, restated for eval.
187
+ expect(api.current?.openMap.e1).toBe(true);
188
+ });
189
+
190
+ test('a settled eval from a reloaded transcript stays closed', async () => {
191
+ // History is not a live run: opening every past eval would bury the
192
+ // conversation under scripts nobody asked to see again.
193
+ const api = await mountMutable([evalTool({ status: 'success', output: 'line 1' })]);
194
+ expect(api.current?.openMap.e1).toBe(false);
195
+ });
196
+
197
+ test('a running bash is still folded', async () => {
198
+ // The auto-open is for the card whose body IS the answer, not for every
199
+ // streaming call — a bash log belongs folded.
200
+ const api = await mountMutable([tool('b1', { status: 'running' })]);
201
+ expect(api.current?.openMap.b1).toBe(false);
202
+ });
203
+
204
+ test('the reader can still close a running eval, and it stays closed', async () => {
205
+ const api = await mountMutable([evalTool({ status: 'running' })]);
206
+ await act(async () => {
207
+ api.current?.toggle('e1');
208
+ });
209
+ expect(api.current?.openMap.e1).toBe(false);
210
+ await act(async () => {
211
+ api.setTools([evalTool({ status: 'success', output: 'line 1' })]);
212
+ });
213
+ expect(api.current?.openMap.e1).toBe(false);
214
+ });
215
+ });
@@ -18,7 +18,7 @@
18
18
  * session blob.
19
19
  */
20
20
 
21
- import { useCallback, useMemo } from 'preact/hooks';
21
+ import { useCallback, useEffect, useMemo, useRef } from 'preact/hooks';
22
22
  import { useSessionState } from '@/client/hooks/workspace/session-state';
23
23
  import { isSkippedTool } from '@/shared/lib/chat/tool-status';
24
24
  import type { ToolCallData } from '@/shared/types/chat';
@@ -42,6 +42,22 @@ export const TOOL_OPEN_STATE_KEY = 'chat.toolOpen';
42
42
  * belongs folded, while a wait's is three short rows.
43
43
  *
44
44
  * A stored choice still wins, so closing it stays closed.
45
+ *
46
+ * An `eval` is the second such card, and it is the one a reader reported: a
47
+ * script cell streams its stdout as it runs, and a collapsed card showed
48
+ * `Running` with the output invisible for the whole call — the panel had drawn
49
+ * it all along, folded away, and only expanding by hand (or waiting for the
50
+ * call to return) revealed it. It differs from `bash` the same way `wait` does:
51
+ * an eval's output IS the answer, not a log to consult afterwards, and the
52
+ * reader is watching it arrive.
53
+ *
54
+ * A card that was OPENED because it was running stays open once it settles —
55
+ * `watchedIds` records that, in memory only. Collapsing at the settle would
56
+ * take the output away at the exact moment the reader wants to read it (the
57
+ * `wait` lesson above), while re-deriving from `status` alone would do exactly
58
+ * that. The record is deliberately not persisted: a reloaded transcript is
59
+ * history, and every historical eval reopening itself would bury the
60
+ * conversation under scripts nobody asked to see again.
45
61
  */
46
62
  function defaultOpen(tool: ToolCallData, autoOpenFirst: boolean, index: number): boolean {
47
63
  if (isSkippedTool(tool)) return false;
@@ -50,6 +66,12 @@ function defaultOpen(tool: ToolCallData, autoOpenFirst: boolean, index: number):
50
66
  return autoOpenFirst && index === 0;
51
67
  }
52
68
 
69
+ /** True while this call is a script cell that is still producing output. */
70
+ function isStreamingEval(tool: ToolCallData): boolean {
71
+ if (tool.status !== 'running') return false;
72
+ return (tool.name || tool.type) === 'eval';
73
+ }
74
+
53
75
  export interface ToolOpenState {
54
76
  /** Resolved openness per tool id — stored choice first, default second. */
55
77
  openMap: Record<string, boolean>;
@@ -59,28 +81,50 @@ export interface ToolOpenState {
59
81
 
60
82
  export function useToolOpenState(tools: ToolCallData[], autoOpenFirst: boolean): ToolOpenState {
61
83
  const [stored, setStored] = useSessionState<Record<string, boolean>>(TOOL_OPEN_STATE_KEY, {});
84
+ /** Ids opened BECAUSE they were streaming; see `defaultOpen`'s eval note. */
85
+ const watchedRef = useRef<Set<string>>(new Set());
86
+
87
+ const streamingEvalIds = useMemo(
88
+ () => tools.filter(isStreamingEval).map((tool) => tool.id),
89
+ [tools],
90
+ );
91
+ // Recorded after the render that opened them, so the open survives the call
92
+ // settling. In memory only: a reloaded transcript is history.
93
+ useEffect(() => {
94
+ for (const id of streamingEvalIds) watchedRef.current.add(id);
95
+ }, [streamingEvalIds]);
96
+
97
+ /**
98
+ * The default a card is RENDERING, before any stored choice. One function for
99
+ * the map and for `toggle`, because a toggle that flips a different default
100
+ * than the one on screen takes two clicks to close an auto-opened card — the
101
+ * streaming eval made that reachable, since its default now depends on more
102
+ * than `status`.
103
+ */
104
+ const renderedDefault = useCallback(
105
+ (tool: ToolCallData, index: number): boolean =>
106
+ isStreamingEval(tool) || watchedRef.current.has(tool.id) || defaultOpen(tool, autoOpenFirst, index),
107
+ [autoOpenFirst],
108
+ );
62
109
 
63
110
  const openMap = useMemo(() => {
64
111
  const map: Record<string, boolean> = {};
65
112
  tools.forEach((tool, index) => {
66
113
  const explicit = stored[tool.id];
67
- map[tool.id] = explicit === undefined ? defaultOpen(tool, autoOpenFirst, index) : explicit;
114
+ map[tool.id] = explicit === undefined ? renderedDefault(tool, index) : explicit;
68
115
  });
69
116
  return map;
70
- }, [tools, stored, autoOpenFirst]);
117
+ }, [tools, stored, renderedDefault]);
71
118
 
72
119
  const toggle = useCallback(
73
120
  (toolId: string) => {
74
121
  setStored((prev) => {
75
122
  const tool = tools.find((entry) => entry.id === toolId);
76
- // The first toggle of an untouched call has to flip its DERIVED default,
77
- // not an absent key — otherwise an auto-opened error would need two
78
- // clicks to close.
79
- const current = prev[toolId] ?? (tool ? defaultOpen(tool, autoOpenFirst, tools.indexOf(tool)) : false);
123
+ const current = prev[toolId] ?? (tool ? renderedDefault(tool, tools.indexOf(tool)) : false);
80
124
  return { ...prev, [toolId]: !current };
81
125
  });
82
126
  },
83
- [setStored, tools, autoOpenFirst],
127
+ [setStored, tools, renderedDefault],
84
128
  );
85
129
 
86
130
  return { openMap, toggle };