@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
@@ -4,7 +4,7 @@
4
4
  */
5
5
 
6
6
  import { describe, expect, test } from 'bun:test';
7
- import { isVirtualPath, stripReadSelector } from '@/shared/lib/fs/paths';
7
+ import { isVirtualPath, lastPathSegment, stripReadSelector } from '@/shared/lib/fs/paths';
8
8
 
9
9
  describe('stripReadSelector', () => {
10
10
  test('peels every selector shape omp accepts', () => {
@@ -88,3 +88,26 @@ describe('isVirtualPath', () => {
88
88
  expect(isVirtualPath('')).toBe(false);
89
89
  });
90
90
  });
91
+
92
+ describe('lastPathSegment', () => {
93
+ test('names the last segment on either separator', () => {
94
+ // The Windows case is the one that shipped broken: the composer's target
95
+ // trigger read `kabmo · C:\Users\…\cosmic-dolphin`, because `split('/')`
96
+ // has nothing to cut in a `\`-separated absolute path.
97
+ expect(lastPathSegment('/data/worktrees/abc/cosmic-dolphin')).toBe('cosmic-dolphin');
98
+ expect(lastPathSegment('C:\\Users\\u\\AppData\\Local\\worktrees\\abc\\cosmic-dolphin')).toBe('cosmic-dolphin');
99
+ expect(lastPathSegment('wt/nested/branch')).toBe('branch');
100
+ });
101
+
102
+ test('a trailing separator names the same thing', () => {
103
+ expect(lastPathSegment('/data/worktrees/abc/cosmic-dolphin/')).toBe('cosmic-dolphin');
104
+ expect(lastPathSegment('C:\\worktrees\\abc\\cosmic-dolphin\\')).toBe('cosmic-dolphin');
105
+ });
106
+
107
+ test('nothing to name is empty, not a stray separator', () => {
108
+ expect(lastPathSegment(undefined)).toBe('');
109
+ expect(lastPathSegment(null)).toBe('');
110
+ expect(lastPathSegment('')).toBe('');
111
+ expect(lastPathSegment('/')).toBe('');
112
+ });
113
+ });
@@ -107,3 +107,20 @@ export function toAbsolutePath(baseDir: string | null | undefined, relPath: stri
107
107
  // Join with the base's own separator: `C:\proj` + `src` → `C:\proj\src`.
108
108
  return `${base}${base.includes('\\') && !base.includes('/') ? '\\' : '/'}${rel}`;
109
109
  }
110
+
111
+ /**
112
+ * The last segment of a path on ANY platform.
113
+ *
114
+ * `split('/').pop()` is the shape this replaces, and it is wrong for every
115
+ * ABSOLUTE path on Windows: a worktree the server created under
116
+ * `C:\…\worktrees\<id>\cosmic-dolphin` came back whole, so the composer's target
117
+ * trigger read `kabmo · C:\Users\…\cosmic-dolphin` instead of
118
+ * `kabmo · cosmic-dolphin` (measured on the Windows E2E leg, which is the only
119
+ * place it shows — every POSIX path splits the same either way). A trailing
120
+ * separator is dropped first so `…/cosmic-dolphin/` names the same worktree.
121
+ */
122
+ export function lastPathSegment(target: string | null | undefined): string {
123
+ if (!target) return '';
124
+ const parts = target.replace(/[/\\]+$/, '').split(/[/\\]/);
125
+ return parts[parts.length - 1] ?? '';
126
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The two worktree setting keys, in a module with NO server imports.
8
+ *
9
+ * The server reads them from `kabmo_settings` (`lib/fs/worktree/settings.server.ts`)
10
+ * and the settings panel writes them through `writeSetting`; both sides need the
11
+ * same strings, and the client bundle must not pull in `@/db.server` to get them.
12
+ */
13
+
14
+ /** Parent directory new worktrees are created in; empty means the default. */
15
+ export const WORKTREE_DIRECTORY_SETTING = 'worktreeDirectory';
16
+
17
+ /** Whether a create copies the project's ignored build folders. Default: on. */
18
+ export const WORKTREE_CARRY_SETTING = 'worktreeCarryIgnored';
@@ -32,7 +32,14 @@ export function spawnCwdForNewSession(
32
32
  folders: PrewarmFolderShape[],
33
33
  activeSessionId: string | number | null,
34
34
  folderId?: number | string,
35
+ /**
36
+ * A worktree the draft chose. It wins over the folder's project path, because
37
+ * that is the whole point of picking one: the chat is born in the worktree and
38
+ * nothing is ever relocated afterwards.
39
+ */
40
+ worktreePath?: string | null,
35
41
  ): string | null {
42
+ if (worktreePath) return worktreePath;
36
43
  const target = folderId !== undefined
37
44
  ? folders.find((f) => String(f.id) === String(folderId))
38
45
  : folders.find((f) => f.sessions?.some((s) => String(s.id) === String(activeSessionId)))
@@ -45,6 +45,22 @@ export const TOPIC_SIDEBAR = 'sidebar';
45
45
  * the structure needs a JSONL scan to produce. */
46
46
  export const TOPIC_SIDEBAR_STATUS = 'sidebar:status';
47
47
 
48
+ /**
49
+ * The last-activity stamp of each session with a run IN FLIGHT, keyed by id.
50
+ *
51
+ * Split from `sidebar` for the same reason `sidebar:status` is, but the cost
52
+ * difference is far larger: a transcript grows on every tool call, so this
53
+ * changes per tool call while the structure is a 216 KB payload over 731
54
+ * sessions (measured on the author's install) that must be re-scanned to
55
+ * produce. Only a RUNNING session's stamp can move between turn boundaries, so
56
+ * only those are carried — the payload is one ISO string per live run
57
+ * (measured: 87 bytes for one), against 216 KB for the whole list.
58
+ *
59
+ * The structure remains the authority for every other row; this only ever
60
+ * ADVANCES a stamp the structure already carries (see `applySidebarActivity`).
61
+ */
62
+ export const TOPIC_SIDEBAR_ACTIVITY = 'sidebar:activity';
63
+
48
64
  /** One session's live conversation: agent frames, queue and modes. */
49
65
  export function sessionTopic(sessionId: string): string {
50
66
  return `session:${sessionId}`;
@@ -0,0 +1,83 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The `⋯` rule, asserted as arithmetic.
8
+ *
9
+ * Each case is a failure the toolbars had: a row that wrapped into two rows
10
+ * instead of hiding a control, a control hidden while the width was still
11
+ * unknown (a menu flashing on every mount), and a pinned control buried in the
12
+ * menu because the row happened to be narrow.
13
+ */
14
+
15
+ import { describe, expect, test } from 'bun:test';
16
+
17
+ import {
18
+ TOOLBAR_ITEM_GAP_PX,
19
+ TOOLBAR_OVERFLOW_TRIGGER_PX,
20
+ toolbarHasOverflow,
21
+ toolbarVisibleClusters,
22
+ } from '@/shared/lib/ui/toolbar-overflow';
23
+
24
+ /** Save, find, wrap, zoom, copy, modes, full screen — the editor's real row. */
25
+ const widths = [26, 26, 26, 52, 52, 52, 26];
26
+ const pinned = [true, false, false, false, false, true, true];
27
+
28
+ /** Row width needed to draw `visible` plus the trigger when anything is hidden. */
29
+ function rowWidth(visible: boolean[]): number {
30
+ const shown = widths.filter((_, index) => visible[index]);
31
+ const overflow = toolbarHasOverflow(visible);
32
+ return (
33
+ shown.reduce((sum, width) => sum + width, 0) +
34
+ TOOLBAR_ITEM_GAP_PX * Math.max(0, shown.length - 1) +
35
+ (overflow ? TOOLBAR_OVERFLOW_TRIGGER_PX + TOOLBAR_ITEM_GAP_PX : 0)
36
+ );
37
+ }
38
+
39
+ describe('toolbarVisibleClusters', () => {
40
+ test('an unmeasured row shows every cluster', () => {
41
+ // Not "hide everything": the row is measured a layout effect later, and a
42
+ // menu that flashes on every mount is worse than one frame of a full row.
43
+ expect(toolbarVisibleClusters({ widths, pinned, available: 0 }).every(Boolean)).toBe(true);
44
+ });
45
+
46
+ test('a row wide enough for everything shows everything, with no trigger', () => {
47
+ const total = widths.reduce((sum, width) => sum + width, 0) + TOOLBAR_ITEM_GAP_PX * (widths.length - 1);
48
+ const visible = toolbarVisibleClusters({ widths, pinned, available: total });
49
+ expect(visible.every(Boolean)).toBe(true);
50
+ expect(toolbarHasOverflow(visible)).toBe(false);
51
+ });
52
+
53
+ test('a narrow row hides what does not fit and reserves the trigger', () => {
54
+ // 200px of row: the three pinned clusters (26 + 52 + 26) plus their gaps
55
+ // leave room for one unpinned control and the `⋯` trigger, and no more.
56
+ const visible = toolbarVisibleClusters({ widths, pinned, available: 200 });
57
+ expect(toolbarHasOverflow(visible)).toBe(true);
58
+ // The promise the arithmetic has to keep: what stays plus the trigger fits.
59
+ expect(rowWidth(visible)).toBeLessThanOrEqual(200);
60
+ });
61
+
62
+ test('a pinned cluster stays on the row at any width', () => {
63
+ // A 120px row fits one 26px control plus the trigger, and the pinned width
64
+ // modes (52px) would not fit at all on their own — they stay regardless,
65
+ // because a menu-only mode button is a mode the reader never finds.
66
+ const visible = toolbarVisibleClusters({ widths, pinned, available: 120 });
67
+ expect(visible[0]).toBe(true);
68
+ expect(visible[5]).toBe(true);
69
+ expect(visible[6]).toBe(true);
70
+ // The unpinned controls are what gives way.
71
+ expect(visible[1]).toBe(false);
72
+ });
73
+
74
+ test('order is preserved: what stays is a subsequence, not a re-sort', () => {
75
+ const visible = toolbarVisibleClusters({ widths, pinned, available: 200 });
76
+ const shownIndexes = visible.flatMap((isShown, index) => (isShown ? [index] : []));
77
+ expect([...shownIndexes].sort((a, b) => a - b)).toEqual(shownIndexes);
78
+ });
79
+
80
+ test('no clusters is no row', () => {
81
+ expect(toolbarVisibleClusters({ widths: [], pinned: [], available: 500 })).toEqual([]);
82
+ });
83
+ });
@@ -0,0 +1,86 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Which toolbar clusters fit on ONE row, and which go into the `⋯` menu.
8
+ *
9
+ * The editor and diff toolbars carry ten-plus controls and the editor panel is
10
+ * 320px wide on a 1440px screen. WRAPPING was the previous answer and it is the
11
+ * wrong one: a second row moves the document down by the toolbar's own height
12
+ * and reads as a second toolbar. A horizontal scroll is worse still — a clipped
13
+ * control is invisible and reads as "there is nothing more here".
14
+ *
15
+ * So: the clusters that do not fit move into a `⋯` menu, and a cluster can be
16
+ * PINNED to stay on the row at every width. Pinning is what keeps this from
17
+ * being a priority guess — the toolbar's visual order is not its priority order
18
+ * (Save is drawn first because that is where a reader looks for it, while the
19
+ * width modes sit last and are the reason the panel exists).
20
+ *
21
+ * Pure, and measured widths in: the caller lays the clusters out once and hands
22
+ * their rendered widths here. A table of guessed pixel widths drifts the moment
23
+ * a label, an icon or a font changes.
24
+ */
25
+
26
+ /** Width the `⋯` trigger needs, including the gap before it. */
27
+ export const TOOLBAR_OVERFLOW_TRIGGER_PX = 30;
28
+
29
+ /** Gap between two clusters (`gap-x-1`). */
30
+ export const TOOLBAR_ITEM_GAP_PX = 4;
31
+
32
+ export interface ToolbarOverflowArgs {
33
+ /** Rendered width of each cluster, in row order. */
34
+ widths: number[];
35
+ /** Which clusters never leave the row. Same length as `widths`. */
36
+ pinned: boolean[];
37
+ /** Width the row actually has, or 0 before it is measured. */
38
+ available: number;
39
+ }
40
+
41
+ /**
42
+ * Which clusters to render on the row, in row order.
43
+ *
44
+ * An unmeasured row (available 0) keeps everything: hiding controls before the
45
+ * width is known flashes a menu on every mount, and the row is measured a
46
+ * layout effect later.
47
+ */
48
+ export function toolbarVisibleClusters({ widths, pinned, available }: ToolbarOverflowArgs): boolean[] {
49
+ if (!(available > 0) || widths.length === 0) return widths.map(() => true);
50
+
51
+ const total = widths.reduce((sum, width) => sum + width, 0) + TOOLBAR_ITEM_GAP_PX * (widths.length - 1);
52
+ if (total <= available) return widths.map(() => true);
53
+
54
+ // Pinned clusters are placed FIRST and in full, because whether the row is
55
+ // wide enough for them is not a question — they stay and the row clips if it
56
+ // must. The unpinned ones then take whatever is left, in row order, once the
57
+ // `⋯` trigger's own width is set aside. Filling in one pass over row order
58
+ // instead would let an early unpinned control consume the space a pinned one
59
+ // later needs.
60
+ const shown = widths.map(() => false);
61
+ let used = 0;
62
+ let rendered = 0;
63
+ widths.forEach((width, index) => {
64
+ if (!pinned[index]) return;
65
+ used += width + (rendered > 0 ? TOOLBAR_ITEM_GAP_PX : 0);
66
+ rendered += 1;
67
+ shown[index] = true;
68
+ });
69
+
70
+ let budget = available - used - TOOLBAR_OVERFLOW_TRIGGER_PX - TOOLBAR_ITEM_GAP_PX;
71
+ widths.forEach((width, index) => {
72
+ if (pinned[index]) return;
73
+ const cost = width + (rendered > 0 ? TOOLBAR_ITEM_GAP_PX : 0);
74
+ if (cost > budget) return;
75
+ budget -= cost;
76
+ rendered += 1;
77
+ shown[index] = true;
78
+ });
79
+
80
+ return shown;
81
+ }
82
+
83
+ /** True when at least one cluster is going into the menu. */
84
+ export function toolbarHasOverflow(visible: boolean[]): boolean {
85
+ return visible.some((shown) => !shown);
86
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The architecture a DESKTOP shell runs as, and the artifact it installs itself
8
+ * from.
9
+ *
10
+ * ## Why this exists at all
11
+ *
12
+ * Every desktop artifact is per-architecture (`Kabmo-0.2.0-mac-arm64.zip`,
13
+ * `Kabmo-0.2.0-win-x64-setup.exe`), and the console used to answer a desktop
14
+ * probe with the RELEASE PAGE precisely because it could not know which
15
+ * architecture the window asking was running on. That is correct for a link and
16
+ * useless for a self-update: handing an arm64 reader an x64 installer produces
17
+ * an app that does not launch, and on Windows an installer that refuses with
18
+ * "this installation package could not be opened".
19
+ *
20
+ * So a desktop shell reports its own architecture over the bridge, and the
21
+ * server names the ONE artifact. This module is the vocabulary both ends share,
22
+ * which is what stops the shell, the route and the asset table from spelling
23
+ * `x86_64`, `x64` and `amd64` for the same machine.
24
+ *
25
+ * ## Two spellings, one canonical token
26
+ *
27
+ * A shell reports the CANONICAL token (`arm64` / `x64`) because that is what the
28
+ * client and the route pass around. Each platform's release assets spell its
29
+ * architecture differently — Linux says `x86_64`, macOS and Windows say `x64` —
30
+ * and that mapping lives in ONE table (`SHELL_ASSETS`), on the server, next to
31
+ * the names it has to match. A shell that mapped its own spelling to the asset's
32
+ * would be a second copy of the same fact.
33
+ */
34
+
35
+ /** A desktop architecture, in the vocabulary the console and the shells share. */
36
+ export type ShellArch = 'arm64' | 'x64';
37
+
38
+ /**
39
+ * Read a canonical arch out of a string, or null.
40
+ *
41
+ * Lenient about the spellings a shell might report (a shell that read
42
+ * `std::env::consts::ARCH` says `x86_64`; one that read
43
+ * `RuntimeInformation.ProcessArchitecture` says `X64`), because a refused arch
44
+ * costs the reader the self-update and buys nothing: the value only chooses
45
+ * between two artifacts of the same product, and the artifact is then verified
46
+ * by the shell before it is installed.
47
+ */
48
+ export function parseShellArch(value: string | null | undefined): ShellArch | null {
49
+ if (!value) return null;
50
+ const folded = value.trim().toLowerCase();
51
+ if (folded === 'arm64' || folded === 'aarch64') return 'arm64';
52
+ if (folded === 'x64' || folded === 'x86_64' || folded === 'amd64') return 'x64';
53
+ return null;
54
+ }
@@ -19,14 +19,34 @@
19
19
  */
20
20
 
21
21
  /** The native shells that report themselves on the UA. */
22
- export type ShellKind = 'android' | 'ios';
22
+ export type MobileShellKind = 'android' | 'ios';
23
23
 
24
- /** The token's name, before the slash, per shell. */
25
- export const SHELL_UA_PREFIXES: Record<ShellKind, string> = {
24
+ /**
25
+ * The desktop shells. They carry NO User-Agent token — a desktop app is not
26
+ * downloaded from a page, so the server has nothing to parse per request. They
27
+ * report their version and their ARCHITECTURE over the shell bridge instead
28
+ * (`__kabmoDesktop`), which is why they are a separate family: both facts exist,
29
+ * the UA channel does not.
30
+ */
31
+ export type DesktopShellKind = 'macos' | 'windows' | 'linux';
32
+
33
+ /** Every shell Kabmo publishes an artifact for. */
34
+ export type ShellKind = MobileShellKind | DesktopShellKind;
35
+
36
+ /** The token's name, before the slash, per MOBILE shell. */
37
+ export const SHELL_UA_PREFIXES: Record<MobileShellKind, string> = {
26
38
  android: 'KabmoAndroid/',
27
39
  ios: 'KabmoIOS/',
28
40
  };
29
41
 
42
+ /** The desktop family, in the order their release tracks were added. */
43
+ export const DESKTOP_SHELL_KINDS: readonly DesktopShellKind[] = ['macos', 'windows', 'linux'];
44
+
45
+ /** Whether a shell reports itself over the bridge rather than the User-Agent. */
46
+ export function isDesktopShellKind(kind: string): kind is DesktopShellKind {
47
+ return (DESKTOP_SHELL_KINDS as readonly string[]).includes(kind);
48
+ }
49
+
30
50
  /**
31
51
  * Whether this request came from an Android device at all — the app or a
32
52
  * browser on it.
@@ -60,7 +80,7 @@ export function isAndroidDevice(userAgent: string | null | undefined): boolean {
60
80
  */
61
81
  export function shellVersion(
62
82
  userAgent: string | null | undefined,
63
- kind: ShellKind,
83
+ kind: MobileShellKind,
64
84
  ): string | null {
65
85
  if (!userAgent) return null;
66
86
 
@@ -23,6 +23,29 @@ export function activeProjectForSession(
23
23
  return { folder: null, projectPath: null };
24
24
  }
25
25
 
26
+ /**
27
+ * The root the right-panel tools should operate on for this session: its own
28
+ * worktree when the session's cwd is a linked checkout, else the folder's
29
+ * registered project path.
30
+ *
31
+ * A worktree session runs OUTSIDE its folder's project directory, so pointing
32
+ * the panels at `project_path` showed the checkout the agent had left — measured:
33
+ * every panel kept reading the main tree while the run wrote to the worktree.
34
+ * The server's `resolveRoot` accepts a worktree of a registered project for the
35
+ * same reason, so the two agree about what is readable.
36
+ */
37
+ export function effectiveRootFor(
38
+ folders: WorkspaceFolderData[],
39
+ sessionId: string | number | null | undefined,
40
+ ): string | null {
41
+ const { folder } = activeProjectForSession(folders, sessionId);
42
+ if (!folder) return null;
43
+ const row = (folder.sessions || []).find((session) => String(session.id) === String(sessionId));
44
+ const worktree = row?.worktree;
45
+ if (worktree && worktree.status === 'ready') return worktree.path;
46
+ return folder.project_path ?? null;
47
+ }
48
+
26
49
  /**
27
50
  * Resolve the workspace root the composer should scope `@` file mentions to:
28
51
  * the active session's bound project first, then the explicitly selected
@@ -0,0 +1,70 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The three width modes' table, asserted as data.
8
+ *
9
+ * Each case is a rule the layout depends on and that no component test would
10
+ * catch: `compact` must NOT hide the sidebar (that is `full`'s job, and the
11
+ * two buttons would be the same button if it did), the right panel is hidden
12
+ * by neither, and clicking an active mode returns to `normal` rather than
13
+ * doing nothing.
14
+ */
15
+
16
+ import { describe, expect, test } from 'bun:test';
17
+
18
+ import {
19
+ DEFAULT_EDITOR_VIEW_MODE,
20
+ isEditorViewMode,
21
+ editorViewHides,
22
+ toggledEditorViewMode,
23
+ } from '@/shared/lib/workspace/editor-view-mode';
24
+
25
+ describe('editorViewHides', () => {
26
+ test('compact hides the chat only', () => {
27
+ expect(editorViewHides('compact', 'chat')).toBe(true);
28
+ expect(editorViewHides('compact', 'sidebar')).toBe(false);
29
+ });
30
+
31
+ test('full hides the chat AND the sidebar', () => {
32
+ expect(editorViewHides('full', 'chat')).toBe(true);
33
+ expect(editorViewHides('full', 'sidebar')).toBe(true);
34
+ });
35
+
36
+ test('normal hides nothing', () => {
37
+ expect(editorViewHides('normal', 'chat')).toBe(false);
38
+ expect(editorViewHides('normal', 'sidebar')).toBe(false);
39
+ });
40
+ });
41
+
42
+ describe('toggledEditorViewMode', () => {
43
+ test('a mode button enters its mode', () => {
44
+ expect(toggledEditorViewMode('normal', 'compact')).toBe('compact');
45
+ expect(toggledEditorViewMode('normal', 'full')).toBe('full');
46
+ });
47
+
48
+ test('the OTHER mode switches rather than toggling off', () => {
49
+ expect(toggledEditorViewMode('compact', 'full')).toBe('full');
50
+ expect(toggledEditorViewMode('full', 'compact')).toBe('compact');
51
+ });
52
+
53
+ test('clicking the active mode returns to normal', () => {
54
+ expect(toggledEditorViewMode('compact', 'compact')).toBe('normal');
55
+ expect(toggledEditorViewMode('full', 'full')).toBe('normal');
56
+ });
57
+ });
58
+
59
+ describe('isEditorViewMode', () => {
60
+ test('accepts the three modes and nothing else', () => {
61
+ expect(['normal', 'compact', 'full'].every(isEditorViewMode)).toBe(true);
62
+ expect(isEditorViewMode('fullscreen')).toBe(false);
63
+ expect(isEditorViewMode(undefined)).toBe(false);
64
+ expect(isEditorViewMode(null)).toBe(false);
65
+ });
66
+
67
+ test('the fallback is the ordinary layout', () => {
68
+ expect(DEFAULT_EDITOR_VIEW_MODE).toBe('normal');
69
+ });
70
+ });
@@ -0,0 +1,48 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * How wide the editor column is allowed to be, as ONE of three modes.
8
+ *
9
+ * `compact` and `full` both hide the chat timeline and let the editor take the
10
+ * width the chat had; `full` also hides the session sidebar. Neither touches
11
+ * the right panel: the point of the two modes is a wider DOCUMENT, and the
12
+ * right column is where the outline, search hits and the diff list live while
13
+ * it is being read. The third width — the existing `fixed inset-0` full
14
+ * SCREEN — is a different thing entirely (it covers the right panel too) and
15
+ * stays where it is, on its own button.
16
+ *
17
+ * The rules are pure so the layout, both toolbars and the tests read the same
18
+ * table: which columns a mode hides is data, never a condition at a call site.
19
+ */
20
+
21
+ export type EditorViewMode = 'normal' | 'compact' | 'full';
22
+
23
+ /** The two modes a toolbar button can name. */
24
+ export type EditorViewModeTarget = 'compact' | 'full';
25
+
26
+ /** Session-state slot. Per session, like every other layout choice. */
27
+ export const EDITOR_VIEW_MODE_KEY = 'layout.editorViewMode';
28
+
29
+ export const DEFAULT_EDITOR_VIEW_MODE: EditorViewMode = 'normal';
30
+
31
+ /** A stored value written by another build is not a mode this one can draw. */
32
+ export function isEditorViewMode(value: unknown): value is EditorViewMode {
33
+ return value === 'normal' || value === 'compact' || value === 'full';
34
+ }
35
+
36
+ /**
37
+ * What a mode HIDES. `full` hides everything `compact` does plus the sidebar,
38
+ * so it is strictly wider — the two are ordered, not alternatives.
39
+ */
40
+ export function editorViewHides(mode: EditorViewMode, column: 'chat' | 'sidebar'): boolean {
41
+ if (column === 'chat') return mode === 'compact' || mode === 'full';
42
+ return mode === 'full';
43
+ }
44
+
45
+ /** Clicking the mode a button already names returns to `normal`. */
46
+ export function toggledEditorViewMode(current: EditorViewMode, next: EditorViewModeTarget): EditorViewMode {
47
+ return current === next ? 'normal' : next;
48
+ }
@@ -124,6 +124,27 @@ describe('resolvePanelWidth', () => {
124
124
  // panel keeps its minimum and the row clips, rather than collapsing.
125
125
  expect(resolvePanelWidth({ ...base, available: 400, siblingWidth: 320, handleCount: 2 })).toBe(320);
126
126
  });
127
+
128
+ test('a collapsed chat removes the floor AND the pair caps', () => {
129
+ const available = 2000;
130
+ // The ordinary layout would cap this at 0.6 × 2000 = 1200 (own cap) and at
131
+ // 0.7 × 2000 − 320 = 1080 (pair cap, a sibling reserving its floor), and the
132
+ // chat's 400 floor would bind at 1280. With no chat on screen there is no
133
+ // conversation to leave room for, so only the group's own width limits it.
134
+ const wanted = { fraction: 0.9 };
135
+ expect(resolvePanelWidth({ ...base, available, siblingWidth: 320, handleCount: 1, stored: wanted })).toBe(
136
+ Math.round(PAIR_MAX_FRACTION * available),
137
+ );
138
+ expect(resolvePanelWidth({ ...base, available, siblingWidth: 320, handleCount: 1, stored: wanted, chatHidden: true })).toBe(
139
+ available - 320 - 1 * 3,
140
+ );
141
+ });
142
+
143
+ test('the floor still wins with the chat collapsed', () => {
144
+ // A group narrower than the sibling and this panel's floor: the panel keeps
145
+ // its minimum rather than collapsing into nothing.
146
+ expect(resolvePanelWidth({ ...base, available: 400, siblingWidth: 320, handleCount: 1, chatHidden: true })).toBe(320);
147
+ });
127
148
  });
128
149
 
129
150
  describe('the editor/right-panel pair', () => {
@@ -273,4 +294,13 @@ describe('pairMaxSize (the drag cap)', () => {
273
294
  test('no measured area means no cap', () => {
274
295
  expect(pairMaxSize({ available: null, siblingMin: editorMin, siblingOpen: true })).toBeUndefined();
275
296
  });
297
+
298
+ test('a collapsed chat lets a drag reach the whole row', () => {
299
+ // Two panels and nothing else: the editor may be dragged up to the group's
300
+ // width minus the right panel's floor. The 0.6 own-cap would have frozen the
301
+ // separator at 2400 of 4000 with 1600px of empty row behind it.
302
+ expect(pairMaxSize({ available, siblingMin: rightMin, siblingOpen: true, chatHidden: true })).toBe(available - rightMin);
303
+ // The sibling closed means nothing to reserve at all.
304
+ expect(pairMaxSize({ available, siblingMin: 0, siblingOpen: false, chatHidden: true })).toBe(available);
305
+ });
276
306
  });
@@ -149,6 +149,13 @@ export interface ResolveWidthArgs {
149
149
  siblingWidth: number;
150
150
  /** Separators in the group, which also consume width. */
151
151
  handleCount: number;
152
+ /**
153
+ * The CHAT column is collapsed (`compact`/`full` editor views), so it holds no
154
+ * floor and the pair caps do not apply — both exist to leave the conversation
155
+ * a share of the group, and neither has anything to reserve once it is off
156
+ * screen. Keeping them would cap a two-panel layout at 70% of its width.
157
+ */
158
+ chatHidden?: boolean;
152
159
  }
153
160
 
154
161
  /**
@@ -172,7 +179,7 @@ export interface ResolveWidthArgs {
172
179
  * default pixel width is used unchanged.
173
180
  */
174
181
  export function resolvePanelWidth(args: ResolveWidthArgs): number {
175
- const { stored, defaultFraction, defaultPx, available, min, pairReservePx = 0, siblingWidth, handleCount } = args;
182
+ const { stored, defaultFraction, defaultPx, available, min, pairReservePx = 0, siblingWidth, handleCount, chatHidden = false } = args;
176
183
 
177
184
  if (available === null || available <= 0) return Math.max(min, Math.round(stored?.px ?? defaultPx));
178
185
 
@@ -182,14 +189,20 @@ export function resolvePanelWidth(args: ResolveWidthArgs): number {
182
189
  // `total × available − the sibling's reserved px` — and ROUNDED, because
183
190
  // `0.7 × 4000` is `2799.9999…` in binary floating point and flooring it lands
184
191
  // a pixel short of the intended share.
185
- const ownCap = Math.round(PAIR_MAX_FRACTION * available);
186
- const pairCap = Math.round(PAIR_MAX_TOTAL_FRACTION * available) - pairReservePx;
192
+ //
193
+ // With the chat COLLAPSED neither cap means anything: the pair's 70% share
194
+ // and the chat floor are both about leaving the conversation room, and there
195
+ // is no conversation on screen. Only the group's own width remains a limit,
196
+ // which is what lets the editor take the width the chat had.
197
+ const ownCap = chatHidden ? Number.MAX_SAFE_INTEGER : Math.round(PAIR_MAX_FRACTION * available);
198
+ const pairCap = chatHidden ? Number.MAX_SAFE_INTEGER : Math.round(PAIR_MAX_TOTAL_FRACTION * available) - pairReservePx;
199
+ const chatFloor = chatHidden ? 0 : MIN_CHAT_PANEL_WIDTH;
187
200
  const ceiling = Math.max(
188
201
  min,
189
202
  Math.min(
190
203
  ownCap,
191
204
  pairCap,
192
- Math.floor(available - MIN_CHAT_PANEL_WIDTH - siblingWidth - handleCount * HANDLE_WIDTH),
205
+ Math.floor(available - chatFloor - siblingWidth - handleCount * HANDLE_WIDTH),
193
206
  ),
194
207
  );
195
208
 
@@ -263,9 +276,16 @@ export function pairMaxSize(args: {
263
276
  siblingMin: number;
264
277
  /** The sibling is open and therefore reserves its floor from the pair total. */
265
278
  siblingOpen: boolean;
279
+ /** The chat column is collapsed, so the pair's shared share is the whole group. */
280
+ chatHidden?: boolean;
266
281
  }): number | undefined {
267
- const { available, siblingMin, siblingOpen } = args;
282
+ const { available, siblingMin, siblingOpen, chatHidden = false } = args;
268
283
  if (available === null || available <= 0) return undefined;
284
+ // No chat on screen means nothing to reserve a share FOR: the pair is the
285
+ // whole row, so each side may be dragged up to the group's width minus the
286
+ // sibling's floor. The 60% own-cap would freeze a separator halfway across a
287
+ // layout whose only two panels are the ones being dragged.
288
+ if (chatHidden) return Math.max(0, Math.floor(available - (siblingOpen ? siblingMin : 0)));
269
289
  const ownCap = Math.round(PAIR_MAX_FRACTION * available);
270
290
  if (!siblingOpen) return ownCap;
271
291
  const pairCap = Math.round(PAIR_MAX_TOTAL_FRACTION * available) - siblingMin;