@kabmo-labs/kabmo 0.1.0 → 0.3.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 (80) hide show
  1. package/dist/client/index.html +1 -1
  2. package/dist/client/index.js +285 -285
  3. package/dist/client/static/js/{index-enkbbffs.js → index-8x5j8zrr.js} +671 -671
  4. package/dist/client/static/js/index-pdp5dwc8.css +1 -0
  5. package/package.json +1 -1
  6. package/src/client/bootstrap.d.ts +17 -0
  7. package/src/client/components/common/header-controls/MoreMenu.tsx +136 -0
  8. package/src/client/components/common/header-controls/index.test.tsx +77 -11
  9. package/src/client/components/common/header-controls/index.tsx +53 -4
  10. package/src/client/components/common/session-state-provider/index.test.ts +22 -1
  11. package/src/client/components/layout/session-sidebar/Footer.tsx +24 -2
  12. package/src/client/components/layout/session-sidebar/OverflowMenu.tsx +23 -5
  13. package/src/client/components/layout/session-sidebar/Toolbar.tsx +2 -33
  14. package/src/client/components/layout/session-sidebar/index.tsx +8 -0
  15. package/src/client/components/mobile/LayoutWrapper.tsx +32 -1
  16. package/src/client/components/mobile/SessionSidebar.tsx +8 -0
  17. package/src/client/components/mobile/mobile-main-view/Header.tsx +27 -5
  18. package/src/client/components/mobile/mobile-session-sidebar/Footer.tsx +29 -1
  19. package/src/client/components/mobile/mobile-session-sidebar/Toolbar.test.ts +104 -0
  20. package/src/client/components/mobile/mobile-session-sidebar/Toolbar.tsx +166 -106
  21. package/src/client/components/workspace/chat-timeline/ComposerDock.tsx +9 -0
  22. package/src/client/components/workspace/chat-timeline/TimelineBody.tsx +6 -0
  23. package/src/client/components/workspace/chat-timeline/chat-input/AttachmentToolbar.tsx +4 -1
  24. package/src/client/components/workspace/chat-timeline/chat-input/DropOverlay.tsx +1 -1
  25. package/src/client/components/workspace/chat-timeline/chat-input/GoalBanner.tsx +6 -1
  26. package/src/client/components/workspace/chat-timeline/chat-input/Toolbar.tsx +4 -1
  27. package/src/client/components/workspace/chat-timeline/chat-input/VoiceNotice.tsx +2 -2
  28. package/src/client/components/workspace/chat-timeline/chat-input/expansion.test.ts +72 -1
  29. package/src/client/components/workspace/chat-timeline/chat-input/expansion.ts +28 -4
  30. package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +20 -2
  31. package/src/client/components/workspace/chat-timeline/chat-input/props.ts +7 -0
  32. package/src/client/components/workspace/chat-timeline/index.tsx +9 -3
  33. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/OrphanAsks.test.tsx +127 -0
  34. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/OrphanAsks.tsx +71 -0
  35. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/PluginPendingInteractions.test.ts +1 -0
  36. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/index.test.ts +2 -2
  37. package/src/client/components/workspace/git-panel/BranchToolbar.tsx +4 -0
  38. package/src/client/components/workspace/git-panel/index.test.ts +15 -0
  39. package/src/client/components/workspace/search-panel/Body.test.ts +149 -0
  40. package/src/client/components/workspace/search-panel/Body.tsx +53 -9
  41. package/src/client/data/settings/omp-schema.json +89 -4
  42. package/src/client/hooks/chat/timeline/ask-frames-grouped.test.ts +46 -2
  43. package/src/client/hooks/chat/timeline/ask-frames.test.ts +1 -1
  44. package/src/client/hooks/chat/timeline/ask-frames.ts +79 -29
  45. package/src/client/hooks/chat/timeline/index.ts +2 -1
  46. package/src/client/hooks/chat/timeline/scroll-probe.test-util.ts +227 -0
  47. package/src/client/hooks/chat/timeline/scroll-reading-back.test.ts +67 -0
  48. package/src/client/hooks/chat/timeline/scroll.test.ts +84 -226
  49. package/src/client/hooks/chat/timeline/scroll.ts +34 -1
  50. package/src/client/hooks/ui/media-query.ts +41 -0
  51. package/src/client/hooks/ui/updates/android-download.test.ts +112 -0
  52. package/src/client/hooks/ui/updates/android-download.ts +143 -0
  53. package/src/client/hooks/workspace/file-tabs.test.ts +165 -0
  54. package/src/client/hooks/workspace/file-tabs.ts +24 -11
  55. package/src/client/hooks/workspace/session-state/index.ts +24 -1
  56. package/src/client/hooks/workspace/toolbar-row-width.ts +43 -0
  57. package/src/client/lib/native/back.test.ts +108 -0
  58. package/src/client/lib/native/back.ts +60 -0
  59. package/src/client/main.tsx +6 -0
  60. package/src/server/lib/fs/git-actions-branch.ts +18 -1
  61. package/src/server/lib/fs/git-actions-sync.test.ts +144 -0
  62. package/src/server/lib/fs/git-publish.test.ts +96 -0
  63. package/src/server/lib/fs/git-publish.ts +35 -3
  64. package/src/server/lib/fs/listing.test.ts +86 -0
  65. package/src/server/lib/fs/listing.ts +20 -6
  66. package/src/server/lib/updates/android-release.test.ts +138 -0
  67. package/src/server/lib/updates/android-release.ts +183 -0
  68. package/src/server/plugins/ssr.ts +14 -2
  69. package/src/server/routes/fs/read.ts +24 -9
  70. package/src/server/routes/sessions/batch-delete.test.ts +14 -11
  71. package/src/server/routes/updates/android.ts +22 -0
  72. package/src/server/routes/updates/index.ts +2 -0
  73. package/src/shared/lib/updates/shell-ua.test.ts +121 -0
  74. package/src/shared/lib/updates/shell-ua.ts +56 -0
  75. package/src/shared/lib/workspace/session-state/listeners.ts +38 -0
  76. package/src/shared/lib/workspace/session-state/replacement.test.ts +121 -0
  77. package/src/shared/lib/workspace/session-state/store.ts +13 -2
  78. package/src/shared/lib/workspace/sidebar-toolbar.test.ts +98 -0
  79. package/src/shared/lib/workspace/sidebar-toolbar.ts +78 -0
  80. package/dist/client/static/js/index-yn17t04b.css +0 -1
@@ -0,0 +1,121 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The store's WHOLESALE-REPLACEMENT notifications.
8
+ *
9
+ * `useSessionState` hands every caller a private copy that deliberately does
10
+ * not follow another component's per-slot write (a panel width is owned by one
11
+ * component). But the store also REPLACES a session's whole state — a blob
12
+ * landing, a hydrate, a spawn-adoption migrate, a delete — and there the copy's
13
+ * value was never written by anyone: it is the fallback the component read on
14
+ * the commit the session id changed. `ready` does not move across an adoption,
15
+ * so without this event every opened right-panel view collapsed at the first
16
+ * send of a new chat (measured: `true:git` → `true:files`).
17
+ *
18
+ * Split from `store.test.ts` for the per-file size ceiling.
19
+ */
20
+
21
+ import { afterEach, describe, expect, test } from 'bun:test';
22
+
23
+ import { subscribeSessionReplaced } from '@/shared/lib/workspace/session-state/listeners';
24
+ import {
25
+ clearSessionKey,
26
+ forgetSession,
27
+ getSessionValue,
28
+ hydrateSession,
29
+ loadSession,
30
+ migrateSessionState,
31
+ setSessionKey,
32
+ } from '@/shared/lib/workspace/session-state/store';
33
+
34
+ const originalWindow = Reflect.get(globalThis, 'window');
35
+ /** The runner's own fetch, reached through `Bun` so a stub leaked onto the global cannot be mistaken for it. */
36
+ const originalFetch = Bun.fetch;
37
+ const touched: string[] = [];
38
+ const unsubs: Array<() => void> = [];
39
+
40
+ /** Register a session id so `afterEach` forgets it. */
41
+ function use(id: string): string {
42
+ touched.push(id);
43
+ return id;
44
+ }
45
+
46
+ /** Follow wholesale blob replacements for a session, torn down with the rest. */
47
+ function followReplaced(sessionId: string, listener: () => void): () => void {
48
+ const unsubscribe = subscribeSessionReplaced(sessionId, listener);
49
+ unsubs.push(unsubscribe);
50
+ return unsubscribe;
51
+ }
52
+
53
+ /** Count how many times a notification reaches a listener. */
54
+ function counter(): { calls: number; listener: () => void } {
55
+ const state = { calls: 0, listener: () => { state.calls += 1; } };
56
+ return state;
57
+ }
58
+
59
+ afterEach(() => {
60
+ for (const unsubscribe of unsubs.splice(0)) unsubscribe();
61
+ for (const id of touched.splice(0)) forgetSession(id);
62
+ Reflect.set(globalThis, 'window', originalWindow);
63
+ Reflect.set(globalThis, 'fetch', originalFetch);
64
+ });
65
+
66
+ describe('session-state store: wholesale replacement notifications', () => {
67
+ test('migrateSessionState announces both ids as replaced', () => {
68
+ const from = use('new-replace');
69
+ const to = use('real-replace');
70
+ setSessionKey(from, 'layout.showRightPanel', true);
71
+ const fromProbe = counter();
72
+ const toProbe = counter();
73
+ followReplaced(from, fromProbe.listener);
74
+ followReplaced(to, toProbe.listener);
75
+
76
+ migrateSessionState(from, to);
77
+
78
+ expect(fromProbe.calls).toBe(1);
79
+ expect(toProbe.calls).toBe(1);
80
+ expect<boolean | undefined>(getSessionValue(to, 'layout.showRightPanel')).toBe(true);
81
+ });
82
+
83
+ test('a per-slot write is NOT a replacement', () => {
84
+ const id = use('slot-write');
85
+ const replaced = counter();
86
+ followReplaced(id, replaced.listener);
87
+
88
+ setSessionKey(id, 'layout.activeRightPanel', 'git');
89
+ clearSessionKey(id, 'layout.activeRightPanel');
90
+
91
+ // Only the wholesale paths (load/hydrate/migrate/forget) replace state;
92
+ // announcing a write here would make every editor and panel copy re-read
93
+ // on every keystroke elsewhere in the app.
94
+ expect(replaced.calls).toBe(0);
95
+ });
96
+
97
+ test('a blob landing, a hydrate and a delete all announce a replacement', async () => {
98
+ const id = use('replace-paths');
99
+ const probe = counter();
100
+ followReplaced(id, probe.listener);
101
+ Reflect.set(globalThis, 'window', {});
102
+ Reflect.set(globalThis, 'fetch', async () => ({ ok: true, json: async () => ({ state: { a: 1 } }) }));
103
+
104
+ await loadSession(id);
105
+ expect(probe.calls).toBe(1);
106
+
107
+ hydrateSession(id, { b: 2 });
108
+ expect(probe.calls).toBe(2);
109
+
110
+ forgetSession(id);
111
+ expect(probe.calls).toBe(3);
112
+ });
113
+
114
+ test('a subscriber on a null session id is never notified of a replacement', () => {
115
+ const probe = counter();
116
+ followReplaced(null as unknown as string, probe.listener);
117
+ const id = use('replace-null');
118
+ hydrateSession(id, { a: 1 });
119
+ expect(probe.calls).toBe(0);
120
+ });
121
+ });
@@ -68,7 +68,7 @@
68
68
  * - chat.draftAttachments attachment metadata array
69
69
  */
70
70
 
71
- import { notifySessionKey } from '@/shared/lib/workspace/session-state/listeners';
71
+ import { notifySessionKey, notifySessionReplaced } from '@/shared/lib/workspace/session-state/listeners';
72
72
 
73
73
  type SessionState = Record<string, unknown>;
74
74
 
@@ -263,8 +263,10 @@ export async function loadSession(sessionId: string): Promise<void> {
263
263
  }
264
264
  evictIfNeeded(sessionId);
265
265
  // The blob replaces every slot at once, so a follower that read the store
266
- // before it arrived (no pick yet) has to re-read.
266
+ // before it arrived (no pick yet) has to re-read — including a private copy
267
+ // held by `useSessionState`, which does not follow per-slot writes.
267
268
  notifySessionKey(sessionId);
269
+ notifySessionReplaced(sessionId);
268
270
  }
269
271
 
270
272
  /** Seed the cache synchronously (e.g. from SSR-provided data). */
@@ -274,6 +276,7 @@ export function hydrateSession(sessionId: string | null, state: SessionState): v
274
276
  touchCacheEntry(sessionId);
275
277
  evictIfNeeded(sessionId);
276
278
  notifySessionKey(sessionId);
279
+ notifySessionReplaced(sessionId);
277
280
  }
278
281
 
279
282
  /**
@@ -308,6 +311,7 @@ export function forgetSession(sessionId: string): void {
308
311
  dirtySessions.delete(sessionId);
309
312
  lastTouched.delete(sessionId);
310
313
  notifySessionKey(sessionId);
314
+ notifySessionReplaced(sessionId);
311
315
  }
312
316
 
313
317
  /**
@@ -327,4 +331,11 @@ export function migrateSessionState(fromId: string, toId: string): void {
327
331
  // The adopted slot gained the whole transient state; the transient one lost it.
328
332
  notifySessionKey(toId);
329
333
  notifySessionKey(fromId);
334
+ // Both entries were REPLACED, not written slot by slot. `ready` does not
335
+ // change across an adoption (it was already true for the pending id), so
336
+ // without this a private copy kept the fallback it read on the commit the
337
+ // URL moved — the reported "opening panels in a new chat and sending the
338
+ // first prompt closes them all".
339
+ notifySessionReplaced(toId);
340
+ notifySessionReplaced(fromId);
330
341
  }
@@ -16,12 +16,18 @@
16
16
 
17
17
  import { describe, expect, test } from 'bun:test';
18
18
  import {
19
+ MOBILE_TOOLBAR_ALWAYS_VISIBLE,
20
+ MOBILE_TOOLBAR_GAP_PX,
21
+ MOBILE_TOOLBAR_ITEM_PX,
22
+ MOBILE_TOOLBAR_PRIORITY,
23
+ MOBILE_TOOLBAR_PRIMARY_PX,
19
24
  SIDEBAR_TOOLBAR_PRIORITY,
20
25
  TOOLBAR_ALWAYS_VISIBLE,
21
26
  TOOLBAR_CLUSTER_GAP_PX,
22
27
  TOOLBAR_GAP_PX,
23
28
  TOOLBAR_ITEM_PX,
24
29
  TOOLBAR_PRIMARY_PX,
30
+ mobileToolbarCapacity,
25
31
  sidebarToolbarCapacity,
26
32
  } from '@/shared/lib/workspace/sidebar-toolbar';
27
33
 
@@ -30,6 +36,11 @@ function rowWidthFor(count: number): number {
30
36
  return TOOLBAR_PRIMARY_PX + TOOLBAR_CLUSTER_GAP_PX + count * (TOOLBAR_ITEM_PX + TOOLBAR_GAP_PX);
31
37
  }
32
38
 
39
+ /** The phone's row width at which `count` slots fit beside the labelled pill. */
40
+ function mobileRowWidthFor(count: number): number {
41
+ return MOBILE_TOOLBAR_PRIMARY_PX + count * (MOBILE_TOOLBAR_ITEM_PX + MOBILE_TOOLBAR_GAP_PX);
42
+ }
43
+
33
44
  describe('sidebar toolbar capacity', () => {
34
45
  test('an unmeasured row keeps every control', () => {
35
46
  // A collapsed sidebar and the first render both report 0; hiding controls
@@ -75,3 +86,90 @@ describe('sidebar toolbar capacity', () => {
75
86
  expect(SIDEBAR_TOOLBAR_PRIORITY as string[]).not.toContain('close');
76
87
  });
77
88
  });
89
+
90
+ /**
91
+ * The phone's drawer row, which is where the reported bug lived.
92
+ *
93
+ * A 390px phone leaves 366px of row, and the pill plus six 38px controls need
94
+ * 402 — so the row has always been over budget. It used to pay for that by
95
+ * squeezing whichever flex item could shrink, and because the label is
96
+ * `whitespace-nowrap` (min-content = the whole string) the shortfall landed
97
+ * entirely on the `Plus`: measured 15px at 430, 1px at 412 and 0px at
98
+ * 390/375/360. The row now drops a CONTROL into the `⋯` menu instead, and the
99
+ * label's 126px is budgeted for in full.
100
+ */
101
+ describe('mobile toolbar capacity', () => {
102
+ test('an unmeasured row keeps every control', () => {
103
+ // First render, and a drawer that has not been laid out: hiding controls
104
+ // there would make the row flicker as it settles.
105
+ expect(mobileToolbarCapacity(0)).toBe(MOBILE_TOOLBAR_PRIORITY.length);
106
+ });
107
+
108
+ test('the row never budgets less than the labelled pill', () => {
109
+ // The whole bug: a pill that could not afford its own label. Every legal
110
+ // phone width must keep the icon AND the text, so the reserve is the
111
+ // labelled width rather than an icon-only floor.
112
+ expect(MOBILE_TOOLBAR_PRIMARY_PX).toBeGreaterThanOrEqual(126);
113
+ });
114
+
115
+ test('a phone-width row moves the tail into the menu', () => {
116
+ // 390px viewport − the drawer's 24px of padding = 366px of row.
117
+ const capacity = mobileToolbarCapacity(366);
118
+ expect(capacity).toBeLessThan(MOBILE_TOOLBAR_PRIORITY.length);
119
+ // New Workspace is the head, so it is on the row; the scheduler is the
120
+ // tail, so it is what the menu takes first.
121
+ expect(MOBILE_TOOLBAR_PRIORITY.slice(0, capacity)).toContain('new-workspace');
122
+ expect(MOBILE_TOOLBAR_PRIORITY.slice(capacity)).toContain('scheduler');
123
+ });
124
+
125
+ test('the view controls survive a Pixel 7 width', () => {
126
+ // 412px viewport, 388px of row: archive and sort are what the phone's own
127
+ // spec asserts are on the row, so they must outrank the two MODE controls
128
+ // — each of which opens something and returns.
129
+ const capacity = mobileToolbarCapacity(388);
130
+ const onRow = MOBILE_TOOLBAR_PRIORITY.slice(0, capacity);
131
+ expect(onRow).toContain('archive');
132
+ expect(onRow).toContain('sort');
133
+ });
134
+
135
+ test('a wide drawer shows every control', () => {
136
+ expect(mobileToolbarCapacity(mobileRowWidthFor(MOBILE_TOOLBAR_PRIORITY.length))).toBe(
137
+ MOBILE_TOOLBAR_PRIORITY.length,
138
+ );
139
+ // A tablet's drawer, and the desktop-width case.
140
+ expect(mobileToolbarCapacity(600)).toBe(MOBILE_TOOLBAR_PRIORITY.length);
141
+ });
142
+
143
+ test('the menu slot is counted, so the row never overflows', () => {
144
+ // `k` slots fit; one of them is the `⋯` button, so the row draws `k − 1`
145
+ // controls plus the menu. Without that subtraction the row overflowed by
146
+ // exactly one control.
147
+ //
148
+ // Scoped to a 320px phone — the narrowest width this layout targets — and
149
+ // up: below that the floor (the primary action is never dropped) is what
150
+ // decides, and no rule can fit a labelled pill plus a control in it.
151
+ for (let width = 296; width <= 600; width += 5) {
152
+ const capacity = mobileToolbarCapacity(width);
153
+ if (capacity >= MOBILE_TOOLBAR_PRIORITY.length) continue;
154
+ const drawn = capacity + 1; // the controls plus the menu
155
+ const needed = MOBILE_TOOLBAR_PRIMARY_PX + drawn * (MOBILE_TOOLBAR_ITEM_PX + MOBILE_TOOLBAR_GAP_PX);
156
+ expect(needed).toBeLessThanOrEqual(width + MOBILE_TOOLBAR_GAP_PX);
157
+ }
158
+ });
159
+
160
+ test('at least one control survives every width', () => {
161
+ for (let width = 0; width <= 600; width += 5) {
162
+ expect(mobileToolbarCapacity(width)).toBeGreaterThanOrEqual(MOBILE_TOOLBAR_ALWAYS_VISIBLE);
163
+ }
164
+ });
165
+
166
+ test('capacity grows monotonically with the row', () => {
167
+ let previous = 0;
168
+ for (let width = 100; width <= 600; width += 10) {
169
+ const capacity = mobileToolbarCapacity(width);
170
+ expect(capacity).toBeGreaterThanOrEqual(previous);
171
+ previous = capacity;
172
+ }
173
+ expect(previous).toBe(MOBILE_TOOLBAR_PRIORITY.length);
174
+ });
175
+ });
@@ -104,3 +104,81 @@ export function sidebarToolbarCapacity(available: number): number {
104
104
  const fits = Math.floor((budget - TOOLBAR_ITEM_PX - TOOLBAR_CLUSTER_GAP_PX) / (TOOLBAR_ITEM_PX + TOOLBAR_GAP_PX));
105
105
  return Math.max(TOOLBAR_ALWAYS_VISIBLE, Math.min(count, fits));
106
106
  }
107
+
108
+ /* -------------------------------------------------------------------------
109
+ * The PHONE's drawer row.
110
+ *
111
+ * Same rule, different metrics: one row of `w-9.5` (38px) buttons with
112
+ * `space-x-2` (8px) gaps, inside a `p-3` drawer — so 46px per control against
113
+ * the desktop's 34. The phone therefore runs out of room much earlier, and it
114
+ * used to answer that by squeezing whatever flexbox could shrink — which was
115
+ * the primary button's own `Plus` icon, measured at 1px at 412 and 0px at
116
+ * 390/375/360. The row now keeps the head of this list and moves the tail into
117
+ * the same `⋯` menu the desktop toolbar uses.
118
+ *
119
+ * `search` is absent on purpose: the phone's search is a permanent field in the
120
+ * row below, not a toggle, so it is never a control that can overflow.
121
+ * ---------------------------------------------------------------------- */
122
+
123
+ /** One phone toolbar control (`w-9.5`). */
124
+ export const MOBILE_TOOLBAR_ITEM_PX = 38;
125
+ /** `space-x-2` between the primary pill and every control. */
126
+ export const MOBILE_TOOLBAR_GAP_PX = 8;
127
+ /**
128
+ * The primary pill with its label: `Plus` (15) + the `space-x-2` gap (8) + the
129
+ * whole "New Session" string (75) + `px-3.5` (28) + the borders, measured at
130
+ * 126px in the browser.
131
+ *
132
+ * This is the desktop row's rule applied to the phone: the label is budgeted
133
+ * for in FULL, so it never ellipsises in practice and never leaves the row.
134
+ * Letting it share a flex row with the icons is what produced the reported bug
135
+ * — the label's `whitespace-nowrap` min-content is the whole string, so it
136
+ * refused to shrink and the `Plus` beside it absorbed the entire shortfall,
137
+ * measured 1px at 412 and 0px at 390/375/360.
138
+ */
139
+ export const MOBILE_TOOLBAR_PRIMARY_PX = 126;
140
+ /** Floors `mobileToolbarCapacity`, so the head of the list always survives. */
141
+ export const MOBILE_TOOLBAR_ALWAYS_VISIBLE = 1;
142
+
143
+ /**
144
+ * Most important first — the tail is what the `⋯` menu takes.
145
+ *
146
+ * Refresh outranks the modes because it is the one control a stale list is
147
+ * repaired with (the desktop row floors it for the same reason). The two VIEW
148
+ * controls follow: archive and sort decide what the list shows, and they are
149
+ * the pair the phone's own spec asserts are on the row at a Pixel 7 width
150
+ * (412px, a 388px row, four controls beside the menu). The two MODE controls
151
+ * are the tail, because each one is entered deliberately and returns: the
152
+ * scheduler opens a modal, and batch selection is a transient state whose own
153
+ * action bar takes over the drawer once it is on.
154
+ */
155
+ export const MOBILE_TOOLBAR_PRIORITY: SidebarToolbarControl[] = [
156
+ 'new-workspace',
157
+ 'refresh',
158
+ 'archive',
159
+ 'sort',
160
+ 'scheduler',
161
+ 'select',
162
+ ];
163
+
164
+ /**
165
+ * How many of `MOBILE_TOOLBAR_PRIORITY` fit beside the primary pill in a row
166
+ * `available` px wide.
167
+ *
168
+ * `available` is the row's content width (the drawer's `p-3` already removed).
169
+ * A row that reports `0` keeps the full set: the capacity only ever hides a
170
+ * control it has measured no room for, so a first render cannot flicker.
171
+ *
172
+ * The `⋯` menu, when it is needed, costs one of the counted slots — the same
173
+ * 38px plus gap as any control it replaces. That is why a row that fits `k`
174
+ * slots but not all of them shows `k − 1` controls: the last one is the menu.
175
+ */
176
+ export function mobileToolbarCapacity(available: number): number {
177
+ const count = MOBILE_TOOLBAR_PRIORITY.length;
178
+ if (!(available > 0)) return count;
179
+
180
+ const step = MOBILE_TOOLBAR_ITEM_PX + MOBILE_TOOLBAR_GAP_PX;
181
+ const fits = Math.floor((available - MOBILE_TOOLBAR_PRIMARY_PX) / step);
182
+ if (fits >= count) return count;
183
+ return Math.max(MOBILE_TOOLBAR_ALWAYS_VISIBLE, Math.min(count, fits - 1));
184
+ }