@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,156 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Model-role state for the OMP Engine panel: the live `modelRoles` map, the
8
+ * stored presets, and the catalog the role dropdowns offer.
9
+ *
10
+ * Extracted from `OmpSettings` because it is a self-contained store with its own
11
+ * three writes, and because the panel file was crossing the per-file ceiling.
12
+ * The load is still driven by the panel (it fetches everything in one
13
+ * `Promise.all` so a single spinner covers the whole screen), so this hook
14
+ * exposes `applyPayload` for that fetch and `reload` for a standalone refresh.
15
+ *
16
+ * Every write answers with the WHOLE list/map from the server and is adopted
17
+ * verbatim: the route normalizes (blank selectors dropped, the map written
18
+ * whole), so the panel shows what config.yml and the table actually hold rather
19
+ * than an optimistic guess that can drift.
20
+ */
21
+
22
+ import { useCallback, useState } from 'preact/hooks';
23
+ import type { ModelRoleCandidate, ModelRolePreset } from '@/shared/types/model-roles';
24
+
25
+ export interface ModelRoleState {
26
+ roles: Record<string, string>;
27
+ presets: ModelRolePreset[];
28
+ candidates: ModelRoleCandidate[];
29
+ candidatesError: string | null;
30
+ /** The last write's refusal (omp's or the table's own sentence), cleared on the next write. */
31
+ writeError: string | null;
32
+ clearWriteError: () => void;
33
+ /** Adopt the `GET /api/model-roles` payload (the panel's combined load). */
34
+ applyPayload: (data: unknown) => void;
35
+ /** Replace the whole map — a single role edit and applying a preset both. */
36
+ writeRoles: (roles: Record<string, string>) => Promise<boolean>;
37
+ savePreset: (name: string, roles: Record<string, string>) => Promise<boolean>;
38
+ updatePreset: (preset: ModelRolePreset) => Promise<boolean>;
39
+ deletePreset: (preset: ModelRolePreset) => Promise<boolean>;
40
+ }
41
+
42
+ function readRoles(data: unknown): Record<string, string> | null {
43
+ if (typeof data !== 'object' || data === null || !('roles' in data)) return null;
44
+ const roles = data.roles;
45
+ if (typeof roles !== 'object' || roles === null || Array.isArray(roles)) return null;
46
+ return Object.fromEntries(
47
+ Object.entries(roles).filter((entry): entry is [string, string] => typeof entry[1] === 'string'),
48
+ );
49
+ }
50
+
51
+ /** The server's own refusal sentence, when the payload carries one. */
52
+ function errorText(data: unknown): string | null {
53
+ if (typeof data !== 'object' || data === null || !('error' in data)) return null;
54
+ return typeof data.error === 'string' ? data.error : null;
55
+ }
56
+
57
+ export function useModelRoles(onSaved: (key: string) => void): ModelRoleState {
58
+ const [roles, setRoles] = useState<Record<string, string>>({});
59
+ const [presets, setPresets] = useState<ModelRolePreset[]>([]);
60
+ const [candidates, setCandidates] = useState<ModelRoleCandidate[]>([]);
61
+ const [candidatesError, setCandidatesError] = useState<string | null>(null);
62
+ const [writeError, setWriteError] = useState<string | null>(null);
63
+
64
+ const clearWriteError = useCallback(() => setWriteError(null), []);
65
+
66
+ const applyPayload = useCallback((data: unknown) => {
67
+ const nextRoles = readRoles(data);
68
+ if (nextRoles) setRoles(nextRoles);
69
+ if (typeof data !== 'object' || data === null) return;
70
+ if ('presets' in data && Array.isArray(data.presets)) setPresets(data.presets as ModelRolePreset[]);
71
+ if ('candidates' in data && Array.isArray(data.candidates)) setCandidates(data.candidates as ModelRoleCandidate[]);
72
+ if ('candidatesError' in data) {
73
+ setCandidatesError(typeof data.candidatesError === 'string' ? data.candidatesError : null);
74
+ }
75
+ }, []);
76
+
77
+ const writeRoles = useCallback(async (next: Record<string, string>): Promise<boolean> => {
78
+ // The previous map, so a refusal puts the rows back: leaving the optimistic
79
+ // draft on screen after config.yml rejected it would show a role assignment
80
+ // that does not exist, and the next edit would build on it.
81
+ const previous = roles;
82
+ setRoles(next);
83
+ setWriteError(null);
84
+ try {
85
+ const res = await fetch('/api/model-roles', {
86
+ method: 'PUT',
87
+ headers: { 'Content-Type': 'application/json' },
88
+ body: JSON.stringify({ roles: next }),
89
+ });
90
+ const data: unknown = await res.json();
91
+ const stored = readRoles(data);
92
+ if (stored) setRoles(stored);
93
+ const ok = typeof data === 'object' && data !== null && 'success' in data && data.success === true;
94
+ if (ok) onSaved('role-map');
95
+ else {
96
+ setRoles(previous);
97
+ setWriteError(errorText(data) ?? 'The role write was refused');
98
+ }
99
+ return ok;
100
+ } catch (err) {
101
+ console.error('Failed to save model roles:', err);
102
+ setRoles(previous);
103
+ setWriteError(err instanceof Error ? err.message : String(err));
104
+ return false;
105
+ }
106
+ }, [onSaved, roles]);
107
+
108
+ /** Save/update/delete a preset; every answer carries the whole preset list. */
109
+ const writePreset = useCallback(async (init: RequestInit, query = ''): Promise<boolean> => {
110
+ setWriteError(null);
111
+ try {
112
+ const res = await fetch(`/api/model-roles${query}`, init);
113
+ const data: unknown = await res.json();
114
+ if (typeof data === 'object' && data !== null && 'presets' in data && Array.isArray(data.presets)) {
115
+ setPresets(data.presets as ModelRolePreset[]);
116
+ }
117
+ const ok = typeof data === 'object' && data !== null && 'success' in data && data.success === true;
118
+ if (!ok) setWriteError(errorText(data) ?? 'The preset write was refused');
119
+ return ok;
120
+ } catch (err) {
121
+ console.error('Failed to save model role preset:', err);
122
+ setWriteError(err instanceof Error ? err.message : String(err));
123
+ return false;
124
+ }
125
+ }, []);
126
+
127
+ const savePreset = useCallback((name: string, map: Record<string, string>) => writePreset({
128
+ method: 'POST',
129
+ headers: { 'Content-Type': 'application/json' },
130
+ body: JSON.stringify({ preset: { name, roles: map } }),
131
+ }), [writePreset]);
132
+
133
+ const updatePreset = useCallback((preset: ModelRolePreset) => writePreset({
134
+ method: 'POST',
135
+ headers: { 'Content-Type': 'application/json' },
136
+ body: JSON.stringify({ preset: { id: preset.id, name: preset.name, roles: preset.roles } }),
137
+ }), [writePreset]);
138
+
139
+ const deletePreset = useCallback((preset: ModelRolePreset) => writePreset({
140
+ method: 'DELETE',
141
+ }, `?id=${encodeURIComponent(preset.id)}`), [writePreset]);
142
+
143
+ return {
144
+ roles,
145
+ presets,
146
+ candidates,
147
+ candidatesError,
148
+ writeError,
149
+ clearWriteError,
150
+ applyPayload,
151
+ writeRoles,
152
+ savePreset,
153
+ updatePreset,
154
+ deletePreset,
155
+ };
156
+ }
@@ -0,0 +1,157 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ import { describe, expect, test } from 'bun:test';
7
+
8
+ import {
9
+ parseShellRelease,
10
+ shellDownloadLabel,
11
+ shellFromBootstrap,
12
+ shellReleasePath,
13
+ shouldOfferShellDownload,
14
+ type ShellDownload,
15
+ } from '@/client/hooks/ui/updates/shell-download';
16
+
17
+ /** The published release every case compares against. */
18
+ const RELEASE = {
19
+ url: 'https://github.com/kabmo-labs/kabmo/releases/download/v1.2.3/kabmo-android-v1.2.3.apk',
20
+ latest: '1.2.3',
21
+ releaseUrl: 'https://github.com/kabmo-labs/kabmo/releases/tag/v1.2.3',
22
+ };
23
+
24
+ const UPDATE: ShellDownload = { ...RELEASE, kind: 'update', platform: 'android' };
25
+ const INSTALL: ShellDownload = { ...RELEASE, kind: 'install', platform: 'android' };
26
+
27
+ describe('parseShellRelease', () => {
28
+ test('reads a complete payload', () => {
29
+ expect(parseShellRelease(RELEASE)).toEqual(RELEASE);
30
+ });
31
+
32
+ test('refuses a payload with no artifact', () => {
33
+ // The route answers this on a failed probe. A version without a URL would be
34
+ // a button that downloads nothing.
35
+ expect(parseShellRelease({ url: null, latest: '1.2.3', releaseUrl: null })).toBeNull();
36
+ expect(parseShellRelease({ url: 'https://x/a.apk', latest: null, releaseUrl: null })).toBeNull();
37
+ });
38
+
39
+ test('refuses a non-object', () => {
40
+ expect(parseShellRelease(null)).toBeNull();
41
+ expect(parseShellRelease('nope')).toBeNull();
42
+ expect(parseShellRelease({})).toBeNull();
43
+ });
44
+
45
+ test('keeps a missing releaseUrl as null', () => {
46
+ expect(parseShellRelease({ url: 'https://x/a.apk', latest: '1.0.0' })?.releaseUrl).toBeNull();
47
+ });
48
+ });
49
+
50
+ describe('the offer inside the installed shell', () => {
51
+ test('offers an UPDATE when a newer build is published', () => {
52
+ expect(shouldOfferShellDownload('android', '1.2.2', true, RELEASE)).toEqual(UPDATE);
53
+ expect(shouldOfferShellDownload('ios', '1.2.2', null, RELEASE)).toEqual({
54
+ ...RELEASE,
55
+ kind: 'update',
56
+ platform: 'ios',
57
+ });
58
+ });
59
+
60
+ test('offers nothing when the shell is already current', () => {
61
+ expect(shouldOfferShellDownload('android', '1.2.3', true, RELEASE)).toBeNull();
62
+ expect(shouldOfferShellDownload('ios', '1.2.3', null, RELEASE)).toBeNull();
63
+ });
64
+
65
+ test('offers nothing when the shell is ahead', () => {
66
+ // A sideloaded build newer than the published asset: downloading it would be
67
+ // a downgrade.
68
+ expect(shouldOfferShellDownload('android', '1.3.0', true, RELEASE)).toBeNull();
69
+ });
70
+
71
+ test('offers nothing when the probe failed', () => {
72
+ expect(shouldOfferShellDownload('android', '1.0.0', true, null)).toBeNull();
73
+ });
74
+ });
75
+
76
+ describe('the offer to an Android browser', () => {
77
+ test('offers an INSTALL, because there is no installed version to compare', () => {
78
+ // The reported gap: a reader on Chrome or Brave for Android was told
79
+ // nothing, and the shell is exactly what they do not know exists.
80
+ expect(shouldOfferShellDownload('android', null, true, RELEASE)).toEqual(INSTALL);
81
+ });
82
+
83
+ test('offers an install even when the published APK is older than some build', () => {
84
+ // Nothing is installed here, so "newer than" has no left-hand side — the
85
+ // published asset is simply what the button names.
86
+ expect(shouldOfferShellDownload('android', undefined, true, { ...RELEASE, latest: '0.0.1' })).toEqual({
87
+ ...INSTALL,
88
+ latest: '0.0.1',
89
+ });
90
+ });
91
+
92
+ test('offers nothing when no APK is published', () => {
93
+ expect(shouldOfferShellDownload('android', null, true, null)).toBeNull();
94
+ });
95
+
96
+ test('an iOS browser is offered nothing, because an IPA cannot be installed there', () => {
97
+ // iOS has no sideload-by-URL path. Offering a download to a Safari tab on an
98
+ // iPhone would be a button that cannot do what it says.
99
+ expect(shouldOfferShellDownload('ios', null, true, RELEASE)).toBeNull();
100
+ expect(shouldOfferShellDownload('ios', undefined, undefined, RELEASE)).toBeNull();
101
+ });
102
+ });
103
+
104
+ describe('everywhere else', () => {
105
+ test('a desktop browser is offered nothing', () => {
106
+ // An APK cannot be installed there, and the app's own update badge already
107
+ // covers the server.
108
+ expect(shouldOfferShellDownload('android', null, false, RELEASE)).toBeNull();
109
+ expect(shouldOfferShellDownload('android', null, null, RELEASE)).toBeNull();
110
+ expect(shouldOfferShellDownload('android', null, undefined, RELEASE)).toBeNull();
111
+ expect(shouldOfferShellDownload('android', '', false, RELEASE)).toBeNull();
112
+ });
113
+
114
+ test('the shell token alone is enough, even if the wider flag is missing', () => {
115
+ // `isAndroid` is derived from the same UA, so a stale bootstrap that predates
116
+ // the field must not silently drop the update offer.
117
+ expect(shouldOfferShellDownload('android', '1.2.2', false, RELEASE)).toEqual(UPDATE);
118
+ expect(shouldOfferShellDownload('android', '1.2.2', undefined, RELEASE)).toEqual(UPDATE);
119
+ });
120
+ });
121
+
122
+ describe('shellDownloadLabel', () => {
123
+ test('names the verb the reader needs, and the platform', () => {
124
+ expect(shellDownloadLabel(UPDATE)).toBe('Update Kabmo 1.2.3 for Android');
125
+ expect(shellDownloadLabel(INSTALL)).toBe('Install Kabmo 1.2.3 for Android');
126
+ expect(shellDownloadLabel({ ...UPDATE, platform: 'ios' })).toBe('Update Kabmo 1.2.3 for iOS');
127
+ });
128
+ });
129
+
130
+ describe('shellFromBootstrap', () => {
131
+ test('reads whichever token the request carried', () => {
132
+ expect(shellFromBootstrap({ shellVersion: '0.5.0' })).toEqual({ platform: 'android', version: '0.5.0' });
133
+ expect(shellFromBootstrap({ iosShellVersion: '0.1.0' })).toEqual({ platform: 'ios', version: '0.1.0' });
134
+ });
135
+
136
+ test('is null in a browser, and for an absent bootstrap', () => {
137
+ expect(shellFromBootstrap({ shellVersion: null, iosShellVersion: null })).toBeNull();
138
+ expect(shellFromBootstrap({})).toBeNull();
139
+ expect(shellFromBootstrap(undefined)).toBeNull();
140
+ });
141
+
142
+ test('prefers the Android token when both are present', () => {
143
+ // A UA carrying both is crafted rather than observed; one shell has to win,
144
+ // and the one whose platform also has a browser offer is the safer answer.
145
+ expect(shellFromBootstrap({ shellVersion: '0.5.0', iosShellVersion: '0.1.0' })).toEqual({
146
+ platform: 'android',
147
+ version: '0.5.0',
148
+ });
149
+ });
150
+ });
151
+
152
+ describe('shellReleasePath', () => {
153
+ test('is one endpoint per shell', () => {
154
+ expect(shellReleasePath('android')).toBe('/api/updates/android');
155
+ expect(shellReleasePath('ios')).toBe('/api/updates/ios');
156
+ });
157
+ });
@@ -0,0 +1,220 @@
1
+ /**
2
+ * What the sidebar should offer for a native shell, and why.
3
+ *
4
+ * TWO DIFFERENT OFFERS, from two different facts:
5
+ *
6
+ * - **`update`** — this page is running inside the shell (`shellVersion` from
7
+ * the bootstrap) and a newer build is published. An up-to-date shell offers
8
+ * nothing: a button that downloads the build already installed is worse than
9
+ * no button.
10
+ * - **`install`** — this page is an Android BROWSER (Chrome, Brave, Firefox) and
11
+ * an APK is published. The reader may not know a native shell exists, and
12
+ * telling them is the whole point of the offer; there is no installed version
13
+ * to compare against, so the newest published one is what it names.
14
+ *
15
+ * There is NO iOS install offer. An IPA cannot be installed from a page the way
16
+ * an APK can — iOS has no sideload-by-URL path — so a browser on an iPhone is
17
+ * offered nothing, and only an installed iOS shell ever sees this control.
18
+ *
19
+ * A desktop browser gets neither: an APK cannot be installed there, and the
20
+ * app's own update badge already covers the server.
21
+ *
22
+ * The probe is lazy: it runs only when a shell token or an Android device is
23
+ * present, so a console that can never show the button makes no request for it.
24
+ * ONE hook for both shells, because the decision table is identical and only the
25
+ * artifact, the endpoint and the platform word differ.
26
+ */
27
+
28
+ import { useEffect, useState } from 'preact/hooks';
29
+ import { isNewer } from '@/shared/lib/updates/semver';
30
+ import type { ShellKind } from '@/shared/lib/updates/shell-ua';
31
+
32
+ /** Which shell the offer is for. */
33
+ export type ShellPlatform = ShellKind;
34
+
35
+ /** Which offer this is — the two carry different wording. */
36
+ export type ShellOfferKind = 'update' | 'install';
37
+
38
+ export interface ShellDownload {
39
+ /** The artifact to download. */
40
+ url: string;
41
+ /** The version that artifact ships. */
42
+ latest: string;
43
+ /** The release page, for a "what changed" link. */
44
+ releaseUrl: string | null;
45
+ /** An update to an installed shell, or a first install from a browser. */
46
+ kind: ShellOfferKind;
47
+ /** Which shell this offer is for. */
48
+ platform: ShellPlatform;
49
+ }
50
+
51
+ interface ReleasePayload {
52
+ url?: unknown;
53
+ latest?: unknown;
54
+ releaseUrl?: unknown;
55
+ }
56
+
57
+ /** The platform's own name, as the label says it. */
58
+ const PLATFORM_NAMES: Record<ShellPlatform, string> = {
59
+ android: 'Android',
60
+ ios: 'iOS',
61
+ };
62
+
63
+ /**
64
+ * Read the release payload defensively.
65
+ *
66
+ * The route answers `{url: null, latest: null, releaseUrl: null}` on a failed
67
+ * probe, so a shape check that only looks at the version would offer a button
68
+ * with no link.
69
+ */
70
+ export function parseShellRelease(payload: unknown): Omit<ShellDownload, 'kind' | 'platform'> | null {
71
+ if (typeof payload !== 'object' || payload === null) return null;
72
+ const body = payload as ReleasePayload;
73
+ if (typeof body.url !== 'string' || !body.url) return null;
74
+ if (typeof body.latest !== 'string' || !body.latest) return null;
75
+ return {
76
+ url: body.url,
77
+ latest: body.latest,
78
+ releaseUrl: typeof body.releaseUrl === 'string' ? body.releaseUrl : null,
79
+ };
80
+ }
81
+
82
+ /**
83
+ * The offer, or null.
84
+ *
85
+ * `version` is null in every browser — that is what makes the two branches
86
+ * mutually exclusive — and `onAndroid` is the wider fact that admits a browser
87
+ * ON ANDROID. Both come from the bootstrap rather than a client-side UA sniff:
88
+ * the server already parsed them for the very request that served the page.
89
+ *
90
+ * An `install` needs no version comparison, because nothing is installed. It
91
+ * does require a published artifact: `release` being null is a failed probe, and
92
+ * a button the console cannot back with a link must not appear.
93
+ */
94
+ export function shouldOfferShellDownload(
95
+ platform: ShellPlatform,
96
+ version: string | null | undefined,
97
+ onAndroid: boolean | null | undefined,
98
+ release: Omit<ShellDownload, 'kind' | 'platform'> | null,
99
+ ): ShellDownload | null {
100
+ if (!release) return null;
101
+
102
+ // Inside the shell: an update, and only when one exists.
103
+ if (version) {
104
+ return isNewer(release.latest, version) ? { ...release, kind: 'update', platform } : null;
105
+ }
106
+
107
+ // A browser on an Android device: the app is something to install. iOS has no
108
+ // equivalent — an IPA cannot be installed from a page — so the platform is
109
+ // part of this test, not only the label.
110
+ if (platform === 'android' && onAndroid) return { ...release, kind: 'install', platform };
111
+
112
+ return null;
113
+ }
114
+
115
+ /**
116
+ * The wording of the offer, in one place.
117
+ *
118
+ * Both layouts draw this control and they must say the same thing: an installed
119
+ * shell that is behind is an UPDATE, and a browser on Android is an INSTALL —
120
+ * "Download" for both would leave a first-time reader unsure whether they
121
+ * already have the app. The version is in the label because the artifact is a
122
+ * specific build, not "the app".
123
+ */
124
+ export function shellDownloadLabel(download: ShellDownload): string {
125
+ const verb = download.kind === 'update' ? 'Update' : 'Install';
126
+ return `${verb} Kabmo ${download.latest} for ${PLATFORM_NAMES[download.platform]}`;
127
+ }
128
+
129
+ /** The endpoint that answers for one shell. */
130
+ export function shellReleasePath(platform: ShellPlatform): string {
131
+ return `/api/updates/${platform}`;
132
+ }
133
+
134
+ /**
135
+ * Which shell this page is running inside, from the bootstrap.
136
+ *
137
+ * The Android token wins when both are somehow present: the two shells are
138
+ * different devices, and a UA carrying both is crafted rather than observed.
139
+ */
140
+ export function shellFromBootstrap(bootstrap: {
141
+ shellVersion?: string | null;
142
+ iosShellVersion?: string | null;
143
+ } | undefined): { platform: ShellPlatform; version: string } | null {
144
+ if (bootstrap?.shellVersion) return { platform: 'android', version: bootstrap.shellVersion };
145
+ if (bootstrap?.iosShellVersion) return { platform: 'ios', version: bootstrap.iosShellVersion };
146
+ return null;
147
+ }
148
+
149
+ /**
150
+ * Fetch the published artifact and decide what to offer.
151
+ *
152
+ * `fetchImpl` is a seam for the tests: the cases that matter (no release, a
153
+ * failed probe, a version equal to the installed one) cannot be produced by a
154
+ * live GitHub call.
155
+ */
156
+ export function useShellDownload(
157
+ platform: ShellPlatform,
158
+ version: string | null | undefined,
159
+ onAndroid: boolean | null | undefined,
160
+ fetchImpl: typeof fetch = fetch,
161
+ ): ShellDownload | null {
162
+ const [release, setRelease] = useState<Omit<ShellDownload, 'kind' | 'platform'> | null>(null);
163
+ const wanted = Boolean(version) || (platform === 'android' && Boolean(onAndroid));
164
+
165
+ useEffect(() => {
166
+ if (!wanted) return;
167
+
168
+ let cancelled = false;
169
+ void (async () => {
170
+ try {
171
+ const response = await fetchImpl(shellReleasePath(platform));
172
+ if (!response.ok) return;
173
+ const payload: unknown = await response.json();
174
+ if (!cancelled) setRelease(parseShellRelease(payload));
175
+ } catch {
176
+ // A probe that cannot answer is a button that must not appear.
177
+ }
178
+ })();
179
+
180
+ return () => {
181
+ cancelled = true;
182
+ };
183
+ }, [wanted, platform, fetchImpl]);
184
+
185
+ return shouldOfferShellDownload(platform, version, onAndroid, release);
186
+ }
187
+
188
+ /**
189
+ * The offer for whichever shell this page is running inside — or the Android
190
+ * install offer for a browser on that platform.
191
+ *
192
+ * The single entry point both sidebar layouts call, so the two cannot disagree
193
+ * about which shell they are in or which endpoint they read. It reads the
194
+ * bootstrap the SERVER wrote for this request rather than sniffing the UA
195
+ * client-side, because the server already parsed it for the very response that
196
+ * carried the page.
197
+ */
198
+ export function useShellOffer(fetchImpl: typeof fetch = fetch): ShellDownload | null {
199
+ const bootstrap = typeof window === 'undefined' ? undefined : window.__OMP_BOOTSTRAP__;
200
+ const shell = shellFromBootstrap(bootstrap);
201
+ return useShellDownload(
202
+ shell?.platform ?? 'android',
203
+ shell?.version ?? null,
204
+ bootstrap?.isAndroid,
205
+ fetchImpl,
206
+ );
207
+ }
208
+
209
+ /**
210
+ * The short form the phone's footer prints, where the label has no room.
211
+ *
212
+ * It names the OFFER, and for Android it names the file type because that is
213
+ * what a reader recognises (`APK` is the thing they will see in Downloads). An
214
+ * iOS update is an IPA, which nobody installs by hand — the shell finishes the
215
+ * install itself — so the platform word is the honest one there.
216
+ */
217
+ export function shellDownloadShortLabel(download: ShellDownload): string {
218
+ if (download.platform === 'ios') return 'Update app';
219
+ return download.kind === 'update' ? 'Update APK' : 'Get APK';
220
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The workspace shortcuts, bound once for the desktop layout.
8
+ *
9
+ * Extracted from the layout because it is a self-contained decision — a chord
10
+ * mapped to one of four column/settings toggles — that has nothing to do with
11
+ * what the layout renders. `⌘S` is deliberately absent: the editor owns it, and
12
+ * binding it twice would save the file twice.
13
+ */
14
+
15
+ import { useWindowEvent } from '@/client/hooks/ui/window-event';
16
+ import { resolveBinding } from '@/shared/lib/ui/key-binding';
17
+ import { WORKSPACE_KEY_BINDINGS } from '@/shared/lib/workspace/keymap';
18
+
19
+ export interface WorkspaceKeybindingTargets {
20
+ showEditor: boolean;
21
+ /** Whether the editor column has anything to open on to. */
22
+ editorHasContent: boolean;
23
+ toggleSidebar: () => void;
24
+ toggleEditor: () => void;
25
+ toggleRightPanel: () => void;
26
+ openSettings: () => void;
27
+ }
28
+
29
+ export function useWorkspaceKeybindings(targets: WorkspaceKeybindingTargets): void {
30
+ useWindowEvent('keydown', (event) => {
31
+ const command = resolveBinding(WORKSPACE_KEY_BINDINGS, event);
32
+ if (!command) return;
33
+ if (command === 'saveFile') return;
34
+ // The toggle is the only way to REACH a takeover while the column is
35
+ // closed, since the rail lives inside the column: with nothing to open on
36
+ // to there would be no column at all.
37
+ if (command === 'toggleEditorPanel' && !targets.showEditor && !targets.editorHasContent) return;
38
+ event.preventDefault();
39
+ if (command === 'toggleSidebar') targets.toggleSidebar();
40
+ else if (command === 'toggleEditorPanel') targets.toggleEditor();
41
+ else if (command === 'toggleRightPanel') targets.toggleRightPanel();
42
+ else if (command === 'openSettings') targets.openSettings();
43
+ });
44
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The desktop half of `omp:open-panel`: turn a panel key into a visible column.
8
+ *
9
+ * The COLUMN is the user's placement, read from the dock state, never a choice
10
+ * the caller makes: the same panel may sit on the left dock, in the right
11
+ * column or in the editor column, and a plugin that named one would break the
12
+ * moment the user moved it. Each handler therefore does what the rail's own
13
+ * button does — select the view AND open its column — because a selection made
14
+ * while the column is collapsed is a click that changed nothing on screen.
15
+ *
16
+ * The phone does not use this: its drawers are SCREENS, and which one can reach
17
+ * a panel is a different question (`phoneDrawerForPanel`).
18
+ */
19
+
20
+ import { useKabmoEvent } from '@/client/hooks/ui/window-event';
21
+ import type { LeftDockState } from '@/shared/lib/workspace/left-dock';
22
+ import { panelSideOf } from '@/shared/lib/workspace/left-dock';
23
+ import { OPEN_PANEL_EVENT, type OpenPanelDetail } from '@/shared/lib/workspace/panel-open';
24
+
25
+ export interface OpenPanelTargets {
26
+ /** The dock state the placement is read from. */
27
+ dock: LeftDockState;
28
+ onLeft: (panelKey: string) => void;
29
+ onEditor: (panelKey: string) => void;
30
+ onRight: (panelKey: string) => void;
31
+ }
32
+
33
+ export function useOpenPanelRequest(targets: OpenPanelTargets): void {
34
+ useKabmoEvent(OPEN_PANEL_EVENT, (event) => {
35
+ const panelKey = (event as CustomEvent<OpenPanelDetail>).detail?.panelKey;
36
+ if (!panelKey) return;
37
+ const side = panelSideOf(panelKey, targets.dock);
38
+ if (side === 'left') targets.onLeft(panelKey);
39
+ else if (side === 'editor') targets.onEditor(panelKey);
40
+ else targets.onRight(panelKey);
41
+ });
42
+ }