@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
@@ -6,63 +6,37 @@
6
6
  /**
7
7
  * Pins the session-state store's observer contract and cache lifecycle.
8
8
  *
9
- * The store is a module singleton with private Maps, so the tests drive it
10
- * only through its exports and tear every touched session down with
11
- * `forgetSession` (which clears the cache entry, its dirty flag and its
12
- * pending persist timer). What matters here: a write notifies exactly the
13
- * followers of that (session, slot), a blob arriving notifies every follower
14
- * of the session, a delete notifies, and the merge rules keep a local write
15
- * from being clobbered by a stored blob that predates it.
9
+ * The store is the single source of a session's UI state: every panel reads and
10
+ * writes through it, and it persists the whole blob through
11
+ * `GET/POST /api/sessions/:sessionId/state`. Two contracts matter enough to pin
12
+ * here rather than in a component test:
13
+ *
14
+ * - **Notification routing.** A write notifies the slot's followers and the
15
+ * session-wide ones, never another session's, and an unsubscribed listener
16
+ * stops receiving while an in-flight pass still reaches it.
17
+ * - **Cache lifecycle.** The cache is bounded (10 sessions) and TTL'd (10
18
+ * minutes), and eviction never takes a session with unsaved writes.
19
+ *
20
+ * The blob-arrival half — the write-during-read race — is `store-load.test.ts`,
21
+ * and the wholesale-replacement bus is `replacement.test.ts`.
16
22
  */
17
23
 
18
- import { afterEach, describe, expect, setSystemTime, test } from 'bun:test';
24
+ import { describe, expect, setSystemTime, test } from 'bun:test';
19
25
 
20
- import { notifySessionKey, subscribeSessionKey } from '@/shared/lib/workspace/session-state/listeners';
26
+ import { notifySessionKey } from '@/shared/lib/workspace/session-state/listeners';
21
27
  import {
22
28
  clearSessionKey,
23
- flushSession,
24
29
  forgetSession,
25
30
  getLastOpenedAt,
26
31
  getSessionValue,
27
32
  hydrateSession,
28
- loadSession,
29
33
  migrateSessionState,
30
34
  recordSessionOpen,
31
35
  setSessionKey,
32
36
  } from '@/shared/lib/workspace/session-state/store';
37
+ import { counter, follow, installStoreTeardown, use } from '@/shared/lib/workspace/session-state/harness.test-util';
33
38
 
34
- const originalWindow = Reflect.get(globalThis, 'window');
35
- /** The runner's own fetch, reached through `Bun` so a stub leaked onto the global cannot be mistaken for it. */
36
- const originalFetch = Bun.fetch;
37
- const touched: string[] = [];
38
- const unsubs: Array<() => void> = [];
39
-
40
- /** Register a session id so `afterEach` forgets it. */
41
- function use(id: string): string {
42
- touched.push(id);
43
- return id;
44
- }
45
-
46
- /** Subscribe and auto-unsubscribe at teardown. */
47
- function follow(sessionId: string | null, key: string | null, listener: () => void): () => void {
48
- const unsubscribe = subscribeSessionKey(sessionId, key, listener);
49
- unsubs.push(unsubscribe);
50
- return unsubscribe;
51
- }
52
-
53
- /** Count how many times `notifySessionKey` reaches a listener. */
54
- function counter(): { calls: number; listener: () => void } {
55
- const state = { calls: 0, listener: () => { state.calls += 1; } };
56
- return state;
57
- }
58
-
59
- afterEach(() => {
60
- for (const unsubscribe of unsubs.splice(0)) unsubscribe();
61
- for (const id of touched.splice(0)) forgetSession(id);
62
- Reflect.set(globalThis, 'window', originalWindow);
63
- Reflect.set(globalThis, 'fetch', originalFetch);
64
- setSystemTime();
65
- });
39
+ installStoreTeardown();
66
40
 
67
41
  describe('session-state store: read/write', () => {
68
42
  test('a null session id is a no-op for reads and writes', () => {
@@ -211,102 +185,6 @@ describe('session-state store: notification routing', () => {
211
185
  });
212
186
  });
213
187
 
214
- describe('session-state store: blob arrival (loadSession)', () => {
215
- function stubWindow(): void {
216
- Reflect.set(globalThis, 'window', {});
217
- }
218
-
219
- function stubFetch(response: unknown): void {
220
- Reflect.set(globalThis, 'fetch', async () => response);
221
- }
222
-
223
- test('a clean session adopts the stored blob and notifies', async () => {
224
- const id = use('load-clean');
225
- stubWindow();
226
- stubFetch({ ok: true, json: async () => ({ state: { a: 1, b: 2 } }) });
227
- const probe = counter();
228
- follow(id, null, probe.listener);
229
-
230
- await loadSession(id);
231
- expect<number | undefined>(getSessionValue(id, 'a')).toBe(1);
232
- expect<string | number | boolean | Record<string, unknown> | unknown[] | null | undefined>(getSessionValue(id, 'b')).toBe(2);
233
- expect(probe.calls).toBe(1);
234
- });
235
-
236
- test('a dirty session keeps its local writes over the blob', async () => {
237
- const id = use('load-dirty');
238
- stubWindow();
239
- setSessionKey(id, 'a', 99);
240
- stubFetch({ ok: true, json: async () => ({ state: { a: 1, b: 2 } }) });
241
-
242
- await loadSession(id);
243
- expect<string | number | boolean | Record<string, unknown> | unknown[] | null | undefined>(getSessionValue(id, 'a')).toBe(99);
244
- expect<string | number | boolean | Record<string, unknown> | unknown[] | null | undefined>(getSessionValue(id, 'b')).toBe(2);
245
- });
246
-
247
- test('a non-ok response leaves the cache untouched but still notifies', async () => {
248
- const id = use('load-error');
249
- stubWindow();
250
- stubFetch({ ok: false, json: async () => ({}) });
251
- const probe = counter();
252
- follow(id, null, probe.listener);
253
-
254
- await loadSession(id);
255
- expect(getSessionValue(id, 'a')).toBeUndefined();
256
- expect(probe.calls).toBe(1);
257
- });
258
-
259
- test('a rejected fetch is swallowed and notifies', async () => {
260
- const id = use('load-throw');
261
- stubWindow();
262
- Reflect.set(globalThis, 'fetch', async () => {
263
- throw new Error('offline');
264
- });
265
- const probe = counter();
266
- follow(id, null, probe.listener);
267
-
268
- // The store logs the failure; keep the expected warning out of the run log.
269
- const warn = console.warn;
270
- console.warn = () => {};
271
- try {
272
- await loadSession(id);
273
- } finally {
274
- console.warn = warn;
275
- }
276
- expect(getSessionValue(id, 'a')).toBeUndefined();
277
- expect(probe.calls).toBe(1);
278
- });
279
-
280
- test('loadSession without a window returns early: no fetch, no notify', async () => {
281
- const id = use('load-nowindow');
282
- // Removed for the case and put back after it: another file's hook can have
283
- // installed one while this file runs, and leaving it missing would break
284
- // whichever case follows.
285
- const hadWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
286
- Reflect.deleteProperty(globalThis, 'window');
287
- let fetched = false;
288
- Reflect.set(globalThis, 'fetch', async () => {
289
- fetched = true;
290
- throw new Error('must not be called');
291
- });
292
- try {
293
- const probe = counter();
294
- follow(id, null, probe.listener);
295
- await loadSession(id);
296
- expect(fetched).toBe(false);
297
- expect(probe.calls).toBe(0);
298
- } finally {
299
- if (hadWindow) Object.defineProperty(globalThis, 'window', hadWindow);
300
- Reflect.set(globalThis, 'fetch', Bun.fetch);
301
- }
302
- });
303
-
304
- test('flushSession is a resolved no-op without a window or id', async () => {
305
- await expect(flushSession(null)).resolves.toBeUndefined();
306
- await expect(flushSession(use('flush'))).resolves.toBeUndefined();
307
- });
308
- });
309
-
310
188
  describe('session-state store: bounded cache', () => {
311
189
  test('the oldest clean session is evicted past the size cap', () => {
312
190
  const ids = Array.from({ length: 11 }, (_unused, index) => use(`lru-${index}`));
@@ -66,91 +66,52 @@
66
66
  * from another conversation is never restored
67
67
  * - chat.draft string
68
68
  * - chat.draftAttachments attachment metadata array
69
+ *
70
+ * The module is three files, because one had passed the per-file ceiling: this
71
+ * one owns the PUBLIC API (read, write, load, migrate) and what a write means;
72
+ * `cache.ts` owns the storage (the maps, the LRU order, the idle TTL, the dirty
73
+ * flag); `merge.ts` owns the write counter and the "which copy wins when a blob
74
+ * lands" decision. The maps live only in `cache.ts`, so "is this session dirty"
75
+ * and "drop this session" cannot be answered from two places that disagree.
69
76
  */
70
77
 
71
78
  import { notifySessionKey, notifySessionReplaced } from '@/shared/lib/workspace/session-state/listeners';
79
+ import {
80
+ clearDirty,
81
+ dropEntry,
82
+ dropPersistTimer,
83
+ entryForWrite,
84
+ evictIfNeeded,
85
+ isDirty,
86
+ lastAccess,
87
+ markDirty,
88
+ readEntry,
89
+ setPersistTimer,
90
+ stampAccess,
91
+ touchEntry,
92
+ writeEntry,
93
+ } from '@/shared/lib/workspace/session-state/cache';
94
+ import {
95
+ currentWriteSeq,
96
+ forgetWrites,
97
+ keysWrittenAfter,
98
+ mergeStoredBlob,
99
+ noteWrite,
100
+ } from '@/shared/lib/workspace/session-state/merge';
72
101
 
73
102
  type SessionState = Record<string, unknown>;
74
103
 
75
- const cache = new Map<string, SessionState>();
76
- /** Epoch ms of the last user-driven access per session (get/set/hydrate). */
77
- const lastTouched = new Map<string, number>();
78
- const dirtySessions = new Map<string, boolean>();
79
- const persistTimers = new Map<string, ReturnType<typeof setTimeout>>();
80
-
81
104
  const PERSIST_DEBOUNCE_MS = 600;
82
105
 
83
- // Bounded LRU cache: cap the number of sessions kept in memory so the tab
84
- // doesn't grow unboundedly as the user visits more sessions.
85
- const MAX_CACHED_SESSIONS = 10;
86
- /** A session whose last user access is older than this is evicted regardless
87
- * of the size cap, so stale per-session UI state (drafts, panel layouts)
88
- * stops occupying memory 10 minutes after the user last opened it. */
89
- const SESSION_IDLE_TTL_MS = 10 * 60 * 1000;
90
-
91
- /**
92
- * Reorder a cache entry to the end of the Map's iteration order (the
93
- * most-recently-used position) via delete-then-set. Map.set on an existing
94
- * key updates the value but keeps its original position, so the explicit
95
- * delete is required for true LRU semantics.
96
- */
97
- function touchCacheEntry(sessionId: string): void {
98
- const value = cache.get(sessionId);
99
- if (value !== undefined) {
100
- cache.delete(sessionId);
101
- cache.set(sessionId, value);
102
- }
103
- lastTouched.set(sessionId, Date.now());
104
- }
105
-
106
- /**
107
- * Evict the oldest clean sessions until the cache is within bounds.
108
- *
109
- * Dirty-safety rule: a session with unsaved writes (`dirtySessions.get(id) ===
110
- * true`) must never be evicted, or the user's drafts and editor state would be
111
- * silently lost. If every candidate is dirty (or is the protected id currently
112
- * being written), stop evicting rather than lose data.
113
- *
114
- * Entries idle past SESSION_IDLE_TTL_MS are evicted first — even when the
115
- * cache is under the size cap — so long-unused sessions drop out on schedule.
116
- */
117
- function evictIfNeeded(protectedId: string | null): void {
118
- const now = Date.now();
119
- const evict = (id: string): void => {
120
- cache.delete(id);
121
- dirtySessions.delete(id);
122
- lastTouched.delete(id);
123
- const timer = persistTimers.get(id);
124
- if (timer) {
125
- clearTimeout(timer);
126
- persistTimers.delete(id);
127
- }
128
- };
129
- // TTL pass: safe to skip only while the timer still holds the unsaved state
130
- // or the session is the one currently being written.
131
- for (const [id, touched] of lastTouched) {
132
- if (now - touched <= SESSION_IDLE_TTL_MS) continue;
133
- if (id === protectedId) continue;
134
- if (dirtySessions.get(id)) continue;
135
- evict(id);
136
- }
137
- while (cache.size > MAX_CACHED_SESSIONS) {
138
- let evicted = false;
139
- for (const id of cache.keys()) {
140
- if (id === protectedId) continue;
141
- if (dirtySessions.get(id)) continue;
142
- evict(id);
143
- evicted = true;
144
- break;
145
- }
146
- if (!evicted) break;
147
- }
106
+ /** Evict past the cap, dropping this session's write history with it. */
107
+ function evictNow(protectedId: string | null): void {
108
+ evictIfNeeded(protectedId, forgetWrites);
148
109
  }
149
110
 
150
111
  export function getSessionValue<T>(sessionId: string | null, key: string): T | undefined {
151
112
  if (!sessionId) return undefined;
152
- touchCacheEntry(sessionId);
153
- return cache.get(sessionId)?.[key] as T | undefined;
113
+ touchEntry(sessionId);
114
+ return readEntry(sessionId)?.[key] as T | undefined;
154
115
  }
155
116
 
156
117
  /**
@@ -166,18 +127,20 @@ export function getSessionValue<T>(sessionId: string | null, key: string): T | u
166
127
  */
167
128
  export function getSessionState(sessionId: string | null): Readonly<Record<string, unknown>> | undefined {
168
129
  if (!sessionId) return undefined;
169
- return cache.get(sessionId);
130
+ return readEntry(sessionId);
170
131
  }
171
132
 
172
133
  export function setSessionKey(sessionId: string | null, key: string, value: unknown): void {
173
134
  if (!sessionId) return;
174
- const state = cache.get(sessionId) ?? {};
175
- state[key] = value;
176
- cache.set(sessionId, state);
177
- touchCacheEntry(sessionId);
178
- dirtySessions.set(sessionId, true);
135
+ // Mutated IN PLACE, never copied: `useSessionState` compares the store's entry
136
+ // against its own copy by IDENTITY to decide whether a fresher write exists,
137
+ // and a copy per write made every reader see a different object for one slot.
138
+ entryForWrite(sessionId)[key] = value;
139
+ touchEntry(sessionId);
140
+ markDirty(sessionId);
141
+ noteWrite(sessionId, key);
179
142
  schedulePersist(sessionId);
180
- evictIfNeeded(sessionId);
143
+ evictNow(sessionId);
181
144
  notifySessionKey(sessionId, key);
182
145
  }
183
146
 
@@ -188,22 +151,25 @@ export function setSessionKey(sessionId: string | null, key: string, value: unkn
188
151
  */
189
152
  export function clearSessionKey(sessionId: string | null, key: string): void {
190
153
  if (!sessionId) return;
191
- const state = cache.get(sessionId);
154
+ const state = readEntry(sessionId);
192
155
  if (!state || !(key in state)) return;
193
- delete state[key];
194
- touchCacheEntry(sessionId);
195
- dirtySessions.set(sessionId, true);
156
+ delete (state as SessionState)[key];
157
+ touchEntry(sessionId);
158
+ markDirty(sessionId);
159
+ // A DELETE is a write: a blob that still carries the key must not bring it
160
+ // back (a retired key cleared on this side would return from the other tab's
161
+ // older snapshot).
162
+ noteWrite(sessionId, key);
196
163
  schedulePersist(sessionId);
197
164
  notifySessionKey(sessionId, key);
198
165
  }
199
166
 
200
167
  function schedulePersist(sessionId: string): void {
201
- const existing = persistTimers.get(sessionId);
202
- if (existing) clearTimeout(existing);
203
- persistTimers.set(
168
+ dropPersistTimer(sessionId);
169
+ setPersistTimer(
204
170
  sessionId,
205
171
  setTimeout(() => {
206
- persistTimers.delete(sessionId);
172
+ dropPersistTimer(sessionId);
207
173
  void persistSession(sessionId);
208
174
  }, PERSIST_DEBOUNCE_MS),
209
175
  );
@@ -211,9 +177,9 @@ function schedulePersist(sessionId: string): void {
211
177
 
212
178
  async function persistSession(sessionId: string): Promise<void> {
213
179
  if (typeof window === 'undefined' || !sessionId) return;
214
- if (!dirtySessions.get(sessionId)) return;
215
- dirtySessions.set(sessionId, false);
216
- const state = cache.get(sessionId) ?? {};
180
+ if (!isDirty(sessionId)) return;
181
+ clearDirty(sessionId);
182
+ const state = readEntry(sessionId) ?? {};
217
183
  try {
218
184
  await fetch(`/api/sessions/${encodeURIComponent(sessionId)}/state`, {
219
185
  method: 'POST',
@@ -222,18 +188,14 @@ async function persistSession(sessionId: string): Promise<void> {
222
188
  });
223
189
  } catch (err) {
224
190
  console.warn('session-state persist failed:', err);
225
- dirtySessions.set(sessionId, true);
191
+ markDirty(sessionId);
226
192
  }
227
193
  }
228
194
 
229
195
  /** Immediately persist any pending writes for a session (used on switch). */
230
196
  export function flushSession(sessionId: string | null): Promise<void> {
231
197
  if (!sessionId || typeof window === 'undefined') return Promise.resolve();
232
- const timer = persistTimers.get(sessionId);
233
- if (timer) {
234
- clearTimeout(timer);
235
- persistTimers.delete(sessionId);
236
- }
198
+ dropPersistTimer(sessionId);
237
199
  return persistSession(sessionId);
238
200
  }
239
201
 
@@ -241,27 +203,34 @@ export function flushSession(sessionId: string | null): Promise<void> {
241
203
  export async function loadSession(sessionId: string): Promise<void> {
242
204
  if (!sessionId) return;
243
205
  if (typeof window === 'undefined') return;
206
+ /**
207
+ * The write counter as of the moment the read STARTED.
208
+ *
209
+ * Everything written after this is newer than the response, because the
210
+ * response describes the row as it was when the request arrived. Two
211
+ * mechanisms are needed and neither is sufficient alone: `dirtySessions`
212
+ * covers a write whose persist has not landed yet, while this covers one that
213
+ * already persisted — at which point the dirty flag is clear and a stale
214
+ * response would otherwise be adopted wholesale. Measured: a file opened
215
+ * during the first `/state` read came back with `layout.openedFiles` gone.
216
+ */
217
+ const startedAt = currentWriteSeq();
244
218
  try {
245
219
  const res = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}/state`);
246
220
  if (res.ok) {
247
221
  const data = (await res.json()) as { state?: SessionState };
248
222
  const incoming = data.state && typeof data.state === 'object' ? data.state : {};
249
- const existing = cache.get(sessionId) ?? {};
250
- // Only keys written locally while the fetch was in flight win over the
251
- // stored blob. Merging the WHOLE local cache over the blob (the old
252
- // behavior) resurrected stale values on every tab: a second browser
253
- // opening the session had cached the pre-write blob, and its stale
254
- // copy clobbered another tab's fresh writes — queue items written in
255
- // one tab never appeared in the other.
256
- const dirty = dirtySessions.get(sessionId) === true;
257
- const merged = dirty ? { ...incoming, ...existing } : incoming;
258
- cache.set(sessionId, merged);
259
- touchCacheEntry(sessionId);
223
+ const existing = readEntry(sessionId) ?? {};
224
+ writeEntry(
225
+ sessionId,
226
+ mergeStoredBlob(incoming, existing, keysWrittenAfter(sessionId, startedAt), isDirty(sessionId)),
227
+ );
228
+ touchEntry(sessionId);
260
229
  }
261
230
  } catch (err) {
262
231
  console.warn('session-state load failed:', err);
263
232
  }
264
- evictIfNeeded(sessionId);
233
+ evictNow(sessionId);
265
234
  // The blob replaces every slot at once, so a follower that read the store
266
235
  // before it arrived (no pick yet) has to re-read — including a private copy
267
236
  // held by `useSessionState`, which does not follow per-slot writes.
@@ -272,9 +241,9 @@ export async function loadSession(sessionId: string): Promise<void> {
272
241
  /** Seed the cache synchronously (e.g. from SSR-provided data). */
273
242
  export function hydrateSession(sessionId: string | null, state: SessionState): void {
274
243
  if (!sessionId) return;
275
- cache.set(sessionId, { ...(cache.get(sessionId) ?? {}), ...state });
276
- touchCacheEntry(sessionId);
277
- evictIfNeeded(sessionId);
244
+ writeEntry(sessionId, { ...(readEntry(sessionId) ?? {}), ...state });
245
+ touchEntry(sessionId);
246
+ evictNow(sessionId);
278
247
  notifySessionKey(sessionId);
279
248
  notifySessionReplaced(sessionId);
280
249
  }
@@ -285,12 +254,12 @@ export function hydrateSession(sessionId: string | null, state: SessionState): v
285
254
  * its last open is within SESSION_IDLE_TTL_MS.
286
255
  */
287
256
  export function recordSessionOpen(sessionId: string | null): void {
288
- if (sessionId) lastTouched.set(sessionId, Date.now());
257
+ if (sessionId) stampAccess(sessionId);
289
258
  }
290
259
 
291
260
  /** Epoch ms of the user's last open of this session in this tab, if any. */
292
261
  export function getLastOpenedAt(sessionId: string): number | undefined {
293
- return lastTouched.get(sessionId);
262
+ return lastAccess(sessionId);
294
263
  }
295
264
 
296
265
  /**
@@ -302,14 +271,8 @@ export function getLastOpenedAt(sessionId: string): number | undefined {
302
271
  * the delete and resurrect the state.
303
272
  */
304
273
  export function forgetSession(sessionId: string): void {
305
- const timer = persistTimers.get(sessionId);
306
- if (timer) {
307
- clearTimeout(timer);
308
- persistTimers.delete(sessionId);
309
- }
310
- cache.delete(sessionId);
311
- dirtySessions.delete(sessionId);
312
- lastTouched.delete(sessionId);
274
+ dropEntry(sessionId);
275
+ forgetWrites(sessionId);
313
276
  notifySessionKey(sessionId);
314
277
  notifySessionReplaced(sessionId);
315
278
  }
@@ -319,15 +282,14 @@ export function forgetSession(sessionId: string): void {
319
282
  * by a spawn, then discard the transient slot.
320
283
  */
321
284
  export function migrateSessionState(fromId: string, toId: string): void {
322
- const from = cache.get(fromId);
323
- if (from) cache.set(toId, { ...from, ...(cache.get(toId) ?? {}) });
324
- touchCacheEntry(toId);
325
- dirtySessions.set(toId, true);
285
+ const from = readEntry(fromId);
286
+ if (from) writeEntry(toId, { ...from, ...(readEntry(toId) ?? {}) });
287
+ touchEntry(toId);
288
+ markDirty(toId);
326
289
  schedulePersist(toId);
327
- cache.delete(fromId);
328
- dirtySessions.delete(fromId);
329
- lastTouched.delete(fromId);
330
- evictIfNeeded(toId);
290
+ dropEntry(fromId);
291
+ forgetWrites(fromId);
292
+ evictNow(toId);
331
293
  // The adopted slot gained the whole transient state; the transient one lost it.
332
294
  notifySessionKey(toId);
333
295
  notifySessionKey(fromId);
@@ -20,7 +20,7 @@
20
20
 
21
21
  import { afterAll, afterEach, describe, expect, test } from 'bun:test';
22
22
 
23
- import { activeProjectForSession, composerRootFor } from '@/shared/lib/workspace/active-project';
23
+ import { activeProjectForSession, composerRootFor, effectiveRootFor } from '@/shared/lib/workspace/active-project';
24
24
  import { diffTabId, diffTabName, fileTabId } from '@/shared/lib/workspace/file-tab-id';
25
25
  import {
26
26
  DEFAULT_RIGHT_PANEL_FRACTION,
@@ -173,6 +173,54 @@ describe('active-project', () => {
173
173
  expect(composerRootFor(folders, 30, 1)).toBe('/work/gamma');
174
174
  });
175
175
 
176
+ /**
177
+ * The root the panels follow. A worktree session runs OUTSIDE its folder's
178
+ * project path, so pointing the panels at `project_path` showed the checkout
179
+ * the agent had left — measured: every panel kept reading the main tree while
180
+ * the run wrote to the worktree.
181
+ */
182
+ describe('effectiveRootFor', () => {
183
+ const withWorktree = (status: 'ready' | 'missing'): WorkspaceFolderData[] => [
184
+ {
185
+ id: 1,
186
+ name: 'alpha',
187
+ project_path: '/work/alpha',
188
+ isExpanded: true,
189
+ hasMore: false,
190
+ totalSessions: 2,
191
+ sessions: [
192
+ {
193
+ id: 'wt-session',
194
+ folder_id: 1,
195
+ title: 'in a worktree',
196
+ worktree: {
197
+ branch: 'wt/cosmic-dolphin',
198
+ path: '/data/worktrees/abc/cosmic-dolphin',
199
+ primaryRoot: '/work/alpha',
200
+ status,
201
+ detached: false,
202
+ },
203
+ },
204
+ { id: 'plain', folder_id: 1, title: 'in the checkout' },
205
+ ],
206
+ },
207
+ ];
208
+
209
+ test('a ready worktree wins over the folder project path', () => {
210
+ expect(effectiveRootFor(withWorktree('ready'), 'wt-session')).toBe('/data/worktrees/abc/cosmic-dolphin');
211
+ });
212
+
213
+ test('a MISSING worktree falls back to the project path — a directory that is gone is not a root', () => {
214
+ expect(effectiveRootFor(withWorktree('missing'), 'wt-session')).toBe('/work/alpha');
215
+ });
216
+
217
+ test('an ordinary session keeps the project path', () => {
218
+ expect(effectiveRootFor(withWorktree('ready'), 'plain')).toBe('/work/alpha');
219
+ expect(effectiveRootFor(folders, 30)).toBe('/work/gamma');
220
+ expect(effectiveRootFor(folders, 999)).toBeNull();
221
+ });
222
+ });
223
+
176
224
  test('composerRootFor falls back to the selected folder without a session match', () => {
177
225
  expect(composerRootFor(folders, null, 3)).toBe('/work/gamma');
178
226
  expect(composerRootFor(folders, 999, 3)).toBe('/work/gamma');