@polderlabs/bizar 4.9.0 → 5.0.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.
Files changed (183) hide show
  1. package/bizar-dash/dist/assets/icons-Bo0iH9EC.js +701 -0
  2. package/bizar-dash/dist/assets/icons-Bo0iH9EC.js.map +1 -0
  3. package/bizar-dash/dist/assets/{index-DU61awG3.js → index-DmpSFPJY.js} +1 -1
  4. package/bizar-dash/dist/assets/{index-DU61awG3.js.map → index-DmpSFPJY.js.map} +1 -1
  5. package/bizar-dash/dist/assets/main-C1cpttnv.js +19 -0
  6. package/bizar-dash/dist/assets/main-C1cpttnv.js.map +1 -0
  7. package/bizar-dash/dist/assets/main-DTkNlLrw.css +1 -0
  8. package/bizar-dash/dist/assets/markdown-DIquRulQ.js +29 -0
  9. package/bizar-dash/dist/assets/markdown-DIquRulQ.js.map +1 -0
  10. package/bizar-dash/dist/assets/mobile-BtxQJftK.js +2 -0
  11. package/bizar-dash/dist/assets/mobile-BtxQJftK.js.map +1 -0
  12. package/bizar-dash/dist/assets/mobile-DD-FZrTC.js +1 -0
  13. package/bizar-dash/dist/assets/{mobile-CL5uUQEC.js.map → mobile-DD-FZrTC.js.map} +1 -1
  14. package/bizar-dash/dist/assets/react-vendor-DZRUXSPQ.js +40 -0
  15. package/bizar-dash/dist/assets/react-vendor-DZRUXSPQ.js.map +1 -0
  16. package/bizar-dash/dist/index.html +6 -3
  17. package/bizar-dash/dist/mobile.html +5 -2
  18. package/bizar-dash/node_modules/.package-lock.json +6 -0
  19. package/bizar-dash/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
  20. package/bizar-dash/package-lock.json +6 -0
  21. package/bizar-dash/skills/eval/SKILL.md +237 -0
  22. package/bizar-dash/src/server/api.mjs +35 -0
  23. package/bizar-dash/src/server/auth.mjs +155 -1
  24. package/bizar-dash/src/server/diagnostics-store.mjs +452 -2
  25. package/bizar-dash/src/server/eval-store.mjs +226 -0
  26. package/bizar-dash/src/server/eval.mjs +347 -0
  27. package/bizar-dash/src/server/memory-store.mjs +46 -0
  28. package/bizar-dash/src/server/ocr.mjs +55 -0
  29. package/bizar-dash/src/server/plugins/registry.mjs +363 -0
  30. package/bizar-dash/src/server/plugins/sandbox.mjs +655 -0
  31. package/bizar-dash/src/server/plugins/store.mjs +659 -0
  32. package/bizar-dash/src/server/providers-store.mjs +11 -4
  33. package/bizar-dash/src/server/routes/_shared.mjs +2 -2
  34. package/bizar-dash/src/server/routes/clipboard.mjs +173 -0
  35. package/bizar-dash/src/server/routes/doctor.mjs +71 -0
  36. package/bizar-dash/src/server/routes/env-vars.mjs +67 -1
  37. package/bizar-dash/src/server/routes/eval.mjs +147 -0
  38. package/bizar-dash/src/server/routes/memory.mjs +3 -0
  39. package/bizar-dash/src/server/routes/ocr.mjs +182 -0
  40. package/bizar-dash/src/server/routes/opencode-session-detail.mjs +61 -42
  41. package/bizar-dash/src/server/routes/plugins.mjs +220 -0
  42. package/bizar-dash/src/server/routes/schedules.mjs +55 -0
  43. package/bizar-dash/src/server/routes/users.mjs +84 -0
  44. package/bizar-dash/src/server/routes/voice.mjs +131 -0
  45. package/bizar-dash/src/server/routes/workspaces.mjs +204 -0
  46. package/bizar-dash/src/server/serve-info.mjs +172 -0
  47. package/bizar-dash/src/server/voice-store.mjs +202 -0
  48. package/bizar-dash/src/server/voice-transcribe.mjs +72 -0
  49. package/bizar-dash/src/server/workspaces.mjs +626 -0
  50. package/bizar-dash/src/web/App.tsx +48 -11
  51. package/bizar-dash/src/web/components/AutosaveField.tsx +50 -0
  52. package/bizar-dash/src/web/components/DoctorPanel.tsx +160 -0
  53. package/bizar-dash/src/web/components/EnvVarManager.tsx +245 -62
  54. package/bizar-dash/src/web/components/InviteDialog.tsx +205 -0
  55. package/bizar-dash/src/web/components/ScheduleTemplateCard.tsx +71 -0
  56. package/bizar-dash/src/web/components/ScreenshotCapture.tsx +138 -0
  57. package/bizar-dash/src/web/components/ScreenshotOCR.tsx +42 -0
  58. package/bizar-dash/src/web/components/SettingsNav.tsx +101 -0
  59. package/bizar-dash/src/web/components/Sidebar.tsx +25 -1
  60. package/bizar-dash/src/web/components/StatusBadge.tsx +33 -2
  61. package/bizar-dash/src/web/components/Topbar.tsx +11 -0
  62. package/bizar-dash/src/web/components/VoiceNotesPanel.tsx +182 -0
  63. package/bizar-dash/src/web/components/VoiceRecorder.tsx +104 -0
  64. package/bizar-dash/src/web/components/WorkspaceSelector.tsx +158 -0
  65. package/bizar-dash/src/web/components/chat/ChatInfoPanel.tsx +64 -0
  66. package/bizar-dash/src/web/components/chat/Composer.tsx +1 -1
  67. package/bizar-dash/src/web/components/chat/useChat.ts +118 -1
  68. package/bizar-dash/src/web/hooks/useAutosave.ts +107 -0
  69. package/bizar-dash/src/web/lib/types.ts +134 -0
  70. package/bizar-dash/src/web/styles/chat.css +8 -5
  71. package/bizar-dash/src/web/styles/main.css +77 -2
  72. package/bizar-dash/src/web/styles/memory.css +82 -0
  73. package/bizar-dash/src/web/styles/settings.css +265 -0
  74. package/bizar-dash/src/web/views/Chat.tsx +15 -1
  75. package/bizar-dash/src/web/views/Doctor.tsx +317 -0
  76. package/bizar-dash/src/web/views/Memory.tsx +17 -2
  77. package/bizar-dash/src/web/views/MiniMaxUsage.tsx +1 -0
  78. package/bizar-dash/src/web/views/Schedules.tsx +94 -12
  79. package/bizar-dash/src/web/views/Settings.tsx +78 -45
  80. package/bizar-dash/src/web/views/Workspace.tsx +294 -0
  81. package/bizar-dash/src/web/views/memory/ConfigPanel.tsx +114 -133
  82. package/bizar-dash/src/web/views/memory/FromScreenshotPanel.tsx +23 -0
  83. package/bizar-dash/src/web/views/memory/VaultFromClipboardPanel.tsx +101 -0
  84. package/bizar-dash/src/web/views/settings/AgentSection.tsx +23 -12
  85. package/bizar-dash/src/web/views/settings/EnvVarsSection.tsx +5 -6
  86. package/bizar-dash/src/web/views/settings/GeneralSection.tsx +38 -15
  87. package/bizar-dash/src/web/views/settings/MemorySection.tsx +92 -8
  88. package/bizar-dash/src/web/views/settings/WorkspacesSection.tsx +189 -0
  89. package/bizar-dash/tests/autosave.test.tsx +276 -0
  90. package/bizar-dash/tests/bundle-analysis.test.mjs +5 -2
  91. package/bizar-dash/tests/chat-composer.test.tsx +140 -0
  92. package/bizar-dash/tests/clipboard.test.mjs +147 -0
  93. package/bizar-dash/tests/components/doctor-panel.test.tsx +105 -0
  94. package/bizar-dash/tests/components/screenshot-ocr.test.tsx +75 -0
  95. package/bizar-dash/tests/components/workspace-selector.test.tsx +73 -0
  96. package/bizar-dash/tests/deploy-templates.test.mjs +100 -0
  97. package/bizar-dash/tests/diagnostics-store.test.mjs +206 -0
  98. package/bizar-dash/tests/env-vars-extended.test.mjs +190 -0
  99. package/bizar-dash/tests/eval/fixtures.test.mjs +141 -0
  100. package/bizar-dash/tests/eval/report.test.mjs +284 -0
  101. package/bizar-dash/tests/eval/runner.test.mjs +471 -0
  102. package/bizar-dash/tests/layout-spacing.test.mjs +101 -0
  103. package/bizar-dash/tests/memory-default-vault.test.mjs +98 -0
  104. package/bizar-dash/tests/minimax-bar.test.tsx +113 -0
  105. package/bizar-dash/tests/minimax-models.test.mjs +123 -0
  106. package/bizar-dash/tests/ocr.test.mjs +87 -0
  107. package/bizar-dash/tests/opencode-sessions-detail.test.mjs +158 -0
  108. package/bizar-dash/tests/plugins-registry.test.mjs +387 -0
  109. package/bizar-dash/tests/plugins-sandbox.test.mjs +374 -0
  110. package/bizar-dash/tests/plugins-store.test.mjs +455 -0
  111. package/bizar-dash/tests/routes-doctor.test.mjs +159 -0
  112. package/bizar-dash/tests/schedules-templates.test.mjs +144 -0
  113. package/bizar-dash/tests/settings-layout.test.tsx +129 -0
  114. package/bizar-dash/tests/settings-mode-wiring.test.tsx +151 -0
  115. package/bizar-dash/tests/settings-nav.test.tsx +126 -0
  116. package/bizar-dash/tests/users.test.mjs +108 -0
  117. package/bizar-dash/tests/voice-recorder.test.tsx +95 -0
  118. package/bizar-dash/tests/voice-store.test.mjs +148 -0
  119. package/bizar-dash/tests/voice-transcribe.test.mjs +87 -0
  120. package/bizar-dash/tests/workspaces.test.mjs +527 -0
  121. package/cli/bin.mjs +72 -2
  122. package/cli/commands/clip.mjs +146 -0
  123. package/cli/commands/deploy/cloudflare.mjs +250 -0
  124. package/cli/commands/deploy/docker.mjs +221 -0
  125. package/cli/commands/deploy/fly.mjs +161 -0
  126. package/cli/commands/deploy/vercel.mjs +225 -0
  127. package/cli/commands/deploy.mjs +240 -0
  128. package/cli/commands/eval.mjs +378 -0
  129. package/cli/commands/marketplace.mjs +64 -0
  130. package/cli/commands/minimax.mjs +5 -2
  131. package/cli/commands/ocr.mjs +165 -0
  132. package/cli/commands/plugin.mjs +358 -0
  133. package/cli/commands/voice.mjs +211 -0
  134. package/cli/commands/workspace.mjs +247 -0
  135. package/cli/tests/minimax-cli.test.mjs +79 -0
  136. package/config/agents/frigg.md +1 -1
  137. package/config/agents/heimdall.md +1 -1
  138. package/config/agents/mimir.md +1 -1
  139. package/config/agents/quick.md +1 -1
  140. package/config/agents/semble-search.md +1 -1
  141. package/config/agents/vor.md +1 -1
  142. package/config/opencode.json +28 -21
  143. package/config/opencode.json.template +8 -1
  144. package/package.json +12 -8
  145. package/plugins/bizar/index.ts +77 -0
  146. package/plugins/bizar/src/compaction.d.mts +48 -0
  147. package/plugins/bizar/src/compaction.mjs +192 -0
  148. package/plugins/bizar/tests/compaction.test.ts +264 -0
  149. package/templates/deploy/cloudflare/README.md +32 -0
  150. package/templates/deploy/cloudflare/functions-index.template.js +15 -0
  151. package/templates/deploy/cloudflare/wrangler.toml.template +9 -0
  152. package/templates/deploy/docker/.env.template +16 -0
  153. package/templates/deploy/docker/README.md +58 -0
  154. package/templates/deploy/docker/docker-compose.template.yml +23 -0
  155. package/templates/deploy/fly/README.md +35 -0
  156. package/templates/deploy/fly/fly.toml.template +28 -0
  157. package/templates/deploy/vercel/README.md +29 -0
  158. package/templates/deploy/vercel/api-index.template.js +18 -0
  159. package/templates/deploy/vercel/vercel.json.template +16 -0
  160. package/templates/eval-fixtures/README.md +58 -0
  161. package/templates/eval-fixtures/code-search-basic.json +28 -0
  162. package/templates/eval-fixtures/latency-bounds.json +16 -0
  163. package/templates/eval-fixtures/regression-suite.json +79 -0
  164. package/templates/eval-fixtures/response-format.json +30 -0
  165. package/templates/eval-fixtures/tool-call-correctness.json +24 -0
  166. package/templates/plugin-template/README.md +121 -0
  167. package/templates/plugin-template/index.js +66 -0
  168. package/templates/plugin-template/plugin.json +42 -0
  169. package/templates/plugin-template/tests/plugin.test.js +83 -0
  170. package/templates/schedules/daily-backup.json +12 -0
  171. package/templates/schedules/daily-cleanup.json +12 -0
  172. package/templates/schedules/hourly-health-check.json +12 -0
  173. package/templates/schedules/webhook-on-push.json +13 -0
  174. package/templates/schedules/weekly-digest.json +13 -0
  175. package/bizar-dash/dist/assets/main-DaC1Lc6q.js +0 -366
  176. package/bizar-dash/dist/assets/main-DaC1Lc6q.js.map +0 -1
  177. package/bizar-dash/dist/assets/main-DfmIfOUS.css +0 -1
  178. package/bizar-dash/dist/assets/mobile-CL5uUQEC.js +0 -1
  179. package/bizar-dash/dist/assets/mobile-D5WTWvuh.js +0 -338
  180. package/bizar-dash/dist/assets/mobile-D5WTWvuh.js.map +0 -1
  181. package/bizar-dash/src/web/views/settings/BackupSection.tsx +0 -16
  182. package/bizar-dash/src/web/views/settings/ProvidersSection.tsx +0 -16
  183. package/bizar-dash/src/web/views/settings/SkillsSection.tsx +0 -16
@@ -1,5 +1,5 @@
1
1
  // src/views/Settings.tsx — v4 settings shell: routes to focused sub-components.
2
- import React, { useEffect, useState } from 'react';
2
+ import React, { useEffect, useState, useRef, useCallback } from 'react';
3
3
  import { Sliders, Save, RefreshCw, RotateCcw } from 'lucide-react';
4
4
  import { Button } from '../components/Button';
5
5
  import { useToast } from '../components/Toast';
@@ -18,11 +18,8 @@ import { AgentSection } from './settings/AgentSection';
18
18
  import { SystemLlmSection } from './settings/SystemLlmSection';
19
19
  import { HeadroomSection } from './settings/HeadroomSection';
20
20
  import { ActivitySection } from './settings/ActivitySection';
21
+ import { WorkspacesSection } from './settings/WorkspacesSection';
21
22
  import { EnvVarsSection } from './settings/EnvVarsSection';
22
- import { ProvidersSection } from './settings/ProvidersSection';
23
- import { MemorySection } from './settings/MemorySection';
24
- import { SkillsSection } from './settings/SkillsSection';
25
- import { BackupSection } from './settings/BackupSection';
26
23
 
27
24
  type Props = {
28
25
  snapshot: Snapshot;
@@ -30,6 +27,12 @@ type Props = {
30
27
  activeTab: string;
31
28
  setActiveTab: (id: string) => void;
32
29
  refreshSnapshot: () => Promise<void>;
30
+ /** v4.9.0 — When true, navigation uses the sidebar instead of the subnav bar. */
31
+ settingsMode?: boolean;
32
+ /** v4.9.0 — The active section. Null means "show all". Used when settingsMode is true. */
33
+ settingsActiveSection?: string | null;
34
+ /** v4.9.0 — Called when the user selects a section via the sidebar. */
35
+ setSettingsActiveSection?: (id: string | null) => void;
33
36
  };
34
37
 
35
38
  const SECTION_LINKS = [
@@ -37,6 +40,7 @@ const SECTION_LINKS = [
37
40
  { id: 'updates', label: 'Updates' },
38
41
  { id: 'layout', label: 'Layout' },
39
42
  { id: 'general', label: 'General' },
43
+ { id: 'env-vars', label: 'Env Vars' },
40
44
  { id: 'network', label: 'Network' },
41
45
  { id: 'notifications', label: 'Notifications' },
42
46
  { id: 'auth', label: 'Auth' },
@@ -47,11 +51,7 @@ const SECTION_LINKS = [
47
51
  { id: 'headroom', label: 'Headroom' },
48
52
  { id: 'activity-log', label: 'Activity' },
49
53
  { id: 'about', label: 'About' },
50
- { id: 'env-vars', label: 'Env Vars' },
51
- { id: 'providers', label: 'Providers' },
52
- { id: 'memory', label: 'Memory' },
53
- { id: 'skills', label: 'Skills' },
54
- { id: 'backup', label: 'Backup' },
54
+ { id: 'workspaces', label: 'Workspaces' },
55
55
  ] as const;
56
56
 
57
57
  /* ─── Settings search sections metadata ─── */
@@ -81,6 +81,9 @@ const SETTINGS_SECTIONS: SettingsSection[] = [
81
81
  { key: 'defaultAgent', label: 'Default agent', section: 'general' },
82
82
  { key: 'defaultModel', label: 'Model override', section: 'general' },
83
83
  ] },
84
+ { id: 'env-vars', label: 'Environment Variables', fields: [
85
+ { key: 'env-vars.count', label: 'Env var count', section: 'env-vars' },
86
+ ] },
84
87
  { id: 'network', label: 'Network', fields: [
85
88
  { key: 'tailscale.enabled', label: 'Tailscale Serve', section: 'network' },
86
89
  { key: 'tailscale.port', label: 'Tailscale port', section: 'network' },
@@ -129,25 +132,9 @@ const SETTINGS_SECTIONS: SettingsSection[] = [
129
132
  { key: 'about.homepage', label: 'Homepage', section: 'about' },
130
133
  { key: 'about.license', label: 'License', section: 'about' },
131
134
  ] },
132
- { id: 'env-vars', label: 'Env Vars', fields: [
133
- { key: 'env.add', label: 'Add variable', section: 'env-vars' },
134
- ] },
135
- { id: 'providers', label: 'Providers', fields: [
136
- { key: 'providers.list', label: 'Provider list', section: 'providers' },
137
- ] },
138
- { id: 'memory', label: 'Memory', fields: [
139
- { key: 'memory.config', label: 'Memory config', section: 'memory' },
140
- ] },
141
- { id: 'skills', label: 'Skills', fields: [
142
- { key: 'skills.paths', label: 'Skill paths', section: 'skills' },
143
- ] },
144
- { id: 'backup', label: 'Backup', fields: [
145
- { key: 'backup.create', label: 'Create backup', section: 'backup' },
146
- { key: 'backup.restore', label: 'Restore backup', section: 'backup' },
147
- ] },
148
135
  ];
149
136
 
150
- function SettingsViewInner({ settings: initial, refreshSnapshot }: Props) {
137
+ function SettingsViewInner({ settings: initial, refreshSnapshot, settingsMode, settingsActiveSection, setSettingsActiveSection, setActiveTab }: Props) {
151
138
  const toast = useToast();
152
139
  const [settings, setSettings] = useState<Settings>(initial);
153
140
  const [dirty, setDirty] = useState(false);
@@ -156,10 +143,31 @@ function SettingsViewInner({ settings: initial, refreshSnapshot }: Props) {
156
143
  const [tailscaleDraft, setTailscaleDraft] = useState({ port: 4321, https: true, hostname: '' });
157
144
  const [authStatus, setAuthStatus] = useState<{ required: boolean; loopback: boolean; peer: string } | null>(null);
158
145
 
146
+ // v4.9.0 — Exit settings mode by switching back to overview tab.
147
+ const handleExitSettings = () => { setActiveTab('overview'); };
148
+
159
149
  useEffect(() => { setSettings(initial); setDirty(false); if (initial.theme) applyThemeTokens(initial.theme); }, [initial]);
160
150
  useEffect(() => { if (!tailscale) return; setTailscaleDraft({ port: tailscale.settings.port, https: tailscale.settings.https !== false, hostname: tailscale.settings.hostname || '' }); }, [tailscale]);
161
151
  useEffect(() => { api.get<TailscaleStatus>('/tailscale/status').then(setTailscale).catch(() => undefined); }, []);
162
152
 
153
+ const autoSaveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
154
+
155
+ const autoSave = useCallback(async (key: keyof Settings, value: Settings[keyof Settings]) => {
156
+ setSettings((cur) => ({ ...cur, [key]: value }));
157
+ if (autoSaveTimeoutRef.current) clearTimeout(autoSaveTimeoutRef.current);
158
+ autoSaveTimeoutRef.current = setTimeout(async () => {
159
+ try {
160
+ await api.put('/settings', { ...settings, [key]: value });
161
+ } catch (err) {
162
+ console.error('autoSave failed', err);
163
+ }
164
+ }, 300);
165
+ }, [settings]);
166
+
167
+ useEffect(() => () => {
168
+ if (autoSaveTimeoutRef.current) clearTimeout(autoSaveTimeoutRef.current);
169
+ }, []);
170
+
163
171
  const patchTheme = (patch: Partial<Settings['theme']>) => { setSettings((cur) => { const next = { ...cur, theme: { ...cur.theme, ...patch } }; applyThemeTokens(next.theme); return next; }); setDirty(true); };
164
172
  const patchUi = (patch: Partial<Settings['ui']>) => { setSettings((cur) => ({ ...cur, ui: { ...cur.ui, ...patch } })); setDirty(true); };
165
173
  const patchTop = <K extends keyof Settings>(key: K, value: Settings[K]) => { setSettings((cur) => ({ ...cur, [key]: value })); setDirty(true); };
@@ -167,19 +175,36 @@ function SettingsViewInner({ settings: initial, refreshSnapshot }: Props) {
167
175
  const patchAgents = (patch: Partial<Settings['agents']>) => { setSettings((cur) => ({ ...cur, agents: { ...cur.agents, ...patch } })); setDirty(true); };
168
176
  const patchDashboard = (patch: Partial<Settings['dashboard']>) => { setSettings((cur) => ({ ...cur, dashboard: { ...cur.dashboard, ...patch } })); setDirty(true); };
169
177
 
170
- const [activeSection, setActiveSection] = useState<string | null>(() => {
178
+ // v4.9.0 — In settingsMode, section navigation is driven by the sidebar via
179
+ // settingsActiveSection (shared state at App level). Outside settingsMode,
180
+ // fall back to local hash-based state for backwards compatibility.
181
+ const [localActiveSection, setLocalActiveSection] = useState<string | null>(() => {
171
182
  if (typeof window === 'undefined') return null;
172
183
  const hash = window.location.hash.replace(/^#settings-/, '');
173
184
  return SECTION_LINKS.some((s) => s.id === hash) ? hash : null;
174
185
  });
175
186
 
176
- const onJumpSection = (id: string | null) => {
177
- setActiveSection(id);
178
- try {
179
- history.replaceState(null, '', id ? `#settings-${id}` : window.location.pathname);
180
- if (id) { const el = document.getElementById(`settings-${id}`); if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' }); }
181
- else window.scrollTo({ top: 0, behavior: 'smooth' });
182
- } catch { /* ignore */ }
187
+ // The effective active section: use sidebar state in settingsMode, local state otherwise.
188
+ const activeSection = settingsMode ? (settingsActiveSection ?? null) : localActiveSection;
189
+
190
+ // v4.9.0 — In settingsMode, update the shared App-level state. Otherwise update local state.
191
+ const handleJumpSection = (id: string | null) => {
192
+ if (settingsMode) {
193
+ setSettingsActiveSection?.(id);
194
+ } else {
195
+ setLocalActiveSection(id);
196
+ try {
197
+ history.replaceState(null, '', id ? `#settings-${id}` : window.location.pathname);
198
+ if (id) { const el = document.getElementById(`settings-${id}`); if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' }); }
199
+ else window.scrollTo({ top: 0, behavior: 'smooth' });
200
+ } catch { /* ignore */ }
201
+ }
202
+ if (id) {
203
+ const el = document.getElementById(`settings-${id}`);
204
+ if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
205
+ } else {
206
+ window.scrollTo({ top: 0, behavior: 'smooth' });
207
+ }
183
208
  };
184
209
 
185
210
  const onTailscaleToggle = async () => {
@@ -220,6 +245,7 @@ function SettingsViewInner({ settings: initial, refreshSnapshot }: Props) {
220
245
  const sp = { settings, patchTheme, patchUi, patchTop, patchNotifications, patchAgents, patchDashboard };
221
246
  const about = settings.about || { version: '3.0.4', homepage: 'https://github.com/DrB0rk/BizarHarness', license: 'MIT' };
222
247
 
248
+ // v4.9.0 — showAll means no section is filtered (activeSection is null).
223
249
  const showAll = activeSection === null;
224
250
  const sectionOf = (id: string) => activeSection === id;
225
251
 
@@ -239,31 +265,38 @@ function SettingsViewInner({ settings: initial, refreshSnapshot }: Props) {
239
265
  </div>
240
266
  </header>
241
267
 
242
- <SettingsSearch sections={SETTINGS_SECTIONS} onJump={onJumpSection} />
268
+ {settingsMode && (
269
+ <div className="settings-mode-banner">
270
+ <span>You're in settings mode. All sections are visible in the sidebar.</span>
271
+ <Button variant="secondary" size="sm" onClick={handleExitSettings}>← Back to main view</Button>
272
+ </div>
273
+ )}
274
+
275
+ <SettingsSearch sections={SETTINGS_SECTIONS} onJump={handleJumpSection} />
243
276
 
277
+ {/* v4.9.0 — Subnav removed; section navigation is now driven by the sidebar */}
278
+ {/*
244
279
  <nav className="settings-subnav" aria-label="Settings sections">
245
- <button type="button" className={cn('settings-subnav-button', 'settings-subnav-button-all', showAll && 'settings-subnav-button-active')} onClick={() => onJumpSection(null)} title="Show all settings sections">All</button>
280
+ <button type="button" className={cn('settings-subnav-button', 'settings-subnav-button-all', showAll && 'settings-subnav-button-active')} onClick={() => handleJumpSection(null)} title="Show all settings sections">All</button>
246
281
  {SECTION_LINKS.map((s) => (
247
- <button key={s.id} type="button" className={cn('settings-subnav-button', activeSection === s.id && 'settings-subnav-button-active')} onClick={() => onJumpSection(s.id)}>{s.label}</button>
282
+ <button key={s.id} type="button" className={cn('settings-subnav-button', activeSection === s.id && 'settings-subnav-button-active')} onClick={() => handleJumpSection(s.id)}>{s.label}</button>
248
283
  ))}
249
284
  </nav>
285
+ */}
250
286
 
251
287
  <div className={cn('settings-grid', activeSection && 'settings-grid-filtered')} data-active-section={activeSection || undefined}>
252
288
  {(showAll || sectionOf('theme')) && <ThemeSection {...sp} />}
253
289
  {(showAll || sectionOf('updates')) && <UpdatesSection />}
254
- {(showAll || sectionOf('layout') || sectionOf('general')) && <GeneralSection {...sp} />}
290
+ {(showAll || sectionOf('layout') || sectionOf('general')) && <GeneralSection {...sp} autoSave={autoSave} />}
291
+ {(showAll || sectionOf('env-vars')) && <EnvVarsSection />}
255
292
  {(showAll || sectionOf('network') || sectionOf('service') || sectionOf('tailscale')) && <NetworkSection tailscale={tailscale} tailscaleDraft={tailscaleDraft} setTailscaleDraft={setTailscaleDraft} onTailscaleToggle={onTailscaleToggle} />}
256
293
  {(showAll || sectionOf('notifications')) && <NotificationsSection {...sp} />}
257
294
  {(showAll || sectionOf('auth')) && <AuthSection settings={settings} authStatus={authStatus} setAuthStatus={setAuthStatus} />}
258
- {(showAll || sectionOf('agents') || sectionOf('dashboard') || sectionOf('background')) && <AgentSection {...sp} />}
295
+ {(showAll || sectionOf('agents') || sectionOf('dashboard') || sectionOf('background')) && <AgentSection {...sp} autoSave={autoSave} />}
259
296
  {(showAll || sectionOf('system-llm')) && <SystemLlmSection {...sp} />}
260
297
  {(showAll || sectionOf('headroom')) && <HeadroomSection settings={settings} setSettings={setSettings} setDirty={setDirty} />}
261
298
  {(showAll || sectionOf('activity-log') || sectionOf('about')) && <ActivitySection about={about} />}
262
- {(showAll || sectionOf('env-vars')) && <EnvVarsSection />}
263
- {(showAll || sectionOf('providers')) && <ProvidersSection />}
264
- {(showAll || sectionOf('memory')) && <MemorySection />}
265
- {(showAll || sectionOf('skills')) && <SkillsSection />}
266
- {(showAll || sectionOf('backup')) && <BackupSection />}
299
+ {(showAll || sectionOf('workspaces')) && <WorkspacesSection />}
267
300
  </div>
268
301
  </div>
269
302
  );
@@ -0,0 +1,294 @@
1
+ // src/web/views/Workspace.tsx — v5.0.0 — workspace management view.
2
+ import { useEffect, useState, useCallback } from 'react';
3
+ import { Users, Plus, Trash2, Shield, Crown, Eye } from 'lucide-react';
4
+ import { api } from '../lib/api';
5
+ import { useModal } from '../components/Modal';
6
+ import { Button } from '../components/Button';
7
+ import { InviteDialog, type InviteInfo } from '../components/InviteDialog';
8
+ import { cn } from '../lib/utils';
9
+
10
+ export type WorkspaceMember = {
11
+ userId: string;
12
+ email: string;
13
+ name: string;
14
+ role: 'admin' | 'editor' | 'viewer';
15
+ joinedAt: string;
16
+ };
17
+
18
+ export type WorkspaceInfo = {
19
+ id: string;
20
+ name: string;
21
+ ownerId: string;
22
+ createdAt: string;
23
+ };
24
+
25
+ type Props = {
26
+ workspaceId: string;
27
+ onWorkspaceDeleted?: () => void;
28
+ onWorkspaceUpdated?: (name: string) => void;
29
+ };
30
+
31
+ const ROLE_ICONS = {
32
+ admin: Crown,
33
+ editor: Shield,
34
+ viewer: Eye,
35
+ };
36
+
37
+ const ROLE_COLORS = {
38
+ admin: 'role-admin',
39
+ editor: 'role-editor',
40
+ viewer: 'role-viewer',
41
+ };
42
+
43
+ export function WorkspaceView({ workspaceId, onWorkspaceDeleted, onWorkspaceUpdated }: Props) {
44
+ const modal = useModal();
45
+ const [workspace, setWorkspace] = useState<WorkspaceInfo | null>(null);
46
+ const [members, setMembers] = useState<WorkspaceMember[]>([]);
47
+ const [invites, setInvites] = useState<InviteInfo[]>([]);
48
+ const [loading, setLoading] = useState(true);
49
+ const [error, setError] = useState<string | null>(null);
50
+ const [nameDraft, setNameDraft] = useState('');
51
+ const [nameEditing, setNameEditing] = useState(false);
52
+
53
+ const loadWorkspace = useCallback(async () => {
54
+ setLoading(true);
55
+ setError(null);
56
+ try {
57
+ const r = await api.get<{ workspace: WorkspaceInfo; members: WorkspaceMember[] }>(
58
+ `/workspaces/${workspaceId}`,
59
+ );
60
+ setWorkspace(r.workspace);
61
+ setMembers(r.members || []);
62
+ setNameDraft(r.workspace.name);
63
+ } catch (err: unknown) {
64
+ setError((err as Error).message || 'Failed to load workspace');
65
+ } finally {
66
+ setLoading(false);
67
+ }
68
+ }, [workspaceId]);
69
+
70
+ useEffect(() => {
71
+ loadWorkspace();
72
+ }, [loadWorkspace]);
73
+
74
+ const loadInvites = async () => {
75
+ try {
76
+ const r = await api.get<{ invites: InviteInfo[] }>(`/workspaces/${workspaceId}/invites`);
77
+ setInvites(r.invites || []);
78
+ } catch {
79
+ // Ignore
80
+ }
81
+ };
82
+
83
+ useEffect(() => {
84
+ loadInvites();
85
+ }, [workspaceId]);
86
+
87
+ const handleSaveName = async () => {
88
+ if (!nameDraft.trim() || !workspace) return;
89
+ try {
90
+ // Workspace rename would be a separate endpoint; for now just update locally
91
+ setWorkspace((w) => (w ? { ...w, name: nameDraft.trim() } : w));
92
+ onWorkspaceUpdated?.(nameDraft.trim());
93
+ setNameEditing(false);
94
+ } catch {
95
+ setError('Failed to update name');
96
+ }
97
+ };
98
+
99
+ const handleDeleteWorkspace = async () => {
100
+ if (!confirm(`Delete workspace "${workspace?.name}"? This cannot be undone.`)) return;
101
+ if (!confirm('Are you absolutely sure? All workspace data will be permanently deleted.')) return;
102
+ try {
103
+ await api.del(`/workspaces/${workspaceId}`);
104
+ onWorkspaceDeleted?.();
105
+ } catch (err: unknown) {
106
+ setError((err as Error).message || 'Failed to delete workspace');
107
+ }
108
+ };
109
+
110
+ const handleRemoveMember = async (userId: string) => {
111
+ if (!confirm('Remove this member from the workspace?')) return;
112
+ try {
113
+ await api.del(`/workspaces/${workspaceId}/members/${userId}`);
114
+ setMembers((prev) => prev.filter((m) => m.userId !== userId));
115
+ } catch (err: unknown) {
116
+ setError((err as Error).message || 'Failed to remove member');
117
+ }
118
+ };
119
+
120
+ const handleUpdateRole = async (userId: string, newRole: string) => {
121
+ try {
122
+ await api.post(`/workspaces/${workspaceId}/members/${userId}`, { role: newRole });
123
+ setMembers((prev) =>
124
+ prev.map((m) => (m.userId === userId ? { ...m, role: newRole as WorkspaceMember['role'] } : m)),
125
+ );
126
+ } catch (err: unknown) {
127
+ setError((err as Error).message || 'Failed to update role');
128
+ }
129
+ };
130
+
131
+ // Note: In a real implementation, we'd get the current user ID from the session/JWT.
132
+ // For now, we trust the server to only render this view for authorized users.
133
+ const currentUserIsAdmin = true;
134
+
135
+ if (loading) {
136
+ return (
137
+ <div className="view view-workspace">
138
+ <div className="workspace-loading">Loading workspace...</div>
139
+ </div>
140
+ );
141
+ }
142
+
143
+ if (error && !workspace) {
144
+ return (
145
+ <div className="view view-workspace">
146
+ <div className="workspace-error">
147
+ <p>{error}</p>
148
+ <Button variant="secondary" size="sm" onClick={loadWorkspace}>
149
+ Retry
150
+ </Button>
151
+ </div>
152
+ </div>
153
+ );
154
+ }
155
+
156
+ return (
157
+ <div className="view view-workspace">
158
+ <header className="view-header">
159
+ <div className="view-header-text">
160
+ <h2 className="view-title">
161
+ <Users size={18} />
162
+ {nameEditing ? (
163
+ <input
164
+ type="text"
165
+ className="workspace-name-input"
166
+ value={nameDraft}
167
+ onChange={(e) => setNameDraft(e.target.value)}
168
+ onBlur={handleSaveName}
169
+ onKeyDown={(e) => {
170
+ if (e.key === 'Enter') handleSaveName();
171
+ if (e.key === 'Escape') setNameEditing(false);
172
+ }}
173
+ autoFocus
174
+ />
175
+ ) : (
176
+ <span onClick={() => currentUserIsAdmin && setNameEditing(true)} className={cn('workspace-name', currentUserIsAdmin && 'workspace-name-editable')}>
177
+ {workspace?.name}
178
+ </span>
179
+ )}
180
+ </h2>
181
+ <p className="view-subtitle">
182
+ Created {workspace?.createdAt ? new Date(workspace.createdAt).toLocaleDateString() : '—'}
183
+ </p>
184
+ </div>
185
+ <div className="view-actions">
186
+ {currentUserIsAdmin && (
187
+ <>
188
+ <Button
189
+ variant="secondary"
190
+ size="sm"
191
+ onClick={() => {
192
+ modal.open({
193
+ title: 'Invite Members',
194
+ children: <InviteDialog workspaceId={workspaceId} onInviteCreated={loadInvites} />,
195
+ width: 480,
196
+ });
197
+ }}
198
+ >
199
+ <Plus size={14} />
200
+ Invite
201
+ </Button>
202
+ <Button variant="danger" size="sm" onClick={handleDeleteWorkspace}>
203
+ <Trash2 size={14} />
204
+ Delete
205
+ </Button>
206
+ </>
207
+ )}
208
+ </div>
209
+ </header>
210
+
211
+ {error && (
212
+ <div className="workspace-error-banner">
213
+ <span>{error}</span>
214
+ <button onClick={() => setError(null)}>×</button>
215
+ </div>
216
+ )}
217
+
218
+ <div className="workspace-sections">
219
+ {/* Members section */}
220
+ <section className="workspace-section">
221
+ <h3 className="workspace-section-title">Members ({members.length})</h3>
222
+ <div className="workspace-members-list">
223
+ {members.map((member) => {
224
+ const RoleIcon = ROLE_ICONS[member.role] || Eye;
225
+ return (
226
+ <div key={member.userId} className="workspace-member">
227
+ <div className="workspace-member-info">
228
+ <div className="workspace-member-avatar">
229
+ {member.name?.[0]?.toUpperCase() || member.email[0].toUpperCase()}
230
+ </div>
231
+ <div className="workspace-member-details">
232
+ <span className="workspace-member-name">{member.name || 'Unknown'}</span>
233
+ <span className="workspace-member-email">{member.email}</span>
234
+ </div>
235
+ </div>
236
+ <div className="workspace-member-actions">
237
+ <span className={cn('workspace-member-role', ROLE_COLORS[member.role])}>
238
+ <RoleIcon size={12} />
239
+ {member.role}
240
+ </span>
241
+ {currentUserIsAdmin && member.role !== 'admin' && (
242
+ <>
243
+ <select
244
+ className="workspace-role-select"
245
+ value={member.role}
246
+ onChange={(e) => handleUpdateRole(member.userId, e.target.value)}
247
+ >
248
+ <option value="viewer">Viewer</option>
249
+ <option value="editor">Editor</option>
250
+ <option value="admin">Admin</option>
251
+ </select>
252
+ <Button
253
+ variant="ghost"
254
+ size="sm"
255
+ iconOnly
256
+ onClick={() => handleRemoveMember(member.userId)}
257
+ title="Remove member"
258
+ >
259
+ <Trash2 size={13} />
260
+ </Button>
261
+ </>
262
+ )}
263
+ </div>
264
+ </div>
265
+ );
266
+ })}
267
+ </div>
268
+ </section>
269
+
270
+ {/* Pending invites section */}
271
+ {currentUserIsAdmin && (
272
+ <section className="workspace-section">
273
+ <h3 className="workspace-section-title">Pending Invites ({invites.length})</h3>
274
+ {invites.length === 0 ? (
275
+ <p className="workspace-empty">No pending invites</p>
276
+ ) : (
277
+ <div className="workspace-invites-list">
278
+ {invites.map((inv) => (
279
+ <div key={inv.token} className="workspace-invite">
280
+ <span className="workspace-invite-email">{inv.email}</span>
281
+ <span className={cn('workspace-invite-role', ROLE_COLORS[inv.role])}>{inv.role}</span>
282
+ <span className="workspace-invite-expiry">
283
+ Expires {new Date(inv.expiresAt).toLocaleDateString()}
284
+ </span>
285
+ </div>
286
+ ))}
287
+ </div>
288
+ )}
289
+ </section>
290
+ )}
291
+ </div>
292
+ </div>
293
+ );
294
+ }