@kabmo-labs/kabmo 0.5.2 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) 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-zcvn50cf.js → index-ae9z0yvm.js} +703 -703
  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 +10 -15
  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 -6
  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/chat/omp/session-list.test.ts +0 -24
  44. package/src/client/hooks/desktop/servers.test.ts +73 -0
  45. package/src/client/hooks/desktop/servers.ts +126 -0
  46. package/src/client/hooks/settings/model-roles.ts +156 -0
  47. package/src/client/hooks/ui/updates/shell-download.test.ts +157 -0
  48. package/src/client/hooks/ui/updates/shell-download.ts +220 -0
  49. package/src/client/hooks/workspace/keybindings.ts +44 -0
  50. package/src/client/hooks/workspace/open-panel.ts +42 -0
  51. package/src/client/lib/desktop/bridge.ts +158 -0
  52. package/src/client/lib/desktop/titlebar.test.ts +99 -0
  53. package/src/client/lib/desktop/titlebar.ts +97 -0
  54. package/src/client/lib/plugins/slots.test.ts +54 -0
  55. package/src/client/lib/plugins/slots.ts +37 -1
  56. package/src/server/db.server.test.ts +44 -3
  57. package/src/server/db.server.ts +26 -2
  58. package/src/server/lib/bundler/build-errors.test.ts +0 -6
  59. package/src/server/lib/bundler/titlebar-css.test.ts +75 -0
  60. package/src/server/lib/db/schema.ts +3 -0
  61. package/src/server/lib/model-roles/catalog.server.test.ts +75 -0
  62. package/src/server/lib/model-roles/catalog.server.ts +112 -0
  63. package/src/server/lib/model-roles/mock.server.ts +40 -0
  64. package/src/server/lib/model-roles/schema.server.ts +36 -0
  65. package/src/server/lib/model-roles/store.server.test.ts +218 -0
  66. package/src/server/lib/model-roles/store.server.ts +188 -0
  67. package/src/server/lib/omp/rpc/destroy-stream-row.test.ts +4 -4
  68. package/src/server/lib/omp/rpc/frame-fold.test.ts +30 -1
  69. package/src/server/lib/omp/rpc/frame-fold.ts +6 -1
  70. package/src/server/lib/omp/rpc/manager.ts +18 -13
  71. package/src/server/lib/omp/rpc/session-command-guards.ts +4 -0
  72. package/src/server/lib/omp/rpc/session-commands.test.ts +24 -0
  73. package/src/server/lib/omp/rpc/session-commands.ts +7 -0
  74. package/src/server/lib/omp/session/auto-title.server.test.ts +147 -93
  75. package/src/server/lib/omp/session/auto-title.server.ts +150 -120
  76. package/src/server/lib/omp/session/rename-with-ai.server.test.ts +18 -6
  77. package/src/server/lib/omp/session/rename-with-ai.server.ts +36 -20
  78. package/src/server/lib/omp/session/title-model-overlay.server.test.ts +29 -14
  79. package/src/server/lib/omp/session/title-model-overlay.server.ts +46 -25
  80. package/src/server/lib/omp/session/title-request.server.test.ts +183 -0
  81. package/src/server/lib/omp/session/title-request.server.ts +213 -0
  82. package/src/server/lib/panels/baseline.server.test.ts +4 -4
  83. package/src/server/lib/panels/compat.server.test.ts +4 -4
  84. package/src/server/lib/panels/registry-bundled.server.test.ts +4 -4
  85. package/src/server/lib/panels/registry.server.test.ts +4 -4
  86. package/src/server/lib/panels/state.server.test.ts +4 -4
  87. package/src/server/lib/panels/sync.server.test.ts +4 -4
  88. package/src/server/lib/plugins/background.test.ts +2 -2
  89. package/src/server/lib/plugins/e2e.test.ts +2 -2
  90. package/src/server/lib/plugins/platform.test.ts +2 -2
  91. package/src/server/lib/queue/delivery-dispatch.server.test.ts +34 -0
  92. package/src/server/lib/queue/delivery.server.ts +11 -2
  93. package/src/server/lib/updates/shell-release.test.ts +184 -0
  94. package/src/server/lib/updates/shell-release.ts +215 -0
  95. package/src/server/lib/waiting/store.server.test.ts +2 -2
  96. package/src/server/plugins/ssr.ts +13 -10
  97. package/src/server/routes/chat/stream.test.ts +4 -4
  98. package/src/server/routes/fs/git-merge.test.ts +0 -11
  99. package/src/server/routes/models/roles.ts +73 -14
  100. package/src/server/routes/panels/css.test.ts +4 -4
  101. package/src/server/routes/panels/panel-routes.test-util.ts +3 -3
  102. package/src/server/routes/panels/routes.test.ts +0 -7
  103. package/src/server/routes/plugins/routes.test.ts +4 -4
  104. package/src/server/routes/sessions/batch-delete.test.ts +4 -4
  105. package/src/server/routes/updates/android.ts +2 -2
  106. package/src/server/routes/updates/index.ts +2 -0
  107. package/src/server/routes/updates/ios.ts +26 -0
  108. package/src/server/routes/waiting/routes.test.ts +2 -2
  109. package/src/shared/lib/models/server-cache.ts +11 -0
  110. package/src/shared/lib/omp/config/model-roles.ts +129 -0
  111. package/src/shared/lib/updates/shell-ua.test.ts +56 -24
  112. package/src/shared/lib/updates/shell-ua.ts +41 -20
  113. package/src/shared/lib/workspace/mobile-dock.test.ts +7 -13
  114. package/src/shared/lib/workspace/mobile-dock.ts +15 -6
  115. package/src/shared/lib/workspace/panel-open.test.ts +56 -0
  116. package/src/shared/lib/workspace/panel-open.ts +76 -0
  117. package/src/shared/types/model-roles.ts +54 -0
  118. package/src/test-support/isolated-db.ts +17 -5
  119. package/dist/client/static/js/index-3qamd33d.css +0 -1
  120. package/src/client/components/layout/desktop-layout/left-column.test.tsx +0 -192
  121. package/src/client/hooks/ui/updates/android-download.test.ts +0 -112
  122. package/src/client/hooks/ui/updates/android-download.ts +0 -143
  123. package/src/server/lib/updates/android-release.test.ts +0 -138
  124. package/src/server/lib/updates/android-release.ts +0 -183
@@ -0,0 +1,158 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The desktop shell's bridge, and the reason every other module asks this one
8
+ * whether it is running inside the shell.
9
+ *
10
+ * THREE shells install the same transport, and that is the point of this module:
11
+ *
12
+ * - `packages/macos` (macOS, Swift + WKWebView)
13
+ * - `packages/windows` (.NET + WPF + WebView2)
14
+ * - `packages/linux` (Rust + GTK 3 + WebKitGTK 4.1)
15
+ *
16
+ * Each installs ONE transport before the page's own scripts run:
17
+ * `window.__kabmoCall(method, params)` posts `{ id, method, params }` to a host
18
+ * message handler named `kabmo`, and the shell answers by evaluating
19
+ * `window.__kabmoReply(id, ok, payload)`. There is no capability file and no
20
+ * per-origin allowance — the shell owns the webview, so the page cannot be
21
+ * anything but one this window chose to load.
22
+ *
23
+ * This module must be safe in a BROWSER too: every function rejects with "not
24
+ * inside the shell" when there is no bridge, and the navbar draws no server
25
+ * control at all. A browser tab has no shell to control, and a control that
26
+ * opened onto "not available" would be a button that never works.
27
+ */
28
+
29
+ /** What the shell reports about itself. */
30
+ export interface DesktopInfo {
31
+ platform: string;
32
+ shellVersion: string;
33
+ /** True when the shell hides the native title bar and the page draws it. */
34
+ overlayTitlebar: boolean;
35
+ /**
36
+ * macOS: the width of the traffic lights, which float over the page's LEFT
37
+ * edge. Absent on a platform whose window controls are not on the left.
38
+ */
39
+ trafficLightInset?: string;
40
+ /**
41
+ * True when the shell is borderless and has NO controls of its own, so the
42
+ * page must draw minimise / maximise / close.
43
+ *
44
+ * Windows and Linux. macOS gets its traffic lights from the system over a
45
+ * full-size content view; a borderless Windows window has nothing at all, and
46
+ * the webview there is an HWND — a WPF element cannot be drawn on top of it
47
+ * (the airspace problem) — so the page is the only surface that can. Linux
48
+ * could draw native buttons in a `GtkOverlay` (measured: an overlay child does
49
+ * receive clicks over a WebView), but the page's set already exists, is tested,
50
+ * and is the one every other shell's users see.
51
+ */
52
+ windowControls?: boolean;
53
+ }
54
+
55
+ /** One server in the shell's list, or one found on this machine. */
56
+ export interface DesktopServer {
57
+ id: string;
58
+ label: string;
59
+ url: string;
60
+ kind: 'remote' | 'managed';
61
+ port?: number;
62
+ running: boolean;
63
+ /** This shell started it (as opposed to a server found on the machine). */
64
+ owned: boolean;
65
+ version?: string;
66
+ mode?: string;
67
+ pid?: number;
68
+ startedAt?: string;
69
+ /** `stored` for the list, `record` for a local instance found on disk. */
70
+ source: 'stored' | 'record';
71
+ }
72
+
73
+ export interface DesktopServersState {
74
+ entries: DesktopServer[];
75
+ /** Local instances this shell did NOT start — a `kabmo serve`, a dev server. */
76
+ detected: DesktopServer[];
77
+ activeId?: string;
78
+ activeUrl?: string;
79
+ }
80
+
81
+ declare global {
82
+ interface Window {
83
+ __kabmoCall?: (method: string, params?: Record<string, unknown>) => Promise<unknown>;
84
+ __kabmoDesktop?: DesktopInfo;
85
+ __kabmoReply?: (id: number, ok: boolean, payload: unknown) => void;
86
+ }
87
+ }
88
+
89
+ /** True when this page is inside the desktop shell. */
90
+ export function isDesktopShell(): boolean {
91
+ return typeof window !== 'undefined' && typeof window.__kabmoCall === 'function';
92
+ }
93
+
94
+ async function call<T>(method: string, params: Record<string, unknown> = {}): Promise<T> {
95
+ const invoke = typeof window === 'undefined' ? undefined : window.__kabmoCall;
96
+ if (!invoke) throw new Error('Not running inside the Kabmo desktop shell.');
97
+ return (await invoke(method, params)) as T;
98
+ }
99
+
100
+ export function desktopInfo(): Promise<DesktopInfo | null> {
101
+ if (!isDesktopShell()) return Promise.resolve(null);
102
+ const injected = typeof window === 'undefined' ? undefined : window.__kabmoDesktop;
103
+ // The shell injects this before the page's first script, so the navbar's first
104
+ // paint is already correct; the command is only a fallback for a shell that
105
+ // did not (an older build).
106
+ if (injected) return Promise.resolve(injected);
107
+ return call<DesktopInfo>('desktop_info');
108
+ }
109
+
110
+ export function readServers(): Promise<DesktopServersState> {
111
+ return call<DesktopServersState>('servers_state');
112
+ }
113
+
114
+ export function addRemoteServer(label: string, url: string): Promise<DesktopServersState> {
115
+ return call<DesktopServersState>('servers_add', { label, url });
116
+ }
117
+
118
+ export function removeServer(id: string): Promise<DesktopServersState> {
119
+ return call<DesktopServersState>('servers_remove', { id });
120
+ }
121
+
122
+ /** Point the window at a server. The shell navigates, so this page goes away. */
123
+ export function activateServer(id: string): Promise<DesktopServersState> {
124
+ return call<DesktopServersState>('servers_activate', { id });
125
+ }
126
+
127
+ /** Start a local server as the shell's child, then navigate to it. */
128
+ export function startLocalServer(port?: number, label?: string): Promise<DesktopServersState> {
129
+ return call<DesktopServersState>('servers_start', { port: port ?? null, label: label ?? null });
130
+ }
131
+
132
+ export function stopLocalServer(id: string): Promise<DesktopServersState> {
133
+ return call<DesktopServersState>('servers_stop', { id });
134
+ }
135
+
136
+ /**
137
+ * A refused command's message.
138
+ *
139
+ * The shell sends its own sentence (a refused URL, a server that exited), which
140
+ * is what the UI shows. Nothing is stripped: a message that genuinely starts with
141
+ * "Error" would lose its first word.
142
+ */
143
+ export function bridgeError(error: unknown): string {
144
+ return error instanceof Error ? error.message : String(error);
145
+ }
146
+
147
+ /** Minimise, maximise/restore and close, for a shell that draws no controls. */
148
+ export function windowMinimize(): Promise<void> {
149
+ return call<void>('window_minimize');
150
+ }
151
+
152
+ export function windowToggleMaximize(): Promise<void> {
153
+ return call<void>('window_maximize');
154
+ }
155
+
156
+ export function windowClose(): Promise<void> {
157
+ return call<void>('window_close');
158
+ }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The shell's title-bar contract, evaluated against both shells and a browser.
8
+ *
9
+ * Two shells wrap this app, and they put their controls on OPPOSITE edges — or
10
+ * nowhere at all. A single "inset" would have to say which edge it means at
11
+ * every call site, and the cost of getting it wrong is a dead strip of window
12
+ * with no control in it, which is exactly the macOS 76px bug stated in
13
+ * `src/shared/lib/ui/titlebar.ts`.
14
+ *
15
+ * Three properties are load-bearing, and each fails silently:
16
+ *
17
+ * 1. macOS floats traffic lights over the page's LEFT edge, so the left inset is
18
+ * the shell's reported width and nothing is reserved on the right;
19
+ * 2. Windows is borderless with no controls of its own — its webview is an HWND,
20
+ * so a host element cannot be painted over it — which is why the PAGE draws
21
+ * the three buttons there (`drawsWindowControls`), and why it reserves no
22
+ * inset on either edge;
23
+ * 3. a BROWSER, or an older shell that injects no identity, gets the caller's
24
+ * own resolved padding (the PWA's Window Controls Overlay arithmetic).
25
+ */
26
+
27
+ import { afterEach, describe, expect, test } from 'bun:test';
28
+
29
+ type Shell = {
30
+ __kabmoCall?: (method: string, params?: Record<string, unknown>) => Promise<unknown>;
31
+ __kabmoDesktop?: Record<string, unknown>;
32
+ };
33
+
34
+ const scope = () => globalThis as unknown as { window?: Shell };
35
+
36
+ async function titlebar() {
37
+ return import('@/client/lib/desktop/titlebar');
38
+ }
39
+
40
+ function shell(identity: Record<string, unknown> | null) {
41
+ scope().window = identity === null
42
+ ? { __kabmoCall: async () => null }
43
+ : { __kabmoCall: async () => null, __kabmoDesktop: identity };
44
+ }
45
+
46
+ const MACOS = { platform: 'macos', shellVersion: '0.1.0', overlayTitlebar: true, trafficLightInset: '5rem' };
47
+ const WINDOWS = { platform: 'windows', shellVersion: '0.1.0', overlayTitlebar: true, windowControls: true };
48
+
49
+ describe('shell title-bar contract', () => {
50
+ afterEach(() => {
51
+ delete scope().window;
52
+ });
53
+
54
+ test('a browser reserves nothing for a shell it is not in', async () => {
55
+ scope().window = {};
56
+ const bar = await titlebar();
57
+ expect(bar.overlayTitlebar()).toBe(false);
58
+ expect(bar.dragRegionProps()).toEqual({});
59
+ expect(bar.titlebarInsetLeft('1rem')).toBe('1rem');
60
+ expect(bar.drawsWindowControls()).toBe(false);
61
+ });
62
+
63
+ test('macOS reserves the traffic lights on the left and draws no controls', async () => {
64
+ shell(MACOS);
65
+ const bar = await titlebar();
66
+ expect(bar.dragRegionProps()).toEqual({ 'data-drag-region': '' });
67
+ expect(bar.titlebarInsetLeft('1rem')).toBe('5rem');
68
+ // The system owns the traffic lights: a page-drawn set would be a second one.
69
+ expect(bar.drawsWindowControls()).toBe(false);
70
+ });
71
+
72
+ test('Windows reserves nothing and draws the three buttons itself', async () => {
73
+ shell(WINDOWS);
74
+ const bar = await titlebar();
75
+ expect(bar.dragRegionProps()).toEqual({ 'data-drag-region': '' });
76
+ // Nothing floats over the page on a borderless Windows window.
77
+ expect(bar.titlebarInsetLeft('1rem')).toBe('1rem');
78
+ expect(bar.drawsWindowControls()).toBe(true);
79
+ });
80
+
81
+ /** An older shell injects no identity at all; nothing may be invented. */
82
+ test('a shell that reports no identity keeps the resolved padding', async () => {
83
+ shell(null);
84
+ const bar = await titlebar();
85
+ expect(bar.overlayTitlebar()).toBe(true);
86
+ expect(bar.titlebarInsetLeft('1rem')).toBe('1rem');
87
+ expect(bar.drawsWindowControls()).toBe(false);
88
+ });
89
+
90
+ /** A shell that draws its own native bar must not mark a drag region. */
91
+ test('overlayTitlebar false marks no drag region and no controls', async () => {
92
+ shell({ platform: 'windows', shellVersion: '0.1.0', overlayTitlebar: false, windowControls: true });
93
+ const bar = await titlebar();
94
+ expect(bar.overlayTitlebar()).toBe(false);
95
+ expect(bar.dragRegionProps()).toEqual({});
96
+ // The native bar owns the buttons when it is visible.
97
+ expect(bar.drawsWindowControls()).toBe(false);
98
+ });
99
+ });
@@ -0,0 +1,97 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Making the page's own header act as the desktop window's title bar.
8
+ *
9
+ * Three facts, and the third is why this module is shared by three shells.
10
+ *
11
+ * 1. **The window controls float OVER the content**, because the window is
12
+ * built with a transparent title bar over a full-size content view. WHICH
13
+ * EDGE they occupy is the platform's choice, and getting it wrong is a dead
14
+ * strip of window with no control in it: macOS puts its traffic lights on
15
+ * the LEFT, so the leftmost ~76px of the page is behind them and a wordmark
16
+ * drawn at the normal 16px inset sits under the close button.
17
+ *
18
+ * - Windows is borderless too, but draws nothing over the page: its webview
19
+ * is an HWND, so a host element cannot be painted on top of it. The page
20
+ * draws its own minimise / maximise / close instead (`windowControls`), so
21
+ * the only edge it reserves anything for is the one macOS owns. Linux is
22
+ * the same: borderless, page-drawn controls, no reserved edge.
23
+ *
24
+ * The inset is therefore REPORTED by the shell (`trafficLightInset`), and a
25
+ * shell that reports none gets the caller's own resolved padding.
26
+ *
27
+ * 2. **The drag region is decided by the SHELL, from a selector** — not by CSS.
28
+ * macOS asks the page whether the press landed on `[data-drag-region]`
29
+ * (`ShellWindow.sendEvent`); Windows lets WebView2 do the same with
30
+ * `IsNonClientRegionSupportEnabled`, which is what makes `app-region: drag`
31
+ * mean the title bar there. The page's only job is to MARK its header.
32
+ *
33
+ * 3. **The inset must survive a page that has not asked yet.** The shells inject
34
+ * their identity before the first script runs, so the first paint is already
35
+ * correct; a shell that did not (an older build) leaves this module with no
36
+ * identity, and the answer is the caller's own padding rather than a guess.
37
+ */
38
+
39
+ import { isDesktopShell } from '@/client/lib/desktop/bridge';
40
+
41
+ /** Width macOS reserves for its traffic lights, plus the gap after them. */
42
+ export const TRAFFIC_LIGHT_INSET = '5rem';
43
+
44
+ /** The shell's own report, or null in a browser (and in an older shell). */
45
+ function shellInfo(): { overlayTitlebar: boolean; trafficLightInset?: string; windowControls?: boolean } | null {
46
+ if (typeof window === 'undefined') return null;
47
+ const injected = window.__kabmoDesktop;
48
+ return injected ?? null;
49
+ }
50
+
51
+ /** True when the shell hid the native title bar and the page draws it. */
52
+ export function overlayTitlebar(): boolean {
53
+ if (!isDesktopShell()) return false;
54
+ // The injected identity is authoritative when present: a shell that reports
55
+ // `overlayTitlebar: false` draws its own native bar and the page must not
56
+ // reserve anything for controls it does not have.
57
+ const injected = shellInfo();
58
+ return injected ? injected.overlayTitlebar : true;
59
+ }
60
+
61
+ /**
62
+ * The attribute that marks an element as a window drag handle.
63
+ *
64
+ * `data-drag-region` is read by the macOS shell's own hit test. It is an
65
+ * ATTRIBUTE rather than a class so it cannot be reordered away by a utility
66
+ * class and does not read as styling.
67
+ */
68
+ export function dragRegionProps(): Record<string, string> {
69
+ return overlayTitlebar() ? { 'data-drag-region': '' } : {};
70
+ }
71
+
72
+ /**
73
+ * The left inset the first header on the strip must carry.
74
+ *
75
+ * macOS: the traffic lights' width — the caller's own padding would be *inside*
76
+ * them. Windows and Linux: the caller's own padding, because the caption
77
+ * buttons are on the other edge (or drawn by the page) and reserving here would
78
+ * leave a hole. Everywhere else it is what the caller already resolved, which is
79
+ * the PWA's Window Controls Overlay arithmetic (`titlebar-area-x`).
80
+ */
81
+ export function titlebarInsetLeft(resolved: string): string {
82
+ if (!overlayTitlebar()) return resolved;
83
+ return shellInfo()?.trafficLightInset ?? resolved;
84
+ }
85
+
86
+ /**
87
+ * True when the page must draw the window's own controls.
88
+ *
89
+ * The Windows and Linux shells answer true: both are borderless, and Windows'
90
+ * webview is an HWND that no host element can be drawn over, so the three
91
+ * buttons the macOS shell gets from the system have to come from the page there.
92
+ * Linux COULD draw them (`GtkOverlay` children do receive clicks over a WebView,
93
+ * measured), but the page's set already exists and is tested.
94
+ */
95
+ export function drawsWindowControls(): boolean {
96
+ return overlayTitlebar() && shellInfo()?.windowControls === true;
97
+ }
@@ -19,6 +19,7 @@ import { definePluginApp } from '@kabmo-labs/kabmo-sdk/app';
19
19
  import {
20
20
  failPluginBundle,
21
21
  headerDropdownOf,
22
+ headerOpensPanelOf,
22
23
  headerTriggerOf,
23
24
  panelOf,
24
25
  pluginSlotState,
@@ -80,6 +81,59 @@ describe('registerPluginBundle', () => {
80
81
  expect(headerDropdownOf(slots)).toBeUndefined();
81
82
  });
82
83
 
84
+ test('an opensPanel header keeps the slot it opens, and no dropdown', () => {
85
+ // The navbar resolves this to `plugin:<id>:<slot>` and the layout decides
86
+ // the COLUMN, so the store has to carry the slot and nothing else.
87
+ registerPluginBundle('demo', {
88
+ default: definePluginApp((app) => {
89
+ app.rightPanel({ id: 'r', component: Noop });
90
+ app.headerPanel({ id: 'h', component: Noop, opensPanel: 'rightPanel' });
91
+ }),
92
+ });
93
+
94
+ const slots = pluginSlotState().slots.get('demo')!;
95
+ expect(headerOpensPanelOf(slots)).toBe('rightPanel');
96
+ expect(headerDropdownOf(slots)).toBeUndefined();
97
+ });
98
+
99
+ test('a header may not declare both a dropdown and opensPanel', () => {
100
+ // One click cannot open two things. Refused at registration rather than
101
+ // drawing a button that silently does one of them.
102
+ registerPluginBundle('demo', {
103
+ default: definePluginApp((app) =>
104
+ app.headerPanel({ id: 'h', component: Noop, opensPanel: 'panel', dropdown: { component: Noop } }),
105
+ ),
106
+ });
107
+
108
+ const failure = pluginSlotState().failures.find((entry) => entry.pluginId === 'demo');
109
+ expect(failure?.reason).toContain('both a dropdown and opensPanel');
110
+ expect(pluginSlotState().slots.has('demo')).toBe(false);
111
+ });
112
+
113
+ test('an opensPanel naming a panel the plugin never registered is refused', () => {
114
+ // A header that opens a panel this bundle did not contribute is a click
115
+ // with nothing behind it, so the registration is refused by name.
116
+ registerPluginBundle('demo', {
117
+ default: definePluginApp((app) => app.headerPanel({ id: 'h', component: Noop, opensPanel: 'panel' })),
118
+ });
119
+
120
+ const failure = pluginSlotState().failures.find((entry) => entry.pluginId === 'demo');
121
+ expect(failure?.reason).toContain('names a panel this plugin did not register');
122
+ expect(pluginSlotState().slots.has('demo')).toBe(false);
123
+ });
124
+
125
+ test('the panel may be registered AFTER the header that opens it', () => {
126
+ registerPluginBundle('demo', {
127
+ default: definePluginApp((app) => {
128
+ app.headerPanel({ id: 'h', component: Noop, opensPanel: 'rightPanel' });
129
+ app.rightPanel({ id: 'r', component: Noop });
130
+ }),
131
+ });
132
+
133
+ expect(pluginSlotState().failures).toEqual([]);
134
+ expect(headerOpensPanelOf(pluginSlotState().slots.get('demo')!)).toBe('rightPanel');
135
+ });
136
+
83
137
  test('refuses a second registration in the same slot, with the slot named', () => {
84
138
  registerPluginBundle('demo', {
85
139
  default: definePluginApp((app) => {
@@ -65,6 +65,17 @@ export interface PluginSlots {
65
65
  * dropdown is a static readout rather than a broken button.
66
66
  */
67
67
  headerDropdown?: unknown;
68
+ /**
69
+ * The panel a header trigger OPENS, when it declared `opensPanel`.
70
+ *
71
+ * Held here rather than on `components` for the same reason the dropdown is:
72
+ * it is a property of the header registration, and the surface that draws the
73
+ * button (the navbar) is not the surface that draws the panel. The host
74
+ * resolves it to `plugin:<id>:<slot>` at the moment of the click, so the
75
+ * placement rules (left dock, right column, editor column, phone drawer) all
76
+ * apply exactly as they do for the activity bar's own button.
77
+ */
78
+ headerOpensPanel?: 'rightPanel' | 'panel';
68
79
  }
69
80
 
70
81
  /** One plugin's bundle could not be loaded, or refused to register. */
@@ -119,6 +130,11 @@ export function headerDropdownOf(slots: PluginSlots): ComponentType<HeaderDropdo
119
130
  return slots.headerDropdown as ComponentType<HeaderDropdownProps> | undefined;
120
131
  }
121
132
 
133
+ /** The panel a header trigger opens, when it declared `opensPanel`. */
134
+ export function headerOpensPanelOf(slots: PluginSlots): 'rightPanel' | 'panel' | undefined {
135
+ return slots.headerOpensPanel;
136
+ }
137
+
122
138
  export function settingsSectionOf(slots: PluginSlots): ComponentType<SettingsSectionProps> | undefined {
123
139
  return slots.components.settingsSection as ComponentType<SettingsSectionProps> | undefined;
124
140
  }
@@ -142,6 +158,7 @@ export function registerPluginBundle(pluginId: string, module: unknown): void {
142
158
  const titles: PluginSlots['titles'] = {};
143
159
  const sizing: PluginSlots['sizing'] = {};
144
160
  let headerDropdown: unknown;
161
+ let headerOpensPanel: 'rightPanel' | 'panel' | undefined;
145
162
 
146
163
  // A bundle re-imported under a new URL (a rebuild) registers AGAIN, and the
147
164
  // list slots refuse a duplicate id — so the previous rows go first. Doing it
@@ -171,9 +188,15 @@ export function registerPluginBundle(pluginId: string, module: unknown): void {
171
188
  register('rightPanel', component, id, title, { minWidth, defaultFraction }),
172
189
  panel: ({ id, title, component, minWidth, defaultFraction }) =>
173
190
  register('panel', component, id, title, { minWidth, defaultFraction }),
174
- headerPanel: ({ id, title, component, dropdown }) => {
191
+ headerPanel: ({ id, title, component, dropdown, opensPanel }) => {
175
192
  register('headerPanel', component, id, title);
193
+ if (dropdown && opensPanel) {
194
+ throw new Error(
195
+ `headerPanel "${id}" declares both a dropdown and opensPanel — a trigger has one click, so it can open one of them`,
196
+ );
197
+ }
176
198
  if (dropdown) headerDropdown = dropdown.component;
199
+ if (opensPanel) headerOpensPanel = opensPanel;
177
200
  },
178
201
  settingsSection: ({ id, title, component }) => register('settingsSection', component, id, title),
179
202
  // The list slots: each is a keyed registration that RETURNS its own
@@ -205,6 +228,18 @@ export function registerPluginBundle(pluginId: string, module: unknown): void {
205
228
  return;
206
229
  }
207
230
 
231
+ // Checked AFTER `setup`, because a plugin may register the header before the
232
+ // panel it names. A button that opens a panel this bundle never contributed
233
+ // is a click with nothing behind it, so it is refused by name rather than
234
+ // drawn.
235
+ if (headerOpensPanel && !components[headerOpensPanel]) {
236
+ failPluginBundle(
237
+ pluginId,
238
+ `headerPanel opensPanel "${headerOpensPanel}" names a panel this plugin did not register`,
239
+ );
240
+ return;
241
+ }
242
+
208
243
  const slots = new Map(state.slots);
209
244
  slots.set(pluginId, {
210
245
  pluginId,
@@ -212,6 +247,7 @@ export function registerPluginBundle(pluginId: string, module: unknown): void {
212
247
  titles,
213
248
  sizing,
214
249
  ...(headerDropdown ? { headerDropdown } : {}),
250
+ ...(headerOpensPanel ? { headerOpensPanel } : {}),
215
251
  });
216
252
  publish({ slots, failures: state.failures.filter((entry) => entry.pluginId !== pluginId) });
217
253
  }
@@ -168,7 +168,7 @@ describe('closeDb', () => {
168
168
  // continuation is still pending when `closeDb` runs.
169
169
  const pending = getDb();
170
170
  const retired = globalThis.__kabmoDb as { disposed?: boolean } | undefined;
171
- closeDb();
171
+ await closeDb();
172
172
  expect(retired?.disposed).toBe(true);
173
173
 
174
174
  // The open settles as a refusal rather than handing back a handle whose
@@ -181,7 +181,7 @@ describe('closeDb', () => {
181
181
  Bun.env.KABMO_DB_PATH = path.join(tempRoot(), 'after', 'db.sqlite');
182
182
  const db = await getDb();
183
183
  expect(db).toBeDefined();
184
- closeDb();
184
+ await closeDb();
185
185
  } finally {
186
186
  globalThis.__kabmoDb = savedSlot;
187
187
  }
@@ -193,7 +193,7 @@ describe('closeDb', () => {
193
193
  Bun.env.KABMO_DB_PATH = path.join(tempRoot(), 'resolved', 'db.sqlite');
194
194
  globalThis.__kabmoDb = undefined;
195
195
  const db = await getDb();
196
- closeDb();
196
+ await closeDb();
197
197
  expect(globalThis.__kabmoDb ?? null).toBeNull();
198
198
  // A closed handle refuses further work rather than silently succeeding.
199
199
  expect(() => db.raw.exec('SELECT 1')).toThrow();
@@ -201,4 +201,45 @@ describe('closeDb', () => {
201
201
  globalThis.__kabmoDb = savedSlot;
202
202
  }
203
203
  });
204
+
205
+ test('the returned promise settles only once the in-flight handle is closed', async () => {
206
+ // The bug this pins is a RACE, and it is the one the Release gate hit:
207
+ // retiring the slot is synchronous, but an open already in flight still
208
+ // reaches `createDb` and OPENS a file. A caller that deleted the temp
209
+ // directory on the strength of a synchronous return raced a handle that did
210
+ // not exist yet — and could never win, because `removeDir` retries with
211
+ // `Bun.sleepSync`, which blocks the very continuation that closes it.
212
+ //
213
+ // Asserted on the observable the caller needs: after `await closeDb()`, no
214
+ // handle is open and none is about to be. A database file that was opened
215
+ // and closed again reports no further work through it, and the slot is
216
+ // clear, so the next open uses whatever path is configured by then.
217
+ const savedSlot = globalThis.__kabmoDb;
218
+ try {
219
+ const dir = tempRoot();
220
+ Bun.env.KABMO_DB_PATH = path.join(dir, 'settle', 'db.sqlite');
221
+ globalThis.__kabmoDb = undefined;
222
+
223
+ const pending = getDb();
224
+ const retiring = closeDb();
225
+
226
+ // A synchronous return is the bug, not a detail: it is what let a caller
227
+ // delete the directory while the open was still about to create a file.
228
+ expect(retiring).toBeInstanceOf(Promise);
229
+
230
+ // Both settle, and the retirement is what decides when the caller may
231
+ // touch the directory — so it must not resolve before the open has been
232
+ // closed. The open's own rejection is expected and is not an error.
233
+ await expect(pending).rejects.toThrow(/retired/);
234
+ await expect(retiring).resolves.toBeUndefined();
235
+ expect(globalThis.__kabmoDb ?? null).toBeNull();
236
+
237
+ // The proof the caller needs: the directory can be removed the moment the
238
+ // promise settles, with no retry window spent.
239
+ removeDir(dir);
240
+ expect(existsSync(dir)).toBe(false);
241
+ } finally {
242
+ globalThis.__kabmoDb = savedSlot;
243
+ }
244
+ });
204
245
  });
@@ -73,11 +73,25 @@ function slot(): DbSlot {
73
73
  * a lifecycle one: a handle must never outlive the configuration it was opened
74
74
  * under. An in-flight open is marked `disposed` rather than awaited, and the
75
75
  * continuation closes its own handle — see {@link DbSlot.disposed}.
76
+ *
77
+ * **The returned promise is the contract, and it is not decoration.** Marking
78
+ * the slot `disposed` does not stop an open that is already running: the
79
+ * continuation still reaches `createDb`, which OPENS a file. A caller that
80
+ * deleted the temp directory on the strength of a synchronous return therefore
81
+ * raced a handle that did not exist yet — measured on the Release gate, where
82
+ * `releaseDb` failed with `EBUSY … a handle outlived its test` after burning
83
+ * the whole retry window, and the continuation's own rejection surfaced as
84
+ * "Unhandled error between tests". The retry could not win, and the reason is
85
+ * worth stating: `removeDir` spins with `Bun.sleepSync`, which BLOCKS the event
86
+ * loop, so the very continuation that would close the handle could never
87
+ * progress while the delete was being retried.
88
+ *
89
+ * Awaiting this promise means "no handle is open, and none is about to be".
76
90
  */
77
- export function closeDb(): void {
91
+ export function closeDb(): Promise<void> {
78
92
  const state = globalThis.__kabmoDb;
79
93
  globalThis.__kabmoDb = undefined;
80
- if (!state) return;
94
+ if (!state) return Promise.resolve();
81
95
  state.disposed = true;
82
96
  try {
83
97
  state.resolved?.raw.close();
@@ -85,7 +99,17 @@ export function closeDb(): void {
85
99
  // Already closed by a failed open; the slot is gone either way.
86
100
  }
87
101
  state.resolved = null;
102
+ const pending = state.promise;
88
103
  state.promise = null;
104
+ // Swallowed deliberately: the caller is RETIRING this handle, so the
105
+ // continuation's "retired before it finished" rejection is the expected
106
+ // outcome rather than an error. Letting it escape here would resurrect the
107
+ // unhandled-rejection noise this await exists to remove.
108
+ return pending ? pending.then(noop, noop) : Promise.resolve();
109
+ }
110
+
111
+ function noop(): void {
112
+ // See `closeDb`: the settlement is the signal, not the value.
89
113
  }
90
114
 
91
115
  export async function getDatabasePath(): Promise<string> {
@@ -66,12 +66,6 @@ describe('describeShellBuildFailure', () => {
66
66
  expect(await describeShellBuildFailure(root)).toBeNull();
67
67
  });
68
68
 
69
- test('returns null when there is no shell entrypoint to build', async () => {
70
- rmSync(join(root, 'index.html'));
71
-
72
- expect(await describeShellBuildFailure(root)).toBeNull();
73
- });
74
-
75
69
  test('loads the plugins named in bunfig.toml, as the route does', async () => {
76
70
  // A plugin that fails a file the route would bundle: if the probe ignored
77
71
  // bunfig it would report a clean build and hide the real failure.