@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.
- package/dist/client/index.html +1 -1
- package/dist/client/index.js +359 -350
- package/dist/client/static/js/{index-zcvn50cf.js → index-ae9z0yvm.js} +703 -703
- package/dist/client/static/js/index-nzyjf92m.css +1 -0
- package/marketplace/plugins/stats/README.md +26 -4
- package/marketplace/plugins/stats/package.json +2 -2
- package/marketplace/plugins/stats/src/HeaderReadout.test.tsx +157 -0
- package/marketplace/plugins/stats/src/HeaderReadout.tsx +115 -0
- package/marketplace/plugins/stats/src/app.tsx +11 -0
- package/marketplace/plugins/stats/src/format.test.ts +27 -1
- package/marketplace/plugins/stats/src/format.ts +41 -0
- package/package.json +2 -2
- package/src/client/bootstrap.d.ts +11 -0
- package/src/client/components/common/header-controls/MoreMenu.tsx +38 -1
- package/src/client/components/common/header-controls/index.tsx +9 -2
- package/src/client/components/common/header-controls/plugin-entry.test.tsx +229 -0
- package/src/client/components/common/header-panel-buttons/entries.test.ts +46 -0
- package/src/client/components/common/header-panel-buttons/entries.ts +109 -0
- package/src/client/components/common/header-panel-buttons/index.tsx +71 -38
- package/src/client/components/common/server-switcher/index.test.tsx +150 -0
- package/src/client/components/common/server-switcher/index.tsx +281 -0
- package/src/client/components/common/window-controls/index.test.tsx +113 -0
- package/src/client/components/common/window-controls/index.tsx +66 -0
- package/src/client/components/layout/desktop-layout/LeftColumn.tsx +3 -6
- package/src/client/components/layout/desktop-layout/TopNavbar.tsx +59 -3
- package/src/client/components/layout/desktop-layout/index.tsx +34 -26
- package/src/client/components/layout/session-sidebar/Footer.tsx +12 -12
- package/src/client/components/layout/session-sidebar/Header.tsx +4 -2
- package/src/client/components/mobile/LayoutWrapper.tsx +36 -0
- package/src/client/components/mobile/RightSidebar.tsx +2 -2
- package/src/client/components/mobile/SessionSidebar.tsx +10 -15
- package/src/client/components/mobile/mobile-session-sidebar/Footer.tsx +15 -12
- package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +9 -2
- package/src/client/components/mobile/mobile-session-sidebar/folder-gap.test.ts +41 -0
- package/src/client/components/mobile/mobile-session-sidebar/folder-gap.ts +39 -0
- package/src/client/components/mobile/mobile-session-sidebar/use-dock.ts +6 -6
- package/src/client/components/settings/categories/OmpSettings.tsx +25 -29
- package/src/client/components/settings/categories/omp-settings/PresetBar.tsx +187 -0
- package/src/client/components/settings/categories/omp-settings/RoleEditor.test.ts +260 -32
- package/src/client/components/settings/categories/omp-settings/RoleEditor.tsx +198 -102
- package/src/client/components/settings/categories/omp-settings/RoleRow.tsx +79 -0
- package/src/client/data/settings/omp-schema.json +2 -2
- package/src/client/hooks/chat/omp/session-list.test.ts +0 -24
- package/src/client/hooks/desktop/servers.test.ts +73 -0
- package/src/client/hooks/desktop/servers.ts +126 -0
- package/src/client/hooks/settings/model-roles.ts +156 -0
- package/src/client/hooks/ui/updates/shell-download.test.ts +157 -0
- package/src/client/hooks/ui/updates/shell-download.ts +220 -0
- package/src/client/hooks/workspace/keybindings.ts +44 -0
- package/src/client/hooks/workspace/open-panel.ts +42 -0
- package/src/client/lib/desktop/bridge.ts +158 -0
- package/src/client/lib/desktop/titlebar.test.ts +99 -0
- package/src/client/lib/desktop/titlebar.ts +97 -0
- package/src/client/lib/plugins/slots.test.ts +54 -0
- package/src/client/lib/plugins/slots.ts +37 -1
- package/src/server/db.server.test.ts +44 -3
- package/src/server/db.server.ts +26 -2
- package/src/server/lib/bundler/build-errors.test.ts +0 -6
- package/src/server/lib/bundler/titlebar-css.test.ts +75 -0
- package/src/server/lib/db/schema.ts +3 -0
- package/src/server/lib/model-roles/catalog.server.test.ts +75 -0
- package/src/server/lib/model-roles/catalog.server.ts +112 -0
- package/src/server/lib/model-roles/mock.server.ts +40 -0
- package/src/server/lib/model-roles/schema.server.ts +36 -0
- package/src/server/lib/model-roles/store.server.test.ts +218 -0
- package/src/server/lib/model-roles/store.server.ts +188 -0
- package/src/server/lib/omp/rpc/destroy-stream-row.test.ts +4 -4
- package/src/server/lib/omp/rpc/frame-fold.test.ts +30 -1
- package/src/server/lib/omp/rpc/frame-fold.ts +6 -1
- package/src/server/lib/omp/rpc/manager.ts +18 -13
- package/src/server/lib/omp/rpc/session-command-guards.ts +4 -0
- package/src/server/lib/omp/rpc/session-commands.test.ts +24 -0
- package/src/server/lib/omp/rpc/session-commands.ts +7 -0
- package/src/server/lib/omp/session/auto-title.server.test.ts +147 -93
- package/src/server/lib/omp/session/auto-title.server.ts +150 -120
- package/src/server/lib/omp/session/rename-with-ai.server.test.ts +18 -6
- package/src/server/lib/omp/session/rename-with-ai.server.ts +36 -20
- package/src/server/lib/omp/session/title-model-overlay.server.test.ts +29 -14
- package/src/server/lib/omp/session/title-model-overlay.server.ts +46 -25
- package/src/server/lib/omp/session/title-request.server.test.ts +183 -0
- package/src/server/lib/omp/session/title-request.server.ts +213 -0
- package/src/server/lib/panels/baseline.server.test.ts +4 -4
- package/src/server/lib/panels/compat.server.test.ts +4 -4
- package/src/server/lib/panels/registry-bundled.server.test.ts +4 -4
- package/src/server/lib/panels/registry.server.test.ts +4 -4
- package/src/server/lib/panels/state.server.test.ts +4 -4
- package/src/server/lib/panels/sync.server.test.ts +4 -4
- package/src/server/lib/plugins/background.test.ts +2 -2
- package/src/server/lib/plugins/e2e.test.ts +2 -2
- package/src/server/lib/plugins/platform.test.ts +2 -2
- package/src/server/lib/queue/delivery-dispatch.server.test.ts +34 -0
- package/src/server/lib/queue/delivery.server.ts +11 -2
- package/src/server/lib/updates/shell-release.test.ts +184 -0
- package/src/server/lib/updates/shell-release.ts +215 -0
- package/src/server/lib/waiting/store.server.test.ts +2 -2
- package/src/server/plugins/ssr.ts +13 -10
- package/src/server/routes/chat/stream.test.ts +4 -4
- package/src/server/routes/fs/git-merge.test.ts +0 -11
- package/src/server/routes/models/roles.ts +73 -14
- package/src/server/routes/panels/css.test.ts +4 -4
- package/src/server/routes/panels/panel-routes.test-util.ts +3 -3
- package/src/server/routes/panels/routes.test.ts +0 -7
- package/src/server/routes/plugins/routes.test.ts +4 -4
- package/src/server/routes/sessions/batch-delete.test.ts +4 -4
- package/src/server/routes/updates/android.ts +2 -2
- package/src/server/routes/updates/index.ts +2 -0
- package/src/server/routes/updates/ios.ts +26 -0
- package/src/server/routes/waiting/routes.test.ts +2 -2
- package/src/shared/lib/models/server-cache.ts +11 -0
- package/src/shared/lib/omp/config/model-roles.ts +129 -0
- package/src/shared/lib/updates/shell-ua.test.ts +56 -24
- package/src/shared/lib/updates/shell-ua.ts +41 -20
- package/src/shared/lib/workspace/mobile-dock.test.ts +7 -13
- package/src/shared/lib/workspace/mobile-dock.ts +15 -6
- package/src/shared/lib/workspace/panel-open.test.ts +56 -0
- package/src/shared/lib/workspace/panel-open.ts +76 -0
- package/src/shared/types/model-roles.ts +54 -0
- package/src/test-support/isolated-db.ts +17 -5
- package/dist/client/static/js/index-3qamd33d.css +0 -1
- package/src/client/components/layout/desktop-layout/left-column.test.tsx +0 -192
- package/src/client/hooks/ui/updates/android-download.test.ts +0 -112
- package/src/client/hooks/ui/updates/android-download.ts +0 -143
- package/src/server/lib/updates/android-release.test.ts +0 -138
- package/src/server/lib/updates/android-release.ts +0 -183
|
@@ -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 {
|
|
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:
|
|
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 {
|
|
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
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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('
|
|
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 ${
|
|
21
|
-
expect(
|
|
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
|
-
|
|
26
|
-
expect(
|
|
27
|
-
expect(
|
|
28
|
-
expect(
|
|
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(
|
|
35
|
-
expect(
|
|
36
|
-
expect(
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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(
|
|
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(
|
|
52
|
-
expect(
|
|
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 ${
|
|
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
|
-
//
|
|
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
|
|
2
|
+
* The `KabmoAndroid/<version>` / `KabmoIOS/<version>` token in a request's
|
|
3
|
+
* User-Agent.
|
|
3
4
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
|
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
|
|
17
|
-
export
|
|
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
|
|
24
|
-
* an installed APK that is behind (an UPDATE), and a reader using
|
|
25
|
-
* Brave on a phone who may not know a native shell exists (an
|
|
26
|
-
* second one needs this fact, which the shell token cannot
|
|
27
|
-
* carries no `KabmoAndroid/` token, and "no token" alone
|
|
28
|
-
* every platform, including a desktop where an APK is
|
|
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
|
|
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
|
|
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(
|
|
50
|
-
const version = token.slice(
|
|
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
|
|
49
|
+
expect(phoneLeftStripVisible(false)).toBe(false);
|
|
50
50
|
});
|
|
51
51
|
|
|
52
|
-
test('
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
expect(phoneLeftStripVisible(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
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
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
|
|
54
|
-
return dockEnabled
|
|
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
|
+
});
|