@polderlabs/bizar 5.0.2 → 5.2.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 (88) hide show
  1. package/bizar-dash/dist/assets/MobileChat-TCputYzr.js +1 -0
  2. package/bizar-dash/dist/assets/MobileChat-TCputYzr.js.map +1 -0
  3. package/bizar-dash/dist/assets/MobileSettings-BxcbL5XT.js +1 -0
  4. package/bizar-dash/dist/assets/MobileSettings-BxcbL5XT.js.map +1 -0
  5. package/bizar-dash/dist/assets/{icons-Bo0iH9EC.js → icons-DRDXfbBP.js} +151 -136
  6. package/bizar-dash/dist/assets/icons-DRDXfbBP.js.map +1 -0
  7. package/bizar-dash/dist/assets/main-D5Ditnrd.js +19 -0
  8. package/bizar-dash/dist/assets/main-D5Ditnrd.js.map +1 -0
  9. package/bizar-dash/dist/assets/{main-DTkNlLrw.css → main-xFpWMd32.css} +1 -1
  10. package/bizar-dash/dist/assets/markdown-C6mXtQxD.js +1 -0
  11. package/bizar-dash/dist/assets/markdown-C6mXtQxD.js.map +1 -0
  12. package/bizar-dash/dist/assets/mobile--17fkfrl.js +1 -0
  13. package/bizar-dash/dist/assets/{mobile-DD-FZrTC.js.map → mobile--17fkfrl.js.map} +1 -1
  14. package/bizar-dash/dist/assets/mobile-j3rOZK6v.js +2 -0
  15. package/bizar-dash/dist/assets/mobile-j3rOZK6v.js.map +1 -0
  16. package/bizar-dash/dist/assets/{react-vendor-DZRUXSPQ.js → react-vendor-Dn4wqh4Z.js} +8 -8
  17. package/bizar-dash/dist/assets/react-vendor-Dn4wqh4Z.js.map +1 -0
  18. package/bizar-dash/dist/assets/useSlashCommands-BG-DhEck.js +2 -0
  19. package/bizar-dash/dist/assets/useSlashCommands-BG-DhEck.js.map +1 -0
  20. package/bizar-dash/dist/assets/vendor-CeHGtduv.js +29 -0
  21. package/bizar-dash/dist/assets/vendor-CeHGtduv.js.map +1 -0
  22. package/bizar-dash/dist/index.html +8 -6
  23. package/bizar-dash/dist/mobile.html +4 -5
  24. package/bizar-dash/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
  25. package/bizar-dash/src/server/api.mjs +4 -0
  26. package/bizar-dash/src/server/eval.mjs +93 -3
  27. package/bizar-dash/src/server/metrics.mjs +75 -0
  28. package/bizar-dash/src/server/otel.mjs +141 -14
  29. package/bizar-dash/src/server/plugins/registry.mjs +145 -43
  30. package/bizar-dash/src/server/routes/chat.mjs +46 -9
  31. package/bizar-dash/src/server/routes/opencode-session-detail.mjs +120 -17
  32. package/bizar-dash/src/server/routes/plugins.mjs +131 -16
  33. package/bizar-dash/src/server/routes/tailscale.mjs +46 -0
  34. package/bizar-dash/src/server/routes/voice.mjs +42 -6
  35. package/bizar-dash/src/server/routes/workspaces.mjs +156 -61
  36. package/bizar-dash/src/server/server.mjs +57 -15
  37. package/bizar-dash/src/server/voice-store.mjs +27 -0
  38. package/bizar-dash/src/server/workers/transcription-worker.mjs +213 -0
  39. package/bizar-dash/src/web/App.tsx +9 -0
  40. package/bizar-dash/src/web/MobileApp.tsx +18 -10
  41. package/bizar-dash/src/web/components/EvalDiff.tsx +157 -0
  42. package/bizar-dash/src/web/components/EvalRunCard.tsx +78 -0
  43. package/bizar-dash/src/web/components/PluginCard.tsx +64 -0
  44. package/bizar-dash/src/web/components/PluginPermissions.tsx +36 -0
  45. package/bizar-dash/src/web/components/TailscaleSettings.tsx +161 -0
  46. package/bizar-dash/src/web/components/Toggle.tsx +31 -0
  47. package/bizar-dash/src/web/components/Topbar.tsx +6 -0
  48. package/bizar-dash/src/web/mobile/MobileChat.tsx +138 -0
  49. package/bizar-dash/src/web/mobile/MobileSettings.tsx +196 -0
  50. package/bizar-dash/src/web/styles/main.css +545 -0
  51. package/bizar-dash/src/web/views/Eval.tsx +172 -0
  52. package/bizar-dash/src/web/views/EvalReport.tsx +349 -0
  53. package/bizar-dash/src/web/views/Plugins.tsx +128 -0
  54. package/bizar-dash/src/web/views/Settings.tsx +2 -0
  55. package/bizar-dash/tests/bundle-analysis.test.mjs +5 -4
  56. package/bizar-dash/tests/cli-tailscale.test.mjs +113 -0
  57. package/bizar-dash/tests/components/plugin-permissions.test.tsx +95 -0
  58. package/bizar-dash/tests/eval/fixtures-extra.test.mjs +285 -0
  59. package/bizar-dash/tests/eval-web-ui.test.tsx +220 -0
  60. package/bizar-dash/tests/otel-spans.test.mjs +253 -0
  61. package/bizar-dash/tests/plugins-registry-fallback.test.mjs +259 -0
  62. package/bizar-dash/tests/voice-transcribe-worker.test.mjs +343 -0
  63. package/cli/bin.mjs +14 -0
  64. package/cli/commands/dash.mjs +19 -1
  65. package/cli/commands/service.mjs +7 -0
  66. package/cli/commands/tailscale.mjs +251 -0
  67. package/package.json +1 -1
  68. package/templates/eval-fixtures/citation.json +16 -0
  69. package/templates/eval-fixtures/code-review.json +16 -0
  70. package/templates/eval-fixtures/concise-output.json +16 -0
  71. package/templates/eval-fixtures/context-window.json +16 -0
  72. package/templates/eval-fixtures/error-recovery.json +16 -0
  73. package/templates/eval-fixtures/json-output.json +20 -0
  74. package/templates/eval-fixtures/multi-language.json +16 -0
  75. package/templates/eval-fixtures/safe-paths.json +16 -0
  76. package/templates/eval-fixtures/tool-call-multi-step.json +18 -0
  77. package/templates/eval-fixtures/unicode-handling.json +16 -0
  78. package/bizar-dash/dist/assets/icons-Bo0iH9EC.js.map +0 -1
  79. package/bizar-dash/dist/assets/index-DmpSFPJY.js +0 -9
  80. package/bizar-dash/dist/assets/index-DmpSFPJY.js.map +0 -1
  81. package/bizar-dash/dist/assets/main-C1cpttnv.js +0 -19
  82. package/bizar-dash/dist/assets/main-C1cpttnv.js.map +0 -1
  83. package/bizar-dash/dist/assets/markdown-DIquRulQ.js +0 -29
  84. package/bizar-dash/dist/assets/markdown-DIquRulQ.js.map +0 -1
  85. package/bizar-dash/dist/assets/mobile-BtxQJftK.js +0 -2
  86. package/bizar-dash/dist/assets/mobile-BtxQJftK.js.map +0 -1
  87. package/bizar-dash/dist/assets/mobile-DD-FZrTC.js +0 -1
  88. package/bizar-dash/dist/assets/react-vendor-DZRUXSPQ.js.map +0 -1
@@ -0,0 +1,161 @@
1
+ // src/web/components/TailscaleSettings.tsx
2
+ //
3
+ // v5.2 — Tailscale auth key integration UI.
4
+ //
5
+ // Allows users to authenticate with Tailscale using an auth key and
6
+ // set up tailscale serve for the dashboard without using the CLI.
7
+ import React, { useEffect, useState } from 'react';
8
+ import { CheckCircle, Link as LinkIcon, Plug } from 'lucide-react';
9
+ import { Card, CardTitle, CardMeta } from './Card';
10
+ import { Button } from './Button';
11
+ import { api } from '../lib/api';
12
+ import type { TailscaleStatus } from '../lib/types';
13
+
14
+ type Props = {
15
+ /** Existing tailscale status from Settings-level fetch. If provided, used as initial value. */
16
+ initialStatus?: TailscaleStatus | null;
17
+ };
18
+
19
+ export function TailscaleSettings({ initialStatus }: Props) {
20
+ const [status, setStatus] = useState<TailscaleStatus | null>(initialStatus ?? null);
21
+ const [authKey, setAuthKey] = useState('');
22
+ const [loading, setLoading] = useState(false);
23
+ const [error, setError] = useState<string | null>(null);
24
+
25
+ useEffect(() => {
26
+ if (status === null) {
27
+ api.get<TailscaleStatus>('/tailscale/status')
28
+ .then((r) => setStatus(r))
29
+ .catch(() => setStatus(null));
30
+ }
31
+ }, []);
32
+
33
+ const loadStatus = async () => {
34
+ try {
35
+ const r = await api.get<TailscaleStatus>('/tailscale/status');
36
+ setStatus(r);
37
+ } catch {
38
+ setError('Failed to load Tailscale status');
39
+ }
40
+ };
41
+
42
+ const handleAuthenticate = async () => {
43
+ if (!authKey.trim()) return;
44
+ setLoading(true);
45
+ setError(null);
46
+ try {
47
+ await api.post('/tailscale/setup', { authKey: authKey.trim() });
48
+ await loadStatus();
49
+ setAuthKey('');
50
+ } catch (err) {
51
+ setError((err as Error).message || 'Authentication failed');
52
+ } finally {
53
+ setLoading(false);
54
+ }
55
+ };
56
+
57
+ const handleSetupServe = async () => {
58
+ if (!status) return;
59
+ setLoading(true);
60
+ setError(null);
61
+ try {
62
+ await api.post('/tailscale/setup', {
63
+ port: status.settings?.port || 4321,
64
+ https: status.settings?.https !== false,
65
+ hostname: status.settings?.hostname || '',
66
+ });
67
+ await loadStatus();
68
+ } catch (err) {
69
+ setError((err as Error).message || 'Setup failed');
70
+ } finally {
71
+ setLoading(false);
72
+ }
73
+ };
74
+
75
+ const handleRemoveServe = async () => {
76
+ setLoading(true);
77
+ setError(null);
78
+ try {
79
+ await api.post('/tailscale/unserve');
80
+ await loadStatus();
81
+ } catch (err) {
82
+ setError((err as Error).message || 'Remove failed');
83
+ } finally {
84
+ setLoading(false);
85
+ }
86
+ };
87
+
88
+ const isInstalled = status?.installed ?? false;
89
+ const isAuthenticated = status?.authenticated ?? false;
90
+ const isServeEnabled = status?.settings?.enabled ?? false;
91
+ const serveUrl = isServeEnabled ? `https://${status?.hostname || 'bizar-dash'}` : null;
92
+
93
+ if (!isInstalled) {
94
+ return (
95
+ <Card id="settings-tailscale-auth" data-section="tailscale">
96
+ <CardTitle><Plug size={14} /> Tailscale Integration</CardTitle>
97
+ <CardMeta>Tailscale is not installed on this machine.</CardMeta>
98
+ <p className="muted">
99
+ Install from{' '}
100
+ <a href="https://tailscale.com/download" target="_blank" rel="noreferrer">
101
+ tailscale.com/download
102
+ </a>
103
+ </p>
104
+ </Card>
105
+ );
106
+ }
107
+
108
+ return (
109
+ <Card id="settings-tailscale-auth" data-section="tailscale">
110
+ <CardTitle><Plug size={14} /> Tailscale Integration</CardTitle>
111
+ <CardMeta>Expose the dashboard over your Tailscale network using an auth key.</CardMeta>
112
+
113
+ {error && (
114
+ <p style={{ color: 'var(--color-error, #f85149)', fontSize: '0.85rem' }}>{error}</p>
115
+ )}
116
+
117
+ {isAuthenticated ? (
118
+ <>
119
+ <div className="status-row" style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.75rem' }}>
120
+ <CheckCircle size={16} style={{ color: 'var(--color-success, #3fb950)' }} />
121
+ <span>Authenticated as <strong>{status?.hostname || 'unknown'}</strong></span>
122
+ </div>
123
+
124
+ {isServeEnabled && serveUrl ? (
125
+ <>
126
+ <div className="status-row" style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.75rem' }}>
127
+ <LinkIcon size={16} />
128
+ <a href={serveUrl} target="_blank" rel="noreferrer">{serveUrl}</a>
129
+ </div>
130
+ <Button variant="secondary" size="sm" onClick={handleRemoveServe} disabled={loading}>
131
+ Remove serve
132
+ </Button>
133
+ </>
134
+ ) : (
135
+ <Button variant="primary" size="sm" onClick={handleSetupServe} disabled={loading}>
136
+ Set up Tailscale serve
137
+ </Button>
138
+ )}
139
+ </>
140
+ ) : (
141
+ <>
142
+ <p className="muted" style={{ marginBottom: '0.75rem' }}>Not authenticated with Tailscale.</p>
143
+ <div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
144
+ <input
145
+ type="text"
146
+ className="input"
147
+ placeholder="tskey-..."
148
+ value={authKey}
149
+ onChange={(e) => setAuthKey(e.target.value)}
150
+ style={{ flex: 1, minWidth: '200px' }}
151
+ disabled={loading}
152
+ />
153
+ <Button variant="primary" size="sm" onClick={handleAuthenticate} disabled={loading || !authKey.trim()}>
154
+ Authenticate
155
+ </Button>
156
+ </div>
157
+ </>
158
+ )}
159
+ </Card>
160
+ );
161
+ }
@@ -0,0 +1,31 @@
1
+ // src/components/Toggle.tsx — toggle switch control.
2
+
3
+ import { type InputHTMLAttributes, forwardRef } from 'react';
4
+ import { cn } from '../lib/utils';
5
+
6
+ export type ToggleProps = InputHTMLAttributes<HTMLInputElement> & {
7
+ /** Called with the new checked value on change. */
8
+ onChange?: (checked: boolean) => void;
9
+ };
10
+
11
+ export const Toggle = forwardRef<HTMLInputElement, ToggleProps>(
12
+ function Toggle({ className, checked, onChange, onClick, ...rest }, ref) {
13
+ return (
14
+ <label className={cn('toggle', className)}>
15
+ <input
16
+ ref={ref}
17
+ type="checkbox"
18
+ role="switch"
19
+ aria-checked={checked}
20
+ checked={checked}
21
+ onChange={(e) => onChange?.(e.target.checked)}
22
+ onClick={(e) => {
23
+ onClick?.(e as unknown as React.MouseEvent<HTMLInputElement>);
24
+ }}
25
+ {...rest}
26
+ />
27
+ <span className="toggle-slider" aria-hidden />
28
+ </label>
29
+ );
30
+ },
31
+ );
@@ -24,6 +24,7 @@ import {
24
24
  Coins,
25
25
  Brain,
26
26
  Stethoscope,
27
+ ClipboardCheck,
27
28
  type LucideIcon,
28
29
  } from 'lucide-react';
29
30
  import { cn } from '../lib/utils';
@@ -51,8 +52,13 @@ export const TABS: TabDef[] = [
51
52
  { id: 'memory', label: 'Memory', icon: Brain },
52
53
  { id: 'mods', label: 'Mods', icon: Puzzle },
53
54
  { id: 'schedules', label: 'Schedules', icon: Clock },
55
+ { id: 'plugins', label: 'Plugins', icon: Puzzle },
54
56
  { id: 'history', label: 'History', icon: HistoryIcon },
55
57
  { id: 'minimax', label: 'Usage', icon: Coins },
58
+ // v5.2.0 — Eval framework UI. Lives next to Doctor (both are
59
+ // operator-quality surfaces) so the "did the last eval pass?"
60
+ // question is one click from the home screen.
61
+ { id: 'eval', label: 'Eval', icon: ClipboardCheck },
56
62
  // v6.0.0 — Doctor page. Lives between Overview and Settings so the
57
63
  // "is everything healthy?" question is always one click from the
58
64
  // home screen and from the settings surface (where an operator
@@ -0,0 +1,138 @@
1
+ // src/web/mobile/MobileChat.tsx — mobile chat UI without info/sessions sidebars.
2
+ // No OCR capture, no clipper, no sessions panel — pure chat thread.
3
+ import { useEffect, useRef, useState } from 'react';
4
+ import { ChatTopBar } from '../components/chat/ChatTopBar';
5
+ import { ChatThread } from '../components/chat/ChatThread';
6
+ import { FloatingComposer } from '../components/chat/FloatingComposer';
7
+ import { useChat } from '../components/chat/useChat';
8
+ import { useSlashCommands } from '../components/chat/useSlashCommands';
9
+ import { useToast } from '../components/Toast';
10
+ import type { Snapshot, Settings } from '../lib/types';
11
+
12
+ interface Props {
13
+ snapshot: Snapshot;
14
+ settings: Settings;
15
+ setActiveTab?: (id: string) => void;
16
+ initialTaskId?: string | null;
17
+ onClearTaskId?: () => void;
18
+ }
19
+
20
+ export function MobileChat({
21
+ snapshot,
22
+ settings,
23
+ setActiveTab,
24
+ initialTaskId,
25
+ onClearTaskId,
26
+ }: Props) {
27
+ const toast = useToast();
28
+ const chat = useChat(snapshot, settings, initialTaskId ?? '');
29
+ useEffect(() => {
30
+ chat.setToast({
31
+ error: (msg: string) => toast.error(msg),
32
+ success: (msg: string) => toast.success(msg),
33
+ info: (msg: string) => toast.info(msg),
34
+ warning: (msg: string) => toast.warning(msg),
35
+ });
36
+ }, [chat, toast]);
37
+
38
+ const [text, setText] = useState('');
39
+ const [agent, setAgent] = useState(settings.defaultAgent || 'odin');
40
+ const [model, setModel] = useState(settings.defaultModel || '');
41
+ const [attachments, setAttachments] = useState<string[]>([]);
42
+
43
+ const fileInputRef = useRef<HTMLInputElement>(null);
44
+
45
+ const { suggestions, setQuery } = useSlashCommands(snapshot);
46
+ useEffect(() => {
47
+ setQuery(text);
48
+ }, [text, setQuery]);
49
+
50
+ const onAttach = () => fileInputRef.current?.click();
51
+ const onFiles = (e: React.ChangeEvent<HTMLInputElement>) => {
52
+ const files = e.target.files;
53
+ if (!files) return;
54
+ const next: string[] = [];
55
+ for (let i = 0; i < files.length; i++) next.push(files[i].name);
56
+ setAttachments((cur) => {
57
+ const newNames = next.filter((n) => !cur.includes(n));
58
+ return [...cur, ...newNames];
59
+ });
60
+ if (fileInputRef.current) fileInputRef.current.value = '';
61
+ };
62
+
63
+ const handleSend = async () => {
64
+ const msg = text.trim();
65
+ if (!msg) return;
66
+ setText('');
67
+ setQuery('');
68
+ const result = await chat.onSend(msg, agent, model, attachments);
69
+ if (result.ok) chat.jumpToLatest();
70
+ };
71
+
72
+ return (
73
+ <div className="chat-shell">
74
+ <ChatTopBar
75
+ activeProject={snapshot.activeProject}
76
+ sessionCount={chat.sessions.length}
77
+ sessionsOpen={false}
78
+ infoOpen={false}
79
+ onToggleSessions={() => undefined}
80
+ onToggleInfo={() => undefined}
81
+ onOpenOverview={() => setActiveTab?.('overview')}
82
+ />
83
+ <div className="chat-body">
84
+ <main className="chat-main">
85
+ <ChatThread
86
+ messages={
87
+ chat.activeSource === 'opencode'
88
+ ? chat.opencodeMessages
89
+ : chat.bizarMessages
90
+ }
91
+ loading={chat.loading}
92
+ activeProject={snapshot.activeProject}
93
+ sessionId={
94
+ chat.activeSource === 'opencode'
95
+ ? chat.activeOpencodeSessionId ?? chat.sessionId
96
+ : chat.sessionId
97
+ }
98
+ pinned={chat.pinned}
99
+ activeSource={chat.activeSource}
100
+ onPickSuggestion={(t) => setText(t)}
101
+ onCopy={(m) =>
102
+ chat.copyMessage(m as Parameters<typeof chat.copyMessage>[0])
103
+ }
104
+ onDelete={() => undefined}
105
+ onTogglePin={chat.togglePin}
106
+ onRegenerate={chat.onRegenerate}
107
+ />
108
+ <FloatingComposer
109
+ agent={agent}
110
+ setAgent={setAgent}
111
+ model={model}
112
+ setModel={setModel}
113
+ text={text}
114
+ setText={setText}
115
+ sending={chat.sending}
116
+ activeSource={chat.activeSource}
117
+ onSend={handleSend}
118
+ attachments={attachments}
119
+ setAttachments={setAttachments}
120
+ suggestions={suggestions}
121
+ onPickSuggestion={(cmd) => setText(`${cmd.split(' ')[0]} `)}
122
+ agents={snapshot.agents || []}
123
+ onAttach={onAttach}
124
+ sessionsOpen={false}
125
+ infoOpen={false}
126
+ />
127
+ <input
128
+ ref={fileInputRef}
129
+ type="file"
130
+ multiple
131
+ style={{ display: 'none' }}
132
+ onChange={onFiles}
133
+ />
134
+ </main>
135
+ </div>
136
+ </div>
137
+ );
138
+ }
@@ -0,0 +1,196 @@
1
+ // src/web/mobile/MobileSettings.tsx — minimal mobile settings (no companion app, no agent config).
2
+ // Drops: Companion App (qrcode.react chunk), Tailscale Serve, Agent Behavior, Notifications toggles.
3
+ import { useEffect, useState } from 'react';
4
+ import { Sun, Moon, Monitor } from 'lucide-react';
5
+ import { api } from '../lib/api';
6
+ import { applyTheme, applyThemeTokens, type Settings, type Snapshot, type ThemeName } from '../lib/types';
7
+
8
+ type Props = {
9
+ settings: Settings;
10
+ snapshot: Snapshot | null;
11
+ onRefresh: () => Promise<void>;
12
+ };
13
+
14
+ const THEMES: { id: ThemeName; label: string; Icon: typeof Sun }[] = [
15
+ { id: 'dark', label: 'Dark', Icon: Moon },
16
+ { id: 'light', label: 'Light', Icon: Sun },
17
+ { id: 'system', label: 'System', Icon: Monitor },
18
+ ];
19
+
20
+ const PRESET_ACCENTS = [
21
+ { name: 'Purple', accent: '#8b5cf6' },
22
+ { name: 'Blue', accent: '#3b82f6' },
23
+ { name: 'Green', accent: '#10b981' },
24
+ { name: 'Orange', accent: '#f97316' },
25
+ { name: 'Red', accent: '#ef4444' },
26
+ { name: 'Pink', accent: '#ec4899' },
27
+ { name: 'Cyan', accent: '#06b6d4' },
28
+ { name: 'Mono', accent: '#6b7280' },
29
+ ];
30
+
31
+ export function MobileSettings({ settings: initial, snapshot, onRefresh }: Props) {
32
+ const [settings, setSettings] = useState<Settings>(initial);
33
+ const [dirty, setDirty] = useState(false);
34
+ const [saving, setSaving] = useState(false);
35
+
36
+ useEffect(() => {
37
+ setSettings(initial);
38
+ setDirty(false);
39
+ if (initial.theme) applyThemeTokens(initial.theme);
40
+ }, [initial]);
41
+
42
+ const patchTheme = (patch: Partial<Settings['theme']>) => {
43
+ setSettings((cur) => {
44
+ const next = { ...cur, theme: { ...cur.theme, ...patch } };
45
+ applyTheme(next.theme);
46
+ applyThemeTokens(next.theme);
47
+ return next;
48
+ });
49
+ setDirty(true);
50
+ };
51
+
52
+ const onSave = async () => {
53
+ setSaving(true);
54
+ try {
55
+ const r = await api.put<{ data: Settings }>('/settings', settings);
56
+ setSettings(r.data);
57
+ setDirty(false);
58
+ applyTheme(r.data.theme);
59
+ applyThemeTokens(r.data.theme);
60
+ onRefresh();
61
+ } catch {
62
+ // best-effort
63
+ } finally {
64
+ setSaving(false);
65
+ }
66
+ };
67
+
68
+ return (
69
+ <div className="mobile-view">
70
+ {dirty && (
71
+ <div className="mobile-settings-save-bar">
72
+ <span>Unsaved changes</span>
73
+ <button type="button" className="mobile-btn" disabled={saving} onClick={onSave}>
74
+ {saving ? 'Saving…' : 'Save'}
75
+ </button>
76
+ </div>
77
+ )}
78
+
79
+ {/* Theme */}
80
+ <section className="mobile-section">
81
+ <h3 className="mobile-section-title">Appearance</h3>
82
+ <div className="mobile-card">
83
+ <div className="mobile-setting-row">
84
+ <span className="mobile-setting-label">Theme</span>
85
+ <div className="mobile-theme-btns">
86
+ {THEMES.map(({ id, label, Icon }) => (
87
+ <button
88
+ key={id}
89
+ type="button"
90
+ className={`mobile-theme-btn ${settings.theme.mode === id ? 'active' : ''}`}
91
+ onClick={() => patchTheme({ mode: id })}
92
+ >
93
+ <Icon size={14} /> {label}
94
+ </button>
95
+ ))}
96
+ </div>
97
+ </div>
98
+
99
+ <div className="mobile-setting-row" style={{ flexDirection: 'column', alignItems: 'flex-start', gap: 8 }}>
100
+ <span className="mobile-setting-label">Accent color</span>
101
+ <div className="mobile-accent-swatches">
102
+ {PRESET_ACCENTS.map((p) => (
103
+ <button
104
+ key={p.name}
105
+ type="button"
106
+ className={`mobile-accent-swatch ${settings.theme.accent === p.accent ? 'active' : ''}`}
107
+ style={{ background: p.accent }}
108
+ onClick={() => patchTheme({ accent: p.accent })}
109
+ title={p.name}
110
+ />
111
+ ))}
112
+ </div>
113
+ </div>
114
+
115
+ <div className="mobile-setting-row" style={{ flexDirection: 'column', alignItems: 'flex-start', gap: 8 }}>
116
+ <span className="mobile-setting-label">Font size: {settings.theme.fontSize}px</span>
117
+ <input
118
+ type="range"
119
+ min={12}
120
+ max={20}
121
+ value={settings.theme.fontSize}
122
+ onChange={(e) => patchTheme({ fontSize: Number(e.target.value) })}
123
+ style={{ width: '100%' }}
124
+ />
125
+ </div>
126
+
127
+ <div className="mobile-setting-row">
128
+ <span className="mobile-setting-label">Compact mode</span>
129
+ <label className="mobile-toggle">
130
+ <input
131
+ type="checkbox"
132
+ checked={settings.theme.compactMode}
133
+ onChange={(e) => patchTheme({ compactMode: e.target.checked })}
134
+ />
135
+ <span className="mobile-toggle-slider" />
136
+ </label>
137
+ </div>
138
+ <div className="mobile-setting-row">
139
+ <span className="mobile-setting-label">Animations</span>
140
+ <label className="mobile-toggle">
141
+ <input
142
+ type="checkbox"
143
+ checked={settings.theme.animations}
144
+ onChange={(e) => patchTheme({ animations: e.target.checked })}
145
+ />
146
+ <span className="mobile-toggle-slider" />
147
+ </label>
148
+ </div>
149
+ </div>
150
+ </section>
151
+
152
+ {/* Chat defaults */}
153
+ <section className="mobile-section">
154
+ <h3 className="mobile-section-title">Chat Defaults</h3>
155
+ <div className="mobile-card">
156
+ <label className="mobile-field-label">Default Agent</label>
157
+ <select
158
+ className="mobile-input"
159
+ value={settings.defaultAgent || 'odin'}
160
+ onChange={(e) => setSettings((cur) => ({ ...cur, defaultAgent: e.target.value }))}
161
+ >
162
+ {(snapshot?.agents || []).map((a) => (
163
+ <option key={a.name} value={a.name}>@{a.name}</option>
164
+ ))}
165
+ </select>
166
+ </div>
167
+ </section>
168
+
169
+ {/* About */}
170
+ <section className="mobile-section">
171
+ <h3 className="mobile-section-title">About</h3>
172
+ <div className="mobile-card">
173
+ <div className="mobile-setting-row">
174
+ <span className="mobile-setting-label">Version</span>
175
+ <span className="mobile-setting-value mono">{settings.about?.version || '—'}</span>
176
+ </div>
177
+ <div className="mobile-setting-row">
178
+ <span className="mobile-setting-label">Agents</span>
179
+ <span className="mobile-setting-value">{snapshot?.agents?.length || 0}</span>
180
+ </div>
181
+ <div className="mobile-setting-row">
182
+ <span className="mobile-setting-label">Tasks</span>
183
+ <span className="mobile-setting-value">{snapshot?.tasks?.length || 0}</span>
184
+ </div>
185
+ </div>
186
+ </section>
187
+
188
+ {/* Switch to desktop */}
189
+ <div className="mobile-view-footer">
190
+ <a href="/?desktop=1" className="mobile-btn mobile-btn-secondary">
191
+ Switch to Desktop
192
+ </a>
193
+ </div>
194
+ </div>
195
+ );
196
+ }