@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
@@ -151,6 +151,7 @@ function makeSendHarness(options: {
151
151
  const params: string[] = [];
152
152
  const seeds: SessionSeed[] = [];
153
153
  const spawnSelectionRef = { current: { plan: true, goal: false } };
154
+ const spawnWorktreeRef = { current: null as string | null };
154
155
  const adoptedSessionIdRef = { current: null };
155
156
  const deps: ChatTimelineSendDeps = {
156
157
  folders: options.folders ?? [],
@@ -181,6 +182,7 @@ function makeSendHarness(options: {
181
182
  deferredComposerPickRef: options.deferredPick ?? { current: null },
182
183
  accessModeRef: { current: 'always-ask' },
183
184
  spawnSelectionRef,
185
+ spawnWorktreeRef,
184
186
  abortControllerRef: { current: null },
185
187
  setInputValue: (v) => {
186
188
  drafts.push(v);
@@ -58,6 +58,13 @@ export interface ChatTimelineSendDeps {
58
58
  * outside the render that produced the pick, and it is the FIRST moment a
59
59
  * child exists to carry the selection. */
60
60
  spawnSelectionRef: { current: { plan: boolean; goal: boolean } | null };
61
+ /**
62
+ * The worktree the pending chat picked (`chat.spawnTarget`), read through a
63
+ * REF because the composer is remounted by a stretch, a tab switch and a
64
+ * reload while this handler is created once per deps record — the same reason
65
+ * the waiting pick keeps one.
66
+ */
67
+ spawnWorktreeRef: { current: string | null };
61
68
  /** Picks the composer made while a turn was streaming. Pushed onto the
62
69
  * session here, immediately before the prompt they were meant for. */
63
70
  deferredComposerPickRef: DeferredModelStore;
@@ -104,6 +111,7 @@ export function useChatTimelineSend(deps: ChatTimelineSendDeps): ChatTimelineSen
104
111
  deferredComposerPickRef,
105
112
  accessModeRef,
106
113
  spawnSelectionRef,
114
+ spawnWorktreeRef,
107
115
  abortControllerRef,
108
116
  setInputValue,
109
117
  setSearchParams,
@@ -234,7 +242,9 @@ export function useChatTimelineSend(deps: ChatTimelineSendDeps): ChatTimelineSen
234
242
  // when the spawn fails (e.g. MOCK=true or no workspace context).
235
243
  if (!isOmpSession) {
236
244
  const currentFolder = folders.find(f => String(f.id) === String(selectedFolderId));
237
- const cwd = currentFolder?.project_path || currentFolder?.name;
245
+ // A worktree pick wins: the chat is BORN there, so no relocation is ever
246
+ // needed and the child's cwd is right for its whole life.
247
+ const cwd = spawnWorktreeRef.current || currentFolder?.project_path || currentFolder?.name;
238
248
  if (cwd) {
239
249
  const images = collectImages(attachments);
240
250
  const promptText = await buildPromptText(text, textFiles);
@@ -0,0 +1,205 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The harness both session-load suites share.
8
+ *
9
+ * Split out when the suite passed the per-file ceiling. It mounts the REAL
10
+ * `useSessionLoad` through a `Probe` component and gates every `/api/chat/:id`
11
+ * response, so a suite decides when — and with what — a fetch lands.
12
+ *
13
+ * The committed-history contract itself:
14
+ *
15
+ * While a run is live the timeline's rows are the STREAM's, and a committed
16
+ * fetch is a lagging snapshot: for a fresh spawn there may be no transcript on
17
+ * disk at all (omp buffers its writes until the first assistant message
18
+ * settles), and it never carries a segment still streaming. Applying one
19
+ * mid-run cost the operator their own turn in both directions, measured on real
20
+ * sessions:
21
+ *
22
+ * - an empty payload EMPTIED the list, so the turn they had just sent
23
+ * vanished until a reload;
24
+ * - a payload carrying the file's own copy of that turn was merged UNDER the
25
+ * live rows, whose ids differ (`msg-…-user` vs the echoed id), so the turn
26
+ * rendered twice.
27
+ *
28
+ * Both shapes needed the same hole: the guard was keyed on the AI placeholder,
29
+ * which the stream RELEASES at the first assistant `message_end` while the run
30
+ * continues. These cases pin the run as the ownership signal, and pin the two
31
+ * directions the guard must still let through when no run is live.
32
+ */
33
+
34
+ import { afterAll, afterEach, beforeAll } from 'bun:test';
35
+ import { Window } from 'happy-dom';
36
+ import { h, render } from 'preact';
37
+ import { act } from 'preact/test-utils';
38
+ import { useCallback, useRef, useState } from 'preact/hooks';
39
+ import { installDomGlobals, restoreDomGlobals } from '@/test-support/pristine-globals';
40
+ import { useSessionLoad } from '@/client/hooks/chat/timeline/session-load';
41
+ import type { ChatMessageData } from '@/shared/types';
42
+
43
+ /** The runner's own fetch, reached through `Bun` so a leak is not mistaken for it. */
44
+ const originalFetch = Bun.fetch;
45
+
46
+ /** The optimistic bubble `executeSend` mounts for a locally sent turn. */
47
+ export const OPTIMISTIC_USER: ChatMessageData = { id: 'msg-1-user', role: 'user', content: 'halo', date: 'Today, 12:22 PM' };
48
+ /** The empty assistant row the stream fills. */
49
+ export const AI_PLACEHOLDER: ChatMessageData = { id: 'msg-2-ai', role: 'ai', content: '' };
50
+ /** The same turn as the transcript records it, under its own id. */
51
+ export const ECHOED_TURN: ChatMessageData = { id: 'omp-echo', role: 'user', content: 'halo' };
52
+
53
+ interface LoadGate {
54
+ url: string;
55
+ resolve: (body: unknown) => void;
56
+ }
57
+
58
+ export const gates: LoadGate[] = [];
59
+
60
+ /** One mount's observable state: what the timeline holds and what drives it. */
61
+ export interface ProbeApi {
62
+ messages: ChatMessageData[];
63
+ adoptedSessionIdRef: { current: string | null };
64
+ setMessages: (next: ChatMessageData[]) => void;
65
+ isGeneratingRef: { current: boolean };
66
+ ownRunRef: { current: boolean };
67
+ aiPlaceholderIdRef: { current: string | null };
68
+ optimisticUserIdRef: { current: string | null };
69
+ generatingCalls: boolean[];
70
+ }
71
+
72
+ export function makeApi(): ProbeApi {
73
+ return {
74
+ messages: [],
75
+ adoptedSessionIdRef: { current: null },
76
+ setMessages: () => {},
77
+ isGeneratingRef: { current: false },
78
+ ownRunRef: { current: false },
79
+ aiPlaceholderIdRef: { current: null },
80
+ optimisticUserIdRef: { current: null },
81
+ generatingCalls: [],
82
+ };
83
+ }
84
+
85
+ function Probe({ sessionId, api }: { sessionId: string | null; api: ProbeApi }) {
86
+ const [messages, setMessages] = useState<ChatMessageData[]>([]);
87
+ const isGeneratingRef = useRef(false);
88
+ const ownRunRef = useRef(false);
89
+ const streamRunRef = useRef(false);
90
+ const serverRunRef = useRef(false);
91
+ const aiPlaceholderIdRef = useRef<string | null>(null);
92
+ const optimisticUserIdRef = useRef<string | null>(null);
93
+ const pendingUserDisplaysRef = useRef<{ sent: string; display: string }[]>([]);
94
+ const metaRefreshedRef = useRef<string | null>(null);
95
+ const scrollRef = useRef<HTMLDivElement>(null);
96
+ const jumpActiveRef = useRef(false);
97
+ const jumpCountRef = useRef(0);
98
+ // Stable across renders, like the timeline's own `setGenerating` throat: a
99
+ // fresh identity would re-run the load effect on every render.
100
+ const setGenerating = useCallback((v: boolean) => {
101
+ isGeneratingRef.current = v;
102
+ api.generatingCalls.push(v);
103
+ }, [api]);
104
+
105
+ api.messages = messages;
106
+ api.setMessages = setMessages;
107
+ api.isGeneratingRef = isGeneratingRef;
108
+ api.ownRunRef = ownRunRef;
109
+ api.aiPlaceholderIdRef = aiPlaceholderIdRef;
110
+ api.optimisticUserIdRef = optimisticUserIdRef;
111
+
112
+ const { adoptedSessionIdRef } = useSessionLoad({
113
+ sessionId,
114
+ setLocalMessages: setMessages,
115
+ setGenerating,
116
+ isGeneratingRef,
117
+ ownRunRef,
118
+ streamRunRef,
119
+ serverRunRef,
120
+ aiPlaceholderIdRef,
121
+ optimisticUserIdRef,
122
+ pendingUserDisplaysRef,
123
+ cancelStreamingCoalescer: () => {},
124
+ metaRefreshedRef,
125
+ scrollRef,
126
+ jumpActiveRef,
127
+ jumpCountRef,
128
+ });
129
+ api.adoptedSessionIdRef = adoptedSessionIdRef;
130
+ return null;
131
+ }
132
+
133
+ let container: HTMLElement | undefined;
134
+
135
+ beforeAll(() => {
136
+ installDomGlobals(new Window({ url: 'http://localhost' }));
137
+ });
138
+
139
+ afterAll(() => {
140
+ restoreDomGlobals();
141
+ globalThis.fetch = originalFetch;
142
+ });
143
+
144
+ afterEach(() => {
145
+ if (container) render(null, container);
146
+ container?.remove();
147
+ container = undefined;
148
+ gates.length = 0;
149
+ });
150
+
151
+ /** Stub the loader so the test decides when (and with what) the fetch lands. */
152
+ export function stubChatFetch(): void {
153
+ globalThis.fetch = (async (input: RequestInfo | URL) => {
154
+ const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
155
+ const { promise, resolve } = Promise.withResolvers<Response>();
156
+ gates.push({ url, resolve: (body) => resolve(new Response(JSON.stringify(body))) });
157
+ return promise;
158
+ }) as typeof fetch;
159
+ }
160
+
161
+ /** Mount the real hook and let its session-load effect register its fetch. */
162
+ export async function mount(sessionId: string, api: ProbeApi): Promise<void> {
163
+ container ??= document.body.appendChild(document.createElement('div'));
164
+ await act(async () => {
165
+ render(h(Probe, { sessionId, api }), container as HTMLElement);
166
+ });
167
+ await act(async () => {});
168
+ }
169
+
170
+ /** Land the registered fetch, as the server's response would. */
171
+ export async function resolveLoad(messages: unknown[], index = 0): Promise<void> {
172
+ const gate = gates[index];
173
+ if (!gate) throw new Error('no session fetch was registered');
174
+ await act(async () => {
175
+ gate.resolve({ session: { id: 'sess-1', messages }, hasMore: false, oldestIndex: 0 });
176
+ });
177
+ for (let i = 0; i < 3; i += 1) await act(async () => {});
178
+ }
179
+
180
+ /**
181
+ * Re-render the mounted Probe with a new session id, which is what the spawn
182
+ * adoption (`new-…` → UUID) does when the URL flips while the child streams:
183
+ * the session-load effect re-runs and ISSUES its history fetch at that moment,
184
+ * mid-run. The fetch registered before this call is `gates[0]`; the one this
185
+ * issues is `gates[1]`.
186
+ */
187
+ export async function switchSession(sessionId: string, api: ProbeApi): Promise<void> {
188
+ await act(async () => {
189
+ render(h(Probe, { sessionId, api }), container as HTMLElement);
190
+ });
191
+ await act(async () => {});
192
+ }
193
+
194
+ /** A run this page started, with its optimistic rows already on screen. */
195
+ export async function startLiveRun(api: ProbeApi): Promise<void> {
196
+ await act(async () => {
197
+ api.isGeneratingRef.current = true;
198
+ api.ownRunRef.current = true;
199
+ api.optimisticUserIdRef.current = OPTIMISTIC_USER.id;
200
+ api.setMessages([OPTIMISTIC_USER, AI_PLACEHOLDER]);
201
+ });
202
+ }
203
+
204
+ export const ids = (api: ProbeApi) => api.messages.map((m) => m.id);
205
+
@@ -4,7 +4,7 @@
4
4
  */
5
5
 
6
6
  /**
7
- * The committed-history fetch vs a run this page started.
7
+ * The committed-history fetch vs a run this page started, and a spawn's arrival.
8
8
  *
9
9
  * While a run is live the timeline's rows are the STREAM's, and a committed
10
10
  * fetch is a lagging snapshot: for a fresh spawn there may be no transcript on
@@ -23,170 +23,25 @@
23
23
  * which the stream RELEASES at the first assistant `message_end` while the run
24
24
  * continues. These cases pin the run as the ownership signal, and pin the two
25
25
  * directions the guard must still let through when no run is live.
26
+ *
27
+ * The harness is `session-load.test-util.ts`.
26
28
  */
27
29
 
28
- import { afterAll, afterEach, beforeAll, describe, expect, test } from 'bun:test';
29
- import { Window } from 'happy-dom';
30
- import { h, render } from 'preact';
30
+ import { describe, expect, test } from 'bun:test';
31
31
  import { act } from 'preact/test-utils';
32
- import { useCallback, useRef, useState } from 'preact/hooks';
33
- import { installDomGlobals, restoreDomGlobals } from '@/test-support/pristine-globals';
34
- import { useSessionLoad } from '@/client/hooks/chat/timeline/session-load';
35
- import type { ChatMessageData } from '@/shared/types';
36
-
37
- /** The runner's own fetch, reached through `Bun` so a leak is not mistaken for it. */
38
- const originalFetch = Bun.fetch;
39
-
40
- /** The optimistic bubble `executeSend` mounts for a locally sent turn. */
41
- const OPTIMISTIC_USER: ChatMessageData = { id: 'msg-1-user', role: 'user', content: 'halo', date: 'Today, 12:22 PM' };
42
- /** The empty assistant row the stream fills. */
43
- const AI_PLACEHOLDER: ChatMessageData = { id: 'msg-2-ai', role: 'ai', content: '' };
44
- /** The same turn as the transcript records it, under its own id. */
45
- const ECHOED_TURN: ChatMessageData = { id: 'omp-echo', role: 'user', content: 'halo' };
46
-
47
- interface LoadGate {
48
- url: string;
49
- resolve: (body: unknown) => void;
50
- }
51
-
52
- const gates: LoadGate[] = [];
53
-
54
- /** One mount's observable state: what the timeline holds and what drives it. */
55
- interface ProbeApi {
56
- messages: ChatMessageData[];
57
- setMessages: (next: ChatMessageData[]) => void;
58
- isGeneratingRef: { current: boolean };
59
- ownRunRef: { current: boolean };
60
- aiPlaceholderIdRef: { current: string | null };
61
- optimisticUserIdRef: { current: string | null };
62
- generatingCalls: boolean[];
63
- }
64
-
65
- function makeApi(): ProbeApi {
66
- return {
67
- messages: [],
68
- setMessages: () => {},
69
- isGeneratingRef: { current: false },
70
- ownRunRef: { current: false },
71
- aiPlaceholderIdRef: { current: null },
72
- optimisticUserIdRef: { current: null },
73
- generatingCalls: [],
74
- };
75
- }
76
-
77
- function Probe({ sessionId, api }: { sessionId: string | null; api: ProbeApi }) {
78
- const [messages, setMessages] = useState<ChatMessageData[]>([]);
79
- const isGeneratingRef = useRef(false);
80
- const ownRunRef = useRef(false);
81
- const aiPlaceholderIdRef = useRef<string | null>(null);
82
- const optimisticUserIdRef = useRef<string | null>(null);
83
- const metaRefreshedRef = useRef<string | null>(null);
84
- const scrollRef = useRef<HTMLDivElement>(null);
85
- const jumpActiveRef = useRef(false);
86
- const jumpCountRef = useRef(0);
87
- // Stable across renders, like the timeline's own `setGenerating` throat: a
88
- // fresh identity would re-run the load effect on every render.
89
- const setGenerating = useCallback((v: boolean) => {
90
- isGeneratingRef.current = v;
91
- api.generatingCalls.push(v);
92
- }, [api]);
93
-
94
- api.messages = messages;
95
- api.setMessages = setMessages;
96
- api.isGeneratingRef = isGeneratingRef;
97
- api.ownRunRef = ownRunRef;
98
- api.aiPlaceholderIdRef = aiPlaceholderIdRef;
99
- api.optimisticUserIdRef = optimisticUserIdRef;
100
-
101
- useSessionLoad({
102
- sessionId,
103
- setLocalMessages: setMessages,
104
- setGenerating,
105
- isGeneratingRef,
106
- ownRunRef,
107
- aiPlaceholderIdRef,
108
- optimisticUserIdRef,
109
- cancelStreamingCoalescer: () => {},
110
- metaRefreshedRef,
111
- scrollRef,
112
- jumpActiveRef,
113
- jumpCountRef,
114
- });
115
- return null;
116
- }
117
-
118
- let container: HTMLElement | undefined;
119
-
120
- beforeAll(() => {
121
- installDomGlobals(new Window({ url: 'http://localhost' }));
122
- });
123
-
124
- afterAll(() => {
125
- restoreDomGlobals();
126
- globalThis.fetch = originalFetch;
127
- });
128
-
129
- afterEach(() => {
130
- if (container) render(null, container);
131
- container?.remove();
132
- container = undefined;
133
- gates.length = 0;
134
- });
135
-
136
- /** Stub the loader so the test decides when (and with what) the fetch lands. */
137
- function stubChatFetch(): void {
138
- globalThis.fetch = (async (input: RequestInfo | URL) => {
139
- const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
140
- const { promise, resolve } = Promise.withResolvers<Response>();
141
- gates.push({ url, resolve: (body) => resolve(new Response(JSON.stringify(body))) });
142
- return promise;
143
- }) as typeof fetch;
144
- }
145
-
146
- /** Mount the real hook and let its session-load effect register its fetch. */
147
- async function mount(sessionId: string, api: ProbeApi): Promise<void> {
148
- container ??= document.body.appendChild(document.createElement('div'));
149
- await act(async () => {
150
- render(h(Probe, { sessionId, api }), container as HTMLElement);
151
- });
152
- await act(async () => {});
153
- }
154
-
155
- /** Land the registered fetch, as the server's response would. */
156
- async function resolveLoad(messages: unknown[], index = 0): Promise<void> {
157
- const gate = gates[index];
158
- if (!gate) throw new Error('no session fetch was registered');
159
- await act(async () => {
160
- gate.resolve({ session: { id: 'sess-1', messages }, hasMore: false, oldestIndex: 0 });
161
- });
162
- for (let i = 0; i < 3; i += 1) await act(async () => {});
163
- }
164
-
165
- /**
166
- * Re-render the mounted Probe with a new session id, which is what the spawn
167
- * adoption (`new-…` → UUID) does when the URL flips while the child streams:
168
- * the session-load effect re-runs and ISSUES its history fetch at that moment,
169
- * mid-run. The fetch registered before this call is `gates[0]`; the one this
170
- * issues is `gates[1]`.
171
- */
172
- async function switchSession(sessionId: string, api: ProbeApi): Promise<void> {
173
- await act(async () => {
174
- render(h(Probe, { sessionId, api }), container as HTMLElement);
175
- });
176
- await act(async () => {});
177
- }
178
-
179
- /** A run this page started, with its optimistic rows already on screen. */
180
- async function startLiveRun(api: ProbeApi): Promise<void> {
181
- await act(async () => {
182
- api.isGeneratingRef.current = true;
183
- api.ownRunRef.current = true;
184
- api.optimisticUserIdRef.current = OPTIMISTIC_USER.id;
185
- api.setMessages([OPTIMISTIC_USER, AI_PLACEHOLDER]);
186
- });
187
- }
188
-
189
- const ids = (api: ProbeApi) => api.messages.map((m) => m.id);
32
+ import {
33
+ AI_PLACEHOLDER,
34
+ ECHOED_TURN,
35
+ OPTIMISTIC_USER,
36
+ ids,
37
+ makeApi,
38
+ mount,
39
+ resolveLoad,
40
+ gates,
41
+ startLiveRun,
42
+ stubChatFetch,
43
+ switchSession,
44
+ } from '@/client/hooks/chat/timeline/session-load.test-util';
190
45
 
191
46
  describe('a committed fetch while a run this page started is live', () => {
192
47
  test('keeps the live timeline when the payload carries the turn under its own id', async () => {
@@ -271,6 +126,50 @@ describe('a committed fetch while a run this page started is live', () => {
271
126
  });
272
127
  });
273
128
 
129
+ describe('a spawn that adopted its id after the run ended', () => {
130
+ /**
131
+ * The chat opened from a WORKSPACE (`?folderId=` and no `?sessionId=`), so the
132
+ * first send starts from a null session id and the URL flips to the child's
133
+ * UUID when the spawn answers. `isSpawnAdopt` only recognises a `new-…` →
134
+ * UUID move, and `isAdoptingInFlight` also required the run to still be
135
+ * generating — so a FAST child (the scripted one answers in milliseconds)
136
+ * finished before the URL flip, the reset branch ran, and the turn that had
137
+ * just rendered was cleared. Measured in CI on three specs at once
138
+ * (`rename.e2e.ts`, `editor.e2e.ts`'s drop case, `chat.e2e.ts`'s settle
139
+ * case): the answer was on screen and then the timeline was empty.
140
+ */
141
+ test('keeps the optimistic rows when the adopted id arrives after the run settled', async () => {
142
+ stubChatFetch();
143
+ const api = makeApi();
144
+ await mount('', api);
145
+ // The send: optimistic rows mounted, the run owned by this page…
146
+ await act(async () => {
147
+ api.isGeneratingRef.current = true;
148
+ api.ownRunRef.current = true;
149
+ api.optimisticUserIdRef.current = OPTIMISTIC_USER.id;
150
+ api.setMessages([OPTIMISTIC_USER, AI_PLACEHOLDER]);
151
+ });
152
+ // …and it ENDED before the spawn's id reached the URL.
153
+ await act(async () => {
154
+ api.isGeneratingRef.current = false;
155
+ api.ownRunRef.current = false;
156
+ });
157
+ // The spawn answered: the send path records the id it adopted, and the URL
158
+ // moves to it.
159
+ api.adoptedSessionIdRef.current = 'sess-1';
160
+
161
+ await switchSession('sess-1', api);
162
+ // The fetch the URL flip issued answers with the transcript omp has not
163
+ // written yet — the second half of the same bug, and the one that emptied
164
+ // the timeline even once the reset was skipped.
165
+ // `gates[0]`: the mount was a null session, so this flip issued the FIRST
166
+ // read.
167
+ await resolveLoad([], 0);
168
+
169
+ expect(ids(api)).toEqual([OPTIMISTIC_USER.id, AI_PLACEHOLDER.id]);
170
+ });
171
+ });
172
+
274
173
  describe('a committed fetch with no local run', () => {
275
174
  test('lands the transcript, which is the reload-mid-run and open-a-session path', async () => {
276
175
  stubChatFetch();