@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.
Files changed (117) hide show
  1. package/dist/client/index.html +1 -1
  2. package/dist/client/index.js +359 -350
  3. package/dist/client/static/js/{index-9js9mrmj.js → index-aq0e78gv.js} +704 -704
  4. package/dist/client/static/js/index-nzyjf92m.css +1 -0
  5. package/marketplace/plugins/stats/README.md +26 -4
  6. package/marketplace/plugins/stats/package.json +2 -2
  7. package/marketplace/plugins/stats/src/HeaderReadout.test.tsx +157 -0
  8. package/marketplace/plugins/stats/src/HeaderReadout.tsx +115 -0
  9. package/marketplace/plugins/stats/src/app.tsx +11 -0
  10. package/marketplace/plugins/stats/src/format.test.ts +27 -1
  11. package/marketplace/plugins/stats/src/format.ts +41 -0
  12. package/package.json +2 -2
  13. package/src/client/bootstrap.d.ts +11 -0
  14. package/src/client/components/common/header-controls/MoreMenu.tsx +38 -1
  15. package/src/client/components/common/header-controls/index.tsx +9 -2
  16. package/src/client/components/common/header-controls/plugin-entry.test.tsx +229 -0
  17. package/src/client/components/common/header-panel-buttons/entries.test.ts +46 -0
  18. package/src/client/components/common/header-panel-buttons/entries.ts +109 -0
  19. package/src/client/components/common/header-panel-buttons/index.tsx +71 -38
  20. package/src/client/components/common/server-switcher/index.test.tsx +150 -0
  21. package/src/client/components/common/server-switcher/index.tsx +281 -0
  22. package/src/client/components/common/window-controls/index.test.tsx +113 -0
  23. package/src/client/components/common/window-controls/index.tsx +66 -0
  24. package/src/client/components/layout/desktop-layout/LeftColumn.tsx +3 -6
  25. package/src/client/components/layout/desktop-layout/TopNavbar.tsx +59 -3
  26. package/src/client/components/layout/desktop-layout/index.tsx +34 -26
  27. package/src/client/components/layout/session-sidebar/Footer.tsx +12 -12
  28. package/src/client/components/layout/session-sidebar/Header.tsx +4 -2
  29. package/src/client/components/mobile/LayoutWrapper.tsx +36 -0
  30. package/src/client/components/mobile/RightSidebar.tsx +2 -2
  31. package/src/client/components/mobile/SessionSidebar.tsx +5 -8
  32. package/src/client/components/mobile/mobile-session-sidebar/Footer.tsx +15 -12
  33. package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +9 -2
  34. package/src/client/components/mobile/mobile-session-sidebar/folder-gap.test.ts +41 -0
  35. package/src/client/components/mobile/mobile-session-sidebar/folder-gap.ts +39 -0
  36. package/src/client/components/mobile/mobile-session-sidebar/use-dock.ts +6 -2
  37. package/src/client/components/settings/categories/OmpSettings.tsx +25 -29
  38. package/src/client/components/settings/categories/omp-settings/PresetBar.tsx +187 -0
  39. package/src/client/components/settings/categories/omp-settings/RoleEditor.test.ts +260 -32
  40. package/src/client/components/settings/categories/omp-settings/RoleEditor.tsx +198 -102
  41. package/src/client/components/settings/categories/omp-settings/RoleRow.tsx +79 -0
  42. package/src/client/data/settings/omp-schema.json +2 -2
  43. package/src/client/hooks/desktop/servers.test.ts +73 -0
  44. package/src/client/hooks/desktop/servers.ts +126 -0
  45. package/src/client/hooks/settings/model-roles.ts +156 -0
  46. package/src/client/hooks/ui/updates/shell-download.test.ts +157 -0
  47. package/src/client/hooks/ui/updates/shell-download.ts +220 -0
  48. package/src/client/hooks/workspace/keybindings.ts +44 -0
  49. package/src/client/hooks/workspace/open-panel.ts +42 -0
  50. package/src/client/lib/desktop/bridge.ts +158 -0
  51. package/src/client/lib/desktop/titlebar.test.ts +99 -0
  52. package/src/client/lib/desktop/titlebar.ts +97 -0
  53. package/src/client/lib/plugins/slots.test.ts +54 -0
  54. package/src/client/lib/plugins/slots.ts +37 -1
  55. package/src/server/db.server.test.ts +44 -3
  56. package/src/server/db.server.ts +26 -2
  57. package/src/server/lib/bundler/titlebar-css.test.ts +75 -0
  58. package/src/server/lib/db/schema.ts +3 -0
  59. package/src/server/lib/model-roles/catalog.server.test.ts +75 -0
  60. package/src/server/lib/model-roles/catalog.server.ts +112 -0
  61. package/src/server/lib/model-roles/mock.server.ts +40 -0
  62. package/src/server/lib/model-roles/schema.server.ts +36 -0
  63. package/src/server/lib/model-roles/store.server.test.ts +218 -0
  64. package/src/server/lib/model-roles/store.server.ts +188 -0
  65. package/src/server/lib/omp/rpc/destroy-stream-row.test.ts +4 -4
  66. package/src/server/lib/omp/rpc/frame-fold.test.ts +30 -1
  67. package/src/server/lib/omp/rpc/frame-fold.ts +6 -1
  68. package/src/server/lib/omp/rpc/manager.ts +18 -13
  69. package/src/server/lib/omp/rpc/session-command-guards.ts +4 -0
  70. package/src/server/lib/omp/rpc/session-commands.test.ts +24 -0
  71. package/src/server/lib/omp/rpc/session-commands.ts +7 -0
  72. package/src/server/lib/omp/session/auto-title.server.test.ts +147 -93
  73. package/src/server/lib/omp/session/auto-title.server.ts +150 -120
  74. package/src/server/lib/omp/session/rename-with-ai.server.test.ts +18 -6
  75. package/src/server/lib/omp/session/rename-with-ai.server.ts +36 -20
  76. package/src/server/lib/omp/session/title-model-overlay.server.test.ts +29 -14
  77. package/src/server/lib/omp/session/title-model-overlay.server.ts +46 -25
  78. package/src/server/lib/omp/session/title-request.server.test.ts +183 -0
  79. package/src/server/lib/omp/session/title-request.server.ts +213 -0
  80. package/src/server/lib/panels/baseline.server.test.ts +4 -4
  81. package/src/server/lib/panels/compat.server.test.ts +4 -4
  82. package/src/server/lib/panels/registry-bundled.server.test.ts +4 -4
  83. package/src/server/lib/panels/registry.server.test.ts +4 -4
  84. package/src/server/lib/panels/state.server.test.ts +4 -4
  85. package/src/server/lib/panels/sync.server.test.ts +4 -4
  86. package/src/server/lib/plugins/background.test.ts +2 -2
  87. package/src/server/lib/plugins/e2e.test.ts +2 -2
  88. package/src/server/lib/plugins/platform.test.ts +2 -2
  89. package/src/server/lib/queue/delivery-dispatch.server.test.ts +34 -0
  90. package/src/server/lib/queue/delivery.server.ts +11 -2
  91. package/src/server/lib/updates/shell-release.test.ts +184 -0
  92. package/src/server/lib/updates/shell-release.ts +215 -0
  93. package/src/server/lib/waiting/store.server.test.ts +2 -2
  94. package/src/server/plugins/ssr.ts +13 -10
  95. package/src/server/routes/chat/stream.test.ts +4 -4
  96. package/src/server/routes/models/roles.ts +73 -14
  97. package/src/server/routes/panels/css.test.ts +4 -4
  98. package/src/server/routes/panels/panel-routes.test-util.ts +3 -3
  99. package/src/server/routes/plugins/routes.test.ts +4 -4
  100. package/src/server/routes/sessions/batch-delete.test.ts +4 -4
  101. package/src/server/routes/updates/android.ts +2 -2
  102. package/src/server/routes/updates/index.ts +2 -0
  103. package/src/server/routes/updates/ios.ts +26 -0
  104. package/src/server/routes/waiting/routes.test.ts +2 -2
  105. package/src/shared/lib/models/server-cache.ts +11 -0
  106. package/src/shared/lib/omp/config/model-roles.ts +129 -0
  107. package/src/shared/lib/updates/shell-ua.test.ts +56 -24
  108. package/src/shared/lib/updates/shell-ua.ts +41 -20
  109. package/src/shared/lib/workspace/panel-open.test.ts +56 -0
  110. package/src/shared/lib/workspace/panel-open.ts +76 -0
  111. package/src/shared/types/model-roles.ts +54 -0
  112. package/src/test-support/isolated-db.ts +17 -5
  113. package/dist/client/static/js/index-3qamd33d.css +0 -1
  114. package/src/client/hooks/ui/updates/android-download.test.ts +0 -112
  115. package/src/client/hooks/ui/updates/android-download.ts +0 -143
  116. package/src/server/lib/updates/android-release.test.ts +0 -138
  117. 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.5.3",
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.1",
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
- export function HeaderMoreMenu() {
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 && <HeaderMoreMenu />}
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
+ });