@polderlabs/bizar 3.23.1 → 4.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.
- package/README.md +83 -16
- package/bizar-dash/.bizar/activity.log +3 -0
- package/bizar-dash/.bizar/graph/.graphify_analysis.json +1540 -0
- package/bizar-dash/.bizar/graph/.graphify_labels.json +1 -0
- package/bizar-dash/.bizar/graph/GRAPH_REPORT.md +404 -0
- package/bizar-dash/.bizar/graph/cache/ast/v0.8.46/0661b816358db84dfdb7f10443db8a61152fd20b3fee950caf8fbf32920a1790.json +1 -0
- package/bizar-dash/.bizar/graph/cache/ast/v0.8.46/16da06d774142a6e5d74829ad982531286a6193ef798de8f593db029e123ec32.json +1 -0
- package/bizar-dash/.bizar/graph/cache/ast/v0.8.46/27e5697c01254c69c64c5a33896669073b40afb5ba88375102ae77fa5404136d.json +1 -0
- package/bizar-dash/.bizar/graph/cache/ast/v0.8.46/532997898027254aa4c0932243dc71d2f55888181232b65974961934997dc85b.json +1 -0
- package/bizar-dash/.bizar/graph/cache/ast/v0.8.46/84e3451a64c7f99f0cd6a88b0748d7426780486f53ab4935b7476b28c08f8293.json +1 -0
- package/bizar-dash/.bizar/graph/cache/ast/v0.8.46/88e5ea5f3e78fa69353fe9e6a8d1a05391d54605e25600cb125531eb5a6432ff.json +1 -0
- package/bizar-dash/.bizar/graph/cache/stat-index.json +1 -0
- package/bizar-dash/.bizar/graph/graph.html +307 -0
- package/bizar-dash/.bizar/graph/graph.json +40267 -0
- package/bizar-dash/.bizar/graph/manifest.json +762 -0
- package/bizar-dash/.graphifyignore +40 -0
- package/bizar-dash/.obsidian/README.md +14 -0
- package/bizar-dash/.obsidian/daily/2026-06-25.md +9 -0
- package/bizar-dash/.obsidian/vault.json +7 -0
- package/bizar-dash/.od-skills/web-prototype-e1429c1737/SKILL.md +103 -0
- package/bizar-dash/.od-skills/web-prototype-e1429c1737/assets/template.html +338 -0
- package/bizar-dash/.od-skills/web-prototype-e1429c1737/example.html +81 -0
- package/bizar-dash/.od-skills/web-prototype-e1429c1737/open-design.json +172 -0
- package/bizar-dash/.od-skills/web-prototype-e1429c1737/references/checklist.md +44 -0
- package/bizar-dash/.od-skills/web-prototype-e1429c1737/references/layouts.md +247 -0
- package/bizar-dash/CHANGELOG.md +474 -0
- package/bizar-dash/DESIGN.md +136 -0
- package/bizar-dash/dist/assets/main-B3RgW6FS.js +331 -0
- package/bizar-dash/dist/assets/main-B3RgW6FS.js.map +1 -0
- package/bizar-dash/dist/assets/main-DlJ8wgLR.css +1 -0
- package/bizar-dash/dist/assets/mobile-CsZQAswA.css +1 -0
- package/bizar-dash/dist/assets/mobile-DbxIw8BG.js +2 -0
- package/bizar-dash/dist/assets/mobile-DbxIw8BG.js.map +1 -0
- package/bizar-dash/dist/assets/mobile-Dvq2d53Y.js +354 -0
- package/bizar-dash/dist/assets/mobile-Dvq2d53Y.js.map +1 -0
- package/bizar-dash/dist/index.html +40 -0
- package/bizar-dash/dist/mobile.html +22 -0
- package/bizar-dash/plans/debug-plan/comments.json +1 -0
- package/bizar-dash/plans/debug-plan/meta.json +10 -0
- package/bizar-dash/plans/debug-plan/plan.json +174 -0
- package/bizar-dash/scripts/smoke-bg-retry.mjs +246 -0
- package/bizar-dash/src/cli/dashboard-ports.mjs +488 -0
- package/bizar-dash/src/cli.mjs +522 -0
- package/bizar-dash/src/server/activity-log.mjs +174 -0
- package/bizar-dash/src/server/agents-store.mjs +465 -0
- package/bizar-dash/src/server/api.mjs +138 -0
- package/bizar-dash/src/server/artifact-template.html +810 -0
- package/bizar-dash/src/server/artifacts-store.mjs +733 -0
- package/bizar-dash/src/server/auth.mjs +381 -0
- package/bizar-dash/src/server/background-store.mjs +447 -0
- package/bizar-dash/src/server/bg-poller.mjs +305 -0
- package/bizar-dash/src/server/bg-retry.mjs +574 -0
- package/bizar-dash/src/server/browser.mjs +40 -0
- package/bizar-dash/src/server/diagnostics-store.mjs +148 -0
- package/bizar-dash/src/server/dialog-poller.mjs +105 -0
- package/bizar-dash/src/server/dialog-store.mjs +177 -0
- package/bizar-dash/src/server/glyphs/mdx-compiler.mjs +583 -0
- package/bizar-dash/src/server/lib/path-safe.mjs +283 -0
- package/bizar-dash/src/server/memory-git.mjs +329 -0
- package/bizar-dash/src/server/memory-lightrag.mjs +767 -0
- package/bizar-dash/src/server/memory-schema.mjs +145 -0
- package/bizar-dash/src/server/memory-secrets.mjs +84 -0
- package/bizar-dash/src/server/memory-store.mjs +471 -0
- package/bizar-dash/src/server/mod-security.mjs +398 -0
- package/bizar-dash/src/server/mods-loader.mjs +1067 -0
- package/bizar-dash/src/server/notifications-store.mjs +243 -0
- package/bizar-dash/src/server/obsidian-store.mjs +318 -0
- package/bizar-dash/src/server/opencode-runner.mjs +279 -0
- package/bizar-dash/src/server/pair-store.mjs +138 -0
- package/bizar-dash/src/server/projects-store.mjs +364 -0
- package/bizar-dash/src/server/providers-store.mjs +789 -0
- package/bizar-dash/src/server/routes/_shared.mjs +415 -0
- package/bizar-dash/src/server/routes/activity.mjs +110 -0
- package/bizar-dash/src/server/routes/agents.mjs +134 -0
- package/bizar-dash/src/server/routes/artifacts.mjs +279 -0
- package/bizar-dash/src/server/routes/auth.mjs +69 -0
- package/bizar-dash/src/server/routes/background.mjs +165 -0
- package/bizar-dash/src/server/routes/chat.mjs +485 -0
- package/bizar-dash/src/server/routes/config.mjs +129 -0
- package/bizar-dash/src/server/routes/diagnostics.mjs +50 -0
- package/bizar-dash/src/server/routes/dialogs.mjs +86 -0
- package/bizar-dash/src/server/routes/fs.mjs +429 -0
- package/bizar-dash/src/server/routes/history.mjs +78 -0
- package/bizar-dash/src/server/routes/memory.mjs +658 -0
- package/bizar-dash/src/server/routes/misc.mjs +89 -0
- package/bizar-dash/src/server/routes/mods.mjs +359 -0
- package/bizar-dash/src/server/routes/notifications.mjs +54 -0
- package/bizar-dash/src/server/routes/obsidian.mjs +239 -0
- package/bizar-dash/src/server/routes/opencode-sessions.mjs +55 -0
- package/bizar-dash/src/server/routes/overview.mjs +91 -0
- package/bizar-dash/src/server/routes/pair.mjs +67 -0
- package/bizar-dash/src/server/routes/projects.mjs +175 -0
- package/bizar-dash/src/server/routes/providers.mjs +47 -0
- package/bizar-dash/src/server/routes/schedules.mjs +104 -0
- package/bizar-dash/src/server/routes/settings.mjs +51 -0
- package/bizar-dash/src/server/routes/skills.mjs +76 -0
- package/bizar-dash/src/server/routes/tasks.mjs +468 -0
- package/bizar-dash/src/server/routes/themes.mjs +51 -0
- package/bizar-dash/src/server/routes-v2/auth.mjs +59 -0
- package/bizar-dash/src/server/routes-v2/events.mjs +107 -0
- package/bizar-dash/src/server/routes-v2/health.mjs +21 -0
- package/bizar-dash/src/server/routes-v2/index.mjs +90 -0
- package/bizar-dash/src/server/routes-v2/sessions.mjs +111 -0
- package/bizar-dash/src/server/schedules-runner.mjs +369 -0
- package/bizar-dash/src/server/schedules-store.mjs +268 -0
- package/bizar-dash/src/server/search-store.mjs +220 -0
- package/bizar-dash/src/server/serve-info.mjs +684 -0
- package/bizar-dash/src/server/server.mjs +796 -0
- package/bizar-dash/src/server/settings-store.mjs +45 -0
- package/bizar-dash/src/server/skills-store.mjs +300 -0
- package/bizar-dash/src/server/state.mjs +437 -0
- package/bizar-dash/src/server/tailscale-store.mjs +113 -0
- package/bizar-dash/src/server/task-delegator.mjs +824 -0
- package/bizar-dash/src/server/tasks-store.mjs +508 -0
- package/bizar-dash/src/server/tui.mjs +923 -0
- package/bizar-dash/src/server/update-store.mjs +168 -0
- package/bizar-dash/src/server/v2-auth-file.mjs +99 -0
- package/bizar-dash/src/server/v2-event-bus.mjs +128 -0
- package/bizar-dash/src/server/watcher.mjs +81 -0
- package/bizar-dash/src/server/yaml.mjs +238 -0
- package/bizar-dash/src/web/App.tsx +718 -0
- package/bizar-dash/src/web/MobileApp.tsx +408 -0
- package/bizar-dash/src/web/components/ArtifactCreateDialog.tsx +95 -0
- package/bizar-dash/src/web/components/ArtifactListDialog.tsx +55 -0
- package/bizar-dash/src/web/components/ArtifactViewer.tsx +143 -0
- package/bizar-dash/src/web/components/AuditDialog.tsx +79 -0
- package/bizar-dash/src/web/components/BgStatusBadge.tsx +32 -0
- package/bizar-dash/src/web/components/Button.tsx +56 -0
- package/bizar-dash/src/web/components/CanvasContextMenu.tsx +68 -0
- package/bizar-dash/src/web/components/Card.tsx +40 -0
- package/bizar-dash/src/web/components/CollapsibleSection.tsx +72 -0
- package/bizar-dash/src/web/components/CommandDialog.tsx +66 -0
- package/bizar-dash/src/web/components/EmptyState.tsx +30 -0
- package/bizar-dash/src/web/components/FileBrowser.tsx +633 -0
- package/bizar-dash/src/web/components/HelpDialog.tsx +55 -0
- package/bizar-dash/src/web/components/KillConfirmDialog.tsx +88 -0
- package/bizar-dash/src/web/components/Modal.tsx +215 -0
- package/bizar-dash/src/web/components/Notifications.tsx +241 -0
- package/bizar-dash/src/web/components/SearchModal.tsx +224 -0
- package/bizar-dash/src/web/components/Sidebar.tsx +66 -0
- package/bizar-dash/src/web/components/Spinner.tsx +19 -0
- package/bizar-dash/src/web/components/StatusBadge.tsx +25 -0
- package/bizar-dash/src/web/components/Tag.tsx +28 -0
- package/bizar-dash/src/web/components/Toast.tsx +150 -0
- package/bizar-dash/src/web/components/Topbar.tsx +368 -0
- package/bizar-dash/src/web/components/VisualPlanDialog.tsx +58 -0
- package/bizar-dash/src/web/components/chat/AgentChip.tsx +68 -0
- package/bizar-dash/src/web/components/chat/ChatBubble.tsx +60 -0
- package/bizar-dash/src/web/components/chat/ChatThread.tsx +88 -0
- package/bizar-dash/src/web/components/chat/ChatTopBar.tsx +50 -0
- package/bizar-dash/src/web/components/chat/Composer.tsx +164 -0
- package/bizar-dash/src/web/components/chat/ConfirmModal.tsx +54 -0
- package/bizar-dash/src/web/components/chat/EmptyState.tsx +21 -0
- package/bizar-dash/src/web/components/chat/FirstRunGreeting.tsx +18 -0
- package/bizar-dash/src/web/components/chat/FloatingComposer.tsx +32 -0
- package/bizar-dash/src/web/components/chat/InfoPanel.tsx +119 -0
- package/bizar-dash/src/web/components/chat/LoadingSkeleton.tsx +19 -0
- package/bizar-dash/src/web/components/chat/MessageBubble.tsx +60 -0
- package/bizar-dash/src/web/components/chat/SessionList.tsx +153 -0
- package/bizar-dash/src/web/components/chat/StreamingIndicator.tsx +9 -0
- package/bizar-dash/src/web/components/chat/SuggestionCards.tsx +55 -0
- package/bizar-dash/src/web/components/chat/WelcomeScreen.tsx +36 -0
- package/bizar-dash/src/web/components/chat/index.ts +20 -0
- package/bizar-dash/src/web/components/chat/useAutoGrowTextarea.ts +18 -0
- package/bizar-dash/src/web/components/chat/useChat.ts +219 -0
- package/bizar-dash/src/web/components/chat/useSlashCommands.ts +47 -0
- package/bizar-dash/src/web/index.html +37 -0
- package/bizar-dash/src/web/lib/api.ts +193 -0
- package/bizar-dash/src/web/lib/markdown.tsx +59 -0
- package/bizar-dash/src/web/lib/types.ts +668 -0
- package/bizar-dash/src/web/lib/utils.ts +114 -0
- package/bizar-dash/src/web/lib/ws.ts +176 -0
- package/bizar-dash/src/web/main.tsx +34 -0
- package/bizar-dash/src/web/mobile/MobileApp.tsx +278 -0
- package/bizar-dash/src/web/mobile/MobileBottomNav.tsx +38 -0
- package/bizar-dash/src/web/mobile/MobileTopbar.tsx +115 -0
- package/bizar-dash/src/web/mobile/components/MobileBottomSheet.tsx +182 -0
- package/bizar-dash/src/web/mobile/components/MobileListItem.tsx +64 -0
- package/bizar-dash/src/web/mobile/components/MobileModal.tsx +123 -0
- package/bizar-dash/src/web/mobile/views/MobileActivity.tsx +273 -0
- package/bizar-dash/src/web/mobile/views/MobileAgents.tsx +374 -0
- package/bizar-dash/src/web/mobile/views/MobileArtifactCanvas.tsx +439 -0
- package/bizar-dash/src/web/mobile/views/MobileArtifacts.tsx +143 -0
- package/bizar-dash/src/web/mobile/views/MobileChat.tsx +180 -0
- package/bizar-dash/src/web/mobile/views/MobileConfig.tsx +175 -0
- package/bizar-dash/src/web/mobile/views/MobileHistory.tsx +191 -0
- package/bizar-dash/src/web/mobile/views/MobileMods.tsx +149 -0
- package/bizar-dash/src/web/mobile/views/MobileMore.tsx +121 -0
- package/bizar-dash/src/web/mobile/views/MobileNotifications.tsx +137 -0
- package/bizar-dash/src/web/mobile/views/MobilePlans.tsx +26 -0
- package/bizar-dash/src/web/mobile/views/MobileSchedules.tsx +685 -0
- package/bizar-dash/src/web/mobile/views/MobileSearchModal.tsx +172 -0
- package/bizar-dash/src/web/mobile/views/MobileSettings.tsx +388 -0
- package/bizar-dash/src/web/mobile/views/MobileSkills.tsx +153 -0
- package/bizar-dash/src/web/mobile/views/MobileTasks.tsx +564 -0
- package/bizar-dash/src/web/mobile.html +20 -0
- package/bizar-dash/src/web/mobile.tsx +13 -0
- package/bizar-dash/src/web/styles/chat.css +198 -0
- package/bizar-dash/src/web/styles/glyphs.css +960 -0
- package/bizar-dash/src/web/styles/main.css +8855 -0
- package/bizar-dash/src/web/styles/mobile-chat.css +5 -0
- package/bizar-dash/src/web/styles/mobile.css +1960 -0
- package/bizar-dash/src/web/views/Activity.tsx +1203 -0
- package/bizar-dash/src/web/views/Agents.tsx +701 -0
- package/bizar-dash/src/web/views/Artifacts.tsx +1480 -0
- package/bizar-dash/src/web/views/BackgroundAgents.tsx +450 -0
- package/bizar-dash/src/web/views/Chat.tsx +190 -0
- package/bizar-dash/src/web/views/Config.tsx +2063 -0
- package/bizar-dash/src/web/views/History.tsx +335 -0
- package/bizar-dash/src/web/views/ModView.tsx +207 -0
- package/bizar-dash/src/web/views/Mods.tsx +693 -0
- package/bizar-dash/src/web/views/Overview.tsx +812 -0
- package/bizar-dash/src/web/views/Providers.tsx +302 -0
- package/bizar-dash/src/web/views/Schedules.tsx +802 -0
- package/bizar-dash/src/web/views/Settings.tsx +1787 -0
- package/bizar-dash/src/web/views/Skills.tsx +431 -0
- package/bizar-dash/src/web/views/Tasks.tsx +1523 -0
- package/bizar-dash/src/web/views/glyphs/GlyphRenderer.tsx +613 -0
- package/bizar-dash/src/web/views/glyphs/components.tsx +1098 -0
- package/bizar-dash/templates/mod/FORMAT.md +76 -0
- package/bizar-dash/templates/mod/hello-mod/README.md +19 -0
- package/bizar-dash/templates/mod/hello-mod/agents/greeter.md +8 -0
- package/bizar-dash/templates/mod/hello-mod/commands/hello.md +6 -0
- package/bizar-dash/templates/mod/hello-mod/mod.json +20 -0
- package/bizar-dash/templates/mod/hello-mod/routes/ping.mjs +9 -0
- package/bizar-dash/templates/mod/hello-mod/views/HelloView.tsx +10 -0
- package/bizar-dash/tests/dashboard-ports.test.mjs +138 -0
- package/bizar-dash/tests/graphify-mod-spawn.node.test.mjs +90 -0
- package/bizar-dash/tests/memory-cli.test.mjs +542 -0
- package/bizar-dash/tests/memory-config.test.mjs +422 -0
- package/bizar-dash/tests/memory-git.test.mjs +246 -0
- package/bizar-dash/tests/memory-lightrag.test.mjs +153 -0
- package/bizar-dash/tests/memory-protocol-drift.test.mjs +45 -0
- package/bizar-dash/tests/memory-schema.test.mjs +198 -0
- package/bizar-dash/tests/memory-secrets.test.mjs +121 -0
- package/bizar-dash/tests/memory-store.test.mjs +390 -0
- package/bizar-dash/tests/memory-sync.test.mjs +141 -0
- package/bizar-dash/tests/mod-instructions.node.test.mjs +188 -0
- package/bizar-dash/tests/mod-security.test.mjs +273 -0
- package/bizar-dash/tests/mod-upgrade.node.test.mjs +357 -0
- package/bizar-dash/tests/no-agent-browser.node.test.mjs +98 -0
- package/bizar-dash/tests/obsidian-back-compat.test.mjs +199 -0
- package/bizar-dash/tests/opencode-runner.test.mjs +172 -0
- package/bizar-dash/tests/path-safe.test.mjs +108 -0
- package/bizar-dash/tests/providers-store-backup-keys.node.test.mjs +333 -0
- package/bizar-dash/tests/smoke-v2.mjs +212 -0
- package/bizar-dash/tests/strip-thinking.test.mjs +175 -0
- package/bizar-dash/tests/submit-feedback.test.mjs +353 -0
- package/bizar-dash/tests/tmux-wrap.test.mjs +145 -0
- package/bizar-dash/tests/yaml.test.mjs +128 -0
- package/bizar-dash/tsconfig.json +23 -0
- package/bizar-dash/vite.config.ts +27 -0
- package/cli/atomic.mjs +73 -0
- package/cli/banner.mjs +1 -1
- package/cli/bin.mjs +44 -47
- package/cli/bootstrap.mjs +1 -1
- package/cli/copy.mjs +25 -16
- package/cli/dev-link.mjs +17 -1
- package/cli/doctor.mjs +4 -4
- package/cli/doctor.test.mjs +2 -2
- package/cli/init.mjs +135 -1
- package/cli/install.mjs +31 -39
- package/cli/memory-constants.mjs +59 -0
- package/cli/memory.mjs +1436 -0
- package/cli/service.mjs +5 -6
- package/cli/utils.mjs +6 -3
- package/config/AGENTS.md +7 -7
- package/config/agents/_shared/AGENT_BASELINE.md +59 -61
- package/config/opencode.json +13 -38
- package/config/skills/memory-protocol/SKILL.md +105 -0
- package/config/skills/obsidian/SKILL.md +270 -65
- package/install.sh +59 -19
- package/package.json +49 -14
- package/packages/sdk/LICENSE +21 -0
- package/packages/sdk/README.md +80 -0
- package/packages/sdk/src/client.ts +183 -0
- package/packages/sdk/src/errors.ts +120 -0
- package/packages/sdk/src/events.ts +153 -0
- package/packages/sdk/src/index.ts +63 -0
- package/packages/sdk/src/types.ts +176 -0
- package/packages/sdk/src/version.ts +4 -0
- package/packages/sdk/tests/client.test.ts +217 -0
- package/packages/sdk/tests/errors.test.ts +108 -0
- package/packages/sdk/tests/events.test.ts +139 -0
- package/packages/sdk/tests/fixtures/fetch-mock.ts +152 -0
- package/packages/sdk/tests/fixtures/sse-mock.ts +30 -0
- package/packages/sdk/tsconfig.json +25 -0
- package/packages/sdk/vitest.config.ts +9 -0
- package/plugins/bizar/LICENSE +21 -0
- package/plugins/bizar/README.md +448 -0
- package/plugins/bizar/dist/index.js +29901 -0
- package/plugins/bizar/index.ts +1491 -0
- package/plugins/bizar/scripts/check-forbidden-imports.sh +33 -0
- package/plugins/bizar/src/background-state.ts +551 -0
- package/plugins/bizar/src/background.ts +1250 -0
- package/plugins/bizar/src/commands-impl.ts +464 -0
- package/plugins/bizar/src/commands.ts +1168 -0
- package/plugins/bizar/src/dashboard-client.ts +233 -0
- package/plugins/bizar/src/event-stream.ts +606 -0
- package/plugins/bizar/src/fingerprint.ts +120 -0
- package/plugins/bizar/src/handoff.ts +79 -0
- package/plugins/bizar/src/http-client.ts +467 -0
- package/plugins/bizar/src/key-rotation.ts +218 -0
- package/plugins/bizar/src/logger.ts +144 -0
- package/plugins/bizar/src/loop.ts +176 -0
- package/plugins/bizar/src/opencode-runner.ts +390 -0
- package/plugins/bizar/src/options.ts +421 -0
- package/plugins/bizar/src/plan-fs.ts +323 -0
- package/plugins/bizar/src/reasoning-clean.ts +454 -0
- package/plugins/bizar/src/report.ts +178 -0
- package/plugins/bizar/src/research-prompt.ts +35 -0
- package/plugins/bizar/src/serve-info.ts +228 -0
- package/plugins/bizar/src/serve.ts +496 -0
- package/plugins/bizar/src/settings.ts +349 -0
- package/plugins/bizar/src/state.ts +298 -0
- package/plugins/bizar/src/tools/bg-collect.ts +104 -0
- package/plugins/bizar/src/tools/bg-get-comments.ts +239 -0
- package/plugins/bizar/src/tools/bg-kill.ts +87 -0
- package/plugins/bizar/src/tools/bg-spawn.ts +401 -0
- package/plugins/bizar/src/tools/bg-status.ts +99 -0
- package/plugins/bizar/src/tools/open-kb.ts +191 -0
- package/plugins/bizar/src/tools/plan-action.ts +767 -0
- package/plugins/bizar/src/tools/read-glyph-feedback.ts +170 -0
- package/plugins/bizar/src/tools/wait-for-feedback.ts +402 -0
- package/plugins/bizar/tests/attach-handler-bug.test.ts +169 -0
- package/plugins/bizar/tests/background-state.test.ts +277 -0
- package/plugins/bizar/tests/background.test.ts +402 -0
- package/plugins/bizar/tests/block.test.ts +195 -0
- package/plugins/bizar/tests/canonical-key-order.test.ts +75 -0
- package/plugins/bizar/tests/commands-impl.test.ts +442 -0
- package/plugins/bizar/tests/commands.test.ts +584 -0
- package/plugins/bizar/tests/config.test.ts +122 -0
- package/plugins/bizar/tests/dashboard-client.test.ts +159 -0
- package/plugins/bizar/tests/dispose.test.ts +336 -0
- package/plugins/bizar/tests/event-stream.test.ts +409 -0
- package/plugins/bizar/tests/event.test.ts +262 -0
- package/plugins/bizar/tests/fingerprint.test.ts +162 -0
- package/plugins/bizar/tests/http-client.test.ts +404 -0
- package/plugins/bizar/tests/init-helpers.test.ts +203 -0
- package/plugins/bizar/tests/integration/slash-command.test.ts +348 -0
- package/plugins/bizar/tests/integration/tool-routing.test.ts +314 -0
- package/plugins/bizar/tests/key-rotation.test.ts +396 -0
- package/plugins/bizar/tests/loop.test.ts +397 -0
- package/plugins/bizar/tests/options.test.ts +276 -0
- package/plugins/bizar/tests/reasoning-clean.test.ts +422 -0
- package/plugins/bizar/tests/serve.test.ts +339 -0
- package/plugins/bizar/tests/settings.test.ts +351 -0
- package/plugins/bizar/tests/stall-think.test.ts +750 -0
- package/plugins/bizar/tests/state.test.ts +276 -0
- package/plugins/bizar/tests/tools/bg-collect.test.ts +337 -0
- package/plugins/bizar/tests/tools/bg-get-comments.test.ts +485 -0
- package/plugins/bizar/tests/tools/bg-kill.test.ts +235 -0
- package/plugins/bizar/tests/tools/bg-spawn-delegation.test.ts +147 -0
- package/plugins/bizar/tests/tools/bg-spawn.test.ts +311 -0
- package/plugins/bizar/tests/tools/bg-status.test.ts +217 -0
- package/plugins/bizar/tests/tools/opencode-runner.test.ts +115 -0
- package/plugins/bizar/tests/tools/plan-action.test.ts +599 -0
- package/plugins/bizar/tests/tools/read-glyph-feedback.test.ts +253 -0
- package/plugins/bizar/tests/tools/wait-for-feedback.test.ts +390 -0
- package/plugins/bizar/tests/update-deadlock.test.ts +145 -0
- package/plugins/bizar/tsconfig.json +29 -0
|
@@ -0,0 +1,1787 @@
|
|
|
1
|
+
// src/views/Settings.tsx — v3 settings: theme colors, UI layout, defaults, Tailscale, service.
|
|
2
|
+
import React, { useEffect, useState, useCallback } from 'react';
|
|
3
|
+
import {
|
|
4
|
+
Sliders,
|
|
5
|
+
Save,
|
|
6
|
+
RefreshCw,
|
|
7
|
+
Sun,
|
|
8
|
+
Moon,
|
|
9
|
+
Monitor,
|
|
10
|
+
Info,
|
|
11
|
+
Globe,
|
|
12
|
+
Palette,
|
|
13
|
+
Layout as LayoutIcon,
|
|
14
|
+
Server as ServerIcon,
|
|
15
|
+
RotateCcw,
|
|
16
|
+
Plug,
|
|
17
|
+
Download,
|
|
18
|
+
AlertTriangle,
|
|
19
|
+
QrCode,
|
|
20
|
+
Smartphone,
|
|
21
|
+
CheckCircle,
|
|
22
|
+
AlertCircle,
|
|
23
|
+
Shield,
|
|
24
|
+
Copy,
|
|
25
|
+
KeyRound,
|
|
26
|
+
Activity,
|
|
27
|
+
EyeOff,
|
|
28
|
+
Eye,
|
|
29
|
+
Trash2,
|
|
30
|
+
Search,
|
|
31
|
+
Sparkles,
|
|
32
|
+
} from 'lucide-react';
|
|
33
|
+
import { QRCodeSVG } from 'qrcode.react';
|
|
34
|
+
import { Button } from '../components/Button';
|
|
35
|
+
import { Card, CardTitle, CardMeta } from '../components/Card';
|
|
36
|
+
import { useToast } from '../components/Toast';
|
|
37
|
+
import { api } from '../lib/api';
|
|
38
|
+
import { cn } from '../lib/utils';
|
|
39
|
+
import { Ws } from '../lib/ws';
|
|
40
|
+
import {
|
|
41
|
+
applyTheme,
|
|
42
|
+
applyThemeTokens,
|
|
43
|
+
type Settings,
|
|
44
|
+
type SettingsResponse,
|
|
45
|
+
type Snapshot,
|
|
46
|
+
type ThemeName,
|
|
47
|
+
type TailscaleStatus,
|
|
48
|
+
} from '../lib/types';
|
|
49
|
+
|
|
50
|
+
type Props = {
|
|
51
|
+
snapshot: Snapshot;
|
|
52
|
+
settings: Settings;
|
|
53
|
+
activeTab: string;
|
|
54
|
+
setActiveTab: (id: string) => void;
|
|
55
|
+
refreshSnapshot: () => Promise<void>;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const THEMES: { id: ThemeName; label: string; Icon: typeof Sun }[] = [
|
|
59
|
+
{ id: 'dark', label: 'Dark', Icon: Moon },
|
|
60
|
+
{ id: 'light', label: 'Light', Icon: Sun },
|
|
61
|
+
{ id: 'system', label: 'System', Icon: Monitor },
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
// v3.3.1 — One-click accent color presets.
|
|
65
|
+
const PRESET_THEMES = [
|
|
66
|
+
{ name: 'Purple', accent: '#8b5cf6' },
|
|
67
|
+
{ name: 'Blue', accent: '#3b82f6' },
|
|
68
|
+
{ name: 'Green', accent: '#10b981' },
|
|
69
|
+
{ name: 'Orange', accent: '#f97316' },
|
|
70
|
+
{ name: 'Red', accent: '#ef4444' },
|
|
71
|
+
{ name: 'Pink', accent: '#ec4899' },
|
|
72
|
+
{ name: 'Cyan', accent: '#06b6d4' },
|
|
73
|
+
{ name: 'Mono', accent: '#6b7280' },
|
|
74
|
+
];
|
|
75
|
+
|
|
76
|
+
const FONT_FAMILIES = [
|
|
77
|
+
'Inter',
|
|
78
|
+
'system-ui',
|
|
79
|
+
'Segoe UI',
|
|
80
|
+
'Roboto',
|
|
81
|
+
'JetBrains Mono',
|
|
82
|
+
'SF Mono',
|
|
83
|
+
'Cascadia Code',
|
|
84
|
+
];
|
|
85
|
+
|
|
86
|
+
// v3.5.2 — Pair-with-mobile companion card.
|
|
87
|
+
type PairSession = {
|
|
88
|
+
token: string;
|
|
89
|
+
qrPayload: string;
|
|
90
|
+
publicUrl: string;
|
|
91
|
+
expiresAt: number;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
function formatCountdown(ms: number): string {
|
|
95
|
+
if (ms <= 0) return 'expired';
|
|
96
|
+
const s = Math.floor(ms / 1000);
|
|
97
|
+
const m = Math.floor(s / 60);
|
|
98
|
+
const r = s % 60;
|
|
99
|
+
return `${m}:${String(r).padStart(2, '0')}`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function PairDeviceCard() {
|
|
103
|
+
const toast = useToast();
|
|
104
|
+
const [pair, setPair] = useState<PairSession | null>(null);
|
|
105
|
+
const [pairing, setPairing] = useState(false);
|
|
106
|
+
const [pairError, setPairError] = useState<string | null>(null);
|
|
107
|
+
const [now, setNow] = useState(Date.now());
|
|
108
|
+
|
|
109
|
+
const start = useCallback(async () => {
|
|
110
|
+
setPairing(true);
|
|
111
|
+
setPairError(null);
|
|
112
|
+
try {
|
|
113
|
+
const res = await api.post<PairSession>('/pair/start');
|
|
114
|
+
setPair(res);
|
|
115
|
+
} catch (err) {
|
|
116
|
+
setPairError((err as Error)?.message || 'Failed to start pairing');
|
|
117
|
+
toast.error('Pairing failed.');
|
|
118
|
+
} finally {
|
|
119
|
+
setPairing(false);
|
|
120
|
+
}
|
|
121
|
+
}, [toast]);
|
|
122
|
+
|
|
123
|
+
useEffect(() => {
|
|
124
|
+
if (!pair) return;
|
|
125
|
+
const t = setInterval(() => setNow(Date.now()), 1000);
|
|
126
|
+
return () => clearInterval(t);
|
|
127
|
+
}, [pair]);
|
|
128
|
+
|
|
129
|
+
const remaining = pair ? pair.expiresAt - now : 0;
|
|
130
|
+
const expired = pair != null && remaining <= 0;
|
|
131
|
+
|
|
132
|
+
return (
|
|
133
|
+
<Card id="settings-updates" data-section="updates">
|
|
134
|
+
<CardTitle>
|
|
135
|
+
<Smartphone size={14} /> Companion App
|
|
136
|
+
</CardTitle>
|
|
137
|
+
<CardMeta>
|
|
138
|
+
Scan the QR with <a href="https://github.com/DrB0rk/BizarHarness" target="_blank" rel="noopener noreferrer">Bizar Companion</a> to pair.
|
|
139
|
+
Tokens expire after 5 minutes.
|
|
140
|
+
</CardMeta>
|
|
141
|
+
|
|
142
|
+
<div style={{ display: 'flex', gap: 16, alignItems: 'flex-start', flexWrap: 'wrap' }}>
|
|
143
|
+
<div style={{ flex: '0 0 auto' }}>
|
|
144
|
+
{pair && !expired ? (
|
|
145
|
+
<div style={{ background: '#fff', padding: 12, borderRadius: 12 }}>
|
|
146
|
+
<QRCodeSVG value={pair.qrPayload} size={192} level="M" includeMargin={false} />
|
|
147
|
+
</div>
|
|
148
|
+
) : (
|
|
149
|
+
<div
|
|
150
|
+
style={{
|
|
151
|
+
width: 216,
|
|
152
|
+
height: 216,
|
|
153
|
+
borderRadius: 12,
|
|
154
|
+
background: 'var(--surface-2, #161b22)',
|
|
155
|
+
display: 'flex',
|
|
156
|
+
alignItems: 'center',
|
|
157
|
+
justifyContent: 'center',
|
|
158
|
+
color: 'var(--text-muted, #8b949e)',
|
|
159
|
+
fontSize: 12,
|
|
160
|
+
textAlign: 'center',
|
|
161
|
+
padding: 16,
|
|
162
|
+
border: '1px dashed var(--border, #30363d)',
|
|
163
|
+
}}
|
|
164
|
+
>
|
|
165
|
+
{pair && expired ? 'QR expired' : 'No QR generated yet'}
|
|
166
|
+
</div>
|
|
167
|
+
)}
|
|
168
|
+
</div>
|
|
169
|
+
|
|
170
|
+
<div style={{ flex: '1 1 240px', minWidth: 220 }}>
|
|
171
|
+
{!pair && (
|
|
172
|
+
<Button variant="primary" onClick={start} disabled={pairing}>
|
|
173
|
+
<QrCode size={14} /> {pairing ? 'Generating…' : 'Generate QR Code'}
|
|
174
|
+
</Button>
|
|
175
|
+
)}
|
|
176
|
+
|
|
177
|
+
{pair && !expired && (
|
|
178
|
+
<>
|
|
179
|
+
<div style={{ marginBottom: 10 }}>
|
|
180
|
+
<strong>Expires in</strong> <span className="mono">{formatCountdown(remaining)}</span>
|
|
181
|
+
</div>
|
|
182
|
+
<div style={{ marginBottom: 6 }}>
|
|
183
|
+
<strong>URL:</strong> <span className="mono" style={{ wordBreak: 'break-all' }}>{pair.publicUrl}</span>
|
|
184
|
+
</div>
|
|
185
|
+
<div style={{ marginBottom: 12 }}>
|
|
186
|
+
<strong>Token:</strong>{' '}
|
|
187
|
+
<span className="mono" style={{ wordBreak: 'break-all', fontSize: 12 }}>
|
|
188
|
+
{pair.token.slice(0, 16)}…{pair.token.slice(-6)}
|
|
189
|
+
</span>
|
|
190
|
+
</div>
|
|
191
|
+
<Button variant="secondary" onClick={start} disabled={pairing}>
|
|
192
|
+
<RefreshCw size={14} /> Regenerate
|
|
193
|
+
</Button>
|
|
194
|
+
</>
|
|
195
|
+
)}
|
|
196
|
+
|
|
197
|
+
{pair && expired && (
|
|
198
|
+
<>
|
|
199
|
+
<div style={{ marginBottom: 12, color: 'var(--error, #f85149)' }}>
|
|
200
|
+
Token expired — generate a fresh QR to pair again.
|
|
201
|
+
</div>
|
|
202
|
+
<Button variant="primary" onClick={() => { setPair(null); start(); }}>
|
|
203
|
+
<RefreshCw size={14} /> Generate new QR
|
|
204
|
+
</Button>
|
|
205
|
+
</>
|
|
206
|
+
)}
|
|
207
|
+
|
|
208
|
+
{pairError && (
|
|
209
|
+
<div style={{ marginTop: 10, color: 'var(--error, #f85149)', fontSize: 12 }}>
|
|
210
|
+
{pairError}
|
|
211
|
+
</div>
|
|
212
|
+
)}
|
|
213
|
+
</div>
|
|
214
|
+
</div>
|
|
215
|
+
</Card>
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const LAYOUTS = [
|
|
220
|
+
{ id: 'topnav', label: 'Top nav' },
|
|
221
|
+
{ id: 'sidebar', label: 'Sidebar' },
|
|
222
|
+
{ id: 'both', label: 'Both' },
|
|
223
|
+
] as const;
|
|
224
|
+
|
|
225
|
+
// v3.5.3 — Updates card with per-package selection, live progress, and auto-restart
|
|
226
|
+
type PkgStatus = {
|
|
227
|
+
status: 'idle' | 'starting' | 'installing' | 'done' | 'error';
|
|
228
|
+
error?: string;
|
|
229
|
+
newVersion?: string;
|
|
230
|
+
logs: string[];
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
function UpdatesCard() {
|
|
234
|
+
const toast = useToast();
|
|
235
|
+
const [status, setStatus] = useState<{
|
|
236
|
+
current: Record<string, string | null>;
|
|
237
|
+
latest: Record<string, string | null> | null;
|
|
238
|
+
checking: boolean;
|
|
239
|
+
updating: boolean;
|
|
240
|
+
hasUpdates: boolean;
|
|
241
|
+
requiresRestart: boolean;
|
|
242
|
+
perPackage: Record<string, PkgStatus>;
|
|
243
|
+
error?: string;
|
|
244
|
+
}>({
|
|
245
|
+
current: {},
|
|
246
|
+
latest: null,
|
|
247
|
+
checking: false,
|
|
248
|
+
updating: false,
|
|
249
|
+
hasUpdates: false,
|
|
250
|
+
requiresRestart: false,
|
|
251
|
+
perPackage: {},
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
// WebSocket subscription for live update progress (auto-reconnect via Ws class)
|
|
255
|
+
useEffect(() => {
|
|
256
|
+
const ws = new Ws();
|
|
257
|
+
ws.on((msg: Record<string, unknown>) => {
|
|
258
|
+
if (msg.type === 'update:progress' || msg.type === 'update:log' || msg.type === 'update:complete') {
|
|
259
|
+
setStatus((s) => {
|
|
260
|
+
if (msg.type === 'update:complete') {
|
|
261
|
+
return {
|
|
262
|
+
...s,
|
|
263
|
+
updating: false,
|
|
264
|
+
requiresRestart: Boolean((msg as { requiresRestart?: boolean }).requiresRestart),
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
if (msg.type === 'update:log') {
|
|
268
|
+
const m = msg as { pkg: string; line: string };
|
|
269
|
+
const existing = s.perPackage[m.pkg] || { logs: [] };
|
|
270
|
+
return {
|
|
271
|
+
...s,
|
|
272
|
+
perPackage: {
|
|
273
|
+
...s.perPackage,
|
|
274
|
+
[m.pkg]: {
|
|
275
|
+
...existing,
|
|
276
|
+
logs: [...(existing.logs || []).slice(-50), m.line],
|
|
277
|
+
},
|
|
278
|
+
},
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
// update:progress
|
|
282
|
+
const m = msg as { pkg: string; status: PkgStatus['status']; error?: string; newVersion?: string };
|
|
283
|
+
return {
|
|
284
|
+
...s,
|
|
285
|
+
perPackage: {
|
|
286
|
+
...s.perPackage,
|
|
287
|
+
[m.pkg]: {
|
|
288
|
+
...s.perPackage[m.pkg],
|
|
289
|
+
status: m.status,
|
|
290
|
+
error: m.error,
|
|
291
|
+
newVersion: m.newVersion,
|
|
292
|
+
},
|
|
293
|
+
},
|
|
294
|
+
};
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
});
|
|
298
|
+
return () => ws.close();
|
|
299
|
+
}, []);
|
|
300
|
+
|
|
301
|
+
// Load current versions on mount
|
|
302
|
+
useEffect(() => {
|
|
303
|
+
api.get<{ current: Record<string, string | null> }>('/updates/status')
|
|
304
|
+
.then((r) => setStatus((s) => ({ ...s, current: r.current })))
|
|
305
|
+
.catch((e) => setStatus((s) => ({ ...s, error: e.message })));
|
|
306
|
+
}, []);
|
|
307
|
+
|
|
308
|
+
const check = async () => {
|
|
309
|
+
setStatus((s) => ({ ...s, checking: true, error: undefined }));
|
|
310
|
+
try {
|
|
311
|
+
const r = await api.get<{
|
|
312
|
+
current: Record<string, string | null>;
|
|
313
|
+
latest: Record<string, string | null>;
|
|
314
|
+
hasUpdates: boolean;
|
|
315
|
+
}>('/updates/check');
|
|
316
|
+
setStatus((s) => ({
|
|
317
|
+
...s,
|
|
318
|
+
checking: false,
|
|
319
|
+
current: r.current,
|
|
320
|
+
latest: r.latest,
|
|
321
|
+
hasUpdates: r.hasUpdates,
|
|
322
|
+
}));
|
|
323
|
+
} catch (err) {
|
|
324
|
+
setStatus((s) => ({ ...s, checking: false, error: (err as Error).message }));
|
|
325
|
+
}
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
const applyUpdate = async () => {
|
|
329
|
+
if (!confirm('Update Bizar packages? The dashboard will restart automatically.')) return;
|
|
330
|
+
setStatus((s) => ({
|
|
331
|
+
...s,
|
|
332
|
+
updating: true,
|
|
333
|
+
requiresRestart: false,
|
|
334
|
+
perPackage: {},
|
|
335
|
+
error: undefined,
|
|
336
|
+
}));
|
|
337
|
+
try {
|
|
338
|
+
await api.post('/updates/apply', { packages: ['bizar', 'bizar-dash', 'bizar-plugin'] });
|
|
339
|
+
// Progress streams via WebSocket
|
|
340
|
+
} catch (err) {
|
|
341
|
+
setStatus((s) => ({ ...s, updating: false, error: (err as Error).message }));
|
|
342
|
+
}
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
const restart = async () => {
|
|
346
|
+
if (!confirm('Restart the dashboard? You will be disconnected briefly.')) return;
|
|
347
|
+
try {
|
|
348
|
+
await api.post('/restart');
|
|
349
|
+
toast.info('Restarting…', 3000);
|
|
350
|
+
setTimeout(() => window.location.reload(), 3000);
|
|
351
|
+
} catch {
|
|
352
|
+
toast.error('Restart failed');
|
|
353
|
+
}
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
const packages = [
|
|
357
|
+
{ id: 'bizar', name: 'Bizar CLI' },
|
|
358
|
+
{ id: 'bizar-dash', name: 'Dashboard' },
|
|
359
|
+
{ id: 'bizar-plugin', name: 'Opencode Plugin' },
|
|
360
|
+
];
|
|
361
|
+
|
|
362
|
+
const isBusy = status.checking || status.updating;
|
|
363
|
+
|
|
364
|
+
return (
|
|
365
|
+
<Card id="settings-updates" data-section="updates">
|
|
366
|
+
<CardTitle><Download size={14} /> Updates</CardTitle>
|
|
367
|
+
<CardMeta>Check installed Bizar packages and apply dashboard updates.</CardMeta>
|
|
368
|
+
{/* Current versions */}
|
|
369
|
+
<div className="updates-current">
|
|
370
|
+
<h4>Installed versions</h4>
|
|
371
|
+
<ul>
|
|
372
|
+
{packages.map((p) => (
|
|
373
|
+
<li key={p.id}>
|
|
374
|
+
<span>{p.name}</span>
|
|
375
|
+
<code className="mono">{status.current[p.id] || '—'}</code>
|
|
376
|
+
</li>
|
|
377
|
+
))}
|
|
378
|
+
</ul>
|
|
379
|
+
</div>
|
|
380
|
+
|
|
381
|
+
{/* Latest + per-package status */}
|
|
382
|
+
{status.latest && (
|
|
383
|
+
<div className="updates-latest">
|
|
384
|
+
<h4>Latest available</h4>
|
|
385
|
+
<ul>
|
|
386
|
+
{packages.map((p) => {
|
|
387
|
+
const cur = status.current[p.id];
|
|
388
|
+
const lat = status.latest?.[p.id];
|
|
389
|
+
const isOutdated = cur && lat && cur !== lat;
|
|
390
|
+
return (
|
|
391
|
+
<li key={p.id} className={isOutdated ? 'updates-outdated' : 'updates-current-version'}>
|
|
392
|
+
<span>{p.name}</span>
|
|
393
|
+
<code className="mono">
|
|
394
|
+
{lat || '—'}
|
|
395
|
+
{isOutdated && <span className="updates-badge">update available</span>}
|
|
396
|
+
</code>
|
|
397
|
+
</li>
|
|
398
|
+
);
|
|
399
|
+
})}
|
|
400
|
+
</ul>
|
|
401
|
+
</div>
|
|
402
|
+
)}
|
|
403
|
+
|
|
404
|
+
{/* Per-package progress rows (shown while updating) */}
|
|
405
|
+
{status.updating && (
|
|
406
|
+
<div className="updates-progress-rows">
|
|
407
|
+
{packages.map((p) => {
|
|
408
|
+
const pkgStatus = status.perPackage[p.id] || { status: 'idle', logs: [] as string[] };
|
|
409
|
+
return (
|
|
410
|
+
<div key={p.id} className="updates-pkg-row">
|
|
411
|
+
<div className="updates-pkg-row-header">
|
|
412
|
+
<span className="updates-pkg-name">{p.name}</span>
|
|
413
|
+
<div className="updates-pkg-status">
|
|
414
|
+
{pkgStatus.status === 'starting' && <span className="btn-spinner" />}
|
|
415
|
+
{pkgStatus.status === 'installing' && <span className="btn-spinner" />}
|
|
416
|
+
{pkgStatus.status === 'done' && <CheckCircle size={14} className="icon-success" />}
|
|
417
|
+
{pkgStatus.status === 'error' && <AlertCircle size={14} className="icon-error" />}
|
|
418
|
+
<span>{pkgStatus.status}</span>
|
|
419
|
+
{pkgStatus.newVersion && (
|
|
420
|
+
<code className="mono" style={{ fontSize: 11 }}>→ {pkgStatus.newVersion}</code>
|
|
421
|
+
)}
|
|
422
|
+
</div>
|
|
423
|
+
</div>
|
|
424
|
+
{pkgStatus.logs.length > 0 && (
|
|
425
|
+
<details className="updates-pkg-logs">
|
|
426
|
+
<summary>npm output ({pkgStatus.logs.length} lines)</summary>
|
|
427
|
+
<pre>{pkgStatus.logs.join('\n')}</pre>
|
|
428
|
+
</details>
|
|
429
|
+
)}
|
|
430
|
+
{pkgStatus.status === 'error' && pkgStatus.error && (
|
|
431
|
+
<div className="updates-pkg-error">
|
|
432
|
+
<AlertTriangle size={12} /> {pkgStatus.error}
|
|
433
|
+
</div>
|
|
434
|
+
)}
|
|
435
|
+
</div>
|
|
436
|
+
);
|
|
437
|
+
})}
|
|
438
|
+
</div>
|
|
439
|
+
)}
|
|
440
|
+
|
|
441
|
+
{/* Actions */}
|
|
442
|
+
<div className="updates-actions">
|
|
443
|
+
<Button onClick={check} disabled={isBusy}>
|
|
444
|
+
{status.checking ? <span className="btn-spinner" /> : <RefreshCw size={14} />}
|
|
445
|
+
Check for updates
|
|
446
|
+
</Button>
|
|
447
|
+
<Button
|
|
448
|
+
variant="primary"
|
|
449
|
+
onClick={applyUpdate}
|
|
450
|
+
disabled={!status.hasUpdates || status.updating}
|
|
451
|
+
>
|
|
452
|
+
{status.updating ? <span className="btn-spinner" /> : <Download size={14} />}
|
|
453
|
+
{status.updating ? 'Updating…' : (status.hasUpdates ? 'Update now' : 'Up to date')}
|
|
454
|
+
</Button>
|
|
455
|
+
{status.requiresRestart && (
|
|
456
|
+
<Button variant="danger" onClick={restart}>
|
|
457
|
+
<RefreshCw size={14} /> Restart Dashboard
|
|
458
|
+
</Button>
|
|
459
|
+
)}
|
|
460
|
+
</div>
|
|
461
|
+
|
|
462
|
+
{status.error && (
|
|
463
|
+
<div className="updates-error">
|
|
464
|
+
<AlertTriangle size={14} />
|
|
465
|
+
<span>{status.error}</span>
|
|
466
|
+
</div>
|
|
467
|
+
)}
|
|
468
|
+
</Card>
|
|
469
|
+
);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
export function SettingsView({ settings: initial, refreshSnapshot }: Props) {
|
|
473
|
+
const toast = useToast();
|
|
474
|
+
const [settings, setSettings] = useState<Settings>(initial);
|
|
475
|
+
const [dirty, setDirty] = useState(false);
|
|
476
|
+
const [saving, setSaving] = useState(false);
|
|
477
|
+
const [tailscale, setTailscale] = useState<TailscaleStatus | null>(null);
|
|
478
|
+
const [tailscaleDraft, setTailscaleDraft] = useState({ port: 4321, https: true, hostname: '' });
|
|
479
|
+
const [pluginOptions, setPluginOptions] = useState<Record<string, number>>({});
|
|
480
|
+
|
|
481
|
+
useEffect(() => {
|
|
482
|
+
setSettings(initial);
|
|
483
|
+
setDirty(false);
|
|
484
|
+
if (initial.theme) applyThemeTokens(initial.theme);
|
|
485
|
+
}, [initial]);
|
|
486
|
+
|
|
487
|
+
useEffect(() => {
|
|
488
|
+
if (!tailscale) return;
|
|
489
|
+
setTailscaleDraft({
|
|
490
|
+
port: tailscale.settings.port,
|
|
491
|
+
https: tailscale.settings.https !== false,
|
|
492
|
+
hostname: tailscale.settings.hostname || '',
|
|
493
|
+
});
|
|
494
|
+
}, [tailscale]);
|
|
495
|
+
|
|
496
|
+
useEffect(() => {
|
|
497
|
+
api.get<TailscaleStatus>('/tailscale/status').then(setTailscale).catch(() => undefined);
|
|
498
|
+
}, []);
|
|
499
|
+
|
|
500
|
+
useEffect(() => {
|
|
501
|
+
api.get<Record<string, number>>('/settings/plugin-options')
|
|
502
|
+
.then(setPluginOptions)
|
|
503
|
+
.catch(() => { /* not persisted yet — use defaults */ });
|
|
504
|
+
}, []);
|
|
505
|
+
|
|
506
|
+
const patchTheme = (patch: Partial<Settings['theme']>) => {
|
|
507
|
+
setSettings((cur) => {
|
|
508
|
+
const next = { ...cur, theme: { ...cur.theme, ...patch } };
|
|
509
|
+
applyThemeTokens(next.theme);
|
|
510
|
+
return next;
|
|
511
|
+
});
|
|
512
|
+
setDirty(true);
|
|
513
|
+
};
|
|
514
|
+
|
|
515
|
+
const patchUi = (patch: Partial<Settings['ui']>) => {
|
|
516
|
+
setSettings((cur) => ({ ...cur, ui: { ...cur.ui, ...patch } }));
|
|
517
|
+
setDirty(true);
|
|
518
|
+
};
|
|
519
|
+
|
|
520
|
+
const patchTop = <K extends keyof Settings>(key: K, value: Settings[K]) => {
|
|
521
|
+
setSettings((cur) => ({ ...cur, [key]: value }));
|
|
522
|
+
setDirty(true);
|
|
523
|
+
};
|
|
524
|
+
|
|
525
|
+
const patchNotifications = (patch: Partial<Settings['notifications']>) => {
|
|
526
|
+
setSettings((cur) => ({ ...cur, notifications: { ...cur.notifications, ...patch } }));
|
|
527
|
+
setDirty(true);
|
|
528
|
+
};
|
|
529
|
+
|
|
530
|
+
const patchAgents = (patch: Partial<Settings['agents']>) => {
|
|
531
|
+
setSettings((cur) => ({ ...cur, agents: { ...cur.agents, ...patch } }));
|
|
532
|
+
setDirty(true);
|
|
533
|
+
};
|
|
534
|
+
|
|
535
|
+
// v3.17.0 — Settings subnav as a filter (not just scroll-to).
|
|
536
|
+
// Clicking a subnav button shows ONLY that section; clicking "All"
|
|
537
|
+
// restores the full layout. Anchors still work for deep-links but
|
|
538
|
+
// the visible content is filtered, not just scrolled.
|
|
539
|
+
//
|
|
540
|
+
// v3.18.0 — IDs use the section NAME (not the `settings-X` element id)
|
|
541
|
+
// because the inline-style wrap compares against the section name, not
|
|
542
|
+
// the prefixed DOM id.
|
|
543
|
+
const SECTION_LINKS: Array<{ id: string; label: string }> = [
|
|
544
|
+
{ id: 'theme', label: 'Theme' },
|
|
545
|
+
{ id: 'layout', label: 'Layout' },
|
|
546
|
+
{ id: 'general', label: 'General' },
|
|
547
|
+
{ id: 'service', label: 'Service' },
|
|
548
|
+
{ id: 'tailscale', label: 'Tailscale' },
|
|
549
|
+
{ id: 'notifications', label: 'Notifications' },
|
|
550
|
+
{ id: 'auth', label: 'Auth' },
|
|
551
|
+
{ id: 'agents', label: 'Agents' },
|
|
552
|
+
{ id: 'dashboard', label: 'Dashboard' },
|
|
553
|
+
{ id: 'background', label: 'Background' },
|
|
554
|
+
{ id: 'system-llm', label: 'System LLM' },
|
|
555
|
+
{ id: 'updates', label: 'Updates' },
|
|
556
|
+
{ id: 'activity-log', label: 'Activity' },
|
|
557
|
+
{ id: 'about', label: 'About' },
|
|
558
|
+
];
|
|
559
|
+
const [activeSection, setActiveSection] = useState<string | null>(() => {
|
|
560
|
+
// Honour #settings-... deep-link on initial mount, otherwise null = All.
|
|
561
|
+
if (typeof window === 'undefined') return null;
|
|
562
|
+
const hash = window.location.hash.replace(/^#settings-/, '');
|
|
563
|
+
return SECTION_LINKS.some((s) => s.id === hash) ? hash : null;
|
|
564
|
+
});
|
|
565
|
+
const onJumpSection = (id: string | null) => {
|
|
566
|
+
setActiveSection(id);
|
|
567
|
+
try {
|
|
568
|
+
const url = id ? `#settings-${id}` : window.location.pathname;
|
|
569
|
+
history.replaceState(null, '', url);
|
|
570
|
+
if (id) {
|
|
571
|
+
const el = document.getElementById(`settings-${id}`);
|
|
572
|
+
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
573
|
+
} else {
|
|
574
|
+
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
575
|
+
}
|
|
576
|
+
} catch { /* ignore */ }
|
|
577
|
+
};
|
|
578
|
+
// The user picks a section via the subnav; their choice is respected.
|
|
579
|
+
// (v3.18.0 — Removed auto-scroll-based detection; with the inline-style
|
|
580
|
+
// filter approach, hidden sections still report bounding rects, so the
|
|
581
|
+
// detector would race the user's explicit selection and switch tabs back.)
|
|
582
|
+
|
|
583
|
+
const patchDashboard = (patch: Partial<Settings['dashboard']>) => {
|
|
584
|
+
setSettings((cur) => ({ ...cur, dashboard: { ...cur.dashboard, ...patch } }));
|
|
585
|
+
setDirty(true);
|
|
586
|
+
};
|
|
587
|
+
|
|
588
|
+
const onSave = async () => {
|
|
589
|
+
setSaving(true);
|
|
590
|
+
try {
|
|
591
|
+
const r = await api.put<SettingsResponse>('/settings', settings);
|
|
592
|
+
setSettings(r.data);
|
|
593
|
+
setDirty(false);
|
|
594
|
+
applyTheme(r.data.theme);
|
|
595
|
+
applyThemeTokens(r.data.theme);
|
|
596
|
+
toast.success('Settings saved.');
|
|
597
|
+
await refreshSnapshot();
|
|
598
|
+
} catch (err) {
|
|
599
|
+
toast.error(`Save failed: ${(err as Error).message}`);
|
|
600
|
+
} finally {
|
|
601
|
+
setSaving(false);
|
|
602
|
+
}
|
|
603
|
+
};
|
|
604
|
+
|
|
605
|
+
const onReload = async () => {
|
|
606
|
+
try {
|
|
607
|
+
const r = await api.get<SettingsResponse>('/settings');
|
|
608
|
+
setSettings(r.data);
|
|
609
|
+
setDirty(false);
|
|
610
|
+
applyTheme(r.data.theme);
|
|
611
|
+
applyThemeTokens(r.data.theme);
|
|
612
|
+
toast.info('Settings reloaded.', 1500);
|
|
613
|
+
} catch (err) {
|
|
614
|
+
toast.error(`Reload failed: ${(err as Error).message}`);
|
|
615
|
+
}
|
|
616
|
+
};
|
|
617
|
+
|
|
618
|
+
const onReset = async () => {
|
|
619
|
+
if (!confirm('Reset all settings to defaults?')) return;
|
|
620
|
+
try {
|
|
621
|
+
const r = await api.post<SettingsResponse>('/settings/reset');
|
|
622
|
+
setSettings(r.data);
|
|
623
|
+
setDirty(false);
|
|
624
|
+
applyTheme(r.data.theme);
|
|
625
|
+
applyThemeTokens(r.data.theme);
|
|
626
|
+
toast.success('Settings reset.');
|
|
627
|
+
await refreshSnapshot();
|
|
628
|
+
} catch (err) {
|
|
629
|
+
toast.error(`Reset failed: ${(err as Error).message}`);
|
|
630
|
+
}
|
|
631
|
+
};
|
|
632
|
+
|
|
633
|
+
const onTailscaleToggle = async () => {
|
|
634
|
+
try {
|
|
635
|
+
if (tailscale?.settings.enabled) {
|
|
636
|
+
await api.post('/tailscale/disable');
|
|
637
|
+
toast.success('Tailscale serve disabled.');
|
|
638
|
+
} else {
|
|
639
|
+
await api.post('/tailscale/enable', {
|
|
640
|
+
port: tailscaleDraft.port || 4321,
|
|
641
|
+
https: tailscaleDraft.https,
|
|
642
|
+
hostname: tailscaleDraft.hostname || '',
|
|
643
|
+
});
|
|
644
|
+
toast.success('Tailscale serve enabled.');
|
|
645
|
+
}
|
|
646
|
+
const r = await api.get<TailscaleStatus>('/tailscale/status');
|
|
647
|
+
setTailscale(r);
|
|
648
|
+
} catch (err) {
|
|
649
|
+
toast.error(`Tailscale failed: ${(err as Error).message}`);
|
|
650
|
+
}
|
|
651
|
+
};
|
|
652
|
+
|
|
653
|
+
const about = settings.about || {
|
|
654
|
+
version: '3.0.4',
|
|
655
|
+
homepage: 'https://github.com/DrB0rk/BizarHarness',
|
|
656
|
+
license: 'MIT',
|
|
657
|
+
};
|
|
658
|
+
|
|
659
|
+
// v3.6.0 — Auth token UI state. The Settings tab is the only place
|
|
660
|
+
// the operator can read the token back out. We default the input
|
|
661
|
+
// to whatever's already in localStorage so a page reload doesn't
|
|
662
|
+
// wipe the entry.
|
|
663
|
+
const [authToken, setAuthToken] = useState<string>(api.getToken());
|
|
664
|
+
const [authStatus, setAuthStatus] = useState<{ required: boolean; loopback: boolean; peer: string } | null>(null);
|
|
665
|
+
const [revealedToken, setRevealedToken] = useState<string>('');
|
|
666
|
+
|
|
667
|
+
// Probe the server once on mount so we can show "auth required" vs
|
|
668
|
+
// "auth off" in the UI. If /api/auth/status itself 401s, the token
|
|
669
|
+
// we're using is bad — surface that distinctly.
|
|
670
|
+
useEffect(() => {
|
|
671
|
+
let cancelled = false;
|
|
672
|
+
(async () => {
|
|
673
|
+
try {
|
|
674
|
+
const r = await api.probeAuthStatus();
|
|
675
|
+
if (!cancelled) setAuthStatus(r);
|
|
676
|
+
} catch {
|
|
677
|
+
if (!cancelled) {
|
|
678
|
+
setAuthStatus({ required: true, loopback: false, peer: '' });
|
|
679
|
+
// Don't toast — the toast spam would be annoying on every
|
|
680
|
+
// Settings tab open. The Copy/Regenerate buttons themselves
|
|
681
|
+
// surface the real error if it happens there.
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
})();
|
|
685
|
+
return () => { cancelled = true; };
|
|
686
|
+
}, []);
|
|
687
|
+
|
|
688
|
+
const onCopyToken = async () => {
|
|
689
|
+
try {
|
|
690
|
+
const r = await api.get<{ token: string }>('/auth/reveal');
|
|
691
|
+
setRevealedToken(r.token);
|
|
692
|
+
setAuthToken(r.token);
|
|
693
|
+
api.setToken(r.token);
|
|
694
|
+
try {
|
|
695
|
+
await navigator.clipboard.writeText(r.token);
|
|
696
|
+
toast.success('Token copied to clipboard.');
|
|
697
|
+
} catch {
|
|
698
|
+
toast.success('Token revealed — copy from the field below.');
|
|
699
|
+
}
|
|
700
|
+
} catch (err) {
|
|
701
|
+
toast.error(`Reveal failed: ${(err as Error).message}`);
|
|
702
|
+
}
|
|
703
|
+
};
|
|
704
|
+
|
|
705
|
+
const onRegenerateToken = async () => {
|
|
706
|
+
if (!confirm('Regenerate the auth token? Anything still using the old token will start getting 401 errors immediately.')) {
|
|
707
|
+
return;
|
|
708
|
+
}
|
|
709
|
+
try {
|
|
710
|
+
const r = await api.post<{ token: string }>('/auth/regenerate');
|
|
711
|
+
setRevealedToken(r.token);
|
|
712
|
+
setAuthToken(r.token);
|
|
713
|
+
api.setToken(r.token);
|
|
714
|
+
try {
|
|
715
|
+
await navigator.clipboard.writeText(r.token);
|
|
716
|
+
toast.success('New token generated and copied to clipboard.');
|
|
717
|
+
} catch {
|
|
718
|
+
toast.success('New token generated — copy from the field below. Old token is now invalid.');
|
|
719
|
+
}
|
|
720
|
+
} catch (err) {
|
|
721
|
+
toast.error(`Regenerate failed: ${(err as Error).message}`);
|
|
722
|
+
}
|
|
723
|
+
};
|
|
724
|
+
|
|
725
|
+
const onSaveToken = () => {
|
|
726
|
+
api.setToken(authToken.trim());
|
|
727
|
+
toast.success('Token saved. The dashboard will use it on the next request.');
|
|
728
|
+
};
|
|
729
|
+
|
|
730
|
+
return (
|
|
731
|
+
<div className="view view-settings">
|
|
732
|
+
<header className="view-header">
|
|
733
|
+
<div className="view-header-text">
|
|
734
|
+
<h2 className="view-title">
|
|
735
|
+
<Sliders size={18} /> Settings
|
|
736
|
+
</h2>
|
|
737
|
+
<p className="view-subtitle">
|
|
738
|
+
Personal preferences. Changes are saved to{' '}
|
|
739
|
+
<code>~/.config/bizar/settings.json</code>.
|
|
740
|
+
</p>
|
|
741
|
+
</div>
|
|
742
|
+
<div className="view-actions">
|
|
743
|
+
<Button variant="ghost" size="sm" onClick={onReset}>
|
|
744
|
+
<RotateCcw size={14} /> Reset
|
|
745
|
+
</Button>
|
|
746
|
+
<Button variant="secondary" size="sm" onClick={onReload}>
|
|
747
|
+
<RefreshCw size={14} /> Reload
|
|
748
|
+
</Button>
|
|
749
|
+
<Button
|
|
750
|
+
variant="primary"
|
|
751
|
+
size="sm"
|
|
752
|
+
disabled={!dirty || saving}
|
|
753
|
+
onClick={onSave}
|
|
754
|
+
>
|
|
755
|
+
{saving ? <span className="btn-spinner" /> : <Save size={14} />}
|
|
756
|
+
{saving ? 'Saving…' : 'Save'}
|
|
757
|
+
</Button>
|
|
758
|
+
</div>
|
|
759
|
+
</header>
|
|
760
|
+
|
|
761
|
+
<nav className="settings-subnav" aria-label="Settings sections">
|
|
762
|
+
<button
|
|
763
|
+
type="button"
|
|
764
|
+
className={cn('settings-subnav-button', 'settings-subnav-button-all', activeSection === null && 'settings-subnav-button-active')}
|
|
765
|
+
onClick={() => onJumpSection(null)}
|
|
766
|
+
title="Show all settings sections"
|
|
767
|
+
>
|
|
768
|
+
All
|
|
769
|
+
</button>
|
|
770
|
+
{SECTION_LINKS.map((s) => (
|
|
771
|
+
<button
|
|
772
|
+
key={s.id}
|
|
773
|
+
type="button"
|
|
774
|
+
className={cn('settings-subnav-button', activeSection === s.id && 'settings-subnav-button-active')}
|
|
775
|
+
onClick={() => onJumpSection(s.id)}
|
|
776
|
+
>
|
|
777
|
+
{s.label}
|
|
778
|
+
</button>
|
|
779
|
+
))}
|
|
780
|
+
</nav>
|
|
781
|
+
|
|
782
|
+
<div
|
|
783
|
+
className={cn('settings-grid', activeSection && 'settings-grid-filtered')}
|
|
784
|
+
data-active-section={activeSection || undefined}
|
|
785
|
+
>
|
|
786
|
+
{/* When a section is active, render a banner above the grid so
|
|
787
|
+
the user knows they're seeing a filtered view. */}
|
|
788
|
+
{activeSection && (
|
|
789
|
+
<div className="settings-filter-banner">
|
|
790
|
+
<span>
|
|
791
|
+
Showing only <strong>{SECTION_LINKS.find((s) => s.id === activeSection)?.label}</strong>.
|
|
792
|
+
</span>
|
|
793
|
+
<button
|
|
794
|
+
type="button"
|
|
795
|
+
className="settings-filter-clear"
|
|
796
|
+
onClick={() => onJumpSection(null)}
|
|
797
|
+
>
|
|
798
|
+
Show all sections
|
|
799
|
+
</button>
|
|
800
|
+
</div>
|
|
801
|
+
)}
|
|
802
|
+
<div data-section="theme" style={{display: (activeSection === null || activeSection === 'theme') ? 'block' : 'none'}}>
|
|
803
|
+
<Card id="settings-theme" data-section="theme">
|
|
804
|
+
<CardTitle><Palette size={14} /> Theme</CardTitle>
|
|
805
|
+
<CardMeta>Mode, accent, and colors. Live preview as you tweak.</CardMeta>
|
|
806
|
+
|
|
807
|
+
{/* v3.3.1 — Accent color presets */}
|
|
808
|
+
<div className="field" data-setting-id="theme.presets">
|
|
809
|
+
<label className="field-label">Accent presets</label>
|
|
810
|
+
<div className="theme-presets">
|
|
811
|
+
{PRESET_THEMES.map((t) => (
|
|
812
|
+
<button
|
|
813
|
+
key={t.name}
|
|
814
|
+
type="button"
|
|
815
|
+
className={cn(
|
|
816
|
+
'theme-preset',
|
|
817
|
+
settings.theme.accent === t.accent && 'theme-preset-active',
|
|
818
|
+
)}
|
|
819
|
+
onClick={() => patchTheme({ accent: t.accent })}
|
|
820
|
+
title={t.name}
|
|
821
|
+
>
|
|
822
|
+
<span
|
|
823
|
+
className="theme-preset-swatch"
|
|
824
|
+
style={{ background: t.accent }}
|
|
825
|
+
/>
|
|
826
|
+
<span className="theme-preset-name">{t.name}</span>
|
|
827
|
+
</button>
|
|
828
|
+
))}
|
|
829
|
+
</div>
|
|
830
|
+
</div>
|
|
831
|
+
|
|
832
|
+
<div className="field" data-setting-id="theme.mode">
|
|
833
|
+
<label className="field-label">Mode</label>
|
|
834
|
+
<div className="theme-row">
|
|
835
|
+
{THEMES.map(({ id, label, Icon }) => {
|
|
836
|
+
const active = settings.theme.mode === id;
|
|
837
|
+
return (
|
|
838
|
+
<button
|
|
839
|
+
key={id}
|
|
840
|
+
type="button"
|
|
841
|
+
className={cn('theme-card', active && 'theme-card-active')}
|
|
842
|
+
onClick={() => patchTheme({ mode: id })}
|
|
843
|
+
>
|
|
844
|
+
<Icon size={16} />
|
|
845
|
+
<span className="theme-card-label">{label}</span>
|
|
846
|
+
<span
|
|
847
|
+
className={cn(
|
|
848
|
+
'theme-card-swatch',
|
|
849
|
+
`theme-card-swatch-${id}`,
|
|
850
|
+
)}
|
|
851
|
+
/>
|
|
852
|
+
</button>
|
|
853
|
+
);
|
|
854
|
+
})}
|
|
855
|
+
</div>
|
|
856
|
+
</div>
|
|
857
|
+
|
|
858
|
+
<div className="theme-colors">
|
|
859
|
+
<div className="field" data-setting-id="theme.accent">
|
|
860
|
+
<label className="field-label">Accent</label>
|
|
861
|
+
<div className="color-row">
|
|
862
|
+
<input
|
|
863
|
+
type="color"
|
|
864
|
+
className="input color-input"
|
|
865
|
+
value={settings.theme.accent}
|
|
866
|
+
onChange={(e) => patchTheme({ accent: e.target.value })}
|
|
867
|
+
aria-label="Accent color"
|
|
868
|
+
/>
|
|
869
|
+
<input
|
|
870
|
+
type="text"
|
|
871
|
+
className="input"
|
|
872
|
+
value={settings.theme.accent}
|
|
873
|
+
onChange={(e) => patchTheme({ accent: e.target.value })}
|
|
874
|
+
/>
|
|
875
|
+
</div>
|
|
876
|
+
</div>
|
|
877
|
+
<div className="field" data-setting-id="theme.success">
|
|
878
|
+
<label className="field-label">Success</label>
|
|
879
|
+
<div className="color-row">
|
|
880
|
+
<input
|
|
881
|
+
type="color"
|
|
882
|
+
className="input color-input"
|
|
883
|
+
value={settings.theme.success}
|
|
884
|
+
onChange={(e) => patchTheme({ success: e.target.value })}
|
|
885
|
+
aria-label="Success color"
|
|
886
|
+
/>
|
|
887
|
+
<input
|
|
888
|
+
type="text"
|
|
889
|
+
className="input"
|
|
890
|
+
value={settings.theme.success}
|
|
891
|
+
onChange={(e) => patchTheme({ success: e.target.value })}
|
|
892
|
+
/>
|
|
893
|
+
</div>
|
|
894
|
+
</div>
|
|
895
|
+
<div className="field" data-setting-id="theme.warning">
|
|
896
|
+
<label className="field-label">Warning</label>
|
|
897
|
+
<div className="color-row">
|
|
898
|
+
<input
|
|
899
|
+
type="color"
|
|
900
|
+
className="input color-input"
|
|
901
|
+
value={settings.theme.warning}
|
|
902
|
+
onChange={(e) => patchTheme({ warning: e.target.value })}
|
|
903
|
+
aria-label="Warning color"
|
|
904
|
+
/>
|
|
905
|
+
<input
|
|
906
|
+
type="text"
|
|
907
|
+
className="input"
|
|
908
|
+
value={settings.theme.warning}
|
|
909
|
+
onChange={(e) => patchTheme({ warning: e.target.value })}
|
|
910
|
+
/>
|
|
911
|
+
</div>
|
|
912
|
+
</div>
|
|
913
|
+
<div className="field" data-setting-id="theme.error">
|
|
914
|
+
<label className="field-label">Error</label>
|
|
915
|
+
<div className="color-row">
|
|
916
|
+
<input
|
|
917
|
+
type="color"
|
|
918
|
+
className="input color-input"
|
|
919
|
+
value={settings.theme.error}
|
|
920
|
+
onChange={(e) => patchTheme({ error: e.target.value })}
|
|
921
|
+
aria-label="Error color"
|
|
922
|
+
/>
|
|
923
|
+
<input
|
|
924
|
+
type="text"
|
|
925
|
+
className="input"
|
|
926
|
+
value={settings.theme.error}
|
|
927
|
+
onChange={(e) => patchTheme({ error: e.target.value })}
|
|
928
|
+
/>
|
|
929
|
+
</div>
|
|
930
|
+
</div>
|
|
931
|
+
<div className="field" data-setting-id="theme.info">
|
|
932
|
+
<label className="field-label">Info</label>
|
|
933
|
+
<div className="color-row">
|
|
934
|
+
<input
|
|
935
|
+
type="color"
|
|
936
|
+
className="input color-input"
|
|
937
|
+
value={settings.theme.info}
|
|
938
|
+
onChange={(e) => patchTheme({ info: e.target.value })}
|
|
939
|
+
aria-label="Info color"
|
|
940
|
+
/>
|
|
941
|
+
<input
|
|
942
|
+
type="text"
|
|
943
|
+
className="input"
|
|
944
|
+
value={settings.theme.info}
|
|
945
|
+
onChange={(e) => patchTheme({ info: e.target.value })}
|
|
946
|
+
/>
|
|
947
|
+
</div>
|
|
948
|
+
</div>
|
|
949
|
+
</div>
|
|
950
|
+
|
|
951
|
+
<div className="field" data-setting-id="theme.fontFamily">
|
|
952
|
+
<label className="field-label">Font family</label>
|
|
953
|
+
<select
|
|
954
|
+
className="select"
|
|
955
|
+
value={settings.theme.fontFamily}
|
|
956
|
+
onChange={(e) => patchTheme({ fontFamily: e.target.value })}
|
|
957
|
+
>
|
|
958
|
+
{FONT_FAMILIES.map((f) => (
|
|
959
|
+
<option key={f} value={f}>{f}</option>
|
|
960
|
+
))}
|
|
961
|
+
</select>
|
|
962
|
+
</div>
|
|
963
|
+
<div className="field" data-setting-id="theme.fontSize">
|
|
964
|
+
<label className="field-label">Font size: {settings.theme.fontSize}px</label>
|
|
965
|
+
<input
|
|
966
|
+
type="range"
|
|
967
|
+
min={12}
|
|
968
|
+
max={20}
|
|
969
|
+
value={settings.theme.fontSize}
|
|
970
|
+
onChange={(e) => patchTheme({ fontSize: Number(e.target.value) })}
|
|
971
|
+
/>
|
|
972
|
+
</div>
|
|
973
|
+
<label className="checkbox-row" data-setting-id="theme.compactMode">
|
|
974
|
+
<input
|
|
975
|
+
type="checkbox"
|
|
976
|
+
checked={settings.theme.compactMode}
|
|
977
|
+
onChange={(e) => patchTheme({ compactMode: e.target.checked })}
|
|
978
|
+
/>
|
|
979
|
+
<span>Compact mode (denser UI)</span>
|
|
980
|
+
</label>
|
|
981
|
+
<label className="checkbox-row" data-setting-id="theme.animations">
|
|
982
|
+
<input
|
|
983
|
+
type="checkbox"
|
|
984
|
+
checked={settings.theme.animations}
|
|
985
|
+
onChange={(e) => patchTheme({ animations: e.target.checked })}
|
|
986
|
+
/>
|
|
987
|
+
<span>Enable animations</span>
|
|
988
|
+
</label>
|
|
989
|
+
</Card>
|
|
990
|
+
</div>
|
|
991
|
+
|
|
992
|
+
<div data-section="updates" style={{display: (activeSection === null || activeSection === 'updates') ? 'block' : 'none'}}>
|
|
993
|
+
<UpdatesCard />
|
|
994
|
+
</div>
|
|
995
|
+
|
|
996
|
+
<div data-section="layout" style={{display: (activeSection === null || activeSection === 'layout') ? 'block' : 'none'}}>
|
|
997
|
+
<Card id="settings-layout" data-section="layout">
|
|
998
|
+
<CardTitle><LayoutIcon size={14} /> UI layout</CardTitle>
|
|
999
|
+
<CardMeta>Choose how the dashboard's navigation is presented.</CardMeta>
|
|
1000
|
+
<div className="layout-row" data-setting-id="ui.layout">
|
|
1001
|
+
{LAYOUTS.map((l) => (
|
|
1002
|
+
<button
|
|
1003
|
+
key={l.id}
|
|
1004
|
+
type="button"
|
|
1005
|
+
className={cn('layout-card', settings.ui.layout === l.id && 'layout-card-active')}
|
|
1006
|
+
onClick={() => patchUi({ layout: l.id })}
|
|
1007
|
+
>
|
|
1008
|
+
<span className="layout-card-label">{l.label}</span>
|
|
1009
|
+
</button>
|
|
1010
|
+
))}
|
|
1011
|
+
</div>
|
|
1012
|
+
<label className="checkbox-row" data-setting-id="ui.showHeader">
|
|
1013
|
+
<input
|
|
1014
|
+
type="checkbox"
|
|
1015
|
+
checked={settings.ui.showHeader}
|
|
1016
|
+
onChange={(e) => patchUi({ showHeader: e.target.checked })}
|
|
1017
|
+
/>
|
|
1018
|
+
<span>Show header</span>
|
|
1019
|
+
</label>
|
|
1020
|
+
<label className="checkbox-row" data-setting-id="ui.showStatusBar">
|
|
1021
|
+
<input
|
|
1022
|
+
type="checkbox"
|
|
1023
|
+
checked={settings.ui.showStatusBar}
|
|
1024
|
+
onChange={(e) => patchUi({ showStatusBar: e.target.checked })}
|
|
1025
|
+
/>
|
|
1026
|
+
<span>Show status bar</span>
|
|
1027
|
+
</label>
|
|
1028
|
+
<div className="field" data-setting-id="ui.defaultTab">
|
|
1029
|
+
<label className="field-label">Default tab</label>
|
|
1030
|
+
<select
|
|
1031
|
+
className="select"
|
|
1032
|
+
value={settings.ui.defaultTab}
|
|
1033
|
+
onChange={(e) => patchUi({ defaultTab: e.target.value })}
|
|
1034
|
+
>
|
|
1035
|
+
<option value="overview">Overview</option>
|
|
1036
|
+
<option value="chat">Chat</option>
|
|
1037
|
+
<option value="agents">Agents</option>
|
|
1038
|
+
<option value="artifacts">Plans</option>
|
|
1039
|
+
<option value="projects">Projects</option>
|
|
1040
|
+
<option value="tasks">Tasks</option>
|
|
1041
|
+
<option value="config">Config</option>
|
|
1042
|
+
<option value="settings">Settings</option>
|
|
1043
|
+
<option value="mods">Mods</option>
|
|
1044
|
+
<option value="schedules">Schedules</option>
|
|
1045
|
+
</select>
|
|
1046
|
+
</div>
|
|
1047
|
+
</Card>
|
|
1048
|
+
</div>
|
|
1049
|
+
|
|
1050
|
+
<div data-section="general" style={{display: (activeSection === null || activeSection === 'general') ? 'block' : 'none'}}>
|
|
1051
|
+
<Card id="settings-general" data-section="general">
|
|
1052
|
+
<CardTitle>General</CardTitle>
|
|
1053
|
+
<CardMeta>Default agent + model override.</CardMeta>
|
|
1054
|
+
<div className="field" data-setting-id="defaultAgent">
|
|
1055
|
+
<label className="field-label" htmlFor="set-default-agent">Default agent</label>
|
|
1056
|
+
<input
|
|
1057
|
+
id="set-default-agent"
|
|
1058
|
+
className="input"
|
|
1059
|
+
type="text"
|
|
1060
|
+
placeholder="e.g. odin"
|
|
1061
|
+
value={settings.defaultAgent || ''}
|
|
1062
|
+
onChange={(e) => patchTop('defaultAgent', e.target.value)}
|
|
1063
|
+
/>
|
|
1064
|
+
</div>
|
|
1065
|
+
<div className="field" data-setting-id="defaultModel">
|
|
1066
|
+
<label className="field-label" htmlFor="set-default-model">Model override</label>
|
|
1067
|
+
<input
|
|
1068
|
+
id="set-default-model"
|
|
1069
|
+
className="input"
|
|
1070
|
+
type="text"
|
|
1071
|
+
placeholder="(leave empty for provider default)"
|
|
1072
|
+
value={settings.defaultModel || ''}
|
|
1073
|
+
onChange={(e) => patchTop('defaultModel', e.target.value)}
|
|
1074
|
+
/>
|
|
1075
|
+
</div>
|
|
1076
|
+
</Card>
|
|
1077
|
+
</div>
|
|
1078
|
+
|
|
1079
|
+
<div data-section="service" style={{display: (activeSection === null || activeSection === 'service') ? 'block' : 'none'}}>
|
|
1080
|
+
<Card id="settings-service" data-section="service">
|
|
1081
|
+
<CardTitle><ServerIcon size={14} /> Service</CardTitle>
|
|
1082
|
+
<CardMeta>Background daemon that runs schedules.</CardMeta>
|
|
1083
|
+
<div data-setting-id="service.enabled">
|
|
1084
|
+
{tailscale ? (
|
|
1085
|
+
<div className="service-card">
|
|
1086
|
+
<p>
|
|
1087
|
+
Status: <strong>{tailscale?.settings.enabled ? 'enabled' : 'disabled'}</strong>
|
|
1088
|
+
{' '}· Tailscale installed: <strong>{tailscale.installed ? 'yes' : 'no'}</strong>
|
|
1089
|
+
{' '}· authenticated: <strong>{tailscale.authenticated ? 'yes' : 'no'}</strong>
|
|
1090
|
+
</p>
|
|
1091
|
+
<p className="muted">
|
|
1092
|
+
Use <code>bizar service start</code> / <code>bizar service stop</code> in
|
|
1093
|
+
your terminal to control the daemon.
|
|
1094
|
+
</p>
|
|
1095
|
+
</div>
|
|
1096
|
+
) : (
|
|
1097
|
+
<p className="muted">Loading service status…</p>
|
|
1098
|
+
)}
|
|
1099
|
+
</div>
|
|
1100
|
+
</Card>
|
|
1101
|
+
</div>
|
|
1102
|
+
|
|
1103
|
+
<div data-section="tailscale" style={{display: (activeSection === null || activeSection === 'tailscale') ? 'block' : 'none'}}>
|
|
1104
|
+
<Card id="settings-tailscale" data-section="tailscale">
|
|
1105
|
+
<CardTitle><Plug size={14} /> Tailscale serve</CardTitle>
|
|
1106
|
+
<CardMeta>Expose the dashboard over your Tailscale network.</CardMeta>
|
|
1107
|
+
{tailscale ? (
|
|
1108
|
+
<>
|
|
1109
|
+
<p>
|
|
1110
|
+
Installed: <strong>{tailscale.installed ? 'yes' : 'no'}</strong>{' '}
|
|
1111
|
+
{tailscale.version && <span className="muted">({tailscale.version})</span>}
|
|
1112
|
+
</p>
|
|
1113
|
+
<p>
|
|
1114
|
+
Authenticated: <strong>{tailscale.authenticated ? 'yes' : 'no'}</strong>
|
|
1115
|
+
</p>
|
|
1116
|
+
<p>
|
|
1117
|
+
Serve enabled: <strong>{tailscale.settings.enabled ? 'yes' : 'no'}</strong>
|
|
1118
|
+
</p>
|
|
1119
|
+
<div className="task-form-row">
|
|
1120
|
+
<div className="task-form-field">
|
|
1121
|
+
<label className="field-label">Port</label>
|
|
1122
|
+
<input
|
|
1123
|
+
type="number"
|
|
1124
|
+
className="input"
|
|
1125
|
+
value={tailscaleDraft.port}
|
|
1126
|
+
onChange={(e) => setTailscaleDraft((cur) => ({ ...cur, port: Number(e.target.value) || 4321 }))}
|
|
1127
|
+
/>
|
|
1128
|
+
</div>
|
|
1129
|
+
<div className="task-form-field">
|
|
1130
|
+
<label className="field-label">Use HTTPS</label>
|
|
1131
|
+
<input
|
|
1132
|
+
type="checkbox"
|
|
1133
|
+
checked={tailscaleDraft.https}
|
|
1134
|
+
onChange={(e) => setTailscaleDraft((cur) => ({ ...cur, https: e.target.checked }))}
|
|
1135
|
+
/>
|
|
1136
|
+
</div>
|
|
1137
|
+
</div>
|
|
1138
|
+
<Button variant="primary" onClick={onTailscaleToggle}>
|
|
1139
|
+
{tailscale.settings.enabled ? 'Disable serve' : 'Enable serve'}
|
|
1140
|
+
</Button>
|
|
1141
|
+
</>
|
|
1142
|
+
) : (
|
|
1143
|
+
<p className="muted">Loading Tailscale status…</p>
|
|
1144
|
+
)}
|
|
1145
|
+
</Card>
|
|
1146
|
+
</div>
|
|
1147
|
+
|
|
1148
|
+
<div data-section="notifications" style={{display: (activeSection === null || activeSection === 'notifications') ? 'block' : 'none'}}>
|
|
1149
|
+
<Card id="settings-notifications" data-section="notifications">
|
|
1150
|
+
<CardTitle>Notifications</CardTitle>
|
|
1151
|
+
<CardMeta>Toast triggers inside the dashboard.</CardMeta>
|
|
1152
|
+
<label className="checkbox-row" data-setting-id="notifications.onAgentComplete">
|
|
1153
|
+
<input
|
|
1154
|
+
type="checkbox"
|
|
1155
|
+
checked={!!settings.notifications.onAgentComplete}
|
|
1156
|
+
onChange={(e) => patchNotifications({ onAgentComplete: e.target.checked })}
|
|
1157
|
+
/>
|
|
1158
|
+
<span>Notify when an agent invocation completes</span>
|
|
1159
|
+
</label>
|
|
1160
|
+
<label className="checkbox-row" data-setting-id="notifications.onPlanApproval">
|
|
1161
|
+
<input
|
|
1162
|
+
type="checkbox"
|
|
1163
|
+
checked={!!settings.notifications.onPlanApproval}
|
|
1164
|
+
onChange={(e) => patchNotifications({ onPlanApproval: e.target.checked })}
|
|
1165
|
+
/>
|
|
1166
|
+
<span>Notify when a plan needs approval</span>
|
|
1167
|
+
</label>
|
|
1168
|
+
</Card>
|
|
1169
|
+
</div>
|
|
1170
|
+
|
|
1171
|
+
{/* v3.6.0 — Auth token management. The Settings tab is the
|
|
1172
|
+
only place a human can read the token. The input + Save
|
|
1173
|
+
button lets the operator paste a token they got from
|
|
1174
|
+
server stderr or from another machine. Copy / Regenerate
|
|
1175
|
+
buttons act via the authed /api/auth/* endpoints. */}
|
|
1176
|
+
<div data-section="auth" style={{display: (activeSection === null || activeSection === 'auth') ? 'block' : 'none'}}>
|
|
1177
|
+
<Card id="settings-auth" data-section="auth">
|
|
1178
|
+
<CardTitle><Shield size={14} /> Authentication</CardTitle>
|
|
1179
|
+
<CardMeta>
|
|
1180
|
+
Localhost and Tailscale browser access are auto-trusted via loopback.
|
|
1181
|
+
A bearer token is still available for non-loopback clients and
|
|
1182
|
+
forced-auth mode.
|
|
1183
|
+
</CardMeta>
|
|
1184
|
+
<div className="field" data-setting-id="auth.status">
|
|
1185
|
+
<label className="field-label">Server status</label>
|
|
1186
|
+
<p style={{ margin: '4px 0' }}>
|
|
1187
|
+
Auth required:{' '}
|
|
1188
|
+
<strong>{authStatus ? (authStatus.required ? 'yes' : 'no') : 'probing…'}</strong>
|
|
1189
|
+
</p>
|
|
1190
|
+
<p style={{ margin: '4px 0' }}>
|
|
1191
|
+
Connection:{' '}
|
|
1192
|
+
<strong>
|
|
1193
|
+
{authStatus
|
|
1194
|
+
? (authStatus.loopback ? 'loopback (auto-trusted)' : 'remote')
|
|
1195
|
+
: 'probing…'}
|
|
1196
|
+
</strong>
|
|
1197
|
+
</p>
|
|
1198
|
+
<p style={{ margin: '4px 0' }}>
|
|
1199
|
+
Peer address:{' '}
|
|
1200
|
+
{authStatus?.peer ? <code>{authStatus.peer}</code> : <span className="muted">probing…</span>}
|
|
1201
|
+
</p>
|
|
1202
|
+
<p className="muted" style={{ fontSize: 12, margin: '4px 0' }}>
|
|
1203
|
+
Localhost and Tailscale browser access are auto-trusted because the
|
|
1204
|
+
dashboard sees a loopback peer. Paste a token only for non-loopback
|
|
1205
|
+
API clients/scripts, or if you force auth for every connection with{' '}
|
|
1206
|
+
<code>BIZAR_DASHBOARD_REQUIRE_AUTH=1</code>.
|
|
1207
|
+
</p>
|
|
1208
|
+
<p className="muted" style={{ fontSize: 12, margin: '4px 0' }}>
|
|
1209
|
+
Dashboard tokens are generated on first boot and saved to{' '}
|
|
1210
|
+
<code>~/.config/bizar/dashboard-secret</code> (mode 0600).
|
|
1211
|
+
</p>
|
|
1212
|
+
</div>
|
|
1213
|
+
<div className="field" data-setting-id="auth.token">
|
|
1214
|
+
<label className="field-label">Token (this browser)</label>
|
|
1215
|
+
<input
|
|
1216
|
+
type="password"
|
|
1217
|
+
className="input mono"
|
|
1218
|
+
value={authToken}
|
|
1219
|
+
onChange={(e) => setAuthToken(e.target.value)}
|
|
1220
|
+
placeholder="Paste token from server stderr or another browser"
|
|
1221
|
+
spellCheck={false}
|
|
1222
|
+
autoComplete="off"
|
|
1223
|
+
/>
|
|
1224
|
+
<div className="task-form-row" style={{ marginTop: 8 }}>
|
|
1225
|
+
<Button variant="secondary" size="sm" onClick={onSaveToken}>
|
|
1226
|
+
<KeyRound size={14} /> Save token
|
|
1227
|
+
</Button>
|
|
1228
|
+
<Button variant="ghost" size="sm" onClick={onCopyToken}>
|
|
1229
|
+
<Copy size={14} /> Reveal & copy server token
|
|
1230
|
+
</Button>
|
|
1231
|
+
<Button variant="ghost" size="sm" onClick={onRegenerateToken}>
|
|
1232
|
+
<RotateCcw size={14} /> Regenerate
|
|
1233
|
+
</Button>
|
|
1234
|
+
</div>
|
|
1235
|
+
{revealedToken ? (
|
|
1236
|
+
<p className="muted" style={{ fontSize: 12, marginTop: 8 }}>
|
|
1237
|
+
Last revealed token (one-time): <code className="mono">{revealedToken}</code>
|
|
1238
|
+
</p>
|
|
1239
|
+
) : null}
|
|
1240
|
+
<p className="muted" style={{ fontSize: 12, marginTop: 8 }}>
|
|
1241
|
+
Regenerating invalidates the current token immediately. Anything
|
|
1242
|
+
still using the old token will see 401 until it's updated.
|
|
1243
|
+
</p>
|
|
1244
|
+
</div>
|
|
1245
|
+
</Card>
|
|
1246
|
+
</div>
|
|
1247
|
+
|
|
1248
|
+
<div data-section="agents" style={{display: (activeSection === null || activeSection === 'agents') ? 'block' : 'none'}}>
|
|
1249
|
+
<Card id="settings-agents" data-section="agents">
|
|
1250
|
+
<CardTitle><ServerIcon size={14} /> Agent Behavior</CardTitle>
|
|
1251
|
+
<CardMeta>Limits and timeouts for background agent dispatch.</CardMeta>
|
|
1252
|
+
<div className="form-row">
|
|
1253
|
+
<label htmlFor="agents-maxParallel">
|
|
1254
|
+
Max parallel agents
|
|
1255
|
+
<span className="meta-badge">default: 6</span>
|
|
1256
|
+
</label>
|
|
1257
|
+
<input
|
|
1258
|
+
id="agents-maxParallel"
|
|
1259
|
+
type="number"
|
|
1260
|
+
min={1}
|
|
1261
|
+
max={20}
|
|
1262
|
+
value={settings.agents?.maxParallel ?? 6}
|
|
1263
|
+
onChange={(e) => patchAgents({ maxParallel: Math.max(1, Math.min(20, parseInt(e.target.value, 10) || 6)) })}
|
|
1264
|
+
/>
|
|
1265
|
+
</div>
|
|
1266
|
+
<div className="form-row">
|
|
1267
|
+
<label htmlFor="agents-stuckThresholdMs">
|
|
1268
|
+
Stuck threshold (ms)
|
|
1269
|
+
<span className="meta-badge">default: 600000 (10 min)</span>
|
|
1270
|
+
</label>
|
|
1271
|
+
<input
|
|
1272
|
+
id="agents-stuckThresholdMs"
|
|
1273
|
+
type="number"
|
|
1274
|
+
min={60000}
|
|
1275
|
+
max={3600000}
|
|
1276
|
+
step={60000}
|
|
1277
|
+
value={settings.agents?.stuckThresholdMs ?? 600000}
|
|
1278
|
+
onChange={(e) => patchAgents({ stuckThresholdMs: Math.max(60000, Math.min(3600000, parseInt(e.target.value, 10) || 600000)) })}
|
|
1279
|
+
/>
|
|
1280
|
+
</div>
|
|
1281
|
+
<label className="checkbox-row" data-setting-id="agents.autoRestart">
|
|
1282
|
+
<input
|
|
1283
|
+
type="checkbox"
|
|
1284
|
+
checked={!!settings.agents?.autoRestart}
|
|
1285
|
+
onChange={(e) => patchAgents({ autoRestart: e.target.checked })}
|
|
1286
|
+
/>
|
|
1287
|
+
<span>Auto-restart stuck agents</span>
|
|
1288
|
+
</label>
|
|
1289
|
+
</Card>
|
|
1290
|
+
</div>
|
|
1291
|
+
|
|
1292
|
+
<div data-section="dashboard" style={{display: (activeSection === null || activeSection === 'dashboard') ? 'block' : 'none'}}>
|
|
1293
|
+
<Card id="settings-dashboard" data-section="dashboard">
|
|
1294
|
+
<CardTitle><Globe size={14} /> Dashboard</CardTitle>
|
|
1295
|
+
<CardMeta>Controls how <code>bizar</code> starts up.</CardMeta>
|
|
1296
|
+
<label className="checkbox-row" data-setting-id="dashboard.autoLaunchWeb">
|
|
1297
|
+
<input
|
|
1298
|
+
type="checkbox"
|
|
1299
|
+
checked={settings.dashboard.autoLaunchWeb !== false}
|
|
1300
|
+
onChange={(e) => patchDashboard({ autoLaunchWeb: e.target.checked })}
|
|
1301
|
+
/>
|
|
1302
|
+
<span>Auto-launch web UI alongside TUI</span>
|
|
1303
|
+
</label>
|
|
1304
|
+
|
|
1305
|
+
<div className="field" data-setting-id="dashboard.projectsDirectory" style={{ marginTop: 'var(--space-4)' }}>
|
|
1306
|
+
<label className="field-label" htmlFor="set-projects-directory">Projects directory</label>
|
|
1307
|
+
<input
|
|
1308
|
+
id="set-projects-directory"
|
|
1309
|
+
className="input"
|
|
1310
|
+
type="text"
|
|
1311
|
+
placeholder="/home/user/projects"
|
|
1312
|
+
value={settings.dashboard.projectsDirectory ?? ''}
|
|
1313
|
+
onChange={(e) => patchDashboard({ projectsDirectory: e.target.value })}
|
|
1314
|
+
/>
|
|
1315
|
+
<p className="field-help">
|
|
1316
|
+
New projects created via the dashboard will land here, and existing project
|
|
1317
|
+
directories inside this folder are auto-recognized on startup.
|
|
1318
|
+
</p>
|
|
1319
|
+
{settings.dashboard.projectsDirectory && (
|
|
1320
|
+
<>
|
|
1321
|
+
{!/^\/|^[A-Za-z]:/.test(settings.dashboard.projectsDirectory) && (
|
|
1322
|
+
<p style={{ color: 'var(--warning)', fontSize: 11, marginTop: 4 }}>
|
|
1323
|
+
Path should be absolute (start with / on Linux/Mac, or a drive letter on Windows).
|
|
1324
|
+
</p>
|
|
1325
|
+
)}
|
|
1326
|
+
{settings.dashboard.projectsDirectory.includes('..') && (
|
|
1327
|
+
<p style={{ color: 'var(--error)', fontSize: 11, marginTop: 4 }}>
|
|
1328
|
+
Path traversal not allowed — this will be rejected server-side.
|
|
1329
|
+
</p>
|
|
1330
|
+
)}
|
|
1331
|
+
</>
|
|
1332
|
+
)}
|
|
1333
|
+
</div>
|
|
1334
|
+
|
|
1335
|
+
{/* allowedRoots textarea */}
|
|
1336
|
+
<div className="field" data-setting-id="dashboard.allowedRoots" style={{ marginTop: 'var(--space-4)' }}>
|
|
1337
|
+
<label className="field-label" htmlFor="set-allowed-roots">
|
|
1338
|
+
Additional allowed roots <span className="muted">(advanced)</span>
|
|
1339
|
+
</label>
|
|
1340
|
+
<textarea
|
|
1341
|
+
id="set-allowed-roots"
|
|
1342
|
+
className="textarea"
|
|
1343
|
+
rows={4}
|
|
1344
|
+
placeholder="/workspace /srv/projects"
|
|
1345
|
+
value={(settings.dashboard.allowedRoots ?? []).join('\n')}
|
|
1346
|
+
onChange={(e) => {
|
|
1347
|
+
const lines = e.target.value
|
|
1348
|
+
.split('\n')
|
|
1349
|
+
.map((l) => l.trim())
|
|
1350
|
+
.filter(Boolean);
|
|
1351
|
+
patchDashboard({ allowedRoots: lines });
|
|
1352
|
+
}}
|
|
1353
|
+
/>
|
|
1354
|
+
<p className="field-help">
|
|
1355
|
+
Optional. Add filesystem roots beyond your home directory that the file
|
|
1356
|
+
browser and project scanner can access. Each path must be inside your
|
|
1357
|
+
home directory. One per line.
|
|
1358
|
+
</p>
|
|
1359
|
+
{(() => {
|
|
1360
|
+
const rawLines = (settings.dashboard.allowedRoots ?? []).join('\n').split('\n');
|
|
1361
|
+
const warnings: { key: string; msg: React.ReactNode }[] = [];
|
|
1362
|
+
rawLines.forEach((line, i) => {
|
|
1363
|
+
if (!line.trim()) return;
|
|
1364
|
+
if (!/^\/|^[A-Za-z]:/.test(line)) {
|
|
1365
|
+
warnings.push({
|
|
1366
|
+
key: `noabs-${i}`,
|
|
1367
|
+
msg: (
|
|
1368
|
+
<p style={{ color: 'var(--warning)', fontSize: 11, marginTop: 2 }}>
|
|
1369
|
+
Line {i + 1}: "{line}" — should be absolute (start with / or a drive letter).
|
|
1370
|
+
</p>
|
|
1371
|
+
),
|
|
1372
|
+
});
|
|
1373
|
+
}
|
|
1374
|
+
if (line.includes('..')) {
|
|
1375
|
+
warnings.push({
|
|
1376
|
+
key: `dots-${i}`,
|
|
1377
|
+
msg: (
|
|
1378
|
+
<p style={{ color: 'var(--error)', fontSize: 11, marginTop: 2 }}>
|
|
1379
|
+
Line {i + 1}: "{line}" — contains '..' (server will reject this).
|
|
1380
|
+
</p>
|
|
1381
|
+
),
|
|
1382
|
+
});
|
|
1383
|
+
}
|
|
1384
|
+
});
|
|
1385
|
+
return warnings.map((w) => w.msg);
|
|
1386
|
+
})()}
|
|
1387
|
+
</div>
|
|
1388
|
+
</Card>
|
|
1389
|
+
</div>
|
|
1390
|
+
|
|
1391
|
+
<div data-section="background" style={{display: (activeSection === null || activeSection === 'background') ? 'block' : 'none'}}>
|
|
1392
|
+
<Card id="settings-background" data-section="background">
|
|
1393
|
+
<CardTitle><ServerIcon size={14} /> Background Agents</CardTitle>
|
|
1394
|
+
<CardMeta>Tune plugin options. Changes take effect on next plugin restart.</CardMeta>
|
|
1395
|
+
|
|
1396
|
+
<div className="form-row">
|
|
1397
|
+
<label htmlFor="bg-maxConcurrent">
|
|
1398
|
+
Max concurrent instances
|
|
1399
|
+
<span className="meta-badge">default: 8</span>
|
|
1400
|
+
</label>
|
|
1401
|
+
<input
|
|
1402
|
+
id="bg-maxConcurrent"
|
|
1403
|
+
type="number"
|
|
1404
|
+
min={1}
|
|
1405
|
+
max={32}
|
|
1406
|
+
value={pluginOptions.maxConcurrentInstances ?? 8}
|
|
1407
|
+
onChange={(e) => setPluginOptions((cur) => ({ ...cur, maxConcurrentInstances: Math.max(1, Math.min(32, parseInt(e.target.value, 10) || 8)) }))}
|
|
1408
|
+
/>
|
|
1409
|
+
<small className="muted">Plugin option: <code>maxConcurrentInstances</code></small>
|
|
1410
|
+
</div>
|
|
1411
|
+
|
|
1412
|
+
<div className="form-row">
|
|
1413
|
+
<label htmlFor="bg-toolCallCap">
|
|
1414
|
+
Tool-call cap
|
|
1415
|
+
<span className="meta-badge">default: 500</span>
|
|
1416
|
+
</label>
|
|
1417
|
+
<input
|
|
1418
|
+
id="bg-toolCallCap"
|
|
1419
|
+
type="number"
|
|
1420
|
+
min={1}
|
|
1421
|
+
max={5000}
|
|
1422
|
+
value={pluginOptions.backgroundToolCallCap ?? 500}
|
|
1423
|
+
onChange={(e) => setPluginOptions((cur) => ({ ...cur, backgroundToolCallCap: Math.max(1, Math.min(5000, parseInt(e.target.value, 10) || 500)) }))}
|
|
1424
|
+
/>
|
|
1425
|
+
<small className="muted">Plugin option: <code>backgroundToolCallCap</code></small>
|
|
1426
|
+
</div>
|
|
1427
|
+
|
|
1428
|
+
<div className="form-row">
|
|
1429
|
+
<label htmlFor="bg-stallTimeout">
|
|
1430
|
+
Stall timeout (ms)
|
|
1431
|
+
<span className="meta-badge">default: 180000</span>
|
|
1432
|
+
</label>
|
|
1433
|
+
<input
|
|
1434
|
+
id="bg-stallTimeout"
|
|
1435
|
+
type="number"
|
|
1436
|
+
min={10000}
|
|
1437
|
+
max={600000}
|
|
1438
|
+
step={1000}
|
|
1439
|
+
value={pluginOptions.backgroundStallTimeoutMs ?? 180000}
|
|
1440
|
+
onChange={(e) => setPluginOptions((cur) => ({ ...cur, backgroundStallTimeoutMs: Math.max(10000, Math.min(600000, parseInt(e.target.value, 10) || 180000)) }))}
|
|
1441
|
+
/>
|
|
1442
|
+
<small className="muted">Plugin option: <code>backgroundStallTimeoutMs</code></small>
|
|
1443
|
+
</div>
|
|
1444
|
+
|
|
1445
|
+
<div className="form-row">
|
|
1446
|
+
<label htmlFor="bg-thinkingLoopTimeout">
|
|
1447
|
+
Thinking-loop timeout (ms)
|
|
1448
|
+
<span className="meta-badge">default: 300000</span>
|
|
1449
|
+
</label>
|
|
1450
|
+
<input
|
|
1451
|
+
id="bg-thinkingLoopTimeout"
|
|
1452
|
+
type="number"
|
|
1453
|
+
min={30000}
|
|
1454
|
+
max={900000}
|
|
1455
|
+
step={1000}
|
|
1456
|
+
value={pluginOptions.backgroundThinkingLoopTimeoutMs ?? 300000}
|
|
1457
|
+
onChange={(e) => setPluginOptions((cur) => ({ ...cur, backgroundThinkingLoopTimeoutMs: Math.max(30000, Math.min(900000, parseInt(e.target.value, 10) || 300000)) }))}
|
|
1458
|
+
/>
|
|
1459
|
+
<small className="muted">Plugin option: <code>backgroundThinkingLoopTimeoutMs</code></small>
|
|
1460
|
+
</div>
|
|
1461
|
+
|
|
1462
|
+
<div className="form-row">
|
|
1463
|
+
<label htmlFor="bg-maxInterventions">
|
|
1464
|
+
Max interventions
|
|
1465
|
+
<span className="meta-badge">default: 1</span>
|
|
1466
|
+
</label>
|
|
1467
|
+
<input
|
|
1468
|
+
id="bg-maxInterventions"
|
|
1469
|
+
type="number"
|
|
1470
|
+
min={1}
|
|
1471
|
+
max={3}
|
|
1472
|
+
value={pluginOptions.backgroundMaxInterventions ?? 1}
|
|
1473
|
+
onChange={(e) => setPluginOptions((cur) => ({ ...cur, backgroundMaxInterventions: Math.max(1, Math.min(3, parseInt(e.target.value, 10) || 1)) }))}
|
|
1474
|
+
/>
|
|
1475
|
+
<small className="muted">Plugin option: <code>backgroundMaxInterventions</code></small>
|
|
1476
|
+
</div>
|
|
1477
|
+
|
|
1478
|
+
<div className="form-row">
|
|
1479
|
+
<Button variant="secondary" size="sm" onClick={async () => {
|
|
1480
|
+
try {
|
|
1481
|
+
await api.put('/settings/plugin-options', pluginOptions);
|
|
1482
|
+
toast.success('Saved — restart opencode for changes to take effect.');
|
|
1483
|
+
} catch (err) {
|
|
1484
|
+
toast.error(`Save failed: ${(err as Error).message}`);
|
|
1485
|
+
}
|
|
1486
|
+
}}>
|
|
1487
|
+
<Save size={14} /> Save plugin options
|
|
1488
|
+
</Button>
|
|
1489
|
+
|
|
1490
|
+
<Button variant="secondary" size="sm" onClick={async () => {
|
|
1491
|
+
try {
|
|
1492
|
+
const r = await fetch('/api/background/cleanup', {
|
|
1493
|
+
method: 'POST',
|
|
1494
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1495
|
+
body: JSON.stringify({ maxAgeDays: 7 }),
|
|
1496
|
+
});
|
|
1497
|
+
const result = await r.json();
|
|
1498
|
+
toast.success(`Cleaned up ${result.deleted} old instances.`);
|
|
1499
|
+
} catch (err) {
|
|
1500
|
+
toast.error(`Cleanup failed: ${(err as Error).message}`);
|
|
1501
|
+
}
|
|
1502
|
+
}}>
|
|
1503
|
+
Cleanup old instances (>7 days)
|
|
1504
|
+
</Button>
|
|
1505
|
+
</div>
|
|
1506
|
+
|
|
1507
|
+
<div className="form-row">
|
|
1508
|
+
<small>Plugin options are read at startup. Save changes and run <code>bizar update</code> to apply.</small>
|
|
1509
|
+
</div>
|
|
1510
|
+
</Card>
|
|
1511
|
+
</div>
|
|
1512
|
+
</div>
|
|
1513
|
+
|
|
1514
|
+
<div data-section="system-llm" style={{display: (activeSection === null || activeSection === 'system-llm') ? 'block' : 'none'}}>
|
|
1515
|
+
<Card id="settings-system-llm" data-section="system-llm">
|
|
1516
|
+
<CardTitle><Sparkles size={14} /> System LLM API</CardTitle>
|
|
1517
|
+
<CardMeta>
|
|
1518
|
+
Configures the LLM used for automatic title generation, prompt enhancement,
|
|
1519
|
+
summarization, and other system-level calls.
|
|
1520
|
+
</CardMeta>
|
|
1521
|
+
|
|
1522
|
+
<label className="checkbox-row" data-setting-id="systemLlm.enabled">
|
|
1523
|
+
<input
|
|
1524
|
+
type="checkbox"
|
|
1525
|
+
checked={!!settings.systemLlm?.enabled}
|
|
1526
|
+
onChange={(e) => {
|
|
1527
|
+
const cur = settings.systemLlm || { enabled: true, provider: 'opencode', model: 'opencode/deepseek-v4-flash-free' };
|
|
1528
|
+
patchTop('systemLlm', { ...cur, enabled: e.target.checked });
|
|
1529
|
+
}}
|
|
1530
|
+
/>
|
|
1531
|
+
<span>Enable system LLM calls</span>
|
|
1532
|
+
</label>
|
|
1533
|
+
|
|
1534
|
+
<div className="field" data-setting-id="systemLlm.provider" style={{ marginTop: 'var(--space-3)' }}>
|
|
1535
|
+
<label className="field-label" htmlFor="set-system-llm-provider">Provider</label>
|
|
1536
|
+
<select
|
|
1537
|
+
id="set-system-llm-provider"
|
|
1538
|
+
className="select"
|
|
1539
|
+
value={settings.systemLlm?.provider || 'opencode'}
|
|
1540
|
+
onChange={(e) => {
|
|
1541
|
+
const cur = settings.systemLlm || { enabled: true, provider: 'opencode', model: 'opencode/deepseek-v4-flash-free' };
|
|
1542
|
+
patchTop('systemLlm', { ...cur, provider: e.target.value });
|
|
1543
|
+
}}
|
|
1544
|
+
>
|
|
1545
|
+
<option value="opencode">Opencode</option>
|
|
1546
|
+
<option value="openrouter">OpenRouter</option>
|
|
1547
|
+
<option value="minimax">MiniMax</option>
|
|
1548
|
+
</select>
|
|
1549
|
+
</div>
|
|
1550
|
+
|
|
1551
|
+
<div className="field" data-setting-id="systemLlm.model">
|
|
1552
|
+
<label className="field-label" htmlFor="set-system-llm-model">Model</label>
|
|
1553
|
+
<input
|
|
1554
|
+
id="set-system-llm-model"
|
|
1555
|
+
className="input mono"
|
|
1556
|
+
type="text"
|
|
1557
|
+
placeholder="opencode/deepseek-v4-flash-free"
|
|
1558
|
+
value={settings.systemLlm?.model || 'opencode/deepseek-v4-flash-free'}
|
|
1559
|
+
onChange={(e) => {
|
|
1560
|
+
const cur = settings.systemLlm || { enabled: true, provider: 'opencode', model: 'opencode/deepseek-v4-flash-free' };
|
|
1561
|
+
patchTop('systemLlm', { ...cur, model: e.target.value });
|
|
1562
|
+
}}
|
|
1563
|
+
/>
|
|
1564
|
+
<p className="field-help">
|
|
1565
|
+
The API key is read from <code>auth.json</code> for the selected provider.
|
|
1566
|
+
Leave the default model for best results.
|
|
1567
|
+
</p>
|
|
1568
|
+
</div>
|
|
1569
|
+
|
|
1570
|
+
<div style={{ marginTop: 'var(--space-4)' }}>
|
|
1571
|
+
<h4 style={{ margin: '0 0 var(--space-2)' }}>Features using this API</h4>
|
|
1572
|
+
<ul className="settings-feature-list" style={{ margin: 0, paddingLeft: 'var(--space-4)', lineHeight: 1.8 }}>
|
|
1573
|
+
<li>Auto-title generation for new chat sessions</li>
|
|
1574
|
+
<li>Auto-title generation for new tasks</li>
|
|
1575
|
+
<li>"Enhance prompt" button in the task input</li>
|
|
1576
|
+
<li>"Enhance prompt" button in the chat composer</li>
|
|
1577
|
+
<li className="muted" style={{ fontSize: 12 }}>Future: summarization, name generation, and more</li>
|
|
1578
|
+
</ul>
|
|
1579
|
+
</div>
|
|
1580
|
+
</Card>
|
|
1581
|
+
</div>
|
|
1582
|
+
|
|
1583
|
+
<PairDeviceCard />
|
|
1584
|
+
|
|
1585
|
+
<div style={{display: (activeSection === null || activeSection === 'activity-log') ? 'block' : 'none'}}>
|
|
1586
|
+
<div data-section="activity-log" style={{display: (activeSection === null || activeSection === 'activity-log') ? 'block' : 'none'}}>
|
|
1587
|
+
<section id="settings-activity-log" data-section="activity-log" className="settings-section-wrap">
|
|
1588
|
+
<ActivityLogCard />
|
|
1589
|
+
</section>
|
|
1590
|
+
</div>
|
|
1591
|
+
</div>
|
|
1592
|
+
|
|
1593
|
+
<div style={{display: (activeSection === null || activeSection === 'about') ? 'block' : 'none'}}>
|
|
1594
|
+
<div data-section="about" style={{display: (activeSection === null || activeSection === 'about') ? 'block' : 'none'}}>
|
|
1595
|
+
<Card id="settings-about" data-section="about">
|
|
1596
|
+
<CardTitle><Info size={14} /> About</CardTitle>
|
|
1597
|
+
<CardMeta>Build metadata.</CardMeta>
|
|
1598
|
+
<dl className="about-table">
|
|
1599
|
+
<dt>Version</dt>
|
|
1600
|
+
<dd className="mono">{about.version}</dd>
|
|
1601
|
+
<dt>Homepage</dt>
|
|
1602
|
+
<dd>
|
|
1603
|
+
<a href={about.homepage} target="_blank" rel="noopener noreferrer">
|
|
1604
|
+
{about.homepage}
|
|
1605
|
+
</a>
|
|
1606
|
+
</dd>
|
|
1607
|
+
<dt>License</dt>
|
|
1608
|
+
<dd>{about.license}</dd>
|
|
1609
|
+
</dl>
|
|
1610
|
+
</Card>
|
|
1611
|
+
</div>
|
|
1612
|
+
</div>
|
|
1613
|
+
</div>
|
|
1614
|
+
);
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
/**
|
|
1618
|
+
* v3.15.0 — Full activity log + hidden set management.
|
|
1619
|
+
*
|
|
1620
|
+
* Two sources of truth:
|
|
1621
|
+
* - GET /activity — the full unfiltered log (incl. hidden entries)
|
|
1622
|
+
* - GET /activity/hidden — the list of keys the user chose to hide
|
|
1623
|
+
*
|
|
1624
|
+
* Hiding is non-destructive: hidden items still appear here so the
|
|
1625
|
+
* user can review / restore them.
|
|
1626
|
+
*/
|
|
1627
|
+
function ActivityLogCard() {
|
|
1628
|
+
const toast = useToast();
|
|
1629
|
+
const [items, setItems] = useState<Array<{ kind?: string; ts?: string; slug?: string; message?: string }>>([]);
|
|
1630
|
+
const [hidden, setHidden] = useState<Set<string>>(new Set());
|
|
1631
|
+
const [loading, setLoading] = useState(true);
|
|
1632
|
+
const [filter, setFilter] = useState('');
|
|
1633
|
+
const [showHidden, setShowHidden] = useState(true);
|
|
1634
|
+
|
|
1635
|
+
const refresh = useCallback(async () => {
|
|
1636
|
+
setLoading(true);
|
|
1637
|
+
try {
|
|
1638
|
+
const [log, hid] = await Promise.all([
|
|
1639
|
+
api.get<{ items: Array<{ kind?: string; ts?: string; slug?: string; message?: string }> }>('/activity'),
|
|
1640
|
+
api.get<{ hidden: string[] }>('/activity/hidden'),
|
|
1641
|
+
]);
|
|
1642
|
+
setItems(Array.isArray(log.items) ? log.items : []);
|
|
1643
|
+
setHidden(new Set(hid.hidden || []));
|
|
1644
|
+
} catch (err) {
|
|
1645
|
+
toast.error(`Load failed: ${(err as Error).message}`);
|
|
1646
|
+
} finally {
|
|
1647
|
+
setLoading(false);
|
|
1648
|
+
}
|
|
1649
|
+
}, [toast]);
|
|
1650
|
+
|
|
1651
|
+
useEffect(() => { refresh(); }, [refresh]);
|
|
1652
|
+
|
|
1653
|
+
const keyOf = (it: { kind?: string; ts?: string; slug?: string }, idx: number) => {
|
|
1654
|
+
const k = `${it.kind || ''}|${it.ts || ''}|${it.slug || ''}|${idx}`;
|
|
1655
|
+
let h = 0;
|
|
1656
|
+
for (let i = 0; i < k.length; i++) h = ((h << 5) - h + k.charCodeAt(i)) | 0;
|
|
1657
|
+
return Math.abs(h).toString(16).padStart(8, '0').slice(0, 16);
|
|
1658
|
+
};
|
|
1659
|
+
|
|
1660
|
+
const onRestoreAll = async () => {
|
|
1661
|
+
try {
|
|
1662
|
+
await api.del('/activity/hide');
|
|
1663
|
+
setHidden(new Set());
|
|
1664
|
+
toast.success('All hidden activity restored.');
|
|
1665
|
+
} catch (err) {
|
|
1666
|
+
toast.error(`Restore failed: ${(err as Error).message}`);
|
|
1667
|
+
}
|
|
1668
|
+
};
|
|
1669
|
+
|
|
1670
|
+
const onRestoreOne = async (key: string) => {
|
|
1671
|
+
try {
|
|
1672
|
+
await api.del(`/activity/hide/${encodeURIComponent(key)}`);
|
|
1673
|
+
const next = new Set(hidden);
|
|
1674
|
+
next.delete(key);
|
|
1675
|
+
setHidden(next);
|
|
1676
|
+
} catch (err) {
|
|
1677
|
+
toast.error(`Restore failed: ${(err as Error).message}`);
|
|
1678
|
+
}
|
|
1679
|
+
};
|
|
1680
|
+
|
|
1681
|
+
const filtered = items.filter((it, idx) => {
|
|
1682
|
+
const k = keyOf(it, idx);
|
|
1683
|
+
if (!showHidden && hidden.has(k)) return false;
|
|
1684
|
+
if (!filter) return true;
|
|
1685
|
+
const q = filter.toLowerCase();
|
|
1686
|
+
return [it.kind, it.slug, it.message].some((v) => typeof v === 'string' && v.toLowerCase().includes(q));
|
|
1687
|
+
});
|
|
1688
|
+
|
|
1689
|
+
return (
|
|
1690
|
+
<Card id="settings-diagnostics" data-section="diagnostics">
|
|
1691
|
+
<CardTitle>
|
|
1692
|
+
<Activity size={14} /> Activity log
|
|
1693
|
+
<span className="muted" style={{ fontWeight: 400, marginLeft: 8, fontSize: 12 }}>
|
|
1694
|
+
{items.length} total · {hidden.size} hidden
|
|
1695
|
+
</span>
|
|
1696
|
+
<Button variant="ghost" size="sm" style={{ marginLeft: 'auto' }} onClick={refresh} title="Reload">
|
|
1697
|
+
<RefreshCw size={12} />
|
|
1698
|
+
</Button>
|
|
1699
|
+
</CardTitle>
|
|
1700
|
+
<CardMeta>
|
|
1701
|
+
Full history from <code>~/.bizar/activity.log</code>. Hiding an item in the Overview only hides it there — the entry stays here.
|
|
1702
|
+
</CardMeta>
|
|
1703
|
+
|
|
1704
|
+
<div className="activity-log-toolbar">
|
|
1705
|
+
<div className="activity-log-search">
|
|
1706
|
+
<Search size={12} />
|
|
1707
|
+
<input
|
|
1708
|
+
type="text"
|
|
1709
|
+
className="input"
|
|
1710
|
+
placeholder="Filter by kind, slug, or message…"
|
|
1711
|
+
value={filter}
|
|
1712
|
+
onChange={(e) => setFilter(e.target.value)}
|
|
1713
|
+
/>
|
|
1714
|
+
</div>
|
|
1715
|
+
<label className="activity-log-toggle">
|
|
1716
|
+
<input
|
|
1717
|
+
type="checkbox"
|
|
1718
|
+
checked={showHidden}
|
|
1719
|
+
onChange={(e) => setShowHidden(e.target.checked)}
|
|
1720
|
+
/>
|
|
1721
|
+
Show hidden
|
|
1722
|
+
</label>
|
|
1723
|
+
<Button
|
|
1724
|
+
variant="ghost"
|
|
1725
|
+
size="sm"
|
|
1726
|
+
disabled={hidden.size === 0}
|
|
1727
|
+
onClick={onRestoreAll}
|
|
1728
|
+
title="Restore all hidden items to the Overview"
|
|
1729
|
+
>
|
|
1730
|
+
<Eye size={12} /> Restore all
|
|
1731
|
+
</Button>
|
|
1732
|
+
</div>
|
|
1733
|
+
|
|
1734
|
+
{loading ? (
|
|
1735
|
+
<div className="muted" style={{ padding: '12px 0', fontSize: 12 }}>Loading…</div>
|
|
1736
|
+
) : filtered.length === 0 ? (
|
|
1737
|
+
<div className="muted" style={{ padding: '12px 0', fontSize: 12 }}>
|
|
1738
|
+
{items.length === 0 ? 'No activity yet.' : 'No items match the current filter.'}
|
|
1739
|
+
</div>
|
|
1740
|
+
) : (
|
|
1741
|
+
<div className="activity-log-table-wrap">
|
|
1742
|
+
<table className="activity-log-table">
|
|
1743
|
+
<thead>
|
|
1744
|
+
<tr>
|
|
1745
|
+
<th>Kind</th>
|
|
1746
|
+
<th>Detail</th>
|
|
1747
|
+
<th>Time</th>
|
|
1748
|
+
<th></th>
|
|
1749
|
+
</tr>
|
|
1750
|
+
</thead>
|
|
1751
|
+
<tbody>
|
|
1752
|
+
{filtered.slice(0, 200).map((it, idx) => {
|
|
1753
|
+
const k = keyOf(it, idx);
|
|
1754
|
+
const isHidden = hidden.has(k);
|
|
1755
|
+
return (
|
|
1756
|
+
<tr key={`${it.ts}-${idx}`} className={cn(isHidden && 'activity-log-row-hidden')}>
|
|
1757
|
+
<td className="activity-log-kind">{it.kind || 'activity'}</td>
|
|
1758
|
+
<td className="activity-log-detail">
|
|
1759
|
+
{it.message || it.slug || '—'}
|
|
1760
|
+
</td>
|
|
1761
|
+
<td className="activity-log-time mono">
|
|
1762
|
+
{it.ts ? new Date(it.ts).toLocaleString() : '—'}
|
|
1763
|
+
</td>
|
|
1764
|
+
<td>
|
|
1765
|
+
{isHidden ? (
|
|
1766
|
+
<Button variant="ghost" size="sm" onClick={() => onRestoreOne(k)} title="Restore to Overview">
|
|
1767
|
+
<Eye size={12} />
|
|
1768
|
+
</Button>
|
|
1769
|
+
) : (
|
|
1770
|
+
<span className="activity-log-state-tag">shown</span>
|
|
1771
|
+
)}
|
|
1772
|
+
</td>
|
|
1773
|
+
</tr>
|
|
1774
|
+
);
|
|
1775
|
+
})}
|
|
1776
|
+
</tbody>
|
|
1777
|
+
</table>
|
|
1778
|
+
{filtered.length > 200 && (
|
|
1779
|
+
<div className="muted" style={{ fontSize: 11, padding: '8px 0' }}>
|
|
1780
|
+
Showing first 200 of {filtered.length}.
|
|
1781
|
+
</div>
|
|
1782
|
+
)}
|
|
1783
|
+
</div>
|
|
1784
|
+
)}
|
|
1785
|
+
</Card>
|
|
1786
|
+
);
|
|
1787
|
+
}
|