@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
@@ -18,6 +18,7 @@ import type { BatchBinding } from '@/client/components/common/batch-selection/ty
18
18
  import { folderBatchState } from '@/client/components/common/batch-selection/folder-state';
19
19
  import { SelectionCheckbox } from '@/client/components/common/batch-selection/SelectionCheckbox';
20
20
  import { SessionRevealRow } from '@/client/components/mobile/mobile-session-sidebar/RevealRow';
21
+ import { folderGroupGap } from '@/client/components/mobile/mobile-session-sidebar/folder-gap';
21
22
 
22
23
  interface MobileSessionCategoryProps {
23
24
  folder: WorkspaceFolderData;
@@ -126,10 +127,16 @@ export function MobileSessionCategory({
126
127
  const listOpen = isExpanded || sessionCheckboxes;
127
128
 
128
129
  return (
129
- <div className="mb-4">
130
+ <div className={folderGroupGap(listOpen)} data-testid="session-folder">
130
131
  <div
131
132
  onClick={batch ? undefined : onToggleExpand}
132
- className="flex items-center justify-between gap-2 py-2 cursor-pointer hover:bg-ink/5 rounded-lg select-none transition-colors"
133
+ // `py-1`, not `py-2`: the header's height is set by its own action
134
+ // buttons (27px, `p-1.5` around a 15px glyph — the touch target), so
135
+ // 8px of vertical padding made a folder header 43px against the 33px
136
+ // of the session rows it stands in for. A closed folder is a row, and
137
+ // two of them read as one list only if they are built like rows.
138
+ // Measured on a 390px phone: 43px → 35px, 2px off a session row.
139
+ className="flex items-center justify-between gap-2 py-1 cursor-pointer hover:bg-ink/5 rounded-lg select-none transition-colors"
133
140
  >
134
141
  <div className="flex items-center space-x-2 min-w-0">
135
142
  {folderCheckbox && folderSummary ? (
@@ -0,0 +1,41 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The phone drawer's folder gap.
8
+ *
9
+ * A closed workspace is a row in the list, not a section of it, so it takes a
10
+ * row-sized gap — the reported "folder yang tertutup jaraknya terlalu jauh".
11
+ * The rule is unit-tested because the failure is a MEASUREMENT (a mounted
12
+ * happy-dom tree resolves no layout, so a component test can only read back the
13
+ * class it was given); `mobile.e2e.ts` pins the pixels.
14
+ */
15
+
16
+ import { describe, expect, test } from 'bun:test';
17
+ import {
18
+ FOLDER_GAP_COLLAPSED,
19
+ FOLDER_GAP_EXPANDED,
20
+ folderGroupGap,
21
+ } from '@/client/components/mobile/mobile-session-sidebar/folder-gap';
22
+
23
+ describe('mobile folder group gap', () => {
24
+ test('a closed folder takes a row-sized gap', () => {
25
+ expect(folderGroupGap(false)).toBe(FOLDER_GAP_COLLAPSED);
26
+ // The bug: `mb-4` on a closed folder put two of them 59px apart on a
27
+ // 390px phone, with 39px of blank ink between their names.
28
+ expect(folderGroupGap(false)).not.toBe(FOLDER_GAP_EXPANDED);
29
+ });
30
+
31
+ test('an open folder keeps the group gap', () => {
32
+ expect(folderGroupGap(true)).toBe(FOLDER_GAP_EXPANDED);
33
+ });
34
+
35
+ test('the two gaps differ, and the closed one is the smaller step', () => {
36
+ // `mb-1` = 4px, `mb-4` = 16px. Written as the Tailwind steps they are, so
37
+ // a rename that swapped the two cannot pass by keeping them unequal.
38
+ expect(FOLDER_GAP_COLLAPSED).toBe('mb-1');
39
+ expect(FOLDER_GAP_EXPANDED).toBe('mb-4');
40
+ });
41
+ });
@@ -0,0 +1,39 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The gap under one workspace group in the phone's drawer.
8
+ *
9
+ * The gap belongs to the GROUP, so it shrinks when there is no group to
10
+ * separate. Measured on a 390px phone: a folder header is 43px tall (its `+`
11
+ * and `⋯` controls are 27px boxes inside 8px of padding, which is the touch
12
+ * target), so the 16px that separates two EXPANDED groups put two CLOSED
13
+ * folders 59px apart with 39px of blank ink between their names — twice the
14
+ * list's own row rhythm (33px rows, 19px between titles). A closed folder is a
15
+ * row, not a section, so it takes a row-sized gap; the group gap returns the
16
+ * moment its sessions do.
17
+ *
18
+ * Stated here rather than inline in the component because it is the one thing
19
+ * a mounted test cannot check: `happy-dom` resolves no layout, so a component
20
+ * test can only read the class back, which the class string itself already
21
+ * says. This module is the rule; the component applies it.
22
+ */
23
+
24
+ /** Row-sized: the drawer's rows are separated by 2px, and the desktop list
25
+ * puts 4px between a header and the rows it owns. */
26
+ export const FOLDER_GAP_COLLAPSED = 'mb-1';
27
+
28
+ /** Group-sized: what separates one workspace's list from the next. */
29
+ export const FOLDER_GAP_EXPANDED = 'mb-4';
30
+
31
+ /**
32
+ * `listOpen` is the same flag the component uses to draw the rows — the
33
+ * EXPANDED state, or a batch selection that force-opens the group so its
34
+ * checkboxes exist. A group whose rows are on screen is a section whatever the
35
+ * user's own expansion flag says.
36
+ */
37
+ export function folderGroupGap(listOpen: boolean): string {
38
+ return listOpen ? FOLDER_GAP_EXPANDED : FOLDER_GAP_COLLAPSED;
39
+ }
@@ -25,7 +25,7 @@ import type { PanelBodyProps } from '@/client/components/workspace/panels/builti
25
25
  import { usePanelCatalog, useLeftDockView } from '@/client/components/workspace/plugin-panel/catalog';
26
26
  import type { ResolvedPanel } from '@/client/components/workspace/plugin-panel/catalog';
27
27
  import { useLeftDock } from '@/client/hooks/workspace/left-dock';
28
- import { useSessionState } from '@/client/hooks/workspace/session-state';
28
+ import { useSharedSessionState } from '@/client/hooks/workspace/session-state';
29
29
  import { SESSIONS_VIEW_ID } from '@/shared/lib/workspace/left-dock';
30
30
 
31
31
  export interface MobileDockState {
@@ -43,7 +43,11 @@ export interface MobileDockState {
43
43
  export function useMobileDock(): MobileDockState {
44
44
  const dock = useLeftDock();
45
45
  const catalog = usePanelCatalog('left');
46
- const [stored, setStored] = useSessionState<string>('layout.mobileLeftPanel', SESSIONS_VIEW_ID);
46
+ // SHARED, not private: the navbar's own readout can ask for this view, and
47
+ // the write it makes is a different component's. A private copy would keep
48
+ // the selection it read at mount and the drawer would open on the old view —
49
+ // the same rule `useResolvedRepo` follows for a value a second surface moves.
50
+ const [stored, setStored] = useSharedSessionState<string>('layout.mobileLeftPanel', SESSIONS_VIEW_ID);
47
51
  const activeView = useLeftDockView(stored, dock.enabled);
48
52
 
49
53
  return {
@@ -24,6 +24,7 @@ import type { ConfigEntry } from '@/server/lib/omp/config/config-cli';
24
24
  import { OMP_SCHEMA } from '@/shared/lib/omp/config/schema';
25
25
  import { ConfigTabs } from '@/client/components/settings/categories/omp-settings/ConfigTabs';
26
26
  import { RoleEditor } from '@/client/components/settings/categories/omp-settings/RoleEditor';
27
+ import { useModelRoles } from '@/client/hooks/settings/model-roles';
27
28
  import { ExtensionsPanel } from '@/client/components/settings/categories/omp-settings/ExtensionsPanel';
28
29
 
29
30
  interface OmpSettingsProps {
@@ -50,7 +51,6 @@ export function OmpSettings(_props: OmpSettingsProps) {
50
51
  const [state, setState] = useState<AgentState | null>(null);
51
52
  const [stateRunning, setStateRunning] = useState(false);
52
53
  const [entries, setEntries] = useState<Record<string, ConfigEntry>>({});
53
- const [modelRoles, setModelRoles] = useState<Record<string, string>>({});
54
54
  const [extensions, setExtensions] = useState<ExtensionItem[]>([]);
55
55
  const [loading, setLoading] = useState(true);
56
56
  const [activeTabId, setActiveTabId] = useState(OMP_SCHEMA.tabs[0]?.id ?? 'model');
@@ -60,6 +60,16 @@ export function OmpSettings(_props: OmpSettingsProps) {
60
60
  const [savingKeys, setSavingKeys] = useState<Set<string>>(new Set());
61
61
  const [saveErrors, setSaveErrors] = useState<Map<string, string>>(new Map());
62
62
 
63
+ /** Flash a saved badge for one key, then clear it. */
64
+ const flashSaved = useCallback((key: string) => {
65
+ setSavedKey(key);
66
+ setTimeout(() => setSavedKey((current) => (current === key ? null : current)), 2000);
67
+ }, []);
68
+
69
+ // The role map, its presets and the role dropdowns' catalog. Its writes answer
70
+ // with the server's whole map/list, so the panel never renders a guess.
71
+ const modelRoles = useModelRoles(flashSaved);
72
+
63
73
  const load = useCallback(() => {
64
74
  setLoading(true);
65
75
  Promise.all([
@@ -72,20 +82,15 @@ export function OmpSettings(_props: OmpSettingsProps) {
72
82
  if (stateData.running && stateData.state) setState(stateData.state as AgentState);
73
83
  setStateRunning(Boolean(stateData.running));
74
84
  if (configData.entries) setEntries(configData.entries as Record<string, ConfigEntry>);
75
- if (rolesData.roles && typeof rolesData.roles === 'object') setModelRoles(rolesData.roles as Record<string, string>);
85
+ modelRoles.applyPayload(rolesData);
76
86
  if (Array.isArray(extensionsData.extensions)) setExtensions(extensionsData.extensions as ExtensionItem[]);
77
87
  })
78
88
  .catch((err) => console.error('Failed to load omp settings:', err))
79
89
  .finally(() => setLoading(false));
80
- }, []);
90
+ }, [modelRoles.applyPayload]);
81
91
 
82
92
  useEffect(() => { load(); }, [load]);
83
93
 
84
- const flashSaved = (key: string) => {
85
- setSavedKey(key);
86
- setTimeout(() => setSavedKey((current) => (current === key ? null : current)), 2000);
87
- };
88
-
89
94
  /**
90
95
  * Apply one write's outcome. A failure keeps omp's OWN sentence, not just a
91
96
  * flag: `omp config set` refuses a stale key with "Unknown setting: <key>",
@@ -174,23 +179,6 @@ export function OmpSettings(_props: OmpSettingsProps) {
174
179
  });
175
180
  }, []);
176
181
 
177
- const handleSaveRole = (role: string, value: string) => {
178
- const next = { ...modelRoles };
179
- if (value.trim()) next[role] = value.trim();
180
- else delete next[role];
181
- setModelRoles(next);
182
- fetch('/api/model-roles', {
183
- method: 'PUT',
184
- headers: { 'Content-Type': 'application/json' },
185
- body: JSON.stringify({ roles: next }),
186
- })
187
- .then((res) => res.json())
188
- .then((data) => {
189
- if (data.success) flashSaved(`role-${role}`);
190
- })
191
- .catch((err) => console.error('Failed to save model roles:', err));
192
- };
193
-
194
182
  const handleToggleExtension = (extension: ExtensionItem) => {
195
183
  fetch('/api/omp/extensions', {
196
184
  method: 'POST',
@@ -286,7 +274,7 @@ export function OmpSettings(_props: OmpSettingsProps) {
286
274
  {searchQuery.trim() && ' — matching keys only'}
287
275
  </p>
288
276
  </div>
289
- {savedKey && !savedKey.startsWith('role-') && !savedKey.startsWith('ext-') && (
277
+ {savedKey && savedKey !== 'role-map' && !savedKey.startsWith('ext-') && (
290
278
  <span className="inline-flex items-center gap-1 text-[10px] text-ink/60">saved {savedKey}</span>
291
279
  )}
292
280
  </div>
@@ -306,9 +294,17 @@ export function OmpSettings(_props: OmpSettingsProps) {
306
294
  </div>
307
295
 
308
296
  <RoleEditor
309
- modelRoles={modelRoles}
310
- onSaveRole={handleSaveRole}
311
- savedRole={savedKey?.startsWith('role-') ? savedKey.slice(5) : null}
297
+ roles={modelRoles.roles}
298
+ presets={modelRoles.presets}
299
+ candidates={modelRoles.candidates}
300
+ candidatesError={modelRoles.candidatesError}
301
+ onSaveRoles={modelRoles.writeRoles}
302
+ onSavePreset={modelRoles.savePreset}
303
+ onUpdatePreset={modelRoles.updatePreset}
304
+ onDeletePreset={modelRoles.deletePreset}
305
+ savedRole={savedKey === 'role-map' ? 'roles' : null}
306
+ writeError={modelRoles.writeError}
307
+ onDismissWriteError={modelRoles.clearWriteError}
312
308
  />
313
309
 
314
310
  <ExtensionsPanel
@@ -0,0 +1,187 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Preset switcher for the model-role editor.
8
+ *
9
+ * The problem it solves: a role map is up to sixteen assignments, and switching
10
+ * between "everything on the cheap model" and "everything on the thinking model"
11
+ * used to mean sixteen edits. A preset is that map under a name (stored in
12
+ * Kabmo's own `model_role_presets` table), and choosing one APPLIES it.
13
+ *
14
+ * Three behaviours are deliberate:
15
+ *
16
+ * - **Choosing a preset applies it immediately.** A preset picker with a
17
+ * separate Apply button makes the control's state ambiguous ("selected but
18
+ * not in force"), and the write is one `PUT` of a map the user can re-apply
19
+ * from the same list. The list is the switch.
20
+ * - **The selected preset survives an edit, and "in use" does not.** They are
21
+ * two different facts: the selection is what `Update` would overwrite, while
22
+ * `in use` means the live map still equals it. Tying both to the comparison
23
+ * made `Update` unreachable in the exact flow it exists for — pick a preset,
24
+ * tweak one role, save the tweak back into it — because the edit moved the
25
+ * picker to `Custom` and disabled the button. So the picker reads
26
+ * `preset · modified` while they differ, and `in use` only while they agree.
27
+ * - **Overwriting and deleting both ask first, in place.** `Save as…` names a
28
+ * new preset; `Update` overwrites the SELECTED one (never a guess), and
29
+ * Delete confirms inline before removing the record.
30
+ */
31
+
32
+ import { useState } from 'preact/hooks';
33
+ import { Check, Plus, Save, Trash2 } from 'lucide-preact';
34
+ import type { ModelRolePreset } from '@/shared/types/model-roles';
35
+
36
+ interface PresetBarProps {
37
+ presets: ModelRolePreset[];
38
+ /** Preset the user picked; the target of Update/Delete. */
39
+ selectedPreset: ModelRolePreset | null;
40
+ /** Whether the live role map still equals the selected preset. */
41
+ inUse: boolean;
42
+ /** A write is in flight; every control is disabled rather than racing it. */
43
+ busy: boolean;
44
+ onApply: (preset: ModelRolePreset) => void;
45
+ onSave: (name: string) => void;
46
+ onUpdate: () => void;
47
+ onDelete: () => void;
48
+ }
49
+
50
+ export function PresetBar({
51
+ presets,
52
+ selectedPreset,
53
+ inUse,
54
+ busy,
55
+ onApply,
56
+ onSave,
57
+ onUpdate,
58
+ onDelete,
59
+ }: PresetBarProps) {
60
+ const [creating, setCreating] = useState(false);
61
+ const [name, setName] = useState('');
62
+ const [confirmDelete, setConfirmDelete] = useState(false);
63
+
64
+ const commitNew = () => {
65
+ const trimmed = name.trim();
66
+ if (!trimmed) return;
67
+ onSave(trimmed);
68
+ setName('');
69
+ setCreating(false);
70
+ };
71
+
72
+ return (
73
+ <div className="rounded-lg border border-ink/10 bg-ink/[0.02] px-3 py-2 mb-3">
74
+ <div className="flex flex-wrap items-center gap-2">
75
+ <span className="text-[10px] font-semibold uppercase tracking-wide text-ink/50">Preset</span>
76
+ <select
77
+ aria-label="Model role preset"
78
+ value={selectedPreset?.id ?? ''}
79
+ disabled={busy || presets.length === 0}
80
+ onChange={(event) => {
81
+ const chosen = presets.find((preset) => preset.id === event.currentTarget.value);
82
+ if (chosen) onApply(chosen);
83
+ }}
84
+ className="bg-paper border border-ink/15 rounded-md px-2 py-1 text-ink text-[11px] cursor-pointer disabled:opacity-50 min-w-[10rem]"
85
+ >
86
+ <option value="">{presets.length === 0 ? 'No presets yet' : 'Custom'}</option>
87
+ {presets.map((preset) => (
88
+ <option key={preset.id} value={preset.id}>{preset.name}</option>
89
+ ))}
90
+ </select>
91
+
92
+ {selectedPreset && (
93
+ <span
94
+ className={`inline-flex items-center gap-1 text-[10px] ${inUse ? 'text-ink/50' : 'text-warning'}`}
95
+ title={inUse ? 'The live roles are this preset' : 'The roles differ from this preset — Update stores them'}
96
+ >
97
+ {inUse ? <><Check size={11} /> in use</> : 'modified'}
98
+ </span>
99
+ )}
100
+
101
+ <div className="ml-auto flex items-center gap-1.5">
102
+ <button
103
+ type="button"
104
+ onClick={() => setCreating((open) => !open)}
105
+ disabled={busy}
106
+ title="Save the current roles as a new preset"
107
+ className="flex items-center gap-1 px-2 py-1 rounded-md border border-ink/15 text-ink/70 hover:text-ink hover:bg-ink/5 transition-colors cursor-pointer disabled:opacity-50 text-[11px]"
108
+ >
109
+ <Plus size={11} /> Save as…
110
+ </button>
111
+ <button
112
+ type="button"
113
+ onClick={onUpdate}
114
+ disabled={busy || !selectedPreset}
115
+ title={selectedPreset ? `Overwrite “${selectedPreset.name}” with the current roles` : 'Pick a preset first'}
116
+ className="flex items-center gap-1 px-2 py-1 rounded-md border border-ink/15 text-ink/70 hover:text-ink hover:bg-ink/5 transition-colors cursor-pointer disabled:opacity-40 text-[11px]"
117
+ >
118
+ <Save size={11} /> Update
119
+ </button>
120
+ <button
121
+ type="button"
122
+ onClick={() => setConfirmDelete(true)}
123
+ disabled={busy || !selectedPreset}
124
+ title={selectedPreset ? `Delete “${selectedPreset.name}”` : 'Pick a preset first'}
125
+ className="p-1 rounded-md border border-ink/15 text-ink/50 hover:text-error hover:border-error/40 transition-colors cursor-pointer disabled:opacity-40"
126
+ >
127
+ <Trash2 size={12} />
128
+ </button>
129
+ </div>
130
+ </div>
131
+
132
+ {creating && (
133
+ <div className="flex items-center gap-2 pt-2">
134
+ <input
135
+ aria-label="New preset name"
136
+ type="text"
137
+ value={name}
138
+ autoFocus
139
+ onInput={(event) => setName(event.currentTarget.value)}
140
+ onKeyDown={(event) => {
141
+ if (event.key === 'Enter') commitNew();
142
+ if (event.key === 'Escape') { setCreating(false); setName(''); }
143
+ }}
144
+ placeholder="e.g. cheap, thinking, review"
145
+ className="flex-1 bg-paper border border-ink/15 rounded-md px-2 py-1 text-ink text-[11px]"
146
+ />
147
+ <button
148
+ type="button"
149
+ onClick={commitNew}
150
+ disabled={!name.trim() || busy}
151
+ className="px-2 py-1 rounded-md border border-ink/15 text-ink/80 hover:bg-ink/5 transition-colors cursor-pointer disabled:opacity-50 text-[11px]"
152
+ >
153
+ Save
154
+ </button>
155
+ <button
156
+ type="button"
157
+ onClick={() => { setCreating(false); setName(''); }}
158
+ className="px-2 py-1 rounded-md text-ink/50 hover:text-ink cursor-pointer text-[11px]"
159
+ >
160
+ Cancel
161
+ </button>
162
+ </div>
163
+ )}
164
+
165
+ {confirmDelete && selectedPreset && (
166
+ <div className="flex items-center gap-2 pt-2 text-[11px] text-ink/70">
167
+ <span>Delete “{selectedPreset.name}”? The roles themselves are untouched.</span>
168
+ <button
169
+ type="button"
170
+ onClick={() => { setConfirmDelete(false); onDelete(); }}
171
+ disabled={busy}
172
+ className="px-2 py-1 rounded-md border border-error/40 text-error hover:bg-error/10 transition-colors cursor-pointer disabled:opacity-50"
173
+ >
174
+ Delete
175
+ </button>
176
+ <button
177
+ type="button"
178
+ onClick={() => setConfirmDelete(false)}
179
+ className="px-2 py-1 rounded-md text-ink/50 hover:text-ink cursor-pointer"
180
+ >
181
+ Cancel
182
+ </button>
183
+ </div>
184
+ )}
185
+ </div>
186
+ );
187
+ }