@kabmo-labs/kabmo 0.5.3 → 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 (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-ae9z0yvm.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
@@ -28,14 +28,14 @@ import { clearSessionFileCaches } from '@/server/lib/omp/session/files';
28
28
  const AGENT_DIR = path.join(os.tmpdir(), `omc-batch-delete-${process.pid}`, 'agent');
29
29
  let priorAgentDir: string | undefined;
30
30
 
31
- beforeAll(() => {
31
+ beforeAll(async () => {
32
32
  // This suite reaches the database: `removeSession` → `purgeSessionData`
33
33
  // resolves `getDb()`. Without isolating, it inherits whatever handle an
34
34
  // earlier file left on the process-wide slot — and when that handle points at
35
35
  // a temp directory its owner already deleted, SQLite answers `disk I/O error`
36
36
  // and the verdict loses its code (measured on CI: `not_found` came back
37
37
  // `undefined` while the same file passed alone).
38
- isolateDb();
38
+ await isolateDb();
39
39
  fs.rmSync(AGENT_DIR, { recursive: true, force: true });
40
40
  fs.mkdirSync(path.join(AGENT_DIR, 'sessions'), { recursive: true });
41
41
  priorAgentDir = Bun.env.PI_CODING_AGENT_DIR;
@@ -43,12 +43,12 @@ beforeAll(() => {
43
43
  clearSessionFileCaches();
44
44
  });
45
45
 
46
- afterAll(() => {
46
+ afterAll(async () => {
47
47
  clearSessionFileCaches();
48
48
  if (priorAgentDir === undefined) delete Bun.env.PI_CODING_AGENT_DIR;
49
49
  else Bun.env.PI_CODING_AGENT_DIR = priorAgentDir;
50
50
  fs.rmSync(path.dirname(AGENT_DIR), { recursive: true, force: true });
51
- releaseDb();
51
+ await releaseDb();
52
52
  });
53
53
 
54
54
  function post(body: unknown, url: string): Promise<Response> {
@@ -1,5 +1,5 @@
1
1
  import { json, NO_STORE_HEADERS } from '@/server/lib/remix-compat';
2
- import { fetchAndroidRelease, type AndroidRelease } from '@/server/lib/updates/android-release';
2
+ import { fetchShellRelease, type ShellRelease } from '@/server/lib/updates/shell-release';
3
3
 
4
4
  /**
5
5
  * GET /api/updates/android — the newest APK a visitor can download.
@@ -14,7 +14,7 @@ import { fetchAndroidRelease, type AndroidRelease } from '@/server/lib/updates/a
14
14
  * is to omit a button it cannot back with a link.
15
15
  */
16
16
  export async function loader() {
17
- const release: AndroidRelease | null = await fetchAndroidRelease();
17
+ const release: ShellRelease | null = await fetchShellRelease('android');
18
18
  return json(
19
19
  release ?? { url: null, latest: null, releaseUrl: null },
20
20
  { headers: NO_STORE_HEADERS },
@@ -3,10 +3,12 @@ import * as updatesAndroid from '@/server/routes/updates/android';
3
3
  import * as updatesApply from '@/server/routes/updates/apply';
4
4
  import * as updatesChangelog from '@/server/routes/updates/changelog';
5
5
  import * as updatesCheck from '@/server/routes/updates/check';
6
+ import * as updatesIos from '@/server/routes/updates/ios';
6
7
 
7
8
  export const updatesBindings: HandlerBinding[] = [
8
9
  ...bindingsFor(updatesAndroid, '/api/updates/android'),
9
10
  ...bindingsFor(updatesApply, '/api/updates/apply'),
10
11
  ...bindingsFor(updatesChangelog, '/api/updates/changelog'),
11
12
  ...bindingsFor(updatesCheck, '/api/updates/check'),
13
+ ...bindingsFor(updatesIos, '/api/updates/ios'),
12
14
  ];
@@ -0,0 +1,26 @@
1
+ import { json, NO_STORE_HEADERS } from '@/server/lib/remix-compat';
2
+ import { fetchShellRelease, type ShellRelease } from '@/server/lib/updates/shell-release';
3
+
4
+ /**
5
+ * GET /api/updates/ios — the newest iOS shell a visitor can install.
6
+ *
7
+ * A separate route from `/api/updates/android` even though both read the same
8
+ * release list: the two shells are different artifacts on different tracks, and
9
+ * one endpoint answering "the newest shell" would force the client to say which
10
+ * one it meant in a query string — a parameter whose absence silently means
11
+ * Android.
12
+ *
13
+ * Only an INSTALLED shell asks for this. There is no browser-side install offer
14
+ * on iOS (an IPA cannot be sideloaded from a page the way an APK can), so the
15
+ * payload is only ever compared against `KabmoIOS/<version>`.
16
+ *
17
+ * A failed probe is a valid answer (`url: null`), never a 500: the sidebar's job
18
+ * is to omit a button it cannot back with a link.
19
+ */
20
+ export async function loader() {
21
+ const release: ShellRelease | null = await fetchShellRelease('ios');
22
+ return json(
23
+ release ?? { url: null, latest: null, releaseUrl: null },
24
+ { headers: NO_STORE_HEADERS },
25
+ );
26
+ }
@@ -44,11 +44,11 @@ async function call(body: unknown): Promise<{ status: number; body: Record<strin
44
44
 
45
45
  beforeAll(async () => {
46
46
  fs.mkdirSync(ROOT, { recursive: true });
47
- isolateDb();
47
+ await isolateDb();
48
48
  });
49
49
 
50
50
  afterAll(async () => {
51
- releaseDb();
51
+ await releaseDb();
52
52
  fs.rmSync(ROOT, { recursive: true, force: true });
53
53
  });
54
54
 
@@ -12,6 +12,7 @@
12
12
  */
13
13
 
14
14
  import type { ModelsData, ProviderItem } from '@/shared/types';
15
+ import type { ModelRoleCandidate } from '@/shared/types/model-roles';
15
16
  import { emitRealtimeSignal } from '@/server/lib/realtime/signals.server';
16
17
 
17
18
  declare global {
@@ -19,6 +20,15 @@ declare global {
19
20
  var __kabmoModelsCache: { data: ModelsData; expiresAt: number } | undefined;
20
21
  // eslint-disable-next-line no-var
21
22
  var __kabmoProvidersRpcCache: { data: ProviderItem[]; expiresAt: number } | undefined;
23
+ /**
24
+ * The role dropdowns' candidate list (`lib/model-roles/catalog.server.ts`).
25
+ * Declared HERE because it is dropped by the same signal: a model removed
26
+ * from models.yml, or a provider that lost its credential, must leave the
27
+ * role pickers too — otherwise they keep offering a selector omp can no
28
+ * longer resolve.
29
+ */
30
+ // eslint-disable-next-line no-var
31
+ var __kabmoRoleCandidatesCache: { data: ModelRoleCandidate[]; expiresAt: number } | undefined;
22
32
  }
23
33
 
24
34
  /**
@@ -33,5 +43,6 @@ declare global {
33
43
  export function invalidateModelsCaches(): void {
34
44
  globalThis.__kabmoModelsCache = undefined;
35
45
  globalThis.__kabmoProvidersRpcCache = undefined;
46
+ globalThis.__kabmoRoleCandidatesCache = undefined;
36
47
  emitRealtimeSignal('models-changed');
37
48
  }
@@ -0,0 +1,129 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * omp's built-in model roles, and which catalog kinds each one accepts.
8
+ *
9
+ * One table, read by the role editor (labels + sections), by the role dropdowns
10
+ * (which models to offer) and by the server (which role ids a preset may hold).
11
+ * It mirrors `MODEL_ROLES` in `@oh-my-pi/pi-coding-agent/src/config/model-roles.ts`
12
+ * — the `accepts` predicates are flattened into `kinds` so the same rule can run
13
+ * on a catalog row that has no `Model` object.
14
+ *
15
+ * `kinds` is a CATALOG-kind list, and the predicates upstream are not all
16
+ * expressible that way: `web` also accepts chat models that declare a grounding
17
+ * transport (`webSearch`), which the catalog JSON does not carry. Those rows are
18
+ * marked `webCapable` by the server and admitted by
19
+ * {@link roleAcceptsCandidate} — the one predicate both the UI and the tests use.
20
+ *
21
+ * `widget` ("Job widgets") exists at omp 18.12.6 and was missing from this
22
+ * panel, which made it the only role a user could not set from the console.
23
+ */
24
+
25
+ import type { ModelRoleCandidate } from '@/shared/types/model-roles';
26
+
27
+ export interface ModelRoleInfo {
28
+ /** omp's display name for the role (`MODEL_ROLES[role].name`). */
29
+ label: string;
30
+ /** omp's section: a chat model, or a model of that kind only. */
31
+ section: 'chat' | 'kind';
32
+ /** Catalog kinds this role accepts. */
33
+ kinds: readonly string[];
34
+ }
35
+
36
+ /** omp's `MODEL_ROLES` table, in its own declaration order. */
37
+ export const MODEL_ROLES: Record<string, ModelRoleInfo> = {
38
+ default: { label: 'Default', section: 'chat', kinds: ['chat'] },
39
+ smol: { label: 'Fast', section: 'chat', kinds: ['chat'] },
40
+ slow: { label: 'Thinking', section: 'chat', kinds: ['chat'] },
41
+ vision: { label: 'Vision', section: 'chat', kinds: ['chat'] },
42
+ plan: { label: 'Architect', section: 'chat', kinds: ['chat'] },
43
+ commit: { label: 'Commit', section: 'chat', kinds: ['chat'] },
44
+ tiny: { label: 'Tiny', section: 'chat', kinds: ['tiny', 'chat'] },
45
+ memory: { label: 'Memory', section: 'chat', kinds: ['tiny', 'chat'] },
46
+ task: { label: 'Subtask', section: 'chat', kinds: ['chat'] },
47
+ advisor: { label: 'Advisor', section: 'chat', kinds: ['chat'] },
48
+ widget: { label: 'Job widgets', section: 'chat', kinds: ['chat'] },
49
+ image: { label: 'Image generation', section: 'kind', kinds: ['image'] },
50
+ web: { label: 'Web search', section: 'kind', kinds: ['search'] },
51
+ speech: { label: 'Speech (TTS)', section: 'kind', kinds: ['tts'] },
52
+ dictation: { label: 'Dictation (STT)', section: 'kind', kinds: ['stt'] },
53
+ judge: { label: 'Judge', section: 'kind', kinds: ['judge', 'tiny', 'chat'] },
54
+ };
55
+
56
+ /** Role ids in omp's declaration order. */
57
+ export const ROLE_IDS: string[] = Object.keys(MODEL_ROLES);
58
+
59
+ /** Whether `role` is one of omp's built-in roles. */
60
+ export function isModelRole(role: string): boolean {
61
+ return Object.prototype.hasOwnProperty.call(MODEL_ROLES, role);
62
+ }
63
+
64
+ /**
65
+ * Whether a catalog row can fill `role`.
66
+ *
67
+ * `web` is the only role whose upstream predicate needs a field the catalog
68
+ * JSON omits, so its chat rows are admitted through `webCapable` — a search
69
+ * provider is admitted by kind alone.
70
+ */
71
+ export function roleAcceptsCandidate(role: string, candidate: ModelRoleCandidate): boolean {
72
+ const info = MODEL_ROLES[role];
73
+ if (!info) return false;
74
+ if (info.kinds.includes(candidate.kind)) return true;
75
+ return role === 'web' && candidate.kind === 'chat' && candidate.webCapable === true;
76
+ }
77
+
78
+ /** Role ids: a letter, then letters, digits, `-` or `_` (omp's own role-name rule). */
79
+ const ROLE_ID_PATTERN = /^[a-zA-Z][\w-]*$/;
80
+
81
+ /**
82
+ * Validate a `modelRoles` map from a request.
83
+ *
84
+ * The keys are NOT narrowed to omp's built-ins: `modelRoles` accepts custom role
85
+ * ids and omp adds them to its own role list, so a filtered copy would silently
86
+ * delete a custom role the moment a preset was applied (the map is written
87
+ * whole). A key that cannot be a role name, or an empty selector, is dropped —
88
+ * those are typos, not assignments.
89
+ */
90
+ export function normalizeRoleMap(raw: unknown): Record<string, string> {
91
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
92
+ throw new Error('roles must be a role → model selector map');
93
+ }
94
+ const roles: Record<string, string> = {};
95
+ for (const [role, value] of Object.entries(raw)) {
96
+ if (!ROLE_ID_PATTERN.test(role)) continue;
97
+ if (typeof value !== 'string' || !value.trim()) continue;
98
+ roles[role] = value.trim();
99
+ }
100
+ return roles;
101
+ }
102
+
103
+ /** Candidates for one role, ordered by provider then display name. */
104
+ export function candidatesForRole(role: string, candidates: readonly ModelRoleCandidate[]): ModelRoleCandidate[] {
105
+ return candidates
106
+ .filter((candidate) => roleAcceptsCandidate(role, candidate))
107
+ .sort((a, b) => a.provider.localeCompare(b.provider) || a.name.localeCompare(b.name));
108
+ }
109
+
110
+ /** Two `modelRoles` maps hold the same assignments (order-insensitive). */
111
+ export function roleMapsEqual(a: Record<string, string>, b: Record<string, string>): boolean {
112
+ const keys = Object.keys(a);
113
+ if (keys.length !== Object.keys(b).length) return false;
114
+ return keys.every((key) => b[key] === a[key]);
115
+ }
116
+
117
+ /**
118
+ * The preset whose assignments ARE the live map, or null.
119
+ *
120
+ * This is how the picker says which preset is in force: not a stored "last
121
+ * applied" flag, which goes stale the moment a role is edited by hand — the map
122
+ * itself is the state, and a preset matches when it equals it exactly.
123
+ */
124
+ export function matchPreset<T extends { roles: Record<string, string> }>(
125
+ presets: readonly T[],
126
+ roles: Record<string, string>,
127
+ ): T | null {
128
+ return presets.find((preset) => roleMapsEqual(preset.roles, roles)) ?? null;
129
+ }
@@ -5,51 +5,83 @@
5
5
 
6
6
  import { describe, expect, test } from 'bun:test';
7
7
 
8
- import { androidShellVersion, isAndroidDevice, SHELL_UA_PREFIX } from '@/shared/lib/updates/shell-ua';
8
+ import { isAndroidDevice, shellVersion, SHELL_UA_PREFIXES } from '@/shared/lib/updates/shell-ua';
9
9
 
10
10
  /**
11
- * The token is the only signal that a page is running inside the Android app,
12
- * and it decides whether a download button appears. Two failure modes matter:
13
- * a browser mistaken for the app (a button in a place it does not belong) and a
11
+ * The token is the only signal that a page is running inside a native shell, and
12
+ * it decides whether a download button appears. Two failure modes matter: a
13
+ * browser mistaken for the shell (a button in a place it does not belong) and a
14
14
  * malformed token read as a version (a comparison against `''`).
15
+ *
16
+ * Both shells are exercised against the same parser, because the grammar is one
17
+ * grammar and only the prefix differs — the shape that silently reads the other
18
+ * shell's version.
15
19
  */
16
- describe('androidShellVersion', () => {
17
- test('reads the version from a real WebView UA', () => {
20
+ describe('shellVersion', () => {
21
+ test('reads the version from a real Android WebView UA', () => {
18
22
  const ua =
19
23
  'Mozilla/5.0 (Linux; Android 15; Pixel 6 Build/AP4A; wv) AppleWebKit/537.36 (KHTML, like Gecko) '
20
- + `Version/4.0 Chrome/124.0.6367.219 Mobile Safari/537.36 ${SHELL_UA_PREFIX}0.1.0`;
21
- expect(androidShellVersion(ua)).toBe('0.1.0');
24
+ + `Version/4.0 Chrome/124.0.6367.219 Mobile Safari/537.36 ${SHELL_UA_PREFIXES.android}0.1.0`;
25
+ expect(shellVersion(ua, 'android')).toBe('0.1.0');
26
+ expect(shellVersion(ua, 'ios')).toBeNull();
27
+ });
28
+
29
+ test('reads the version from a real iOS WKWebView UA', () => {
30
+ const ua =
31
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) '
32
+ + `Mobile/15E148 ${SHELL_UA_PREFIXES.ios}0.1.0`;
33
+ expect(shellVersion(ua, 'ios')).toBe('0.1.0');
34
+ // The platform token is `iPhone`, not `Android` — so the Android install
35
+ // offer must not fire for the iOS shell.
36
+ expect(isAndroidDevice(ua)).toBe(false);
22
37
  });
23
38
 
24
39
  test('is null for a plain browser', () => {
25
- expect(androidShellVersion('Mozilla/5.0 (Macintosh) AppleWebKit/537.36 Chrome/124.0 Safari/537.36')).toBeNull();
26
- expect(androidShellVersion('')).toBeNull();
27
- expect(androidShellVersion(null)).toBeNull();
28
- expect(androidShellVersion(undefined)).toBeNull();
40
+ const browser = 'Mozilla/5.0 (Macintosh) AppleWebKit/537.36 Chrome/124.0 Safari/537.36';
41
+ expect(shellVersion(browser, 'android')).toBeNull();
42
+ expect(shellVersion(browser, 'ios')).toBeNull();
43
+ expect(shellVersion('', 'android')).toBeNull();
44
+ expect(shellVersion(null, 'android')).toBeNull();
45
+ expect(shellVersion(undefined, 'ios')).toBeNull();
29
46
  });
30
47
 
31
48
  test('is null when the token carries no usable version', () => {
32
49
  // A forged or truncated token must not read as version '' — that would make
33
50
  // `isNewer(latest, '')` true for every release and show a button always.
34
- expect(androidShellVersion('Mozilla/5.0 KabmoAndroid/')).toBeNull();
35
- expect(androidShellVersion('Mozilla/5.0 KabmoAndroid/abc')).toBeNull();
36
- expect(androidShellVersion('Mozilla/5.0 KabmoAndroid/1.x.3')).toBeNull();
51
+ expect(shellVersion('Mozilla/5.0 KabmoAndroid/', 'android')).toBeNull();
52
+ expect(shellVersion('Mozilla/5.0 KabmoAndroid/abc', 'android')).toBeNull();
53
+ expect(shellVersion('Mozilla/5.0 KabmoAndroid/1.x.3', 'android')).toBeNull();
54
+ expect(shellVersion('Mozilla/5.0 KabmoIOS/', 'ios')).toBeNull();
55
+ expect(shellVersion('Mozilla/5.0 KabmoIOS/1.x.3', 'ios')).toBeNull();
37
56
  });
38
57
 
39
58
  test('accepts one-, two- and three-part versions and a prerelease', () => {
40
- expect(androidShellVersion('KabmoAndroid/1')).toBe('1');
41
- expect(androidShellVersion('KabmoAndroid/1.2')).toBe('1.2');
42
- expect(androidShellVersion('KabmoAndroid/1.2.3')).toBe('1.2.3');
43
- expect(androidShellVersion('KabmoAndroid/1.2.3-beta.1')).toBe('1.2.3-beta.1');
59
+ for (const prefix of Object.values(SHELL_UA_PREFIXES)) {
60
+ const kind = prefix === SHELL_UA_PREFIXES.android ? 'android' : 'ios';
61
+ expect(shellVersion(`${prefix}1`, kind)).toBe('1');
62
+ expect(shellVersion(`${prefix}1.2`, kind)).toBe('1.2');
63
+ expect(shellVersion(`${prefix}1.2.3`, kind)).toBe('1.2.3');
64
+ expect(shellVersion(`${prefix}1.2.3-beta.1`, kind)).toBe('1.2.3-beta.1');
65
+ }
44
66
  });
45
67
 
46
68
  test('takes the first token when the UA was crafted twice', () => {
47
- expect(androidShellVersion('KabmoAndroid/1.0.0 KabmoAndroid/9.9.9')).toBe('1.0.0');
69
+ expect(shellVersion('KabmoAndroid/1.0.0 KabmoAndroid/9.9.9', 'android')).toBe('1.0.0');
70
+ expect(shellVersion('KabmoIOS/1.0.0 KabmoIOS/9.9.9', 'ios')).toBe('1.0.0');
48
71
  });
49
72
 
50
73
  test('does not match a lookalike token', () => {
51
- expect(androidShellVersion('KabmoAndroidX/1.0.0')).toBeNull();
52
- expect(androidShellVersion('Mozilla KabmoAndroid')).toBeNull();
74
+ expect(shellVersion('KabmoAndroidX/1.0.0', 'android')).toBeNull();
75
+ expect(shellVersion('Mozilla KabmoAndroid', 'android')).toBeNull();
76
+ expect(shellVersion('KabmoIOSX/1.0.0', 'ios')).toBeNull();
77
+ expect(shellVersion('Mozilla KabmoIOS', 'ios')).toBeNull();
78
+ });
79
+
80
+ test('the two prefixes are distinct', () => {
81
+ // A shared prefix would make one shell's token readable as the other's, and
82
+ // the two artifacts are on separate release tracks.
83
+ expect(SHELL_UA_PREFIXES.android).not.toBe(SHELL_UA_PREFIXES.ios);
84
+ expect(SHELL_UA_PREFIXES.android.startsWith(SHELL_UA_PREFIXES.ios)).toBe(false);
53
85
  });
54
86
  });
55
87
 
@@ -82,7 +114,7 @@ describe('isAndroidDevice', () => {
82
114
  expect(
83
115
  isAndroidDevice(
84
116
  `Mozilla/5.0 (Linux; Android 15; Pixel 6 Build/AP4A; wv) AppleWebKit/537.36 (KHTML, like Gecko) `
85
- + `Version/4.0 Chrome/124.0.6367.219 Mobile Safari/537.36 ${SHELL_UA_PREFIX}0.3.0`,
117
+ + `Version/4.0 Chrome/124.0.6367.219 Mobile Safari/537.36 ${SHELL_UA_PREFIXES.android}0.3.0`,
86
118
  ),
87
119
  ).toBe(true);
88
120
  });
@@ -114,7 +146,7 @@ describe('isAndroidDevice', () => {
114
146
  });
115
147
 
116
148
  test('does not match a lookalike word', () => {
117
- // `` is what stops a crafted or unrelated token from counting.
149
+ // `` is what stops a crafted or unrelated token from counting.
118
150
  expect(isAndroidDevice('Mozilla/5.0 (compatible; NotAndroidBot/1.0)')).toBe(false);
119
151
  expect(isAndroidDevice('Mozilla/5.0 AndroidX/1.0')).toBe(false);
120
152
  });
@@ -1,35 +1,51 @@
1
1
  /**
2
- * The `KabmoAndroid/<version>` token in a request's User-Agent.
2
+ * The `KabmoAndroid/<version>` / `KabmoIOS/<version>` token in a request's
3
+ * User-Agent.
3
4
  *
4
- * The APK appends it (`KabmoWebView.configure`), and it is the console's only
5
- * signal for two questions at once: "am I running inside the Android app" and
6
- * "which build of it". The UA is the right channel because it crosses the very
7
- * request that renders the shell, so the answer needs no bridge, no extra
8
- * endpoint, and no round trip — the page is served already knowing.
5
+ * Each native shell appends its own token, and it is the console's only signal
6
+ * for two questions at once: "am I running inside a shell" and "which build of
7
+ * it". The UA is the right channel because it crosses the very request that
8
+ * renders the shell, so the answer needs no bridge, no extra endpoint, and no
9
+ * round trip — the page is served already knowing.
10
+ *
11
+ * ONE parser for both shells, because the token grammar is one grammar and two
12
+ * copies would be two answers to "is this a version". What differs per shell is
13
+ * only the prefix, which is the table below.
9
14
  *
10
15
  * Parsed leniently and defensively: a browser can be made to send any UA, so a
11
16
  * forged token buys nothing here (the value only decides whether to show a
12
- * download button), and a malformed one must read as "not the app" rather than
13
- * as version `''`.
17
+ * download button), and a malformed one must read as "not the shell" rather
18
+ * than as version `''`.
14
19
  */
15
20
 
16
- /** The token's name, before the slash. */
17
- export const SHELL_UA_PREFIX = 'KabmoAndroid/';
21
+ /** The native shells that report themselves on the UA. */
22
+ export type ShellKind = 'android' | 'ios';
23
+
24
+ /** The token's name, before the slash, per shell. */
25
+ export const SHELL_UA_PREFIXES: Record<ShellKind, string> = {
26
+ android: 'KabmoAndroid/',
27
+ ios: 'KabmoIOS/',
28
+ };
18
29
 
19
30
  /**
20
31
  * Whether this request came from an Android device at all — the app or a
21
32
  * browser on it.
22
33
  *
23
- * The download button has two reasons to appear and they are different offers:
24
- * an installed APK that is behind (an UPDATE), and a reader using Chrome or
25
- * Brave on a phone who may not know a native shell exists (an INSTALL). The
26
- * second one needs this fact, which the shell token cannot supply: a browser
27
- * carries no `KabmoAndroid/` token, and "no token" alone means "not the app" on
28
- * every platform, including a desktop where an APK is useless.
34
+ * The Android download button has two reasons to appear and they are different
35
+ * offers: an installed APK that is behind (an UPDATE), and a reader using
36
+ * Chrome or Brave on a phone who may not know a native shell exists (an
37
+ * INSTALL). The second one needs this fact, which the shell token cannot
38
+ * supply: a browser carries no `KabmoAndroid/` token, and "no token" alone
39
+ * means "not the app" on every platform, including a desktop where an APK is
40
+ * useless.
29
41
  *
30
42
  * Matched on `Android`, the platform token every Android browser sends, and NOT
31
43
  * on `Mobile`: an iPad sends `Mobile` and cannot install an APK, while an
32
44
  * Android tablet sends `Android` without it.
45
+ *
46
+ * There is deliberately no iOS counterpart. An IPA cannot be installed from a
47
+ * browser at all — iOS has no sideload-by-URL path — so an iOS browser is
48
+ * offered nothing, and only the installed shell asks for an update.
33
49
  */
34
50
  export function isAndroidDevice(userAgent: string | null | undefined): boolean {
35
51
  if (!userAgent) return false;
@@ -37,17 +53,22 @@ export function isAndroidDevice(userAgent: string | null | undefined): boolean {
37
53
  }
38
54
 
39
55
  /**
40
- * The APK version that sent this request, or null when it is not the app.
56
+ * The shell version that sent this request, or null when it is not that shell.
41
57
  *
42
58
  * The FIRST matching token wins: the suffix is appended once, and a duplicate
43
59
  * would mean something else crafted the string.
44
60
  */
45
- export function androidShellVersion(userAgent: string | null | undefined): string | null {
61
+ export function shellVersion(
62
+ userAgent: string | null | undefined,
63
+ kind: ShellKind,
64
+ ): string | null {
46
65
  if (!userAgent) return null;
47
66
 
67
+ const prefix = SHELL_UA_PREFIXES[kind];
68
+
48
69
  for (const token of userAgent.split(/\s+/)) {
49
- if (!token.startsWith(SHELL_UA_PREFIX)) continue;
50
- const version = token.slice(SHELL_UA_PREFIX.length);
70
+ if (!token.startsWith(prefix)) continue;
71
+ const version = token.slice(prefix.length);
51
72
  // A version, not an arbitrary string: `1`, `1.2`, `1.2.3`, optionally with a
52
73
  // prerelease suffix. Anything else is a token this parser does not own.
53
74
  if (/^\d+(\.\d+){0,2}(-[0-9A-Za-z.-]+)?$/.test(version)) return version;
@@ -0,0 +1,56 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Which phone drawer can reach a panel, and when none can.
8
+ *
9
+ * Three answers and each is a silent failure when wrong:
10
+ *
11
+ * - a panel placed on the left with the rail ON belongs to the LEFT drawer —
12
+ * the right drawer's catalog deliberately drops it (`phoneRightPanels`), so
13
+ * sending the user there would open a drawer that does not list it;
14
+ * - the same panel with the rail OFF belongs to the RIGHT drawer, which is the
15
+ * rule that keeps a placement reachable when the left drawer has no strip;
16
+ * - an `editor`-slot takeover belongs to NEITHER, because a phone has no editor
17
+ * column — the caller must do nothing rather than navigate to a blank screen.
18
+ *
19
+ * A built-in view is always `right`, so it never lands on the left drawer even
20
+ * though its id is not a plugin key.
21
+ */
22
+
23
+ import { describe, expect, test } from 'bun:test';
24
+ import { phoneDrawerForPanel } from '@/shared/lib/workspace/panel-open';
25
+ import type { LeftDockState } from '@/shared/lib/workspace/left-dock';
26
+
27
+ const RAIL_ON: LeftDockState = { enabled: true, panels: ['plugin:stats:rightPanel'] };
28
+ const RAIL_OFF: LeftDockState = { enabled: false, panels: ['plugin:stats:rightPanel'] };
29
+
30
+ describe('phoneDrawerForPanel', () => {
31
+ test('a panel placed on the left, with the rail on, is the left drawer', () => {
32
+ expect(phoneDrawerForPanel('plugin:stats:rightPanel', RAIL_ON)).toBe('left');
33
+ });
34
+
35
+ test('the same placement with the rail off is the right drawer', () => {
36
+ // The left drawer draws no chip strip while the rail is off, so the panel
37
+ // stays in the right drawer's `all` catalog — and that is where the click
38
+ // has to land.
39
+ expect(phoneDrawerForPanel('plugin:stats:rightPanel', RAIL_OFF)).toBe('right');
40
+ });
41
+
42
+ test('an unplaced panel is the right drawer', () => {
43
+ expect(phoneDrawerForPanel('plugin:stats:rightPanel', { enabled: true, panels: [] })).toBe('right');
44
+ });
45
+
46
+ test('a built-in view is the right drawer, never the left one', () => {
47
+ expect(phoneDrawerForPanel('git', RAIL_ON)).toBe('right');
48
+ expect(phoneDrawerForPanel('files', RAIL_OFF)).toBe('right');
49
+ });
50
+
51
+ test('an editor-slot takeover has no drawer at all', () => {
52
+ // A phone has no editor column. Reporting a drawer would navigate to a
53
+ // screen whose catalog cannot show the panel.
54
+ expect(phoneDrawerForPanel('plugin:session-info:panel', RAIL_ON)).toBeNull();
55
+ });
56
+ });
@@ -0,0 +1,76 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Opening a panel from somewhere that is not its own rail.
8
+ *
9
+ * A plugin's navbar entry can be an ACTION — `app.headerPanel({ opensPanel })`
10
+ * — and so can anything else that names a panel it does not draw. The click
11
+ * cannot resolve the destination itself, because the destination is the USER's
12
+ * choice: the same panel may sit on the left dock, in the right column, in the
13
+ * editor column, or in one of a phone's two drawers. So the event carries the
14
+ * panel INSTANCE key and the layout decides where that key lives right now.
15
+ *
16
+ * The placement rules live in `left-dock.ts` (one source, read by the catalogs,
17
+ * the rails and this module), and this file adds the one thing they cannot
18
+ * answer: which of a PHONE's two drawers can reach a panel. A phone has no
19
+ * rail, so "left" and "right" are screens rather than columns — and a panel
20
+ * placed on the left is drawn by the RIGHT drawer while the rail is off, which
21
+ * is the case a naive `side === 'left' ? leftDrawer : rightDrawer` gets wrong.
22
+ */
23
+
24
+ import { panelSideOf, type LeftDockState, type PanelSide } from '@/shared/lib/workspace/left-dock';
25
+
26
+ /**
27
+ * The window event that asks a layout to show a panel.
28
+ *
29
+ * `detail.panelKey` is a panel INSTANCE key (`plugin:<pluginId>:<slot>`) or a
30
+ * built-in view id. A key no layout can resolve is reported by the panel
31
+ * position's own notice, never silently ignored — the same answer the activity
32
+ * bar gives for a panel whose plugin was uninstalled.
33
+ */
34
+ export const OPEN_PANEL_EVENT = 'omp:open-panel';
35
+
36
+ export interface OpenPanelDetail {
37
+ /** The panel instance key to show. */
38
+ panelKey: string;
39
+ }
40
+
41
+ /**
42
+ * Ask the layout to show a panel.
43
+ *
44
+ * The one place the event is dispatched: a navbar readout, a folded menu row and
45
+ * anything else that names a panel must send the SAME shape, or one of them
46
+ * silently stops opening anything.
47
+ */
48
+ export function requestOpenPanel(panelKey: string): void {
49
+ if (typeof window === 'undefined' || !panelKey) return;
50
+ window.dispatchEvent(new CustomEvent(OPEN_PANEL_EVENT, { detail: { panelKey } }));
51
+ }
52
+
53
+ /** The two screens a phone can put a panel on. */
54
+ export type PanelDrawer = 'left' | 'right';
55
+
56
+ /**
57
+ * The phone drawer that can reach a panel, or null when none can.
58
+ *
59
+ * Three answers, each a different fact:
60
+ *
61
+ * - `left` — the panel is placed on the dock AND the rail is on, so the left
62
+ * drawer is where its chip is drawn;
63
+ * - `right` — everything else that the right drawer lists. That includes a
64
+ * panel placed on the left while the rail is OFF: `phoneRightPanels` keeps it
65
+ * there on purpose, because the left drawer then has no strip and the
66
+ * placement would otherwise be unreachable;
67
+ * - `null` — an `editor`-slot takeover. The phone has no editor column, so
68
+ * there is no screen to navigate to and the caller must do nothing rather
69
+ * than send the user to a drawer that will not show it.
70
+ */
71
+ export function phoneDrawerForPanel(panelKey: string, dock: LeftDockState): PanelDrawer | null {
72
+ const side: PanelSide = panelSideOf(panelKey, dock);
73
+ if (side === 'editor') return null;
74
+ if (side === 'left' && dock.enabled) return 'left';
75
+ return 'right';
76
+ }