@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,1523 @@
|
|
|
1
|
+
// src/views/Tasks.tsx — v3.1.0 task board: archive, filters, sort, bulk, recurring, timers, comments, real-time.
|
|
2
|
+
import { useEffect, useMemo, useState } from 'react';
|
|
3
|
+
import {
|
|
4
|
+
CheckSquare,
|
|
5
|
+
Plus,
|
|
6
|
+
Trash2,
|
|
7
|
+
ChevronLeft,
|
|
8
|
+
ChevronRight,
|
|
9
|
+
Search,
|
|
10
|
+
X as XIcon,
|
|
11
|
+
Clock,
|
|
12
|
+
PlayCircle,
|
|
13
|
+
PauseCircle,
|
|
14
|
+
MessageSquare,
|
|
15
|
+
Activity,
|
|
16
|
+
Link2,
|
|
17
|
+
Calendar,
|
|
18
|
+
Paperclip,
|
|
19
|
+
Tag as TagIcon,
|
|
20
|
+
Archive,
|
|
21
|
+
ArchiveRestore,
|
|
22
|
+
Filter,
|
|
23
|
+
ArrowUpDown,
|
|
24
|
+
Square,
|
|
25
|
+
CheckSquare as CheckSquareIcon,
|
|
26
|
+
Bot,
|
|
27
|
+
RotateCw,
|
|
28
|
+
AlertOctagon,
|
|
29
|
+
Sparkles,
|
|
30
|
+
RefreshCw,
|
|
31
|
+
Send,
|
|
32
|
+
FileText,
|
|
33
|
+
} from 'lucide-react';
|
|
34
|
+
import { Button } from '../components/Button';
|
|
35
|
+
import { Card, CardTitle } from '../components/Card';
|
|
36
|
+
import { EmptyState } from '../components/EmptyState';
|
|
37
|
+
import { Spinner } from '../components/Spinner';
|
|
38
|
+
import { StatusBadge } from '../components/StatusBadge';
|
|
39
|
+
import { Tag } from '../components/Tag';
|
|
40
|
+
import { useModal } from '../components/Modal';
|
|
41
|
+
import { useToast } from '../components/Toast';
|
|
42
|
+
import { BgStatusBadge } from '../components/BgStatusBadge';
|
|
43
|
+
import { openKillConfirmDialog } from '../components/KillConfirmDialog';
|
|
44
|
+
import { api, enhancePrompt } from '../lib/api';
|
|
45
|
+
import { cn, formatRelative, priorityColors, autoTitleFromContent } from '../lib/utils';
|
|
46
|
+
import type { Agent, Settings, Snapshot, Task } from '../lib/types';
|
|
47
|
+
import { openArtifactViewer } from '../components/ArtifactViewer';
|
|
48
|
+
|
|
49
|
+
type Props = {
|
|
50
|
+
snapshot: Snapshot;
|
|
51
|
+
settings: Settings;
|
|
52
|
+
activeTab: string;
|
|
53
|
+
setActiveTab: (id: string) => void;
|
|
54
|
+
refreshSnapshot: () => Promise<void>;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
type Column = {
|
|
58
|
+
id: Task['status'] | string;
|
|
59
|
+
label: string;
|
|
60
|
+
kind: 'info' | 'accent' | 'success' | 'warning' | 'error';
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const COLUMNS: Column[] = [
|
|
64
|
+
{ id: 'queued', label: 'Queued', kind: 'info' },
|
|
65
|
+
{ id: 'doing', label: 'Doing', kind: 'accent' },
|
|
66
|
+
{ id: 'blocked', label: 'Blocked', kind: 'warning' },
|
|
67
|
+
{ id: 'done', label: 'Done', kind: 'success' },
|
|
68
|
+
];
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* v3.7.4 — Decide whether a task belongs in the given kanban column.
|
|
72
|
+
* Archived tasks (any prior session's "finished and tucked away" tasks)
|
|
73
|
+
* show up in the DONE column so a fresh dashboard load still surfaces
|
|
74
|
+
* everything that exists on disk.
|
|
75
|
+
*/
|
|
76
|
+
function matchesColumn(task: Task, colId: string): boolean {
|
|
77
|
+
const isArchived = task.status === 'archived' || task.archived === true;
|
|
78
|
+
if (colId === 'done') return task.status === 'done' || isArchived;
|
|
79
|
+
return !isArchived && task.status === colId;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const PRIORITIES = ['low', 'normal', 'high'] as const;
|
|
83
|
+
type Priority = (typeof PRIORITIES)[number];
|
|
84
|
+
type SortKey = 'priority' | 'due' | 'created' | 'updated';
|
|
85
|
+
|
|
86
|
+
const SORTS: { id: SortKey; label: string }[] = [
|
|
87
|
+
{ id: 'updated', label: 'Last updated' },
|
|
88
|
+
{ id: 'created', label: 'Created' },
|
|
89
|
+
{ id: 'priority', label: 'Priority' },
|
|
90
|
+
{ id: 'due', label: 'Due date' },
|
|
91
|
+
];
|
|
92
|
+
|
|
93
|
+
export function Tasks({ snapshot, refreshSnapshot, setActiveTab }: Props) {
|
|
94
|
+
const toast = useToast();
|
|
95
|
+
const modal = useModal();
|
|
96
|
+
const [tasks, setTasks] = useState<Task[]>(snapshot.tasks || []);
|
|
97
|
+
const [loading, setLoading] = useState(!snapshot.tasks);
|
|
98
|
+
const [filter, setFilter] = useState('');
|
|
99
|
+
const [assigneeFilter, setAssigneeFilter] = useState<string>('');
|
|
100
|
+
const [priorityFilter, setPriorityFilter] = useState<string>('');
|
|
101
|
+
const [tagFilter, setTagFilter] = useState<string>('');
|
|
102
|
+
const [sortKey, setSortKey] = useState<SortKey>('updated');
|
|
103
|
+
const [showArchived, setShowArchived] = useState(false);
|
|
104
|
+
const [selected, setSelected] = useState<Set<string>>(new Set());
|
|
105
|
+
const [tick, setTick] = useState(0); // for live timer re-render
|
|
106
|
+
|
|
107
|
+
const reload = async (opts: { archived?: boolean } = {}) => {
|
|
108
|
+
try {
|
|
109
|
+
const params = opts.archived ? '?archived=only' : '';
|
|
110
|
+
const data = await api.get<Task[]>(`/tasks${params}`);
|
|
111
|
+
setTasks(Array.isArray(data) ? data : []);
|
|
112
|
+
} catch (err) {
|
|
113
|
+
toast.error(`Tasks load failed: ${(err as Error).message}`);
|
|
114
|
+
} finally {
|
|
115
|
+
setLoading(false);
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
useEffect(() => {
|
|
120
|
+
if (snapshot.tasks) {
|
|
121
|
+
setTasks(snapshot.tasks);
|
|
122
|
+
setLoading(false);
|
|
123
|
+
}
|
|
124
|
+
}, [snapshot.tasks]);
|
|
125
|
+
|
|
126
|
+
// Periodic tick for live timer display.
|
|
127
|
+
useEffect(() => {
|
|
128
|
+
const id = setInterval(() => setTick((t) => t + 1), 1000);
|
|
129
|
+
return () => clearInterval(id);
|
|
130
|
+
}, []);
|
|
131
|
+
|
|
132
|
+
const allTags = useMemo(() => {
|
|
133
|
+
const set = new Set<string>();
|
|
134
|
+
for (const t of tasks) for (const tag of t.tags || []) set.add(tag);
|
|
135
|
+
return Array.from(set).sort();
|
|
136
|
+
}, [tasks]);
|
|
137
|
+
|
|
138
|
+
const filtered = useMemo(() => {
|
|
139
|
+
let out = tasks;
|
|
140
|
+
if (assigneeFilter) {
|
|
141
|
+
out = out.filter((t) => (t.assignee || '') === assigneeFilter);
|
|
142
|
+
}
|
|
143
|
+
if (priorityFilter) {
|
|
144
|
+
out = out.filter((t) => (t.priority || 'normal') === priorityFilter);
|
|
145
|
+
}
|
|
146
|
+
if (tagFilter) {
|
|
147
|
+
out = out.filter((t) => (t.tags || []).includes(tagFilter));
|
|
148
|
+
}
|
|
149
|
+
if (filter.trim()) {
|
|
150
|
+
const q = filter.toLowerCase();
|
|
151
|
+
out = out.filter((t) => {
|
|
152
|
+
const title = (t.title || '').toLowerCase();
|
|
153
|
+
const desc = (t.description || '').toLowerCase();
|
|
154
|
+
const tags = (t.tags || []).join(' ').toLowerCase();
|
|
155
|
+
return title.includes(q) || desc.includes(q) || tags.includes(q);
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
return out;
|
|
159
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
160
|
+
}, [tasks, filter, assigneeFilter, priorityFilter, tagFilter]);
|
|
161
|
+
|
|
162
|
+
const sorted = useMemo(() => {
|
|
163
|
+
const out = [...filtered];
|
|
164
|
+
const priorityWeight = { high: 0, normal: 1, low: 2 } as Record<string, number>;
|
|
165
|
+
switch (sortKey) {
|
|
166
|
+
case 'priority':
|
|
167
|
+
out.sort((a, b) => (priorityWeight[a.priority] ?? 1) - (priorityWeight[b.priority] ?? 1));
|
|
168
|
+
break;
|
|
169
|
+
case 'created':
|
|
170
|
+
out.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
|
171
|
+
break;
|
|
172
|
+
case 'due':
|
|
173
|
+
out.sort((a, b) => {
|
|
174
|
+
const ax = a.dueDate ? new Date(a.dueDate).getTime() : Infinity;
|
|
175
|
+
const bx = b.dueDate ? new Date(b.dueDate).getTime() : Infinity;
|
|
176
|
+
return ax - bx;
|
|
177
|
+
});
|
|
178
|
+
break;
|
|
179
|
+
case 'updated':
|
|
180
|
+
default:
|
|
181
|
+
out.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
|
|
182
|
+
break;
|
|
183
|
+
}
|
|
184
|
+
return out;
|
|
185
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
186
|
+
}, [filtered, sortKey]);
|
|
187
|
+
|
|
188
|
+
const moveTask = async (taskId: string, newStatus: string) => {
|
|
189
|
+
const t = tasks.find((x) => x.id === taskId);
|
|
190
|
+
if (!t) return;
|
|
191
|
+
const prev = t.status;
|
|
192
|
+
setTasks((cur) =>
|
|
193
|
+
cur.map((x) => (x.id === taskId ? { ...x, status: newStatus } : x)),
|
|
194
|
+
);
|
|
195
|
+
try {
|
|
196
|
+
await api.patch(`/tasks/${encodeURIComponent(taskId)}/status`, {
|
|
197
|
+
status: newStatus,
|
|
198
|
+
});
|
|
199
|
+
toast.success(`Moved to ${newStatus}.`, 1500);
|
|
200
|
+
} catch (err) {
|
|
201
|
+
setTasks((cur) =>
|
|
202
|
+
cur.map((x) => (x.id === taskId ? { ...x, status: prev } : x)),
|
|
203
|
+
);
|
|
204
|
+
toast.error(`Move failed: ${(err as Error).message}`);
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
const deleteTask = async (taskId: string) => {
|
|
209
|
+
if (!confirm('Delete this task?')) return;
|
|
210
|
+
try {
|
|
211
|
+
await api.del(`/tasks/${encodeURIComponent(taskId)}`);
|
|
212
|
+
setTasks((cur) => cur.filter((t) => t.id !== taskId));
|
|
213
|
+
toast.success('Task deleted.', 1500);
|
|
214
|
+
} catch (err) {
|
|
215
|
+
toast.error(`Delete failed: ${(err as Error).message}`);
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
const archiveTask = async (taskId: string) => {
|
|
220
|
+
try {
|
|
221
|
+
const updated = await api.post<Task>(`/tasks/${encodeURIComponent(taskId)}/archive`);
|
|
222
|
+
setTasks((cur) => cur.filter((t) => t.id !== taskId));
|
|
223
|
+
toast.success('Task archived.', 1500);
|
|
224
|
+
} catch (err) {
|
|
225
|
+
toast.error(`Archive failed: ${(err as Error).message}`);
|
|
226
|
+
}
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
const unarchiveTask = async (taskId: string) => {
|
|
230
|
+
try {
|
|
231
|
+
const updated = await api.post<Task>(`/tasks/${encodeURIComponent(taskId)}/unarchive`);
|
|
232
|
+
setTasks((cur) => cur.map((t) => (t.id === taskId ? updated : t)));
|
|
233
|
+
toast.success('Task restored.', 1500);
|
|
234
|
+
} catch (err) {
|
|
235
|
+
toast.error(`Restore failed: ${(err as Error).message}`);
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
const onBulkAction = async (action: 'archive' | 'delete' | 'move' | 'priority' | 'assign' | 'tag', params: Record<string, unknown> = {}) => {
|
|
240
|
+
if (selected.size === 0) return;
|
|
241
|
+
const ids = Array.from(selected);
|
|
242
|
+
try {
|
|
243
|
+
const out = await api.post<{ affected: { id: string; ok: boolean; error?: string }[] }>('/tasks/bulk', {
|
|
244
|
+
ids,
|
|
245
|
+
action,
|
|
246
|
+
params,
|
|
247
|
+
});
|
|
248
|
+
const ok = out.affected.filter((r) => r.ok).length;
|
|
249
|
+
const failed = out.affected.length - ok;
|
|
250
|
+
if (action === 'archive' || action === 'delete') {
|
|
251
|
+
setTasks((cur) => cur.filter((t) => !ids.includes(t.id)));
|
|
252
|
+
} else {
|
|
253
|
+
await reload();
|
|
254
|
+
}
|
|
255
|
+
toast.success(`Bulk ${action}: ${ok} ok${failed ? `, ${failed} failed` : ''}.`, 2500);
|
|
256
|
+
setSelected(new Set());
|
|
257
|
+
} catch (err) {
|
|
258
|
+
toast.error(`Bulk failed: ${(err as Error).message}`);
|
|
259
|
+
}
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
const onAssignMe = async (t: Task) => {
|
|
263
|
+
try {
|
|
264
|
+
const me = 'me';
|
|
265
|
+
const updated = await api.put<Task>(`/tasks/${encodeURIComponent(t.id)}`, { assignee: me });
|
|
266
|
+
setTasks((cur) => cur.map((x) => (x.id === t.id ? updated : x)));
|
|
267
|
+
toast.success('Assigned to me.', 1200);
|
|
268
|
+
} catch (err) {
|
|
269
|
+
toast.error(`Assign failed: ${(err as Error).message}`);
|
|
270
|
+
}
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
const onMarkWorkedBy = async (t: Task, agent: string, status: 'doing' | 'done' | 'idle', complete: boolean) => {
|
|
274
|
+
try {
|
|
275
|
+
const updated = await api.post<Task>(`/tasks/${encodeURIComponent(t.id)}/work`, {
|
|
276
|
+
agent,
|
|
277
|
+
status,
|
|
278
|
+
complete,
|
|
279
|
+
});
|
|
280
|
+
setTasks((cur) => cur.map((x) => (x.id === t.id ? updated : x)));
|
|
281
|
+
toast.success(`Marked ${status} by ${agent}.`, 1500);
|
|
282
|
+
} catch (err) {
|
|
283
|
+
toast.error(`Mark failed: ${(err as Error).message}`);
|
|
284
|
+
}
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
const toggleSelect = (id: string) => {
|
|
288
|
+
setSelected((cur) => {
|
|
289
|
+
const next = new Set(cur);
|
|
290
|
+
if (next.has(id)) next.delete(id);
|
|
291
|
+
else next.add(id);
|
|
292
|
+
return next;
|
|
293
|
+
});
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
const toggleSelectAll = (ids: string[]) => {
|
|
297
|
+
setSelected((cur) => {
|
|
298
|
+
const next = new Set(cur);
|
|
299
|
+
const allSelected = ids.length > 0 && ids.every((id) => next.has(id));
|
|
300
|
+
if (allSelected) {
|
|
301
|
+
ids.forEach((id) => next.delete(id));
|
|
302
|
+
return next;
|
|
303
|
+
}
|
|
304
|
+
ids.forEach((id) => next.add(id));
|
|
305
|
+
return next;
|
|
306
|
+
});
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
// v3.6.2 — Pipeline: open task chat session.
|
|
310
|
+
const onOpenTaskChat = (taskId: string) => {
|
|
311
|
+
setActiveTab('chat');
|
|
312
|
+
// The Chat view reads crossState.initialChatTaskId to pre-load a session.
|
|
313
|
+
// We set it via a custom event that Chat.tsx listens to on mount.
|
|
314
|
+
window.dispatchEvent(new CustomEvent('bizar:setChatTask', { detail: { taskId } }));
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
// v3.6.2 — Pipeline: open task artifact viewer.
|
|
318
|
+
const onOpenTaskArtifact = (artifactId: string) => {
|
|
319
|
+
openArtifactViewer(modal, artifactId);
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
return (
|
|
323
|
+
<div className="view view-tasks">
|
|
324
|
+
<header className="view-header">
|
|
325
|
+
<div className="view-header-text">
|
|
326
|
+
<h2 className="view-title">
|
|
327
|
+
<CheckSquare size={18} /> Tasks ({sorted.length})
|
|
328
|
+
</h2>
|
|
329
|
+
<p className="view-subtitle">
|
|
330
|
+
Personal kanban. Click a card for details (subtasks, deps, timer, comments, activity).
|
|
331
|
+
</p>
|
|
332
|
+
</div>
|
|
333
|
+
</header>
|
|
334
|
+
|
|
335
|
+
{/* v3.4.0 — Compact single-row toolbar: search + filters + sort + actions */}
|
|
336
|
+
<div className="tasks-toolbar">
|
|
337
|
+
<div className="tasks-toolbar-group">
|
|
338
|
+
<div className="search-input" style={{ width: 200 }}>
|
|
339
|
+
<Search size={12} />
|
|
340
|
+
<input
|
|
341
|
+
className="input"
|
|
342
|
+
type="text"
|
|
343
|
+
placeholder="Search…"
|
|
344
|
+
value={filter}
|
|
345
|
+
onChange={(e) => setFilter(e.target.value)}
|
|
346
|
+
/>
|
|
347
|
+
{filter && (
|
|
348
|
+
<button
|
|
349
|
+
type="button"
|
|
350
|
+
className="icon-btn"
|
|
351
|
+
aria-label="Clear search"
|
|
352
|
+
onClick={() => setFilter('')}
|
|
353
|
+
>
|
|
354
|
+
<XIcon size={12} />
|
|
355
|
+
</button>
|
|
356
|
+
)}
|
|
357
|
+
</div>
|
|
358
|
+
</div>
|
|
359
|
+
|
|
360
|
+
<div className="tasks-toolbar-divider" />
|
|
361
|
+
|
|
362
|
+
<div className="tasks-toolbar-group">
|
|
363
|
+
<span className="tasks-toolbar-label">Filter</span>
|
|
364
|
+
<select
|
|
365
|
+
className="select select-sm"
|
|
366
|
+
value={assigneeFilter}
|
|
367
|
+
onChange={(e) => setAssigneeFilter(e.target.value)}
|
|
368
|
+
title="Assignee"
|
|
369
|
+
>
|
|
370
|
+
<option value="">All assignees</option>
|
|
371
|
+
<option value="me">Me</option>
|
|
372
|
+
{(snapshot.agents || []).map((a) => (
|
|
373
|
+
<option key={a.name} value={a.name}>@{a.name}</option>
|
|
374
|
+
))}
|
|
375
|
+
</select>
|
|
376
|
+
<select
|
|
377
|
+
className="select select-sm"
|
|
378
|
+
value={priorityFilter}
|
|
379
|
+
onChange={(e) => setPriorityFilter(e.target.value)}
|
|
380
|
+
title="Priority"
|
|
381
|
+
>
|
|
382
|
+
<option value="">All priorities</option>
|
|
383
|
+
{PRIORITIES.map((p) => (
|
|
384
|
+
<option key={p} value={p}>{p}</option>
|
|
385
|
+
))}
|
|
386
|
+
</select>
|
|
387
|
+
{allTags.length > 0 && (
|
|
388
|
+
<select
|
|
389
|
+
className="select select-sm"
|
|
390
|
+
value={tagFilter}
|
|
391
|
+
onChange={(e) => setTagFilter(e.target.value)}
|
|
392
|
+
title="Tag"
|
|
393
|
+
>
|
|
394
|
+
<option value="">All tags</option>
|
|
395
|
+
{allTags.map((t) => (
|
|
396
|
+
<option key={t} value={t}>#{t}</option>
|
|
397
|
+
))}
|
|
398
|
+
</select>
|
|
399
|
+
)}
|
|
400
|
+
</div>
|
|
401
|
+
|
|
402
|
+
<div className="tasks-toolbar-divider" />
|
|
403
|
+
|
|
404
|
+
<div className="tasks-toolbar-group">
|
|
405
|
+
<span className="tasks-toolbar-label">Sort</span>
|
|
406
|
+
<select
|
|
407
|
+
className="select select-sm"
|
|
408
|
+
value={sortKey}
|
|
409
|
+
onChange={(e) => setSortKey(e.target.value as SortKey)}
|
|
410
|
+
title="Sort by"
|
|
411
|
+
>
|
|
412
|
+
{SORTS.map((s) => (
|
|
413
|
+
<option key={s.id} value={s.id}>{s.label}</option>
|
|
414
|
+
))}
|
|
415
|
+
</select>
|
|
416
|
+
</div>
|
|
417
|
+
|
|
418
|
+
<div className="tasks-toolbar-spacer" />
|
|
419
|
+
|
|
420
|
+
<div className="tasks-toolbar-group">
|
|
421
|
+
<Button
|
|
422
|
+
variant={showArchived ? 'primary' : 'ghost'}
|
|
423
|
+
size="sm"
|
|
424
|
+
onClick={() => {
|
|
425
|
+
setShowArchived((v) => !v);
|
|
426
|
+
reload({ archived: !showArchived });
|
|
427
|
+
}}
|
|
428
|
+
title={showArchived ? 'Showing archived' : 'Show archived'}
|
|
429
|
+
>
|
|
430
|
+
{showArchived ? <ArchiveRestore size={14} /> : <Archive size={14} />}
|
|
431
|
+
{showArchived ? 'Active' : 'Archived'}
|
|
432
|
+
</Button>
|
|
433
|
+
<Button variant="ghost" size="sm" onClick={() => reload()} title="Refresh" aria-label="Refresh tasks">
|
|
434
|
+
<RefreshCw size={14} />
|
|
435
|
+
</Button>
|
|
436
|
+
<Button
|
|
437
|
+
variant="accent"
|
|
438
|
+
size="sm"
|
|
439
|
+
onClick={() => openSubmitTaskModal(modal, toast, setTasks, reload, refreshSnapshot)}
|
|
440
|
+
>
|
|
441
|
+
<Send size={14} /> Odin
|
|
442
|
+
</Button>
|
|
443
|
+
<Button
|
|
444
|
+
variant="primary"
|
|
445
|
+
size="sm"
|
|
446
|
+
onClick={() => openTaskModal(modal, toast, null, 'queued', setTasks, reload, refreshSnapshot, snapshot.agents)}
|
|
447
|
+
>
|
|
448
|
+
<Plus size={14} /> Add
|
|
449
|
+
</Button>
|
|
450
|
+
</div>
|
|
451
|
+
</div>
|
|
452
|
+
|
|
453
|
+
{selected.size > 0 && (
|
|
454
|
+
<div className="task-bulk-bar">
|
|
455
|
+
<span className="text-sm">
|
|
456
|
+
<CheckSquareIcon size={14} /> {selected.size} selected
|
|
457
|
+
</span>
|
|
458
|
+
<div className="task-bulk-bar-actions">
|
|
459
|
+
<Button size="sm" variant="ghost" onClick={() => onBulkAction('move', { status: 'queued' })}>
|
|
460
|
+
Queued
|
|
461
|
+
</Button>
|
|
462
|
+
<Button size="sm" variant="ghost" onClick={() => onBulkAction('move', { status: 'doing' })}>
|
|
463
|
+
Doing
|
|
464
|
+
</Button>
|
|
465
|
+
<Button size="sm" variant="ghost" onClick={() => onBulkAction('move', { status: 'done' })}>
|
|
466
|
+
Done
|
|
467
|
+
</Button>
|
|
468
|
+
<Button size="sm" variant="ghost" onClick={() => onBulkAction('priority', { priority: 'high' })}>
|
|
469
|
+
High
|
|
470
|
+
</Button>
|
|
471
|
+
<Button size="sm" variant="ghost" onClick={() => onBulkAction('archive')}>
|
|
472
|
+
<Archive size={12} /> Archive
|
|
473
|
+
</Button>
|
|
474
|
+
<Button size="sm" variant="danger" onClick={() => onBulkAction('delete')}>
|
|
475
|
+
<Trash2 size={12} /> Delete
|
|
476
|
+
</Button>
|
|
477
|
+
<Button size="sm" variant="ghost" onClick={() => setSelected(new Set())}>
|
|
478
|
+
Clear
|
|
479
|
+
</Button>
|
|
480
|
+
</div>
|
|
481
|
+
</div>
|
|
482
|
+
)}
|
|
483
|
+
|
|
484
|
+
{loading ? (
|
|
485
|
+
<div className="view-loading"><Spinner size="lg" /></div>
|
|
486
|
+
) : (
|
|
487
|
+
<div className="kanban">
|
|
488
|
+
{COLUMNS.map((col) => (
|
|
489
|
+
<KanbanColumn
|
|
490
|
+
key={col.id}
|
|
491
|
+
column={col}
|
|
492
|
+
// v3.7.4 — Archived tasks (`status: "archived"` OR `archived: true`)
|
|
493
|
+
// surface in the DONE column so earlier-existing tasks are
|
|
494
|
+
// visible from a fresh dashboard load. The TaskCard itself
|
|
495
|
+
// shows an "Archived" badge so the user can distinguish them
|
|
496
|
+
// from completed tasks.
|
|
497
|
+
tasks={sorted.filter((t) => matchesColumn(t, col.id))}
|
|
498
|
+
selected={selected}
|
|
499
|
+
onToggleSelect={toggleSelect}
|
|
500
|
+
onToggleSelectAll={toggleSelectAll}
|
|
501
|
+
allTaskIdsInCol={sorted.filter((t) => matchesColumn(t, col.id)).map((t) => t.id)}
|
|
502
|
+
onMove={moveTask}
|
|
503
|
+
onDelete={deleteTask}
|
|
504
|
+
onArchive={archiveTask}
|
|
505
|
+
onUnarchive={unarchiveTask}
|
|
506
|
+
onEdit={(t) => openTaskDetail(modal, toast, t, setTasks, reload, refreshSnapshot, snapshot.agents, tick)}
|
|
507
|
+
onAdd={() => openTaskModal(modal, toast, null, col.id, setTasks, reload, refreshSnapshot, snapshot.agents)}
|
|
508
|
+
onAssignMe={onAssignMe}
|
|
509
|
+
onMarkWorkedBy={onMarkWorkedBy}
|
|
510
|
+
onOpenChat={onOpenTaskChat}
|
|
511
|
+
onOpenArtifact={onOpenTaskArtifact}
|
|
512
|
+
isArchivedView={showArchived}
|
|
513
|
+
/>
|
|
514
|
+
))}
|
|
515
|
+
</div>
|
|
516
|
+
)}
|
|
517
|
+
</div>
|
|
518
|
+
);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function KanbanColumn({
|
|
522
|
+
column,
|
|
523
|
+
tasks,
|
|
524
|
+
selected,
|
|
525
|
+
onToggleSelect,
|
|
526
|
+
onToggleSelectAll,
|
|
527
|
+
allTaskIdsInCol,
|
|
528
|
+
onMove,
|
|
529
|
+
onDelete,
|
|
530
|
+
onArchive,
|
|
531
|
+
onUnarchive,
|
|
532
|
+
onEdit,
|
|
533
|
+
onAdd,
|
|
534
|
+
onAssignMe,
|
|
535
|
+
onMarkWorkedBy,
|
|
536
|
+
onOpenChat,
|
|
537
|
+
onOpenArtifact,
|
|
538
|
+
isArchivedView,
|
|
539
|
+
}: {
|
|
540
|
+
column: Column;
|
|
541
|
+
tasks: Task[];
|
|
542
|
+
selected: Set<string>;
|
|
543
|
+
onToggleSelect: (id: string) => void;
|
|
544
|
+
onToggleSelectAll: (ids: string[]) => void;
|
|
545
|
+
allTaskIdsInCol: string[];
|
|
546
|
+
onMove: (id: string, status: string) => void;
|
|
547
|
+
onDelete: (id: string) => void;
|
|
548
|
+
onArchive: (id: string) => void;
|
|
549
|
+
onUnarchive: (id: string) => void;
|
|
550
|
+
onEdit: (task: Task) => void;
|
|
551
|
+
onAdd: () => void;
|
|
552
|
+
onAssignMe: (task: Task) => void;
|
|
553
|
+
onMarkWorkedBy: (task: Task, agent: string, status: 'doing' | 'done' | 'idle', complete: boolean) => void;
|
|
554
|
+
onOpenChat: (taskId: string) => void;
|
|
555
|
+
onOpenArtifact: (artifactId: string) => void;
|
|
556
|
+
isArchivedView: boolean;
|
|
557
|
+
}) {
|
|
558
|
+
const [dragOver, setDragOver] = useState(false);
|
|
559
|
+
const allSelected = tasks.length > 0 && allTaskIdsInCol.every((id) => selected.has(id));
|
|
560
|
+
return (
|
|
561
|
+
<div
|
|
562
|
+
className={cn('kanban-column', dragOver && 'kanban-column-drop')}
|
|
563
|
+
data-column={column.id}
|
|
564
|
+
onDragOver={(e) => {
|
|
565
|
+
e.preventDefault();
|
|
566
|
+
setDragOver(true);
|
|
567
|
+
}}
|
|
568
|
+
onDragLeave={() => setDragOver(false)}
|
|
569
|
+
onDrop={(e) => {
|
|
570
|
+
e.preventDefault();
|
|
571
|
+
setDragOver(false);
|
|
572
|
+
const id = e.dataTransfer.getData('text/task-id');
|
|
573
|
+
if (id) onMove(id, column.id);
|
|
574
|
+
}}
|
|
575
|
+
>
|
|
576
|
+
<div className="kanban-col-header">
|
|
577
|
+
<button
|
|
578
|
+
type="button"
|
|
579
|
+
className="icon-btn"
|
|
580
|
+
onClick={() => onToggleSelectAll(allTaskIdsInCol)}
|
|
581
|
+
title={allSelected ? 'Deselect all' : 'Select all'}
|
|
582
|
+
aria-label={allSelected ? 'Deselect all' : 'Select all'}
|
|
583
|
+
>
|
|
584
|
+
{allSelected ? <CheckSquareIcon size={12} /> : <Square size={12} />}
|
|
585
|
+
</button>
|
|
586
|
+
<CardTitle>
|
|
587
|
+
<StatusBadge kind={column.kind} dot>
|
|
588
|
+
{column.label}
|
|
589
|
+
</StatusBadge>
|
|
590
|
+
</CardTitle>
|
|
591
|
+
<span className="kanban-col-count tabular-nums">{tasks.length}</span>
|
|
592
|
+
</div>
|
|
593
|
+
<div className="kanban-col-body">
|
|
594
|
+
{tasks.length === 0 ? (
|
|
595
|
+
<div className="kanban-empty">No tasks</div>
|
|
596
|
+
) : (
|
|
597
|
+
tasks.map((t) => (
|
|
598
|
+
<TaskCard
|
|
599
|
+
key={t.id}
|
|
600
|
+
task={t}
|
|
601
|
+
focused={selected.has(t.id)}
|
|
602
|
+
onFocus={() => onToggleSelect(t.id)}
|
|
603
|
+
onMove={(dir) => {
|
|
604
|
+
const idx = COLUMNS.findIndex((c) => c.id === t.status);
|
|
605
|
+
const next = idx + dir;
|
|
606
|
+
if (next >= 0 && next < COLUMNS.length)
|
|
607
|
+
onMove(t.id, COLUMNS[next].id);
|
|
608
|
+
}}
|
|
609
|
+
onEdit={() => onEdit(t)}
|
|
610
|
+
onDelete={() => onDelete(t.id)}
|
|
611
|
+
onArchive={() => onArchive(t.id)}
|
|
612
|
+
onUnarchive={() => onUnarchive(t.id)}
|
|
613
|
+
onAssignMe={() => onAssignMe(t)}
|
|
614
|
+
onMarkWorkedBy={(agent, status, complete) => onMarkWorkedBy(t, agent, status, complete)}
|
|
615
|
+
onOpenChat={onOpenChat}
|
|
616
|
+
onOpenArtifact={onOpenArtifact}
|
|
617
|
+
isArchivedView={isArchivedView}
|
|
618
|
+
/>
|
|
619
|
+
))
|
|
620
|
+
)}
|
|
621
|
+
</div>
|
|
622
|
+
{!isArchivedView && (
|
|
623
|
+
<footer className="kanban-col-footer">
|
|
624
|
+
<Button variant="ghost" size="sm" onClick={onAdd} className="w-full">
|
|
625
|
+
<Plus size={12} /> Add task
|
|
626
|
+
</Button>
|
|
627
|
+
</footer>
|
|
628
|
+
)}
|
|
629
|
+
</div>
|
|
630
|
+
);
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
function TaskCard({
|
|
634
|
+
task,
|
|
635
|
+
focused,
|
|
636
|
+
onFocus,
|
|
637
|
+
onMove,
|
|
638
|
+
onEdit,
|
|
639
|
+
onDelete,
|
|
640
|
+
onArchive,
|
|
641
|
+
onUnarchive,
|
|
642
|
+
onAssignMe,
|
|
643
|
+
onMarkWorkedBy,
|
|
644
|
+
onOpenChat,
|
|
645
|
+
onOpenArtifact,
|
|
646
|
+
isArchivedView,
|
|
647
|
+
}: {
|
|
648
|
+
task: Task;
|
|
649
|
+
focused: boolean;
|
|
650
|
+
onFocus: () => void;
|
|
651
|
+
onMove: (dir: -1 | 1) => void;
|
|
652
|
+
onEdit: () => void;
|
|
653
|
+
onDelete: () => void;
|
|
654
|
+
onArchive: () => void;
|
|
655
|
+
onUnarchive: () => void;
|
|
656
|
+
onAssignMe: () => void;
|
|
657
|
+
onMarkWorkedBy: (agent: string, status: 'doing' | 'done' | 'idle', complete: boolean) => void;
|
|
658
|
+
onOpenChat: (taskId: string) => void;
|
|
659
|
+
onOpenArtifact: (artifactId: string) => void;
|
|
660
|
+
isArchivedView: boolean;
|
|
661
|
+
}) {
|
|
662
|
+
const toast = useToast();
|
|
663
|
+
const modal = useModal();
|
|
664
|
+
const isTimer = task._timerStart;
|
|
665
|
+
const isWorking = (task.workedBy || task.status === 'doing') && !isArchivedView;
|
|
666
|
+
|
|
667
|
+
// v3.6.2 — Pipeline: determine if task has a chat session or artifact.
|
|
668
|
+
type TaskPipelineMeta = {
|
|
669
|
+
sessionId?: string;
|
|
670
|
+
bgInstanceId?: string;
|
|
671
|
+
status?: string;
|
|
672
|
+
artifactIds?: string[];
|
|
673
|
+
artifactId?: string;
|
|
674
|
+
};
|
|
675
|
+
const metadata = (task.metadata || {}) as TaskPipelineMeta;
|
|
676
|
+
const hasChatSession = !!(metadata.sessionId || metadata.bgInstanceId);
|
|
677
|
+
const artifactIds: string[] = metadata.artifactIds || (metadata.artifactId ? [metadata.artifactId] : []);
|
|
678
|
+
const primaryArtifactId = artifactIds[0];
|
|
679
|
+
return (
|
|
680
|
+
<div
|
|
681
|
+
className={cn(
|
|
682
|
+
'task-card',
|
|
683
|
+
`priority-${task.priority}`,
|
|
684
|
+
focused && 'task-card-focused',
|
|
685
|
+
isTimer ? 'task-card-timer is-working' : null,
|
|
686
|
+
isWorking && !isTimer ? 'is-working' : null,
|
|
687
|
+
)}
|
|
688
|
+
data-task-id={task.id}
|
|
689
|
+
data-task-parent={task.parent || ''}
|
|
690
|
+
draggable={!isArchivedView}
|
|
691
|
+
onDragStart={(e) => {
|
|
692
|
+
e.dataTransfer.setData('text/task-id', task.id);
|
|
693
|
+
e.dataTransfer.effectAllowed = 'move';
|
|
694
|
+
}}
|
|
695
|
+
onClick={onFocus}
|
|
696
|
+
>
|
|
697
|
+
<div className="task-card-head">
|
|
698
|
+
{/* v3.7.0 — Visible checkbox for bulk select */}
|
|
699
|
+
<button
|
|
700
|
+
type="button"
|
|
701
|
+
className={cn('task-card-checkbox', focused && 'task-card-checkbox-on')}
|
|
702
|
+
aria-label={focused ? 'Deselect task' : 'Select task'}
|
|
703
|
+
title={focused ? 'Deselect' : 'Select'}
|
|
704
|
+
onClick={(e) => {
|
|
705
|
+
e.stopPropagation();
|
|
706
|
+
onFocus();
|
|
707
|
+
}}
|
|
708
|
+
>
|
|
709
|
+
{focused ? <CheckSquareIcon size={13} /> : <Square size={13} />}
|
|
710
|
+
</button>
|
|
711
|
+
<span
|
|
712
|
+
className="priority-dot"
|
|
713
|
+
style={{ background: priorityColors[task.priority] || 'var(--info)' }}
|
|
714
|
+
/>
|
|
715
|
+
<div className="task-card-title">{task.title}</div>
|
|
716
|
+
{isTimer && <span className="task-card-timer-pill"><Clock size={10} /> running</span>}
|
|
717
|
+
{task.workedBy && task.status === 'doing' && !isTimer && (
|
|
718
|
+
<span className="task-card-timer-pill" title={`@${task.workedBy}`}>
|
|
719
|
+
<Bot size={10} /> @{task.workedBy}
|
|
720
|
+
</span>
|
|
721
|
+
)}
|
|
722
|
+
{task.recurring && (
|
|
723
|
+
<span className="task-card-timer-pill" title="Recurring">
|
|
724
|
+
<RotateCw size={10} />
|
|
725
|
+
</span>
|
|
726
|
+
)}
|
|
727
|
+
</div>
|
|
728
|
+
{task.description && (
|
|
729
|
+
<div className="task-card-desc">{task.description.slice(0, 160)}</div>
|
|
730
|
+
)}
|
|
731
|
+
<div className="task-card-badges">
|
|
732
|
+
{task.assignee && !task.workedBy && <span className="task-card-badge"><TagIcon size={10} /> @{task.assignee}</span>}
|
|
733
|
+
{task.timeSpent ? <span className="task-card-badge"><Clock size={10} /> {formatDuration(task.timeSpent)}</span> : null}
|
|
734
|
+
{task.dependencies?.length ? <span className="task-card-badge"><Link2 size={10} /> {task.dependencies.length}</span> : null}
|
|
735
|
+
{task.recurring ? <span className="task-card-badge"><Calendar size={10} /> {task.recurring.cron || task.recurring.freq || 'recurring'}</span> : null}
|
|
736
|
+
{task.comments?.length ? <span className="task-card-badge"><MessageSquare size={10} /> {task.comments.length}</span> : null}
|
|
737
|
+
{task.attachments?.length ? <span className="task-card-badge"><Paperclip size={10} /> {task.attachments.length}</span> : null}
|
|
738
|
+
{metadata.bgInstanceId ? (
|
|
739
|
+
<span className="task-card-badge">
|
|
740
|
+
<Activity size={10} /> <BgStatusBadge status={metadata.status || 'pending'} />
|
|
741
|
+
</span>
|
|
742
|
+
) : null}
|
|
743
|
+
</div>
|
|
744
|
+
{task.tags && task.tags.length > 0 && (
|
|
745
|
+
<div className="task-card-tags">
|
|
746
|
+
{task.tags.map((t) => (
|
|
747
|
+
<Tag key={t}>{t}</Tag>
|
|
748
|
+
))}
|
|
749
|
+
</div>
|
|
750
|
+
)}
|
|
751
|
+
<div className="task-card-footer">
|
|
752
|
+
<span className="task-card-time tabular-nums muted">
|
|
753
|
+
{formatRelative(task.updatedAt || task.createdAt)}
|
|
754
|
+
</span>
|
|
755
|
+
<div className="task-card-actions" onClick={(e) => e.stopPropagation()}>
|
|
756
|
+
{!isArchivedView && (
|
|
757
|
+
<>
|
|
758
|
+
<button
|
|
759
|
+
type="button"
|
|
760
|
+
className="icon-btn"
|
|
761
|
+
aria-label="Move left"
|
|
762
|
+
title="Move left"
|
|
763
|
+
onClick={() => onMove(-1)}
|
|
764
|
+
>
|
|
765
|
+
<ChevronLeft size={14} />
|
|
766
|
+
</button>
|
|
767
|
+
<button
|
|
768
|
+
type="button"
|
|
769
|
+
className="icon-btn"
|
|
770
|
+
aria-label="Edit"
|
|
771
|
+
title="Open detail"
|
|
772
|
+
onClick={onEdit}
|
|
773
|
+
>
|
|
774
|
+
<Sparkles size={14} />
|
|
775
|
+
</button>
|
|
776
|
+
<button
|
|
777
|
+
type="button"
|
|
778
|
+
className="icon-btn"
|
|
779
|
+
aria-label="Assign me"
|
|
780
|
+
title="Assign to me"
|
|
781
|
+
onClick={onAssignMe}
|
|
782
|
+
>
|
|
783
|
+
<TagIcon size={14} />
|
|
784
|
+
</button>
|
|
785
|
+
{hasChatSession && (
|
|
786
|
+
<button
|
|
787
|
+
type="button"
|
|
788
|
+
className="icon-btn"
|
|
789
|
+
aria-label="Open chat"
|
|
790
|
+
title="Open in chat"
|
|
791
|
+
onClick={() => onOpenChat(task.id)}
|
|
792
|
+
>
|
|
793
|
+
<MessageSquare size={14} />
|
|
794
|
+
</button>
|
|
795
|
+
)}
|
|
796
|
+
{primaryArtifactId && (
|
|
797
|
+
<button
|
|
798
|
+
type="button"
|
|
799
|
+
className="icon-btn"
|
|
800
|
+
aria-label="Open artifact"
|
|
801
|
+
title="Open artifact"
|
|
802
|
+
onClick={() => onOpenArtifact(primaryArtifactId)}
|
|
803
|
+
>
|
|
804
|
+
<FileText size={14} />
|
|
805
|
+
</button>
|
|
806
|
+
)}
|
|
807
|
+
{isArchivedView ? (
|
|
808
|
+
<button
|
|
809
|
+
type="button"
|
|
810
|
+
className="icon-btn"
|
|
811
|
+
aria-label="Restore"
|
|
812
|
+
title="Restore"
|
|
813
|
+
onClick={onUnarchive}
|
|
814
|
+
>
|
|
815
|
+
<ArchiveRestore size={14} />
|
|
816
|
+
</button>
|
|
817
|
+
) : (
|
|
818
|
+
<button
|
|
819
|
+
type="button"
|
|
820
|
+
className="icon-btn"
|
|
821
|
+
aria-label="Archive"
|
|
822
|
+
title="Archive"
|
|
823
|
+
onClick={onArchive}
|
|
824
|
+
>
|
|
825
|
+
<Archive size={14} />
|
|
826
|
+
</button>
|
|
827
|
+
)}
|
|
828
|
+
{metadata.bgInstanceId && (
|
|
829
|
+
<button
|
|
830
|
+
type="button"
|
|
831
|
+
className="icon-btn icon-btn-danger"
|
|
832
|
+
aria-label="Kill background instance"
|
|
833
|
+
title="Kill background instance"
|
|
834
|
+
onClick={() => {
|
|
835
|
+
openKillConfirmDialog(modal, toast, metadata.bgInstanceId!, task.title, () => {
|
|
836
|
+
// Optimistically mark task as blocked after kill.
|
|
837
|
+
const event = new CustomEvent('bizar:taskKilled', {
|
|
838
|
+
detail: { taskId: task.id, bgInstanceId: metadata.bgInstanceId },
|
|
839
|
+
});
|
|
840
|
+
window.dispatchEvent(event);
|
|
841
|
+
});
|
|
842
|
+
}}
|
|
843
|
+
>
|
|
844
|
+
<Activity size={14} />
|
|
845
|
+
</button>
|
|
846
|
+
)}
|
|
847
|
+
<button
|
|
848
|
+
type="button"
|
|
849
|
+
className="icon-btn icon-btn-danger"
|
|
850
|
+
aria-label="Delete"
|
|
851
|
+
title="Delete"
|
|
852
|
+
onClick={onDelete}
|
|
853
|
+
>
|
|
854
|
+
<Trash2 size={14} />
|
|
855
|
+
</button>
|
|
856
|
+
<button
|
|
857
|
+
type="button"
|
|
858
|
+
className="icon-btn"
|
|
859
|
+
aria-label="Move right"
|
|
860
|
+
title="Move right"
|
|
861
|
+
onClick={() => onMove(1)}
|
|
862
|
+
>
|
|
863
|
+
<ChevronRight size={14} />
|
|
864
|
+
</button>
|
|
865
|
+
</>
|
|
866
|
+
)}
|
|
867
|
+
{isArchivedView && (
|
|
868
|
+
<>
|
|
869
|
+
<button
|
|
870
|
+
type="button"
|
|
871
|
+
className="icon-btn"
|
|
872
|
+
aria-label="Restore"
|
|
873
|
+
title="Restore"
|
|
874
|
+
onClick={onUnarchive}
|
|
875
|
+
>
|
|
876
|
+
<ArchiveRestore size={14} />
|
|
877
|
+
</button>
|
|
878
|
+
<button
|
|
879
|
+
type="button"
|
|
880
|
+
className="icon-btn icon-btn-danger"
|
|
881
|
+
aria-label="Delete"
|
|
882
|
+
title="Delete"
|
|
883
|
+
onClick={onDelete}
|
|
884
|
+
>
|
|
885
|
+
<Trash2 size={14} />
|
|
886
|
+
</button>
|
|
887
|
+
</>
|
|
888
|
+
)}
|
|
889
|
+
</div>
|
|
890
|
+
</div>
|
|
891
|
+
</div>
|
|
892
|
+
);
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
function formatDuration(seconds: number): string {
|
|
896
|
+
if (!seconds) return '0m';
|
|
897
|
+
const h = Math.floor(seconds / 3600);
|
|
898
|
+
const m = Math.floor((seconds % 3600) / 60);
|
|
899
|
+
if (h > 0) return `${h}h ${m}m`;
|
|
900
|
+
return `${m}m`;
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
function openTaskModal(
|
|
904
|
+
modal: ReturnType<typeof useModal>,
|
|
905
|
+
toast: ReturnType<typeof useToast>,
|
|
906
|
+
task: Task | null,
|
|
907
|
+
initialStatus: string,
|
|
908
|
+
setTasks: (updater: (cur: Task[]) => Task[]) => void,
|
|
909
|
+
reload: () => Promise<void>,
|
|
910
|
+
refreshSnapshot: () => Promise<void>,
|
|
911
|
+
agents: Agent[] = [],
|
|
912
|
+
) {
|
|
913
|
+
let titleEl: HTMLInputElement | null = null;
|
|
914
|
+
let descEl: HTMLTextAreaElement | null = null;
|
|
915
|
+
let tagsEl: HTMLInputElement | null = null;
|
|
916
|
+
let priorityEl: HTMLDivElement | null = null;
|
|
917
|
+
let statusEl: HTMLSelectElement | null = null;
|
|
918
|
+
let assigneeEl: HTMLInputElement | null = null;
|
|
919
|
+
let recurEl: HTMLSelectElement | null = null;
|
|
920
|
+
let cronEl: HTMLInputElement | null = null;
|
|
921
|
+
let dueEl: HTMLInputElement | null = null;
|
|
922
|
+
|
|
923
|
+
const submit = async () => {
|
|
924
|
+
const title = titleEl?.value.trim() || '';
|
|
925
|
+
if (!title) {
|
|
926
|
+
toast.warning('Title is required.');
|
|
927
|
+
titleEl?.focus();
|
|
928
|
+
return;
|
|
929
|
+
}
|
|
930
|
+
const description = descEl?.value.trim() || '';
|
|
931
|
+
const priority =
|
|
932
|
+
(priorityEl?.querySelector<HTMLInputElement>(
|
|
933
|
+
'input[name="task-priority"]:checked',
|
|
934
|
+
)?.value as Priority) || 'normal';
|
|
935
|
+
const tagsRaw = tagsEl?.value.trim() || '';
|
|
936
|
+
const tags = tagsRaw
|
|
937
|
+
? tagsRaw.split(',').map((t) => t.trim()).filter(Boolean)
|
|
938
|
+
: [];
|
|
939
|
+
const status = statusEl?.value || initialStatus;
|
|
940
|
+
const assignee = (assigneeEl?.value || '').trim() || null;
|
|
941
|
+
const recurFreq = recurEl?.value || '';
|
|
942
|
+
const cron = cronEl?.value.trim() || '';
|
|
943
|
+
const recurring = recurFreq === 'custom' && cron
|
|
944
|
+
? { freq: 'custom', cron }
|
|
945
|
+
: recurFreq && recurFreq !== 'none'
|
|
946
|
+
? { freq: recurFreq }
|
|
947
|
+
: null;
|
|
948
|
+
const dueDate = dueEl?.value || null;
|
|
949
|
+
|
|
950
|
+
try {
|
|
951
|
+
if (task) {
|
|
952
|
+
const updated = await api.put<Task>(`/tasks/${encodeURIComponent(task.id)}`, {
|
|
953
|
+
title,
|
|
954
|
+
description,
|
|
955
|
+
tags,
|
|
956
|
+
priority,
|
|
957
|
+
status,
|
|
958
|
+
assignee,
|
|
959
|
+
recurring,
|
|
960
|
+
dueDate,
|
|
961
|
+
});
|
|
962
|
+
// v3.3.1 — close modal first, bump safe window, then state.
|
|
963
|
+
modal.close();
|
|
964
|
+
if (typeof window !== 'undefined') {
|
|
965
|
+
window.dispatchEvent(new MouseEvent('mousedown'));
|
|
966
|
+
}
|
|
967
|
+
setTasks((cur) => cur.map((x) => (x.id === task.id ? updated : x)));
|
|
968
|
+
toast.success('Task updated.', 1500);
|
|
969
|
+
} else {
|
|
970
|
+
const created = await api.post<Task>('/tasks', {
|
|
971
|
+
title,
|
|
972
|
+
description,
|
|
973
|
+
status,
|
|
974
|
+
tags,
|
|
975
|
+
priority,
|
|
976
|
+
assignee,
|
|
977
|
+
recurring,
|
|
978
|
+
dueDate,
|
|
979
|
+
});
|
|
980
|
+
// v3.3.1 — close modal first, bump safe window, then state.
|
|
981
|
+
modal.close();
|
|
982
|
+
if (typeof window !== 'undefined') {
|
|
983
|
+
window.dispatchEvent(new MouseEvent('mousedown'));
|
|
984
|
+
}
|
|
985
|
+
setTasks((cur) => [created, ...cur]);
|
|
986
|
+
toast.success('Task created.', 1500);
|
|
987
|
+
}
|
|
988
|
+
await refreshSnapshot();
|
|
989
|
+
} catch (err) {
|
|
990
|
+
toast.error(`Save failed: ${(err as Error).message}`);
|
|
991
|
+
}
|
|
992
|
+
};
|
|
993
|
+
|
|
994
|
+
modal.open({
|
|
995
|
+
title: task ? 'Edit task' : 'New task',
|
|
996
|
+
width: 560,
|
|
997
|
+
children: (
|
|
998
|
+
<div className="task-form">
|
|
999
|
+
<label className="field-label" htmlFor="task-title">Title *</label>
|
|
1000
|
+
<input
|
|
1001
|
+
ref={(el) => { titleEl = el; }}
|
|
1002
|
+
id="task-title"
|
|
1003
|
+
className="input"
|
|
1004
|
+
type="text"
|
|
1005
|
+
maxLength={200}
|
|
1006
|
+
placeholder="What needs to be done?"
|
|
1007
|
+
defaultValue={task?.title || ''}
|
|
1008
|
+
autoFocus
|
|
1009
|
+
/>
|
|
1010
|
+
<label className="field-label" htmlFor="task-desc">Description (markdown)</label>
|
|
1011
|
+
<textarea
|
|
1012
|
+
ref={(el) => { descEl = el; }}
|
|
1013
|
+
id="task-desc"
|
|
1014
|
+
className="textarea"
|
|
1015
|
+
rows={3}
|
|
1016
|
+
placeholder="Markdown supported…"
|
|
1017
|
+
defaultValue={task?.description || ''}
|
|
1018
|
+
/>
|
|
1019
|
+
<div className="task-form-row">
|
|
1020
|
+
<div className="task-form-field">
|
|
1021
|
+
<label className="field-label" htmlFor="task-status">Status</label>
|
|
1022
|
+
<select
|
|
1023
|
+
ref={(el) => { statusEl = el; }}
|
|
1024
|
+
id="task-status"
|
|
1025
|
+
className="select"
|
|
1026
|
+
defaultValue={task?.status || initialStatus}
|
|
1027
|
+
>
|
|
1028
|
+
{COLUMNS.map((c) => (
|
|
1029
|
+
<option key={c.id} value={c.id}>{c.label}</option>
|
|
1030
|
+
))}
|
|
1031
|
+
</select>
|
|
1032
|
+
</div>
|
|
1033
|
+
<div className="task-form-field">
|
|
1034
|
+
<label className="field-label">Priority</label>
|
|
1035
|
+
<div className="radio-row" ref={(el) => { priorityEl = el; }}>
|
|
1036
|
+
{PRIORITIES.map((p) => (
|
|
1037
|
+
<label key={p} className="radio-label">
|
|
1038
|
+
<input
|
|
1039
|
+
type="radio"
|
|
1040
|
+
name="task-priority"
|
|
1041
|
+
value={p}
|
|
1042
|
+
defaultChecked={(task?.priority || 'normal') === p}
|
|
1043
|
+
/>
|
|
1044
|
+
<span className="capitalize">{p}</span>
|
|
1045
|
+
</label>
|
|
1046
|
+
))}
|
|
1047
|
+
</div>
|
|
1048
|
+
</div>
|
|
1049
|
+
</div>
|
|
1050
|
+
<div className="task-form-row">
|
|
1051
|
+
<div className="task-form-field">
|
|
1052
|
+
<label className="field-label" htmlFor="task-assignee">Assignee</label>
|
|
1053
|
+
<input
|
|
1054
|
+
ref={(el) => { assigneeEl = el; }}
|
|
1055
|
+
id="task-assignee"
|
|
1056
|
+
className="input"
|
|
1057
|
+
type="text"
|
|
1058
|
+
placeholder="odin, thor, me, …"
|
|
1059
|
+
list="task-assignee-suggestions"
|
|
1060
|
+
defaultValue={task?.assignee || ''}
|
|
1061
|
+
/>
|
|
1062
|
+
<datalist id="task-assignee-suggestions">
|
|
1063
|
+
{agents.map((a) => (
|
|
1064
|
+
<option key={a.name} value={a.name}>@{a.name}</option>
|
|
1065
|
+
))}
|
|
1066
|
+
</datalist>
|
|
1067
|
+
</div>
|
|
1068
|
+
<div className="task-form-field">
|
|
1069
|
+
<label className="field-label" htmlFor="task-due">Due date</label>
|
|
1070
|
+
<input
|
|
1071
|
+
ref={(el) => { dueEl = el; }}
|
|
1072
|
+
id="task-due"
|
|
1073
|
+
className="input"
|
|
1074
|
+
type="date"
|
|
1075
|
+
defaultValue={task?.dueDate?.slice(0, 10) || ''}
|
|
1076
|
+
/>
|
|
1077
|
+
</div>
|
|
1078
|
+
</div>
|
|
1079
|
+
<label className="field-label" htmlFor="task-tags">Tags</label>
|
|
1080
|
+
<input
|
|
1081
|
+
ref={(el) => { tagsEl = el; }}
|
|
1082
|
+
id="task-tags"
|
|
1083
|
+
className="input"
|
|
1084
|
+
type="text"
|
|
1085
|
+
placeholder="comma-separated, e.g. bug, frontend"
|
|
1086
|
+
defaultValue={(task?.tags || []).join(', ')}
|
|
1087
|
+
/>
|
|
1088
|
+
<div className="task-form-row">
|
|
1089
|
+
<div className="task-form-field">
|
|
1090
|
+
<label className="field-label" htmlFor="task-recur">Recurring</label>
|
|
1091
|
+
<select
|
|
1092
|
+
ref={(el) => { recurEl = el; }}
|
|
1093
|
+
id="task-recur"
|
|
1094
|
+
className="select"
|
|
1095
|
+
defaultValue={task?.recurring?.freq || 'none'}
|
|
1096
|
+
>
|
|
1097
|
+
<option value="none">None</option>
|
|
1098
|
+
<option value="daily">Daily</option>
|
|
1099
|
+
<option value="weekly">Weekly</option>
|
|
1100
|
+
<option value="monthly">Monthly</option>
|
|
1101
|
+
<option value="custom">Custom (cron)</option>
|
|
1102
|
+
</select>
|
|
1103
|
+
</div>
|
|
1104
|
+
<div className="task-form-field">
|
|
1105
|
+
<label className="field-label" htmlFor="task-cron">Cron expression</label>
|
|
1106
|
+
<input
|
|
1107
|
+
ref={(el) => { cronEl = el; }}
|
|
1108
|
+
id="task-cron"
|
|
1109
|
+
className="input"
|
|
1110
|
+
type="text"
|
|
1111
|
+
placeholder="0 9 * * *"
|
|
1112
|
+
defaultValue={task?.recurring?.cron || ''}
|
|
1113
|
+
/>
|
|
1114
|
+
</div>
|
|
1115
|
+
</div>
|
|
1116
|
+
</div>
|
|
1117
|
+
),
|
|
1118
|
+
footer: (
|
|
1119
|
+
<div className="modal-footer-actions">
|
|
1120
|
+
<Button variant="ghost" onClick={() => modal.close()}>Cancel</Button>
|
|
1121
|
+
<Button variant="primary" onClick={submit}>{task ? 'Save' : 'Create'}</Button>
|
|
1122
|
+
</div>
|
|
1123
|
+
),
|
|
1124
|
+
});
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
function openTaskDetail(
|
|
1128
|
+
modal: ReturnType<typeof useModal>,
|
|
1129
|
+
toast: ReturnType<typeof useToast>,
|
|
1130
|
+
task: Task,
|
|
1131
|
+
setTasks: (updater: (cur: Task[]) => Task[]) => void,
|
|
1132
|
+
reload: () => Promise<void>,
|
|
1133
|
+
refreshSnapshot: () => Promise<void>,
|
|
1134
|
+
agents: Agent[] = [],
|
|
1135
|
+
tick: number = 0,
|
|
1136
|
+
) {
|
|
1137
|
+
let commentEl: HTMLTextAreaElement | null = null;
|
|
1138
|
+
let depEl: HTMLInputElement | null = null;
|
|
1139
|
+
let agentEl: HTMLSelectElement | null = null;
|
|
1140
|
+
|
|
1141
|
+
const refresh = async () => {
|
|
1142
|
+
try {
|
|
1143
|
+
const r = await api.get<Task[]>(`/tasks?archived=${task.archived ? 'true' : 'false'}`);
|
|
1144
|
+
const found = r.find((t) => t.id === task.id);
|
|
1145
|
+
if (found) {
|
|
1146
|
+
setTasks((cur) => cur.map((x) => (x.id === found.id ? found : x)));
|
|
1147
|
+
}
|
|
1148
|
+
} catch { /* ignore */ }
|
|
1149
|
+
};
|
|
1150
|
+
|
|
1151
|
+
const onComment = async () => {
|
|
1152
|
+
const text = (commentEl?.value || '').trim();
|
|
1153
|
+
if (!text) return;
|
|
1154
|
+
try {
|
|
1155
|
+
const updated = await api.post<Task>(`/tasks/${encodeURIComponent(task.id)}/comments`, { text });
|
|
1156
|
+
setTasks((cur) => cur.map((x) => (x.id === task.id ? updated : x)));
|
|
1157
|
+
if (commentEl) commentEl.value = '';
|
|
1158
|
+
toast.success('Comment added.', 1200);
|
|
1159
|
+
} catch (err) {
|
|
1160
|
+
toast.error(`Comment failed: ${(err as Error).message}`);
|
|
1161
|
+
}
|
|
1162
|
+
};
|
|
1163
|
+
|
|
1164
|
+
const onStartTimer = async () => {
|
|
1165
|
+
try {
|
|
1166
|
+
const updated = await api.post<Task>(`/tasks/${encodeURIComponent(task.id)}/timer/start`);
|
|
1167
|
+
setTasks((cur) => cur.map((x) => (x.id === task.id ? updated : x)));
|
|
1168
|
+
toast.success('Timer started.', 1200);
|
|
1169
|
+
} catch (err) {
|
|
1170
|
+
toast.error(`Start failed: ${(err as Error).message}`);
|
|
1171
|
+
}
|
|
1172
|
+
};
|
|
1173
|
+
|
|
1174
|
+
const onStopTimer = async () => {
|
|
1175
|
+
try {
|
|
1176
|
+
const updated = await api.post<Task>(`/tasks/${encodeURIComponent(task.id)}/timer/stop`);
|
|
1177
|
+
setTasks((cur) => cur.map((x) => (x.id === task.id ? updated : x)));
|
|
1178
|
+
toast.success('Timer stopped.', 1200);
|
|
1179
|
+
} catch (err) {
|
|
1180
|
+
toast.error(`Stop failed: ${(err as Error).message}`);
|
|
1181
|
+
}
|
|
1182
|
+
};
|
|
1183
|
+
|
|
1184
|
+
const onAddDep = async () => {
|
|
1185
|
+
const id = (depEl?.value || '').trim();
|
|
1186
|
+
if (!id) return;
|
|
1187
|
+
const next = [...(task.dependencies || []), id];
|
|
1188
|
+
try {
|
|
1189
|
+
const updated = await api.put<Task>(`/tasks/${encodeURIComponent(task.id)}`, { dependencies: next });
|
|
1190
|
+
setTasks((cur) => cur.map((x) => (x.id === task.id ? updated : x)));
|
|
1191
|
+
if (depEl) depEl.value = '';
|
|
1192
|
+
toast.success('Dependency added.', 1200);
|
|
1193
|
+
} catch (err) {
|
|
1194
|
+
toast.error(`Failed: ${(err as Error).message}`);
|
|
1195
|
+
}
|
|
1196
|
+
};
|
|
1197
|
+
|
|
1198
|
+
const onMarkWorkedBy = async (status: 'doing' | 'done' | 'idle', complete: boolean) => {
|
|
1199
|
+
const agent = agentEl?.value || '';
|
|
1200
|
+
if (!agent) {
|
|
1201
|
+
toast.warning('Pick an agent first.');
|
|
1202
|
+
return;
|
|
1203
|
+
}
|
|
1204
|
+
try {
|
|
1205
|
+
const updated = await api.post<Task>(`/tasks/${encodeURIComponent(task.id)}/work`, {
|
|
1206
|
+
agent,
|
|
1207
|
+
status,
|
|
1208
|
+
complete,
|
|
1209
|
+
});
|
|
1210
|
+
setTasks((cur) => cur.map((x) => (x.id === task.id ? updated : x)));
|
|
1211
|
+
toast.success(`${status} by @${agent}.`, 1200);
|
|
1212
|
+
} catch (err) {
|
|
1213
|
+
toast.error(`Failed: ${(err as Error).message}`);
|
|
1214
|
+
}
|
|
1215
|
+
};
|
|
1216
|
+
|
|
1217
|
+
const timerSeconds = task._timerStart
|
|
1218
|
+
? Math.floor((Date.now() - task._timerStart) / 1000) + (task.timeSpent || 0)
|
|
1219
|
+
: (task.timeSpent || 0);
|
|
1220
|
+
|
|
1221
|
+
modal.open({
|
|
1222
|
+
title: `Task — ${task.title}`,
|
|
1223
|
+
width: 640,
|
|
1224
|
+
children: (
|
|
1225
|
+
<div className="task-detail">
|
|
1226
|
+
<div className="task-detail-meta">
|
|
1227
|
+
<span><strong>Status:</strong> {task.status}</span>
|
|
1228
|
+
<span><strong>Priority:</strong> {task.priority}</span>
|
|
1229
|
+
{task.assignee && <span><strong>Assignee:</strong> @{task.assignee}</span>}
|
|
1230
|
+
{task.workedBy && <span><strong>Working:</strong> <span className="is-working" style={{ display: 'inline-block', padding: '0 6px' }}>@{task.workedBy}</span></span>}
|
|
1231
|
+
{task.dueDate && <span><strong>Due:</strong> {task.dueDate.slice(0, 10)}</span>}
|
|
1232
|
+
<span><strong>Time:</strong> {formatDuration(timerSeconds)}</span>
|
|
1233
|
+
</div>
|
|
1234
|
+
{task.description && (
|
|
1235
|
+
<Card>
|
|
1236
|
+
<CardTitle>Description</CardTitle>
|
|
1237
|
+
<div className="task-detail-desc">{task.description}</div>
|
|
1238
|
+
</Card>
|
|
1239
|
+
)}
|
|
1240
|
+
<Card>
|
|
1241
|
+
<CardTitle><Bot size={14} /> Mark as worked on by</CardTitle>
|
|
1242
|
+
<div className="task-detail-row">
|
|
1243
|
+
<select ref={(el) => { agentEl = el; }} className="select" defaultValue={task.workedBy || ''}>
|
|
1244
|
+
<option value="">(pick an agent)</option>
|
|
1245
|
+
{agents.map((a) => (
|
|
1246
|
+
<option key={a.name} value={a.name}>@{a.name}</option>
|
|
1247
|
+
))}
|
|
1248
|
+
</select>
|
|
1249
|
+
<Button variant="primary" size="sm" onClick={() => onMarkWorkedBy('doing', false)}>
|
|
1250
|
+
<PlayCircle size={12} /> Start working
|
|
1251
|
+
</Button>
|
|
1252
|
+
<Button variant="success" size="sm" onClick={() => onMarkWorkedBy('done', true)}>
|
|
1253
|
+
<CheckSquare size={12} /> Complete
|
|
1254
|
+
</Button>
|
|
1255
|
+
<Button variant="ghost" size="sm" onClick={() => onMarkWorkedBy('idle', false)}>
|
|
1256
|
+
<PauseCircle size={12} /> Stop
|
|
1257
|
+
</Button>
|
|
1258
|
+
</div>
|
|
1259
|
+
</Card>
|
|
1260
|
+
<Card>
|
|
1261
|
+
<CardTitle><Clock size={14} /> Time tracking</CardTitle>
|
|
1262
|
+
<div className="task-detail-row">
|
|
1263
|
+
{!task._timerStart ? (
|
|
1264
|
+
<Button variant="secondary" size="sm" onClick={onStartTimer}>
|
|
1265
|
+
<PlayCircle size={12} /> Start
|
|
1266
|
+
</Button>
|
|
1267
|
+
) : (
|
|
1268
|
+
<Button variant="secondary" size="sm" onClick={onStopTimer} className="is-working">
|
|
1269
|
+
<PauseCircle size={12} /> Stop
|
|
1270
|
+
</Button>
|
|
1271
|
+
)}
|
|
1272
|
+
<span className={cn('muted', task._timerStart ? 'is-working' : null)}>
|
|
1273
|
+
{formatDuration(timerSeconds)}
|
|
1274
|
+
{task._timerStart && ' (running)'}
|
|
1275
|
+
</span>
|
|
1276
|
+
</div>
|
|
1277
|
+
</Card>
|
|
1278
|
+
<Card>
|
|
1279
|
+
<CardTitle><Link2 size={14} /> Dependencies</CardTitle>
|
|
1280
|
+
<div className="task-detail-row">
|
|
1281
|
+
<input
|
|
1282
|
+
ref={(el) => { depEl = el; }}
|
|
1283
|
+
className="input"
|
|
1284
|
+
type="text"
|
|
1285
|
+
placeholder="task id (e.g. tsk_abc12345)"
|
|
1286
|
+
/>
|
|
1287
|
+
<Button variant="secondary" size="sm" onClick={onAddDep}>Add</Button>
|
|
1288
|
+
</div>
|
|
1289
|
+
<ul>
|
|
1290
|
+
{(task.dependencies || []).map((d) => (
|
|
1291
|
+
<li key={d}><code>{d}</code></li>
|
|
1292
|
+
))}
|
|
1293
|
+
{(task.dependencies || []).length === 0 && <li className="muted">No dependencies.</li>}
|
|
1294
|
+
</ul>
|
|
1295
|
+
</Card>
|
|
1296
|
+
{task.recurring && (
|
|
1297
|
+
<Card>
|
|
1298
|
+
<CardTitle><Calendar size={14} /> Recurring</CardTitle>
|
|
1299
|
+
<div className="muted">Frequency: <code>{task.recurring.freq || 'custom'}</code> {task.recurring.cron && <code>{task.recurring.cron}</code>}</div>
|
|
1300
|
+
{task.recurring.lastGenerated && (
|
|
1301
|
+
<div className="muted text-sm">Last spawned: {formatRelative(task.recurring.lastGenerated)}</div>
|
|
1302
|
+
)}
|
|
1303
|
+
</Card>
|
|
1304
|
+
)}
|
|
1305
|
+
<Card>
|
|
1306
|
+
<CardTitle><MessageSquare size={14} /> Comments</CardTitle>
|
|
1307
|
+
<textarea
|
|
1308
|
+
ref={(el) => { commentEl = el; }}
|
|
1309
|
+
className="textarea"
|
|
1310
|
+
rows={3}
|
|
1311
|
+
placeholder="Add a comment…"
|
|
1312
|
+
/>
|
|
1313
|
+
<Button variant="primary" size="sm" onClick={onComment}>Post</Button>
|
|
1314
|
+
<ul className="task-comments">
|
|
1315
|
+
{(task.comments || []).map((c) => (
|
|
1316
|
+
<li key={c.id}>
|
|
1317
|
+
<div className="muted tabular-nums">{formatRelative(c.createdAt)}</div>
|
|
1318
|
+
<div>{c.text}</div>
|
|
1319
|
+
</li>
|
|
1320
|
+
))}
|
|
1321
|
+
{(task.comments || []).length === 0 && <li className="muted">No comments.</li>}
|
|
1322
|
+
</ul>
|
|
1323
|
+
</Card>
|
|
1324
|
+
<Card>
|
|
1325
|
+
<CardTitle><Activity size={14} /> Activity</CardTitle>
|
|
1326
|
+
<ul className="task-activity">
|
|
1327
|
+
{(task.activity || []).slice().reverse().slice(0, 20).map((a) => (
|
|
1328
|
+
<li key={a.id}>
|
|
1329
|
+
<span className="muted tabular-nums">{formatRelative(a.ts)}</span>{' '}
|
|
1330
|
+
<span className="tag">{a.type}</span>
|
|
1331
|
+
{a.data && typeof a.data === 'object' ? (
|
|
1332
|
+
<code className="muted"> {JSON.stringify(a.data)}</code>
|
|
1333
|
+
) : null}
|
|
1334
|
+
</li>
|
|
1335
|
+
))}
|
|
1336
|
+
{(task.activity || []).length === 0 && <li className="muted">No activity yet.</li>}
|
|
1337
|
+
</ul>
|
|
1338
|
+
</Card>
|
|
1339
|
+
</div>
|
|
1340
|
+
),
|
|
1341
|
+
footer: (
|
|
1342
|
+
<div className="modal-footer-actions">
|
|
1343
|
+
<Button variant="ghost" onClick={() => modal.close()}>Close</Button>
|
|
1344
|
+
<Button variant="primary" onClick={() => { modal.close(); openTaskModal(modal, toast, task, task.status, setTasks, reload, refreshSnapshot, agents); }}>
|
|
1345
|
+
<Sparkles size={12} /> Edit basics
|
|
1346
|
+
</Button>
|
|
1347
|
+
</div>
|
|
1348
|
+
),
|
|
1349
|
+
});
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
// v3.2.0 — Submit a task to Odin. Odin splits it into subtasks and
|
|
1353
|
+
// assigns each to the best-fit agent. Falls back to a regular task
|
|
1354
|
+
// create if the delegator route is unavailable (older dashboards).
|
|
1355
|
+
function openSubmitTaskModal(
|
|
1356
|
+
modal: ReturnType<typeof useModal>,
|
|
1357
|
+
toast: ReturnType<typeof useToast>,
|
|
1358
|
+
setTasks: (updater: (cur: Task[]) => Task[]) => void,
|
|
1359
|
+
reload: () => Promise<void>,
|
|
1360
|
+
refreshSnapshot: () => Promise<void>,
|
|
1361
|
+
) {
|
|
1362
|
+
let titleEl: HTMLInputElement | null = null;
|
|
1363
|
+
let descEl: HTMLTextAreaElement | null = null;
|
|
1364
|
+
let priorityEl: HTMLSelectElement | null = null;
|
|
1365
|
+
let tagsEl: HTMLInputElement | null = null;
|
|
1366
|
+
|
|
1367
|
+
// v3.3.0 — Wraps the submit click so we can preventDefault /
|
|
1368
|
+
// stopPropagation. Without this, a click on a button could bubble
|
|
1369
|
+
// up through the modal portal after the modal closes and either
|
|
1370
|
+
// trigger a sibling button or, more importantly, allow a
|
|
1371
|
+
// subsequent digit-key event to fire setActiveTab("overview")
|
|
1372
|
+
// because the just-closed modal had removed the user's focused
|
|
1373
|
+
// form control.
|
|
1374
|
+
const onSubmit = async (e?: React.SyntheticEvent) => {
|
|
1375
|
+
if (e && typeof e.preventDefault === 'function') e.preventDefault();
|
|
1376
|
+
if (e && typeof e.stopPropagation === 'function') e.stopPropagation();
|
|
1377
|
+
const description = descEl?.value || '';
|
|
1378
|
+
let title = (titleEl?.value || '').trim();
|
|
1379
|
+
// v3.7.1 — Title is now optional. Auto-generate from the description
|
|
1380
|
+
// when the user left it blank. Reject only if BOTH fields are empty.
|
|
1381
|
+
if (!title && description.trim()) {
|
|
1382
|
+
title = autoTitleFromContent(description);
|
|
1383
|
+
if (titleEl) titleEl.value = title;
|
|
1384
|
+
}
|
|
1385
|
+
if (!title) {
|
|
1386
|
+
toast.warning('Add a description so Odin knows what to do.');
|
|
1387
|
+
return;
|
|
1388
|
+
}
|
|
1389
|
+
const priority = priorityEl?.value || 'normal';
|
|
1390
|
+
const tags = (tagsEl?.value || '')
|
|
1391
|
+
.split(',')
|
|
1392
|
+
.map((t: string) => t.trim())
|
|
1393
|
+
.filter(Boolean);
|
|
1394
|
+
|
|
1395
|
+
try {
|
|
1396
|
+
const result = await api.post<{ main: Task; subtasks: Task[] }>('/tasks/submit', {
|
|
1397
|
+
title,
|
|
1398
|
+
description,
|
|
1399
|
+
priority,
|
|
1400
|
+
tags,
|
|
1401
|
+
});
|
|
1402
|
+
const count = (result.subtasks || []).length;
|
|
1403
|
+
toast.success(
|
|
1404
|
+
count > 1
|
|
1405
|
+
? `Odin split it into ${count} subtasks.`
|
|
1406
|
+
: 'Task submitted to Odin.',
|
|
1407
|
+
);
|
|
1408
|
+
// v3.3.1 — CRITICAL: close modal FIRST, then bump safe window,
|
|
1409
|
+
// then update state. This way the portal unmount + React re-render
|
|
1410
|
+
// can't trigger any key handlers in the brief window before state
|
|
1411
|
+
// updates. We manually dispatch a synthetic mousedown to extend
|
|
1412
|
+
// the safe window so any stray keyboard events during the state-
|
|
1413
|
+
// update tick also can't trigger tab switches.
|
|
1414
|
+
modal.close();
|
|
1415
|
+
if (typeof window !== 'undefined') {
|
|
1416
|
+
window.dispatchEvent(new MouseEvent('mousedown'));
|
|
1417
|
+
}
|
|
1418
|
+
if (setTasks) setTasks((cur: Task[]) => [result.main, ...(result.subtasks || []), ...cur]);
|
|
1419
|
+
if (reload) await reload();
|
|
1420
|
+
if (refreshSnapshot) await refreshSnapshot();
|
|
1421
|
+
} catch (err) {
|
|
1422
|
+
toast.error(`Submit failed: ${(err as Error).message}`);
|
|
1423
|
+
}
|
|
1424
|
+
};
|
|
1425
|
+
|
|
1426
|
+
// v3.3.0 — Submit on Enter from the title input. Without an
|
|
1427
|
+
// explicit onKeyDown, the implicit form-submit behavior can fire
|
|
1428
|
+
// when a user presses Enter. Even though we don't wrap inputs in
|
|
1429
|
+
// <form>, this is the safest pattern.
|
|
1430
|
+
const onTitleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
1431
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
1432
|
+
e.preventDefault();
|
|
1433
|
+
onSubmit(e);
|
|
1434
|
+
}
|
|
1435
|
+
};
|
|
1436
|
+
|
|
1437
|
+
modal.open({
|
|
1438
|
+
title: 'Submit Task to Odin',
|
|
1439
|
+
width: 560,
|
|
1440
|
+
children: (
|
|
1441
|
+
<div className="submit-task-form">
|
|
1442
|
+
<label htmlFor="submit-task-title">
|
|
1443
|
+
Title <span className="field-hint">(optional — auto-generated from description)</span>
|
|
1444
|
+
<input
|
|
1445
|
+
id="submit-task-title"
|
|
1446
|
+
ref={(el) => { titleEl = el; }}
|
|
1447
|
+
className="input"
|
|
1448
|
+
type="text"
|
|
1449
|
+
placeholder="Leave blank to derive from description"
|
|
1450
|
+
autoFocus
|
|
1451
|
+
onKeyDown={onTitleKeyDown}
|
|
1452
|
+
/>
|
|
1453
|
+
<span className="field-hint">
|
|
1454
|
+
Odin will analyze this and split it into subtasks assigned to the best agent.
|
|
1455
|
+
</span>
|
|
1456
|
+
</label>
|
|
1457
|
+
<label htmlFor="submit-task-desc">
|
|
1458
|
+
Description
|
|
1459
|
+
<span style={{ display: 'flex', gap: 6, alignItems: 'flex-start' }}>
|
|
1460
|
+
<textarea
|
|
1461
|
+
id="submit-task-desc"
|
|
1462
|
+
ref={(el) => { descEl = el; }}
|
|
1463
|
+
className="textarea"
|
|
1464
|
+
rows={5}
|
|
1465
|
+
placeholder="Provide more detail (markdown ok)…"
|
|
1466
|
+
style={{ flex: 1 }}
|
|
1467
|
+
/>
|
|
1468
|
+
<button
|
|
1469
|
+
type="button"
|
|
1470
|
+
className="btn btn-ghost btn-icon"
|
|
1471
|
+
style={{ marginTop: 0, flexShrink: 0 }}
|
|
1472
|
+
title="Enhance prompt with AI"
|
|
1473
|
+
onClick={async () => {
|
|
1474
|
+
if (!descEl?.value?.trim()) return;
|
|
1475
|
+
const enhanced = await enhancePrompt(descEl.value);
|
|
1476
|
+
if (enhanced !== descEl.value) descEl.value = enhanced;
|
|
1477
|
+
}}
|
|
1478
|
+
>
|
|
1479
|
+
<Sparkles size={14} />
|
|
1480
|
+
</button>
|
|
1481
|
+
</span>
|
|
1482
|
+
</label>
|
|
1483
|
+
<div className="task-form-row">
|
|
1484
|
+
<label htmlFor="submit-task-priority" className="task-form-field">
|
|
1485
|
+
Priority
|
|
1486
|
+
<select
|
|
1487
|
+
id="submit-task-priority"
|
|
1488
|
+
ref={(el) => { priorityEl = el; }}
|
|
1489
|
+
className="select"
|
|
1490
|
+
defaultValue="normal"
|
|
1491
|
+
>
|
|
1492
|
+
{PRIORITIES.map((p) => (
|
|
1493
|
+
<option key={p} value={p}>{p}</option>
|
|
1494
|
+
))}
|
|
1495
|
+
</select>
|
|
1496
|
+
</label>
|
|
1497
|
+
<label htmlFor="submit-task-tags" className="task-form-field" style={{ flex: 2 }}>
|
|
1498
|
+
Tags <span className="field-hint">(comma-separated)</span>
|
|
1499
|
+
<input
|
|
1500
|
+
id="submit-task-tags"
|
|
1501
|
+
ref={(el) => { tagsEl = el; }}
|
|
1502
|
+
className="input"
|
|
1503
|
+
type="text"
|
|
1504
|
+
placeholder="backend, auth"
|
|
1505
|
+
/>
|
|
1506
|
+
</label>
|
|
1507
|
+
</div>
|
|
1508
|
+
</div>
|
|
1509
|
+
),
|
|
1510
|
+
footer: (
|
|
1511
|
+
<div className="modal-footer-actions">
|
|
1512
|
+
<Button variant="ghost" onClick={() => modal.close()}>Cancel</Button>
|
|
1513
|
+
<Button
|
|
1514
|
+
variant="primary"
|
|
1515
|
+
type="button"
|
|
1516
|
+
onClick={(e) => onSubmit(e)}
|
|
1517
|
+
>
|
|
1518
|
+
<Send size={14} /> Submit to Odin
|
|
1519
|
+
</Button>
|
|
1520
|
+
</div>
|
|
1521
|
+
),
|
|
1522
|
+
});
|
|
1523
|
+
}
|