@kabmo-labs/kabmo 0.4.0 → 0.5.1

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 (31) hide show
  1. package/dist/client/index.html +1 -1
  2. package/dist/client/index.js +1 -1
  3. package/dist/client/static/js/{index-twsk4wfa.js → index-za950td7.js} +665 -665
  4. package/package.json +1 -1
  5. package/src/client/components/layout/desktop-layout/TopNavbar.tsx +5 -3
  6. package/src/client/components/layout/session-sidebar/Header.tsx +2 -1
  7. package/src/client/components/layout/session-sidebar/SessionItem.tsx +12 -1
  8. package/src/client/components/mobile/LayoutWrapper.tsx +4 -0
  9. package/src/client/components/mobile/RightSidebar.tsx +23 -32
  10. package/src/client/components/mobile/SessionSidebar.tsx +120 -103
  11. package/src/client/components/mobile/mobile-main-view/Header.tsx +3 -2
  12. package/src/client/components/mobile/mobile-right-sidebar/FullDiff.tsx +3 -2
  13. package/src/client/components/mobile/mobile-right-sidebar/Header.tsx +3 -2
  14. package/src/client/components/mobile/mobile-session-sidebar/Dialogs.tsx +106 -0
  15. package/src/client/components/mobile/mobile-session-sidebar/Dock.tsx +80 -0
  16. package/src/client/components/mobile/mobile-session-sidebar/Header.tsx +7 -4
  17. package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +6 -1
  18. package/src/client/components/mobile/mobile-session-sidebar/use-dock.ts +89 -0
  19. package/src/client/components/mobile/panel-chips.test.tsx +155 -0
  20. package/src/client/components/mobile/panel-chips.tsx +74 -0
  21. package/src/client/components/workspace/chat-timeline/chat-input/Toolbar.tsx +1 -1
  22. package/src/client/components/workspace/chat-timeline/chat-input/compact.test.tsx +12 -4
  23. package/src/client/components/workspace/model-dropdown/index.tsx +3 -3
  24. package/src/client/components/workspace/model-dropdown/trigger.test.ts +123 -0
  25. package/src/client/hooks/chat/timeline/queue-rewind.test.ts +6 -0
  26. package/src/client/hooks/ui/toasts.ts +12 -1
  27. package/src/shared/lib/ui/titlebar.test.ts +124 -0
  28. package/src/shared/lib/ui/titlebar.ts +54 -0
  29. package/src/shared/lib/workspace/mobile-dock.test.ts +65 -0
  30. package/src/shared/lib/workspace/mobile-dock.ts +55 -0
  31. package/src/test-support/silent-socket.ts +69 -0
@@ -0,0 +1,80 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The phone's LEFT dock: the chip strip and the body of a placed panel.
8
+ *
9
+ * The desktop's left column is a dock — the session list plus whatever plugin
10
+ * the user placed beside it, switched by a vertical rail. A phone has no room
11
+ * for a rail, so the rail is the same horizontal chip strip the right drawer
12
+ * uses (`panel-chips.tsx`), and the body is the same `panel.render`.
13
+ *
14
+ * The session list is NOT rendered here: the phone's list is its own component
15
+ * (`MobileSessionList`), unlike the desktop's `SessionsView`, so the caller
16
+ * keeps drawing it and only hands over the OTHER views. That is also why the
17
+ * strip is drawn only while it has something else to switch to.
18
+ */
19
+
20
+ import { Suspense } from 'preact/compat';
21
+ import { PanelHostProvider } from '@kabmo-labs/kabmo-ui';
22
+ import type { ResolvedPanel } from '@/client/components/workspace/plugin-panel/catalog';
23
+ import { PluginPanelBody } from '@/client/components/workspace/plugin-panel/resolve';
24
+ import type { PanelBodyProps } from '@/client/components/workspace/panels/builtin';
25
+ import { PanelChips } from '@/client/components/mobile/panel-chips';
26
+
27
+ /** The strip row, drawn under the drawer's title. */
28
+ export function MobileDockStrip({
29
+ panels,
30
+ activeId,
31
+ onSelect,
32
+ }: {
33
+ panels: readonly ResolvedPanel[];
34
+ activeId: string;
35
+ onSelect: (id: string) => void;
36
+ }) {
37
+ return (
38
+ <div className="px-3 py-1.5 border-b border-ink/10 bg-canvas flex-shrink-0">
39
+ <PanelChips panels={panels} activeId={activeId} onSelect={onSelect} />
40
+ </div>
41
+ );
42
+ }
43
+
44
+ /**
45
+ * The body of one placed panel.
46
+ *
47
+ * A selection naming a panel that is switched off, uninstalled or still loading
48
+ * has no entry — `PluginPanelBody` says which, rather than leaving the drawer
49
+ * blank. That is the same notice the desktop dock draws.
50
+ */
51
+ export function MobileDockBody({
52
+ panelKey,
53
+ panel,
54
+ body,
55
+ }: {
56
+ panelKey: string;
57
+ panel?: ResolvedPanel;
58
+ body: PanelBodyProps;
59
+ }) {
60
+ if (!panel) {
61
+ return (
62
+ <div className="flex-1 min-h-0">
63
+ <PluginPanelBody panelKey={panelKey} />
64
+ </div>
65
+ );
66
+ }
67
+ return (
68
+ <div className="flex-1 min-h-0 overflow-hidden relative">
69
+ <Suspense
70
+ fallback={
71
+ <div className="h-full flex items-center justify-center text-ink/40">
72
+ <span className="text-xs font-mono">Loading…</span>
73
+ </div>
74
+ }
75
+ >
76
+ <PanelHostProvider value={body}>{panel.render(body)}</PanelHostProvider>
77
+ </Suspense>
78
+ </div>
79
+ );
80
+ }
@@ -1,7 +1,8 @@
1
1
  import { X } from 'lucide-preact';
2
2
  import { BrandWordmark } from '@/client/components/common/brand-wordmark';
3
+ import { titlebarPaddingLeft, titlebarPaddingRight } from '@/shared/lib/ui/titlebar';
3
4
 
4
- export function MobileSessionHeader({ onClose }: { onClose: () => void }) {
5
+ export function MobileSessionHeader({ onClose, viewLabel = 'Sessions' }: { onClose: () => void; viewLabel?: string }) {
5
6
  return (
6
7
  <div
7
8
  className="border-b border-ink/10 flex items-center justify-between px-3 flex-shrink-0 bg-canvas titlebar-drag-region select-none"
@@ -10,8 +11,10 @@ export function MobileSessionHeader({ onClose }: { onClose: () => void }) {
10
11
  // `px-3`, the toolbar's own inset: the wordmark above and the New
11
12
  // Session button below share one left edge, which the header's old
12
13
  // 14px did not.
13
- paddingLeft: 'max(0.75rem, env(safe-area-inset-left, 0px), env(titlebar-area-x, 0px))',
14
- paddingRight: 'max(0.75rem, env(safe-area-inset-right, 0px))',
14
+ paddingLeft: titlebarPaddingLeft('0.75rem', true),
15
+ // The drawer is the full window width, so on a desktop showing the
16
+ // mobile layout its close button sits under the window controls too.
17
+ paddingRight: titlebarPaddingRight('0.75rem', true),
15
18
  paddingTop: 'env(safe-area-inset-top, 0px)',
16
19
  }}
17
20
  >
@@ -20,7 +23,7 @@ export function MobileSessionHeader({ onClose }: { onClose: () => void }) {
20
23
  <BrandWordmark className="text-[16px]" />
21
24
  </span>
22
25
  <span className="text-ink/25 text-xs select-none">/</span>
23
- <span className="text-[11px] font-semibold text-ink/60 uppercase tracking-wider font-mono">Sessions</span>
26
+ <span className="text-[11px] font-semibold text-ink/60 uppercase tracking-wider font-mono">{viewLabel}</span>
24
27
  </div>
25
28
 
26
29
  <button
@@ -157,7 +157,12 @@ export function MobileSessionRow({
157
157
  <Hourglass size={13} className="text-ink/60 animate-pulse" />
158
158
  </span>
159
159
  ) : status === 'stream' ? (
160
- <Loader2 size={13} className="text-ink/50 animate-spin" />
160
+ // Same label the desktop row carries, for the same reason: the
161
+ // phone draws the identical cue, and an unlabelled spinner is
162
+ // both unreachable to a screen reader and unassertable.
163
+ <span role="status" aria-label="Running">
164
+ <Loader2 size={13} className="text-ink/50 animate-spin" />
165
+ </span>
161
166
  ) : status ? (
162
167
  <Check size={13} className="text-ink/50" />
163
168
  ) : null}
@@ -0,0 +1,89 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The phone drawer's LEFT dock state.
8
+ *
9
+ * The desktop's left column is a dock — the session list plus whatever plugin
10
+ * panel the user placed beside it, switched by a vertical rail. A phone has no
11
+ * room for a rail, so the same catalog is switched by the chip strip the right
12
+ * drawer uses, and everything that decides WHICH view is on screen lives here:
13
+ *
14
+ * - the catalog is `usePanelCatalog('left')`, the dock's own list (`sessions`
15
+ * first, then the placed plugins in placement order), so this drawer and the
16
+ * desktop column cannot disagree about what lives here;
17
+ * - the selection is PERSISTED per session, like the right drawer's tab, because
18
+ * the drawer unmounts when it closes;
19
+ * - a stored id naming a panel that has moved back to the right resolves to the
20
+ * session list, while one naming nothing at all is returned as-is so the body
21
+ * can say WHY rather than showing a blank column.
22
+ */
23
+
24
+ import type { PanelBodyProps } from '@/client/components/workspace/panels/builtin';
25
+ import { usePanelCatalog, useLeftDockView } from '@/client/components/workspace/plugin-panel/catalog';
26
+ import type { ResolvedPanel } from '@/client/components/workspace/plugin-panel/catalog';
27
+ import { useLeftDock } from '@/client/hooks/workspace/left-dock';
28
+ import { useSessionState } from '@/client/hooks/workspace/session-state';
29
+ import { SESSIONS_VIEW_ID } from '@/shared/lib/workspace/left-dock';
30
+
31
+ export interface MobileDockState {
32
+ /** The strip's chips: `sessions` and every placed panel. */
33
+ catalog: ResolvedPanel[];
34
+ /** The placed panels only — what makes the strip worth drawing. */
35
+ placed: ResolvedPanel[];
36
+ activeView: string;
37
+ setActiveView: (id: string) => void;
38
+ /** True while the session list is the view on screen. */
39
+ showingSessions: boolean;
40
+ activePanel: ResolvedPanel | undefined;
41
+ /** Whether the rail is on at all. */
42
+ enabled: boolean;
43
+ }
44
+
45
+ export function useMobileDock(): MobileDockState {
46
+ const dock = useLeftDock();
47
+ const catalog = usePanelCatalog('left');
48
+ const [stored, setStored] = useSessionState<string>('layout.mobileLeftPanel', SESSIONS_VIEW_ID);
49
+ const activeView = useLeftDockView(stored, dock.enabled);
50
+ const placed = catalog.filter((panel) => panel.id !== SESSIONS_VIEW_ID);
51
+
52
+ return {
53
+ catalog,
54
+ placed,
55
+ activeView,
56
+ setActiveView: setStored,
57
+ showingSessions: activeView === SESSIONS_VIEW_ID,
58
+ activePanel: catalog.find((panel) => panel.id === activeView),
59
+ enabled: dock.enabled,
60
+ };
61
+ }
62
+
63
+ /**
64
+ * The props a placed panel's body receives.
65
+ *
66
+ * The drawer draws its own title row, so `showHeader` is false — a view's own
67
+ * header would be a second one, the same rule the right drawer follows.
68
+ */
69
+ export function dockBodyProps(input: {
70
+ sessionId: number | string | null;
71
+ refreshKey: number;
72
+ rootPath?: string;
73
+ onRefresh?: () => void;
74
+ onOpenFile?: (file: unknown) => void;
75
+ onClose: () => void;
76
+ }): PanelBodyProps {
77
+ return {
78
+ className: 'w-full h-full',
79
+ sessionId: input.sessionId === null ? null : String(input.sessionId),
80
+ enabled: true,
81
+ active: true,
82
+ refreshKey: input.refreshKey,
83
+ ...(input.rootPath ? { rootPath: input.rootPath } : {}),
84
+ ...(input.onRefresh ? { onRefresh: input.onRefresh } : {}),
85
+ ...(input.onOpenFile ? { onOpenFile: input.onOpenFile } : {}),
86
+ onClose: input.onClose,
87
+ showHeader: false,
88
+ };
89
+ }
@@ -0,0 +1,155 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The phone's panel strip and dock body — the LEFT drawer's half of the panel
8
+ * system, which did not exist before.
9
+ *
10
+ * Three properties, and each was the reported symptom:
11
+ *
12
+ * - A placed plugin panel gets a CHIP in the left drawer. Before this the
13
+ * placement was stored, the desktop drew a button for it, and a phone had no
14
+ * surface at all — the panel was reachable only through the right drawer.
15
+ * - The chip that is ON is the one marked pressed, so the drawer says which
16
+ * panel it is showing.
17
+ * - A stored view naming a panel that no longer resolves draws the NOTICE, not
18
+ * a blank column, and keeps the strip so the session list is reachable.
19
+ *
20
+ * Rendered with `h()` against happy-dom, with the kit's services installed so a
21
+ * plugin row resolves its identity the way the real host resolves it.
22
+ */
23
+
24
+ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from 'bun:test';
25
+ import { Window } from 'happy-dom';
26
+ import { h, render } from 'preact';
27
+ import { act } from 'preact/test-utils';
28
+ import { configureUiKit } from '@kabmo-labs/kabmo-ui';
29
+ import { makeUiKitServices } from '@kabmo-labs/kabmo-sdk/testing';
30
+ import { PanelChips } from '@/client/components/mobile/panel-chips';
31
+ import { MobileDockBody } from '@/client/components/mobile/mobile-session-sidebar/Dock';
32
+ import type { ResolvedPanel } from '@/client/components/workspace/plugin-panel/catalog';
33
+ import type { PanelBodyProps } from '@/client/components/workspace/panels/builtin';
34
+ import { restoreRealtime, silenceRealtime } from '@/test-support/silent-socket';
35
+
36
+ const DOM_GLOBALS = ['window', 'document', 'navigator', 'Node', 'Element', 'HTMLElement', 'Event', 'MouseEvent'] as const;
37
+ const nativeGlobals: Partial<Record<(typeof DOM_GLOBALS)[number], unknown>> = {};
38
+
39
+ let container: HTMLElement;
40
+
41
+ beforeAll(() => {
42
+ configureUiKit(makeUiKitServices());
43
+ });
44
+
45
+ beforeEach(() => {
46
+ const win = new Window({ url: 'http://localhost' });
47
+ const target = globalThis as unknown as Record<string, unknown>;
48
+ for (const key of DOM_GLOBALS) {
49
+ if (!(key in nativeGlobals)) nativeGlobals[key] = target[key];
50
+ target[key] = (win as unknown as Record<string, unknown>)[key];
51
+ }
52
+ // The dock's body renders panels, and a real panel reads a realtime topic on
53
+ // mount. This window's origin is `localhost`, where nothing listens, so a real
54
+ // dial fails asynchronously — after this suite has restored the globals, which
55
+ // reports as an unhandled error in the NEXT file.
56
+ silenceRealtime();
57
+ container = document.createElement('div');
58
+ document.body.appendChild(container);
59
+ });
60
+
61
+ afterEach(() => {
62
+ render(null, container);
63
+ container.remove();
64
+ restoreRealtime();
65
+ });
66
+
67
+ afterAll(() => {
68
+ const target = globalThis as unknown as Record<string, unknown>;
69
+ for (const key of DOM_GLOBALS) {
70
+ if (key in nativeGlobals) target[key] = nativeGlobals[key];
71
+ else delete target[key];
72
+ }
73
+ });
74
+
75
+ /** A catalog entry with only what the strip and the body read. */
76
+ function panel(id: string, overrides: Partial<ResolvedPanel> = {}): ResolvedPanel {
77
+ return {
78
+ id,
79
+ side: id === 'sessions' ? 'left' : 'left',
80
+ title: `${id} title`,
81
+ label: id,
82
+ name: '',
83
+ builtin: true,
84
+ requiresWorkspace: false,
85
+ liveWhileHidden: false,
86
+ minWidth: 200,
87
+ defaultWidth: 280,
88
+ defaultFraction: 0.25,
89
+ render: () => h('div', { 'data-rendered': id }, `body of ${id}`),
90
+ ...overrides,
91
+ };
92
+ }
93
+
94
+ const body: PanelBodyProps = { enabled: true, active: true, refreshKey: 0 };
95
+
96
+ async function mountStrip(panels: ResolvedPanel[], activeId: string, onSelect = (_id: string) => {}) {
97
+ await act(async () => {
98
+ render(h(PanelChips, { panels, activeId, onSelect }), container);
99
+ });
100
+ }
101
+
102
+ describe('PanelChips', () => {
103
+ test('draws one chip per panel, in the order it is given', async () => {
104
+ await mountStrip([panel('sessions'), panel('plugin:demo:rightPanel', { builtin: false, name: 'demo', title: 'Demo — demo' })], 'sessions');
105
+ const labels = [...container.querySelectorAll('button')].map((el) => el.getAttribute('title'));
106
+ expect(labels).toEqual(['sessions title', 'Demo — demo']);
107
+ });
108
+
109
+ test('the active chip is the pressed one, and only it', async () => {
110
+ await mountStrip([panel('sessions'), panel('plugin:demo:rightPanel', { builtin: false, name: 'demo' })], 'plugin:demo:rightPanel');
111
+ const pressed = [...container.querySelectorAll('button')].map((el) => el.getAttribute('aria-pressed'));
112
+ expect(pressed).toEqual(['false', 'true']);
113
+ });
114
+
115
+ test('a click hands back the panel id, which is what the drawer stores', async () => {
116
+ const picked: string[] = [];
117
+ await mountStrip([panel('sessions'), panel('plugin:demo:rightPanel', { builtin: false, name: 'demo' })], 'sessions', (id) => picked.push(id));
118
+ const chip = [...container.querySelectorAll('button')].at(-1) as HTMLButtonElement;
119
+ await act(async () => chip.click());
120
+ expect(picked).toEqual(['plugin:demo:rightPanel']);
121
+ });
122
+
123
+ test('the Source Control dot is drawn only where it is asked for', async () => {
124
+ // The left catalog never holds a built-in view, so the left drawer passes
125
+ // nothing and must not grow a dot it can never mean.
126
+ await mountStrip([panel('git', { title: 'Source Control', label: 'GIT' })], 'git');
127
+ expect(container.querySelector('[title="Uncommitted changes"]')).toBeNull();
128
+ render(null, container);
129
+ await act(async () => {
130
+ render(h(PanelChips, { panels: [panel('git', { title: 'Source Control', label: 'GIT' })], activeId: 'git', onSelect: () => {}, hasGitChanges: true }), container);
131
+ });
132
+ expect(container.querySelector('[title="Uncommitted changes"]')).not.toBeNull();
133
+ });
134
+ });
135
+
136
+ describe('MobileDockBody', () => {
137
+ test('renders the panel it is handed', async () => {
138
+ await act(async () => {
139
+ render(h(MobileDockBody, { panelKey: 'plugin:demo:rightPanel', panel: panel('plugin:demo:rightPanel', { builtin: false, name: 'demo' }), body }), container);
140
+ });
141
+ expect(container.querySelector('[data-rendered="plugin:demo:rightPanel"]')).not.toBeNull();
142
+ });
143
+
144
+ test('a panel that does not resolve says WHY instead of drawing a blank column', async () => {
145
+ // No provider is installed for this key, so the notice reports "no plugin
146
+ // with this id is installed" — the same component the desktop dock uses.
147
+ await act(async () => {
148
+ render(h(MobileDockBody, { panelKey: 'plugin:gone:rightPanel', panel: undefined, body }), container);
149
+ });
150
+ expect(container.textContent).toContain('Plugin unavailable');
151
+ // The notice names the PLUGIN, not the instance key, and gives the reason.
152
+ expect(container.textContent).toContain('gone');
153
+ expect(container.textContent).toContain('no plugin with this id is installed');
154
+ });
155
+ });
@@ -0,0 +1,74 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The phone's panel tab strip — ONE component, drawn by both drawers.
8
+ *
9
+ * A phone has no rail: the desktop's vertical activity bar is a horizontal
10
+ * strip of chips here. The right drawer had this from the start and the left
11
+ * drawer (the session list) did not, so a plugin panel placed on the left was
12
+ * reachable only through the RIGHT drawer's `all` catalog — the placement
13
+ * existed and nothing on the left ever showed it.
14
+ *
15
+ * Both drawers render this, so the two cannot drift in label, active state or
16
+ * the Source Control dot. The strip is scrollable rather than wrapping: a phone
17
+ * drawer is one row tall, and a wrapped strip would eat the list's height.
18
+ */
19
+
20
+ import type { ResolvedPanel } from '@/client/components/workspace/plugin-panel/catalog';
21
+ import { PluginMark } from '@/client/components/settings/categories/panel-plugins/PluginMark';
22
+ import { PANEL_MARK_SIZE } from '@/shared/lib/workspace/right-panels';
23
+
24
+ interface PanelChipsProps {
25
+ panels: readonly ResolvedPanel[];
26
+ activeId: string;
27
+ onSelect: (id: string) => void;
28
+ /**
29
+ * Draw the Source Control dot on the `git` chip. The left catalog never holds
30
+ * a built-in view, so only the right drawer passes this.
31
+ */
32
+ hasGitChanges?: boolean;
33
+ }
34
+
35
+ export function PanelChips({ panels, activeId, onSelect, hasGitChanges = false }: PanelChipsProps) {
36
+ return (
37
+ <div className="flex items-center space-x-1.5 overflow-x-auto no-scrollbar py-1">
38
+ {panels.map((panel) => {
39
+ const active = panel.id === activeId;
40
+ return (
41
+ <button
42
+ key={panel.id}
43
+ type="button"
44
+ onClick={() => onSelect(panel.id)}
45
+ aria-pressed={active}
46
+ // The name is the panel's TITLE, never its visible label. The label
47
+ // only renders while the chip is active, so without this the same
48
+ // button is called "Resources — stats" before a click and
49
+ // "Resources" after it — a control that renames itself when used is
50
+ // unusable to a screen reader and unfindable to a spec.
51
+ aria-label={panel.title}
52
+ className={`relative flex items-center transition-all cursor-pointer ${
53
+ active
54
+ ? 'space-x-1.5 px-3 py-1.5 rounded-lg text-xs font-semibold bg-ink text-canvas shadow-sm'
55
+ : 'p-2 rounded-lg text-ink/70 hover:bg-ink/5'
56
+ }`}
57
+ title={panel.title}
58
+ >
59
+ {panel.builtin ? panel.icon : <PluginMark name={panel.name} iconUrl={panel.iconUrl} size={PANEL_MARK_SIZE} />}
60
+ {active && (
61
+ <span className={panel.id === 'git' ? 'tracking-wide' : undefined}>{panel.label}</span>
62
+ )}
63
+ {panel.id === 'git' && hasGitChanges && (
64
+ <span
65
+ className="absolute top-1 right-1 w-1.5 h-1.5 rounded-full bg-info"
66
+ title="Uncommitted changes"
67
+ />
68
+ )}
69
+ </button>
70
+ );
71
+ })}
72
+ </div>
73
+ );
74
+ }
@@ -115,7 +115,7 @@ export function ComposerToolbar({
115
115
  selectedModel={selectedModel ?? undefined}
116
116
  onSelectModel={onSelectModel}
117
117
  onThinkingLevelChange={onThinkingLevelChange}
118
- className={isMobile ? 'min-w-0 max-w-[58%]' : undefined}
118
+ className="min-w-0"
119
119
  />
120
120
  )}
121
121
 
@@ -18,12 +18,12 @@
18
18
  * (or a focused composer) never enters compact at all.
19
19
  */
20
20
 
21
- import { afterAll, afterEach, beforeAll, describe, expect, test } from 'bun:test';
21
+ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from 'bun:test';
22
22
  import { Window } from 'happy-dom';
23
23
  import { h, render } from 'preact';
24
24
  import { act } from 'preact/test-utils';
25
25
  import { ChatInput } from '@/client/components/workspace/chat-timeline/chat-input';
26
- import { resetRealtimeClient } from '@/shared/lib/realtime/client';
26
+ import { restoreRealtime, silenceRealtime } from '@/test-support/silent-socket';
27
27
 
28
28
  const DOM_GLOBALS = ['window', 'document', 'navigator', 'Node', 'Element', 'HTMLElement', 'HTMLInputElement', 'HTMLTextAreaElement', 'Event', 'MouseEvent', 'KeyboardEvent', 'FocusEvent'] as const;
29
29
  const nativeGlobals: Partial<Record<(typeof DOM_GLOBALS)[number], unknown>> = {};
@@ -47,14 +47,22 @@ afterAll(() => {
47
47
 
48
48
  let container: HTMLElement | null = null;
49
49
 
50
+ beforeEach(() => {
51
+ // Per case, because `restoreRealtime` clears the constructor: the composer's
52
+ // model picker subscribes on every mount, and this origin has no listener to
53
+ // reach, so a real dial would fail after the globals are restored.
54
+ silenceRealtime();
55
+ });
56
+
50
57
  afterEach(() => {
51
58
  container?.remove();
52
59
  container = null;
53
60
  // The composer's model picker subscribes to the realtime topic, and its
54
61
  // reconnect timer outlives this file's DOM — it then fires into a torn-down
55
62
  // `window` and surfaces as an unhandled error in whichever test runs next.
56
- // Resetting the client is what the app's own suites do for the same reason.
57
- resetRealtimeClient();
63
+ // `restoreRealtime` resets the client AND puts the real constructor back, so
64
+ // the silence does not follow this file into the next one.
65
+ restoreRealtime();
58
66
  });
59
67
 
60
68
  interface MountOptions {
@@ -239,10 +239,10 @@ export function ModelDropdown({
239
239
  />
240
240
  )}
241
241
  </span>
242
- <span className="flex items-center space-x-1 min-w-0 max-w-[280px]">
243
- <span className="text-ink/50 font-normal truncate">{selectedModel.provider}</span>
242
+ <span className="flex items-center space-x-1 min-w-0">
243
+ <span className="shrink-0 text-ink/50 font-normal">{selectedModel.provider}</span>
244
244
  <span className="text-ink/30 flex-shrink-0">•</span>
245
- <span className="truncate">{selectedModel.name}</span>
245
+ <span className="min-w-0 flex-1 truncate">{selectedModel.name}</span>
246
246
  </span>
247
247
  </>
248
248
  ) : (
@@ -0,0 +1,123 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The composer trigger's provider/model label.
8
+ *
9
+ * The trigger showed `kenari • Deepseek-V4-1-Flash` as `kena… • Deepse…` on a
10
+ * 412px phone with 64px of unused room beside it. Two hardcoded caps were the
11
+ * cause, and neither could see the space it was sitting in:
12
+ *
13
+ * - the WRAPPER carried `max-w-[58%]` on the phone, a share of the toolbar
14
+ * that ignores the four controls to its right;
15
+ * - the LABEL carried `max-w-[280px]`, a fixed budget that has nothing to do
16
+ * with the panel width (the chat column is 320px wide on a 1440px screen).
17
+ *
18
+ * Measured before/after at a 412px viewport: the label went 117px → 210px
19
+ * available and the model name 80px → 110px, with the toolbar reporting no
20
+ * overflow and the action strip still ending 12px inside its own edge.
21
+ *
22
+ * The rule this file pins is the one that has to hold at EVERY width: the
23
+ * model name is the only thing that gives up width. The provider is an
24
+ * identity ("kenari" and "kenari-jatis" are different accounts), so shrinking
25
+ * it first made two accounts render as the same `kena…`.
26
+ */
27
+
28
+ import { afterAll, afterEach, beforeEach, describe, expect, test } from 'bun:test';
29
+ import { Window } from 'happy-dom';
30
+ import { h, render } from 'preact';
31
+ import { act } from 'preact/test-utils';
32
+ import { ModelDropdown } from '@/client/components/workspace/model-dropdown/index';
33
+ import type { AIModelOption } from '@/shared/types';
34
+ import { installDomGlobals, restoreDomGlobals } from '@/test-support/pristine-globals';
35
+ import { restoreRealtime, silenceRealtime } from '@/test-support/silent-socket';
36
+
37
+ const MODEL: AIModelOption = {
38
+ id: 'deepseek-v4-1-flash',
39
+ name: 'Deepseek-V4-1-Flash',
40
+ provider: 'kenari',
41
+ };
42
+
43
+ let container: HTMLElement;
44
+
45
+ beforeEach(() => {
46
+ installDomGlobals(new Window());
47
+ // The picker reads the `models` topic on mount, and this window has no origin
48
+ // (`about:blank`), so a real dial would target `ws:///api/realtime/ws` and
49
+ // fail asynchronously — after this suite has restored the globals, which
50
+ // reports as an unhandled error in the NEXT file. The channel is not what this
51
+ // file measures.
52
+ silenceRealtime();
53
+ container = document.createElement('div');
54
+ document.body.appendChild(container);
55
+ });
56
+
57
+ afterEach(() => {
58
+ render(null, container);
59
+ container.remove();
60
+ restoreRealtime();
61
+ restoreDomGlobals();
62
+ });
63
+
64
+ afterAll(() => restoreDomGlobals());
65
+
66
+ function mount(className: string, model: AIModelOption = MODEL): HTMLElement {
67
+ act(() => {
68
+ render(h(ModelDropdown, { selectedModel: model, className }), container);
69
+ });
70
+ return container;
71
+ }
72
+
73
+ /** The trigger button is the only one carrying the provider • model title. */
74
+ function trigger(el: HTMLElement): HTMLButtonElement {
75
+ const button = el.querySelector('button[title]') as HTMLButtonElement | null;
76
+ if (!button) throw new Error('model trigger button not found');
77
+ return button;
78
+ }
79
+
80
+ function labelParts(el: HTMLElement): { provider: HTMLElement; separator: HTMLElement; model: HTMLElement } {
81
+ const provider = el.querySelector('button[title] > span > span') as HTMLElement | null;
82
+ const separator = provider?.nextElementSibling as HTMLElement | null;
83
+ const model = separator?.nextElementSibling as HTMLElement | null;
84
+ if (!provider || !separator || !model) throw new Error('label parts not found');
85
+ return { provider, separator, model };
86
+ }
87
+
88
+ describe('composer model trigger label', () => {
89
+ test('grows to its content instead of a fixed px budget', () => {
90
+ // `max-w-[280px]` is what truncated a 216px model name inside a 968px
91
+ // toolbar; the cap has to come from the flex parent, not from a constant.
92
+ const { provider, model } = labelParts(mount('min-w-0'));
93
+ const label = provider.parentElement!;
94
+
95
+ expect(label.className).not.toContain('max-w-');
96
+ expect(provider.className).not.toContain('truncate');
97
+ expect(model.className).toContain('truncate');
98
+ });
99
+
100
+ test('keeps the provider at its natural width and lets the model shrink', () => {
101
+ // Two accounts (`kenari` / `kenari-jatis`) render identically once the
102
+ // provider truncates, which is why it must not be a shrink target.
103
+ const { provider, separator, model } = labelParts(mount('min-w-0'));
104
+
105
+ expect(provider.className).toContain('shrink-0');
106
+ expect(separator.className).toContain('shrink-0');
107
+ expect(model.className).toContain('flex-1');
108
+ expect(model.className).toContain('min-w-0');
109
+ });
110
+
111
+ test('the trigger itself may shrink on every layout', () => {
112
+ // The phone-only `max-w-[58%]` made the trigger a property of the layout
113
+ // rather than of the room beside it: a 479px viewport left 64px unused.
114
+ const button = trigger(mount('min-w-0'));
115
+ expect(button.className).toContain('min-w-0');
116
+ expect(button.className).not.toContain('max-w-[58%]');
117
+ });
118
+
119
+ test('names both halves in the title, so an ellipsis never hides the identity', () => {
120
+ const button = trigger(mount('min-w-0'));
121
+ expect(button.title).toBe('kenari • Deepseek-V4-1-Flash');
122
+ });
123
+ });
@@ -28,6 +28,7 @@ import type { QueuedMessage } from '@/shared/types';
28
28
  import { SessionStateContext } from '@/client/hooks/workspace/session-state/context';
29
29
  import { useChatTimelineQueue, type ChatTimelineQueueResult } from '@/client/hooks/chat/timeline/queue';
30
30
  import { requestRewind } from '@/client/hooks/chat/timeline/rewind';
31
+ import { restoreRealtime, silenceRealtime } from '@/test-support/silent-socket';
31
32
 
32
33
  const DOM_GLOBALS = ['window', 'document', 'navigator', 'Node', 'Element', 'HTMLElement', 'Event', 'CustomEvent'] as const;
33
34
  /** The runner's own globals, restored on teardown so later files still have them. */
@@ -80,12 +81,17 @@ afterAll(() => {
80
81
  }
81
82
  });
82
83
  beforeEach(() => {
84
+ // The queue hook subscribes to its session topic on mount, and this origin has
85
+ // no listener: a real dial would fail asynchronously, after the globals are
86
+ // restored. Per case, because `restoreRealtime` clears the constructor.
87
+ silenceRealtime();
83
88
  calls.length = 0;
84
89
  canonical = [];
85
90
  gate = null;
86
91
  stubFetch();
87
92
  });
88
93
  afterEach(() => {
94
+ restoreRealtime();
89
95
  if (container) render(null, container);
90
96
  container?.remove();
91
97
  latest = null;