@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,113 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The window controls, rendered only for a shell that has none of its own.
8
+ *
9
+ * Three properties are what this suite exists for, and each fails as a button
10
+ * that looks fine and does nothing:
11
+ *
12
+ * 1. **Nothing in a browser.** There is no shell, so `window.__kabmoCall` does
13
+ * not exist and a click would be a no-op behind a control that appears to
14
+ * work.
15
+ * 2. **Nothing on macOS**, whose traffic lights are the system's — a second set
16
+ * drawn by the page would be two close buttons for one window.
17
+ * 3. **A click issues the shell's own verb.** The window state lives in the
18
+ * shell, so the only observable here is the command that crossed the bridge.
19
+ */
20
+
21
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
22
+ import { Window } from 'happy-dom';
23
+ import { h, render } from 'preact';
24
+ import { act } from 'preact/test-utils';
25
+ import { WindowControls } from '@/client/components/common/window-controls';
26
+ import { installDomGlobals, restoreDomGlobals } from '@/test-support/pristine-globals';
27
+
28
+ type Shell = {
29
+ __kabmoCall?: (method: string, params?: Record<string, unknown>) => Promise<unknown>;
30
+ __kabmoDesktop?: Record<string, unknown>;
31
+ };
32
+
33
+ const scope = () => globalThis as unknown as { window?: Shell };
34
+
35
+ let win: Window;
36
+ let container: HTMLElement;
37
+ let calls: string[];
38
+
39
+ function mount(identity: Record<string, unknown> | null, withBridge: boolean) {
40
+ calls = [];
41
+ const shellWindow = win as unknown as Shell;
42
+ if (withBridge) {
43
+ shellWindow.__kabmoCall = async (method) => {
44
+ calls.push(method);
45
+ return null;
46
+ };
47
+ } else {
48
+ delete shellWindow.__kabmoCall;
49
+ }
50
+ if (identity) shellWindow.__kabmoDesktop = identity;
51
+ else delete shellWindow.__kabmoDesktop;
52
+
53
+ win.document.body.innerHTML = '';
54
+ container = win.document.createElement('div') as unknown as HTMLElement;
55
+ win.document.body.appendChild(container as never);
56
+ act(() => {
57
+ render(h(WindowControls, {}), container);
58
+ });
59
+ }
60
+
61
+ const buttons = () => Array.from(container.querySelectorAll('button'));
62
+
63
+ describe('WindowControls', () => {
64
+ beforeEach(() => {
65
+ win = new Window({ url: 'http://127.0.0.1:3000/' });
66
+ installDomGlobals(win as never);
67
+ });
68
+
69
+ afterEach(() => {
70
+ restoreDomGlobals();
71
+ delete scope().window;
72
+ });
73
+
74
+ test('three buttons, each with an accessible name', () => {
75
+ mount({ platform: 'windows', shellVersion: '0.1.0', overlayTitlebar: true, windowControls: true }, true);
76
+ const labels = buttons().map((button) => button.getAttribute('aria-label'));
77
+ expect(labels).toEqual(['Minimize', 'Maximize', 'Close']);
78
+ // An icon-only control with no title is a tooltip-less button.
79
+ expect(buttons().every((button) => Boolean(button.getAttribute('title')))).toBe(true);
80
+ });
81
+
82
+ test('a click issues the shell’s own verb', () => {
83
+ mount({ platform: 'windows', shellVersion: '0.1.0', overlayTitlebar: true, windowControls: true }, true);
84
+ for (const button of buttons()) {
85
+ act(() => {
86
+ button.dispatchEvent(new win.MouseEvent('click', { bubbles: true }) as never);
87
+ });
88
+ }
89
+ expect(calls).toEqual(['window_minimize', 'window_maximize', 'window_close']);
90
+ });
91
+
92
+ /** The label follows the click, so a maximised window does not say "Maximize". */
93
+ test('the maximize button reads Restore after a click', () => {
94
+ mount({ platform: 'windows', shellVersion: '0.1.0', overlayTitlebar: true, windowControls: true }, true);
95
+ const maximize = buttons()[1];
96
+ expect(maximize.getAttribute('aria-label')).toBe('Maximize');
97
+ act(() => {
98
+ maximize.dispatchEvent(new win.MouseEvent('click', { bubbles: true }) as never);
99
+ });
100
+ expect(buttons()[1].getAttribute('aria-label')).toBe('Restore');
101
+ });
102
+
103
+ /** A bridge that is missing must not throw out of a click handler. */
104
+ test('a click without a bridge is inert', () => {
105
+ mount(null, false);
106
+ expect(() => {
107
+ act(() => {
108
+ buttons()[0].dispatchEvent(new win.MouseEvent('click', { bubbles: true }) as never);
109
+ });
110
+ }).not.toThrow();
111
+ expect(calls).toEqual([]);
112
+ });
113
+ });
@@ -0,0 +1,66 @@
1
+ import { useState } from 'preact/hooks';
2
+ import { Minus, Square, X } from 'lucide-preact';
3
+
4
+ /**
5
+ * The window's own controls, for a shell that has none.
6
+ *
7
+ * The macOS shell keeps the system's traffic lights (a `fullSizeContentView`
8
+ * window over a transparent title bar), so the page draws nothing. The Windows
9
+ * shell is borderless, and its webview is an HWND — no host element can be
10
+ * painted over it — so the three buttons the system would draw have to come from
11
+ * the page. `drawsWindowControls()` is what says which shell this is; the buttons
12
+ * exist only there, and a browser tab never renders them.
13
+ *
14
+ * Two decisions:
15
+ *
16
+ * - **The glyphs are Lucide's, not the OS's.** Windows draws its own caption
17
+ * buttons at a size and weight that a web font cannot reproduce, so pretending
18
+ * to would look almost right — the worst outcome. These read as the app's
19
+ * chrome, which is what they are.
20
+ * - **Close is the only one with a destructive hover.** A red fill on minimise
21
+ * would make a harmless action look dangerous.
22
+ *
23
+ * Every button is `aria-label`ed and titled: an icon-only control with no
24
+ * accessible name is unreachable by keyboard and invisible to a screen reader.
25
+ */
26
+ export function WindowControls() {
27
+ const [maximised, setMaximised] = useState(false);
28
+ const call = (method: 'window_minimize' | 'window_maximize' | 'window_close') => {
29
+ void window.__kabmoCall?.(method).catch(() => undefined);
30
+ // The shell owns the state; this only keeps the glyph honest between the
31
+ // click and the window's own resize notification.
32
+ if (method === 'window_maximize') setMaximised((value) => !value);
33
+ };
34
+
35
+ return (
36
+ <div className="flex items-stretch h-10 -mr-1 titlebar-no-drag" role="group" aria-label="Window controls">
37
+ <button
38
+ type="button"
39
+ onClick={() => call('window_minimize')}
40
+ title="Minimize"
41
+ aria-label="Minimize"
42
+ className="w-11 inline-flex items-center justify-center text-ink/60 hover:bg-ink/10 hover:text-ink transition-colors cursor-pointer"
43
+ >
44
+ <Minus size={14} />
45
+ </button>
46
+ <button
47
+ type="button"
48
+ onClick={() => call('window_maximize')}
49
+ title={maximised ? 'Restore' : 'Maximize'}
50
+ aria-label={maximised ? 'Restore' : 'Maximize'}
51
+ className="w-11 inline-flex items-center justify-center text-ink/60 hover:bg-ink/10 hover:text-ink transition-colors cursor-pointer"
52
+ >
53
+ <Square size={12} />
54
+ </button>
55
+ <button
56
+ type="button"
57
+ onClick={() => call('window_close')}
58
+ title="Close (Alt+F4)"
59
+ aria-label="Close"
60
+ className="w-11 inline-flex items-center justify-center text-ink/60 hover:bg-error hover:text-paper transition-colors cursor-pointer"
61
+ >
62
+ <X size={15} />
63
+ </button>
64
+ </div>
65
+ );
66
+ }
@@ -46,7 +46,7 @@ import { SettingsModal } from '@/client/components/settings/LazyModal';
46
46
  import { SessionSidebarFooter } from '@/client/components/layout/session-sidebar/Footer';
47
47
  import { SessionSidebarHeader } from '@/client/components/layout/session-sidebar/Header';
48
48
  import { ToastStack } from '@/client/components/common/ToastStack';
49
- import { useAndroidDownload } from '@/client/hooks/ui/updates/android-download';
49
+ import { useShellOffer } from '@/client/hooks/ui/updates/shell-download';
50
50
  import { useKabmoEvent } from '@/client/hooks/ui/window-event';
51
51
  import { useToasts } from '@/client/hooks/ui/toasts';
52
52
  import { useUpdates } from '@/client/hooks/ui/updates';
@@ -95,10 +95,7 @@ export function LeftColumn({
95
95
  const [infoOpen, setInfoOpen] = useState(false);
96
96
  const updates = useUpdates();
97
97
  const { toasts, pushToast, dismissToast } = useToasts();
98
- const androidDownload = useAndroidDownload(
99
- window.__OMP_BOOTSTRAP__?.shellVersion,
100
- window.__OMP_BOOTSTRAP__?.isAndroid,
101
- );
98
+ const shellDownload = useShellOffer();
102
99
 
103
100
  // The update popup's "Update now" hands the run to the About modal, which owns
104
101
  // the live output. The column owns both, so a view switch cannot lose them.
@@ -151,7 +148,7 @@ export function LeftColumn({
151
148
  <SessionSidebarFooter
152
149
  onSettings={() => setSettingsOpen(true)}
153
150
  onInfo={() => setInfoOpen(true)}
154
- androidDownload={androidDownload}
151
+ shellDownload={shellDownload}
155
152
  updateAvailable={updates.hasUpdate}
156
153
  onUpdateClick={() => {
157
154
  setInfoOpen(true);
@@ -1,4 +1,5 @@
1
- import { Loader2, MoreHorizontal, PanelLeft } from 'lucide-preact';
1
+ import { useRef, useState } from 'preact/hooks';
2
+ import { Loader2, MoreHorizontal, PanelLeft, ServerCog } from 'lucide-preact';
2
3
  import { HeaderControls } from '@/client/components/common/header-controls';
3
4
  import { BrandWordmark } from '@/client/components/common/brand-wordmark';
4
5
  import { StreamStatusDot } from '@/client/components/common/StreamStatusDot';
@@ -8,6 +9,12 @@ import { useSessionRenaming } from '@/client/hooks/workspace/renaming-sessions';
8
9
  import type { AgentStreamStatus } from '@/shared/lib/chat/omp/status';
9
10
  import { withChord } from '@/client/lib/ui/with-chord';
10
11
  import { titlebarPaddingLeft, titlebarPaddingRight } from '@/shared/lib/ui/titlebar';
12
+ import { ServerSwitcher } from '@/client/components/common/server-switcher';
13
+ import { useDesktopServers } from '@/client/hooks/desktop/servers';
14
+ import { isDesktopShell } from '@/client/lib/desktop/bridge';
15
+ import { dragRegionProps, drawsWindowControls, titlebarInsetLeft } from '@/client/lib/desktop/titlebar';
16
+ import { WindowControls } from '@/client/components/common/window-controls';
17
+ import { useOnClickOutside } from '@/client/hooks/ui/on-click-outside';
11
18
 
12
19
  interface TopNavbarProps {
13
20
  sessionTitle: string | null;
@@ -55,15 +62,29 @@ export function TopNavbar({
55
62
  // sidebar row may be scrolled out of view — so the header says the wait is
56
63
  // happening even when the row that started it is not on screen.
57
64
  const renaming = useSessionRenaming(sessionId);
65
+ // The shell's server control: which server this window is on, and how to
66
+ // change it. Absent in a browser — there is no shell to control there, and a
67
+ // button that opened onto "not available" would never work.
68
+ const shell = isDesktopShell();
69
+ const [serverPanelOpen, setServerPanelOpen] = useState(false);
70
+ const serverPanelRef = useRef<HTMLDivElement>(null);
71
+ const servers = useDesktopServers(serverPanelOpen);
72
+ const activeServer =
73
+ servers.state?.entries.find((entry) => entry.id === servers.state?.activeId) ??
74
+ servers.state?.detected.find((entry) => entry.url === servers.state?.activeUrl) ??
75
+ null;
76
+ useOnClickOutside(serverPanelRef, () => setServerPanelOpen(false));
77
+
58
78
  const hasSessionActions = Boolean(
59
79
  sessionActions && (sessionActions.onRename || sessionActions.onRenameWithAi || sessionActions.onArchive || sessionActions.onDelete)
60
80
  );
61
81
  return (
62
- <header
82
+ <header
63
83
  className="h-10 flex-shrink-0 border-b border-ink/10 bg-paper flex items-center justify-between z-20 titlebar-drag-region select-none"
84
+ {...dragRegionProps()}
64
85
  style={{
65
86
  // The sidebar's own header carries the left inset while it is open.
66
- paddingLeft: !showLeftPanel ? titlebarPaddingLeft('1rem') : undefined,
87
+ paddingLeft: !showLeftPanel ? titlebarInsetLeft(titlebarPaddingLeft('1rem')) : undefined,
67
88
  paddingRight: titlebarPaddingRight('1rem'),
68
89
  }}
69
90
  >
@@ -142,6 +163,37 @@ export function TopNavbar({
142
163
  <div className="flex items-center space-x-3 titlebar-no-drag">
143
164
  {/* One cluster, one order, shared with the phone's header. Each layout
144
165
  passes only what it has; the order is stated in `HeaderControls`. */}
166
+ {shell && (
167
+ <div className="relative" ref={serverPanelRef}>
168
+ <button
169
+ type="button"
170
+ onClick={() => setServerPanelOpen((open) => !open)}
171
+ title={
172
+ activeServer
173
+ ? `Server: ${activeServer.label} — ${activeServer.running ? 'running' : 'stopped'}`
174
+ : 'Choose a server'
175
+ }
176
+ aria-label="Servers"
177
+ aria-haspopup="dialog"
178
+ aria-expanded={serverPanelOpen}
179
+ className="flex items-center gap-1.5 rounded px-1.5 py-1 text-ink/60 hover:text-ink hover:bg-ink/10 transition-colors cursor-pointer"
180
+ >
181
+ <ServerCog size={16} />
182
+ {activeServer && (
183
+ <span className="max-w-[120px] truncate text-[11px] font-medium text-ink/70">
184
+ {activeServer.label}
185
+ </span>
186
+ )}
187
+ <span
188
+ className={`w-1.5 h-1.5 rounded-full ${
189
+ activeServer?.running ? 'bg-success' : 'bg-ink/25'
190
+ }`}
191
+ aria-hidden="true"
192
+ />
193
+ </button>
194
+ <ServerSwitcher open={serverPanelOpen} onClose={() => setServerPanelOpen(false)} />
195
+ </div>
196
+ )}
145
197
  <HeaderControls
146
198
  layout="desktop"
147
199
  sessionId={sessionId ?? null}
@@ -151,6 +203,10 @@ export function TopNavbar({
151
203
  onToggleEditor={onToggleEditor}
152
204
  onSwitchToMobile={onSwitchToMobile}
153
205
  />
206
+ {/* The Windows shell is borderless and its webview is an HWND, so the
207
+ system's caption buttons cannot be drawn over it — the page owns
208
+ them there. Absent on macOS, whose traffic lights are the system's. */}
209
+ {drawsWindowControls() && <WindowControls />}
154
210
  </div>
155
211
 
156
212
  {menu.anchor && sessionActions && (
@@ -16,13 +16,13 @@ import { useSidebarData } from '@/client/hooks/chat/omp/session-list';
16
16
  import { MIN_LEFT_PANEL_WIDTH } from '@/shared/lib/workspace/sidebar-width';
17
17
  import { MIN_LEFT_DOCK_PLUGIN_WIDTH } from '@/shared/lib/workspace/left-dock-width';
18
18
  import { SESSIONS_VIEW_ID } from '@/shared/lib/workspace/left-dock';
19
+ import { useOpenPanelRequest } from '@/client/hooks/workspace/open-panel';
19
20
  import { useLeftDock, useLeftDockWidth } from '@/client/hooks/workspace/left-dock';
20
21
  import { useLeftDockView } from '@/client/components/workspace/plugin-panel/catalog';
21
22
  import type { EditorWidthMode } from '@/shared/lib/workspace/panel-widths';
22
- import { useKabmoEvent, useWindowEvent } from '@/client/hooks/ui/window-event';
23
+ import { useKabmoEvent } from '@/client/hooks/ui/window-event';
24
+ import { useWorkspaceKeybindings } from '@/client/hooks/workspace/keybindings';
23
25
  import { PluginBootstrap } from '@/client/lib/plugins/bootstrap';
24
- import { WORKSPACE_KEY_BINDINGS } from '@/shared/lib/workspace/keymap';
25
- import { resolveBinding } from '@/shared/lib/ui/key-binding';
26
26
  import { useToasts } from '@/client/hooks/ui/toasts';
27
27
  import type { EditorDropResult } from '@/client/hooks/editor/use-editor-drop';
28
28
  import { ToastStack } from '@/client/components/common/ToastStack';
@@ -158,32 +158,20 @@ export function DesktopLayout({ sessionId, onSwitchToMobile, appSettings = {} }:
158
158
  }
159
159
  : undefined;
160
160
 
161
- /**
162
- * The workspace shortcuts, from the shared keymap so the toggles' tooltips
163
- * name the bound chord. `⌘S` is absent: the editor owns it, and binding it
164
- * twice would save the file twice.
165
- */
166
- useWindowEvent('keydown', (e) => {
167
- const command = resolveBinding(WORKSPACE_KEY_BINDINGS, e);
168
- if (!command) return;
169
- if (command === 'saveFile') return;
170
- if (command === 'toggleEditorPanel' && !showEditor) {
171
- // The editor panel needs something to show — a file or a takeover — and
172
- // `showEditor` already folds in the user's own toggle.
173
- // The toggle is also the only way to REACH a takeover while the column is
174
- // closed, since the rail lives inside the column: with no file and no
175
- // takeover there would be nothing to open on to, and with one there is.
176
- if (openedFiles.length === 0 && !activeEditorPanelKey && editorTakeovers.length === 0) return;
177
- }
178
- e.preventDefault();
179
- if (command === 'toggleSidebar') setShowLeftPanel((previous) => !previous);
180
- else if (command === 'toggleEditorPanel') setUserToggledEditor((previous) => !(previous ?? showEditor));
181
- else if (command === 'toggleRightPanel') setShowRightPanel((previous) => !previous);
182
- else if (command === 'openSettings') {
161
+ // The workspace shortcuts, from the shared keymap so the toggles' tooltips
162
+ // name the bound chord. `⌘S` is absent: the editor owns it, and binding it
163
+ // twice would save the file twice.
164
+ useWorkspaceKeybindings({
165
+ showEditor,
166
+ editorHasContent,
167
+ toggleSidebar: () => setShowLeftPanel((previous) => !previous),
168
+ toggleEditor: () => setUserToggledEditor((previous) => !(previous ?? showEditor)),
169
+ toggleRightPanel: () => setShowRightPanel((previous) => !previous),
170
+ openSettings: () => {
183
171
  setSettingsCategory('appearance');
184
172
  setAutoOpenAddProvider(false);
185
173
  setSettingsOpen((previous) => !previous);
186
- }
174
+ },
187
175
  });
188
176
 
189
177
  // Global event listener for settings triggers
@@ -210,6 +198,26 @@ export function DesktopLayout({ sessionId, onSwitchToMobile, appSettings = {} }:
210
198
  const handleToggleRightPanel = () => setShowRightPanel(!showRightPanel);
211
199
  const handleToggleEditor = () => setUserToggledEditor(!showEditor);
212
200
 
201
+ // A panel asked to be shown from somewhere that is not its own rail — a
202
+ // plugin's navbar readout (`app.headerPanel({ opensPanel })`). Where it lives
203
+ // is the USER's placement, so the handler resolves the column from the dock
204
+ // state and opens it; see `useOpenPanelRequest`.
205
+ useOpenPanelRequest({
206
+ dock,
207
+ onLeft: (panelKey) => {
208
+ setActiveLeftView(panelKey);
209
+ setShowLeftPanel(true);
210
+ },
211
+ onEditor: (panelKey) => {
212
+ setActiveEditorPanelKey(panelKey);
213
+ setUserToggledEditor(true);
214
+ },
215
+ onRight: (panelKey) => {
216
+ setActiveRightPanel(panelKey);
217
+ setShowRightPanel(true);
218
+ },
219
+ });
220
+
213
221
  return (
214
222
  <div
215
223
  data-testid="app-root"
@@ -1,16 +1,16 @@
1
1
  import { Download, Info, Settings } from 'lucide-preact';
2
2
  import {
3
- androidDownloadLabel,
4
- type AndroidDownload,
5
- } from '@/client/hooks/ui/updates/android-download';
3
+ shellDownloadLabel,
4
+ type ShellDownload,
5
+ } from '@/client/hooks/ui/updates/shell-download';
6
6
 
7
7
  interface SessionSidebarFooterProps {
8
8
  onSettings: () => void;
9
9
  onInfo: () => void;
10
10
  updateAvailable?: boolean;
11
11
  onUpdateClick?: () => void;
12
- /** The APK to offer, or null. Set inside the app (an update) or on an Android browser (an install). */
13
- androidDownload?: AndroidDownload | null;
12
+ /** The native-shell artifact to offer, or null. Set inside a shell (an update) or on an Android browser (an install). */
13
+ shellDownload?: ShellDownload | null;
14
14
  }
15
15
 
16
16
  /**
@@ -23,7 +23,7 @@ interface SessionSidebarFooterProps {
23
23
  * is unreachable by keyboard"). It is also what lets a browser spec address
24
24
  * these controls by role and name instead of by their position in the DOM.
25
25
  */
26
- export function SessionSidebarFooter({ onSettings, onInfo, updateAvailable, onUpdateClick, androidDownload }: SessionSidebarFooterProps) {
26
+ export function SessionSidebarFooter({ onSettings, onInfo, updateAvailable, onUpdateClick, shellDownload }: SessionSidebarFooterProps) {
27
27
  return (
28
28
  <div className="h-10 px-3 border-t border-ink/10 flex items-center justify-between text-ink/60 shrink-0">
29
29
  <div className="flex space-x-1">
@@ -45,17 +45,17 @@ export function SessionSidebarFooter({ onSettings, onInfo, updateAvailable, onUp
45
45
  >
46
46
  <Info size={16} />
47
47
  </button>
48
- {androidDownload && (
48
+ {shellDownload && (
49
49
  // Next to About. Two reasons to be here, and the label says which:
50
- // an installed APK that is behind (an update), or an Android browser
50
+ // an installed shell that is behind (an update), or an Android browser
51
51
  // whose reader may not know the shell exists (an install). A desktop
52
- // browser gets neither — see `useAndroidDownload`.
52
+ // browser and an iOS browser get neither — see `useShellDownload`.
53
53
  <a
54
- href={androidDownload.url}
54
+ href={shellDownload.url}
55
55
  target="_blank"
56
56
  rel="noopener noreferrer"
57
- title={androidDownloadLabel(androidDownload)}
58
- aria-label={androidDownloadLabel(androidDownload)}
57
+ title={shellDownloadLabel(shellDownload)}
58
+ aria-label={shellDownloadLabel(shellDownload)}
59
59
  className="p-1 rounded hover:text-ink hover:bg-ink/5 cursor-pointer transition-colors"
60
60
  >
61
61
  <Download size={16} />
@@ -3,6 +3,7 @@ import { BrandWordmark } from '@/client/components/common/brand-wordmark';
3
3
  import { StreamStatusDot } from '@/client/components/common/StreamStatusDot';
4
4
  import { withChord } from '@/client/lib/ui/with-chord';
5
5
  import { titlebarPaddingLeft } from '@/shared/lib/ui/titlebar';
6
+ import { dragRegionProps, titlebarInsetLeft } from '@/client/lib/desktop/titlebar';
6
7
  import type { AgentStreamStatus } from '@/shared/lib/chat/omp/status';
7
8
 
8
9
  /**
@@ -28,9 +29,10 @@ export function SessionSidebarHeader({
28
29
  }) {
29
30
  const label = withChord('toggleSidebar', 'Collapse Sidebar');
30
31
  return (
31
- <div
32
+ <div
32
33
  className="h-10 flex-shrink-0 flex items-center justify-between px-4 border-b border-ink/10 titlebar-drag-region select-none"
33
- style={{ paddingLeft: titlebarPaddingLeft('1rem') }}
34
+ {...dragRegionProps()}
35
+ style={{ paddingLeft: titlebarInsetLeft(titlebarPaddingLeft('1rem')) }}
34
36
  >
35
37
  {/* The stream indicator badges the wordmark's top-right corner, the
36
38
  same placement the phone's header uses — it reads as part of the
@@ -20,6 +20,10 @@ import { useEditorDrop, type EditorDropResult } from '@/client/hooks/editor/use-
20
20
  import { EditorDropOverlay } from '@/client/components/workspace/editor/EditorDropOverlay';
21
21
  import { PluginBootstrap } from '@/client/lib/plugins/bootstrap';
22
22
  import { mobileEditorFileFrom, type MobileEditorFile } from '@/client/components/mobile/open-file-payload';
23
+ import { useLeftDock } from '@/client/hooks/workspace/left-dock';
24
+ import { useSessionStateContext } from '@/client/hooks/workspace/session-state/context';
25
+ import { setSessionKey } from '@/shared/lib/workspace/session-state/store';
26
+ import { OPEN_PANEL_EVENT, phoneDrawerForPanel, type OpenPanelDetail } from '@/shared/lib/workspace/panel-open';
23
27
 
24
28
  interface MobileLayoutWrapperProps {
25
29
  onDesktopToggle?: () => void;
@@ -30,6 +34,11 @@ export type MobileScreen = 'main' | 'session' | 'right';
30
34
 
31
35
  export function MobileLayoutWrapper({ onDesktopToggle, appSettings = {} }: MobileLayoutWrapperProps) {
32
36
  const { folders } = useSidebarData();
37
+ // The placement state the panel-open event resolves against, and the session
38
+ // the tab it writes belongs to — read once here rather than per drawer,
39
+ // because the drawer that receives the selection is not mounted yet.
40
+ const dock = useLeftDock();
41
+ const { sessionId: stateSessionId } = useSessionStateContext();
33
42
  const [searchParams, setSearchParams] = useSearchParams();
34
43
  const sessionParam = searchParams.get('sessionId');
35
44
  const sessionId = sessionParam ? (Number.isNaN(Number(sessionParam)) ? sessionParam : Number(sessionParam)) : null;
@@ -135,6 +144,33 @@ export function MobileLayoutWrapper({ onDesktopToggle, appSettings = {} }: Mobil
135
144
  // transcript opens behind the drawer and the tap reads as a no-op.
136
145
  useKabmoEvent('omp:view-subagent', () => setCurrentScreen('main'));
137
146
 
147
+ /**
148
+ * A plugin's navbar readout asking for its own panel.
149
+ *
150
+ * A phone has no rail, so "show this panel" is two decisions: which DRAWER can
151
+ * reach it, and which chip inside that drawer is the view. Both are answered
152
+ * from the placement state (`phoneDrawerForPanel`), never from the panel's
153
+ * slot — a panel placed on the left is drawn by the RIGHT drawer while the
154
+ * rail is off, and naming the drawer from the slot would open one that does
155
+ * not list it.
156
+ *
157
+ * The tab is written through the session store rather than through a prop:
158
+ * the drawer mounts on this screen change and reads the stored tab, so the
159
+ * selection has to be in the store before it mounts. An `editor`-slot
160
+ * takeover resolves to null — the phone has no editor column — and the click
161
+ * then does nothing rather than opening a drawer that cannot show it.
162
+ */
163
+ useKabmoEvent(OPEN_PANEL_EVENT, (event) => {
164
+ const panelKey = (event as CustomEvent<OpenPanelDetail>).detail?.panelKey;
165
+ if (!panelKey) return;
166
+ const drawer = phoneDrawerForPanel(panelKey, dock);
167
+ if (!drawer) return;
168
+ // The phone's two drawers are SCREENS, not columns: the session screen
169
+ // draws the left dock and the right screen draws the panel drawer.
170
+ setSessionKey(stateSessionId, drawer === 'left' ? 'layout.mobileLeftPanel' : 'layout.mobileRightPanel', panelKey);
171
+ setCurrentScreen(drawer === 'left' ? 'session' : 'right');
172
+ });
173
+
138
174
  // Settings requests (e.g. the model dropdown asking for Settings → Providers
139
175
  // when no provider is configured) arrive on the same global channel the
140
176
  // desktop layout listens on. SettingsModal itself only reads the payload and
@@ -4,7 +4,7 @@ import { usePanelCatalog, type PanelBodyProps } from '@/client/components/worksp
4
4
  import { PanelHostProvider } from '@kabmo-labs/kabmo-ui';
5
5
  import { useGitStatus } from '@/client/hooks/workspace/git-status';
6
6
  import { useResolvedRepo } from '@/client/hooks/workspace/repo-scope';
7
- import { useSessionState } from '@/client/hooks/workspace/session-state';
7
+ import { useSharedSessionState } from '@/client/hooks/workspace/session-state';
8
8
  import { useLeftDock } from '@/client/hooks/workspace/left-dock';
9
9
  import { SESSIONS_VIEW_ID } from '@/shared/lib/workspace/left-dock';
10
10
  import { phoneRightPanels } from '@/shared/lib/workspace/mobile-dock';
@@ -45,7 +45,7 @@ export function MobileRightSidebar({
45
45
  // back on the Search view re-ran a query that can hold thousands of hits, and
46
46
  // the user never asked for a search. Restoring the tab they left also keeps
47
47
  // the phone and the desktop on the same view.
48
- const [activeTab, setActiveTab] = useSessionState<string>('layout.mobileRightPanel', 'files');
48
+ const [activeTab, setActiveTab] = useSharedSessionState<string>('layout.mobileRightPanel', 'files');
49
49
  // ONE catalog, the same one the desktop bars read, and deliberately `all`:
50
50
  // a phone has ONE drawer, so a plugin panel the user placed on the left must
51
51
  // stay reachable here — reading only the right column would make placing a
@@ -3,7 +3,7 @@ import { MobileSessionHeader } from '@/client/components/mobile/mobile-session-s
3
3
  import { MobileSessionToolbar } from '@/client/components/mobile/mobile-session-sidebar/Toolbar';
4
4
  import { MobileSessionList } from '@/client/components/mobile/mobile-session-sidebar/List';
5
5
  import { MobileSessionFooter } from '@/client/components/mobile/mobile-session-sidebar/Footer';
6
- import { useAndroidDownload } from '@/client/hooks/ui/updates/android-download';
6
+ import { useShellOffer } from '@/client/hooks/ui/updates/shell-download';
7
7
  import { useOnClickOutside } from '@/client/hooks/ui/on-click-outside';
8
8
  import { useKabmoEvent } from '@/client/hooks/ui/window-event';
9
9
  import { useToasts } from '@/client/hooks/ui/toasts';
@@ -83,12 +83,9 @@ export function MobileSessionSidebar({
83
83
  const scheduledCount = useScheduledTaskCount();
84
84
  const [aboutOpen, setAboutOpen] = useState(false);
85
85
  const updates = useUpdates();
86
- // An update inside the APK, an install on an Android browser, nothing
87
- // elsewhere — see `useAndroidDownload`.
88
- const androidDownload = useAndroidDownload(
89
- window.__OMP_BOOTSTRAP__?.shellVersion,
90
- window.__OMP_BOOTSTRAP__?.isAndroid,
91
- );
86
+ // An update inside a native shell, an install on an Android browser, nothing
87
+ // elsewhere — see `useShellOffer`.
88
+ const shellDownload = useShellOffer();
92
89
  const { toasts, pushToast, dismissToast } = useToasts();
93
90
  const cancelWait = useCancelWait(pushToast);
94
91
  useWaitingDispatch({ onNotice: pushToast });
@@ -298,7 +295,7 @@ export function MobileSessionSidebar({
298
295
  <MobileSessionFooter
299
296
  onSettings={() => window.dispatchEvent(new CustomEvent('omp:open-settings', { detail: {} }))}
300
297
  onAbout={() => setAboutOpen(true)}
301
- androidDownload={androidDownload}
298
+ shellDownload={shellDownload}
302
299
  onDesktopToggle={onDesktopToggle}
303
300
  updateAvailable={updates.hasUpdate}
304
301
  onUpdateClick={() => {
@@ -1,17 +1,18 @@
1
1
  import { Download, Info, Monitor, Settings } from 'lucide-preact';
2
2
  import packageJson from '@/../package.json';
3
3
  import {
4
- androidDownloadLabel,
5
- type AndroidDownload,
6
- } from '@/client/hooks/ui/updates/android-download';
4
+ shellDownloadLabel,
5
+ shellDownloadShortLabel,
6
+ type ShellDownload,
7
+ } from '@/client/hooks/ui/updates/shell-download';
7
8
 
8
9
  interface MobileSessionFooterProps {
9
10
  onSettings: () => void;
10
11
  onAbout: () => void;
11
12
  updateAvailable?: boolean;
12
13
  onUpdateClick?: () => void;
13
- /** The APK to offer, or null. Set inside the app (an update) or on an Android browser (an install). */
14
- androidDownload?: AndroidDownload | null;
14
+ /** The native-shell artifact to offer, or null. Set inside a shell (an update) or on an Android browser (an install). */
15
+ shellDownload?: ShellDownload | null;
15
16
  /** Switch the app to the desktop layout for this page session only. The
16
17
  * choice is not persisted, so the next load re-detects the device. */
17
18
  onDesktopToggle?: () => void;
@@ -23,7 +24,7 @@ export function MobileSessionFooter({
23
24
  updateAvailable,
24
25
  onUpdateClick,
25
26
  onDesktopToggle,
26
- androidDownload,
27
+ shellDownload,
27
28
  }: MobileSessionFooterProps) {
28
29
  return (
29
30
  <div
@@ -47,7 +48,7 @@ export function MobileSessionFooter({
47
48
  <Info size={14} />
48
49
  <span>About</span>
49
50
  </button>
50
- {androidDownload && (
51
+ {shellDownload && (
51
52
  // Directly after About, matching the desktop footer. An `<a>`, not a
52
53
  // button: the download is the browser's (or the WebView's) job, and
53
54
  // `target="_blank"` hands it to the system browser so the console is
@@ -55,17 +56,19 @@ export function MobileSessionFooter({
55
56
  //
56
57
  // The visible word is the OFFER, not the file type: `APK` told a
57
58
  // reader what the bytes are but not what the control does, and on a
58
- // browser it is the only place the native shell is ever mentioned.
59
+ // browser it is the only place the native shell is ever mentioned. An
60
+ // iOS update is an IPA, so the word names the platform instead of a
61
+ // file extension.
59
62
  <a
60
- href={androidDownload.url}
63
+ href={shellDownload.url}
61
64
  target="_blank"
62
65
  rel="noopener noreferrer"
63
- title={androidDownloadLabel(androidDownload)}
64
- aria-label={androidDownloadLabel(androidDownload)}
66
+ title={shellDownloadLabel(shellDownload)}
67
+ aria-label={shellDownloadLabel(shellDownload)}
65
68
  className="flex items-center space-x-1 hover:text-ink transition-colors"
66
69
  >
67
70
  <Download size={14} />
68
- <span>{androidDownload.kind === 'update' ? 'Update APK' : 'Get APK'}</span>
71
+ <span>{shellDownloadShortLabel(shellDownload)}</span>
69
72
  </a>
70
73
  )}
71
74
  {onDesktopToggle && (