@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
@@ -36,12 +36,12 @@ import { action as httpAction } from '@/server/routes/plugins/http';
36
36
  import { registerRpcMethod, resetPluginRpc } from '@/server/lib/plugins/rpc.server';
37
37
  import { createPluginHttp, resetPluginHttp } from '@/server/lib/plugins/http.server';
38
38
 
39
- beforeAll(() => {
40
- isolateDb();
39
+ beforeAll(async () => {
40
+ await isolateDb();
41
41
  });
42
42
 
43
- afterAll(() => {
44
- releaseDb();
43
+ afterAll(async () => {
44
+ await releaseDb();
45
45
  });
46
46
 
47
47
  afterEach(() => {
@@ -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;
@@ -46,20 +46,14 @@ describe('phoneRightPanels', () => {
46
46
 
47
47
  describe('phoneLeftStripVisible', () => {
48
48
  test('no rail, no strip', () => {
49
- expect(phoneLeftStripVisible(false, 2, true)).toBe(false);
49
+ expect(phoneLeftStripVisible(false)).toBe(false);
50
50
  });
51
51
 
52
- test('a strip holding only Sessions is chrome that never changes', () => {
53
- expect(phoneLeftStripVisible(true, 0, true)).toBe(false);
54
- });
55
-
56
- test('a placed panel is a second destination, so the strip appears', () => {
57
- expect(phoneLeftStripVisible(true, 1, true)).toBe(true);
58
- });
59
-
60
- test('a stored view that no longer resolves still gets the strip — its body is a notice', () => {
61
- // Without this the drawer would be stuck on a notice with no way back to
62
- // the session list: the body has no chips of its own.
63
- expect(phoneLeftStripVisible(true, 0, false)).toBe(true);
52
+ test('the rail being on IS the strip, even with nothing placed', () => {
53
+ // The bug this pins: the rule used to require a second destination, so the
54
+ // Settings switch "Show the left activity bar" drew nothing at all on a
55
+ // phone with no plugin panel placed on the left — a switch whose only
56
+ // effect was a stored boolean. On a phone the strip is the rail.
57
+ expect(phoneLeftStripVisible(true)).toBe(true);
64
58
  });
65
59
  });
@@ -13,6 +13,15 @@
13
13
  * the left drawer draws the dock too, so leaving `all` in the right one puts a
14
14
  * single panel on two strips of one phone.
15
15
  *
16
+ * **The left drawer's strip follows the rail SWITCH and nothing else.** It used
17
+ * to need a second destination (a placed panel, or a stored view that no longer
18
+ * resolves), on the reasoning that a strip holding only `Sessions` is chrome
19
+ * that never changes. That rule made the Settings switch — "Show the left
20
+ * activity bar" — a no-op on every phone with no plugin panel placed on the
21
+ * left: the rail was on, the desktop column drew its `Sessions` button, and the
22
+ * phone drawer drew nothing, which reads as the switch being broken. On a phone
23
+ * the strip IS the rail, so it is drawn whenever the rail is on.
24
+ *
16
25
  * Pure, and separate from both components, so the two drawers cannot drift and
17
26
  * the rule can be tested without mounting either.
18
27
  */
@@ -45,11 +54,11 @@ export function phoneRightPanels<T extends SidedPanel>(
45
54
  /**
46
55
  * Whether the left drawer draws its chip strip.
47
56
  *
48
- * A strip holding one chip is chrome that never changes, so it needs a second
49
- * destination — either a placed panel, or a stored view that no longer
50
- * resolves. The second half is the escape hatch: that body is a notice, and
51
- * without the strip the session list would be unreachable.
57
+ * The switch alone: on a phone the strip is the rail, so "Show the left activity
58
+ * bar" that draws nothing is a control with no effect. The strip holding one
59
+ * `Sessions` chip is the honest picture of a rail with one view on it — the same
60
+ * picture the desktop column draws — and it grows the moment a panel is placed.
52
61
  */
53
- export function phoneLeftStripVisible(dockEnabled: boolean, placedCount: number, showingSessions: boolean): boolean {
54
- return dockEnabled && (placedCount > 0 || !showingSessions);
62
+ export function phoneLeftStripVisible(dockEnabled: boolean): boolean {
63
+ return dockEnabled;
55
64
  }
@@ -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
+ });