@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
@@ -28,6 +28,7 @@ import {
28
28
  PDF_MAX_FILE_BYTES,
29
29
  unsupportedEditorMessage,
30
30
  } from '@/shared/lib/fs/file-kind';
31
+ import { formatBytes } from '@/shared/lib/format/number';
31
32
 
32
33
  const bytes = (...values: number[]): Uint8Array => new Uint8Array(values);
33
34
 
@@ -96,8 +97,11 @@ describe('classifyEditorFile', () => {
96
97
 
97
98
  // The document ceiling does not reach plain text: a 6 MB source file is still
98
99
  // refused, because a textarea holds that buffer for the life of the tab.
99
- test('plain text keeps the text ceiling', () => {
100
- expect(classifyEditorFile({ path: 'big.ts', size: 6 * 1024 * 1024 })).toBe('too-large');
100
+ test('plain text keeps the text ceiling, which a real source file clears', () => {
101
+ // 6 MB was over the old 5 MiB ceiling and is a normal generated file, not a
102
+ // refusal. The ceiling moved; this case is what keeps it from moving back.
103
+ expect(classifyEditorFile({ path: 'big.ts', size: 6 * 1024 * 1024 })).toBe('text');
104
+ expect(classifyEditorFile({ path: 'big.log', size: EDITOR_MAX_FILE_BYTES })).toBe('text');
101
105
  expect(classifyEditorFile({ path: 'big.log', size: EDITOR_MAX_FILE_BYTES + 1 })).toBe('too-large');
102
106
  });
103
107
 
@@ -120,24 +124,34 @@ describe('unsupportedEditorMessage', () => {
120
124
  });
121
125
 
122
126
  test('a size refusal names both the actual size and the ceiling', () => {
123
- const message = unsupportedEditorMessage('too-large', 9 * 1024 * 1024);
124
- expect(message).toContain('9.00 MB');
125
- expect(message).toContain('5.00 MB');
127
+ // Derived, never spelled out: the point of the sentence is that its number
128
+ // is the one the classifier applied, so a literal here would pass while the
129
+ // two disagreed.
130
+ const message = unsupportedEditorMessage('too-large', 40 * 1024 * 1024);
131
+ expect(message).toContain('40.00 MB');
132
+ expect(message).toContain(formatBytes(EDITOR_MAX_FILE_BYTES));
126
133
  });
127
134
 
128
135
  test('a size refusal without a measured size names only the ceiling', () => {
129
136
  const message = unsupportedEditorMessage('too-large');
130
- expect(message).toContain('5.00 MB');
137
+ expect(message).toContain(formatBytes(EDITOR_MAX_FILE_BYTES));
131
138
  expect(message).not.toContain('undefined');
132
139
  });
133
140
 
141
+ test('the text ceiling leaves room for a real source file and stops short of the knee', () => {
142
+ // 32 MiB: measured, the last size before a tab's heap crosses a gigabyte and
143
+ // a 64 MB file stops opening at all (see the constant's own table).
144
+ expect(EDITOR_MAX_FILE_BYTES).toBe(32 * 1024 * 1024);
145
+ expect(EDITOR_MAX_FILE_BYTES).toBeGreaterThan(DOCUMENT_MAX_FILE_BYTES);
146
+ });
147
+
134
148
  // The sentence names the number the classifier actually applied: a PDF is
135
- // refused against 30 MB, so telling the user "5.00 MB" would describe a limit
136
- // the file was never measured against.
137
- test('a PDF size refusal names the PDF ceiling', () => {
149
+ // refused against its own ceiling, so naming the text one would describe a
150
+ // limit the file was never measured against.
151
+ test('a PDF size refusal names the PDF ceiling, not the text one', () => {
138
152
  const message = unsupportedEditorMessage('too-large', 31 * 1024 * 1024, 'docs/report.pdf');
139
153
  expect(message).toContain('31.00 MB');
140
- expect(message).toContain('30.00 MB');
141
- expect(message).not.toContain('5.00 MB');
154
+ expect(message).toContain(formatBytes(PDF_MAX_FILE_BYTES));
155
+ expect(message).not.toContain(formatBytes(EDITOR_MAX_FILE_BYTES));
142
156
  });
143
157
  });
@@ -143,16 +143,43 @@ export function getImageMimeFromBytes(bytes: Uint8Array): string | null {
143
143
  }
144
144
 
145
145
  /**
146
- * The largest file the editor will open when its bytes reach the page WHOLE.
146
+ * The largest text file the editor will open when its bytes reach the page
147
+ * WHOLE.
147
148
  *
148
- * Not a policy knob: the editor holds the whole file in one string and posts
149
- * the whole thing back on every save, so a ceiling has to exist. 5 MiB is above
150
- * any source file and well below the size at which a textarea stops responding.
149
+ * Not a policy knob: the editor holds the whole file in one string, CodeMirror
150
+ * holds a second representation of it, and the whole thing is posted back on
151
+ * every save — so a ceiling has to exist.
151
152
  *
152
- * A DOCX and an XLSX are parsed in the page too, but they get their own, higher
153
- * number — see `DOCUMENT_MAX_FILE_BYTES`.
153
+ * **32 MiB, measured.** The number used to be 5 MiB, and its justification died
154
+ * with the `<textarea>`: that limit existed because a textarea stops responding,
155
+ * and the surface is CodeMirror 6 now. Re-measured on the assembled app (a
156
+ * generated source file, Files panel open, Chromium):
157
+ *
158
+ * | File | Open | Keystroke | Save | JS heap |
159
+ * |---|---|---|---|---|
160
+ * | 5 MB | 99 ms | 17 ms | 289 ms | 73 MB |
161
+ * | 10 MB | 147 ms | 32 ms | 494 ms | 73 MB |
162
+ * | 20 MB | 212 ms | 34 ms | 969 ms | 73 MB |
163
+ * | 30 MB | 323 ms | 39 ms | 2 292 ms | — |
164
+ * | 40 MB | 283 ms | 45 ms | 1 793 ms | — |
165
+ * | 50 MB | 372 ms | 72 ms | 2 228 ms | **1 078 MB** |
166
+ * | 64 MB | never finished loading | — | — | — |
167
+ *
168
+ * The curve is flat through 40 MB and breaks at 50: the heap goes from a few
169
+ * hundred megabytes to over a gigabyte for ONE tab, and a 64 MB file does not
170
+ * open at all. 32 MiB is the last size with a comfortable margin below that
171
+ * knee — ten times any source file, and the point past which a tab is a risk to
172
+ * every other tab in the browser rather than a convenience.
173
+ *
174
+ * Two costs had to be removed before this number was reachable, and both were
175
+ * linear-in-file-size work paid per keystroke: the outline's scan-and-render of
176
+ * every declaration (`outline/rows.ts`), and a full `doc.toString()` on every
177
+ * render (`code-surface/index.tsx`).
178
+ *
179
+ * A DOCX and an XLSX are parsed in the page too, but they get their own number —
180
+ * see `DOCUMENT_MAX_FILE_BYTES`.
154
181
  */
155
- export const EDITOR_MAX_FILE_BYTES = 5 * 1024 * 1024;
182
+ export const EDITOR_MAX_FILE_BYTES = 32 * 1024 * 1024;
156
183
 
157
184
  /**
158
185
  * The largest Word document or workbook the editor opens.
@@ -160,9 +187,10 @@ export const EDITOR_MAX_FILE_BYTES = 5 * 1024 * 1024;
160
187
  * `mammoth` and `read-excel-file` parse the fetched `ArrayBuffer` in the page,
161
188
  * so the file does sit in memory — but it is unzipped and converted once and
162
189
  * then released, where a text buffer is held for as long as the tab is open and
163
- * re-posted on every save. That is why these get four times the text ceiling
164
- * rather than the text ceiling itself: the reason the 5 MiB limit exists (a
165
- * textarea that stops responding) does not apply to a read-only conversion.
190
+ * re-posted on every save. That is why these get a ceiling of their own rather
191
+ * than the text one: the reason the text limit exists (a buffer that must stay
192
+ * editable, and a save that re-posts it) does not apply to a read-only
193
+ * conversion.
166
194
  *
167
195
  * Measured on a 15 MB workbook: the fetch, the unzip and the grid render all
168
196
  * complete, and the panel stays responsive.
@@ -170,8 +198,13 @@ export const EDITOR_MAX_FILE_BYTES = 5 * 1024 * 1024;
170
198
  export const DOCUMENT_MAX_FILE_BYTES = 20 * 1024 * 1024;
171
199
 
172
200
  /**
173
- * The largest PDF the editor opens — above both, because a PDF is the one
174
- * document the page never holds.
201
+ * The largest PDF the editor opens.
202
+ *
203
+ * Its own number rather than the text ceiling, because a PDF is the one document
204
+ * the page never holds — and the only one whose limit is not about a buffer at
205
+ * all (see below). It is no longer the largest of the three: the TEXT ceiling
206
+ * moved past it, which is fine because the two numbers answer different
207
+ * questions rather than ranking a shared budget.
175
208
  *
176
209
  * `PdfView` points an `<iframe>` at `/api/fs/raw`, so the bytes go straight to
177
210
  * Chrome's own viewer, which fetches them with range requests (verified: a
@@ -180,7 +213,8 @@ export const DOCUMENT_MAX_FILE_BYTES = 20 * 1024 * 1024;
180
213
  * Nothing in Kabmo buffers the file, so the text ceiling has no reason to
181
214
  * apply to it — and applying it was the bug: a 6 MB PDF dragged from Finder was
182
215
  * refused as "larger than the 5.00 MB the editor opens" before the document
183
- * branch was ever reached, because the size test runs first.
216
+ * branch was ever reached, because the size test runs first. The 30 MB here is
217
+ * Chrome's own plugin's comfort zone, not Kabmo's memory.
184
218
  */
185
219
  export const PDF_MAX_FILE_BYTES = 30 * 1024 * 1024;
186
220
 
@@ -0,0 +1,52 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * `pathFromFileUrl` — the platform-specific half of a dropped-file reference.
8
+ *
9
+ * Pinned here rather than through the scope route because the Windows case is
10
+ * invisible on the machine this repository is developed on: the route's own test
11
+ * is `testPosix`, so the drive-letter branch had no test at all until a Windows
12
+ * CI run refused every dropped file.
13
+ */
14
+
15
+ import { describe, expect, test } from 'bun:test';
16
+ import { pathFromFileUrl } from '@/shared/lib/fs/file-url';
17
+
18
+ describe('pathFromFileUrl', () => {
19
+ test('a POSIX file URL decodes to the path it names', () => {
20
+ expect(pathFromFileUrl('file:///tmp/x/b.ts')).toBe('/tmp/x/b.ts');
21
+ });
22
+
23
+ test('a percent-encoded space is decoded', () => {
24
+ expect(pathFromFileUrl('file:///tmp/x/My%20Notes.md')).toBe('/tmp/x/My Notes.md');
25
+ });
26
+
27
+ test('a Windows drive letter loses the leading slash, in every spelling', () => {
28
+ // All three forms answer `/C:/…` from `new URL().pathname`, and a leading
29
+ // slash on win32 resolves against the CURRENT DRIVE's root — a path no
30
+ // workspace contains.
31
+ expect(pathFromFileUrl('file:///C:/Users/x/b.ts')).toBe('C:/Users/x/b.ts');
32
+ expect(pathFromFileUrl('file://C:/Users/x/b.ts')).toBe('C:/Users/x/b.ts');
33
+ expect(pathFromFileUrl('file://C:\\Users\\x\\b.ts')).toBe('C:/Users/x/b.ts');
34
+ });
35
+
36
+ test('a percent-encoded drive path is decoded before the slash is dropped', () => {
37
+ expect(pathFromFileUrl('file:///D:/a/My%20Notes.md')).toBe('D:/a/My Notes.md');
38
+ });
39
+
40
+ test('a path that is not a file URL is refused rather than guessed at', () => {
41
+ expect(pathFromFileUrl('/tmp/x/b.ts')).toBeNull();
42
+ expect(pathFromFileUrl('https://example.com/x.ts')).toBeNull();
43
+ });
44
+
45
+ test('a malformed escape is refused, not thrown', () => {
46
+ expect(pathFromFileUrl('file:///tmp/%E0%A4%A')).toBeNull();
47
+ });
48
+
49
+ test('a lowercase drive letter is kept as written', () => {
50
+ expect(pathFromFileUrl('file:///c:/x/b.ts')).toBe('c:/x/b.ts');
51
+ });
52
+ });
@@ -0,0 +1,37 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * A `file://` URL to the path it names.
8
+ *
9
+ * Shared rather than private to the scope route, because the transformation is
10
+ * platform-specific and a private copy can only be exercised on the platform it
11
+ * was written on: the Windows branch below was wrong for as long as it lived
12
+ * inside a route whose test skipped Windows.
13
+ *
14
+ * Two rules, both measured:
15
+ *
16
+ * - **The pathname is percent-encoded** — a space is `%20`, and a Finder drop of
17
+ * `My Notes.md` is exactly that — so decoding is not optional.
18
+ * `decodeURIComponent` throws on a malformed escape, which is answered as
19
+ * "not a path" rather than surfacing as a 500.
20
+ * - **A WINDOWS drive letter loses its leading slash.** `new URL('file://C:\a\b.ts')`
21
+ * and the canonical `file:///C:/a/b.ts` BOTH answer `/C:/a/b.ts`, and
22
+ * `path.resolve` on win32 turns that into `\C:\a\b.ts` — a path under the
23
+ * current DRIVE's root, which no workspace contains. On the Windows CI leg
24
+ * every dropped file was refused with "outside every workspace", so a file
25
+ * dropped on the editor opened nothing at all. `fileURLToPath` drops the same
26
+ * slash; this does it without a `node:url` import in a shared module.
27
+ */
28
+ export function pathFromFileUrl(reference: string): string | null {
29
+ if (!/^file:\/\//i.test(reference)) return null;
30
+ try {
31
+ const pathname = decodeURIComponent(new URL(reference).pathname);
32
+ // `/C:/…` → `C:/…`; `/tmp/…` is left alone.
33
+ return /^\/[A-Za-z]:[\\/]/.test(pathname) ? pathname.slice(1) : pathname;
34
+ } catch {
35
+ return null;
36
+ }
37
+ }
@@ -0,0 +1,114 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * A unified patch → the two documents it compares.
8
+ *
9
+ * What this pins is the shape a chat tool card depends on: a card has a PATCH
10
+ * and no files, and it draws the same merge surface the diff panel does — so the
11
+ * sides it hands over must be exactly what the patch says they were. A context
12
+ * row belongs to both, a `-` row only to the original, a `+` row only to the new
13
+ * one; everything else (the `diff --git` header, `index`, `---`/`+++`, mode and
14
+ * rename lines, the "No newline" marker) is metadata and must not appear in
15
+ * either document.
16
+ */
17
+
18
+ import { describe, expect, test } from 'bun:test';
19
+
20
+ import { patchSides } from '@/shared/lib/fs/patch-sides';
21
+
22
+ const GIT_DIFF = [
23
+ 'diff --git a/src/a.ts b/src/a.ts',
24
+ 'index 1111111..2222222 100644',
25
+ '--- a/src/a.ts',
26
+ '+++ b/src/a.ts',
27
+ '@@ -1,3 +1,3 @@',
28
+ ' const one = 1;',
29
+ '-const two = 2;',
30
+ '+const two = 22;',
31
+ ' const three = 3;',
32
+ '',
33
+ ].join('\n');
34
+
35
+ describe('patchSides', () => {
36
+ test('splits a git diff into the documents it compares', () => {
37
+ const sides = patchSides(GIT_DIFF);
38
+
39
+ expect(sides.original).toBe('const one = 1;\nconst two = 2;\nconst three = 3;');
40
+ expect(sides.next).toBe('const one = 1;\nconst two = 22;\nconst three = 3;');
41
+ expect(sides.additions).toBe(1);
42
+ expect(sides.deletions).toBe(1);
43
+ expect(sides.hasHunk).toBe(true);
44
+ });
45
+
46
+ test('keeps the header lines out of both documents', () => {
47
+ // A `--- a/x` header would otherwise land in the original as `-- a/x`, and
48
+ // the merge view would draw a chunk that is not a change.
49
+ const sides = patchSides(GIT_DIFF);
50
+ for (const line of ['diff --git', 'index ', '--- a/', '+++ b/']) {
51
+ expect(sides.original.includes(line)).toBe(false);
52
+ expect(sides.next.includes(line)).toBe(false);
53
+ }
54
+ });
55
+
56
+ test('a new file has an empty original and the whole new document', () => {
57
+ const sides = patchSides(['@@ -0,0 +1,2 @@', '+alpha', '+beta'].join('\n'));
58
+
59
+ expect(sides.original).toBe('');
60
+ expect(sides.next).toBe('alpha\nbeta');
61
+ expect(sides.additions).toBe(2);
62
+ expect(sides.deletions).toBe(0);
63
+ });
64
+
65
+ test('a deleted file is the mirror image', () => {
66
+ const sides = patchSides(['@@ -1,1 +0,0 @@', '-gone'].join('\n'));
67
+
68
+ expect(sides.original).toBe('gone');
69
+ expect(sides.next).toBe('');
70
+ expect(sides.deletions).toBe(1);
71
+ });
72
+
73
+ test('a header-only patch has no rows, so the caller can fall back to text', () => {
74
+ const sides = patchSides('--- a/x\n+++ b/x\n');
75
+
76
+ expect(sides.rows).toBe(0);
77
+ expect(sides.hasHunk).toBe(false);
78
+ });
79
+
80
+ test('an empty patch is the empty answer, not a one-line document', () => {
81
+ expect(patchSides('')).toEqual({ original: '', next: '', additions: 0, deletions: 0, rows: 0, hasHunk: false });
82
+ expect(patchSides(' \n')).toEqual({ original: '', next: '', additions: 0, deletions: 0, rows: 0, hasHunk: false });
83
+ });
84
+
85
+ test('counts rows without the blank line a trailing newline leaves behind', () => {
86
+ const sides = patchSides(['@@ -1 +1 @@', '-a', '+b'].join('\n'));
87
+
88
+ expect(sides.rows).toBe(2);
89
+ });
90
+
91
+ test('a hunk starting mid-file keeps its real line numbers', () => {
92
+ // Without the padding the change would be line 1 of the rebuilt document,
93
+ // and the card's gutter would name a line the file does not have it on.
94
+ const sides = patchSides(['@@ -43,2 +43,2 @@', ' context', '-old', '+new'].join('\n'));
95
+
96
+ expect(sides.original.split('\n')).toHaveLength(44);
97
+ expect(sides.original.split('\n')[42]).toBe('context');
98
+ expect(sides.original.split('\n')[43]).toBe('old');
99
+ expect(sides.next.split('\n')[43]).toBe('new');
100
+ });
101
+
102
+ test('the gap between two hunks is restored from their own headers', () => {
103
+ const sides = patchSides(
104
+ ['@@ -2,1 +2,1 @@', '-a', '+b', '@@ -10,1 +10,1 @@', '-c', '+d'].join('\n'),
105
+ );
106
+
107
+ // Line 2 is the first change, line 10 the second, and everything between is
108
+ // unchanged content the patch did not carry.
109
+ const original = sides.original.split('\n');
110
+ expect(original[1]).toBe('a');
111
+ expect(original[9]).toBe('c');
112
+ expect(original).toHaveLength(10);
113
+ });
114
+ });
@@ -0,0 +1,139 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * A unified patch as its two DOCUMENTS.
8
+ *
9
+ * This replaced a table parser (`diff-parser.ts`) that produced per-row line
10
+ * numbers and paired split-view rows. Both of those are now the merge view's
11
+ * job — `@codemirror/merge` computes the alignment itself from two documents —
12
+ * so the only thing still needed from a patch is what the two sides were.
13
+ *
14
+ * That matters in one place: a chat tool card has a PATCH and no documents, and
15
+ * the card draws the same merge surface the diff panel does. Reconstructing the
16
+ * sides here keeps one renderer for both, instead of the card growing a second
17
+ * diff implementation that drifts.
18
+ *
19
+ * **The gap between hunks is restored as blank lines, and that is what keeps the
20
+ * gutter honest.** A hunk header names the line it starts on (`@@ -43,7 +43,8`),
21
+ * and a document rebuilt by concatenating hunks would number the change from 1.
22
+ * Padding each side up to the header's own counters puts the change back on the
23
+ * line the file has it on — and because the padding is unchanged content, the
24
+ * merge view collapses it into one marker (`contentMode: 'diff'`).
25
+ *
26
+ * The counters are the patch's own (`+`/`-` rows, hunk headers excluded), which
27
+ * is what the card's header prints. The diff panel deliberately does NOT use
28
+ * them: it has the real documents, and its counts must describe the rows the
29
+ * merge view actually draws (a collapsed or whitespace-folded change is not a
30
+ * changed row on screen).
31
+ */
32
+
33
+ /** One side's text, and what the patch said about it. */
34
+ export interface PatchSides {
35
+ /** The document before the change, with the hunk gaps as blank lines. */
36
+ original: string;
37
+ /** The document after it, the same way. */
38
+ next: string;
39
+ additions: number;
40
+ deletions: number;
41
+ /** Content rows the patch produced — zero for a header-only or empty patch. */
42
+ rows: number;
43
+ /** The patch named at least one hunk (`@@`), so it is a real diff. */
44
+ hasHunk: boolean;
45
+ }
46
+
47
+ const EMPTY: PatchSides = { original: '', next: '', additions: 0, deletions: 0, rows: 0, hasHunk: false };
48
+
49
+ /** Header lines that describe the patch rather than its content. */
50
+ const HEADER_PREFIXES = [
51
+ 'diff --git',
52
+ 'index ',
53
+ '---',
54
+ '+++',
55
+ 'new file mode',
56
+ 'deleted file mode',
57
+ 'similarity index',
58
+ 'dissimilarity index',
59
+ 'rename from',
60
+ 'rename to',
61
+ 'copy from',
62
+ 'copy to',
63
+ 'old mode',
64
+ 'new mode',
65
+ '\\ No newline',
66
+ ];
67
+
68
+ /** `@@ -43,7 +43,8 @@` → the two starting lines; anything else → null. */
69
+ function hunkStarts(line: string): { old: number; next: number } | null {
70
+ const match = line.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
71
+ if (!match) return null;
72
+ return { old: Number(match[1]), next: Number(match[2]) };
73
+ }
74
+
75
+ /** Pad `lines` with blanks until it holds `count` lines. */
76
+ function padTo(lines: string[], count: number): void {
77
+ while (lines.length < count) lines.push('');
78
+ }
79
+
80
+ export function patchSides(patch: string): PatchSides {
81
+ if (!patch.trim()) return EMPTY;
82
+
83
+ const before: string[] = [];
84
+ const after: string[] = [];
85
+ let additions = 0;
86
+ let deletions = 0;
87
+ let rows = 0;
88
+ let hasHunk = false;
89
+
90
+ const lines = patch.split(/\r?\n/);
91
+ // A patch ends with a newline, and `split` turns that into a final empty
92
+ // string. It is the terminator, not a blank row: keeping it put an empty line
93
+ // at the end of BOTH documents, which the merge view drew as a chunk.
94
+ if (lines[lines.length - 1] === '') lines.pop();
95
+
96
+ for (const raw of lines) {
97
+ if (HEADER_PREFIXES.some((prefix) => raw.startsWith(prefix))) continue;
98
+
99
+ if (raw.startsWith('@@')) {
100
+ hasHunk = true;
101
+ const starts = hunkStarts(raw);
102
+ // `-0,0` (a new file) has no original line to reach, and `padTo` is a
103
+ // no-op for it because the count is below what is already there.
104
+ if (starts) {
105
+ padTo(before, starts.old - 1);
106
+ padTo(after, starts.next - 1);
107
+ }
108
+ continue;
109
+ }
110
+
111
+ if (raw.startsWith('+')) {
112
+ additions++;
113
+ rows++;
114
+ after.push(raw.slice(1));
115
+ continue;
116
+ }
117
+ if (raw.startsWith('-')) {
118
+ deletions++;
119
+ rows++;
120
+ before.push(raw.slice(1));
121
+ continue;
122
+ }
123
+ // A context row (` ` prefix) is on BOTH sides; a bare line is what a
124
+ // header-less patch carries, and it is context too.
125
+ const text = raw.startsWith(' ') ? raw.slice(1) : raw;
126
+ rows++;
127
+ before.push(text);
128
+ after.push(text);
129
+ }
130
+
131
+ return {
132
+ original: before.join('\n'),
133
+ next: after.join('\n'),
134
+ additions,
135
+ deletions,
136
+ rows,
137
+ hasHunk,
138
+ };
139
+ }
@@ -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
+ });