@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.
- package/dist/client/index.html +1 -1
- package/dist/client/index.js +1 -1
- package/dist/client/static/js/{index-twsk4wfa.js → index-za950td7.js} +665 -665
- package/package.json +1 -1
- package/src/client/components/layout/desktop-layout/TopNavbar.tsx +5 -3
- package/src/client/components/layout/session-sidebar/Header.tsx +2 -1
- package/src/client/components/layout/session-sidebar/SessionItem.tsx +12 -1
- package/src/client/components/mobile/LayoutWrapper.tsx +4 -0
- package/src/client/components/mobile/RightSidebar.tsx +23 -32
- package/src/client/components/mobile/SessionSidebar.tsx +120 -103
- package/src/client/components/mobile/mobile-main-view/Header.tsx +3 -2
- package/src/client/components/mobile/mobile-right-sidebar/FullDiff.tsx +3 -2
- package/src/client/components/mobile/mobile-right-sidebar/Header.tsx +3 -2
- package/src/client/components/mobile/mobile-session-sidebar/Dialogs.tsx +106 -0
- package/src/client/components/mobile/mobile-session-sidebar/Dock.tsx +80 -0
- package/src/client/components/mobile/mobile-session-sidebar/Header.tsx +7 -4
- package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +6 -1
- package/src/client/components/mobile/mobile-session-sidebar/use-dock.ts +89 -0
- package/src/client/components/mobile/panel-chips.test.tsx +155 -0
- package/src/client/components/mobile/panel-chips.tsx +74 -0
- package/src/client/components/workspace/chat-timeline/chat-input/Toolbar.tsx +1 -1
- package/src/client/components/workspace/chat-timeline/chat-input/compact.test.tsx +12 -4
- package/src/client/components/workspace/model-dropdown/index.tsx +3 -3
- package/src/client/components/workspace/model-dropdown/trigger.test.ts +123 -0
- package/src/client/hooks/chat/timeline/queue-rewind.test.ts +6 -0
- package/src/client/hooks/ui/toasts.ts +12 -1
- package/src/shared/lib/ui/titlebar.test.ts +124 -0
- package/src/shared/lib/ui/titlebar.ts +54 -0
- package/src/shared/lib/workspace/mobile-dock.test.ts +65 -0
- package/src/shared/lib/workspace/mobile-dock.ts +55 -0
- 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: '
|
|
14
|
-
|
|
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">
|
|
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
|
-
|
|
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=
|
|
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 {
|
|
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
|
-
//
|
|
57
|
-
|
|
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
|
|
243
|
-
<span className="text-ink/50 font-normal
|
|
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;
|