@kabmo-labs/kabmo 0.5.3 → 0.6.1
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-9js9mrmj.js → index-aq0e78gv.js} +704 -704
- 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 +5 -8
- 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 -2
- 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/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/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/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/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/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/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
|
@@ -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="
|
|
130
|
+
<div className={folderGroupGap(listOpen)} data-testid="session-folder">
|
|
130
131
|
<div
|
|
131
132
|
onClick={batch ? undefined : onToggleExpand}
|
|
132
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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 &&
|
|
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
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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
|
+
}
|