@kabmo-labs/kabmo 0.5.3 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client/index.html +1 -1
- package/dist/client/index.js +359 -350
- package/dist/client/static/js/{index-9js9mrmj.js → index-aq0e78gv.js} +704 -704
- package/dist/client/static/js/index-nzyjf92m.css +1 -0
- package/marketplace/plugins/stats/README.md +26 -4
- package/marketplace/plugins/stats/package.json +2 -2
- package/marketplace/plugins/stats/src/HeaderReadout.test.tsx +157 -0
- package/marketplace/plugins/stats/src/HeaderReadout.tsx +115 -0
- package/marketplace/plugins/stats/src/app.tsx +11 -0
- package/marketplace/plugins/stats/src/format.test.ts +27 -1
- package/marketplace/plugins/stats/src/format.ts +41 -0
- package/package.json +2 -2
- package/src/client/bootstrap.d.ts +11 -0
- package/src/client/components/common/header-controls/MoreMenu.tsx +38 -1
- package/src/client/components/common/header-controls/index.tsx +9 -2
- package/src/client/components/common/header-controls/plugin-entry.test.tsx +229 -0
- package/src/client/components/common/header-panel-buttons/entries.test.ts +46 -0
- package/src/client/components/common/header-panel-buttons/entries.ts +109 -0
- package/src/client/components/common/header-panel-buttons/index.tsx +71 -38
- package/src/client/components/common/server-switcher/index.test.tsx +150 -0
- package/src/client/components/common/server-switcher/index.tsx +281 -0
- package/src/client/components/common/window-controls/index.test.tsx +113 -0
- package/src/client/components/common/window-controls/index.tsx +66 -0
- package/src/client/components/layout/desktop-layout/LeftColumn.tsx +3 -6
- package/src/client/components/layout/desktop-layout/TopNavbar.tsx +59 -3
- package/src/client/components/layout/desktop-layout/index.tsx +34 -26
- package/src/client/components/layout/session-sidebar/Footer.tsx +12 -12
- package/src/client/components/layout/session-sidebar/Header.tsx +4 -2
- package/src/client/components/mobile/LayoutWrapper.tsx +36 -0
- package/src/client/components/mobile/RightSidebar.tsx +2 -2
- package/src/client/components/mobile/SessionSidebar.tsx +5 -8
- package/src/client/components/mobile/mobile-session-sidebar/Footer.tsx +15 -12
- package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +9 -2
- package/src/client/components/mobile/mobile-session-sidebar/folder-gap.test.ts +41 -0
- package/src/client/components/mobile/mobile-session-sidebar/folder-gap.ts +39 -0
- package/src/client/components/mobile/mobile-session-sidebar/use-dock.ts +6 -2
- package/src/client/components/settings/categories/OmpSettings.tsx +25 -29
- package/src/client/components/settings/categories/omp-settings/PresetBar.tsx +187 -0
- package/src/client/components/settings/categories/omp-settings/RoleEditor.test.ts +260 -32
- package/src/client/components/settings/categories/omp-settings/RoleEditor.tsx +198 -102
- package/src/client/components/settings/categories/omp-settings/RoleRow.tsx +79 -0
- package/src/client/data/settings/omp-schema.json +2 -2
- package/src/client/hooks/desktop/servers.test.ts +73 -0
- package/src/client/hooks/desktop/servers.ts +126 -0
- package/src/client/hooks/settings/model-roles.ts +156 -0
- package/src/client/hooks/ui/updates/shell-download.test.ts +157 -0
- package/src/client/hooks/ui/updates/shell-download.ts +220 -0
- package/src/client/hooks/workspace/keybindings.ts +44 -0
- package/src/client/hooks/workspace/open-panel.ts +42 -0
- package/src/client/lib/desktop/bridge.ts +158 -0
- package/src/client/lib/desktop/titlebar.test.ts +99 -0
- package/src/client/lib/desktop/titlebar.ts +97 -0
- package/src/client/lib/plugins/slots.test.ts +54 -0
- package/src/client/lib/plugins/slots.ts +37 -1
- package/src/server/db.server.test.ts +44 -3
- package/src/server/db.server.ts +26 -2
- package/src/server/lib/bundler/titlebar-css.test.ts +75 -0
- package/src/server/lib/db/schema.ts +3 -0
- package/src/server/lib/model-roles/catalog.server.test.ts +75 -0
- package/src/server/lib/model-roles/catalog.server.ts +112 -0
- package/src/server/lib/model-roles/mock.server.ts +40 -0
- package/src/server/lib/model-roles/schema.server.ts +36 -0
- package/src/server/lib/model-roles/store.server.test.ts +218 -0
- package/src/server/lib/model-roles/store.server.ts +188 -0
- package/src/server/lib/omp/rpc/destroy-stream-row.test.ts +4 -4
- package/src/server/lib/omp/rpc/frame-fold.test.ts +30 -1
- package/src/server/lib/omp/rpc/frame-fold.ts +6 -1
- package/src/server/lib/omp/rpc/manager.ts +18 -13
- package/src/server/lib/omp/rpc/session-command-guards.ts +4 -0
- package/src/server/lib/omp/rpc/session-commands.test.ts +24 -0
- package/src/server/lib/omp/rpc/session-commands.ts +7 -0
- package/src/server/lib/omp/session/auto-title.server.test.ts +147 -93
- package/src/server/lib/omp/session/auto-title.server.ts +150 -120
- package/src/server/lib/omp/session/rename-with-ai.server.test.ts +18 -6
- package/src/server/lib/omp/session/rename-with-ai.server.ts +36 -20
- package/src/server/lib/omp/session/title-model-overlay.server.test.ts +29 -14
- package/src/server/lib/omp/session/title-model-overlay.server.ts +46 -25
- package/src/server/lib/omp/session/title-request.server.test.ts +183 -0
- package/src/server/lib/omp/session/title-request.server.ts +213 -0
- package/src/server/lib/panels/baseline.server.test.ts +4 -4
- package/src/server/lib/panels/compat.server.test.ts +4 -4
- package/src/server/lib/panels/registry-bundled.server.test.ts +4 -4
- package/src/server/lib/panels/registry.server.test.ts +4 -4
- package/src/server/lib/panels/state.server.test.ts +4 -4
- package/src/server/lib/panels/sync.server.test.ts +4 -4
- package/src/server/lib/plugins/background.test.ts +2 -2
- package/src/server/lib/plugins/e2e.test.ts +2 -2
- package/src/server/lib/plugins/platform.test.ts +2 -2
- package/src/server/lib/queue/delivery-dispatch.server.test.ts +34 -0
- package/src/server/lib/queue/delivery.server.ts +11 -2
- package/src/server/lib/updates/shell-release.test.ts +184 -0
- package/src/server/lib/updates/shell-release.ts +215 -0
- package/src/server/lib/waiting/store.server.test.ts +2 -2
- package/src/server/plugins/ssr.ts +13 -10
- package/src/server/routes/chat/stream.test.ts +4 -4
- package/src/server/routes/models/roles.ts +73 -14
- package/src/server/routes/panels/css.test.ts +4 -4
- package/src/server/routes/panels/panel-routes.test-util.ts +3 -3
- package/src/server/routes/plugins/routes.test.ts +4 -4
- package/src/server/routes/sessions/batch-delete.test.ts +4 -4
- package/src/server/routes/updates/android.ts +2 -2
- package/src/server/routes/updates/index.ts +2 -0
- package/src/server/routes/updates/ios.ts +26 -0
- package/src/server/routes/waiting/routes.test.ts +2 -2
- package/src/shared/lib/models/server-cache.ts +11 -0
- package/src/shared/lib/omp/config/model-roles.ts +129 -0
- package/src/shared/lib/updates/shell-ua.test.ts +56 -24
- package/src/shared/lib/updates/shell-ua.ts +41 -20
- package/src/shared/lib/workspace/panel-open.test.ts +56 -0
- package/src/shared/lib/workspace/panel-open.ts +76 -0
- package/src/shared/types/model-roles.ts +54 -0
- package/src/test-support/isolated-db.ts +17 -5
- package/dist/client/static/js/index-3qamd33d.css +0 -1
- package/src/client/hooks/ui/updates/android-download.test.ts +0 -112
- package/src/client/hooks/ui/updates/android-download.ts +0 -143
- package/src/server/lib/updates/android-release.test.ts +0 -138
- package/src/server/lib/updates/android-release.ts +0 -183
|
@@ -0,0 +1,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 {
|
|
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
|
|
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
|
-
|
|
151
|
+
shellDownload={shellDownload}
|
|
155
152
|
updateAvailable={updates.hasUpdate}
|
|
156
153
|
onUpdateClick={() => {
|
|
157
154
|
setInfoOpen(true);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
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
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
4
|
-
type
|
|
5
|
-
} from '@/client/hooks/ui/updates/
|
|
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
|
|
13
|
-
|
|
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,
|
|
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
|
-
{
|
|
48
|
+
{shellDownload && (
|
|
49
49
|
// Next to About. Two reasons to be here, and the label says which:
|
|
50
|
-
// an installed
|
|
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
|
|
52
|
+
// browser and an iOS browser get neither — see `useShellDownload`.
|
|
53
53
|
<a
|
|
54
|
-
href={
|
|
54
|
+
href={shellDownload.url}
|
|
55
55
|
target="_blank"
|
|
56
56
|
rel="noopener noreferrer"
|
|
57
|
-
title={
|
|
58
|
-
aria-label={
|
|
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
|
-
|
|
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 {
|
|
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] =
|
|
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 {
|
|
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
|
|
87
|
-
// elsewhere — see `
|
|
88
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
|
14
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
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={
|
|
63
|
+
href={shellDownload.url}
|
|
61
64
|
target="_blank"
|
|
62
65
|
rel="noopener noreferrer"
|
|
63
|
-
title={
|
|
64
|
-
aria-label={
|
|
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>{
|
|
71
|
+
<span>{shellDownloadShortLabel(shellDownload)}</span>
|
|
69
72
|
</a>
|
|
70
73
|
)}
|
|
71
74
|
{onDesktopToggle && (
|