@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.
- package/dist/client/index.html +1 -1
- package/dist/client/index.js +285 -285
- package/dist/client/static/js/{index-enkbbffs.js → index-8x5j8zrr.js} +671 -671
- package/dist/client/static/js/index-pdp5dwc8.css +1 -0
- package/package.json +1 -1
- package/src/client/bootstrap.d.ts +17 -0
- package/src/client/components/common/header-controls/MoreMenu.tsx +136 -0
- package/src/client/components/common/header-controls/index.test.tsx +77 -11
- package/src/client/components/common/header-controls/index.tsx +53 -4
- package/src/client/components/common/session-state-provider/index.test.ts +22 -1
- package/src/client/components/layout/session-sidebar/Footer.tsx +24 -2
- package/src/client/components/layout/session-sidebar/OverflowMenu.tsx +23 -5
- package/src/client/components/layout/session-sidebar/Toolbar.tsx +2 -33
- package/src/client/components/layout/session-sidebar/index.tsx +8 -0
- package/src/client/components/mobile/LayoutWrapper.tsx +32 -1
- package/src/client/components/mobile/SessionSidebar.tsx +8 -0
- package/src/client/components/mobile/mobile-main-view/Header.tsx +27 -5
- package/src/client/components/mobile/mobile-session-sidebar/Footer.tsx +29 -1
- package/src/client/components/mobile/mobile-session-sidebar/Toolbar.test.ts +104 -0
- package/src/client/components/mobile/mobile-session-sidebar/Toolbar.tsx +166 -106
- package/src/client/components/workspace/chat-timeline/ComposerDock.tsx +9 -0
- package/src/client/components/workspace/chat-timeline/TimelineBody.tsx +6 -0
- package/src/client/components/workspace/chat-timeline/chat-input/AttachmentToolbar.tsx +4 -1
- package/src/client/components/workspace/chat-timeline/chat-input/DropOverlay.tsx +1 -1
- package/src/client/components/workspace/chat-timeline/chat-input/GoalBanner.tsx +6 -1
- package/src/client/components/workspace/chat-timeline/chat-input/Toolbar.tsx +4 -1
- package/src/client/components/workspace/chat-timeline/chat-input/VoiceNotice.tsx +2 -2
- package/src/client/components/workspace/chat-timeline/chat-input/expansion.test.ts +72 -1
- package/src/client/components/workspace/chat-timeline/chat-input/expansion.ts +28 -4
- package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +20 -2
- package/src/client/components/workspace/chat-timeline/chat-input/props.ts +7 -0
- package/src/client/components/workspace/chat-timeline/index.tsx +9 -3
- package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/OrphanAsks.test.tsx +127 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/OrphanAsks.tsx +71 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/PluginPendingInteractions.test.ts +1 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/index.test.ts +2 -2
- package/src/client/components/workspace/git-panel/BranchToolbar.tsx +4 -0
- package/src/client/components/workspace/git-panel/index.test.ts +15 -0
- package/src/client/components/workspace/search-panel/Body.test.ts +149 -0
- package/src/client/components/workspace/search-panel/Body.tsx +53 -9
- package/src/client/data/settings/omp-schema.json +89 -4
- package/src/client/hooks/chat/timeline/ask-frames-grouped.test.ts +46 -2
- package/src/client/hooks/chat/timeline/ask-frames.test.ts +1 -1
- package/src/client/hooks/chat/timeline/ask-frames.ts +79 -29
- package/src/client/hooks/chat/timeline/index.ts +2 -1
- package/src/client/hooks/chat/timeline/scroll-probe.test-util.ts +227 -0
- package/src/client/hooks/chat/timeline/scroll-reading-back.test.ts +67 -0
- package/src/client/hooks/chat/timeline/scroll.test.ts +84 -226
- package/src/client/hooks/chat/timeline/scroll.ts +34 -1
- package/src/client/hooks/ui/media-query.ts +41 -0
- package/src/client/hooks/ui/updates/android-download.test.ts +112 -0
- package/src/client/hooks/ui/updates/android-download.ts +143 -0
- package/src/client/hooks/workspace/file-tabs.test.ts +165 -0
- package/src/client/hooks/workspace/file-tabs.ts +24 -11
- package/src/client/hooks/workspace/session-state/index.ts +24 -1
- package/src/client/hooks/workspace/toolbar-row-width.ts +43 -0
- package/src/client/lib/native/back.test.ts +108 -0
- package/src/client/lib/native/back.ts +60 -0
- package/src/client/main.tsx +6 -0
- package/src/server/lib/fs/git-actions-branch.ts +18 -1
- package/src/server/lib/fs/git-actions-sync.test.ts +144 -0
- package/src/server/lib/fs/git-publish.test.ts +96 -0
- package/src/server/lib/fs/git-publish.ts +35 -3
- package/src/server/lib/fs/listing.test.ts +86 -0
- package/src/server/lib/fs/listing.ts +20 -6
- package/src/server/lib/updates/android-release.test.ts +138 -0
- package/src/server/lib/updates/android-release.ts +183 -0
- package/src/server/plugins/ssr.ts +14 -2
- package/src/server/routes/fs/read.ts +24 -9
- package/src/server/routes/sessions/batch-delete.test.ts +14 -11
- package/src/server/routes/updates/android.ts +22 -0
- package/src/server/routes/updates/index.ts +2 -0
- package/src/shared/lib/updates/shell-ua.test.ts +121 -0
- package/src/shared/lib/updates/shell-ua.ts +56 -0
- package/src/shared/lib/workspace/session-state/listeners.ts +38 -0
- package/src/shared/lib/workspace/session-state/replacement.test.ts +121 -0
- package/src/shared/lib/workspace/session-state/store.ts +13 -2
- package/src/shared/lib/workspace/sidebar-toolbar.test.ts +98 -0
- package/src/shared/lib/workspace/sidebar-toolbar.ts +78 -0
- 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
|
+
}
|