@kabmo-labs/kabmo 0.5.3 → 0.6.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 +359 -350
- package/dist/client/static/js/{index-9js9mrmj.js → index-aq0e78gv.js} +704 -704
- package/dist/client/static/js/index-nzyjf92m.css +1 -0
- package/marketplace/plugins/stats/README.md +26 -4
- package/marketplace/plugins/stats/package.json +2 -2
- package/marketplace/plugins/stats/src/HeaderReadout.test.tsx +157 -0
- package/marketplace/plugins/stats/src/HeaderReadout.tsx +115 -0
- package/marketplace/plugins/stats/src/app.tsx +11 -0
- package/marketplace/plugins/stats/src/format.test.ts +27 -1
- package/marketplace/plugins/stats/src/format.ts +41 -0
- package/package.json +2 -2
- package/src/client/bootstrap.d.ts +11 -0
- package/src/client/components/common/header-controls/MoreMenu.tsx +38 -1
- package/src/client/components/common/header-controls/index.tsx +9 -2
- package/src/client/components/common/header-controls/plugin-entry.test.tsx +229 -0
- package/src/client/components/common/header-panel-buttons/entries.test.ts +46 -0
- package/src/client/components/common/header-panel-buttons/entries.ts +109 -0
- package/src/client/components/common/header-panel-buttons/index.tsx +71 -38
- package/src/client/components/common/server-switcher/index.test.tsx +150 -0
- package/src/client/components/common/server-switcher/index.tsx +281 -0
- package/src/client/components/common/window-controls/index.test.tsx +113 -0
- package/src/client/components/common/window-controls/index.tsx +66 -0
- package/src/client/components/layout/desktop-layout/LeftColumn.tsx +3 -6
- package/src/client/components/layout/desktop-layout/TopNavbar.tsx +59 -3
- package/src/client/components/layout/desktop-layout/index.tsx +34 -26
- package/src/client/components/layout/session-sidebar/Footer.tsx +12 -12
- package/src/client/components/layout/session-sidebar/Header.tsx +4 -2
- package/src/client/components/mobile/LayoutWrapper.tsx +36 -0
- package/src/client/components/mobile/RightSidebar.tsx +2 -2
- package/src/client/components/mobile/SessionSidebar.tsx +5 -8
- package/src/client/components/mobile/mobile-session-sidebar/Footer.tsx +15 -12
- package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +9 -2
- package/src/client/components/mobile/mobile-session-sidebar/folder-gap.test.ts +41 -0
- package/src/client/components/mobile/mobile-session-sidebar/folder-gap.ts +39 -0
- package/src/client/components/mobile/mobile-session-sidebar/use-dock.ts +6 -2
- package/src/client/components/settings/categories/OmpSettings.tsx +25 -29
- package/src/client/components/settings/categories/omp-settings/PresetBar.tsx +187 -0
- package/src/client/components/settings/categories/omp-settings/RoleEditor.test.ts +260 -32
- package/src/client/components/settings/categories/omp-settings/RoleEditor.tsx +198 -102
- package/src/client/components/settings/categories/omp-settings/RoleRow.tsx +79 -0
- package/src/client/data/settings/omp-schema.json +2 -2
- package/src/client/hooks/desktop/servers.test.ts +73 -0
- package/src/client/hooks/desktop/servers.ts +126 -0
- package/src/client/hooks/settings/model-roles.ts +156 -0
- package/src/client/hooks/ui/updates/shell-download.test.ts +157 -0
- package/src/client/hooks/ui/updates/shell-download.ts +220 -0
- package/src/client/hooks/workspace/keybindings.ts +44 -0
- package/src/client/hooks/workspace/open-panel.ts +42 -0
- package/src/client/lib/desktop/bridge.ts +158 -0
- package/src/client/lib/desktop/titlebar.test.ts +99 -0
- package/src/client/lib/desktop/titlebar.ts +97 -0
- package/src/client/lib/plugins/slots.test.ts +54 -0
- package/src/client/lib/plugins/slots.ts +37 -1
- package/src/server/db.server.test.ts +44 -3
- package/src/server/db.server.ts +26 -2
- package/src/server/lib/bundler/titlebar-css.test.ts +75 -0
- package/src/server/lib/db/schema.ts +3 -0
- package/src/server/lib/model-roles/catalog.server.test.ts +75 -0
- package/src/server/lib/model-roles/catalog.server.ts +112 -0
- package/src/server/lib/model-roles/mock.server.ts +40 -0
- package/src/server/lib/model-roles/schema.server.ts +36 -0
- package/src/server/lib/model-roles/store.server.test.ts +218 -0
- package/src/server/lib/model-roles/store.server.ts +188 -0
- package/src/server/lib/omp/rpc/destroy-stream-row.test.ts +4 -4
- package/src/server/lib/omp/rpc/frame-fold.test.ts +30 -1
- package/src/server/lib/omp/rpc/frame-fold.ts +6 -1
- package/src/server/lib/omp/rpc/manager.ts +18 -13
- package/src/server/lib/omp/rpc/session-command-guards.ts +4 -0
- package/src/server/lib/omp/rpc/session-commands.test.ts +24 -0
- package/src/server/lib/omp/rpc/session-commands.ts +7 -0
- package/src/server/lib/omp/session/auto-title.server.test.ts +147 -93
- package/src/server/lib/omp/session/auto-title.server.ts +150 -120
- package/src/server/lib/omp/session/rename-with-ai.server.test.ts +18 -6
- package/src/server/lib/omp/session/rename-with-ai.server.ts +36 -20
- package/src/server/lib/omp/session/title-model-overlay.server.test.ts +29 -14
- package/src/server/lib/omp/session/title-model-overlay.server.ts +46 -25
- package/src/server/lib/omp/session/title-request.server.test.ts +183 -0
- package/src/server/lib/omp/session/title-request.server.ts +213 -0
- package/src/server/lib/panels/baseline.server.test.ts +4 -4
- package/src/server/lib/panels/compat.server.test.ts +4 -4
- package/src/server/lib/panels/registry-bundled.server.test.ts +4 -4
- package/src/server/lib/panels/registry.server.test.ts +4 -4
- package/src/server/lib/panels/state.server.test.ts +4 -4
- package/src/server/lib/panels/sync.server.test.ts +4 -4
- package/src/server/lib/plugins/background.test.ts +2 -2
- package/src/server/lib/plugins/e2e.test.ts +2 -2
- package/src/server/lib/plugins/platform.test.ts +2 -2
- package/src/server/lib/queue/delivery-dispatch.server.test.ts +34 -0
- package/src/server/lib/queue/delivery.server.ts +11 -2
- package/src/server/lib/updates/shell-release.test.ts +184 -0
- package/src/server/lib/updates/shell-release.ts +215 -0
- package/src/server/lib/waiting/store.server.test.ts +2 -2
- package/src/server/plugins/ssr.ts +13 -10
- package/src/server/routes/chat/stream.test.ts +4 -4
- package/src/server/routes/models/roles.ts +73 -14
- package/src/server/routes/panels/css.test.ts +4 -4
- package/src/server/routes/panels/panel-routes.test-util.ts +3 -3
- package/src/server/routes/plugins/routes.test.ts +4 -4
- package/src/server/routes/sessions/batch-delete.test.ts +4 -4
- package/src/server/routes/updates/android.ts +2 -2
- package/src/server/routes/updates/index.ts +2 -0
- package/src/server/routes/updates/ios.ts +26 -0
- package/src/server/routes/waiting/routes.test.ts +2 -2
- package/src/shared/lib/models/server-cache.ts +11 -0
- package/src/shared/lib/omp/config/model-roles.ts +129 -0
- package/src/shared/lib/updates/shell-ua.test.ts +56 -24
- package/src/shared/lib/updates/shell-ua.ts +41 -20
- package/src/shared/lib/workspace/panel-open.test.ts +56 -0
- package/src/shared/lib/workspace/panel-open.ts +76 -0
- package/src/shared/types/model-roles.ts +54 -0
- package/src/test-support/isolated-db.ts +17 -5
- package/dist/client/static/js/index-3qamd33d.css +0 -1
- package/src/client/hooks/ui/updates/android-download.test.ts +0 -112
- package/src/client/hooks/ui/updates/android-download.ts +0 -143
- package/src/server/lib/updates/android-release.test.ts +0 -138
- package/src/server/lib/updates/android-release.ts +0 -183
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The navbar readout: CPU and RAM for the whole omp stack, two columns wide.
|
|
8
|
+
*
|
|
9
|
+
* ## It is a readout, not a control
|
|
10
|
+
*
|
|
11
|
+
* The host wraps this component in the button that opens the panel (the
|
|
12
|
+
* registration's `opensPanel`), so nothing here is clickable, focusable or
|
|
13
|
+
* keyboard-reachable. That split is the header contract: a trigger renders what
|
|
14
|
+
* the entry READS, and the host owns the interaction.
|
|
15
|
+
*
|
|
16
|
+
* ## Two lines, because the labels and the figures answer different questions
|
|
17
|
+
*
|
|
18
|
+
* ```
|
|
19
|
+
* CPU RAM
|
|
20
|
+
* 18% 2.5G
|
|
21
|
+
* ```
|
|
22
|
+
*
|
|
23
|
+
* The labels are 9px uppercase mono and muted; the figures are 12px mono and
|
|
24
|
+
* full ink. That is the header's own type scale (`context-usage-button`'s ring
|
|
25
|
+
* label sits at the same size), and the contrast is what lets the numbers be
|
|
26
|
+
* scanned without reading the words above them twice.
|
|
27
|
+
*
|
|
28
|
+
* ## It samples while it is on screen, and only then
|
|
29
|
+
*
|
|
30
|
+
* A figure nobody is looking at costs a process-table sweep per tick, so the
|
|
31
|
+
* readout arms the server's sampler on mount and disarms it on unmount — the
|
|
32
|
+
* same `watch`/`unwatch` pair the panel uses, which is why the two can be open
|
|
33
|
+
* at once without the loop being armed twice. A BACKGROUND tab is not looking:
|
|
34
|
+
* the watch is released while the document is hidden, so a tab left open
|
|
35
|
+
* overnight does not sample until it is brought forward again.
|
|
36
|
+
*
|
|
37
|
+
* ## The states that are not zero
|
|
38
|
+
*
|
|
39
|
+
* - **No sample yet** — the first tick carries RSS and no CPU rate, so the CPU
|
|
40
|
+
* column reads `—` rather than `0%`.
|
|
41
|
+
* - **The server half is not loaded** — a plugin installed into a running Kabmo
|
|
42
|
+
* answers no `watch` and no topic. The readout then reads `—` in both
|
|
43
|
+
* columns; the panel itself explains why, and a header has no room to.
|
|
44
|
+
* - **A platform with no probe** — the snapshot says so and both columns read
|
|
45
|
+
* `—`.
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
import { useEffect, useState } from 'preact/hooks';
|
|
49
|
+
import { useRealtime, useRpc } from '@kabmo-labs/kabmo-ui';
|
|
50
|
+
|
|
51
|
+
import { formatCompactBytes, formatCompactPercent } from './format';
|
|
52
|
+
import type { StatsSnapshot } from './sampler';
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Whether this tab is being looked at.
|
|
56
|
+
*
|
|
57
|
+
* `visibilityState` rather than a focus check: a window that is visible but not
|
|
58
|
+
* focused is still on screen, and its readout still has a reader.
|
|
59
|
+
*/
|
|
60
|
+
function useDocumentVisible(): boolean {
|
|
61
|
+
const [visible, setVisible] = useState(() => document.visibilityState !== 'hidden');
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
const onChange = () => setVisible(document.visibilityState !== 'hidden');
|
|
64
|
+
document.addEventListener('visibilitychange', onChange);
|
|
65
|
+
return () => document.removeEventListener('visibilitychange', onChange);
|
|
66
|
+
}, []);
|
|
67
|
+
return visible;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** One column of the readout: a label above, a figure below. */
|
|
71
|
+
function ReadoutCell({ label, value, title }: { label: string; value: string; title: string }) {
|
|
72
|
+
return (
|
|
73
|
+
<span class="flex flex-col leading-none" title={title}>
|
|
74
|
+
<span class="text-[9px] uppercase font-mono text-ink/40 tracking-wide">{label}</span>
|
|
75
|
+
<span class="text-[12px] font-mono font-semibold text-ink tabular-nums">{value}</span>
|
|
76
|
+
</span>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function StatsHeaderReadout() {
|
|
81
|
+
const realtime = useRealtime<StatsSnapshot>();
|
|
82
|
+
const watch = useRpc<undefined, { watching: number }>('watch');
|
|
83
|
+
const unwatch = useRpc<undefined, { watching: number }>('unwatch');
|
|
84
|
+
const visible = useDocumentVisible();
|
|
85
|
+
|
|
86
|
+
// Arm while the readout is on screen and the tab is visible. The cleanup
|
|
87
|
+
// always unwatches: a mount that failed to arm answers `plugin_not_loaded`
|
|
88
|
+
// and decrements to zero, which the server clamps — the alternative is a
|
|
89
|
+
// counter that drifts up on every failed load and never releases the loop.
|
|
90
|
+
useEffect(() => {
|
|
91
|
+
if (!visible) return;
|
|
92
|
+
void watch.call(undefined);
|
|
93
|
+
return () => {
|
|
94
|
+
void unwatch.call(undefined);
|
|
95
|
+
};
|
|
96
|
+
// `call` is stable per method+plugin; the effect keys on what it means.
|
|
97
|
+
}, [visible, watch.call, unwatch.call]);
|
|
98
|
+
|
|
99
|
+
const snapshot = realtime.value;
|
|
100
|
+
const measurable = snapshot !== null && snapshot.probe.supported;
|
|
101
|
+
const cpu = measurable ? formatCompactPercent(snapshot.total.cpuPercent) : '—';
|
|
102
|
+
const ram = measurable ? formatCompactBytes(snapshot.total.rss) : '—';
|
|
103
|
+
// A figure with no sample behind it must not look like a measurement: the
|
|
104
|
+
// tooltip is the only place the header can say so.
|
|
105
|
+
const title = measurable
|
|
106
|
+
? `omp stack — ${ram} RAM, ${cpu} CPU. Click for the process table.`
|
|
107
|
+
: 'Stats — no measurement yet. Click for details.';
|
|
108
|
+
|
|
109
|
+
return (
|
|
110
|
+
<span class="flex items-center gap-2.5" aria-label={`CPU ${cpu}, RAM ${ram}`} title={title}>
|
|
111
|
+
<ReadoutCell label="CPU" value={cpu} title="CPU over the last sample interval, across every process" />
|
|
112
|
+
<ReadoutCell label="RAM" value={ram} title="Resident memory across every process" />
|
|
113
|
+
</span>
|
|
114
|
+
);
|
|
115
|
+
}
|
|
@@ -33,6 +33,7 @@ import { Empty, Note, Panel } from '@kabmo-labs/kabmo-ui/components';
|
|
|
33
33
|
import { ProcessTable } from './ProcessTable';
|
|
34
34
|
import { Sparkline } from './Sparkline';
|
|
35
35
|
import { MachineFacts, TotalsBar } from './TotalsBar';
|
|
36
|
+
import { StatsHeaderReadout } from './HeaderReadout';
|
|
36
37
|
import { formatAge, formatBytes, formatPercent } from './format';
|
|
37
38
|
import type { StatsSnapshot } from './sampler';
|
|
38
39
|
|
|
@@ -180,4 +181,14 @@ export default definePluginApp((app) => {
|
|
|
180
181
|
// Kabmo's own Context view takes.
|
|
181
182
|
minWidth: 420,
|
|
182
183
|
});
|
|
184
|
+
// The navbar readout: CPU and RAM in two columns, and a click that opens the
|
|
185
|
+
// panel above. `opensPanel` names the SLOT, not a column — the host resolves
|
|
186
|
+
// it to this plugin's `rightPanel` instance and shows it wherever the user
|
|
187
|
+
// placed it (left dock, right column, or the phone drawer that holds it).
|
|
188
|
+
app.headerPanel({
|
|
189
|
+
id: 'resources',
|
|
190
|
+
title: 'Stats — CPU & RAM',
|
|
191
|
+
component: StatsHeaderReadout,
|
|
192
|
+
opensPanel: 'rightPanel',
|
|
193
|
+
});
|
|
183
194
|
});
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
|
|
16
16
|
import { describe, expect, test } from 'bun:test';
|
|
17
17
|
|
|
18
|
-
import { formatAge, formatBytes, formatPercent } from './format';
|
|
18
|
+
import { formatAge, formatBytes, formatCompactBytes, formatCompactPercent, formatPercent } from './format';
|
|
19
19
|
import { procProbe } from './proc';
|
|
20
20
|
import { readDaemonRegistry } from './daemons';
|
|
21
21
|
|
|
@@ -123,3 +123,29 @@ describe('procProbe against this machine', () => {
|
|
|
123
123
|
}
|
|
124
124
|
});
|
|
125
125
|
});
|
|
126
|
+
|
|
127
|
+
describe('formatCompactBytes', () => {
|
|
128
|
+
test('one character of unit, one decimal past megabytes', () => {
|
|
129
|
+
expect(formatCompactBytes(0)).toBe('0B');
|
|
130
|
+
expect(formatCompactBytes(512)).toBe('512B');
|
|
131
|
+
expect(formatCompactBytes(2048)).toBe('2K');
|
|
132
|
+
expect(formatCompactBytes(512 * 1024 ** 2)).toBe('512M');
|
|
133
|
+
expect(formatCompactBytes(2.5 * 1024 ** 3)).toBe('2.5G');
|
|
134
|
+
expect(formatCompactBytes(3 * 1024 ** 3)).toBe('3G');
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('a sub-megabyte figure never rounds to 0M', () => {
|
|
138
|
+
// The failure this pins: a header reading `0M` looks like "this stack uses
|
|
139
|
+
// nothing", which is the opposite of what a 410 KB figure means.
|
|
140
|
+
expect(formatCompactBytes(0.4 * 1024 ** 2)).toBe('410K');
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
describe('formatCompactPercent', () => {
|
|
145
|
+
test('whole percent, and a dash for no measurement', () => {
|
|
146
|
+
expect(formatCompactPercent(null)).toBe('—');
|
|
147
|
+
expect(formatCompactPercent(0)).toBe('0%');
|
|
148
|
+
expect(formatCompactPercent(18.4)).toBe('18%');
|
|
149
|
+
expect(formatCompactPercent(140.2)).toBe('140%');
|
|
150
|
+
});
|
|
151
|
+
});
|
|
@@ -51,3 +51,44 @@ export function formatAge(sampledAt: number, now: number): string {
|
|
|
51
51
|
const minutes = Math.floor(seconds / 60);
|
|
52
52
|
return `${minutes}m ago`;
|
|
53
53
|
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* A byte count as the NAVBAR reads it: `2.5G`, `512M`, `0B`.
|
|
57
|
+
*
|
|
58
|
+
* The panel's own `formatBytes` spells the unit out (`2.5 GB`) because a table
|
|
59
|
+
* has a column header and room to be unambiguous. A header readout sits beside
|
|
60
|
+
* the context ring and the window controls, so it is two characters of unit and
|
|
61
|
+
* one decimal — the same number, in the space it has.
|
|
62
|
+
*
|
|
63
|
+
* Megabytes and below stay whole: `512M` is already precise enough that a
|
|
64
|
+
* decimal would be a digit the reader cannot act on, and a sub-megabyte figure
|
|
65
|
+
* that printed `0G` would read as nothing at all.
|
|
66
|
+
*/
|
|
67
|
+
export function formatCompactBytes(bytes: number): string {
|
|
68
|
+
if (!Number.isFinite(bytes) || bytes <= 0) return '0B';
|
|
69
|
+
const units = ['B', 'K', 'M', 'G', 'T'];
|
|
70
|
+
let value = bytes;
|
|
71
|
+
let unit = 0;
|
|
72
|
+
while (value >= 1024 && unit < units.length - 1) {
|
|
73
|
+
value /= 1024;
|
|
74
|
+
unit += 1;
|
|
75
|
+
}
|
|
76
|
+
if (unit <= 2) return `${Math.round(value)}${units[unit]}`;
|
|
77
|
+
// One decimal, and `.0` trimmed: `2G` rather than `2.0G` — the trailing zero
|
|
78
|
+
// is a character the header pays for and nobody reads.
|
|
79
|
+
const rounded = value.toFixed(1).replace(/\.0$/, '');
|
|
80
|
+
return `${rounded}${units[unit]}`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* A CPU percent as the navbar reads it: `18%`, `140%`, `—`.
|
|
85
|
+
*
|
|
86
|
+
* `null` stays a dash for the reason `formatPercent` gives — the first sample
|
|
87
|
+
* has no rate, and `0%` would claim the stack is idle. A header is worse than a
|
|
88
|
+
* table here: nobody scrolls a navbar looking for the footnote that says the
|
|
89
|
+
* figure is not a measurement.
|
|
90
|
+
*/
|
|
91
|
+
export function formatCompactPercent(value: number | null): string {
|
|
92
|
+
if (value === null || !Number.isFinite(value)) return '—';
|
|
93
|
+
return `${Math.round(value)}%`;
|
|
94
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kabmo-labs/kabmo",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.1",
|
|
4
4
|
"description": "Web console and diagnostic workbench for AI coding agents — Elysia + Preact on Bun.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"author": "Wijanarko Putra Rajeb <wijanarko.rajeb@gmail.com>",
|
|
@@ -59,7 +59,7 @@
|
|
|
59
59
|
},
|
|
60
60
|
"dependencies": {
|
|
61
61
|
"@fontsource/fira-code": "^5.3.0",
|
|
62
|
-
"@kabmo-labs/kabmo-sdk": "0.0
|
|
62
|
+
"@kabmo-labs/kabmo-sdk": "0.1.0",
|
|
63
63
|
"@kabmo-labs/kabmo-ui": "0.0.1",
|
|
64
64
|
"@shikijs/langs": "^4.4.3",
|
|
65
65
|
"@shikijs/themes": "^4.4.3",
|
|
@@ -30,6 +30,17 @@ export interface KabmoBootstrap {
|
|
|
30
30
|
* behind the published APK.
|
|
31
31
|
*/
|
|
32
32
|
shellVersion?: string | null;
|
|
33
|
+
/**
|
|
34
|
+
* The iOS shell's version, when this page was requested by the IPA
|
|
35
|
+
* (`KabmoIOS/<version>` in the User-Agent). Null in every browser.
|
|
36
|
+
*
|
|
37
|
+
* A separate field from `shellVersion` rather than one "shell version",
|
|
38
|
+
* because the two shells are two different artifacts on two different release
|
|
39
|
+
* tracks: an APK update is an `.apk` download, an IPA update is an App Store
|
|
40
|
+
* or TestFlight page. Folding them would let one shell's version be compared
|
|
41
|
+
* against the other's published artifact.
|
|
42
|
+
*/
|
|
43
|
+
iosShellVersion?: string | null;
|
|
33
44
|
/**
|
|
34
45
|
* Whether this request came from an Android device at all — the app, or a
|
|
35
46
|
* browser running on it.
|
|
@@ -31,11 +31,22 @@ import { Ellipsis, Lock, LockOpen, RefreshCw } from 'lucide-preact';
|
|
|
31
31
|
import { useEffect, useRef, useState } from 'preact/hooks';
|
|
32
32
|
import { useOnClickOutside } from '@/client/hooks/ui/on-click-outside';
|
|
33
33
|
import { logout, useAuthEnabled, useAuthState } from '@/client/hooks/ui/auth';
|
|
34
|
+
import { PluginIdProvider } from '@kabmo-labs/kabmo-ui';
|
|
35
|
+
import { requestOpenPanel } from '@/shared/lib/workspace/panel-open';
|
|
36
|
+
import type { HeaderEntry } from '@/client/components/common/header-panel-buttons/entries';
|
|
34
37
|
|
|
35
38
|
const ROW_CLASS =
|
|
36
39
|
'w-full text-left px-3 py-2 flex items-center gap-2 transition-colors text-ink/75 hover:bg-ink/5 hover:text-ink cursor-pointer';
|
|
37
40
|
|
|
38
|
-
|
|
41
|
+
/**
|
|
42
|
+
* `entries` are the plugin header entries the row could not hold.
|
|
43
|
+
*
|
|
44
|
+
* They are ACTION entries only — a dropdown needs its trigger's anchor and a
|
|
45
|
+
* static readout has no action, so neither can become a menu row. Here the
|
|
46
|
+
* action is the same one the row would have performed: the panel key travels on
|
|
47
|
+
* the same event, and the layout opens it wherever the user placed it.
|
|
48
|
+
*/
|
|
49
|
+
export function HeaderMoreMenu({ entries = [] }: { entries?: HeaderEntry[] }) {
|
|
39
50
|
const authEnabled = useAuthEnabled();
|
|
40
51
|
const authState = useAuthState();
|
|
41
52
|
const [open, setOpen] = useState(false);
|
|
@@ -115,6 +126,32 @@ export function HeaderMoreMenu() {
|
|
|
115
126
|
<span>Reload page</span>
|
|
116
127
|
</button>
|
|
117
128
|
|
|
129
|
+
{entries.map((entry) => (
|
|
130
|
+
<button
|
|
131
|
+
key={entry.pluginId}
|
|
132
|
+
type="button"
|
|
133
|
+
role="menuitem"
|
|
134
|
+
onClick={() => {
|
|
135
|
+
setOpen(false);
|
|
136
|
+
if (entry.opensPanelKey) requestOpenPanel(entry.opensPanelKey);
|
|
137
|
+
}}
|
|
138
|
+
className={ROW_CLASS}
|
|
139
|
+
// The row IS the plugin's readout — naming it twice (a label plus
|
|
140
|
+
// the same figures) was what the first cut rendered. The title is
|
|
141
|
+
// the accessible name and the tooltip, exactly as it is on the
|
|
142
|
+
// button this row stands in for.
|
|
143
|
+
title={entry.title}
|
|
144
|
+
aria-label={entry.title}
|
|
145
|
+
>
|
|
146
|
+
{/* Scoped to its plugin: `useRpc` and `useRealtime` read the id
|
|
147
|
+
from this provider, so without it a folded entry would render
|
|
148
|
+
a dash over a live figure. */}
|
|
149
|
+
<PluginIdProvider pluginId={entry.pluginId}>
|
|
150
|
+
<entry.Trigger sessionId={null} workspacePath={null} />
|
|
151
|
+
</PluginIdProvider>
|
|
152
|
+
</button>
|
|
153
|
+
))}
|
|
154
|
+
|
|
118
155
|
{canSignOut && (
|
|
119
156
|
<button
|
|
120
157
|
type="button"
|
|
@@ -4,6 +4,7 @@ import { ReloadPageButton } from '@/client/components/common/ReloadPageButton';
|
|
|
4
4
|
import { SignOutButton } from '@/client/components/common/SignOutButton';
|
|
5
5
|
import { ContextUsageButton } from '@/client/components/common/context-usage-button';
|
|
6
6
|
import { HeaderPanelButtons } from '@/client/components/common/header-panel-buttons';
|
|
7
|
+
import { useHeaderEntries } from '@/client/components/common/header-panel-buttons/entries';
|
|
7
8
|
import { HeaderMoreMenu } from '@/client/components/common/header-controls/MoreMenu';
|
|
8
9
|
import { useAuthEnabled, useAuthState } from '@/client/hooks/ui/auth';
|
|
9
10
|
import { useViewportAtLeast } from '@/client/hooks/ui/media-query';
|
|
@@ -104,11 +105,15 @@ export function HeaderControls({
|
|
|
104
105
|
const wideEnoughForAll = layout === 'desktop' || wideRow;
|
|
105
106
|
// A phone folds the two rare controls only while the row cannot hold them.
|
|
106
107
|
const folded = layout === 'mobile' && !wideEnoughForAll;
|
|
108
|
+
// ONE resolution of what the plugin entries do, because two surfaces draw it:
|
|
109
|
+
// the row (inline) and the `⋯` menu (folded). Asking twice would be two media
|
|
110
|
+
// queries deciding one row, and the two halves could disagree mid-resize.
|
|
111
|
+
const headerEntries = useHeaderEntries(layout);
|
|
107
112
|
|
|
108
113
|
return (
|
|
109
114
|
<div className="flex items-center space-x-1">
|
|
110
115
|
{layout === 'desktop' && <PWAInstallButton />}
|
|
111
|
-
<HeaderPanelButtons layout={layout} />
|
|
116
|
+
<HeaderPanelButtons layout={layout} entries={headerEntries.inline} />
|
|
112
117
|
{/* The cluster's ORDER is fixed; what changes on a narrow phone is that
|
|
113
118
|
reload and sign-out move behind `⋯`, which sits in the slot they
|
|
114
119
|
left. Nothing is hidden at a width that can hold it: measured, the
|
|
@@ -150,7 +155,9 @@ export function HeaderControls({
|
|
|
150
155
|
{showRightPanel ? <PanelRightClose size={size} /> : <PanelRight size={size} />}
|
|
151
156
|
</button>
|
|
152
157
|
)}
|
|
153
|
-
{folded &&
|
|
158
|
+
{(folded || headerEntries.folded.length > 0) && (
|
|
159
|
+
<HeaderMoreMenu entries={headerEntries.folded} />
|
|
160
|
+
)}
|
|
154
161
|
{layout === 'desktop' && onSwitchToMobile && (
|
|
155
162
|
<button
|
|
156
163
|
type="button"
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A plugin's navbar entry, and where it goes when the row runs out of room.
|
|
8
|
+
*
|
|
9
|
+
* Two facts, and both are a control that becomes unreachable when broken:
|
|
10
|
+
*
|
|
11
|
+
* - an `opensPanel` entry is an ACTION: the row's button asks the layout for
|
|
12
|
+
* the plugin's panel by its instance key, so the layout can open it wherever
|
|
13
|
+
* the user placed it;
|
|
14
|
+
* - on a phone too narrow to hold it, that action MOVES into the `⋯` menu
|
|
15
|
+
* rather than shrinking or disappearing. Measured with a `CPU 18% / RAM 2.5G`
|
|
16
|
+
* readout (73px) at 390px: the session title kept 48px of its 74px and was
|
|
17
|
+
* clipped. At 430px it kept all of it, which is why that is the threshold.
|
|
18
|
+
*
|
|
19
|
+
* Rendered through the real `HeaderControls` against a real realtime server: the
|
|
20
|
+
* split between the row and the menu lives in that component's own call, so a
|
|
21
|
+
* test of the resolver alone would pass while the call site rendered something
|
|
22
|
+
* else.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { afterAll, afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
26
|
+
import { Window } from 'happy-dom';
|
|
27
|
+
import { h, render } from 'preact';
|
|
28
|
+
import { act } from 'preact/test-utils';
|
|
29
|
+
import { definePluginApp } from '@kabmo-labs/kabmo-sdk/app';
|
|
30
|
+
import { HeaderControls } from '@/client/components/common/header-controls';
|
|
31
|
+
import { SessionStateProvider } from '@/client/components/common/session-state-provider/index';
|
|
32
|
+
import { registerPluginBundle, resetPluginSlots } from '@/client/lib/plugins/slots';
|
|
33
|
+
import { primeAuthState } from '@/client/hooks/ui/auth';
|
|
34
|
+
import { resetRealtimeClient } from '@/shared/lib/realtime/client';
|
|
35
|
+
import { invalidateModelsCache } from '@/shared/lib/models/client';
|
|
36
|
+
import { sessionTelemetryTopic, TOPIC_PANELS } from '@/shared/lib/realtime/protocol';
|
|
37
|
+
import type { PanelRegistryPayload } from '@/shared/types';
|
|
38
|
+
import type { SessionContextTelemetry } from '@/shared/types/context';
|
|
39
|
+
import { startRealtimeTestServer, type RealtimeTestServer } from '@/test-support/realtime-server';
|
|
40
|
+
import { installDomGlobals, pristineWebSocket, restoreDomGlobals } from '@/test-support/pristine-globals';
|
|
41
|
+
import type { TopicResolver } from '@/server/lib/realtime/hub.server';
|
|
42
|
+
|
|
43
|
+
const SESSION = 'session-header-plugin-entry';
|
|
44
|
+
const nativeFetch = Bun.fetch;
|
|
45
|
+
const Noop = () => null;
|
|
46
|
+
|
|
47
|
+
let container: HTMLElement;
|
|
48
|
+
let server: RealtimeTestServer;
|
|
49
|
+
|
|
50
|
+
const PANELS: PanelRegistryPayload = {
|
|
51
|
+
panels: [{ pluginId: 'demo', name: 'Demo', version: '1.0.0', appUrl: '/api/panels/bundle/demo.js' }],
|
|
52
|
+
marketplaces: [],
|
|
53
|
+
errors: [],
|
|
54
|
+
plugins: [{ pluginId: 'demo', name: 'Demo', version: '1.0.0', isPackage: true, built: true, enabled: true, bundled: false, gitOrigin: null }],
|
|
55
|
+
catalog: [],
|
|
56
|
+
disabledPanels: [],
|
|
57
|
+
gitStatus: {},
|
|
58
|
+
autoUpdate: false,
|
|
59
|
+
syncBlocked: {},
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
function telemetry(): SessionContextTelemetry {
|
|
63
|
+
return {
|
|
64
|
+
sessionId: SESSION,
|
|
65
|
+
sessionTitle: 'Header',
|
|
66
|
+
modelId: 'kenari/deepseek-v4-1-flash',
|
|
67
|
+
modelName: 'deepseek-v4-1-flash',
|
|
68
|
+
timestamp: 'today',
|
|
69
|
+
contextUsed: 12_000,
|
|
70
|
+
contextLimit: 100_000,
|
|
71
|
+
contextPercent: 12,
|
|
72
|
+
messagesCount: 3,
|
|
73
|
+
userCount: 1,
|
|
74
|
+
assistantCount: 2,
|
|
75
|
+
totalCost: 0.01,
|
|
76
|
+
costFormatted: '$0.01',
|
|
77
|
+
cacheHitAverage: 90,
|
|
78
|
+
lastMessage: { input: 10, output: 4, reasoning: 0, cacheRead: 2, cacheWrite: 0, cacheHitPercent: 16.6 },
|
|
79
|
+
distribution: { userTokens: 1, userPercent: 25, assistantTokens: 1, assistantPercent: 25, toolTokens: 1, toolPercent: 25, otherTokens: 1, otherPercent: 25 },
|
|
80
|
+
rawMessages: [],
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Register a plugin whose header ACTION opens its own right panel. */
|
|
85
|
+
function registerActionPlugin(): void {
|
|
86
|
+
registerPluginBundle('demo', {
|
|
87
|
+
default: definePluginApp((app) => {
|
|
88
|
+
app.rightPanel({ id: 'r', title: 'Resources', component: Noop });
|
|
89
|
+
app.headerPanel({ id: 'h', title: 'Demo Stats', component: Noop, opensPanel: 'rightPanel' });
|
|
90
|
+
}),
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
beforeEach(async () => {
|
|
95
|
+
invalidateModelsCache();
|
|
96
|
+
const panels: TopicResolver = async () => PANELS;
|
|
97
|
+
const telemetryTopic: TopicResolver = async () => telemetry();
|
|
98
|
+
server = await startRealtimeTestServer(new Map<string, TopicResolver>([
|
|
99
|
+
[TOPIC_PANELS, panels],
|
|
100
|
+
[sessionTelemetryTopic(SESSION), telemetryTopic],
|
|
101
|
+
]));
|
|
102
|
+
installDomGlobals(new Window({ url: `http://127.0.0.1:${server.port}` }));
|
|
103
|
+
const target = globalThis as unknown as Record<string, unknown>;
|
|
104
|
+
target.WebSocket = pristineWebSocket;
|
|
105
|
+
target.fetch = async (input: unknown) => {
|
|
106
|
+
const json = (body: unknown) => new Response(JSON.stringify(body), {
|
|
107
|
+
status: 200,
|
|
108
|
+
headers: { 'content-type': 'application/json' },
|
|
109
|
+
});
|
|
110
|
+
const url = String(input);
|
|
111
|
+
if (url.includes('/api/sessions/')) return json({ state: {} });
|
|
112
|
+
if (url.includes('/api/telemetry/context')) return json({ telemetry: telemetry() });
|
|
113
|
+
if (url.includes('/api/models')) return json({ modelList: [], connectedProviders: [] });
|
|
114
|
+
return json({});
|
|
115
|
+
};
|
|
116
|
+
resetPluginSlots();
|
|
117
|
+
registerActionPlugin();
|
|
118
|
+
// No password: the sign-out control would otherwise add a row to the menu
|
|
119
|
+
// this suite asserts on.
|
|
120
|
+
primeAuthState(false, false);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
afterEach(() => {
|
|
124
|
+
if (container) {
|
|
125
|
+
render(null, container);
|
|
126
|
+
container.remove();
|
|
127
|
+
}
|
|
128
|
+
resetPluginSlots();
|
|
129
|
+
resetRealtimeClient();
|
|
130
|
+
server?.stop();
|
|
131
|
+
(globalThis as unknown as Record<string, unknown>).fetch = nativeFetch;
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
afterAll(() => {
|
|
135
|
+
restoreDomGlobals();
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
async function mount(layout: 'desktop' | 'mobile', width: number): Promise<void> {
|
|
139
|
+
Object.defineProperty(window, 'innerWidth', { value: width, configurable: true, writable: true });
|
|
140
|
+
container = document.createElement('div');
|
|
141
|
+
document.body.appendChild(container);
|
|
142
|
+
await act(async () => {
|
|
143
|
+
render(
|
|
144
|
+
h(SessionStateProvider, {
|
|
145
|
+
sessionId: SESSION,
|
|
146
|
+
children: h(HeaderControls, {
|
|
147
|
+
layout,
|
|
148
|
+
sessionId: SESSION,
|
|
149
|
+
showRightPanel: false,
|
|
150
|
+
onToggleRightPanel: () => {},
|
|
151
|
+
}),
|
|
152
|
+
}),
|
|
153
|
+
container,
|
|
154
|
+
);
|
|
155
|
+
});
|
|
156
|
+
await server.waitForTopic(TOPIC_PANELS, (value) => value !== null);
|
|
157
|
+
for (let i = 0; i < 10; i += 1) await act(async () => {});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Every control in the row, by its accessible name. */
|
|
161
|
+
function rowNames(): string[] {
|
|
162
|
+
return [...container.querySelectorAll('button, [title]')]
|
|
163
|
+
.map((element) => element.getAttribute('aria-label') ?? element.getAttribute('title') ?? '')
|
|
164
|
+
.map((label) => label.replace(/ \([^)]*\)$/, ''));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
describe('a plugin header action', () => {
|
|
168
|
+
test('desktop: a button that asks for its own panel by instance key', async () => {
|
|
169
|
+
await mount('desktop', 1024);
|
|
170
|
+
|
|
171
|
+
const events: string[] = [];
|
|
172
|
+
const listener = (event: Event) => {
|
|
173
|
+
const detail = (event as CustomEvent<{ panelKey?: string }>).detail;
|
|
174
|
+
if (detail?.panelKey) events.push(detail.panelKey);
|
|
175
|
+
};
|
|
176
|
+
window.addEventListener('omp:open-panel', listener);
|
|
177
|
+
const button = container.querySelector('button[aria-label="Demo Stats"]') as HTMLElement;
|
|
178
|
+
expect(button).toBeTruthy();
|
|
179
|
+
await act(async () => {
|
|
180
|
+
button.click();
|
|
181
|
+
});
|
|
182
|
+
window.removeEventListener('omp:open-panel', listener);
|
|
183
|
+
|
|
184
|
+
expect(events).toEqual(['plugin:demo:rightPanel']);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test('a narrow phone folds it into the menu, and the row keeps the title space', async () => {
|
|
188
|
+
await mount('mobile', 390);
|
|
189
|
+
|
|
190
|
+
expect(rowNames()).not.toContain('Demo Stats');
|
|
191
|
+
|
|
192
|
+
const trigger = container.querySelector('button[aria-label="More actions"]') as HTMLElement;
|
|
193
|
+
expect(trigger).toBeTruthy();
|
|
194
|
+
await act(async () => {
|
|
195
|
+
trigger.click();
|
|
196
|
+
});
|
|
197
|
+
// The row IS the plugin's readout: the title is its accessible name, not a
|
|
198
|
+
// second label printed beside the same figures.
|
|
199
|
+
const row = container.querySelector('[role="menu"] [role="menuitem"][aria-label="Demo Stats"]');
|
|
200
|
+
expect(row).toBeTruthy();
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test('the menu row opens the panel, on the same event', async () => {
|
|
204
|
+
await mount('mobile', 390);
|
|
205
|
+
const trigger = container.querySelector('button[aria-label="More actions"]') as HTMLElement;
|
|
206
|
+
await act(async () => {
|
|
207
|
+
trigger.click();
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
const events: string[] = [];
|
|
211
|
+
const listener = (event: Event) => {
|
|
212
|
+
const detail = (event as CustomEvent<{ panelKey?: string }>).detail;
|
|
213
|
+
if (detail?.panelKey) events.push(detail.panelKey);
|
|
214
|
+
};
|
|
215
|
+
window.addEventListener('omp:open-panel', listener);
|
|
216
|
+
const row = container.querySelector('[role="menu"] [role="menuitem"][aria-label="Demo Stats"]') as HTMLElement;
|
|
217
|
+
await act(async () => {
|
|
218
|
+
row.click();
|
|
219
|
+
});
|
|
220
|
+
window.removeEventListener('omp:open-panel', listener);
|
|
221
|
+
|
|
222
|
+
expect(events).toEqual(['plugin:demo:rightPanel']);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test('a wide enough phone keeps it in the row', async () => {
|
|
226
|
+
await mount('mobile', 500);
|
|
227
|
+
expect(rowNames()).toContain('Demo Stats');
|
|
228
|
+
});
|
|
229
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Where a plugin's navbar entry goes, and why the answer is not "the row".
|
|
8
|
+
*
|
|
9
|
+
* A phone header shares 320–430px between the wordmark, the session title and
|
|
10
|
+
* the controls used mid-conversation. A plugin readout is the first thing that
|
|
11
|
+
* has to give way, and the measured cost of not folding it is the session title:
|
|
12
|
+
* with a `CPU 18% / RAM 2.5G` readout (73px) in the row at 390px, the title kept
|
|
13
|
+
* 48px of its 74px and was clipped; at 430px it kept all of it.
|
|
14
|
+
*
|
|
15
|
+
* Three rules, and each is a control that becomes unreachable when broken:
|
|
16
|
+
*
|
|
17
|
+
* - **An ACTION entry folds into `⋯`.** It has one click and no UI of its own,
|
|
18
|
+
* so a menu row is the same action in words.
|
|
19
|
+
* - **A dropdown or a static readout never folds.** A dropdown needs its
|
|
20
|
+
* trigger's anchor, and a static readout has no action to perform; folding
|
|
21
|
+
* either would be a different control rather than the same one moved.
|
|
22
|
+
* - **A wide enough phone, and every desktop, draws everything in the row.**
|
|
23
|
+
* The threshold is a WIDTH, not a device class — the same rule the header's
|
|
24
|
+
* own reload/sign-out fold follows.
|
|
25
|
+
*
|
|
26
|
+
* The hook half is exercised through `HeaderControls` (which is where the row
|
|
27
|
+
* and the menu actually split); this file pins the pure decision it encodes.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { describe, expect, test } from 'bun:test';
|
|
31
|
+
import { MOBILE_HEADER_ACTION_PX, MOBILE_HEADER_ENTRY_MAX_PX } from '@/client/components/common/header-panel-buttons/entries';
|
|
32
|
+
|
|
33
|
+
describe('the header entry thresholds', () => {
|
|
34
|
+
test('the action threshold is past the width where the title clips', () => {
|
|
35
|
+
// Measured: 390px clips the title to 48px of 74px; 430px does not.
|
|
36
|
+
expect(MOBILE_HEADER_ACTION_PX).toBe(430);
|
|
37
|
+
expect(MOBILE_HEADER_ACTION_PX).toBeGreaterThan(390);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('the per-entry cap holds a two-column readout and no more', () => {
|
|
41
|
+
// The measured readout is 73px wide at 12px mono; the cap leaves room for a
|
|
42
|
+
// longer figure without letting one plugin take the row.
|
|
43
|
+
expect(MOBILE_HEADER_ENTRY_MAX_PX).toBeGreaterThanOrEqual(73);
|
|
44
|
+
expect(MOBILE_HEADER_ENTRY_MAX_PX).toBeLessThan(120);
|
|
45
|
+
});
|
|
46
|
+
});
|