@kabmo-labs/kabmo 0.8.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 (254) hide show
  1. package/README.md +7 -0
  2. package/dist/client/index.html +1 -1
  3. package/dist/client/index.js +314 -314
  4. package/dist/client/static/js/{index-qwpvd9fv.js → index-bpptfkz2.js} +922 -892
  5. package/dist/client/static/js/index-k315p1e8.css +1 -0
  6. package/package.json +9 -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/shell-update-button/Modal.tsx +163 -0
  24. package/src/client/components/common/shell-update-button/index.test.tsx +251 -0
  25. package/src/client/components/common/shell-update-button/index.tsx +125 -0
  26. package/src/client/components/common/toolbar-row/index.test.tsx +166 -0
  27. package/src/client/components/common/toolbar-row/index.tsx +189 -0
  28. package/src/client/components/layout/desktop-layout/WorkspacePanels.tsx +41 -78
  29. package/src/client/components/layout/desktop-layout/index.tsx +47 -67
  30. package/src/client/components/layout/desktop-layout/resizer/index.tsx +6 -3
  31. package/src/client/components/layout/session-sidebar/Footer.tsx +8 -15
  32. package/src/client/components/mobile/LayoutWrapper.tsx +1 -0
  33. package/src/client/components/mobile/mobile-right-sidebar/FullDiff.tsx +3 -0
  34. package/src/client/components/mobile/mobile-right-sidebar/FullEditor.tsx +63 -38
  35. package/src/client/components/mobile/mobile-right-sidebar/OutlineButton.test.tsx +137 -0
  36. package/src/client/components/mobile/mobile-right-sidebar/OutlineButton.tsx +14 -3
  37. package/src/client/components/mobile/mobile-session-sidebar/Footer.tsx +9 -21
  38. package/src/client/components/settings/Modal.tsx +8 -0
  39. package/src/client/components/settings/categories/AppearanceSettings.tsx +3 -0
  40. package/src/client/components/settings/categories/appearance-settings/EditorThemeSection.test.tsx +167 -0
  41. package/src/client/components/settings/categories/appearance-settings/EditorThemeSection.tsx +184 -0
  42. package/src/client/components/settings/categories/project-settings/WorktreeSettings.tsx +8 -2
  43. package/src/client/components/workspace/chat-timeline/EmptyWorkspacePrompt.tsx +16 -8
  44. package/src/client/components/workspace/chat-timeline/SystemNotice.tsx +9 -4
  45. package/src/client/components/workspace/chat-timeline/TaskResultContent.tsx +9 -7
  46. package/src/client/components/workspace/chat-timeline/target-picker/WorkspacePicker.test.tsx +132 -0
  47. package/src/client/components/workspace/chat-timeline/target-picker/WorkspacePicker.tsx +120 -0
  48. package/src/client/components/workspace/chat-timeline/target-picker/WorktreePicker.test.tsx +319 -0
  49. package/src/client/components/workspace/chat-timeline/target-picker/WorktreePicker.tsx +285 -0
  50. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/index.test.ts +2 -0
  51. package/src/client/components/workspace/chat-timeline/tool-renderers/hashline-patch/index.tsx +34 -29
  52. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/AstEdit.tsx +11 -9
  53. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Bash.tsx +10 -7
  54. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Edit.tsx +25 -8
  55. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Eval.tsx +8 -11
  56. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/FileMatches.test.ts +114 -0
  57. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/FileMatches.tsx +111 -0
  58. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Read.tsx +7 -7
  59. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/SearchTool.tsx +9 -59
  60. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/ArtifactBody.tsx +2 -7
  61. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/DiffView.test.ts +27 -28
  62. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/DiffView.tsx +39 -26
  63. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/ExcerptCode.tsx +8 -8
  64. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/HighlightedCode.tsx +35 -0
  65. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/JsonCodeBlock.tsx +4 -4
  66. package/src/client/components/workspace/diff-panel/MergeDiffView.tsx +179 -0
  67. package/src/client/components/workspace/diff-panel/ThemedMergeDiffView.tsx +24 -0
  68. package/src/client/components/workspace/diff-panel/Toolbar.tsx +224 -186
  69. package/src/client/components/workspace/diff-panel/ToolbarSegments.tsx +98 -0
  70. package/src/client/components/workspace/diff-panel/index.tsx +76 -145
  71. package/src/client/components/workspace/diff-panel/merge.ts +315 -0
  72. package/src/client/components/workspace/editor/Body.tsx +4 -7
  73. package/src/client/components/workspace/editor/CommandPalette.tsx +2 -2
  74. package/src/client/components/workspace/editor/FileDocument.tsx +58 -63
  75. package/src/client/components/workspace/editor/FilePane.tsx +4 -7
  76. package/src/client/components/workspace/editor/FindWidget.tsx +7 -2
  77. package/src/client/components/workspace/editor/Toolbar.tsx +220 -123
  78. package/src/client/components/workspace/editor/index.tsx +7 -9
  79. package/src/client/components/workspace/file-explorer/OutlineSection.test.tsx +18 -0
  80. package/src/client/components/workspace/file-explorer/OutlineSection.tsx +10 -3
  81. package/src/client/components/workspace/search-panel/Body.tsx +1 -1
  82. package/src/client/components/workspace/worktree-modal/index.test.tsx +118 -0
  83. package/src/client/hooks/chat/omp/reconnect.test.ts +338 -0
  84. package/src/client/hooks/chat/omp/reconnect.ts +187 -0
  85. package/src/client/hooks/chat/omp/run-liveness.test.ts +71 -0
  86. package/src/client/hooks/chat/omp/run-liveness.ts +65 -0
  87. package/src/client/hooks/chat/omp/session-list.activity.test.ts +114 -0
  88. package/src/client/hooks/chat/omp/session-list.test-util.ts +164 -0
  89. package/src/client/hooks/chat/omp/session-list.test.ts +30 -150
  90. package/src/client/hooks/chat/omp/session-list.tsx +69 -9
  91. package/src/client/hooks/chat/timeline/generating.ts +19 -2
  92. package/src/client/hooks/chat/timeline/index.ts +19 -24
  93. package/src/client/hooks/chat/timeline/lost-run.test.ts +329 -0
  94. package/src/client/hooks/chat/timeline/lost-run.ts +177 -0
  95. package/src/client/hooks/chat/timeline/session-load.test-util.ts +205 -0
  96. package/src/client/hooks/chat/timeline/session-load.test.ts +61 -162
  97. package/src/client/hooks/chat/timeline/session-load.ts +69 -37
  98. package/src/client/hooks/chat/timeline/session-switch.test.ts +65 -0
  99. package/src/client/hooks/chat/timeline/session-switch.ts +63 -0
  100. package/src/client/hooks/code/deferred-highlight.test.ts +182 -0
  101. package/src/client/hooks/code/deferred-highlight.ts +129 -0
  102. package/src/client/hooks/editor/find-state.ts +1 -1
  103. package/src/client/hooks/editor/theme.test.ts +206 -0
  104. package/src/client/hooks/editor/theme.ts +82 -0
  105. package/src/client/hooks/editor/use-editor-drop.test.ts +101 -0
  106. package/src/client/hooks/editor/use-editor-drop.ts +67 -14
  107. package/src/client/hooks/editor/use-editor-find.ts +7 -7
  108. package/src/client/hooks/editor/use-outline-view.test.ts +182 -0
  109. package/src/client/hooks/editor/use-outline-view.ts +94 -7
  110. package/src/client/hooks/editor/use-scroll-restore.test.ts +55 -2
  111. package/src/client/hooks/editor/use-scroll-restore.ts +124 -4
  112. package/src/client/hooks/editor/use-search-reveal.ts +1 -1
  113. package/src/client/hooks/ui/settings-modal.ts +56 -0
  114. package/src/client/hooks/ui/updates/shell-download.test.ts +2 -0
  115. package/src/client/hooks/ui/updates/shell-download.ts +55 -14
  116. package/src/client/hooks/ui/updates/shell-offer.test.ts +26 -0
  117. package/src/client/hooks/ui/updates/shell-update.test.ts +140 -0
  118. package/src/client/hooks/ui/updates/shell-update.ts +119 -0
  119. package/src/client/hooks/workspace/diff-file.ts +29 -10
  120. package/src/client/hooks/workspace/diff-git-actions.ts +147 -0
  121. package/src/client/hooks/workspace/editor-view.ts +61 -0
  122. package/src/client/hooks/workspace/file-tabs.test.ts +62 -5
  123. package/src/client/hooks/workspace/file-tabs.ts +24 -11
  124. package/src/client/hooks/workspace/navbar-session-actions.ts +72 -0
  125. package/src/client/hooks/workspace/worktree/index.ts +20 -5
  126. package/src/client/lib/desktop/bridge.ts +45 -0
  127. package/src/client/lib/search-reveal.ts +1 -1
  128. package/src/client/main.tsx +5 -1
  129. package/src/client/tailwind.css +37 -8
  130. package/src/server/lib/auth/guard.test.ts +28 -0
  131. package/src/server/lib/auth/guard.ts +10 -0
  132. package/src/server/lib/fs/worktree/create.ts +2 -3
  133. package/src/server/lib/fs/worktree/list.ts +13 -0
  134. package/src/server/lib/fs/worktree/remove.ts +3 -3
  135. package/src/server/lib/lifecycle/claim-port.test.ts +156 -0
  136. package/src/server/lib/lifecycle/port-guard.test.ts +20 -164
  137. package/src/server/lib/lifecycle/port-guard.ts +39 -7
  138. package/src/server/lib/lifecycle/probe-health.test.ts +166 -0
  139. package/src/server/lib/lifecycle/probe.test.ts +77 -164
  140. package/src/server/lib/lifecycle/probe.ts +121 -13
  141. package/src/server/lib/omp/rpc/frame-fold-signals.test.ts +112 -0
  142. package/src/server/lib/omp/rpc/frame-fold.ts +10 -5
  143. package/src/server/lib/omp/session/sidebar-activity.server.test.ts +162 -0
  144. package/src/server/lib/omp/session/sidebar-activity.server.ts +70 -0
  145. package/src/server/lib/omp/session/telemetry/compute.ts +13 -1
  146. package/src/server/lib/omp/session/telemetry.test.ts +33 -0
  147. package/src/server/lib/realtime/signals.server.ts +10 -0
  148. package/src/server/lib/realtime/topics/catalog.server.ts +4 -1
  149. package/src/server/lib/realtime/topics.server.ts +16 -0
  150. package/src/server/lib/updates/shell-assets.ts +94 -3
  151. package/src/server/lib/updates/shell-cache.ts +105 -0
  152. package/src/server/lib/updates/shell-pages.test.ts +33 -2
  153. package/src/server/lib/updates/shell-pages.ts +17 -5
  154. package/src/server/lib/updates/shell-release-desktop.test.ts +166 -0
  155. package/src/server/lib/updates/shell-release.test.ts +10 -27
  156. package/src/server/lib/updates/shell-release.ts +107 -111
  157. package/src/server/routes/fs/git-merge.test.ts +11 -1
  158. package/src/server/routes/fs/read.test.ts +24 -7
  159. package/src/server/routes/fs/scope.test.ts +15 -10
  160. package/src/server/routes/fs/scope.ts +3 -14
  161. package/src/server/routes/updates/android.ts +6 -1
  162. package/src/server/routes/updates/desktop.ts +29 -7
  163. package/src/server/routes/updates/ios.ts +5 -1
  164. package/src/server/routes/worktrees/list.ts +11 -1
  165. package/src/server/routes/worktrees/routes.test.ts +17 -0
  166. package/src/shared/lib/chat/timeline/omp-callbacks.test.ts +36 -0
  167. package/src/shared/lib/chat/timeline/omp-callbacks.ts +18 -0
  168. package/src/shared/lib/chat/timeline/run-signals.test.ts +2 -0
  169. package/src/shared/lib/code/editor/comment-syntax.test.ts +37 -0
  170. package/src/shared/lib/code/editor/comment-syntax.ts +55 -0
  171. package/src/shared/lib/code/editor/find-replace.test.ts +134 -0
  172. package/src/shared/lib/code/editor/{find.ts → find-replace.ts} +23 -107
  173. package/src/shared/lib/code/editor/{find.test.ts → find-scan.test.ts} +19 -73
  174. package/src/shared/lib/code/editor/find-scan.ts +177 -0
  175. package/src/shared/lib/code/editor/keymap.ts +3 -3
  176. package/src/shared/lib/code/editor/lines.test.ts +7 -55
  177. package/src/shared/lib/code/editor/lines.ts +8 -48
  178. package/src/shared/lib/code/editor/marks.test.ts +4 -5
  179. package/src/shared/lib/code/editor/marks.ts +13 -25
  180. package/src/shared/lib/code/editor/theme-loader.ts +97 -0
  181. package/src/shared/lib/code/editor/themes.generated.ts +156 -0
  182. package/src/shared/lib/code/editor/themes.test.ts +165 -0
  183. package/src/shared/lib/code/editor/themes.ts +165 -0
  184. package/src/shared/lib/code/highlight-memo.test.ts +103 -0
  185. package/src/shared/lib/code/highlight-memo.ts +149 -0
  186. package/src/shared/lib/code/highlighter-warmup.ts +112 -0
  187. package/src/shared/lib/code/highlighter.ts +3 -1
  188. package/src/shared/lib/code/idle.ts +37 -0
  189. package/src/shared/lib/code/outline/rows.test.ts +96 -0
  190. package/src/shared/lib/code/outline/rows.ts +94 -0
  191. package/src/shared/lib/code/syntax-highlight.test.ts +1 -79
  192. package/src/shared/lib/code/syntax-highlight.ts +2 -3
  193. package/src/shared/lib/fs/file-kind-classify.test.ts +25 -11
  194. package/src/shared/lib/fs/file-kind.ts +47 -13
  195. package/src/shared/lib/fs/file-url.test.ts +52 -0
  196. package/src/shared/lib/fs/file-url.ts +37 -0
  197. package/src/shared/lib/fs/patch-sides.test.ts +114 -0
  198. package/src/shared/lib/fs/patch-sides.ts +139 -0
  199. package/src/shared/lib/realtime/protocol.ts +16 -0
  200. package/src/shared/lib/ui/toolbar-overflow.test.ts +83 -0
  201. package/src/shared/lib/ui/toolbar-overflow.ts +86 -0
  202. package/src/shared/lib/updates/shell-arch.ts +54 -0
  203. package/src/shared/lib/updates/shell-ua.ts +4 -4
  204. package/src/shared/lib/workspace/editor-view-mode.test.ts +70 -0
  205. package/src/shared/lib/workspace/editor-view-mode.ts +48 -0
  206. package/src/shared/lib/workspace/panel-widths.test.ts +30 -0
  207. package/src/shared/lib/workspace/panel-widths.ts +25 -5
  208. package/src/shared/lib/workspace/session-state/cache.ts +159 -0
  209. package/src/shared/lib/workspace/session-state/harness.test-util.ts +55 -0
  210. package/src/shared/lib/workspace/session-state/merge.ts +100 -0
  211. package/src/shared/lib/workspace/session-state/store-load.test.ts +206 -0
  212. package/src/shared/lib/workspace/session-state/store.test.ts +17 -139
  213. package/src/shared/lib/workspace/session-state/store.ts +92 -130
  214. package/src/shared/lib/workspace/workspace-panel-widths.ts +124 -0
  215. package/src/shared/types/realtime.ts +29 -0
  216. package/src/shared/types/settings/state.ts +10 -0
  217. package/src/test-support/loopback.ts +183 -0
  218. package/dist/client/static/js/index-gtbpewr7.css +0 -1
  219. package/src/client/components/common/code-editor/handler.test.ts +0 -274
  220. package/src/client/components/common/code-editor/handler.ts +0 -175
  221. package/src/client/components/common/code-editor/index.tsx +0 -314
  222. package/src/client/components/common/code-surface/measure.ts +0 -148
  223. package/src/client/components/workspace/chat-timeline/target-picker/index.test.tsx +0 -227
  224. package/src/client/components/workspace/chat-timeline/target-picker/index.tsx +0 -260
  225. package/src/client/components/workspace/diff-panel/SplitView.tsx +0 -209
  226. package/src/client/components/workspace/diff-panel/UnifiedView.tsx +0 -150
  227. package/src/client/hooks/editor/scroll-container.test.ts +0 -102
  228. package/src/client/hooks/editor/scroll-container.ts +0 -26
  229. package/src/client/hooks/editor/use-code-window.ts +0 -100
  230. package/src/client/hooks/editor/use-editor-history.test.ts +0 -216
  231. package/src/client/hooks/editor/use-editor-history.ts +0 -134
  232. package/src/client/hooks/editor/use-occurrences.test.ts +0 -236
  233. package/src/client/hooks/editor/use-occurrences.ts +0 -98
  234. package/src/shared/lib/code/editor/commands.test.ts +0 -155
  235. package/src/shared/lib/code/editor/commands.ts +0 -260
  236. package/src/shared/lib/code/editor/edits.test.ts +0 -100
  237. package/src/shared/lib/code/editor/edits.ts +0 -122
  238. package/src/shared/lib/code/editor/history.test.ts +0 -118
  239. package/src/shared/lib/code/editor/history.ts +0 -101
  240. package/src/shared/lib/code/editor/layers.test.ts +0 -77
  241. package/src/shared/lib/code/editor/layers.ts +0 -67
  242. package/src/shared/lib/code/editor/line-edits.test.ts +0 -131
  243. package/src/shared/lib/code/editor/line-edits.ts +0 -159
  244. package/src/shared/lib/code/editor/occurrences.test.ts +0 -117
  245. package/src/shared/lib/code/editor/occurrences.ts +0 -111
  246. package/src/shared/lib/code/editor/reset-css.test.ts +0 -35
  247. package/src/shared/lib/code/editor/reset-css.ts +0 -32
  248. package/src/shared/lib/code/editor/text-commands.test.ts +0 -178
  249. package/src/shared/lib/code/editor/text-commands.ts +0 -154
  250. package/src/shared/lib/code/lazy-window.test.ts +0 -161
  251. package/src/shared/lib/code/lazy-window.ts +0 -156
  252. package/src/shared/lib/code/windowed-highlight.ts +0 -114
  253. package/src/shared/lib/fs/diff-parser.test.ts +0 -116
  254. package/src/shared/lib/fs/diff-parser.ts +0 -213
@@ -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
+ }
@@ -23,10 +23,10 @@ export type MobileShellKind = 'android' | 'ios';
23
23
 
24
24
  /**
25
25
  * The desktop shells. They carry NO User-Agent token — a desktop app is not
26
- * downloaded from the console, so the server has nothing to parse per request.
27
- * They report their version over the shell bridge instead (`__kabmoDesktop`),
28
- * which is why they are a separate family: the version exists, the UA channel
29
- * does 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
30
  */
31
31
  export type DesktopShellKind = 'macos' | 'windows' | 'linux';
32
32
 
@@ -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;
@@ -0,0 +1,159 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The session-state cache: which entries are held, and for how long.
8
+ *
9
+ * Split out of `store.ts` for the file-size ceiling, and it is the natural seam
10
+ * rather than an arbitrary cut. `store.ts` owns the PUBLIC API (read, write,
11
+ * load, migrate) and the decisions about what a write means; this owns the
12
+ * STORAGE — the maps, the LRU order, the idle TTL, and the dirty flag that says
13
+ * an entry holds writes no persist has carried yet.
14
+ *
15
+ * The maps live here and nowhere else, so "is this session dirty" and "drop this
16
+ * session" cannot be answered from two places that disagree.
17
+ */
18
+
19
+ type SessionState = Record<string, unknown>;
20
+
21
+ const entries = new Map<string, SessionState>();
22
+ /** Epoch ms of the last user-driven access per session (get/set/hydrate). */
23
+ const lastTouched = new Map<string, number>();
24
+ /** A session with writes no persist has carried yet. */
25
+ const dirty = new Map<string, boolean>();
26
+ const persistTimers = new Map<string, ReturnType<typeof setTimeout>>();
27
+
28
+ // Bounded LRU cache: cap the number of sessions kept in memory so the tab
29
+ // doesn't grow unboundedly as the user visits more sessions.
30
+ const MAX_CACHED_SESSIONS = 10;
31
+ /** A session whose last user access is older than this is evicted regardless
32
+ * of the size cap, so stale per-session UI state (drafts, panel layouts)
33
+ * stops occupying memory 10 minutes after the user last opened it. */
34
+ const SESSION_IDLE_TTL_MS = 10 * 60 * 1000;
35
+
36
+ /** The cached entry, as read-only — write through the store's own setters. */
37
+ export function readEntry(sessionId: string): Readonly<SessionState> | undefined {
38
+ return entries.get(sessionId);
39
+ }
40
+
41
+ /**
42
+ * Reorder a cache entry to the end of the Map's iteration order (the
43
+ * most-recently-used position) via delete-then-set. Map.set on an existing
44
+ * key updates the value but keeps its original position, so the explicit
45
+ * delete is required for true LRU semantics.
46
+ */
47
+ export function touchEntry(sessionId: string): void {
48
+ const value = entries.get(sessionId);
49
+ if (value !== undefined) {
50
+ entries.delete(sessionId);
51
+ entries.set(sessionId, value);
52
+ }
53
+ lastTouched.set(sessionId, Date.now());
54
+ }
55
+
56
+ /** Stamp the entry as most-recently-used without reordering it. */
57
+ export function stampAccess(sessionId: string): void {
58
+ lastTouched.set(sessionId, Date.now());
59
+ }
60
+
61
+ export function lastAccess(sessionId: string): number | undefined {
62
+ return lastTouched.get(sessionId);
63
+ }
64
+
65
+ /** Replace an entry outright (a blob arriving, a migrate, a hydrate). */
66
+ export function writeEntry(sessionId: string, state: SessionState): void {
67
+ entries.set(sessionId, state);
68
+ }
69
+
70
+ /**
71
+ * The MUTABLE entry for a write, created on demand.
72
+ *
73
+ * `setSessionKey` and `clearSessionKey` mutate the cached object in place, as
74
+ * they always have: a copy per write would give every reader a different object
75
+ * for the same slot, and `useSessionState` compares the store's entry against
76
+ * its own copy by IDENTITY to decide whether a fresher write exists.
77
+ */
78
+ export function entryForWrite(sessionId: string): SessionState {
79
+ const existing = entries.get(sessionId);
80
+ if (existing) return existing;
81
+ const created: SessionState = {};
82
+ entries.set(sessionId, created);
83
+ return created;
84
+ }
85
+
86
+ /** Mark a session as holding writes no persist has carried yet. */
87
+ export function markDirty(sessionId: string): void {
88
+ dirty.set(sessionId, true);
89
+ }
90
+
91
+ export function clearDirty(sessionId: string): void {
92
+ dirty.set(sessionId, false);
93
+ }
94
+
95
+ export function isDirty(sessionId: string): boolean {
96
+ return dirty.get(sessionId) === true;
97
+ }
98
+
99
+ export function setPersistTimer(sessionId: string, timer: ReturnType<typeof setTimeout>): void {
100
+ persistTimers.set(sessionId, timer);
101
+ }
102
+
103
+ export function persistTimer(sessionId: string): ReturnType<typeof setTimeout> | undefined {
104
+ return persistTimers.get(sessionId);
105
+ }
106
+
107
+ export function dropPersistTimer(sessionId: string): void {
108
+ const timer = persistTimers.get(sessionId);
109
+ if (timer) {
110
+ clearTimeout(timer);
111
+ persistTimers.delete(sessionId);
112
+ }
113
+ }
114
+
115
+ /** Remove an entry and everything recorded about it. */
116
+ export function dropEntry(sessionId: string): void {
117
+ entries.delete(sessionId);
118
+ dirty.delete(sessionId);
119
+ lastTouched.delete(sessionId);
120
+ dropPersistTimer(sessionId);
121
+ }
122
+
123
+ /**
124
+ * Evict the oldest clean sessions until the cache is within bounds.
125
+ *
126
+ * Dirty-safety rule: a session with unsaved writes (`isDirty`) must never be
127
+ * evicted, or the user's drafts and editor state would be silently lost. If
128
+ * every candidate is dirty (or is the protected id currently being written),
129
+ * stop evicting rather than lose data.
130
+ *
131
+ * Entries idle past SESSION_IDLE_TTL_MS are evicted first — even when the
132
+ * cache is under the size cap — so long-unused sessions drop out on schedule.
133
+ */
134
+ export function evictIfNeeded(protectedId: string | null, onEvict: (sessionId: string) => void): void {
135
+ const now = Date.now();
136
+ const evict = (id: string): void => {
137
+ dropEntry(id);
138
+ onEvict(id);
139
+ };
140
+ // TTL pass: safe to skip only while the timer still holds the unsaved state
141
+ // or the session is the one currently being written.
142
+ for (const [id, touched] of lastTouched) {
143
+ if (now - touched <= SESSION_IDLE_TTL_MS) continue;
144
+ if (id === protectedId) continue;
145
+ if (isDirty(id)) continue;
146
+ evict(id);
147
+ }
148
+ while (entries.size > MAX_CACHED_SESSIONS) {
149
+ let evicted = false;
150
+ for (const id of entries.keys()) {
151
+ if (id === protectedId) continue;
152
+ if (isDirty(id)) continue;
153
+ evict(id);
154
+ evicted = true;
155
+ break;
156
+ }
157
+ if (!evicted) break;
158
+ }
159
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Shared harness for the session-state store's suites.
8
+ *
9
+ * Split out when `store.test.ts` passed the per-file ceiling. Every case in
10
+ * every suite needs the same three things — an id that teardown forgets, a
11
+ * subscription that teardown cancels, and a counter — and a per-file copy is how
12
+ * two suites end up disagreeing about what teardown restores.
13
+ */
14
+
15
+ import { afterEach, setSystemTime } from 'bun:test';
16
+
17
+ import { subscribeSessionKey } from '@/shared/lib/workspace/session-state/listeners';
18
+ import { forgetSession } from '@/shared/lib/workspace/session-state/store';
19
+
20
+ /** The runner's own fetch, reached through `Bun` so a stub leaked onto the global cannot be mistaken for it. */
21
+ export const originalFetch = Bun.fetch;
22
+ export const originalWindow = Reflect.get(globalThis, 'window');
23
+
24
+ const touched: string[] = [];
25
+ const unsubs: Array<() => void> = [];
26
+
27
+ /** Register a session id so teardown forgets it. */
28
+ export function use(id: string): string {
29
+ touched.push(id);
30
+ return id;
31
+ }
32
+
33
+ /** Subscribe and auto-unsubscribe at teardown. */
34
+ export function follow(sessionId: string | null, key: string | null, listener: () => void): () => void {
35
+ const unsubscribe = subscribeSessionKey(sessionId, key, listener);
36
+ unsubs.push(unsubscribe);
37
+ return unsubscribe;
38
+ }
39
+
40
+ /** Count how many times a notification reaches a listener. */
41
+ export function counter(): { calls: number; listener: () => void } {
42
+ const state = { calls: 0, listener: () => { state.calls += 1; } };
43
+ return state;
44
+ }
45
+
46
+ /** Install the suite's teardown. Called once per test file. */
47
+ export function installStoreTeardown(): void {
48
+ afterEach(() => {
49
+ for (const unsubscribe of unsubs.splice(0)) unsubscribe();
50
+ for (const id of touched.splice(0)) forgetSession(id);
51
+ Reflect.set(globalThis, 'window', originalWindow);
52
+ Reflect.set(globalThis, 'fetch', originalFetch);
53
+ setSystemTime();
54
+ });
55
+ }