@polderlabs/bizar 4.4.13 → 4.5.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 (90) hide show
  1. package/bizar-dash/CHANGELOG.md +37 -276
  2. package/bizar-dash/dist/assets/main-CDFKHzBg.css +1 -0
  3. package/bizar-dash/dist/assets/main-NYFpS2wY.js +312 -0
  4. package/bizar-dash/dist/assets/main-NYFpS2wY.js.map +1 -0
  5. package/bizar-dash/dist/assets/{mobile-DSb-t42Y.js → mobile--0FBIKX3.js} +2 -2
  6. package/bizar-dash/dist/assets/{mobile-DSb-t42Y.js.map → mobile--0FBIKX3.js.map} +1 -1
  7. package/bizar-dash/dist/assets/mobile-OgRp8VIb.js +352 -0
  8. package/bizar-dash/dist/assets/mobile-OgRp8VIb.js.map +1 -0
  9. package/bizar-dash/dist/index.html +3 -3
  10. package/bizar-dash/dist/mobile.html +2 -2
  11. package/bizar-dash/skills/agent-baseline/SKILL.md +80 -0
  12. package/bizar-dash/skills/bizar/SKILL.md +96 -0
  13. package/bizar-dash/skills/chat/SKILL.md +74 -0
  14. package/bizar-dash/skills/lightrag/SKILL.md +75 -0
  15. package/bizar-dash/skills/minimax/SKILL.md +80 -0
  16. package/bizar-dash/skills/obsidian/SKILL.md +55 -0
  17. package/bizar-dash/skills/providers/SKILL.md +75 -0
  18. package/bizar-dash/skills/sdk/SKILL.md +138 -0
  19. package/bizar-dash/skills/self-improvement/SKILL.md +53 -0
  20. package/bizar-dash/skills/skills-cli/SKILL.md +94 -0
  21. package/bizar-dash/skills/usage/SKILL.md +62 -0
  22. package/bizar-dash/src/server/api.mjs +12 -0
  23. package/bizar-dash/src/server/memory-lightrag.mjs +5 -2
  24. package/bizar-dash/src/server/memory-store.mjs +38 -0
  25. package/bizar-dash/src/server/minimax-usage-store.mjs +372 -0
  26. package/bizar-dash/src/server/minimax.mjs +196 -5
  27. package/bizar-dash/src/server/providers-store.mjs +956 -0
  28. package/bizar-dash/src/server/routes/config.mjs +52 -1
  29. package/bizar-dash/src/server/routes/env-vars.mjs +165 -0
  30. package/bizar-dash/src/server/routes/lightrag.mjs +154 -0
  31. package/bizar-dash/src/server/routes/memory.mjs +241 -1
  32. package/bizar-dash/src/server/routes/opencode-session-detail.mjs +14 -29
  33. package/bizar-dash/src/server/routes/opencode-sessions.mjs +205 -3
  34. package/bizar-dash/src/server/routes/providers.mjs +266 -5
  35. package/bizar-dash/src/server/routes/skills.mjs +32 -43
  36. package/bizar-dash/src/server/routes/update.mjs +340 -0
  37. package/bizar-dash/src/server/routes/usage.mjs +136 -0
  38. package/bizar-dash/src/server/serve-info.mjs +135 -4
  39. package/bizar-dash/src/server/server.mjs +4 -0
  40. package/bizar-dash/src/server/skills-store.mjs +152 -262
  41. package/bizar-dash/src/web/App.tsx +118 -29
  42. package/bizar-dash/src/web/components/EnvVarManager.tsx +247 -0
  43. package/bizar-dash/src/web/components/SettingsSearch.tsx +213 -0
  44. package/bizar-dash/src/web/components/Topbar.tsx +0 -1
  45. package/bizar-dash/src/web/components/UsageChart.tsx +250 -0
  46. package/bizar-dash/src/web/components/UsageTable.tsx +90 -0
  47. package/bizar-dash/src/web/components/chat/ChatComposer.tsx +21 -25
  48. package/bizar-dash/src/web/components/chat/ChatInfoPanel.tsx +199 -37
  49. package/bizar-dash/src/web/components/chat/ChatThread.tsx +29 -17
  50. package/bizar-dash/src/web/components/chat/FloatingComposer.tsx +7 -1
  51. package/bizar-dash/src/web/components/chat/InfoPanel.tsx +71 -6
  52. package/bizar-dash/src/web/components/chat/useChat.ts +751 -257
  53. package/bizar-dash/src/web/lib/api.ts +43 -0
  54. package/bizar-dash/src/web/main.tsx +1 -0
  55. package/bizar-dash/src/web/mobile/views/MobileChat.tsx +110 -35
  56. package/bizar-dash/src/web/styles/chat.css +135 -1
  57. package/bizar-dash/src/web/styles/main.css +46 -0
  58. package/bizar-dash/src/web/styles/minimax-usage.css +335 -0
  59. package/bizar-dash/src/web/styles/settings.css +418 -0
  60. package/bizar-dash/src/web/styles/skills.css +302 -0
  61. package/bizar-dash/src/web/styles/tasks.css +288 -0
  62. package/bizar-dash/src/web/views/Chat.tsx +276 -48
  63. package/bizar-dash/src/web/views/Config.tsx +3 -2065
  64. package/bizar-dash/src/web/views/MiniMaxUsage.tsx +476 -461
  65. package/bizar-dash/src/web/views/Settings.tsx +6 -0
  66. package/bizar-dash/src/web/views/Skills.tsx +208 -260
  67. package/bizar-dash/src/web/views/Tasks.tsx +348 -1119
  68. package/bizar-dash/tests/chat-session-create.test.mjs +391 -0
  69. package/bizar-dash/tests/chat-session-stream.test.mjs +308 -0
  70. package/bizar-dash/tests/env-vars-store.test.mjs +216 -0
  71. package/bizar-dash/tests/lightrag-defaults.node.test.mjs +118 -0
  72. package/bizar-dash/tests/minimax-chat-usage.test.mjs +178 -0
  73. package/bizar-dash/tests/minimax-usage-store.node.test.mjs +293 -0
  74. package/bizar-dash/tests/opencode-sessions-detail.test.mjs +12 -9
  75. package/bizar-dash/tests/providers-store-backup-keys.node.test.mjs +479 -3
  76. package/bizar-dash/tests/providers-store-search.node.test.mjs +166 -0
  77. package/bizar-dash/tests/skills-list.test.mjs +232 -0
  78. package/bizar-dash/tests/skills-search.test.mjs +222 -0
  79. package/bizar-dash/tests/tasks-create.test.mjs +187 -0
  80. package/bizar-dash/tests/update-check.test.mjs +127 -0
  81. package/bizar-dash/tests/update-run.test.mjs +266 -0
  82. package/cli/bin.mjs +82 -1
  83. package/cli/provision.mjs +118 -4
  84. package/config/agents/_shared/SKILLS.md +109 -0
  85. package/package.json +1 -1
  86. package/bizar-dash/dist/assets/main-BB5mJurD.js +0 -352
  87. package/bizar-dash/dist/assets/main-BB5mJurD.js.map +0 -1
  88. package/bizar-dash/dist/assets/main-BsnQLXdh.css +0 -1
  89. package/bizar-dash/dist/assets/mobile-Dl1q7Cyq.js +0 -354
  90. package/bizar-dash/dist/assets/mobile-Dl1q7Cyq.js.map +0 -1
@@ -7,8 +7,8 @@ import { ModalProvider, useModal } from './components/Modal';
7
7
  import { ToastProvider, useToast } from './components/Toast';
8
8
  import { SearchModal } from './components/SearchModal';
9
9
  import { Notifications } from './components/Notifications';
10
+ import { api, ApiError } from './lib/api';
10
11
  import { CommandDialog, type DialogDescriptor } from './components/CommandDialog';
11
- import { api } from './lib/api';
12
12
  import { Ws } from './lib/ws';
13
13
  import {
14
14
  applyTheme,
@@ -79,7 +79,7 @@ const VIEW_MAP: Record<string, (p: ViewProps) => React.ReactNode> = {
79
79
  minimax: MiniMaxUsage,
80
80
  };
81
81
 
82
- const VERSION = 'v3.21.0';
82
+ const VERSION = 'v4.5.0';
83
83
 
84
84
  /**
85
85
  * Render the active view. If `activeTab` matches a built-in tab id,
@@ -113,6 +113,67 @@ function renderActiveView(
113
113
  return <V {...viewProps} />;
114
114
  }
115
115
 
116
+ // v3.6.2 — Inline token-entry form shown on the boot-error screen when
117
+ // the server returns 401 (reverse-proxy / Tailscale Serve scenario).
118
+ // The user pastes the token, it is saved to localStorage, and boot is
119
+ // retried. The form closes on Escape or on successful save.
120
+ function TokenEntryForm({ onSaved }: { onSaved: () => void }) {
121
+ const [value, setValue] = useState('');
122
+ const inputRef = useRef<HTMLInputElement>(null);
123
+
124
+ // Auto-focus the input on mount
125
+ useEffect(() => {
126
+ inputRef.current?.focus();
127
+ }, []);
128
+
129
+ const submit = () => {
130
+ const tok = value.trim();
131
+ if (!tok) return;
132
+ api.setToken(tok);
133
+ onSaved();
134
+ };
135
+
136
+ const handleKeyDown = (e: React.KeyboardEvent) => {
137
+ if (e.key === 'Escape') {
138
+ e.preventDefault();
139
+ onSaved();
140
+ }
141
+ if (e.key === 'Enter') {
142
+ e.preventDefault();
143
+ submit();
144
+ }
145
+ };
146
+
147
+ return (
148
+ <form
149
+ className="token-entry-form"
150
+ onSubmit={(e) => { e.preventDefault(); submit(); }}
151
+ >
152
+ <div className="token-entry-row">
153
+ <input
154
+ ref={inputRef}
155
+ type="password"
156
+ className="input mono"
157
+ value={value}
158
+ onChange={(e) => setValue(e.target.value)}
159
+ placeholder="Paste auth token"
160
+ spellCheck={false}
161
+ autoComplete="off"
162
+ onKeyDown={handleKeyDown}
163
+ />
164
+ <Button type="submit" variant="primary" size="sm" disabled={!value.trim()}>
165
+ Save &amp; retry
166
+ </Button>
167
+ </div>
168
+ <p className="token-entry-hint">
169
+ Where do I find this token? Check the file{' '}
170
+ <code>~/.config/bizar/dashboard-secret</code> on the machine
171
+ running the dashboard, or look for it in the server output.
172
+ </p>
173
+ </form>
174
+ );
175
+ }
176
+
116
177
  export function App() {
117
178
  return (
118
179
  <ToastProvider>
@@ -133,6 +194,7 @@ function Shell() {
133
194
  const [settings, setSettings] = useState<Settings | null>(null);
134
195
  const [wsStatus, setWsStatus] = useState<WsStatus>('connecting');
135
196
  const [bootError, setBootError] = useState<string | null>(null);
197
+ const [authPromptOpen, setAuthPromptOpen] = useState(false);
136
198
  const [searchOpen, setSearchOpen] = useState(false);
137
199
  const [stuckAgents, setStuckAgents] = useState<{ name: string }[]>([]);
138
200
  const [stuckBannerDismissed, setStuckBannerDismissed] = useState(false);
@@ -196,9 +258,11 @@ function Shell() {
196
258
  return () => mql.removeEventListener('change', handler);
197
259
  }, [settings?.theme?.mode]);
198
260
 
199
- // Initial fetch runs once on mount
200
- useEffect(() => {
261
+ // v3.6.2Boot logic extracted to retryBoot so it can be re-invoked
262
+ // after the user pastes a token in the inline recovery form.
263
+ const retryBoot = useCallback(async () => {
201
264
  let cancelled = false;
265
+
202
266
  const loadBootData = () =>
203
267
  Promise.all([
204
268
  api.get<Snapshot>('/snapshot').catch(() => null),
@@ -216,42 +280,59 @@ function Shell() {
216
280
  if (stuck?.stuck) setStuckAgents(stuck.stuck);
217
281
  };
218
282
 
219
- (async () => {
220
- try {
221
- const auth = await api.probeAuthStatus();
222
- const [snap, set, stuck] = await loadBootData();
223
- if (cancelled) return;
283
+ try {
284
+ const auth = await api.probeAuthStatus();
285
+ const [snap, set, stuck] = await loadBootData();
286
+ if (cancelled) return;
224
287
 
225
- applyBootData(snap, set, stuck);
226
- if (snap || set) return;
288
+ applyBootData(snap, set, stuck);
289
+ if (snap || set) {
290
+ setBootError(null);
291
+ return;
292
+ }
227
293
 
228
- if (!auth.loopback) {
229
- setBootError('Dashboard server unreachable.');
230
- return;
231
- }
294
+ if (!auth.loopback) {
295
+ setBootError('Dashboard server unreachable.');
296
+ return;
297
+ }
232
298
 
233
- await new Promise((resolve) => setTimeout(resolve, 1000));
234
- if (cancelled) return;
299
+ await new Promise((resolve) => setTimeout(resolve, 1000));
300
+ if (cancelled) return;
235
301
 
236
- const [retrySnap, retrySet, retryStuck] = await loadBootData();
237
- if (cancelled) return;
302
+ const [retrySnap, retrySet, retryStuck] = await loadBootData();
303
+ if (cancelled) return;
238
304
 
239
- applyBootData(retrySnap, retrySet, retryStuck);
240
- if (!retrySnap && !retrySet) {
241
- setBootError('Dashboard server unreachable.');
242
- }
243
- } catch (err) {
244
- if (cancelled) return;
245
- const msg = (err as Error)?.message ?? 'unknown error';
305
+ applyBootData(retrySnap, retrySet, retryStuck);
306
+ if (!retrySnap && !retrySet) {
307
+ setBootError('Dashboard server unreachable.');
308
+ }
309
+ } catch (err) {
310
+ if (cancelled) return;
311
+ const msg = (err as Error)?.message ?? 'unknown error';
312
+ // v3.6.2 — If we got a 401, the user needs to supply a token.
313
+ // Surface the form inline instead of just the error message.
314
+ if (err instanceof ApiError && err.status === 401) {
246
315
  setBootError(msg);
247
- toast.error(`Failed to load: ${msg}`);
316
+ setAuthPromptOpen(true);
317
+ toast.error(`Auth required: ${msg}`);
318
+ return;
248
319
  }
249
- })();
320
+ setBootError(msg);
321
+ toast.error(`Failed to load: ${msg}`);
322
+ }
323
+ }, [toast]);
250
324
 
325
+ // Initial fetch — runs once on mount
326
+ useEffect(() => {
327
+ let cancelled = false;
328
+ const id = setTimeout(() => {
329
+ if (!cancelled) retryBoot();
330
+ }, 0);
251
331
  return () => {
252
332
  cancelled = true;
333
+ clearTimeout(id);
253
334
  };
254
- }, [toast]);
335
+ }, [retryBoot]);
255
336
 
256
337
  // Apply defaultTab ONCE on mount only — must NOT re-fire on toast changes
257
338
  useEffect(() => {
@@ -695,6 +776,14 @@ function Shell() {
695
776
  <div className="boot-error">
696
777
  <h2>Dashboard unavailable</h2>
697
778
  <p>{bootError}</p>
779
+ {authPromptOpen && (
780
+ <TokenEntryForm
781
+ onSaved={() => {
782
+ setAuthPromptOpen(false);
783
+ retryBoot();
784
+ }}
785
+ />
786
+ )}
698
787
  <p className="boot-error-hint">
699
788
  Make sure the Bizar dashboard server is running. Try{' '}
700
789
  <code>bizar-dash start</code> in your terminal.
@@ -0,0 +1,247 @@
1
+ // src/web/components/EnvVarManager.tsx — manage BIZAR_* env vars from ~/.config/bizar/env.json
2
+ import { useCallback, useEffect, useState } from 'react';
3
+ import { Plus, Pencil, Trash2, Eye, EyeOff, RefreshCw, KeyRound, CheckCircle, X } from 'lucide-react';
4
+ import { Button } from './Button';
5
+ import { useToast } from './Toast';
6
+ import { api } from '../lib/api';
7
+ import { cn } from '../lib/utils';
8
+
9
+ export type EnvVarEntry = {
10
+ name: string;
11
+ value: string; // masked on GET, full on edit
12
+ createdAt: string;
13
+ source: string;
14
+ };
15
+
16
+ type Props = {
17
+ onError?: (msg: string) => void;
18
+ };
19
+
20
+ export function EnvVarManager({ onError }: Props) {
21
+ const toast = useToast();
22
+ const [vars, setVars] = useState<EnvVarEntry[]>([]);
23
+ const [loading, setLoading] = useState(false);
24
+ const [saving, setSaving] = useState(false);
25
+ // Which rows have the value revealed (edit mode shows real value)
26
+ const [revealed, setRevealed] = useState<Set<string>>(new Set());
27
+ // Currently editing name (null = none)
28
+ const [editing, setEditing] = useState<string | null>(null);
29
+ const [editValue, setEditValue] = useState('');
30
+ // New var form
31
+ const [showAdd, setShowAdd] = useState(false);
32
+ const [newName, setNewName] = useState('');
33
+ const [newValue, setNewValue] = useState('');
34
+
35
+ const load = useCallback(async () => {
36
+ setLoading(true);
37
+ try {
38
+ const r = await api.get<EnvVarEntry[]>('/env-vars');
39
+ setVars(r);
40
+ } catch (err) {
41
+ toast.error(`Load failed: ${(err as Error).message}`);
42
+ onError?.((err as Error).message);
43
+ } finally {
44
+ setLoading(false);
45
+ }
46
+ }, [toast, onError]);
47
+
48
+ useEffect(() => { load(); }, [load]);
49
+
50
+ const toggleReveal = (name: string) => {
51
+ setRevealed((prev) => {
52
+ const next = new Set(prev);
53
+ if (next.has(name)) next.delete(name);
54
+ else next.add(name);
55
+ return next;
56
+ });
57
+ };
58
+
59
+ const handleAdd = async () => {
60
+ const name = newName.trim().toUpperCase();
61
+ const value = newValue.trim();
62
+ if (!name || !/^BIZAR_[A-Z0-9_]+$/.test(name)) {
63
+ toast.warning('Name must match BIZAR_<NAME> (e.g. BIZAR_MINIMAX_KEY)');
64
+ return;
65
+ }
66
+ if (!value) {
67
+ toast.warning('Value is required.');
68
+ return;
69
+ }
70
+ if (vars.some((v) => v.name === name)) {
71
+ toast.warning(`${name} already exists.`);
72
+ return;
73
+ }
74
+ setSaving(true);
75
+ try {
76
+ await api.post('/env-vars', { name, value });
77
+ toast.success(`${name} created.`);
78
+ setShowAdd(false);
79
+ setNewName('');
80
+ setNewValue('');
81
+ await load();
82
+ } catch (err) {
83
+ toast.error(`Create failed: ${(err as Error).message}`);
84
+ } finally {
85
+ setSaving(false);
86
+ }
87
+ };
88
+
89
+ const startEdit = (v: EnvVarEntry) => {
90
+ setEditing(v.name);
91
+ setEditValue(v.value === v.value.replace(/\*/g, '') ? v.value : '');
92
+ setRevealed((prev) => { const next = new Set(prev); next.add(v.name); return next; });
93
+ };
94
+
95
+ const handleUpdate = async (name: string) => {
96
+ if (!editValue.trim()) {
97
+ toast.warning('Value cannot be empty.');
98
+ return;
99
+ }
100
+ setSaving(true);
101
+ try {
102
+ await api.put(`/env-vars/${encodeURIComponent(name)}`, { value: editValue.trim() });
103
+ toast.success(`${name} updated.`);
104
+ setEditing(null);
105
+ await load();
106
+ } catch (err) {
107
+ toast.error(`Update failed: ${(err as Error).message}`);
108
+ } finally {
109
+ setSaving(false);
110
+ }
111
+ };
112
+
113
+ const handleDelete = async (name: string) => {
114
+ if (!confirm(`Delete ${name}? This cannot be undone.`)) return;
115
+ try {
116
+ await api.del(`/env-vars/${encodeURIComponent(name)}`);
117
+ toast.success(`${name} deleted.`);
118
+ await load();
119
+ } catch (err) {
120
+ toast.error(`Delete failed: ${(err as Error).message}`);
121
+ }
122
+ };
123
+
124
+ if (loading) {
125
+ return <div className="muted" style={{ padding: '16px 0', fontSize: 13 }}>Loading env vars…</div>;
126
+ }
127
+
128
+ return (
129
+ <div className="env-var-manager">
130
+ <div className="env-var-toolbar">
131
+ <Button variant="primary" size="sm" onClick={() => setShowAdd(true)}>
132
+ <Plus size={12} /> New variable
133
+ </Button>
134
+ <Button variant="ghost" size="sm" onClick={load} title="Reload">
135
+ <RefreshCw size={12} />
136
+ </Button>
137
+ </div>
138
+
139
+ {vars.length === 0 && !showAdd && (
140
+ <div className="env-var-empty">
141
+ <KeyRound size={20} />
142
+ <span>No BIZAR_* env vars yet.</span>
143
+ <span style={{ fontSize: 11 }}>Create one to store API keys securely.</span>
144
+ </div>
145
+ )}
146
+
147
+ {/* New var form */}
148
+ {showAdd && (
149
+ <div className="env-var-add-form">
150
+ <div className="env-var-add-row">
151
+ <input
152
+ className="input mono"
153
+ style={{ width: 240 }}
154
+ placeholder="BIZAR_NAME"
155
+ value={newName}
156
+ onChange={(e) => setNewName(e.target.value.toUpperCase())}
157
+ onKeyDown={(e) => { if (e.key === 'Enter') handleAdd(); if (e.key === 'Escape') setShowAdd(false); }}
158
+ autoFocus
159
+ />
160
+ <input
161
+ className="input mono"
162
+ type="password"
163
+ placeholder="value (API key, token, etc.)"
164
+ value={newValue}
165
+ onChange={(e) => setNewValue(e.target.value)}
166
+ onKeyDown={(e) => { if (e.key === 'Enter') handleAdd(); if (e.key === 'Escape') setShowAdd(false); }}
167
+ />
168
+ <Button variant="primary" size="sm" onClick={handleAdd} disabled={saving}>
169
+ <CheckCircle size={12} /> Create
170
+ </Button>
171
+ <Button variant="ghost" size="sm" onClick={() => { setShowAdd(false); setNewName(''); setNewValue(''); }}>
172
+ <X size={12} />
173
+ </Button>
174
+ </div>
175
+ <p style={{ fontSize: 11, color: 'var(--text-dim)', margin: 0 }}>
176
+ Names must match <code>BIZAR_<var>NAME</var></code> (uppercase letters, digits, underscores).
177
+ </p>
178
+ </div>
179
+ )}
180
+
181
+ {/* Var list */}
182
+ {vars.length > 0 && (
183
+ <div className="env-var-list">
184
+ {vars.map((v) => {
185
+ const isEditing = editing === v.name;
186
+ const isRevealed = revealed.has(v.name);
187
+ return (
188
+ <div key={v.name} className={cn('env-var-row', isEditing && 'env-var-row-editing')}>
189
+ <span className="env-var-name mono">{v.name}</span>
190
+ {isEditing ? (
191
+ <input
192
+ className="input mono"
193
+ type="password"
194
+ style={{ flex: 1, minWidth: 200 }}
195
+ value={editValue}
196
+ onChange={(e) => setEditValue(e.target.value)}
197
+ onKeyDown={(e) => {
198
+ if (e.key === 'Enter') handleUpdate(v.name);
199
+ if (e.key === 'Escape') setEditing(null);
200
+ }}
201
+ autoFocus
202
+ />
203
+ ) : (
204
+ <span className={cn('env-var-value mono', !isRevealed && 'env-var-value-masked')}>
205
+ {isRevealed ? v.value : v.value}
206
+ </span>
207
+ )}
208
+ <span className="env-var-source muted">{v.source}</span>
209
+ <div className="env-var-actions">
210
+ {!isEditing && (
211
+ <button
212
+ type="button"
213
+ className="icon-btn"
214
+ title={isRevealed ? 'Hide' : 'Reveal'}
215
+ onClick={() => toggleReveal(v.name)}
216
+ >
217
+ {isRevealed ? <EyeOff size={12} /> : <Eye size={12} />}
218
+ </button>
219
+ )}
220
+ {isEditing ? (
221
+ <>
222
+ <Button variant="primary" size="sm" onClick={() => handleUpdate(v.name)} disabled={saving}>
223
+ Save
224
+ </Button>
225
+ <Button variant="ghost" size="sm" onClick={() => setEditing(null)}>
226
+ Cancel
227
+ </Button>
228
+ </>
229
+ ) : (
230
+ <>
231
+ <button type="button" className="icon-btn" title="Edit" onClick={() => startEdit(v)}>
232
+ <Pencil size={12} />
233
+ </button>
234
+ <button type="button" className="icon-btn icon-btn-danger" title="Delete" onClick={() => handleDelete(v.name)}>
235
+ <Trash2 size={12} />
236
+ </button>
237
+ </>
238
+ )}
239
+ </div>
240
+ </div>
241
+ );
242
+ })}
243
+ </div>
244
+ )}
245
+ </div>
246
+ );
247
+ }
@@ -0,0 +1,213 @@
1
+ // src/web/components/SettingsSearch.tsx — fuzzy search across Settings sections with scroll-to highlight
2
+ import { useCallback, useEffect, useRef, useState } from 'react';
3
+ import { Search, X } from 'lucide-react';
4
+ import Fuse from 'fuse.js';
5
+ import { cn } from '../lib/utils';
6
+
7
+ export type SettingsSection = {
8
+ id: string;
9
+ label: string;
10
+ icon?: string;
11
+ fields: SettingsField[];
12
+ };
13
+
14
+ export type SettingsField = {
15
+ key: string; // e.g. "theme.accent"
16
+ label: string; // e.g. "Accent color"
17
+ value?: string; // current value (masked if secret)
18
+ section: string; // parent section id
19
+ };
20
+
21
+ type Props = {
22
+ sections: SettingsSection[];
23
+ onJump: (sectionId: string, fieldKey?: string) => void;
24
+ };
25
+
26
+ type Match = {
27
+ item: SettingsField | SettingsSection;
28
+ score: number;
29
+ type: 'section' | 'field';
30
+ sectionId: string;
31
+ fieldKey?: string;
32
+ };
33
+
34
+ function fuseSearch(items: SettingsSection[], query: string): Match[] {
35
+ if (!query.trim()) return [];
36
+
37
+ const sectionFuse = new Fuse(items, {
38
+ keys: ['label'],
39
+ threshold: 0.4,
40
+ includeScore: true,
41
+ });
42
+
43
+ const fieldFuse = new Fuse(
44
+ items.flatMap((s) => s.fields.map((f) => ({ ...f, _sectionId: s.id }))),
45
+ { keys: ['label', 'key'], threshold: 0.4, includeScore: true },
46
+ );
47
+
48
+ const results: Match[] = [];
49
+
50
+ for (const r of sectionFuse.search(query)) {
51
+ results.push({
52
+ item: r.item,
53
+ score: r.score ?? 1,
54
+ type: 'section',
55
+ sectionId: r.item.id,
56
+ });
57
+ }
58
+
59
+ for (const r of fieldFuse.search(query)) {
60
+ results.push({
61
+ item: r.item as SettingsField,
62
+ score: r.score ?? 1,
63
+ type: 'field',
64
+ sectionId: (r.item as SettingsField & { _sectionId: string })._sectionId,
65
+ fieldKey: r.item.key,
66
+ });
67
+ }
68
+
69
+ // Deduplicate by sectionId+fieldKey, prefer lower score
70
+ const seen = new Set<string>();
71
+ return results
72
+ .filter((r) => {
73
+ const k = `${r.sectionId}::${r.fieldKey ?? ''}`;
74
+ if (seen.has(k)) return false;
75
+ seen.add(k);
76
+ return true;
77
+ })
78
+ .sort((a, b) => a.score - b.score)
79
+ .slice(0, 8);
80
+ }
81
+
82
+ export function SettingsSearch({ sections, onJump }: Props) {
83
+ const [q, setQ] = useState('');
84
+ const [matches, setMatches] = useState<Match[]>([]);
85
+ const [activeIdx, setActiveIdx] = useState(0);
86
+ const [highlighted, setHighlighted] = useState<string | null>(null);
87
+ const inputRef = useRef<HTMLInputElement>(null);
88
+ const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
89
+ const highlightTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
90
+
91
+ const search = useCallback(
92
+ (query: string) => {
93
+ if (debounceRef.current) clearTimeout(debounceRef.current);
94
+ if (!query.trim()) {
95
+ setMatches([]);
96
+ setActiveIdx(0);
97
+ return;
98
+ }
99
+ debounceRef.current = setTimeout(() => {
100
+ setMatches(fuseSearch(sections, query));
101
+ setActiveIdx(0);
102
+ }, 200);
103
+ },
104
+ [sections],
105
+ );
106
+
107
+ useEffect(() => {
108
+ search(q);
109
+ }, [q, search]);
110
+
111
+ // Cleanup timers
112
+ useEffect(() => {
113
+ return () => {
114
+ if (debounceRef.current) clearTimeout(debounceRef.current);
115
+ if (highlightTimerRef.current) clearTimeout(highlightTimerRef.current);
116
+ };
117
+ }, []);
118
+
119
+ const highlightSection = (sectionId: string) => {
120
+ setHighlighted(sectionId);
121
+ if (highlightTimerRef.current) clearTimeout(highlightTimerRef.current);
122
+ highlightTimerRef.current = setTimeout(() => setHighlighted(null), 1500);
123
+ const el = document.querySelector(`[data-section="${sectionId}"]`);
124
+ if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
125
+ };
126
+
127
+ const handleKey = (e: React.KeyboardEvent) => {
128
+ if (e.key === 'Escape') {
129
+ setQ('');
130
+ inputRef.current?.blur();
131
+ } else if (e.key === 'ArrowDown') {
132
+ e.preventDefault();
133
+ setActiveIdx((i) => Math.min(i + 1, matches.length - 1));
134
+ } else if (e.key === 'ArrowUp') {
135
+ e.preventDefault();
136
+ setActiveIdx((i) => Math.max(i - 1, 0));
137
+ } else if (e.key === 'Enter' && matches[activeIdx]) {
138
+ const m = matches[activeIdx];
139
+ highlightSection(m.sectionId);
140
+ onJump(m.sectionId, m.fieldKey);
141
+ setQ('');
142
+ }
143
+ };
144
+
145
+ const handleSelect = (m: Match) => {
146
+ highlightSection(m.sectionId);
147
+ onJump(m.sectionId, m.fieldKey);
148
+ setQ('');
149
+ };
150
+
151
+ return (
152
+ <div className="settings-search">
153
+ <div className="settings-search-input-wrap">
154
+ <Search size={13} className="settings-search-icon" />
155
+ <input
156
+ ref={inputRef}
157
+ className="settings-search-input"
158
+ placeholder="Search settings…"
159
+ value={q}
160
+ onChange={(e) => setQ(e.target.value)}
161
+ onKeyDown={handleKey}
162
+ aria-label="Search settings"
163
+ />
164
+ {q && (
165
+ <button
166
+ type="button"
167
+ className="icon-btn settings-search-clear"
168
+ onClick={() => setQ('')}
169
+ aria-label="Clear search"
170
+ >
171
+ <X size={12} />
172
+ </button>
173
+ )}
174
+ </div>
175
+
176
+ {q && matches.length > 0 && (
177
+ <div className="settings-search-results">
178
+ {matches.map((m, idx) => {
179
+ const label = m.type === 'section'
180
+ ? (m.item as SettingsSection).label
181
+ : (m.item as SettingsField).label;
182
+ const key = m.type === 'section'
183
+ ? (m.item as SettingsSection).id
184
+ : (m.item as SettingsField).key;
185
+ return (
186
+ <button
187
+ key={`${m.sectionId}::${key}`}
188
+ type="button"
189
+ className={cn('settings-search-result', idx === activeIdx && 'settings-search-result-active')}
190
+ onClick={() => handleSelect(m)}
191
+ onMouseEnter={() => setActiveIdx(idx)}
192
+ >
193
+ <span className={cn('settings-search-result-type', m.type === 'section' && 'settings-search-result-type-section')}>
194
+ {m.type === 'section' ? 'section' : 'field'}
195
+ </span>
196
+ <span className="settings-search-result-label">
197
+ {label}
198
+ </span>
199
+ {m.type === 'field' && (
200
+ <span className="settings-search-result-key mono muted">{key}</span>
201
+ )}
202
+ </button>
203
+ );
204
+ })}
205
+ </div>
206
+ )}
207
+
208
+ {q && matches.length === 0 && (
209
+ <div className="settings-search-empty muted">No matching settings.</div>
210
+ )}
211
+ </div>
212
+ );
213
+ }
@@ -50,7 +50,6 @@ export const TABS: TabDef[] = [
50
50
  { id: 'schedules', label: 'Schedules', icon: Clock },
51
51
  { id: 'history', label: 'History', icon: HistoryIcon },
52
52
  { id: 'minimax', label: 'Usage', icon: Coins },
53
- { id: 'config', label: 'Config', icon: Settings2 },
54
53
  { id: 'settings', label: 'Settings', icon: Sliders },
55
54
  ];
56
55