@kabmo-labs/kabmo 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (351) hide show
  1. package/README.md +7 -0
  2. package/dist/client/index.html +1 -1
  3. package/dist/client/index.js +403 -384
  4. package/dist/client/static/js/{index-0wvbca0b.js → index-bpptfkz2.js} +905 -875
  5. package/dist/client/static/js/index-k315p1e8.css +1 -0
  6. package/package.json +10 -1
  7. package/src/client/components/common/code-surface/documents.test.ts +120 -0
  8. package/src/client/components/common/code-surface/documents.ts +201 -0
  9. package/src/client/components/common/code-surface/extensions.test.ts +110 -0
  10. package/src/client/components/common/code-surface/extensions.ts +162 -0
  11. package/src/client/components/common/code-surface/handle.ts +102 -0
  12. package/src/client/components/common/code-surface/index.tsx +253 -314
  13. package/src/client/components/common/code-surface/keymap.test.ts +133 -0
  14. package/src/client/components/common/code-surface/keymap.ts +267 -0
  15. package/src/client/components/common/code-surface/marks.ts +74 -0
  16. package/src/client/components/common/code-surface/shiki.ts +234 -0
  17. package/src/client/components/common/code-surface/sync.test.ts +185 -0
  18. package/src/client/components/common/code-surface/sync.ts +58 -0
  19. package/src/client/components/common/code-surface/theme.test.ts +202 -0
  20. package/src/client/components/common/code-surface/theme.ts +111 -0
  21. package/src/client/components/common/editor-view-buttons/index.test.tsx +148 -0
  22. package/src/client/components/common/editor-view-buttons/index.tsx +109 -0
  23. package/src/client/components/common/header-controls/index.tsx +24 -1
  24. package/src/client/components/common/header-controls/shell-chrome.test.tsx +272 -0
  25. package/src/client/components/common/shell-update-button/Modal.tsx +163 -0
  26. package/src/client/components/common/shell-update-button/index.test.tsx +251 -0
  27. package/src/client/components/common/shell-update-button/index.tsx +125 -0
  28. package/src/client/components/common/toolbar-row/index.test.tsx +166 -0
  29. package/src/client/components/common/toolbar-row/index.tsx +189 -0
  30. package/src/client/components/common/workspace-options-menu/index.tsx +19 -1
  31. package/src/client/components/layout/desktop-layout/TopNavbar.tsx +5 -6
  32. package/src/client/components/layout/desktop-layout/WorkspacePanels.tsx +41 -78
  33. package/src/client/components/layout/desktop-layout/index.tsx +55 -69
  34. package/src/client/components/layout/desktop-layout/resizer/index.tsx +6 -3
  35. package/src/client/components/layout/session-sidebar/CategoryHeader.tsx +24 -0
  36. package/src/client/components/layout/session-sidebar/CategoryHeader.worktree.test.tsx +126 -0
  37. package/src/client/components/layout/session-sidebar/CategoryItem.tsx +4 -0
  38. package/src/client/components/layout/session-sidebar/Footer.tsx +8 -15
  39. package/src/client/components/layout/session-sidebar/SessionItem.tsx +12 -0
  40. package/src/client/components/layout/session-sidebar/SessionList.tsx +4 -0
  41. package/src/client/components/layout/session-sidebar/SessionsView.tsx +24 -0
  42. package/src/client/components/layout/session-sidebar/WorktreeBadge.test.ts +48 -0
  43. package/src/client/components/layout/session-sidebar/WorktreeBadge.tsx +55 -0
  44. package/src/client/components/mobile/LayoutWrapper.tsx +23 -16
  45. package/src/client/components/mobile/RightSidebar.tsx +21 -10
  46. package/src/client/components/mobile/SessionSidebar.tsx +8 -0
  47. package/src/client/components/mobile/header-titlebar.test.tsx +172 -0
  48. package/src/client/components/mobile/mobile-main-view/Header.tsx +10 -1
  49. package/src/client/components/mobile/mobile-right-sidebar/FullDiff.tsx +3 -0
  50. package/src/client/components/mobile/mobile-right-sidebar/FullEditor.tsx +63 -38
  51. package/src/client/components/mobile/mobile-right-sidebar/OutlineButton.test.tsx +137 -0
  52. package/src/client/components/mobile/mobile-right-sidebar/OutlineButton.tsx +14 -3
  53. package/src/client/components/mobile/mobile-session-sidebar/Footer.tsx +9 -21
  54. package/src/client/components/mobile/mobile-session-sidebar/Header.tsx +6 -2
  55. package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +16 -0
  56. package/src/client/components/mobile/mobile-session-sidebar/List.tsx +4 -0
  57. package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +5 -0
  58. package/src/client/components/mobile/mobile-session-sidebar/WorktreeDialog.tsx +49 -0
  59. package/src/client/components/mobile/worktree-session.ts +51 -0
  60. package/src/client/components/settings/Modal.tsx +8 -0
  61. package/src/client/components/settings/categories/AppearanceSettings.tsx +3 -0
  62. package/src/client/components/settings/categories/ProjectSettings.tsx +22 -8
  63. package/src/client/components/settings/categories/appearance-settings/EditorThemeSection.test.tsx +167 -0
  64. package/src/client/components/settings/categories/appearance-settings/EditorThemeSection.tsx +184 -0
  65. package/src/client/components/settings/categories/project-settings/WorktreeSettings.tsx +146 -0
  66. package/src/client/components/settings/categories/project-settings/worktree-row.test.ts +83 -0
  67. package/src/client/components/settings/categories/project-settings/worktree-row.ts +47 -0
  68. package/src/client/components/workspace/chat-timeline/EmptyWorkspacePrompt.tsx +36 -55
  69. package/src/client/components/workspace/chat-timeline/PendingChatView.tsx +8 -0
  70. package/src/client/components/workspace/chat-timeline/SystemNotice.tsx +9 -4
  71. package/src/client/components/workspace/chat-timeline/TaskResultContent.tsx +9 -7
  72. package/src/client/components/workspace/chat-timeline/index.tsx +5 -0
  73. package/src/client/components/workspace/chat-timeline/target-picker/WorkspacePicker.test.tsx +132 -0
  74. package/src/client/components/workspace/chat-timeline/target-picker/WorkspacePicker.tsx +120 -0
  75. package/src/client/components/workspace/chat-timeline/target-picker/WorktreePicker.test.tsx +319 -0
  76. package/src/client/components/workspace/chat-timeline/target-picker/WorktreePicker.tsx +285 -0
  77. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/index.test.ts +2 -0
  78. package/src/client/components/workspace/chat-timeline/tool-renderers/hashline-patch/index.tsx +34 -29
  79. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/AstEdit.tsx +11 -9
  80. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Bash.tsx +10 -7
  81. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Edit.tsx +25 -8
  82. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Eval.test.tsx +146 -0
  83. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Eval.tsx +32 -17
  84. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/FileMatches.test.ts +114 -0
  85. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/FileMatches.tsx +111 -0
  86. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Read.tsx +7 -7
  87. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/SearchTool.tsx +9 -59
  88. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/ArtifactBody.tsx +2 -7
  89. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/DiffView.test.ts +27 -28
  90. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/DiffView.tsx +39 -26
  91. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/ExcerptCode.tsx +8 -8
  92. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/HighlightedCode.tsx +35 -0
  93. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/JsonCodeBlock.tsx +4 -4
  94. package/src/client/components/workspace/diff-panel/MergeDiffView.tsx +179 -0
  95. package/src/client/components/workspace/diff-panel/ThemedMergeDiffView.tsx +24 -0
  96. package/src/client/components/workspace/diff-panel/Toolbar.tsx +224 -186
  97. package/src/client/components/workspace/diff-panel/ToolbarSegments.tsx +98 -0
  98. package/src/client/components/workspace/diff-panel/index.tsx +76 -145
  99. package/src/client/components/workspace/diff-panel/merge.ts +315 -0
  100. package/src/client/components/workspace/editor/Body.tsx +4 -7
  101. package/src/client/components/workspace/editor/CommandPalette.tsx +2 -2
  102. package/src/client/components/workspace/editor/FileDocument.tsx +58 -63
  103. package/src/client/components/workspace/editor/FilePane.tsx +4 -7
  104. package/src/client/components/workspace/editor/FindWidget.tsx +7 -2
  105. package/src/client/components/workspace/editor/Toolbar.tsx +220 -123
  106. package/src/client/components/workspace/editor/index.tsx +7 -9
  107. package/src/client/components/workspace/file-explorer/ContextMenu.tsx +2 -1
  108. package/src/client/components/workspace/file-explorer/Modals.tsx +40 -12
  109. package/src/client/components/workspace/file-explorer/OutlineSection.test.tsx +18 -0
  110. package/src/client/components/workspace/file-explorer/OutlineSection.tsx +10 -3
  111. package/src/client/components/workspace/file-explorer/RootMenu.tsx +14 -5
  112. package/src/client/components/workspace/file-explorer/Toolbar.tsx +74 -0
  113. package/src/client/components/workspace/file-explorer/TreeItem.tsx +38 -3
  114. package/src/client/components/workspace/file-explorer/filter.ts +47 -0
  115. package/src/client/components/workspace/file-explorer/index.tsx +77 -64
  116. package/src/client/components/workspace/file-explorer/local-edit.test.ts +135 -0
  117. package/src/client/components/workspace/search-panel/Body.tsx +1 -1
  118. package/src/client/components/workspace/worktree-modal/index.test.tsx +118 -0
  119. package/src/client/components/workspace/worktree-modal/index.tsx +261 -0
  120. package/src/client/hooks/chat/omp/reconnect.test.ts +338 -0
  121. package/src/client/hooks/chat/omp/reconnect.ts +187 -0
  122. package/src/client/hooks/chat/omp/run-liveness.test.ts +71 -0
  123. package/src/client/hooks/chat/omp/run-liveness.ts +65 -0
  124. package/src/client/hooks/chat/omp/session-list.activity.test.ts +114 -0
  125. package/src/client/hooks/chat/omp/session-list.test-util.ts +164 -0
  126. package/src/client/hooks/chat/omp/session-list.test.ts +30 -150
  127. package/src/client/hooks/chat/omp/session-list.tsx +69 -9
  128. package/src/client/hooks/chat/timeline/generating.ts +19 -2
  129. package/src/client/hooks/chat/timeline/index.ts +27 -28
  130. package/src/client/hooks/chat/timeline/lost-run.test.ts +329 -0
  131. package/src/client/hooks/chat/timeline/lost-run.ts +177 -0
  132. package/src/client/hooks/chat/timeline/send-execute.test.ts +2 -0
  133. package/src/client/hooks/chat/timeline/send-steer.test.ts +2 -0
  134. package/src/client/hooks/chat/timeline/send.test.ts +2 -0
  135. package/src/client/hooks/chat/timeline/send.ts +11 -1
  136. package/src/client/hooks/chat/timeline/session-load.test-util.ts +205 -0
  137. package/src/client/hooks/chat/timeline/session-load.test.ts +61 -162
  138. package/src/client/hooks/chat/timeline/session-load.ts +69 -37
  139. package/src/client/hooks/chat/timeline/session-switch.test.ts +65 -0
  140. package/src/client/hooks/chat/timeline/session-switch.ts +63 -0
  141. package/src/client/hooks/chat/timeline/spawn-target.ts +52 -0
  142. package/src/client/hooks/chat/timeline/tool-open.test.ts +74 -0
  143. package/src/client/hooks/chat/timeline/tool-open.ts +52 -8
  144. package/src/client/hooks/code/deferred-highlight.test.ts +182 -0
  145. package/src/client/hooks/code/deferred-highlight.ts +129 -0
  146. package/src/client/hooks/editor/find-state.ts +1 -1
  147. package/src/client/hooks/editor/theme.test.ts +206 -0
  148. package/src/client/hooks/editor/theme.ts +82 -0
  149. package/src/client/hooks/editor/use-editor-drop.test.ts +101 -0
  150. package/src/client/hooks/editor/use-editor-drop.ts +67 -14
  151. package/src/client/hooks/editor/use-editor-find.ts +7 -7
  152. package/src/client/hooks/editor/use-outline-view.test.ts +182 -0
  153. package/src/client/hooks/editor/use-outline-view.ts +94 -7
  154. package/src/client/hooks/editor/use-scroll-restore.test.ts +55 -2
  155. package/src/client/hooks/editor/use-scroll-restore.ts +124 -4
  156. package/src/client/hooks/editor/use-search-reveal.ts +1 -1
  157. package/src/client/hooks/ui/long-press.test.ts +174 -0
  158. package/src/client/hooks/ui/long-press.ts +112 -0
  159. package/src/client/hooks/ui/settings-modal.ts +56 -0
  160. package/src/client/hooks/ui/updates/shell-download.test.ts +75 -0
  161. package/src/client/hooks/ui/updates/shell-download.ts +153 -28
  162. package/src/client/hooks/ui/updates/shell-offer.test.ts +213 -0
  163. package/src/client/hooks/ui/updates/shell-update.test.ts +140 -0
  164. package/src/client/hooks/ui/updates/shell-update.ts +119 -0
  165. package/src/client/hooks/workspace/diff-file.ts +29 -10
  166. package/src/client/hooks/workspace/diff-git-actions.ts +147 -0
  167. package/src/client/hooks/workspace/editor-view.ts +61 -0
  168. package/src/client/hooks/workspace/file-create.ts +41 -11
  169. package/src/client/hooks/workspace/file-listing.ts +56 -1
  170. package/src/client/hooks/workspace/file-tabs.test.ts +62 -5
  171. package/src/client/hooks/workspace/file-tabs.ts +24 -11
  172. package/src/client/hooks/workspace/file-tree-actions.ts +37 -5
  173. package/src/client/hooks/workspace/navbar-session-actions.ts +72 -0
  174. package/src/client/hooks/workspace/worktree/index.ts +165 -0
  175. package/src/client/lib/desktop/bridge.ts +45 -0
  176. package/src/client/lib/search-reveal.ts +1 -1
  177. package/src/client/main.tsx +5 -1
  178. package/src/client/tailwind.css +56 -8
  179. package/src/server/index.ts +7 -2
  180. package/src/server/lib/auth/guard.test.ts +28 -0
  181. package/src/server/lib/auth/guard.ts +10 -0
  182. package/src/server/lib/bundler/titlebar-css.test.ts +42 -1
  183. package/src/server/lib/db/schema.ts +4 -0
  184. package/src/server/lib/db/worktree-registry.server.ts +91 -0
  185. package/src/server/lib/fs/canonical.ts +52 -0
  186. package/src/server/lib/fs/relative-path.ts +48 -0
  187. package/src/server/lib/fs/root-worktree.test.ts +116 -0
  188. package/src/server/lib/fs/root.ts +39 -10
  189. package/src/server/lib/fs/worktree/carry.ts +134 -0
  190. package/src/server/lib/fs/worktree/create.test.ts +249 -0
  191. package/src/server/lib/fs/worktree/create.ts +262 -0
  192. package/src/server/lib/fs/worktree/errors.ts +43 -0
  193. package/src/server/lib/fs/worktree/list.test.ts +207 -0
  194. package/src/server/lib/fs/worktree/list.ts +142 -0
  195. package/src/server/lib/fs/worktree/location.test.ts +206 -0
  196. package/src/server/lib/fs/worktree/location.ts +114 -0
  197. package/src/server/lib/fs/worktree/project-id.ts +53 -0
  198. package/src/server/lib/fs/worktree/remove.test.ts +242 -0
  199. package/src/server/lib/fs/worktree/remove.ts +152 -0
  200. package/src/server/lib/fs/worktree/settings.server.ts +56 -0
  201. package/src/server/lib/fs/worktree/topology.ts +142 -0
  202. package/src/server/lib/lifecycle/claim-port.test.ts +156 -0
  203. package/src/server/lib/lifecycle/port-guard.test.ts +20 -164
  204. package/src/server/lib/lifecycle/port-guard.ts +39 -7
  205. package/src/server/lib/lifecycle/probe-health.test.ts +166 -0
  206. package/src/server/lib/lifecycle/probe.test.ts +77 -164
  207. package/src/server/lib/lifecycle/probe.ts +121 -13
  208. package/src/server/lib/omp/rpc/frame-fold-signals.test.ts +112 -0
  209. package/src/server/lib/omp/rpc/frame-fold.ts +10 -5
  210. package/src/server/lib/omp/session/sidebar-activity.server.test.ts +162 -0
  211. package/src/server/lib/omp/session/sidebar-activity.server.ts +70 -0
  212. package/src/server/lib/omp/session/sidebar-data.canonical.test.ts +155 -0
  213. package/src/server/lib/omp/session/sidebar-data.server.ts +28 -1
  214. package/src/server/lib/omp/session/telemetry/compute.ts +13 -1
  215. package/src/server/lib/omp/session/telemetry.test.ts +33 -0
  216. package/src/server/lib/panels/local-edits.server.ts +33 -0
  217. package/src/server/lib/panels/sync.fixture.test-util.ts +92 -0
  218. package/src/server/lib/panels/sync.local-edits.test.ts +150 -0
  219. package/src/server/lib/panels/sync.server.test.ts +125 -147
  220. package/src/server/lib/panels/sync.server.ts +153 -48
  221. package/src/server/lib/realtime/signals.server.ts +10 -0
  222. package/src/server/lib/realtime/topics/catalog.server.ts +4 -1
  223. package/src/server/lib/realtime/topics.server.ts +16 -0
  224. package/src/server/lib/updates/shell-assets.test.ts +114 -0
  225. package/src/server/lib/updates/shell-assets.ts +200 -0
  226. package/src/server/lib/updates/shell-cache.ts +105 -0
  227. package/src/server/lib/updates/shell-pages.test.ts +187 -0
  228. package/src/server/lib/updates/shell-pages.ts +157 -0
  229. package/src/server/lib/updates/shell-release-desktop.test.ts +166 -0
  230. package/src/server/lib/updates/shell-release.test.ts +59 -71
  231. package/src/server/lib/updates/shell-release.ts +178 -198
  232. package/src/server/routes/fs/action-create.test.ts +18 -13
  233. package/src/server/routes/fs/action-nested.test.ts +184 -0
  234. package/src/server/routes/fs/action.ts +47 -16
  235. package/src/server/routes/fs/git-merge.test.ts +11 -1
  236. package/src/server/routes/fs/read.test.ts +24 -7
  237. package/src/server/routes/fs/scope.test.ts +15 -10
  238. package/src/server/routes/fs/scope.ts +3 -14
  239. package/src/server/routes/index.ts +2 -0
  240. package/src/server/routes/updates/android.ts +6 -1
  241. package/src/server/routes/updates/desktop.ts +59 -0
  242. package/src/server/routes/updates/index.ts +2 -0
  243. package/src/server/routes/updates/ios.ts +5 -1
  244. package/src/server/routes/worktrees/create.ts +138 -0
  245. package/src/server/routes/worktrees/index.ts +25 -0
  246. package/src/server/routes/worktrees/list.ts +134 -0
  247. package/src/server/routes/worktrees/remove.ts +102 -0
  248. package/src/server/routes/worktrees/routes.test.ts +326 -0
  249. package/src/server/routes/worktrees/shared.ts +90 -0
  250. package/src/shared/lib/chat/timeline/omp-callbacks.test.ts +36 -0
  251. package/src/shared/lib/chat/timeline/omp-callbacks.ts +18 -0
  252. package/src/shared/lib/chat/timeline/run-signals.test.ts +2 -0
  253. package/src/shared/lib/code/editor/comment-syntax.test.ts +37 -0
  254. package/src/shared/lib/code/editor/comment-syntax.ts +55 -0
  255. package/src/shared/lib/code/editor/find-replace.test.ts +134 -0
  256. package/src/shared/lib/code/editor/{find.ts → find-replace.ts} +23 -107
  257. package/src/shared/lib/code/editor/{find.test.ts → find-scan.test.ts} +19 -73
  258. package/src/shared/lib/code/editor/find-scan.ts +177 -0
  259. package/src/shared/lib/code/editor/keymap.ts +3 -3
  260. package/src/shared/lib/code/editor/lines.test.ts +7 -55
  261. package/src/shared/lib/code/editor/lines.ts +8 -48
  262. package/src/shared/lib/code/editor/marks.test.ts +4 -5
  263. package/src/shared/lib/code/editor/marks.ts +13 -25
  264. package/src/shared/lib/code/editor/theme-loader.ts +97 -0
  265. package/src/shared/lib/code/editor/themes.generated.ts +156 -0
  266. package/src/shared/lib/code/editor/themes.test.ts +165 -0
  267. package/src/shared/lib/code/editor/themes.ts +165 -0
  268. package/src/shared/lib/code/highlight-memo.test.ts +103 -0
  269. package/src/shared/lib/code/highlight-memo.ts +149 -0
  270. package/src/shared/lib/code/highlighter-warmup.ts +112 -0
  271. package/src/shared/lib/code/highlighter.ts +3 -1
  272. package/src/shared/lib/code/idle.ts +37 -0
  273. package/src/shared/lib/code/outline/rows.test.ts +96 -0
  274. package/src/shared/lib/code/outline/rows.ts +94 -0
  275. package/src/shared/lib/code/syntax-highlight.test.ts +1 -79
  276. package/src/shared/lib/code/syntax-highlight.ts +2 -3
  277. package/src/shared/lib/fs/file-kind-classify.test.ts +25 -11
  278. package/src/shared/lib/fs/file-kind.ts +47 -13
  279. package/src/shared/lib/fs/file-tree.test.ts +183 -0
  280. package/src/shared/lib/fs/file-tree.ts +159 -0
  281. package/src/shared/lib/fs/file-url.test.ts +52 -0
  282. package/src/shared/lib/fs/file-url.ts +37 -0
  283. package/src/shared/lib/fs/patch-sides.test.ts +114 -0
  284. package/src/shared/lib/fs/patch-sides.ts +139 -0
  285. package/src/shared/lib/fs/paths.test.ts +24 -1
  286. package/src/shared/lib/fs/paths.ts +17 -0
  287. package/src/shared/lib/fs/worktree/settings-keys.ts +18 -0
  288. package/src/shared/lib/omp/session/prewarm.ts +7 -0
  289. package/src/shared/lib/realtime/protocol.ts +16 -0
  290. package/src/shared/lib/ui/toolbar-overflow.test.ts +83 -0
  291. package/src/shared/lib/ui/toolbar-overflow.ts +86 -0
  292. package/src/shared/lib/updates/shell-arch.ts +54 -0
  293. package/src/shared/lib/updates/shell-ua.ts +24 -4
  294. package/src/shared/lib/workspace/active-project.ts +23 -0
  295. package/src/shared/lib/workspace/editor-view-mode.test.ts +70 -0
  296. package/src/shared/lib/workspace/editor-view-mode.ts +48 -0
  297. package/src/shared/lib/workspace/panel-widths.test.ts +30 -0
  298. package/src/shared/lib/workspace/panel-widths.ts +25 -5
  299. package/src/shared/lib/workspace/session-state/cache.ts +159 -0
  300. package/src/shared/lib/workspace/session-state/harness.test-util.ts +55 -0
  301. package/src/shared/lib/workspace/session-state/merge.ts +100 -0
  302. package/src/shared/lib/workspace/session-state/store-load.test.ts +206 -0
  303. package/src/shared/lib/workspace/session-state/store.test.ts +17 -139
  304. package/src/shared/lib/workspace/session-state/store.ts +92 -130
  305. package/src/shared/lib/workspace/workspace-modules.test.ts +49 -1
  306. package/src/shared/lib/workspace/workspace-panel-widths.ts +124 -0
  307. package/src/shared/lib/workspace/worktree/branch-name.test.ts +95 -0
  308. package/src/shared/lib/workspace/worktree/branch-name.ts +115 -0
  309. package/src/shared/lib/workspace/worktree/types.ts +104 -0
  310. package/src/shared/types/fs.ts +10 -0
  311. package/src/shared/types/realtime.ts +29 -0
  312. package/src/shared/types/settings/state.ts +10 -0
  313. package/src/shared/types/workspace.ts +14 -0
  314. package/src/test-support/file-explorer-disk.test-util.ts +76 -0
  315. package/src/test-support/file-explorer.test-util.ts +71 -40
  316. package/src/test-support/loopback.ts +183 -0
  317. package/dist/client/static/js/index-nzyjf92m.css +0 -1
  318. package/src/client/components/common/code-editor/handler.test.ts +0 -274
  319. package/src/client/components/common/code-editor/handler.ts +0 -175
  320. package/src/client/components/common/code-editor/index.tsx +0 -314
  321. package/src/client/components/common/code-surface/measure.ts +0 -148
  322. package/src/client/components/workspace/diff-panel/SplitView.tsx +0 -209
  323. package/src/client/components/workspace/diff-panel/UnifiedView.tsx +0 -150
  324. package/src/client/hooks/editor/scroll-container.test.ts +0 -102
  325. package/src/client/hooks/editor/scroll-container.ts +0 -26
  326. package/src/client/hooks/editor/use-code-window.ts +0 -100
  327. package/src/client/hooks/editor/use-editor-history.test.ts +0 -216
  328. package/src/client/hooks/editor/use-editor-history.ts +0 -134
  329. package/src/client/hooks/editor/use-occurrences.test.ts +0 -236
  330. package/src/client/hooks/editor/use-occurrences.ts +0 -98
  331. package/src/shared/lib/code/editor/commands.test.ts +0 -155
  332. package/src/shared/lib/code/editor/commands.ts +0 -260
  333. package/src/shared/lib/code/editor/edits.test.ts +0 -100
  334. package/src/shared/lib/code/editor/edits.ts +0 -122
  335. package/src/shared/lib/code/editor/history.test.ts +0 -118
  336. package/src/shared/lib/code/editor/history.ts +0 -101
  337. package/src/shared/lib/code/editor/layers.test.ts +0 -77
  338. package/src/shared/lib/code/editor/layers.ts +0 -67
  339. package/src/shared/lib/code/editor/line-edits.test.ts +0 -131
  340. package/src/shared/lib/code/editor/line-edits.ts +0 -159
  341. package/src/shared/lib/code/editor/occurrences.test.ts +0 -117
  342. package/src/shared/lib/code/editor/occurrences.ts +0 -111
  343. package/src/shared/lib/code/editor/reset-css.test.ts +0 -35
  344. package/src/shared/lib/code/editor/reset-css.ts +0 -32
  345. package/src/shared/lib/code/editor/text-commands.test.ts +0 -178
  346. package/src/shared/lib/code/editor/text-commands.ts +0 -154
  347. package/src/shared/lib/code/lazy-window.test.ts +0 -161
  348. package/src/shared/lib/code/lazy-window.ts +0 -156
  349. package/src/shared/lib/code/windowed-highlight.ts +0 -114
  350. package/src/shared/lib/fs/diff-parser.test.ts +0 -116
  351. package/src/shared/lib/fs/diff-parser.ts +0 -213
@@ -0,0 +1,47 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * One worktree row's second line, and the words the delete control uses.
8
+ *
9
+ * Split from the component because these are the sentences a reader decides on:
10
+ * "which branch is held here" (and therefore cannot be checked out anywhere
11
+ * else), "how many chats start here", and "is it safe to delete". A markup test
12
+ * would prove the component renders *something*; these functions are what it has
13
+ * to say.
14
+ */
15
+
16
+ import type { WorktreeInfo } from '@/shared/lib/workspace/worktree/types';
17
+
18
+ /** What a row says under its branch. */
19
+ export function worktreeRowDetail(worktree: WorktreeInfo): string {
20
+ if (worktree.missing) return 'folder is missing — its chats still open';
21
+ const branch = worktree.detached
22
+ ? `detached @ ${worktree.head.slice(0, 7) || 'unknown'}`
23
+ : `${worktree.branch} checked out here`;
24
+ const sessions = worktree.sessions === 1 ? '1 chat' : `${worktree.sessions} chats`;
25
+ const changes = worktree.dirty === 0 ? 'clean' : `${worktree.dirty} changed`;
26
+ return `${branch} · ${sessions} · ${changes}`;
27
+ }
28
+
29
+ /**
30
+ * Whether the branch may be deleted along with the worktree.
31
+ *
32
+ * `git branch -d` refuses a branch with commits of its own, so the checkbox is
33
+ * offered only when there is nothing to lose — a control that can only be
34
+ * refused is worse than one that is disabled with the reason beside it.
35
+ */
36
+ export function branchDeletable(worktree: WorktreeInfo): boolean {
37
+ return !worktree.detached && Boolean(worktree.branch) && worktree.aheadOfStart === 0;
38
+ }
39
+
40
+ /** Why the remove control is refused, or null when it may run. */
41
+ export function removalRefusal(worktree: WorktreeInfo): string | null {
42
+ if (worktree.isPrimary) return 'The main checkout cannot be removed.';
43
+ if (worktree.dirty > 0) {
44
+ return `${worktree.dirty} uncommitted file${worktree.dirty === 1 ? '' : 's'} here would be lost.`;
45
+ }
46
+ return null;
47
+ }
@@ -1,9 +1,7 @@
1
1
  import type { SetStateAction } from 'preact/compat';
2
2
  import { Fragment } from 'preact';
3
- import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
4
- import { Check, ChevronDown, Folder } from 'lucide-preact';
5
- import type { Attachment, ChatMessageData } from '@/shared/types';
6
- import { useOnClickOutside } from '@/client/hooks/ui/on-click-outside';
3
+ import { useEffect, useMemo, useRef } from 'preact/hooks';
4
+ import type { Attachment, ChatMessageData, WorkspaceFolderData } from '@/shared/types';
7
5
  import { ChatInput } from '@/client/components/workspace/chat-timeline/chat-input/index';
8
6
  import { ChatMessageItem } from '@/client/components/workspace/chat-timeline/MessageItem';
9
7
  import { RunFooter } from '@/client/components/workspace/chat-timeline/RunFooter';
@@ -16,6 +14,9 @@ import {
16
14
  type WaitingCandidate,
17
15
  } from '@/client/components/workspace/chat-timeline/chat-input/WaitingDependencyPicker';
18
16
  import { Hourglass } from 'lucide-preact';
17
+ import { WorkspacePicker } from '@/client/components/workspace/chat-timeline/target-picker/WorkspacePicker';
18
+ import { WorktreePicker } from '@/client/components/workspace/chat-timeline/target-picker/WorktreePicker';
19
+ import type { SpawnTarget } from '@/client/hooks/chat/timeline/spawn-target';
19
20
 
20
21
  interface EmptyWorkspacePromptProps {
21
22
  className?: string;
@@ -65,6 +66,15 @@ interface EmptyWorkspacePromptProps {
65
66
  streamStatusMap?: Record<string, string | undefined>;
66
67
  /** A name for a streaming session the sidebar has not scanned yet. */
67
68
  titleForSession?: (id: string) => string | undefined;
69
+ /**
70
+ * Where this chat will run. The picker draws it and the send path reads it
71
+ * through its own ref, so a worktree choice survives the remount a stretch or
72
+ * a reload performs.
73
+ */
74
+ spawnTarget?: SpawnTarget | null;
75
+ onSpawnTargetChange?: (next: SpawnTarget | null) => void;
76
+ /** Reports a worktree the composer created or failed to create. */
77
+ onWorktreeReport?: (message: string) => void;
68
78
  /** The "wait for session" pick, persisted per chat. */
69
79
  waitingFor?: WaitingCandidate | null;
70
80
  onWaitingForChange?: (candidate: WaitingCandidate | null) => void;
@@ -108,20 +118,16 @@ export function EmptyWorkspacePrompt({
108
118
  onNewChat,
109
119
  streamStatusMap = {},
110
120
  titleForSession,
121
+ spawnTarget = null,
122
+ onSpawnTargetChange,
123
+ onWorktreeReport,
111
124
  waitingFor = null,
112
125
  onWaitingForChange,
113
126
  heldWait = null,
114
127
  onCancelWait,
115
128
  }: EmptyWorkspacePromptProps) {
116
- const [showWorkspace, setShowWorkspace] = useState(false);
117
- const workspaceRef = useRef<HTMLDivElement>(null);
118
- useOnClickOutside(workspaceRef, () => setShowWorkspace(false));
119
129
  const timelineRef = useRef<HTMLDivElement>(null);
120
130
 
121
- const selectedFolder = useMemo(() => {
122
- return folders?.find(f => f.id === selectedFolderId);
123
- }, [folders, selectedFolderId]);
124
-
125
131
  // Sessions the wait may name: the ones with a run in flight, from the same
126
132
  // server-tracked map the sidebar's spinner is drawn from.
127
133
  const candidates = useMemo(
@@ -159,51 +165,26 @@ export function EmptyWorkspacePrompt({
159
165
  a target workspace, so the composer is the only focus. */}
160
166
  {messages.length === 0 && (
161
167
  <div className="flex items-center gap-1 flex-wrap">
162
- <div className="relative shrink-0" ref={workspaceRef}>
163
- <button
164
- type="button"
165
- onClick={() => setShowWorkspace(!showWorkspace)}
166
- className="group flex items-center space-x-1.5 px-1.5 py-1 rounded-md text-xs font-medium text-ink/75 hover:text-ink hover:bg-ink/5 focus-visible:bg-ink/5 transition-all outline-none cursor-pointer select-none"
167
- >
168
- <Folder size={13} className="text-ink/50 group-hover:text-ink/80 transition-colors flex-shrink-0" />
169
- <span className="flex items-center space-x-1 max-w-[260px] truncate tracking-tight">
170
- <span>{selectedFolder ? selectedFolder.name : 'Select Workspace Context'}</span>
171
- </span>
172
- <ChevronDown size={13} className={`text-ink/40 group-hover:text-ink/70 flex-shrink-0 transition-transform duration-150 ${showWorkspace ? 'rotate-180 text-ink/70' : ''}`} />
173
- </button>
174
-
175
- {showWorkspace && (
176
- <div className="absolute bottom-full left-0 mb-1.5 w-64 bg-paper border border-ink/10 rounded-lg shadow-lg z-50 flex flex-col overflow-hidden text-xs py-1">
177
- <div className="px-3 py-1.5 text-[10px] uppercase tracking-wider text-ink/40 font-semibold border-b border-ink/5">
178
- Target Workspace
179
- </div>
180
- <div className="max-h-56 scrollbar-overlay-container py-1">
181
- {folders.length === 0 ? (
182
- <div className="px-3 py-2 text-ink/40 italic">No workspaces available</div>
183
- ) : (
184
- folders.map((f: any) => (
185
- <button
186
- key={f.id}
187
- type="button"
188
- onClick={() => { setSelectedFolderId(f.id); setShowWorkspace(false); }}
189
- className={`w-full text-left px-3 py-1.5 flex items-center justify-between transition-colors cursor-pointer ${
190
- selectedFolderId === f.id
191
- ? 'bg-ink/10 text-ink font-semibold'
192
- : 'text-ink/80 hover:bg-ink/5 hover:text-ink'
193
- }`}
194
- >
195
- <div className="flex items-center space-x-2 truncate pr-2">
196
- <Folder size={12} className={selectedFolderId === f.id ? "text-ink flex-shrink-0" : "text-ink/50 flex-shrink-0"} />
197
- <span className="truncate font-medium">{f.name}</span>
198
- </div>
199
- {selectedFolderId === f.id && <Check size={12} className="text-ink flex-shrink-0" />}
200
- </button>
201
- ))
202
- )}
203
- </div>
204
- </div>
168
+ {/* Two controls, because they answer two questions: the workspace
169
+ (which project) and the worktree (which checkout of it). The
170
+ worktree control draws itself only for a git repository, and it
171
+ reads its list on mount because its own trigger names the branch
172
+ the project root holds. */}
173
+ <WorkspacePicker
174
+ folders={folders as WorkspaceFolderData[]}
175
+ selectedFolderId={selectedFolderId}
176
+ setSelectedFolderId={setSelectedFolderId}
177
+ onClearWorktree={() => onSpawnTargetChange?.(null)}
178
+ />
179
+ {onSpawnTargetChange && (
180
+ <WorktreePicker
181
+ folders={folders as WorkspaceFolderData[]}
182
+ selectedFolderId={selectedFolderId}
183
+ target={spawnTarget}
184
+ setTarget={onSpawnTargetChange}
185
+ onReport={onWorktreeReport}
186
+ />
205
187
  )}
206
- </div>
207
188
  {/* The wait picker sits BESIDE the workspace picker, because both
208
189
  answer the same question about the chat that has not started:
209
190
  where it runs, and what it waits for. It only appears while there
@@ -20,6 +20,7 @@ import type { ComposerModes } from '@/client/components/workspace/chat-timeline/
20
20
  import type { WaitingCandidate } from '@/client/components/workspace/chat-timeline/chat-input/WaitingDependencyPicker';
21
21
  import type { DeferredModelStore } from '@/client/hooks/chat/timeline/deferred-model';
22
22
  import { EmptyWorkspacePrompt } from '@/client/components/workspace/chat-timeline/EmptyWorkspacePrompt';
23
+ import type { SpawnTarget } from '@/client/hooks/chat/timeline/spawn-target';
23
24
 
24
25
  export interface PendingChatViewProps {
25
26
  className: string;
@@ -59,6 +60,10 @@ export interface PendingChatViewProps {
59
60
  onWaitingForChange: (candidate: WaitingCandidate | null) => void;
60
61
  heldWait: { title: string | null } | null;
61
62
  onCancelWait: () => void;
63
+ /** Where the chat will run: the folder, or one of its worktrees. */
64
+ spawnTarget?: SpawnTarget | null;
65
+ onSpawnTargetChange?: (next: SpawnTarget | null) => void;
66
+ onWorktreeReport?: (message: string) => void;
62
67
  }
63
68
 
64
69
  export function PendingChatView(props: PendingChatViewProps) {
@@ -101,6 +106,9 @@ export function PendingChatView(props: PendingChatViewProps) {
101
106
  onWaitingForChange={props.onWaitingForChange}
102
107
  heldWait={props.heldWait}
103
108
  onCancelWait={props.onCancelWait}
109
+ spawnTarget={props.spawnTarget}
110
+ onSpawnTargetChange={props.onSpawnTargetChange}
111
+ onWorktreeReport={props.onWorktreeReport}
104
112
  />
105
113
  );
106
114
  }
@@ -1,8 +1,7 @@
1
1
  import { useMemo, useRef, useState } from 'preact/hooks';
2
2
  import { AlertCircle, Bell, Bot, CheckCircle2, ChevronDown, Clock, Info, Layers, Target } from 'lucide-preact';
3
3
  import { isCodeLike } from '@/shared/lib/code/language';
4
- import { highlightCode } from '@/shared/lib/code/syntax-highlight';
5
- import { useSyntaxReady } from '@/client/hooks/ui/syntax-ready';
4
+ import { useDeferredHighlight } from '@/client/hooks/code/deferred-highlight';
6
5
  import { useIsTruncated } from '@/client/hooks/ui/text-overflow';
7
6
  import { parseTaskNotice } from '@/shared/lib/chat/task-result-parser';
8
7
  import { isReminderTag, stripNoticeTags, unwrapXmlEnvelope } from '@/shared/lib/chat/xml-envelope';
@@ -53,7 +52,6 @@ interface SystemNoticeProps {
53
52
  * injection renders open and has no collapse control (see `forcedOpen`). */
54
53
  export function SystemNotice({ notice, source, isMobile = false }: SystemNoticeProps) {
55
54
  const [userOpen, setUserOpen] = useState(false);
56
- useSyntaxReady();
57
55
 
58
56
  // 1. Check if notice contains a structured <task-result> block
59
57
  const taskNotice = useMemo(() => parseTaskNotice(notice), [notice]);
@@ -115,6 +113,13 @@ export function SystemNotice({ notice, source, isMobile = false }: SystemNoticeP
115
113
  };
116
114
  }, [notice]);
117
115
 
116
+ // Deferred and gated: the code branch only renders while the card is open, so
117
+ // a collapsed notice computes nothing and an opened one paints its text first.
118
+ const codeHtml = useDeferredHighlight(
119
+ genericInfo.isCode ? genericInfo.rest || genericInfo.clean : '',
120
+ 'javascript',
121
+ );
122
+
118
123
  const rawTitle = goalNotice
119
124
  ? goalNoticeTitle(goalNotice)
120
125
  : taskNotice?.intro || reminderInfo?.title || genericInfo.firstLine || notice;
@@ -284,7 +289,7 @@ export function SystemNotice({ notice, source, isMobile = false }: SystemNoticeP
284
289
  <pre
285
290
  className="max-h-56 overflow-auto font-mono text-[11px] leading-relaxed whitespace-pre-wrap break-words text-ink/75 scrollbar-overlay-container"
286
291
  dangerouslySetInnerHTML={{
287
- __html: highlightCode(genericInfo.rest || genericInfo.clean, 'javascript'),
292
+ __html: codeHtml,
288
293
  }}
289
294
  />
290
295
  ) : (
@@ -4,15 +4,13 @@ import type { ParsedTaskNotice } from '@/shared/lib/chat/task-result-parser';
4
4
  import { MarkdownRenderer } from '@/client/components/common/MarkdownRenderer';
5
5
  import { CopyButton } from '@/client/components/common/CopyButton';
6
6
  import { isCodeLike } from '@/shared/lib/code/language';
7
- import { highlightCode } from '@/shared/lib/code/syntax-highlight';
8
- import { useSyntaxReady } from '@/client/hooks/ui/syntax-ready';
7
+ import { useDeferredHighlight } from '@/client/hooks/code/deferred-highlight';
9
8
 
10
9
  interface TaskResultContentProps {
11
10
  task: ParsedTaskNotice;
12
11
  }
13
12
 
14
13
  export function TaskResultContent({ task }: TaskResultContentProps) {
15
- useSyntaxReady();
16
14
  const structured = task.structuredOutput;
17
15
  const isRawCode = !task.formattedJson && isCodeLike(task.rawOutput);
18
16
 
@@ -20,6 +18,13 @@ export function TaskResultContent({ task }: TaskResultContentProps) {
20
18
  structured || !isRawCode ? 'formatted' : 'raw'
21
19
  );
22
20
 
21
+ // Gated on the tab that draws it: the raw view is one of two, and tokenizing
22
+ // it while the formatted view is on screen is work nobody sees.
23
+ const rawHtml = useDeferredHighlight(
24
+ activeTab === 'raw' ? task.formattedJson || task.rawOutput : '',
25
+ task.formattedJson ? 'json' : isRawCode ? 'javascript' : 'markdown',
26
+ );
27
+
23
28
  return (
24
29
  <div className="space-y-3">
25
30
  {/* Top action toolbar / tab toggles */}
@@ -151,10 +156,7 @@ export function TaskResultContent({ task }: TaskResultContentProps) {
151
156
  <pre
152
157
  className="max-h-64 overflow-auto rounded-lg border border-ink/8 bg-paper p-3 font-mono text-[11px] leading-relaxed whitespace-pre-wrap break-words text-ink/80 scrollbar-overlay-container"
153
158
  dangerouslySetInnerHTML={{
154
- __html: highlightCode(
155
- task.formattedJson || task.rawOutput,
156
- task.formattedJson ? 'json' : isRawCode ? 'javascript' : 'markdown'
157
- ),
159
+ __html: rawHtml,
158
160
  }}
159
161
  />
160
162
  )}
@@ -88,6 +88,8 @@ export function ChatTimeline({ className = '', appSettings = {}, onSessionTitle,
88
88
  extensionDialogs,
89
89
  resolveExtensionDialog,
90
90
  respondToExtensionUi,
91
+ spawnTarget,
92
+ setSpawnTarget,
91
93
  } = useChatTimeline({ folders, streamStatusMap, appSettings, reportActionError: pushToast });
92
94
 
93
95
  // Ask dialogs render on their own tool card; anything else omp is blocked on
@@ -215,6 +217,9 @@ export function ChatTimeline({ className = '', appSettings = {}, onSessionTitle,
215
217
  onWaitingForChange={waitingDependency.set}
216
218
  heldWait={heldWait ? { title: heldWait.dependsOnTitle } : null}
217
219
  onCancelWait={cancelHeldWait}
220
+ spawnTarget={spawnTarget}
221
+ onSpawnTargetChange={setSpawnTarget}
222
+ onWorktreeReport={(message) => pushToast(message, 'success', { duration: 6000 })}
218
223
  />
219
224
  );
220
225
  }
@@ -0,0 +1,132 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The composer's WORKSPACE picker, rendered.
8
+ *
9
+ * The cases a reader would otherwise find only in a browser:
10
+ *
11
+ * - the trigger names the selected folder, and says what it is waiting for
12
+ * when nothing is selected;
13
+ * - the menu lists folders and NOTHING about worktrees — the split is the
14
+ * point of this control, and a worktree row here is the regression;
15
+ * - choosing another folder clears the worktree pick, because a checkout of
16
+ * the previous project names a path the new one cannot resolve.
17
+ */
18
+
19
+ import { afterAll, afterEach, beforeAll, describe, expect, test } from 'bun:test';
20
+ import { Window } from 'happy-dom';
21
+ import { h, render } from 'preact';
22
+ import { act } from 'preact/test-utils';
23
+ import { installDomGlobals, restoreDomGlobals } from '@/test-support/pristine-globals';
24
+ import { WorkspacePicker, workspaceLabel } from '@/client/components/workspace/chat-timeline/target-picker/WorkspacePicker';
25
+ import type { WorkspaceFolderData } from '@/shared/types';
26
+
27
+ const folder = (id: number, name: string): WorkspaceFolderData => ({
28
+ id,
29
+ name,
30
+ project_path: `/repo/${name}`,
31
+ isExpanded: true,
32
+ hasMore: false,
33
+ totalSessions: 0,
34
+ sessions: [],
35
+ });
36
+
37
+ const mounted: HTMLElement[] = [];
38
+
39
+ beforeAll(() => {
40
+ installDomGlobals(new Window({ url: 'http://localhost' }));
41
+ });
42
+
43
+ afterEach(() => {
44
+ for (const host of mounted.splice(0)) {
45
+ render(null, host);
46
+ host.remove();
47
+ }
48
+ });
49
+
50
+ afterAll(() => {
51
+ restoreDomGlobals();
52
+ });
53
+
54
+ async function mount(node: h.JSX.Element): Promise<HTMLElement> {
55
+ const host = document.createElement('div');
56
+ document.body.appendChild(host);
57
+ await act(async () => {
58
+ render(node, host);
59
+ });
60
+ mounted.push(host);
61
+ return host;
62
+ }
63
+
64
+ function textOf(host: HTMLElement): string {
65
+ return (host.textContent ?? '').replace(/\s+/g, ' ').trim();
66
+ }
67
+
68
+ describe('workspaceLabel', () => {
69
+ test('the selected folder', () => {
70
+ expect(workspaceLabel([folder(1, 'kabmo')], 1)).toBe('kabmo');
71
+ });
72
+
73
+ test('what it is waiting for, with nothing selected', () => {
74
+ expect(workspaceLabel([], null)).toBe('Select Workspace Context');
75
+ });
76
+ });
77
+
78
+ describe('WorkspacePicker', () => {
79
+ test('the menu lists the workspaces and nothing about worktrees', async () => {
80
+ const host = await mount(
81
+ h(WorkspacePicker, {
82
+ folders: [folder(1, 'kabmo'), folder(2, 'notes')],
83
+ selectedFolderId: 1,
84
+ setSelectedFolderId: () => {},
85
+ }),
86
+ );
87
+
88
+ await act(async () => {
89
+ (host.querySelector('button') as HTMLButtonElement).click();
90
+ });
91
+
92
+ const text = textOf(host);
93
+ expect(text).toContain('Target Workspace');
94
+ expect(text).toContain('kabmo');
95
+ expect(text).toContain('notes');
96
+ // The split: a worktree row here would be the merged menu coming back.
97
+ expect(text).not.toContain('Quick worktree');
98
+ expect(text).not.toContain('New worktree');
99
+ expect(text).not.toContain('Worktrees');
100
+ });
101
+
102
+ test('choosing a folder clears the worktree pick', async () => {
103
+ // A record rather than two locals: a value assigned inside a callback is
104
+ // still narrowed to its initializer by control-flow analysis, so `picked`
105
+ // would be `null` at the assertion.
106
+ const seen: { picked: number | null; cleared: number } = { picked: null, cleared: 0 };
107
+ const host = await mount(
108
+ h(WorkspacePicker, {
109
+ folders: [folder(1, 'kabmo'), folder(2, 'notes')],
110
+ selectedFolderId: 1,
111
+ setSelectedFolderId: (id) => {
112
+ seen.picked = id;
113
+ },
114
+ onClearWorktree: () => {
115
+ seen.cleared += 1;
116
+ },
117
+ }),
118
+ );
119
+
120
+ await act(async () => {
121
+ (host.querySelector('button') as HTMLButtonElement).click();
122
+ });
123
+ const rows = [...host.querySelectorAll('[role="menuitemradio"]')] as HTMLButtonElement[];
124
+ expect(rows.length).toBe(2);
125
+ await act(async () => {
126
+ rows[1]?.click();
127
+ });
128
+
129
+ expect(seen.picked).toBe(2);
130
+ expect(seen.cleared).toBe(1);
131
+ });
132
+ });
@@ -0,0 +1,120 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * WHERE the chat runs: the workspace folder.
8
+ *
9
+ * One control of the composer's draft-target pair, and the pair exists because
10
+ * the two questions are different: the folder is the project, and the worktree
11
+ * is the checkout inside it (`WorktreePicker`). They were merged into one menu
12
+ * once, and the result read as a workspace picker with worktree rows buried in
13
+ * it — a user looking for another project scrolled past branch names, and a
14
+ * user looking for a worktree could not tell the control was the place to look.
15
+ * OpenChamber draws them as two controls for the same reason.
16
+ *
17
+ * Picking a folder CLEARS the worktree pick: a checkout of the previous project
18
+ * names a path that does not exist under the new one.
19
+ */
20
+
21
+ import { useRef, useState } from 'preact/hooks';
22
+ import { Check, ChevronDown, Folder } from 'lucide-preact';
23
+ import { useOnClickOutside } from '@/client/hooks/ui/on-click-outside';
24
+ import type { WorkspaceFolderData } from '@/shared/types';
25
+
26
+ export interface WorkspacePickerProps {
27
+ folders: WorkspaceFolderData[];
28
+ selectedFolderId: number | null;
29
+ setSelectedFolderId: (id: number | null) => void;
30
+ /** Called with `null` when the user moves to another project. */
31
+ onClearWorktree?: () => void;
32
+ }
33
+
34
+ /** The trigger's words, exported so a test asserts them without the markup. */
35
+ export function workspaceLabel(folders: WorkspaceFolderData[], selectedFolderId: number | null): string {
36
+ const folder = folders.find((candidate) => String(candidate.id) === String(selectedFolderId));
37
+ return folder?.name ?? 'Select Workspace Context';
38
+ }
39
+
40
+ export function WorkspacePicker({
41
+ folders,
42
+ selectedFolderId,
43
+ setSelectedFolderId,
44
+ onClearWorktree,
45
+ }: WorkspacePickerProps) {
46
+ const [open, setOpen] = useState(false);
47
+ const containerRef = useRef<HTMLDivElement>(null);
48
+ useOnClickOutside(containerRef, () => setOpen(false));
49
+
50
+ const pick = (folder: WorkspaceFolderData) => {
51
+ setSelectedFolderId(folder.id);
52
+ // A worktree belongs to the project it was made from; keeping the pick
53
+ // across a project change would spawn the chat in a path this project
54
+ // cannot resolve.
55
+ onClearWorktree?.();
56
+ setOpen(false);
57
+ };
58
+
59
+ const label = workspaceLabel(folders, selectedFolderId);
60
+
61
+ return (
62
+ <div className="relative shrink-0" ref={containerRef}>
63
+ <button
64
+ type="button"
65
+ // Two triggers sit side by side, so each needs a name that says which
66
+ // question it answers: "kabmo" and "main" alone are both ambiguous.
67
+ aria-label={`Workspace: ${label}`}
68
+ aria-haspopup="menu"
69
+ aria-expanded={open}
70
+ onClick={() => setOpen((value) => !value)}
71
+ className="group flex items-center space-x-1.5 px-1.5 py-1 rounded-md text-xs font-medium text-ink/75 hover:text-ink hover:bg-ink/5 focus-visible:bg-ink/5 transition-all outline-none cursor-pointer select-none"
72
+ >
73
+ <Folder size={13} className="text-ink/50 group-hover:text-ink/80 transition-colors flex-shrink-0" />
74
+ <span className="max-w-[260px] truncate tracking-tight">{label}</span>
75
+ <ChevronDown
76
+ size={13}
77
+ className={`text-ink/40 group-hover:text-ink/70 flex-shrink-0 transition-transform duration-150 ${open ? 'rotate-180 text-ink/70' : ''}`}
78
+ />
79
+ </button>
80
+
81
+ {open && (
82
+ <div
83
+ role="menu"
84
+ className="absolute bottom-full left-0 mb-1.5 w-64 bg-paper border border-ink/10 rounded-lg shadow-lg z-50 flex flex-col overflow-hidden text-xs py-1"
85
+ >
86
+ <div className="px-3 py-1.5 text-[10px] uppercase tracking-wider text-ink/40 font-semibold border-b border-ink/5">
87
+ Target Workspace
88
+ </div>
89
+ <div className="max-h-56 scrollbar-overlay-container py-1">
90
+ {folders.length === 0 ? (
91
+ <div className="px-3 py-2 text-ink/40 italic">No workspaces available</div>
92
+ ) : (
93
+ folders.map((folder) => {
94
+ const active = String(selectedFolderId) === String(folder.id);
95
+ return (
96
+ <button
97
+ key={folder.id}
98
+ type="button"
99
+ role="menuitemradio"
100
+ aria-checked={active}
101
+ onClick={() => pick(folder)}
102
+ className={`w-full text-left px-3 py-1.5 flex items-center justify-between transition-colors cursor-pointer ${
103
+ active ? 'bg-ink/10 text-ink font-semibold' : 'text-ink/80 hover:bg-ink/5 hover:text-ink'
104
+ }`}
105
+ >
106
+ <span className="flex items-center space-x-2 truncate pr-2">
107
+ <Folder size={12} className={active ? 'text-ink flex-shrink-0' : 'text-ink/50 flex-shrink-0'} />
108
+ <span className="truncate font-medium">{folder.name}</span>
109
+ </span>
110
+ {active && <Check size={12} className="text-ink flex-shrink-0" />}
111
+ </button>
112
+ );
113
+ })
114
+ )}
115
+ </div>
116
+ </div>
117
+ )}
118
+ </div>
119
+ );
120
+ }