@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
@@ -4,126 +4,219 @@
4
4
  */
5
5
 
6
6
  /**
7
- * Model role editor — role → model selector overrides in config.yml.
8
- * Roles come from the native omp schema (`modelRoles` built-in roles), and
9
- * writes go through /api/model-roles, which edits only `modelRoles` in the
10
- * global config. Empty input removes the role override.
7
+ * Model role editor — `role → model selector` overrides in config.yml, with
8
+ * presets.
11
9
  *
12
- * The list is omp's OWN `MODEL_ROLE_IDS` (15 entries at 18.11.0), not a
13
- * hand-picked subset: the panel used to offer 9, which left `memory`, `image`,
14
- * `web`, `speech`, `dictation` and `judge` unreachable from the UI even though
15
- * `omp config set` accepts them and omp reads them at runtime. `dictation` in
16
- * particular is how a local STT model is chosen (Kabmo's own dictation
17
- * resolves `stt.language` from config; the model comes from this role), so the
18
- * missing row was the one a non-English user needed most.
10
+ * Roles come from omp's own `MODEL_ROLES` table (see
11
+ * `@/shared/lib/omp/config/model-roles`), and the candidate models from omp's
12
+ * own catalog, so a row can only name a model omp can resolve. Writes go
13
+ * through `/api/model-roles`: `PUT` replaces the whole map (an empty value
14
+ * removes that role's override), `POST` saves a preset, `DELETE` removes one.
19
15
  *
20
- * The two SECTIONS are omp's own (`chat` vs `kind`): the first ten roles each
21
- * accept a chat model, while `image`/`web`/`speech`/`dictation`/`judge` accept
22
- * a model of that kind only — a chat model in `judge` is silently unusable, so
23
- * the split is information the user needs, not decoration.
16
+ * Two things this panel used to get wrong and must not again:
17
+ *
18
+ * - **A role row was a free-text `provider/model` field.** That is a value a
19
+ * user cannot type reliably, and a typo is invisible: omp falls back to its
20
+ * own resolution and the role silently runs on a different model. Every row
21
+ * is a picker over the catalog now, with the stored value kept selectable
22
+ * even when the catalog no longer lists it.
23
+ * - **`widget` was missing from the role table** (omp 18.12.6), which made it
24
+ * the one role unreachable from the console.
25
+ *
26
+ * The presets are the reason this panel exists in its current shape: switching
27
+ * a whole role map used to mean sixteen edits. Two facts are kept apart on
28
+ * purpose — the SELECTED preset (the target of Update/Delete, which a hand edit
29
+ * must not clear, or the save-back flow would be impossible) and whether the
30
+ * live map still equals it (`in use` vs `modified`).
24
31
  */
25
32
 
26
- import { useState } from 'preact/hooks';
27
- import { Check } from 'lucide-preact';
33
+ import { useCallback, useEffect, useMemo, useState } from 'preact/hooks';
34
+ import { AlertCircle, Check } from 'lucide-preact';
35
+ import { candidatesForRole, matchPreset, MODEL_ROLES, roleMapsEqual, ROLE_IDS } from '@/shared/lib/omp/config/model-roles';
36
+ import type { ModelRoleCandidate, ModelRolePreset } from '@/shared/types/model-roles';
37
+ import { RoleRow } from '@/client/components/settings/categories/omp-settings/RoleRow';
38
+ import { PresetBar } from '@/client/components/settings/categories/omp-settings/PresetBar';
28
39
 
29
- interface RoleInfo {
30
- /** omp's display name for the role (`MODEL_ROLES[role].name`). */
31
- label: string;
32
- /** omp's section: a chat model, or a model of that kind only. */
33
- section: 'chat' | 'kind';
40
+ interface RoleEditorProps {
41
+ roles: Record<string, string>;
42
+ presets: ModelRolePreset[];
43
+ candidates: ModelRoleCandidate[];
44
+ /** Why the catalog could not be read; the rows still render. */
45
+ candidatesError?: string | null;
46
+ /** Replace the whole map (an empty value removes that role's override). */
47
+ onSaveRoles: (roles: Record<string, string>) => Promise<boolean>;
48
+ onSavePreset: (name: string, roles: Record<string, string>) => Promise<boolean>;
49
+ onUpdatePreset: (preset: ModelRolePreset) => Promise<boolean>;
50
+ onDeletePreset: (preset: ModelRolePreset) => Promise<boolean>;
51
+ savedRole: string | null;
52
+ /** The last write's refusal; shown in place so a rejected save is not silent. */
53
+ writeError?: string | null;
54
+ onDismissWriteError?: () => void;
34
55
  }
35
56
 
36
- /** omp's `MODEL_ROLES` table, in its own declaration order. */
37
- export const MODEL_ROLES: Record<string, RoleInfo> = {
38
- default: { label: 'Default', section: 'chat' },
39
- smol: { label: 'Fast', section: 'chat' },
40
- slow: { label: 'Thinking', section: 'chat' },
41
- vision: { label: 'Vision', section: 'chat' },
42
- plan: { label: 'Architect', section: 'chat' },
43
- commit: { label: 'Commit', section: 'chat' },
44
- tiny: { label: 'Tiny', section: 'chat' },
45
- memory: { label: 'Memory', section: 'chat' },
46
- task: { label: 'Subtask', section: 'chat' },
47
- advisor: { label: 'Advisor', section: 'chat' },
48
- image: { label: 'Image generation', section: 'kind' },
49
- web: { label: 'Web search', section: 'kind' },
50
- speech: { label: 'Speech (TTS)', section: 'kind' },
51
- dictation: { label: 'Dictation (STT)', section: 'kind' },
52
- judge: { label: 'Judge', section: 'kind' },
53
- };
54
-
55
- const ROLE_IDS = Object.keys(MODEL_ROLES);
56
-
57
- /** One editable role row. */
58
- function RoleRow({
59
- role,
60
- value,
61
- onCommit,
62
- }: {
63
- role: string;
64
- value: string;
65
- onCommit: (role: string, raw: string) => void;
66
- }) {
67
- const [draft, setDraft] = useState(value);
68
- const info = MODEL_ROLES[role];
69
- return (
70
- <div className="flex items-center justify-between gap-3 px-3 py-2.5">
71
- <div className="min-w-0">
72
- <div className="font-medium text-ink">{info.label}</div>
73
- <div className="font-mono text-[9px] text-ink/30">{role}</div>
74
- </div>
75
- <input
76
- aria-label={`${info.label} (${role}) model`}
77
- type="text"
78
- value={draft}
79
- onChange={(e) => setDraft(e.currentTarget.value)}
80
- onBlur={(e) => onCommit(role, e.currentTarget.value)}
81
- placeholder="provider/model"
82
- className="w-56 bg-paper border border-ink/15 rounded-md px-2 py-1 text-ink text-right font-mono"
83
- />
84
- </div>
57
+ const SECTIONS: Array<{ id: 'chat' | 'kind'; title: string; note: string }> = [
58
+ {
59
+ id: 'chat',
60
+ title: 'Chat roles',
61
+ note: 'Each takes a chat model. “omp default” removes the override and omp resolves the role itself.',
62
+ },
63
+ {
64
+ id: 'kind',
65
+ title: 'Model-kind roles',
66
+ note: 'Each takes a model of that kind only — a chat model here is silently unusable.',
67
+ },
68
+ ];
69
+
70
+ export function RoleEditor({
71
+ roles,
72
+ presets,
73
+ candidates,
74
+ candidatesError,
75
+ onSaveRoles,
76
+ onSavePreset,
77
+ onUpdatePreset,
78
+ onDeletePreset,
79
+ savedRole,
80
+ writeError,
81
+ onDismissWriteError,
82
+ }: RoleEditorProps) {
83
+ const [draft, setDraft] = useState<Record<string, string>>(roles);
84
+ const [busy, setBusy] = useState(false);
85
+ const [selectedId, setSelectedId] = useState<string | null>(() => matchPreset(presets, roles)?.id ?? null);
86
+
87
+ // The server's map is the truth; a save answers with the stored copy.
88
+ useEffect(() => { setDraft(roles); }, [roles]);
89
+
90
+ /**
91
+ * Keep the selection honest as the list and the map arrive:
92
+ * - a deleted preset cannot stay selected;
93
+ * - with nothing selected, a map that equals a preset adopts it — this is the
94
+ * load path (the payload lands after mount) and a reload of a saved state.
95
+ * A hand edit does NOT clear the selection: it is the target of `Update`.
96
+ */
97
+ useEffect(() => {
98
+ setSelectedId((current) => {
99
+ if (current && !presets.some((preset) => preset.id === current)) return null;
100
+ if (current) return current;
101
+ return matchPreset(presets, draft)?.id ?? null;
102
+ });
103
+ }, [presets, draft]);
104
+
105
+ const selectedPreset = useMemo(
106
+ () => presets.find((preset) => preset.id === selectedId) ?? null,
107
+ [presets, selectedId],
108
+ );
109
+ const inUse = useMemo(
110
+ () => Boolean(selectedPreset && roleMapsEqual(selectedPreset.roles, draft)),
111
+ [selectedPreset, draft],
85
112
  );
86
- }
87
113
 
88
- interface RoleEditorProps {
89
- modelRoles: Record<string, string>;
90
- onSaveRole: (role: string, value: string) => void;
91
- savedRole: string | null;
92
- }
114
+ const commit = useCallback(async (next: Record<string, string>) => {
115
+ setDraft(next);
116
+ setBusy(true);
117
+ try {
118
+ await onSaveRoles(next);
119
+ } finally {
120
+ setBusy(false);
121
+ }
122
+ }, [onSaveRoles]);
123
+
124
+ const changeRole = useCallback((role: string, value: string) => {
125
+ const next = { ...draft };
126
+ if (value) next[role] = value;
127
+ else delete next[role];
128
+ void commit(next);
129
+ }, [commit, draft]);
130
+
131
+ const applyPreset = useCallback((preset: ModelRolePreset) => {
132
+ setSelectedId(preset.id);
133
+ void commit({ ...preset.roles });
134
+ }, [commit]);
135
+
136
+ const saveAs = useCallback(async (name: string) => {
137
+ setBusy(true);
138
+ try {
139
+ await onSavePreset(name, draft);
140
+ } finally {
141
+ setBusy(false);
142
+ }
143
+ }, [draft, onSavePreset]);
144
+
145
+ const update = useCallback(async () => {
146
+ if (!selectedPreset) return;
147
+ setBusy(true);
148
+ try {
149
+ await onUpdatePreset({ ...selectedPreset, roles: draft });
150
+ } finally {
151
+ setBusy(false);
152
+ }
153
+ }, [selectedPreset, draft, onUpdatePreset]);
93
154
 
94
- export function RoleEditor({ modelRoles, onSaveRole, savedRole }: RoleEditorProps) {
95
- const commitRole = (role: string, raw: string) => {
96
- const trimmed = raw.trim();
97
- if ((modelRoles[role] ?? '') === trimmed) return;
98
- onSaveRole(role, trimmed);
99
- };
100
-
101
- const sections: Array<{ id: 'chat' | 'kind'; title: string; note: string }> = [
102
- {
103
- id: 'chat',
104
- title: 'Chat roles',
105
- note: 'Each takes a chat model. Empty removes the override and omp falls back to its own resolution.',
106
- },
107
- {
108
- id: 'kind',
109
- title: 'Model-kind roles',
110
- note: 'Each takes a model of that kind only — a chat model here is silently unusable.',
111
- },
112
- ];
155
+ const remove = useCallback(async () => {
156
+ if (!selectedPreset) return;
157
+ setBusy(true);
158
+ try {
159
+ await onDeletePreset(selectedPreset);
160
+ setSelectedId(null);
161
+ } finally {
162
+ setBusy(false);
163
+ }
164
+ }, [selectedPreset, onDeletePreset]);
113
165
 
114
166
  return (
115
167
  <div className="pt-2">
116
168
  <div className="flex items-center justify-between pb-2">
117
169
  <div>
118
170
  <h3 className="text-sm font-semibold text-ink">Model Roles</h3>
119
- <p className="text-ink/50 mt-0.5">Role → model selectors from config.yml. Empty removes the role override.</p>
171
+ <p className="text-ink/50 mt-0.5">
172
+ Role → model selectors from config.yml. Pick a preset to switch every role at once.
173
+ </p>
120
174
  </div>
121
175
  {savedRole && (
122
176
  <span className="inline-flex items-center gap-1 text-[10px] text-ink/60"><Check size={11} /> saved</span>
123
177
  )}
124
178
  </div>
125
- {sections.map((section) => {
126
- const roles = ROLE_IDS.filter((role) => MODEL_ROLES[role].section === section.id);
179
+
180
+ <PresetBar
181
+ presets={presets}
182
+ selectedPreset={selectedPreset}
183
+ inUse={inUse}
184
+ busy={busy}
185
+ onApply={applyPreset}
186
+ onSave={saveAs}
187
+ onUpdate={update}
188
+ onDelete={remove}
189
+ />
190
+
191
+ {writeError && (
192
+ <div className="flex items-start gap-1.5 pb-2 text-[10px] text-error">
193
+ <AlertCircle size={11} className="mt-px flex-shrink-0" />
194
+ <span className="flex-1">{writeError}</span>
195
+ {onDismissWriteError && (
196
+ <button
197
+ type="button"
198
+ onClick={onDismissWriteError}
199
+ className="text-ink/40 hover:text-ink cursor-pointer"
200
+ aria-label="Dismiss role write error"
201
+ >
202
+ ×
203
+ </button>
204
+ )}
205
+ </div>
206
+ )}
207
+
208
+ {candidatesError && (
209
+ <div className="flex items-start gap-1.5 pb-2 text-[10px] text-warning">
210
+ <AlertCircle size={11} className="mt-px flex-shrink-0" />
211
+ <span>
212
+ Model catalog unavailable ({candidatesError}). Stored selectors stay editable; new picks need a working
213
+ omp install.
214
+ </span>
215
+ </div>
216
+ )}
217
+
218
+ {SECTIONS.map((section) => {
219
+ const sectionRoles = ROLE_IDS.filter((role) => MODEL_ROLES[role].section === section.id);
127
220
  return (
128
221
  <div key={section.id} className="mb-3">
129
222
  <div className="pb-1">
@@ -131,12 +224,15 @@ export function RoleEditor({ modelRoles, onSaveRole, savedRole }: RoleEditorProp
131
224
  <div className="text-[10px] text-ink/40 leading-snug">{section.note}</div>
132
225
  </div>
133
226
  <div className="rounded-lg border border-ink/10 divide-y divide-ink/5">
134
- {roles.map((role) => (
227
+ {sectionRoles.map((role) => (
135
228
  <RoleRow
136
229
  key={role}
137
230
  role={role}
138
- value={modelRoles[role] ?? ''}
139
- onCommit={commitRole}
231
+ info={MODEL_ROLES[role]}
232
+ value={draft[role] ?? ''}
233
+ candidates={candidatesForRole(role, candidates)}
234
+ disabled={busy}
235
+ onChange={changeRole}
140
236
  />
141
237
  ))}
142
238
  </div>
@@ -0,0 +1,79 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * One role row: the role's name, its id, and a MODEL PICKER.
8
+ *
9
+ * A `<select>` grouped by provider rather than a text field or a hand-rolled
10
+ * popup: the value is a `provider/model` selector, which a user cannot be
11
+ * expected to type correctly (and a typo in `modelRoles` is a role that
12
+ * silently falls back), while a native select gives type-ahead, keyboard
13
+ * navigation and screen-reader semantics for free. The candidates come from
14
+ * omp's own catalog, so a row can only name a model omp can resolve.
15
+ *
16
+ * The current value is always present as an option, even when the catalog no
17
+ * longer lists it (a model removed from models.yml, a provider whose credential
18
+ * was removed, a catalog read that failed): dropping it would make the select
19
+ * show a different model than the config holds, and the next edit would write
20
+ * that one over it.
21
+ */
22
+
23
+ import type { ModelRoleCandidate } from '@/shared/types/model-roles';
24
+ import type { ModelRoleInfo } from '@/shared/lib/omp/config/model-roles';
25
+
26
+ interface RoleRowProps {
27
+ role: string;
28
+ info: ModelRoleInfo;
29
+ value: string;
30
+ candidates: ModelRoleCandidate[];
31
+ disabled: boolean;
32
+ onChange: (role: string, value: string) => void;
33
+ }
34
+
35
+ /** Candidates grouped by provider, in the order they arrive (already sorted). */
36
+ function groupByProvider(candidates: ModelRoleCandidate[]): Array<{ provider: string; rows: ModelRoleCandidate[] }> {
37
+ const groups: Array<{ provider: string; rows: ModelRoleCandidate[] }> = [];
38
+ for (const candidate of candidates) {
39
+ const last = groups[groups.length - 1];
40
+ if (last && last.provider === candidate.provider) last.rows.push(candidate);
41
+ else groups.push({ provider: candidate.provider, rows: [candidate] });
42
+ }
43
+ return groups;
44
+ }
45
+
46
+ export function RoleRow({ role, info, value, candidates, disabled, onChange }: RoleRowProps) {
47
+ const known = candidates.some((candidate) => candidate.selector === value);
48
+ const groups = groupByProvider(candidates);
49
+ const label = `${info.label} (${role}) model`;
50
+
51
+ return (
52
+ <div className="flex items-center justify-between gap-3 px-3 py-2.5">
53
+ <div className="min-w-0">
54
+ <div className="font-medium text-ink">{info.label}</div>
55
+ <div className="font-mono text-[9px] text-ink/30">{role}</div>
56
+ </div>
57
+ <select
58
+ aria-label={label}
59
+ title={value || 'omp decides this role'}
60
+ value={value}
61
+ disabled={disabled}
62
+ onChange={(event) => onChange(role, event.currentTarget.value)}
63
+ className="w-60 max-w-[60%] bg-paper border border-ink/15 rounded-md px-2 py-1 text-ink font-mono text-[11px] cursor-pointer disabled:opacity-50"
64
+ >
65
+ <option value="">— omp default —</option>
66
+ {value && !known && <option value={value}>{value} (not in catalog)</option>}
67
+ {groups.map((group) => (
68
+ <optgroup key={group.provider} label={group.provider}>
69
+ {group.rows.map((candidate) => (
70
+ <option key={candidate.selector} value={candidate.selector}>
71
+ {candidate.name}
72
+ </option>
73
+ ))}
74
+ </optgroup>
75
+ ))}
76
+ </select>
77
+ </div>
78
+ );
79
+ }
@@ -3087,8 +3087,8 @@
3087
3087
  },
3088
3088
  {
3089
3089
  "value": "long",
3090
- "label": "Long (1h)",
3091
- "description": "1h TTL where the provider supports it; pricier writes, only warmed during active runs"
3090
+ "label": "Long (1h+)",
3091
+ "description": "Longer TTL where supported (Anthropic/Bedrock 1h, OpenAI GPT-5.6+ Responses 12h); pricier writes, only warmed during active runs"
3092
3092
  },
3093
3093
  {
3094
3094
  "value": "none",
@@ -0,0 +1,73 @@
1
+ /**
2
+ * The bridge, driven through the transport the macOS shell installs.
3
+ *
4
+ * The interesting failures are about what the page can tell apart: a browser has
5
+ * no shell at all, a shell announces itself before the first render, and a
6
+ * refused command keeps the shell's own sentence. None of that is visible in the
7
+ * panel's markup — a control that never works looks exactly like one that does
8
+ * until it is pressed.
9
+ */
10
+
11
+ import { describe, expect, test, beforeEach } from 'bun:test';
12
+
13
+ type Reply = (id: number, ok: boolean, payload: unknown) => void;
14
+
15
+ interface ShellWindow {
16
+ __kabmoCall?: (method: string, params?: Record<string, unknown>) => Promise<unknown>;
17
+ __kabmoDesktop?: unknown;
18
+ __kabmoReply?: Reply;
19
+ }
20
+
21
+ const shell = () => globalThis as unknown as { window?: ShellWindow };
22
+
23
+ describe('desktop bridge', () => {
24
+ beforeEach(() => {
25
+ delete shell().window;
26
+ });
27
+
28
+ test('a browser has no shell, and every call refuses', async () => {
29
+ shell().window = {};
30
+ const bridge = await import('@/client/lib/desktop/bridge');
31
+ expect(bridge.isDesktopShell()).toBe(false);
32
+ expect(await bridge.desktopInfo()).toBeNull();
33
+ await expect(bridge.readServers()).rejects.toThrow('Not running inside the Kabmo desktop shell.');
34
+ });
35
+
36
+ test('the injected identity is readable before any command runs', async () => {
37
+ shell().window = {
38
+ __kabmoCall: async () => null,
39
+ __kabmoDesktop: { platform: 'macos', shellVersion: '0.1.0', overlayTitlebar: true, trafficLightInset: '5rem' },
40
+ };
41
+ const bridge = await import('@/client/lib/desktop/bridge');
42
+ expect(bridge.isDesktopShell()).toBe(true);
43
+ const info = await bridge.desktopInfo();
44
+ expect(info?.overlayTitlebar).toBe(true);
45
+ expect(info?.trafficLightInset).toBe('5rem');
46
+ });
47
+
48
+ test('a command answers through __kabmoCall', async () => {
49
+ const seen: string[] = [];
50
+ shell().window = {
51
+ __kabmoCall: async (method) => {
52
+ seen.push(method);
53
+ return { entries: [], detected: [], activeId: 'local:1' };
54
+ },
55
+ };
56
+ const bridge = await import('@/client/lib/desktop/bridge');
57
+ const state = await bridge.readServers();
58
+ expect(seen).toEqual(['servers_state']);
59
+ expect(state.activeId).toBe('local:1');
60
+ });
61
+
62
+ /**
63
+ * The shell sends its own sentence, which is what the UI shows. Stripping a
64
+ * prefix would eat the first word of a message that starts with "Error".
65
+ */
66
+ test('a refused command keeps the shell’s own sentence, unstripped', async () => {
67
+ const bridge = await import('@/client/lib/desktop/bridge');
68
+ expect(bridge.bridgeError(new Error('Use the server’s root address — a base path is not supported.'))).toBe(
69
+ 'Use the server’s root address — a base path is not supported.',
70
+ );
71
+ expect(bridge.bridgeError('plain')).toBe('plain');
72
+ });
73
+ });
@@ -0,0 +1,126 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The server list the desktop shell owns, as the navbar reads it.
8
+ *
9
+ * One store per window, not per component: the navbar button, its popover and
10
+ * anything else that asks "which server am I on" must not each hold a copy —
11
+ * two copies is how a start on one side leaves the other showing `Stopped`.
12
+ *
13
+ * Every mutation answers with the WHOLE state (`ServersState`), and the shell is
14
+ * the source of truth: it re-reads its own list, re-probes every URL and
15
+ * re-discovers local instances before answering. Adopting the response verbatim
16
+ * is what keeps the panel from inventing a running state the shell does not
17
+ * agree with.
18
+ */
19
+
20
+ import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
21
+ import {
22
+ activateServer,
23
+ addRemoteServer,
24
+ bridgeError,
25
+ isDesktopShell,
26
+ readServers,
27
+ removeServer,
28
+ startLocalServer,
29
+ stopLocalServer,
30
+ type DesktopServersState,
31
+ } from '@/client/lib/desktop/bridge';
32
+
33
+ export interface DesktopServersStore {
34
+ available: boolean;
35
+ state: DesktopServersState | null;
36
+ /** A request is in flight; the panel disables its controls rather than queueing. */
37
+ busy: boolean;
38
+ error: string | null;
39
+ reload: () => Promise<void>;
40
+ activate: (id: string) => Promise<boolean>;
41
+ add: (label: string, url: string) => Promise<boolean>;
42
+ remove: (id: string) => Promise<boolean>;
43
+ start: (port?: number) => Promise<boolean>;
44
+ stop: (id: string) => Promise<boolean>;
45
+ }
46
+
47
+ /**
48
+ * How often the open popover re-reads the list.
49
+ *
50
+ * A local server started in a terminal, a `bun run dev` coming up, a managed
51
+ * one that just died — none of those announce themselves to this page, and the
52
+ * popover is the surface that has to show them. Only while it is open: a poll
53
+ * running behind a closed popover would probe every server every few seconds
54
+ * for a panel nobody is looking at.
55
+ */
56
+ const REFRESH_INTERVAL_MS = 4000;
57
+
58
+ export function useDesktopServers(open: boolean): DesktopServersStore {
59
+ const available = isDesktopShell();
60
+ const [state, setState] = useState<DesktopServersState | null>(null);
61
+ const [busy, setBusy] = useState(false);
62
+ const [error, setError] = useState<string | null>(null);
63
+ // A mutation and a poll can overlap; the poll must not overwrite the answer
64
+ // the user's own click produced. The counter is what tells them apart.
65
+ const mutationsRef = useRef(0);
66
+
67
+ const reload = useCallback(async () => {
68
+ if (!available) return;
69
+ const ticket = mutationsRef.current;
70
+ try {
71
+ const next = await readServers();
72
+ if (mutationsRef.current === ticket) setState(next);
73
+ } catch (err) {
74
+ if (mutationsRef.current === ticket) setError(bridgeError(err));
75
+ }
76
+ }, [available]);
77
+
78
+ const run = useCallback(
79
+ async (action: () => Promise<DesktopServersState>) => {
80
+ mutationsRef.current += 1;
81
+ setBusy(true);
82
+ setError(null);
83
+ try {
84
+ setState(await action());
85
+ return true;
86
+ } catch (err) {
87
+ setError(bridgeError(err));
88
+ return false;
89
+ } finally {
90
+ setBusy(false);
91
+ }
92
+ },
93
+ [],
94
+ );
95
+
96
+ useEffect(() => {
97
+ if (!available) return;
98
+ void reload();
99
+ }, [available, reload]);
100
+
101
+ useEffect(() => {
102
+ if (!available || !open) return;
103
+ const timer = setInterval(() => void reload(), REFRESH_INTERVAL_MS);
104
+ return () => clearInterval(timer);
105
+ }, [available, open, reload]);
106
+
107
+ const activate = useCallback(
108
+ // The shell NAVIGATES on success, so this page is replaced and the resolved
109
+ // state never renders. A failure is what comes back here.
110
+ (id: string) => run(() => activateServer(id)),
111
+ [run],
112
+ );
113
+
114
+ const add = useCallback(
115
+ (label: string, url: string) => run(() => addRemoteServer(label, url)),
116
+ [run],
117
+ );
118
+
119
+ const remove = useCallback((id: string) => run(() => removeServer(id)), [run]);
120
+
121
+ const start = useCallback((port?: number) => run(() => startLocalServer(port)), [run]);
122
+
123
+ const stop = useCallback((id: string) => run(() => stopLocalServer(id)), [run]);
124
+
125
+ return { available, state, busy, error, reload, activate, add, remove, start, stop };
126
+ }