@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,35 +4,77 @@
4
4
  */
5
5
 
6
6
  /**
7
- * The model-role editor's role list.
7
+ * The model-role editor: role coverage, the model PICKER, and presets.
8
8
  *
9
- * Pinned because a missing row is unreachable, not merely unlabelled: the panel
10
- * offered 9 of omp's 15 built-in roles, so `memory`, `image`, `web`, `speech`,
11
- * `dictation` and `judge` could not be set from the console even though
12
- * `omp config set`/`modelRoles` accept them. `dictation` is the one with a
13
- * feature behind it — Kabmo's own dictation reads `stt.language` from
14
- * config, and the local STT MODEL comes from that role — so an editor without
15
- * it left the model choice to omp's default with no way to change it here.
9
+ * Pinned because each of these was a bug or a gap first:
16
10
  *
17
- * The expected list is omp's own `MODEL_ROLE_IDS` at 18.11.0, read from the
18
- * installed package (`pi-tui/src/overlays/model-browser.ts`), so an upstream
19
- * role addition surfaces as a failure here rather than as a silently missing
20
- * input.
11
+ * - **Role coverage.** The panel offered 9 of omp's 16 built-in roles, so
12
+ * `memory`, `image`, `web`, `speech`, `dictation`, `judge` and `widget`
13
+ * could not be set from the console. The expected list is omp's own
14
+ * `MODEL_ROLE_IDS` at 18.12.6 (read from the installed package,
15
+ * `pi-tui/src/overlays/model-browser.ts`), so an upstream addition surfaces
16
+ * as a failure here rather than as a silently missing row.
17
+ * - **A role row is a SELECT, not a text field.** A free-text `provider/model`
18
+ * box cannot be typed reliably and a typo silently falls back to omp's own
19
+ * resolution. The candidates are grouped per provider, only the role's own
20
+ * kinds are offered, and the stored value stays selectable when the catalog
21
+ * no longer lists it.
22
+ * - **The selection and "in use" are two facts.** `in use` is derived by
23
+ * comparing the live map with the preset, so there is no stored "last
24
+ * applied" flag to go stale. The SELECTION survives a hand edit, because it
25
+ * is the target of `Update` — tying it to the comparison made Update
26
+ * unreachable in the save-back flow it exists for.
21
27
  */
22
28
 
23
29
  import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
24
30
  import { Window } from 'happy-dom';
25
31
  import { h, render } from 'preact';
26
32
  import { act } from 'preact/test-utils';
27
- import { MODEL_ROLES, RoleEditor } from '@/client/components/settings/categories/omp-settings/RoleEditor';
33
+ import { RoleEditor } from '@/client/components/settings/categories/omp-settings/RoleEditor';
34
+ import {
35
+ MODEL_ROLES,
36
+ ROLE_IDS,
37
+ candidatesForRole,
38
+ matchPreset,
39
+ roleAcceptsCandidate,
40
+ roleMapsEqual,
41
+ } from '@/shared/lib/omp/config/model-roles';
42
+ import type { ModelRoleCandidate, ModelRolePreset } from '@/shared/types/model-roles';
28
43
 
29
- /** omp 18.11.0 `MODEL_ROLE_IDS`. */
44
+ /** omp 18.12.6 `MODEL_ROLE_IDS`. */
30
45
  const OMP_ROLES = [
31
- 'default', 'smol', 'slow', 'vision', 'plan', 'commit', 'tiny', 'memory', 'task', 'advisor',
46
+ 'default', 'smol', 'slow', 'vision', 'plan', 'commit', 'tiny', 'memory', 'task', 'advisor', 'widget',
32
47
  'image', 'web', 'speech', 'dictation', 'judge',
33
48
  ];
34
- /** The six that were missing before this change. */
35
- const PREVIOUSLY_MISSING = ['memory', 'image', 'web', 'speech', 'dictation', 'judge'];
49
+ /** The roles this panel used to omit. */
50
+ const PREVIOUSLY_MISSING = ['memory', 'image', 'web', 'speech', 'dictation', 'judge', 'widget'];
51
+
52
+ const CANDIDATES: ModelRoleCandidate[] = [
53
+ { selector: 'kenari/deepseek-v4-flash', id: 'deepseek-v4-flash', name: 'DeepSeek V4 Flash', provider: 'kenari', kind: 'chat' },
54
+ { selector: 'kenari/glm-5', id: 'glm-5', name: 'GLM-5', provider: 'kenari', kind: 'chat' },
55
+ { selector: 'local/gemma-3-1b', id: 'gemma-3-1b', name: 'Gemma 3 1B', provider: 'local', kind: 'tiny' },
56
+ { selector: 'web/brave', id: 'brave', name: 'Brave', provider: 'web', kind: 'search' },
57
+ { selector: 'local/whisper-base', id: 'whisper-base', name: 'Whisper Base', provider: 'local', kind: 'stt' },
58
+ { selector: 'local/kokoro', id: 'kokoro', name: 'Kokoro-82M', provider: 'local', kind: 'tts' },
59
+ { selector: 'local/flux', id: 'flux', name: 'FLUX.1', provider: 'local', kind: 'image' },
60
+ ];
61
+
62
+ const PRESETS: ModelRolePreset[] = [
63
+ {
64
+ id: 'p-cheap',
65
+ name: 'cheap',
66
+ roles: { default: 'kenari/deepseek-v4-flash', tiny: 'local/gemma-3-1b' },
67
+ createdAt: 1,
68
+ updatedAt: 1,
69
+ },
70
+ {
71
+ id: 'p-think',
72
+ name: 'thinking',
73
+ roles: { default: 'kenari/glm-5' },
74
+ createdAt: 2,
75
+ updatedAt: 2,
76
+ },
77
+ ];
36
78
 
37
79
  let container: HTMLElement;
38
80
 
@@ -56,39 +98,225 @@ afterAll(() => {
56
98
  }
57
99
  });
58
100
 
59
- async function mount(modelRoles: Record<string, string>): Promise<HTMLElement> {
101
+ async function mount(roles: Record<string, string>, overrides: Partial<Parameters<typeof RoleEditor>[0]> = {}): Promise<HTMLElement> {
60
102
  container = document.createElement('div');
61
103
  document.body.appendChild(container);
104
+ const props = {
105
+ roles,
106
+ presets: PRESETS,
107
+ candidates: CANDIDATES,
108
+ candidatesError: null,
109
+ onSaveRoles: async () => true,
110
+ onSavePreset: async () => true,
111
+ onUpdatePreset: async () => true,
112
+ onDeletePreset: async () => true,
113
+ savedRole: null,
114
+ ...overrides,
115
+ };
62
116
  await act(async () => {
63
- render(h(RoleEditor, { modelRoles, onSaveRole: () => {}, savedRole: null }), container);
117
+ render(h(RoleEditor, props), container);
64
118
  });
65
119
  return container;
66
120
  }
67
121
 
68
- describe('model role coverage', () => {
122
+ describe('role table', () => {
69
123
  test('every one of omp’s built-in roles is present', () => {
70
- expect(Object.keys(MODEL_ROLES).sort()).toEqual([...OMP_ROLES].sort());
124
+ expect([...ROLE_IDS].sort()).toEqual([...OMP_ROLES].sort());
71
125
  });
72
126
 
73
- test('the six roles the editor used to omit are each offered as an input', async () => {
127
+ test('the roles the editor used to omit are each offered as a picker', async () => {
74
128
  const el = await mount({});
75
129
  for (const role of PREVIOUSLY_MISSING) {
76
- const input = el.querySelector(`input[aria-label*="(${role})"]`);
77
- expect(input, `${role} must be editable`).not.toBeNull();
130
+ const select = el.querySelector(`select[aria-label*="(${role})"]`);
131
+ expect(select, `${role} must be editable`).not.toBeNull();
78
132
  }
79
133
  });
80
134
 
81
- test('a chat role and a kind role fall under the section omp assigns them', async () => {
82
- // `judge` accepts a judge/tiny/chat model; a chat role accepts a chat model.
135
+ test('a chat role and a kind role fall under the section omp assigns them', () => {
83
136
  expect(MODEL_ROLES.advisor.section).toBe('chat');
137
+ expect(MODEL_ROLES.widget.section).toBe('chat');
84
138
  expect(MODEL_ROLES.dictation.section).toBe('kind');
85
139
  });
140
+ });
141
+
142
+ describe('role candidate rules', () => {
143
+ test('a chat role offers chat models only, and a kind role its own kind', () => {
144
+ expect(candidatesForRole('default', CANDIDATES).every((c) => c.kind === 'chat')).toBe(true);
145
+ expect(candidatesForRole('web', CANDIDATES).map((c) => c.selector)).toEqual(['web/brave']);
146
+ expect(candidatesForRole('dictation', CANDIDATES).map((c) => c.selector)).toEqual(['local/whisper-base']);
147
+ expect(candidatesForRole('speech', CANDIDATES).map((c) => c.selector)).toEqual(['local/kokoro']);
148
+ expect(candidatesForRole('image', CANDIDATES).map((c) => c.selector)).toEqual(['local/flux']);
149
+ });
150
+
151
+ test('tiny and judge accept a tiny model as well as a chat model', () => {
152
+ const tiny = candidatesForRole('tiny', CANDIDATES).map((c) => c.selector);
153
+ expect(tiny).toContain('local/gemma-3-1b');
154
+ expect(tiny).toContain('kenari/glm-5');
155
+ const judge = candidatesForRole('judge', CANDIDATES).map((c) => c.selector);
156
+ expect(judge).toContain('local/gemma-3-1b');
157
+ expect(judge).toContain('kenari/deepseek-v4-flash');
158
+ });
159
+
160
+ test('web also admits a chat model that declares grounding, and nothing else does', () => {
161
+ const grounded: ModelRoleCandidate = {
162
+ selector: 'kenari/glm-5', id: 'glm-5', name: 'GLM-5', provider: 'kenari', kind: 'chat', webCapable: true,
163
+ };
164
+ expect(roleAcceptsCandidate('web', grounded)).toBe(true);
165
+ expect(roleAcceptsCandidate('web', { ...grounded, webCapable: false })).toBe(false);
166
+ expect(roleAcceptsCandidate('default', grounded)).toBe(true);
167
+ // A search model is not a chat role's business.
168
+ expect(roleAcceptsCandidate('default', CANDIDATES[3])).toBe(false);
169
+ });
170
+
171
+ test('an unknown role accepts nothing', () => {
172
+ expect(candidatesForRole('nope', CANDIDATES)).toEqual([]);
173
+ });
174
+ });
175
+
176
+ describe('role rows', () => {
177
+ test('the picker groups candidates by provider and carries the stored value', async () => {
178
+ const el = await mount({ default: 'kenari/glm-5' });
179
+ const select = el.querySelector('select[aria-label*="(default)"]') as HTMLSelectElement;
180
+ expect(select.value).toBe('kenari/glm-5');
181
+ const groups = [...select.querySelectorAll('optgroup')].map((group) => group.getAttribute('label'));
182
+ expect(groups).toEqual(['kenari']);
183
+ // "omp default" is always the first option — it is how an override is removed.
184
+ expect(select.options[0].value).toBe('');
185
+ });
186
+
187
+ test('a stored selector the catalog no longer lists stays selectable', async () => {
188
+ const el = await mount({ default: 'gone/model-x' });
189
+ const select = el.querySelector('select[aria-label*="(default)"]') as HTMLSelectElement;
190
+ expect(select.value).toBe('gone/model-x');
191
+ const values = [...select.options].map((option) => option.value);
192
+ expect(values).toContain('gone/model-x');
193
+ });
194
+
195
+ test('choosing a model writes the whole map with that role set', async () => {
196
+ const writes: Array<Record<string, string>> = [];
197
+ const el = await mount({ tiny: 'local/gemma-3-1b' }, {
198
+ onSaveRoles: async (roles) => { writes.push(roles); return true; },
199
+ });
200
+ const select = el.querySelector('select[aria-label*="(default)"]') as HTMLSelectElement;
201
+ await act(async () => {
202
+ select.value = 'kenari/glm-5';
203
+ select.dispatchEvent(new window.Event('change'));
204
+ });
205
+ expect(writes).toEqual([{ tiny: 'local/gemma-3-1b', default: 'kenari/glm-5' }]);
206
+ });
207
+
208
+ test('choosing “omp default” removes the role from the map', async () => {
209
+ const writes: Array<Record<string, string>> = [];
210
+ const el = await mount({ default: 'kenari/glm-5', tiny: 'local/gemma-3-1b' }, {
211
+ onSaveRoles: async (roles) => { writes.push(roles); return true; },
212
+ });
213
+ const select = el.querySelector('select[aria-label*="(default)"]') as HTMLSelectElement;
214
+ await act(async () => {
215
+ select.value = '';
216
+ select.dispatchEvent(new window.Event('change'));
217
+ });
218
+ expect(writes).toEqual([{ tiny: 'local/gemma-3-1b' }]);
219
+ });
220
+ });
221
+
222
+ describe('presets', () => {
223
+ test('the preset equal to the live map reads as active', () => {
224
+ expect(matchPreset(PRESETS, { ...PRESETS[0].roles })?.name).toBe('cheap');
225
+ expect(matchPreset(PRESETS, { default: 'kenari/glm-5', extra: 'a/b' })).toBeNull();
226
+ expect(roleMapsEqual({ a: '1' }, { a: '1' })).toBe(true);
227
+ expect(roleMapsEqual({ a: '1' }, { a: '1', b: '2' })).toBe(false);
228
+ expect(roleMapsEqual({ a: '1' }, { a: '2' })).toBe(false);
229
+ });
230
+
231
+ test('the picker names the preset the live map equals, and reads in use', async () => {
232
+ const active = await mount({ ...PRESETS[0].roles });
233
+ const picker = active.querySelector('select[aria-label="Model role preset"]') as HTMLSelectElement;
234
+ expect(picker.value).toBe('p-cheap');
235
+ expect(active.textContent).toContain('in use');
236
+ });
237
+
238
+ test('a map no preset matches leaves the picker on Custom', async () => {
239
+ const el = await mount({ default: 'kenari/deepseek-v4-flash' });
240
+ const picker = el.querySelector('select[aria-label="Model role preset"]') as HTMLSelectElement;
241
+ expect(picker.value).toBe('');
242
+ expect(el.textContent).not.toContain('in use');
243
+ });
244
+
245
+ test('a hand edit keeps the selection and reads modified, so Update stays reachable', async () => {
246
+ const el = await mount({ ...PRESETS[0].roles });
247
+ const select = el.querySelector('select[aria-label*="(default)"]') as HTMLSelectElement;
248
+ await act(async () => {
249
+ select.value = 'kenari/glm-5';
250
+ select.dispatchEvent(new window.Event('change'));
251
+ });
252
+ const picker = el.querySelector('select[aria-label="Model role preset"]') as HTMLSelectElement;
253
+ expect(picker.value).toBe('p-cheap');
254
+ expect(el.textContent).toContain('modified');
255
+ expect(el.textContent).not.toContain('in use');
256
+ const update = [...el.querySelectorAll('button')].find((button) => button.textContent?.includes('Update'))!;
257
+ expect(update.disabled).toBe(false);
258
+ });
259
+
260
+ test('Update stores the live map into the selected preset', async () => {
261
+ const updates: ModelRolePreset[] = [];
262
+ const el = await mount({ ...PRESETS[0].roles }, {
263
+ onUpdatePreset: async (preset) => { updates.push(preset); return true; },
264
+ });
265
+ const select = el.querySelector('select[aria-label*="(default)"]') as HTMLSelectElement;
266
+ await act(async () => {
267
+ select.value = 'kenari/glm-5';
268
+ select.dispatchEvent(new window.Event('change'));
269
+ });
270
+ const update = [...el.querySelectorAll('button')].find((button) => button.textContent?.includes('Update'))!;
271
+ await act(async () => { update.click(); });
272
+ expect(updates.length).toBe(1);
273
+ expect(updates[0].id).toBe('p-cheap');
274
+ expect(updates[0].roles).toEqual({ default: 'kenari/glm-5', tiny: 'local/gemma-3-1b' });
275
+ });
276
+
277
+ test('choosing a preset applies its whole role map', async () => {
278
+ const writes: Array<Record<string, string>> = [];
279
+ const el = await mount({}, { onSaveRoles: async (roles) => { writes.push(roles); return true; } });
280
+ const picker = el.querySelector('select[aria-label="Model role preset"]') as HTMLSelectElement;
281
+ await act(async () => {
282
+ picker.value = 'p-think';
283
+ picker.dispatchEvent(new window.Event('change'));
284
+ });
285
+ expect(writes).toEqual([PRESETS[1].roles]);
286
+ });
287
+
288
+ test('Update and Delete are refused with no selected preset', async () => {
289
+ const el = await mount({ default: 'kenari/deepseek-v4-flash' });
290
+ const update = [...el.querySelectorAll('button')].find((button) => button.textContent?.includes('Update'))!;
291
+ expect(update.disabled).toBe(true);
292
+ });
293
+
294
+ test('a failed catalog is reported and the rows stay editable', async () => {
295
+ const el = await mount({ default: 'kenari/glm-5' }, { candidates: [], candidatesError: 'omp binary not found' });
296
+ expect(el.textContent).toContain('Model catalog unavailable');
297
+ expect(el.textContent).toContain('omp binary not found');
298
+ const select = el.querySelector('select[aria-label*="(default)"]') as HTMLSelectElement;
299
+ expect(select.disabled).toBe(false);
300
+ // The stored selector survives even with no catalog to draw options from.
301
+ expect(select.value).toBe('kenari/glm-5');
302
+ });
303
+
304
+ test('a refused write is shown in place', async () => {
305
+ const el = await mount({}, { writeError: 'Invalid value: modelRoles' });
306
+ expect(el.textContent).toContain('Invalid value: modelRoles');
307
+ });
86
308
 
87
- test('a stored value renders in its row and survives another role’s edit', async () => {
88
- const el = await mount({ dictation: 'local/whisper-base', default: 'kenari/deepseek-v4-flash' });
89
- const dictation = el.querySelector('input[aria-label*="(dictation)"]') as HTMLInputElement;
90
- expect(dictation.value).toBe('local/whisper-base');
91
- const fallback = el.querySelector('input[aria-label*="(default)"]') as HTMLInputElement;
92
- expect(fallback.value).toBe('kenari/deepseek-v4-flash');
309
+ test('deleting clears the selection', async () => {
310
+ const deleted: string[] = [];
311
+ const el = await mount({ ...PRESETS[0].roles }, {
312
+ onDeletePreset: async (preset) => { deleted.push(preset.id); return true; },
313
+ });
314
+ const trash = el.querySelector('button[title^="Delete"]') as HTMLButtonElement;
315
+ await act(async () => { trash.click(); });
316
+ const confirm = [...el.querySelectorAll('button')].find((button) => button.textContent?.trim() === 'Delete')!;
317
+ await act(async () => { confirm.click(); });
318
+ expect(deleted).toEqual(['p-cheap']);
319
+ const picker = el.querySelector('select[aria-label="Model role preset"]') as HTMLSelectElement;
320
+ expect(picker.value).toBe('');
93
321
  });
94
322
  });