@aion0/forge 0.5.26 → 0.5.28
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/.forge/worktrees/pipeline-4dd8dc2d/CLAUDE.md +86 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/README.md +136 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/RELEASE_NOTES.md +36 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/agents/route.ts +17 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/auth/[...nextauth]/route.ts +3 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/auth/verify/route.ts +46 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/claude/[id]/route.ts +31 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/claude/[id]/stream/route.ts +63 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/claude/route.ts +28 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/claude-sessions/[projectName]/entries/route.ts +23 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/claude-sessions/[projectName]/live/route.ts +72 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/claude-sessions/[projectName]/route.ts +37 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/claude-sessions/sync/route.ts +17 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/claude-templates/route.ts +145 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/code/route.ts +299 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/delivery/[id]/route.ts +62 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/delivery/route.ts +40 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/detect-cli/route.ts +46 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/docs/route.ts +176 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/docs/sessions/route.ts +54 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/favorites/route.ts +26 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/flows/route.ts +6 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/flows/run/route.ts +19 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/git/route.ts +149 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/help/route.ts +84 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/issue-scanner/route.ts +116 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/logs/route.ts +100 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/mobile-chat/route.ts +115 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/monitor/route.ts +74 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/notifications/route.ts +42 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/notify/test/route.ts +33 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/online/route.ts +40 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/pipelines/[id]/route.ts +41 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/pipelines/route.ts +90 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/plugins/route.ts +75 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/preview/[...path]/route.ts +64 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/preview/route.ts +156 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/project-pipelines/route.ts +91 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/project-sessions/route.ts +61 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/projects/route.ts +26 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/sessions/[id]/chat/route.ts +64 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/sessions/[id]/messages/route.ts +9 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/sessions/[id]/route.ts +17 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/sessions/route.ts +20 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/settings/route.ts +64 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/skills/local/route.ts +228 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/skills/route.ts +182 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/smith-templates/route.ts +81 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/status/route.ts +12 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/tabs/route.ts +25 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/tasks/[id]/route.ts +51 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/tasks/[id]/stream/route.ts +77 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/tasks/link/route.ts +37 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/tasks/route.ts +44 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/tasks/session/route.ts +14 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/telegram/route.ts +23 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/templates/route.ts +6 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/terminal-bell/route.ts +39 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/terminal-cwd/route.ts +19 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/terminal-state/route.ts +15 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/tunnel/route.ts +26 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/upgrade/route.ts +43 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/usage/route.ts +20 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/version/route.ts +78 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/watchers/route.ts +33 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/workspace/[id]/agents/route.ts +35 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/workspace/[id]/memory/route.ts +23 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/workspace/[id]/smith/route.ts +22 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/workspace/[id]/stream/route.ts +31 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/workspace/route.ts +79 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/global-error.tsx +21 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/globals.css +52 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/icon.ico +0 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/icon.png +0 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/icon.svg +106 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/layout.tsx +17 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/login/LoginForm.tsx +96 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/login/page.tsx +10 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/mobile/page.tsx +10 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/page.tsx +22 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/bin/forge-server.mjs +484 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/check-forge-status.sh +71 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/cli/mw.ts +579 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/BrowserPanel.tsx +175 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/ChatPanel.tsx +191 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/ClaudeTerminal.tsx +267 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/CodeViewer.tsx +787 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/ConversationEditor.tsx +411 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/ConversationGraphView.tsx +347 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/ConversationTerminalView.tsx +303 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/Dashboard.tsx +807 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/DashboardWrapper.tsx +9 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/DeliveryFlowEditor.tsx +491 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/DeliveryList.tsx +230 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/DeliveryWorkspace.tsx +589 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/DocTerminal.tsx +187 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/DocsViewer.tsx +574 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/HelpDialog.tsx +169 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/HelpTerminal.tsx +141 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/InlinePipelineView.tsx +111 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/LogViewer.tsx +194 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/MarkdownContent.tsx +73 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/MobileView.tsx +385 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/MonitorPanel.tsx +122 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/NewSessionModal.tsx +93 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/NewTaskModal.tsx +492 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/PipelineEditor.tsx +570 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/PipelineView.tsx +1018 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/PluginsPanel.tsx +472 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/ProjectDetail.tsx +1618 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/ProjectList.tsx +108 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/ProjectManager.tsx +401 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/SessionList.tsx +74 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/SessionView.tsx +726 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/SettingsModal.tsx +1647 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/SkillsPanel.tsx +969 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/StatusBar.tsx +99 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/TabBar.tsx +46 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/TaskBoard.tsx +113 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/TaskDetail.tsx +372 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/TerminalLauncher.tsx +398 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/TunnelToggle.tsx +206 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/UsagePanel.tsx +207 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/WebTerminal.tsx +1743 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/WorkspaceTree.tsx +221 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/WorkspaceView.tsx +4048 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/dev-test.sh +5 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/docs/Forge_Memory_Layer_Design.docx +0 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/docs/Forge_Strategy_Research_2026.docx +0 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/docs/LOCAL-DEPLOY.md +144 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/docs/roadmap-multi-agent-workflow.md +330 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/forge-logo.png +0 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/forge-logo.svg +106 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/hooks/useSidebarResize.ts +52 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/install.sh +29 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/instrumentation.ts +35 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/agents/claude-adapter.ts +104 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/agents/generic-adapter.ts +64 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/agents/index.ts +245 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/agents/types.ts +70 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/artifacts.ts +106 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/auth.ts +62 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/builtin-plugins/docker.yaml +70 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/builtin-plugins/http.yaml +66 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/builtin-plugins/jenkins.yaml +92 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/builtin-plugins/llm-vision.yaml +85 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/builtin-plugins/playwright.yaml +111 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/builtin-plugins/shell-command.yaml +60 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/builtin-plugins/slack.yaml +48 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/builtin-plugins/webhook.yaml +56 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/claude-process.ts +361 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/claude-sessions.ts +266 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/claude-templates.ts +227 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/cloudflared.ts +424 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/crypto.ts +67 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/delivery.ts +787 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/dirs.ts +99 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/flows.ts +86 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/forge-mcp-server.ts +732 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/forge-skills/forge-inbox.md +38 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/forge-skills/forge-send.md +47 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/forge-skills/forge-status.md +32 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/forge-skills/forge-workspace-sync.md +37 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/help-docs/00-overview.md +40 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/help-docs/01-settings.md +194 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/help-docs/02-telegram.md +41 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/help-docs/03-tunnel.md +31 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/help-docs/04-tasks.md +52 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/help-docs/05-pipelines.md +460 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/help-docs/06-skills.md +43 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/help-docs/07-projects.md +73 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/help-docs/08-rules.md +53 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/help-docs/09-issue-autofix.md +55 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/help-docs/10-troubleshooting.md +89 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/help-docs/11-workspace.md +810 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/help-docs/CLAUDE.md +62 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/init.ts +266 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/issue-scanner.ts +298 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/logger.ts +79 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/notifications.ts +75 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/notify.ts +108 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/password.ts +97 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/pipeline-scheduler.ts +373 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/pipeline.ts +1565 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/plugins/executor.ts +347 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/plugins/registry.ts +228 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/plugins/types.ts +103 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/project-sessions.ts +53 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/projects.ts +86 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/session-manager.ts +156 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/session-utils.ts +53 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/session-watcher.ts +345 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/settings.ts +195 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/skills.ts +458 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/task-manager.ts +951 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/telegram-bot.ts +1477 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/telegram-standalone.ts +83 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/terminal-server.ts +70 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/terminal-standalone.ts +438 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/usage-scanner.ts +249 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/workspace/__tests__/state-machine.test.ts +388 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/workspace/__tests__/workspace.test.ts +311 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/workspace/agent-bus.ts +416 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/workspace/agent-worker.ts +655 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/workspace/backends/api-backend.ts +262 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/workspace/backends/cli-backend.ts +491 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/workspace/index.ts +84 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/workspace/manager.ts +136 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/workspace/orchestrator.ts +3415 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/workspace/persistence.ts +309 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/workspace/presets.ts +649 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/workspace/requests.ts +287 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/workspace/session-monitor.ts +240 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/workspace/skill-installer.ts +275 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/workspace/smith-memory.ts +498 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/workspace/types.ts +241 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/workspace/watch-manager.ts +560 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/workspace-standalone.ts +978 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/middleware.ts +51 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/next.config.ts +26 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/package.json +74 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/pnpm-lock.yaml +3719 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/pnpm-workspace.yaml +1 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/postcss.config.mjs +7 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/publish.sh +133 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/scripts/bench/README.md +66 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/scripts/bench/results/.gitignore +2 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/scripts/bench/run.ts +635 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/scripts/bench/tasks/01-text-utils/task.md +26 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/scripts/bench/tasks/01-text-utils/validator.sh +46 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/scripts/bench/tasks/02-pagination/setup.sh +19 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/scripts/bench/tasks/02-pagination/task.md +48 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/scripts/bench/tasks/02-pagination/validator.sh +69 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/scripts/bench/tasks/03-bug-fix/setup.sh +82 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/scripts/bench/tasks/03-bug-fix/task.md +30 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/scripts/bench/tasks/03-bug-fix/validator.sh +29 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/scripts/verify-usage.ts +178 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/src/config/index.ts +129 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/src/core/db/database.ts +259 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/src/core/memory/strategy.ts +32 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/src/core/providers/chat.ts +65 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/src/core/providers/registry.ts +60 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/src/core/session/manager.ts +190 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/src/types/index.ts +129 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/start.sh +32 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/templates/smith-lead.json +45 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/tsconfig.json +42 -0
- package/RELEASE_NOTES.md +10 -29
- package/app/api/terminal-bell/route.ts +6 -2
- package/app/api/terminal-cwd/route.ts +7 -4
- package/components/CodeViewer.tsx +3 -31
- package/components/Dashboard.tsx +34 -20
- package/components/WebTerminal.tsx +36 -2
- package/lib/terminal-standalone.ts +19 -2
- package/package.json +1 -1
|
@@ -0,0 +1,4048 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect, useCallback, useMemo, useRef, forwardRef, useImperativeHandle, lazy, Suspense } from 'react';
|
|
4
|
+
import { TerminalSessionPickerLazy, fetchAgentSessions, type PickerSelection } from './TerminalLauncher';
|
|
5
|
+
import {
|
|
6
|
+
ReactFlow, Background, Controls, Handle, Position, useReactFlow, ReactFlowProvider,
|
|
7
|
+
type Node, type NodeProps, MarkerType, type NodeChange,
|
|
8
|
+
applyNodeChanges,
|
|
9
|
+
} from '@xyflow/react';
|
|
10
|
+
import '@xyflow/react/dist/style.css';
|
|
11
|
+
|
|
12
|
+
// ─── Types (mirrors lib/workspace/types) ─────────────────
|
|
13
|
+
|
|
14
|
+
interface AgentConfig {
|
|
15
|
+
id: string; label: string; icon: string; role: string;
|
|
16
|
+
type?: 'agent' | 'input';
|
|
17
|
+
primary?: boolean;
|
|
18
|
+
content?: string;
|
|
19
|
+
entries?: { content: string; timestamp: number }[];
|
|
20
|
+
backend: 'api' | 'cli';
|
|
21
|
+
agentId?: string; provider?: string; model?: string;
|
|
22
|
+
dependsOn: string[];
|
|
23
|
+
workDir?: string;
|
|
24
|
+
outputs: string[];
|
|
25
|
+
steps: { id: string; label: string; prompt: string }[];
|
|
26
|
+
requiresApproval?: boolean;
|
|
27
|
+
persistentSession?: boolean;
|
|
28
|
+
skipPermissions?: boolean;
|
|
29
|
+
boundSessionId?: string;
|
|
30
|
+
watch?: { enabled: boolean; interval: number; targets: any[]; action?: 'log' | 'analyze' | 'approve' | 'send_message'; prompt?: string; sendTo?: string };
|
|
31
|
+
plugins?: string[]; // plugin IDs to auto-install when agent is created
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface AgentState {
|
|
35
|
+
smithStatus: 'down' | 'starting' | 'active';
|
|
36
|
+
taskStatus: 'idle' | 'running' | 'done' | 'failed';
|
|
37
|
+
currentStep?: number;
|
|
38
|
+
tmuxSession?: string;
|
|
39
|
+
artifacts: { type: string; path?: string; summary?: string }[];
|
|
40
|
+
error?: string; lastCheckpoint?: number;
|
|
41
|
+
daemonIteration?: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ─── Constants ───────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
const COLORS = [
|
|
47
|
+
{ border: '#22c55e', bg: '#0a1a0a', accent: '#4ade80' },
|
|
48
|
+
{ border: '#3b82f6', bg: '#0a0f1a', accent: '#60a5fa' },
|
|
49
|
+
{ border: '#a855f7', bg: '#100a1a', accent: '#c084fc' },
|
|
50
|
+
{ border: '#f97316', bg: '#1a100a', accent: '#fb923c' },
|
|
51
|
+
{ border: '#ec4899', bg: '#1a0a10', accent: '#f472b6' },
|
|
52
|
+
{ border: '#06b6d4', bg: '#0a1a1a', accent: '#22d3ee' },
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
// Smith status colors
|
|
56
|
+
const SMITH_STATUS: Record<string, { label: string; color: string; glow?: boolean }> = {
|
|
57
|
+
down: { label: 'down', color: '#30363d' },
|
|
58
|
+
starting: { label: 'starting', color: '#f0883e' }, // orange: ensurePersistentSession in progress
|
|
59
|
+
active: { label: 'active', color: '#3fb950', glow: true },
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
// Task status colors
|
|
63
|
+
const TASK_STATUS: Record<string, { label: string; color: string; glow?: boolean }> = {
|
|
64
|
+
idle: { label: 'idle', color: '#30363d' },
|
|
65
|
+
running: { label: 'running', color: '#3fb950', glow: true },
|
|
66
|
+
done: { label: 'done', color: '#58a6ff' },
|
|
67
|
+
failed: { label: 'failed', color: '#f85149' },
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const PRESET_AGENTS: Omit<AgentConfig, 'id'>[] = [
|
|
71
|
+
{
|
|
72
|
+
label: 'Lead', icon: '👑', backend: 'cli', agentId: 'claude', dependsOn: [], outputs: ['docs/lead/'],
|
|
73
|
+
primary: true, persistentSession: true, plugins: ['playwright', 'shell-command'],
|
|
74
|
+
role: `Lead — primary coordinator (recommended for Primary smith). Context auto-includes Workspace Team (all agents, roles, status, missing roles).
|
|
75
|
+
|
|
76
|
+
SOP: Intake → HAS Architect? delegate via create_request : break down yourself → HAS Engineer? create_request(open) : implement in src/ → HAS QA? auto-notified : test yourself → HAS Reviewer? auto-notified : review yourself.
|
|
77
|
+
|
|
78
|
+
SOP: Monitor → get_status + list_requests → stuck/failed agents: send_message or take over → unclaimed requests: nudge Engineers.
|
|
79
|
+
|
|
80
|
+
SOP: Quality Gate → ALL requests done + review=approved + qa=passed → write docs/lead/delivery-summary.md.
|
|
81
|
+
|
|
82
|
+
Gap coverage: missing PM → you break requirements; missing Engineer → you code; missing QA → you test; missing Reviewer → you review. Every delegation uses create_request with acceptance_criteria.`,
|
|
83
|
+
steps: [
|
|
84
|
+
{ id: 'intake', label: 'Intake & Analyze', prompt: 'Read Workspace Team in context. Identify present/missing roles and incoming requirements. Classify scope and plan delegation vs self-handling.' },
|
|
85
|
+
{ id: 'delegate', label: 'Create Requests & Route', prompt: 'create_request for each task with acceptance_criteria. Route to Architect/Engineer or note for self-implementation. Verify with list_requests.' },
|
|
86
|
+
{ id: 'cover-gaps', label: 'Cover Missing Roles', prompt: 'Implement/test/review for any missing role. update_response for each section you cover.' },
|
|
87
|
+
{ id: 'monitor', label: 'Monitor & Unblock', prompt: 'get_status + list_requests. Unblock stuck agents via send_message or take over their work.' },
|
|
88
|
+
{ id: 'gate', label: 'Quality Gate & Summary', prompt: 'Verify all requests done/approved/passed. Write docs/lead/delivery-summary.md.' },
|
|
89
|
+
],
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
label: 'PM', icon: '📋', backend: 'cli', agentId: 'claude', dependsOn: [], outputs: ['docs/prd/'],
|
|
93
|
+
role: `Product Manager. Context auto-includes Workspace Team.
|
|
94
|
+
|
|
95
|
+
SOP: Read upstream input → list docs/prd/ for version history → identify NEW vs covered → create NEW versioned PRD (never overwrite).
|
|
96
|
+
|
|
97
|
+
PRD structure: version + date, summary, goals, user stories with testable acceptance_criteria, constraints, out of scope, open questions.
|
|
98
|
+
|
|
99
|
+
Version: patch (v1.0.1) = clarification, minor (v1.1) = new feature, major (v2.0) = scope overhaul.
|
|
100
|
+
|
|
101
|
+
Handoff: Do NOT create request docs or write code. Architect/Lead reads docs/prd/ downstream.`,
|
|
102
|
+
steps: [
|
|
103
|
+
{ id: 'analyze', label: 'Analyze Requirements', prompt: 'Read Workspace Team. Read upstream input. List docs/prd/ for version history. Identify NEW vs already covered requirements. Decide version number.' },
|
|
104
|
+
{ id: 'write-prd', label: 'Write PRD', prompt: 'Create NEW versioned file in docs/prd/. Include testable acceptance criteria for every user story. Never overwrite existing PRD files.' },
|
|
105
|
+
{ id: 'self-review', label: 'Self-Review', prompt: 'Checklist: criteria testable by QA? Edge cases? Scope clear for Engineer? No duplication? Fix issues.' },
|
|
106
|
+
],
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
label: 'Engineer', icon: '🔨', backend: 'cli', agentId: 'claude', dependsOn: [], outputs: ['src/', 'docs/architecture/'],
|
|
110
|
+
role: `Senior Software Engineer. Context auto-includes Workspace Team.
|
|
111
|
+
|
|
112
|
+
SOP: Find Work → list_requests(status: "open") → claim_request → get_request for details.
|
|
113
|
+
SOP: Implement → read acceptance_criteria → design (docs/architecture/) → code (src/) → self-test.
|
|
114
|
+
SOP: Report → update_response(section: "engineer", data: {files_changed, notes}) → auto-notifies QA/Reviewer.
|
|
115
|
+
|
|
116
|
+
IF claim fails (already taken) → pick next open request.
|
|
117
|
+
IF blocked by unclear requirement → send_message to upstream (Architect/PM/Lead) with specific question.
|
|
118
|
+
IF no open requests → check inbox for direct assignments.
|
|
119
|
+
|
|
120
|
+
Rules: always claim before starting, always update_response when done, follow existing conventions, architecture docs versioned (never overwrite).`,
|
|
121
|
+
steps: [
|
|
122
|
+
{ id: 'claim', label: 'Find & Claim', prompt: 'Read Workspace Team. Check inbox. list_requests(status: "open"). claim_request on highest priority. If none, check inbox.' },
|
|
123
|
+
{ id: 'design', label: 'Design', prompt: 'get_request for details. Read acceptance_criteria. Read existing code + docs/architecture/. Create new architecture doc if significant change.' },
|
|
124
|
+
{ id: 'implement', label: 'Implement', prompt: 'Implement per design. Follow conventions. Track files changed. Run existing tests. Verify against each acceptance_criterion.' },
|
|
125
|
+
{ id: 'report', label: 'Report Done', prompt: 'update_response(section: "engineer") with files_changed and notes. If blocked, send_message upstream.' },
|
|
126
|
+
],
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
label: 'QA', icon: '🧪', backend: 'cli', agentId: 'claude', dependsOn: [], outputs: ['tests/', 'docs/qa/'],
|
|
130
|
+
plugins: ['playwright', 'shell-command'],
|
|
131
|
+
role: `QA Engineer. Context auto-includes Workspace Team.
|
|
132
|
+
|
|
133
|
+
SOP: Find Work → list_requests(status: "qa") → get_request → read acceptance_criteria + engineer's files_changed.
|
|
134
|
+
SOP: Test → map each criterion to test cases → write Playwright tests in tests/e2e/ → run via run_plugin or npx playwright.
|
|
135
|
+
SOP: Report → update_response(section: "qa", data: {result, test_files, findings}).
|
|
136
|
+
|
|
137
|
+
IF result=passed → auto-advances, no message needed.
|
|
138
|
+
IF result=failed → classify: CRITICAL/MAJOR → ONE send_message to Engineer. MINOR → report only, no message.
|
|
139
|
+
|
|
140
|
+
Rules: never fix bugs (report only), each test traces to acceptance_criterion, max 1 consolidated message, no messages during planning/writing steps.`,
|
|
141
|
+
steps: [
|
|
142
|
+
{ id: 'find-work', label: 'Find Work', prompt: 'Read Workspace Team. Check inbox. list_requests(status: "qa"). get_request for acceptance_criteria and engineer notes.' },
|
|
143
|
+
{ id: 'plan', label: 'Test Plan', prompt: 'Map each criterion to test cases (happy path + edge + error). Write docs/qa/test-plan. Skip already-tested unchanged features.' },
|
|
144
|
+
{ id: 'write-tests', label: 'Write Tests', prompt: 'Write Playwright tests in tests/e2e/. Create config if missing. No messages in this step.' },
|
|
145
|
+
{ id: 'execute', label: 'Execute & Report', prompt: 'Run tests. Record pass/fail per criterion. update_response(section: qa). If critical/major failures: ONE send_message to Engineer.' },
|
|
146
|
+
],
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
label: 'Reviewer', icon: '🔍', backend: 'cli', agentId: 'claude', dependsOn: [], outputs: ['docs/review/'],
|
|
150
|
+
role: `Code Reviewer. Context auto-includes Workspace Team.
|
|
151
|
+
|
|
152
|
+
SOP: Find Work → list_requests(status: "review") → get_request → read request + engineer response + QA results.
|
|
153
|
+
SOP: Review each file in files_changed → check: criteria met? code quality? security (OWASP)? performance? → classify CRITICAL/MAJOR/MINOR.
|
|
154
|
+
SOP: Verdict → approved (all good) / changes_requested (issues) / rejected (security/data).
|
|
155
|
+
SOP: Report → update_response(section: "review", data: {result, findings}) → write docs/review/.
|
|
156
|
+
|
|
157
|
+
IF approved → auto-advances to done, no message.
|
|
158
|
+
IF changes_requested → ONE send_message to Engineer with top issues.
|
|
159
|
+
IF rejected → send_message to Engineer AND Lead.
|
|
160
|
+
|
|
161
|
+
Rules: never modify code, review only files_changed (not entire codebase), actionable feedback ("change X to Y because Z"), MINOR findings in report only.`,
|
|
162
|
+
steps: [
|
|
163
|
+
{ id: 'find-work', label: 'Find Work', prompt: 'Read Workspace Team. Check inbox. list_requests(status: "review"). get_request for full context.' },
|
|
164
|
+
{ id: 'review', label: 'Code Review', prompt: 'Review each file in files_changed: criteria met? quality? security? performance? Classify CRITICAL/MAJOR/MINOR.' },
|
|
165
|
+
{ id: 'report', label: 'Verdict & Report', prompt: 'Decide verdict. update_response(section: review). Write docs/review/. If changes_requested/rejected: ONE message to Engineer (+ Lead if rejected).' },
|
|
166
|
+
],
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
label: 'UI Designer', icon: '🎨', backend: 'cli', agentId: 'claude', dependsOn: [], outputs: ['docs/ui-spec.md', 'src/'],
|
|
170
|
+
plugins: ['playwright', 'shell-command'],
|
|
171
|
+
role: `UI/UX Designer — You design and implement user interfaces. You write real UI code, preview it visually, and iterate until the quality meets your standards.
|
|
172
|
+
|
|
173
|
+
Rules:
|
|
174
|
+
- You WRITE CODE, not just specs. Implement the UI yourself.
|
|
175
|
+
- After writing UI code, always preview your work: take a screenshot and review it visually.
|
|
176
|
+
- Iterate: if the screenshot doesn't look right, fix the code and screenshot again. Aim for 3-5 review cycles.
|
|
177
|
+
- Focus on user experience first, aesthetics second
|
|
178
|
+
- Design for the existing tech stack (check project's UI framework)
|
|
179
|
+
- Be specific: colors (hex), spacing (px/rem), typography, component hierarchy
|
|
180
|
+
- Consider responsive design, accessibility (WCAG), dark/light mode
|
|
181
|
+
- Include interaction states: hover, active, disabled, loading, error, empty
|
|
182
|
+
- Reference existing UI patterns in the codebase for consistency
|
|
183
|
+
|
|
184
|
+
Visual review workflow:
|
|
185
|
+
1. Write/modify UI code
|
|
186
|
+
2. Start dev server if not running (e.g., npm run dev)
|
|
187
|
+
3. Take screenshot: run_plugin({ plugin: "<playwright-instance>", action: "screenshot", params: { url: "http://localhost:3000/page" } })
|
|
188
|
+
4. Read the screenshot file to visually evaluate your work
|
|
189
|
+
5. Grade yourself: layout correctness, visual polish, consistency with existing UI, responsiveness
|
|
190
|
+
6. If not satisfied, fix and repeat from step 2
|
|
191
|
+
7. When satisfied, document the final design in docs/ui-spec.md
|
|
192
|
+
|
|
193
|
+
If reference designs or mockups exist in the project (e.g., docs/designs/), study them before implementing.`,
|
|
194
|
+
steps: [
|
|
195
|
+
{ id: 'audit', label: 'UI Audit', prompt: 'Analyze the existing UI: framework used (React/Vue/etc), component library, design tokens (colors, spacing, fonts), layout patterns. Take screenshots of existing pages to understand the current look and feel. Document the current design system.' },
|
|
196
|
+
{ id: 'implement', label: 'Implement UI', prompt: 'Based on the PRD, implement the UI. Write real component code. Start the dev server, take screenshots of your work, and iterate until the visual quality is high. Aim for at least 3 review cycles — screenshot, evaluate, improve.' },
|
|
197
|
+
{ id: 'polish', label: 'Polish & Document', prompt: 'Final polish pass: check all states (loading, empty, error, hover, disabled), responsive breakpoints, dark/light mode. Take final screenshots. Write docs/ui-spec.md documenting: component hierarchy, design decisions, interaction patterns, and accessibility notes.' },
|
|
198
|
+
],
|
|
199
|
+
},
|
|
200
|
+
{
|
|
201
|
+
label: 'Design Evaluator', icon: '🔍', backend: 'cli', agentId: 'claude', dependsOn: [], outputs: ['docs/design-review.md'],
|
|
202
|
+
plugins: ['playwright', 'llm-vision'],
|
|
203
|
+
role: `Design Evaluator — You are a senior design critic. You evaluate UI implementations visually, not by reading code. You are deliberately skeptical and hold work to a high standard.
|
|
204
|
+
|
|
205
|
+
You evaluate on 4 dimensions (each scored 1-10):
|
|
206
|
+
1. **Design Quality** — Visual coherence, distinct identity, not generic/template-like
|
|
207
|
+
2. **Originality** — Evidence of intentional design decisions vs default AI patterns
|
|
208
|
+
3. **Craft** — Typography, spacing, color harmony, alignment, pixel-level polish
|
|
209
|
+
4. **Functionality** — Usability, interaction clarity, error states, responsiveness
|
|
210
|
+
|
|
211
|
+
Rules:
|
|
212
|
+
- NEVER modify code — only evaluate and report
|
|
213
|
+
- Always take screenshots and visually inspect before scoring
|
|
214
|
+
- Use run_plugin with Playwright to screenshot every relevant page/state
|
|
215
|
+
- If llm-vision instances are available, use them for cross-model evaluation
|
|
216
|
+
- Be specific: "the spacing between header and content is 8px, should be 16px for breathing room"
|
|
217
|
+
- A score of 7+ means "good enough to ship". Below 7 means "needs revision"
|
|
218
|
+
- Send feedback to UI Designer via send_message with specific, actionable items
|
|
219
|
+
- If overall score < 7, request changes. If >= 7, approve with minor suggestions.
|
|
220
|
+
|
|
221
|
+
Workflow:
|
|
222
|
+
1. Receive notification that UI Designer has completed work
|
|
223
|
+
2. Take screenshots of all relevant pages and states (normal, loading, error, empty, mobile)
|
|
224
|
+
3. Evaluate each screenshot against the 4 dimensions
|
|
225
|
+
4. Optionally send screenshots to llm-vision instances for additional opinions
|
|
226
|
+
5. Write docs/design-review.md with scores, specific feedback, and verdict
|
|
227
|
+
6. send_message to UI Designer: APPROVE or REQUEST_CHANGES with actionable feedback`,
|
|
228
|
+
steps: [
|
|
229
|
+
{ id: 'screenshot', label: 'Visual Capture', prompt: 'Take screenshots of all pages and states the UI Designer worked on. Include: default view, loading state, error state, empty state, mobile viewport (375px), tablet viewport (768px). Save all screenshots to /tmp/ and list them.' },
|
|
230
|
+
{ id: 'evaluate', label: 'Evaluate', prompt: 'Review each screenshot. Score each page on the 4 dimensions (Design Quality, Originality, Craft, Functionality). Be critical and specific. If llm-vision plugin instances are available, send key screenshots for additional evaluation and compare opinions.' },
|
|
231
|
+
{ id: 'report', label: 'Report & Feedback', prompt: 'Write docs/design-review.md with: overall scores, per-page breakdown, specific issues with suggested fixes. Send verdict to UI Designer via send_message: APPROVE (score >= 7) or REQUEST_CHANGES (score < 7) with the top 3-5 actionable items.' },
|
|
232
|
+
],
|
|
233
|
+
},
|
|
234
|
+
];
|
|
235
|
+
|
|
236
|
+
// ─── API helpers ─────────────────────────────────────────
|
|
237
|
+
|
|
238
|
+
async function wsApi(workspaceId: string, action: string, body?: Record<string, any>) {
|
|
239
|
+
const res = await fetch(`/api/workspace/${workspaceId}/agents`, {
|
|
240
|
+
method: 'POST',
|
|
241
|
+
headers: { 'Content-Type': 'application/json' },
|
|
242
|
+
body: JSON.stringify({ action, ...body }),
|
|
243
|
+
});
|
|
244
|
+
const data = await res.json();
|
|
245
|
+
if (data.warning) {
|
|
246
|
+
alert(`Warning: ${data.warning}`);
|
|
247
|
+
}
|
|
248
|
+
if (!res.ok && data.error) {
|
|
249
|
+
alert(`Error: ${data.error}`);
|
|
250
|
+
}
|
|
251
|
+
return data;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async function ensureWorkspace(projectPath: string, projectName: string): Promise<string> {
|
|
255
|
+
// Find or create workspace
|
|
256
|
+
const res = await fetch(`/api/workspace?projectPath=${encodeURIComponent(projectPath)}`);
|
|
257
|
+
const existing = await res.json();
|
|
258
|
+
if (existing?.id) return existing.id;
|
|
259
|
+
|
|
260
|
+
const createRes = await fetch('/api/workspace', {
|
|
261
|
+
method: 'POST',
|
|
262
|
+
headers: { 'Content-Type': 'application/json' },
|
|
263
|
+
body: JSON.stringify({ projectPath, projectName }),
|
|
264
|
+
});
|
|
265
|
+
const created = await createRes.json();
|
|
266
|
+
return created.id;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// ─── SSE Hook ────────────────────────────────────────────
|
|
270
|
+
|
|
271
|
+
function useWorkspaceStream(workspaceId: string | null, onEvent?: (event: any) => void) {
|
|
272
|
+
const [agents, setAgents] = useState<AgentConfig[]>([]);
|
|
273
|
+
const [states, setStates] = useState<Record<string, AgentState>>({});
|
|
274
|
+
const [logPreview, setLogPreview] = useState<Record<string, string[]>>({});
|
|
275
|
+
const [busLog, setBusLog] = useState<any[]>([]);
|
|
276
|
+
const [daemonActive, setDaemonActive] = useState(false);
|
|
277
|
+
const onEventRef = useRef(onEvent);
|
|
278
|
+
onEventRef.current = onEvent;
|
|
279
|
+
|
|
280
|
+
useEffect(() => {
|
|
281
|
+
if (!workspaceId) return;
|
|
282
|
+
|
|
283
|
+
const es = new EventSource(`/api/workspace/${workspaceId}/stream`);
|
|
284
|
+
|
|
285
|
+
es.onmessage = (e) => {
|
|
286
|
+
try {
|
|
287
|
+
const event = JSON.parse(e.data);
|
|
288
|
+
|
|
289
|
+
if (event.type === 'init') {
|
|
290
|
+
setAgents(event.agents || []);
|
|
291
|
+
setStates(event.agentStates || {});
|
|
292
|
+
setBusLog(event.busLog || []);
|
|
293
|
+
if (event.daemonActive !== undefined) setDaemonActive(event.daemonActive);
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (event.type === 'task_status') {
|
|
298
|
+
setStates(prev => ({
|
|
299
|
+
...prev,
|
|
300
|
+
[event.agentId]: {
|
|
301
|
+
...prev[event.agentId],
|
|
302
|
+
taskStatus: event.taskStatus,
|
|
303
|
+
error: event.error,
|
|
304
|
+
},
|
|
305
|
+
}));
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (event.type === 'smith_status') {
|
|
309
|
+
setStates(prev => ({
|
|
310
|
+
...prev,
|
|
311
|
+
[event.agentId]: {
|
|
312
|
+
...prev[event.agentId],
|
|
313
|
+
smithStatus: event.smithStatus,
|
|
314
|
+
},
|
|
315
|
+
}));
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (event.type === 'log') {
|
|
319
|
+
const entry = event.entry;
|
|
320
|
+
if (entry?.content) {
|
|
321
|
+
setLogPreview(prev => {
|
|
322
|
+
// Summary entries replace the preview entirely (cleaner display)
|
|
323
|
+
if (entry.subtype === 'step_summary' || entry.subtype === 'final_summary') {
|
|
324
|
+
const summaryLines = entry.content.split('\n').filter((l: string) => l.trim()).slice(0, 4);
|
|
325
|
+
return { ...prev, [event.agentId]: summaryLines };
|
|
326
|
+
}
|
|
327
|
+
// Regular logs: append, keep last 3
|
|
328
|
+
const lines = [...(prev[event.agentId] || []), entry.content].slice(-3);
|
|
329
|
+
return { ...prev, [event.agentId]: lines };
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
if (event.type === 'step') {
|
|
335
|
+
setStates(prev => ({
|
|
336
|
+
...prev,
|
|
337
|
+
[event.agentId]: { ...prev[event.agentId], currentStep: event.stepIndex },
|
|
338
|
+
}));
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
if (event.type === 'error') {
|
|
342
|
+
setStates(prev => ({
|
|
343
|
+
...prev,
|
|
344
|
+
[event.agentId]: { ...prev[event.agentId], taskStatus: 'failed', error: event.error },
|
|
345
|
+
}));
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
if (event.type === 'bus_message') {
|
|
349
|
+
setBusLog(prev => prev.some(m => m.id === event.message.id) ? prev : [...prev, event.message]);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
if (event.type === 'bus_message_status') {
|
|
353
|
+
setBusLog(prev => prev.map(m =>
|
|
354
|
+
m.id === event.messageId ? { ...m, status: event.status } : m
|
|
355
|
+
));
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
if (event.type === 'bus_log_updated') {
|
|
359
|
+
setBusLog(event.log || []);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// Server pushed updated agents list + states (after add/remove/update/reset)
|
|
363
|
+
if (event.type === 'agents_changed') {
|
|
364
|
+
const newAgents = event.agents || [];
|
|
365
|
+
setAgents(prev => {
|
|
366
|
+
// Guard: don't accept a smaller agents list unless it was an explicit removal
|
|
367
|
+
// (removal shrinks by exactly 1, not more)
|
|
368
|
+
if (newAgents.length > 0 && newAgents.length < prev.length - 1) {
|
|
369
|
+
console.warn(`[sse] agents_changed: ignoring shrink from ${prev.length} to ${newAgents.length}`);
|
|
370
|
+
return prev;
|
|
371
|
+
}
|
|
372
|
+
return newAgents;
|
|
373
|
+
});
|
|
374
|
+
if (event.agentStates) setStates(event.agentStates);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// Watch alerts — update agent state with last alert
|
|
378
|
+
if (event.type === 'watch_alert') {
|
|
379
|
+
setStates(prev => ({
|
|
380
|
+
...prev,
|
|
381
|
+
[event.agentId]: {
|
|
382
|
+
...prev[event.agentId],
|
|
383
|
+
lastWatchAlert: event.summary,
|
|
384
|
+
lastWatchTime: event.timestamp,
|
|
385
|
+
},
|
|
386
|
+
}));
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Forward special events to the component
|
|
390
|
+
if (event.type === 'user_input_request' || event.type === 'workspace_complete') {
|
|
391
|
+
onEventRef.current?.(event);
|
|
392
|
+
}
|
|
393
|
+
} catch {}
|
|
394
|
+
};
|
|
395
|
+
|
|
396
|
+
return () => es.close();
|
|
397
|
+
}, [workspaceId]);
|
|
398
|
+
|
|
399
|
+
return { agents, states, logPreview, busLog, setAgents, daemonActive, setDaemonActive };
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// ─── Session Target Selector (for Watch) ─────────────────
|
|
403
|
+
|
|
404
|
+
function SessionTargetSelector({ target, agents, projectPath, onChange }: {
|
|
405
|
+
target: { type: string; path?: string; pattern?: string; cmd?: string };
|
|
406
|
+
agents: AgentConfig[];
|
|
407
|
+
projectPath?: string;
|
|
408
|
+
onChange: (updated: typeof target) => void;
|
|
409
|
+
}) {
|
|
410
|
+
const [sessions, setSessions] = useState<{ id: string; modified: string; label: string }[]>([]);
|
|
411
|
+
|
|
412
|
+
// Load sessions and mark fixed session
|
|
413
|
+
useEffect(() => {
|
|
414
|
+
if (!projectPath) return;
|
|
415
|
+
const pName = (projectPath || '').replace(/\/+$/, '').split('/').pop() || '';
|
|
416
|
+
Promise.all([
|
|
417
|
+
fetch(`/api/claude-sessions/${encodeURIComponent(pName)}`).then(r => r.json()).catch(() => []),
|
|
418
|
+
fetch(`/api/project-sessions?projectPath=${encodeURIComponent(projectPath)}`).then(r => r.json()).catch(() => ({})),
|
|
419
|
+
]).then(([data, psData]) => {
|
|
420
|
+
const fixedId = psData?.fixedSessionId || '';
|
|
421
|
+
if (Array.isArray(data)) {
|
|
422
|
+
setSessions(data.map((s: any, i: number) => {
|
|
423
|
+
const sid = s.sessionId || s.id || '';
|
|
424
|
+
const isBound = sid === fixedId;
|
|
425
|
+
const label = isBound ? `${sid.slice(0, 8)} (fixed)` : i === 0 ? `${sid.slice(0, 8)} (latest)` : sid.slice(0, 8);
|
|
426
|
+
return { id: sid, modified: s.modified || '', label };
|
|
427
|
+
}));
|
|
428
|
+
}
|
|
429
|
+
});
|
|
430
|
+
}, [projectPath]);
|
|
431
|
+
|
|
432
|
+
return (
|
|
433
|
+
<>
|
|
434
|
+
<select value={target.path || ''} onChange={e => onChange({ ...target, path: e.target.value, cmd: '' })}
|
|
435
|
+
className="text-[10px] bg-[#161b22] border border-[#30363d] rounded px-1 py-0.5 text-white w-24">
|
|
436
|
+
<option value="">Any agent</option>
|
|
437
|
+
{agents.map(a => <option key={a.id} value={a.id}>{a.icon} {a.label}</option>)}
|
|
438
|
+
</select>
|
|
439
|
+
<select value={target.cmd || ''} onChange={e => onChange({ ...target, cmd: e.target.value })}
|
|
440
|
+
className="text-[10px] bg-[#161b22] border border-[#30363d] rounded px-1 py-0.5 text-white w-28">
|
|
441
|
+
<option value="">Latest session</option>
|
|
442
|
+
{sessions.map(s => (
|
|
443
|
+
<option key={s.id} value={s.id}>{s.label}{s.modified ? ` · ${new Date(s.modified).toLocaleDateString()}` : ''}</option>
|
|
444
|
+
))}
|
|
445
|
+
</select>
|
|
446
|
+
<input value={target.pattern || ''} onChange={e => onChange({ ...target, pattern: e.target.value })}
|
|
447
|
+
placeholder="regex (optional)"
|
|
448
|
+
className="text-[10px] bg-[#161b22] border border-[#30363d] rounded px-1 py-0.5 text-white w-24" />
|
|
449
|
+
</>
|
|
450
|
+
);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// ─── Watch Path Picker (file/directory browser) ─────────
|
|
454
|
+
|
|
455
|
+
function WatchPathPicker({ value, projectPath, onChange }: { value: string; projectPath: string; onChange: (v: string) => void }) {
|
|
456
|
+
const [showBrowser, setShowBrowser] = useState(false);
|
|
457
|
+
const [tree, setTree] = useState<any[]>([]);
|
|
458
|
+
const [search, setSearch] = useState('');
|
|
459
|
+
const [flatFiles, setFlatFiles] = useState<string[]>([]);
|
|
460
|
+
|
|
461
|
+
const loadTree = useCallback(() => {
|
|
462
|
+
if (!projectPath) return;
|
|
463
|
+
fetch(`/api/code?dir=${encodeURIComponent(projectPath)}`)
|
|
464
|
+
.then(r => r.json())
|
|
465
|
+
.then(data => {
|
|
466
|
+
setTree(data.tree || []);
|
|
467
|
+
// Build flat list for search
|
|
468
|
+
const files: string[] = [];
|
|
469
|
+
const walk = (nodes: any[], prefix = '') => {
|
|
470
|
+
for (const n of nodes || []) {
|
|
471
|
+
const path = prefix ? `${prefix}/${n.name}` : n.name;
|
|
472
|
+
files.push(n.type === 'dir' ? path + '/' : path);
|
|
473
|
+
if (n.children) walk(n.children, path);
|
|
474
|
+
}
|
|
475
|
+
};
|
|
476
|
+
walk(data.tree || []);
|
|
477
|
+
setFlatFiles(files);
|
|
478
|
+
})
|
|
479
|
+
.catch(() => {});
|
|
480
|
+
}, [projectPath]);
|
|
481
|
+
|
|
482
|
+
const filtered = search ? flatFiles.filter(f => f.toLowerCase().includes(search.toLowerCase())).slice(0, 30) : [];
|
|
483
|
+
|
|
484
|
+
return (
|
|
485
|
+
<div className="flex-1 flex items-center gap-1 relative">
|
|
486
|
+
<input
|
|
487
|
+
value={value}
|
|
488
|
+
onChange={e => onChange(e.target.value)}
|
|
489
|
+
placeholder="./ (project root)"
|
|
490
|
+
className="text-[10px] bg-[#161b22] border border-[#30363d] rounded px-1 py-0.5 text-white flex-1"
|
|
491
|
+
/>
|
|
492
|
+
<button onClick={() => { setShowBrowser(!showBrowser); if (!showBrowser) loadTree(); }}
|
|
493
|
+
className="text-[9px] px-1 py-0.5 rounded bg-[#30363d] text-gray-400 hover:text-white shrink-0">📂</button>
|
|
494
|
+
|
|
495
|
+
{showBrowser && (
|
|
496
|
+
<div className="absolute left-0 right-0 top-full mt-1 z-50 bg-[#0d1117] border border-[#30363d] rounded-lg shadow-xl max-h-60 overflow-hidden flex flex-col" style={{ minWidth: 250 }}>
|
|
497
|
+
<input
|
|
498
|
+
value={search}
|
|
499
|
+
onChange={e => setSearch(e.target.value)}
|
|
500
|
+
placeholder="Search files & dirs..."
|
|
501
|
+
autoFocus
|
|
502
|
+
className="text-[10px] bg-[#161b22] border-b border-[#30363d] px-2 py-1 text-white focus:outline-none"
|
|
503
|
+
/>
|
|
504
|
+
<div className="overflow-y-auto flex-1">
|
|
505
|
+
{search ? (
|
|
506
|
+
// Search results
|
|
507
|
+
filtered.length > 0 ? filtered.map(f => (
|
|
508
|
+
<div key={f} onClick={() => { onChange(f); setShowBrowser(false); setSearch(''); }}
|
|
509
|
+
className="px-2 py-0.5 text-[9px] text-gray-300 hover:bg-[#161b22] cursor-pointer truncate font-mono">
|
|
510
|
+
{f.endsWith('/') ? `📁 ${f}` : `📄 ${f}`}
|
|
511
|
+
</div>
|
|
512
|
+
)) : <div className="px-2 py-1 text-[9px] text-gray-500">No matches</div>
|
|
513
|
+
) : (
|
|
514
|
+
// Tree view (first 2 levels)
|
|
515
|
+
tree.map(n => <PathTreeNode key={n.name} node={n} prefix="" onSelect={p => { onChange(p); setShowBrowser(false); }} />)
|
|
516
|
+
)}
|
|
517
|
+
</div>
|
|
518
|
+
<div className="flex items-center justify-between px-2 py-0.5 border-t border-[#30363d] bg-[#161b22]">
|
|
519
|
+
<span className="text-[8px] text-gray-600">{flatFiles.length} items</span>
|
|
520
|
+
<button onClick={() => setShowBrowser(false)} className="text-[8px] text-gray-500 hover:text-white">Close</button>
|
|
521
|
+
</div>
|
|
522
|
+
</div>
|
|
523
|
+
)}
|
|
524
|
+
</div>
|
|
525
|
+
);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function PathTreeNode({ node, prefix, onSelect, depth = 0 }: { node: any; prefix: string; onSelect: (path: string) => void; depth?: number }) {
|
|
529
|
+
const [expanded, setExpanded] = useState(depth < 1);
|
|
530
|
+
const path = prefix ? `${prefix}/${node.name}` : node.name;
|
|
531
|
+
const isDir = node.type === 'dir';
|
|
532
|
+
|
|
533
|
+
if (!isDir && depth > 1) return null; // only show files at top 2 levels
|
|
534
|
+
|
|
535
|
+
return (
|
|
536
|
+
<div>
|
|
537
|
+
<div
|
|
538
|
+
onClick={() => isDir ? setExpanded(!expanded) : onSelect(path)}
|
|
539
|
+
className="flex items-center px-2 py-0.5 text-[9px] hover:bg-[#161b22] cursor-pointer"
|
|
540
|
+
style={{ paddingLeft: 8 + depth * 12 }}
|
|
541
|
+
>
|
|
542
|
+
<span className="text-gray-500 mr-1 w-3">{isDir ? (expanded ? '▼' : '▶') : ''}</span>
|
|
543
|
+
<span className={isDir ? 'text-[var(--accent)]' : 'text-gray-400'}>{isDir ? '📁' : '📄'} {node.name}</span>
|
|
544
|
+
{isDir && (
|
|
545
|
+
<button onClick={e => { e.stopPropagation(); onSelect(path + '/'); }}
|
|
546
|
+
className="ml-auto text-[8px] text-gray-600 hover:text-[var(--accent)]">select</button>
|
|
547
|
+
)}
|
|
548
|
+
</div>
|
|
549
|
+
{isDir && expanded && node.children && depth < 2 && (
|
|
550
|
+
node.children.map((c: any) => <PathTreeNode key={c.name} node={c} prefix={path} onSelect={onSelect} depth={depth + 1} />)
|
|
551
|
+
)}
|
|
552
|
+
</div>
|
|
553
|
+
);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// ─── Fixed Session Picker ────────────────────────────────
|
|
557
|
+
|
|
558
|
+
function FixedSessionPicker({ projectPath, value, onChange }: { projectPath?: string; value: string; onChange: (v: string) => void }) {
|
|
559
|
+
const [sessions, setSessions] = useState<{ id: string; modified: string; size: number }[]>([]);
|
|
560
|
+
const [copied, setCopied] = useState(false);
|
|
561
|
+
|
|
562
|
+
useEffect(() => {
|
|
563
|
+
if (!projectPath) return;
|
|
564
|
+
const pName = projectPath.replace(/\/+$/, '').split('/').pop() || '';
|
|
565
|
+
fetch(`/api/claude-sessions/${encodeURIComponent(pName)}`)
|
|
566
|
+
.then(r => r.json())
|
|
567
|
+
.then(data => { if (Array.isArray(data)) setSessions(data.map((s: any) => ({ id: s.sessionId || s.id || '', modified: s.modified || '', size: s.size || 0 }))); })
|
|
568
|
+
.catch(() => {});
|
|
569
|
+
}, [projectPath]);
|
|
570
|
+
|
|
571
|
+
const formatTime = (iso: string) => {
|
|
572
|
+
if (!iso) return '';
|
|
573
|
+
const diff = Date.now() - new Date(iso).getTime();
|
|
574
|
+
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`;
|
|
575
|
+
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`;
|
|
576
|
+
return new Date(iso).toLocaleDateString();
|
|
577
|
+
};
|
|
578
|
+
const formatSize = (b: number) => b < 1024 ? `${b}B` : b < 1048576 ? `${(b / 1024).toFixed(0)}KB` : `${(b / 1048576).toFixed(1)}MB`;
|
|
579
|
+
|
|
580
|
+
const copyId = () => {
|
|
581
|
+
if (!value) return;
|
|
582
|
+
navigator.clipboard.writeText(value).then(() => { setCopied(true); setTimeout(() => setCopied(false), 1500); });
|
|
583
|
+
};
|
|
584
|
+
|
|
585
|
+
return (
|
|
586
|
+
<div className="flex flex-col gap-0.5">
|
|
587
|
+
<label className="text-[9px] text-gray-500">Bound Session {value ? '' : '(auto-detect on first start)'}</label>
|
|
588
|
+
<select value={value} onChange={e => onChange(e.target.value)}
|
|
589
|
+
className="text-[10px] bg-[#161b22] border border-[#30363d] rounded px-2 py-1 text-gray-400 font-mono focus:outline-none focus:border-[#58a6ff]">
|
|
590
|
+
<option value="">Auto-detect (latest session)</option>
|
|
591
|
+
{sessions.map(s => (
|
|
592
|
+
<option key={s.id} value={s.id}>
|
|
593
|
+
{s.id.slice(0, 8)} · {formatTime(s.modified)} · {formatSize(s.size)}
|
|
594
|
+
</option>
|
|
595
|
+
))}
|
|
596
|
+
</select>
|
|
597
|
+
{value && (
|
|
598
|
+
<div className="flex items-center gap-1 mt-0.5">
|
|
599
|
+
<code className="text-[8px] text-gray-500 font-mono bg-[#0d1117] px-1.5 py-0.5 rounded border border-[#21262d] flex-1 overflow-hidden text-ellipsis select-all">{value}</code>
|
|
600
|
+
<button onClick={copyId} className="text-[8px] px-1.5 py-0.5 rounded bg-[#30363d] text-gray-400 hover:text-white shrink-0">{copied ? '✓' : 'Copy'}</button>
|
|
601
|
+
<button onClick={() => onChange('')} className="text-[8px] px-1.5 py-0.5 rounded text-gray-600 hover:text-red-400 shrink-0">Clear</button>
|
|
602
|
+
</div>
|
|
603
|
+
)}
|
|
604
|
+
</div>
|
|
605
|
+
);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// ─── Agent Config Modal ──────────────────────────────────
|
|
609
|
+
|
|
610
|
+
function AgentConfigModal({ initial, mode, existingAgents, projectPath, onConfirm, onCancel }: {
|
|
611
|
+
initial: Partial<AgentConfig>;
|
|
612
|
+
mode: 'add' | 'edit';
|
|
613
|
+
existingAgents: AgentConfig[];
|
|
614
|
+
projectPath?: string;
|
|
615
|
+
onConfirm: (cfg: Omit<AgentConfig, 'id'>) => void;
|
|
616
|
+
onCancel: () => void;
|
|
617
|
+
}) {
|
|
618
|
+
const [label, setLabel] = useState(initial.label || '');
|
|
619
|
+
const [icon, setIcon] = useState(initial.icon || '🤖');
|
|
620
|
+
const [role, setRole] = useState(initial.role || '');
|
|
621
|
+
const [backend, setBackend] = useState<'api' | 'cli'>(initial.backend === 'api' ? 'api' : 'cli');
|
|
622
|
+
const [agentId, setAgentId] = useState(initial.agentId || 'claude');
|
|
623
|
+
const [availableAgents, setAvailableAgents] = useState<{ id: string; name: string; isProfile?: boolean; backendType?: string; base?: string; cliType?: string }[]>([]);
|
|
624
|
+
|
|
625
|
+
const [pluginInstances, setPluginInstances] = useState<{ id: string; name: string; icon: string; source?: string }[]>([]);
|
|
626
|
+
const [pluginDefs, setPluginDefs] = useState<{ id: string; name: string; icon: string }[]>([]);
|
|
627
|
+
|
|
628
|
+
useEffect(() => {
|
|
629
|
+
fetch('/api/agents').then(r => r.json()).then(data => {
|
|
630
|
+
const list = (data.agents || data || []).map((a: any) => ({
|
|
631
|
+
id: a.id, name: a.name || a.id,
|
|
632
|
+
isProfile: a.isProfile || a.base,
|
|
633
|
+
base: a.base,
|
|
634
|
+
cliType: a.cliType,
|
|
635
|
+
backendType: a.backendType || 'cli',
|
|
636
|
+
}));
|
|
637
|
+
setAvailableAgents(list);
|
|
638
|
+
}).catch(() => {});
|
|
639
|
+
// Fetch saved smith templates
|
|
640
|
+
fetch('/api/smith-templates').then(r => r.json()).then(data => {
|
|
641
|
+
setSavedTemplates(data.templates || []);
|
|
642
|
+
}).catch(() => {});
|
|
643
|
+
// Fetch both: plugin definitions + installed instances
|
|
644
|
+
Promise.all([
|
|
645
|
+
fetch('/api/plugins').then(r => r.json()),
|
|
646
|
+
fetch('/api/plugins?installed=true').then(r => r.json()),
|
|
647
|
+
]).then(([defData, instData]) => {
|
|
648
|
+
setPluginDefs((defData.plugins || []).map((p: any) => ({ id: p.id, name: p.name, icon: p.icon })));
|
|
649
|
+
setPluginInstances((instData.plugins || []).map((p: any) => ({
|
|
650
|
+
id: p.id,
|
|
651
|
+
name: p.instanceName || p.definition?.name || p.id,
|
|
652
|
+
icon: p.definition?.icon || '🔌',
|
|
653
|
+
source: p.source,
|
|
654
|
+
})));
|
|
655
|
+
}).catch(() => {});
|
|
656
|
+
}, []);
|
|
657
|
+
const [workDirVal, setWorkDirVal] = useState(initial.workDir || '');
|
|
658
|
+
const [outputs, setOutputs] = useState((initial.outputs || []).join(', '));
|
|
659
|
+
const [selectedDeps, setSelectedDeps] = useState<Set<string>>(new Set(initial.dependsOn || []));
|
|
660
|
+
const [stepsText, setStepsText] = useState(
|
|
661
|
+
(initial.steps || []).map(s => `${s.label}: ${s.prompt}`).join('\n') || ''
|
|
662
|
+
);
|
|
663
|
+
const [requiresApproval, setRequiresApproval] = useState(initial.requiresApproval || false);
|
|
664
|
+
const [isPrimary, setIsPrimary] = useState(initial.primary || false);
|
|
665
|
+
const hasPrimaryAlready = existingAgents.some(a => a.primary && a.id !== initial.id);
|
|
666
|
+
const [persistentSession, setPersistentSession] = useState(initial.persistentSession || initial.primary || false);
|
|
667
|
+
const [skipPermissions, setSkipPermissions] = useState(initial.skipPermissions !== false);
|
|
668
|
+
const [agentModel, setAgentModel] = useState(initial.model || '');
|
|
669
|
+
const [watchEnabled, setWatchEnabled] = useState(initial.watch?.enabled || false);
|
|
670
|
+
const [watchInterval, setWatchInterval] = useState(String(initial.watch?.interval || 60));
|
|
671
|
+
const [watchAction, setWatchAction] = useState<'log' | 'analyze' | 'approve' | 'send_message'>(initial.watch?.action || 'log');
|
|
672
|
+
const [watchPrompt, setWatchPrompt] = useState(initial.watch?.prompt || '');
|
|
673
|
+
const [watchSendTo, setWatchSendTo] = useState(initial.watch?.sendTo || '');
|
|
674
|
+
const [selectedPlugins, setSelectedPlugins] = useState<string[]>(initial.plugins || []);
|
|
675
|
+
const [recommendedTypes, setRecommendedTypes] = useState<string[]>([]);
|
|
676
|
+
const [savedTemplates, setSavedTemplates] = useState<{ id: string; name: string; icon: string; description?: string; config: any }[]>([]);
|
|
677
|
+
const [watchDebounce, setWatchDebounce] = useState(String(initial.watch?.targets?.[0]?.debounce ?? 10));
|
|
678
|
+
const [watchTargets, setWatchTargets] = useState<{ type: string; path?: string; cmd?: string; pattern?: string }[]>(
|
|
679
|
+
initial.watch?.targets || []
|
|
680
|
+
);
|
|
681
|
+
const [projectDirs, setProjectDirs] = useState<string[]>([]);
|
|
682
|
+
|
|
683
|
+
useEffect(() => {
|
|
684
|
+
if (!watchEnabled || !projectPath) return;
|
|
685
|
+
fetch(`/api/code?dir=${encodeURIComponent(projectPath)}`)
|
|
686
|
+
.then(r => r.json())
|
|
687
|
+
.then(data => {
|
|
688
|
+
// Collect directories with depth limit (max 2 levels for readability)
|
|
689
|
+
const dirs: string[] = [];
|
|
690
|
+
const walk = (nodes: any[], prefix = '', depth = 0) => {
|
|
691
|
+
for (const n of nodes || []) {
|
|
692
|
+
if (n.type === 'dir') {
|
|
693
|
+
const path = prefix ? `${prefix}/${n.name}` : n.name;
|
|
694
|
+
dirs.push(path);
|
|
695
|
+
if (n.children && depth < 2) walk(n.children, path, depth + 1);
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
};
|
|
699
|
+
walk(data.tree || []);
|
|
700
|
+
setProjectDirs(dirs);
|
|
701
|
+
})
|
|
702
|
+
.catch(() => {});
|
|
703
|
+
}, [watchEnabled, projectPath]);
|
|
704
|
+
|
|
705
|
+
const applyPreset = (p: Omit<AgentConfig, 'id'>) => {
|
|
706
|
+
setLabel(p.label); setIcon(p.icon); setRole(p.role);
|
|
707
|
+
setBackend(p.backend); setAgentId(p.agentId || 'claude');
|
|
708
|
+
setWorkDirVal(p.workDir || './');
|
|
709
|
+
setOutputs(p.outputs.join(', '));
|
|
710
|
+
setStepsText(p.steps.map(s => `${s.label}: ${s.prompt}`).join('\n'));
|
|
711
|
+
setRecommendedTypes(p.plugins || []);
|
|
712
|
+
setSelectedPlugins(p.plugins || []);
|
|
713
|
+
if (p.persistentSession !== undefined) setPersistentSession(!!p.persistentSession);
|
|
714
|
+
if (p.skipPermissions !== undefined) setSkipPermissions(p.skipPermissions !== false);
|
|
715
|
+
if (p.requiresApproval !== undefined) setRequiresApproval(!!p.requiresApproval);
|
|
716
|
+
if (p.model) setAgentModel(p.model);
|
|
717
|
+
if (p.watch) {
|
|
718
|
+
setWatchEnabled(!!p.watch.enabled);
|
|
719
|
+
setWatchInterval(String(p.watch.interval || 60));
|
|
720
|
+
setWatchAction(p.watch.action || 'log');
|
|
721
|
+
setWatchPrompt(p.watch.prompt || '');
|
|
722
|
+
setWatchSendTo(p.watch.sendTo || '');
|
|
723
|
+
setWatchTargets(p.watch.targets || []);
|
|
724
|
+
setWatchDebounce(String(p.watch.targets?.[0]?.debounce ?? 10));
|
|
725
|
+
}
|
|
726
|
+
};
|
|
727
|
+
|
|
728
|
+
const applySavedTemplate = (t: { config: any }) => {
|
|
729
|
+
const c = t.config;
|
|
730
|
+
applyPreset({
|
|
731
|
+
label: c.label || '', icon: c.icon || '🤖', role: c.role || '',
|
|
732
|
+
backend: c.backend || 'cli', agentId: c.agentId, dependsOn: [],
|
|
733
|
+
workDir: c.workDir || './', outputs: c.outputs || [],
|
|
734
|
+
steps: c.steps || [], plugins: c.plugins,
|
|
735
|
+
persistentSession: c.persistentSession, skipPermissions: c.skipPermissions,
|
|
736
|
+
requiresApproval: c.requiresApproval, model: c.model,
|
|
737
|
+
watch: c.watch,
|
|
738
|
+
} as any);
|
|
739
|
+
};
|
|
740
|
+
|
|
741
|
+
const handleImportFile = () => {
|
|
742
|
+
const input = document.createElement('input');
|
|
743
|
+
input.type = 'file';
|
|
744
|
+
input.accept = '.json';
|
|
745
|
+
input.onchange = async (e) => {
|
|
746
|
+
const file = (e.target as HTMLInputElement).files?.[0];
|
|
747
|
+
if (!file) return;
|
|
748
|
+
try {
|
|
749
|
+
const text = await file.text();
|
|
750
|
+
const data = JSON.parse(text);
|
|
751
|
+
// Support both raw config and template wrapper
|
|
752
|
+
const config = data.config || data;
|
|
753
|
+
applySavedTemplate({ config });
|
|
754
|
+
} catch {
|
|
755
|
+
alert('Invalid template file');
|
|
756
|
+
}
|
|
757
|
+
};
|
|
758
|
+
input.click();
|
|
759
|
+
};
|
|
760
|
+
|
|
761
|
+
const toggleDep = (id: string) => {
|
|
762
|
+
setSelectedDeps(prev => {
|
|
763
|
+
const next = new Set(prev);
|
|
764
|
+
if (next.has(id)) next.delete(id); else next.add(id);
|
|
765
|
+
return next;
|
|
766
|
+
});
|
|
767
|
+
};
|
|
768
|
+
|
|
769
|
+
const parseSteps = () => stepsText.split('\n').filter(Boolean).map((line, i) => {
|
|
770
|
+
const [lbl, ...rest] = line.split(':');
|
|
771
|
+
return { id: `step-${i}`, label: lbl.trim(), prompt: rest.join(':').trim() || lbl.trim() };
|
|
772
|
+
});
|
|
773
|
+
|
|
774
|
+
// Filter out self when editing
|
|
775
|
+
const otherAgents = existingAgents.filter(a => a.id !== initial.id);
|
|
776
|
+
|
|
777
|
+
return (
|
|
778
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center" style={{ background: 'rgba(0,0,0,0.75)' }}
|
|
779
|
+
onClick={e => { if (e.target === e.currentTarget) onCancel(); }}>
|
|
780
|
+
<div className="w-[440px] max-h-[80vh] overflow-auto rounded-lg border border-[#30363d] p-4 shadow-xl" style={{ background: '#0d1117' }}>
|
|
781
|
+
<div className="flex items-center justify-between mb-3">
|
|
782
|
+
<span className="text-sm font-bold text-white">{mode === 'add' ? 'Add Agent' : 'Edit Agent'}</span>
|
|
783
|
+
<button onClick={onCancel} className="text-gray-500 hover:text-white text-xs">✕</button>
|
|
784
|
+
</div>
|
|
785
|
+
|
|
786
|
+
<div className="flex flex-col gap-2.5">
|
|
787
|
+
{/* Preset + saved templates (add mode only) */}
|
|
788
|
+
{mode === 'add' && (
|
|
789
|
+
<div className="flex flex-col gap-1">
|
|
790
|
+
<label className="text-[9px] text-gray-500 uppercase">Presets</label>
|
|
791
|
+
<div className="flex gap-1 flex-wrap">
|
|
792
|
+
{PRESET_AGENTS.map((p, i) => (
|
|
793
|
+
<button key={i} onClick={() => applyPreset(p)}
|
|
794
|
+
title={p.primary ? 'Recommended for Primary smith (runs at project root, coordinates others)' : p.label}
|
|
795
|
+
className={`text-[9px] px-2 py-1 rounded border transition-colors ${label === p.label ? 'border-[#58a6ff] text-[#58a6ff] bg-[#58a6ff]/10' : p.primary ? 'border-[#f0883e]/40 text-[#f0883e] hover:border-[#f0883e]' : 'border-[#30363d] text-gray-400 hover:text-white'}`}>
|
|
796
|
+
{p.icon} {p.label}{p.primary ? ' ★' : ''}
|
|
797
|
+
</button>
|
|
798
|
+
))}
|
|
799
|
+
<button onClick={() => { setLabel(''); setIcon('🤖'); setRole(''); setStepsText(''); setOutputs(''); }}
|
|
800
|
+
className={`text-[9px] px-2 py-1 rounded border border-dashed ${!label ? 'border-[#58a6ff] text-[#58a6ff]' : 'border-[#30363d] text-gray-500 hover:text-white'}`}>
|
|
801
|
+
Custom
|
|
802
|
+
</button>
|
|
803
|
+
</div>
|
|
804
|
+
{savedTemplates.length > 0 && (<>
|
|
805
|
+
<label className="text-[9px] text-gray-500 uppercase mt-1">Saved Templates</label>
|
|
806
|
+
<div className="flex gap-1 flex-wrap">
|
|
807
|
+
{savedTemplates.map(t => (
|
|
808
|
+
<button key={t.id} onClick={() => applySavedTemplate(t)}
|
|
809
|
+
className={`text-[9px] px-2 py-1 rounded border transition-colors ${label === t.config?.label ? 'border-[#f0883e] text-[#f0883e] bg-[#f0883e]/10' : 'border-[#30363d] text-gray-400 hover:text-white'}`}
|
|
810
|
+
title={t.description || t.name}>
|
|
811
|
+
{t.icon} {t.name}
|
|
812
|
+
</button>
|
|
813
|
+
))}
|
|
814
|
+
</div>
|
|
815
|
+
</>)}
|
|
816
|
+
<button onClick={handleImportFile}
|
|
817
|
+
className="text-[9px] px-2 py-1 rounded border border-dashed border-[#30363d] text-gray-500 hover:text-white hover:border-gray-400 self-start mt-0.5">
|
|
818
|
+
📂 Import from file
|
|
819
|
+
</button>
|
|
820
|
+
</div>
|
|
821
|
+
)}
|
|
822
|
+
|
|
823
|
+
{/* Icon + Label */}
|
|
824
|
+
<div className="flex gap-2">
|
|
825
|
+
<div className="flex flex-col gap-1">
|
|
826
|
+
<label className="text-[9px] text-gray-500 uppercase">Icon</label>
|
|
827
|
+
<input value={icon} onChange={e => setIcon(e.target.value)} className="w-12 text-center text-sm bg-[#161b22] border border-[#30363d] rounded px-1 py-1 text-white focus:outline-none focus:border-[#58a6ff]" />
|
|
828
|
+
</div>
|
|
829
|
+
<div className="flex flex-col gap-1 flex-1">
|
|
830
|
+
<label className="text-[9px] text-gray-500 uppercase">Label</label>
|
|
831
|
+
<input value={label} onChange={e => setLabel(e.target.value)} placeholder="e.g. Engineer" className="text-xs bg-[#161b22] border border-[#30363d] rounded px-2 py-1 text-white focus:outline-none focus:border-[#58a6ff]" />
|
|
832
|
+
</div>
|
|
833
|
+
</div>
|
|
834
|
+
|
|
835
|
+
{/* Backend */}
|
|
836
|
+
<div className="flex flex-col gap-1">
|
|
837
|
+
<label className="text-[9px] text-gray-500 uppercase">Backend</label>
|
|
838
|
+
<div className="flex gap-1">
|
|
839
|
+
{(['cli', 'api'] as const).map(b => (
|
|
840
|
+
<button key={b} onClick={() => setBackend(b)}
|
|
841
|
+
className={`text-[9px] px-2 py-1 rounded border ${backend === b ? 'border-[#58a6ff] text-[#58a6ff] bg-[#58a6ff]/10' : 'border-[#30363d] text-gray-400 hover:text-white'}`}>
|
|
842
|
+
{b === 'cli' ? 'CLI (subscription)' : 'API (api key)'}
|
|
843
|
+
</button>
|
|
844
|
+
))}
|
|
845
|
+
</div>
|
|
846
|
+
</div>
|
|
847
|
+
|
|
848
|
+
{/* Agent selection — dynamic from /api/agents */}
|
|
849
|
+
{backend === 'cli' && (
|
|
850
|
+
<div className="flex flex-col gap-1">
|
|
851
|
+
<label className="text-[9px] text-gray-500 uppercase">Agent / Profile</label>
|
|
852
|
+
<div className="flex gap-1 flex-wrap">
|
|
853
|
+
{(availableAgents.length > 0
|
|
854
|
+
? availableAgents.filter(a => a.backendType !== 'api')
|
|
855
|
+
: [{ id: 'claude', name: 'claude' }, { id: 'codex', name: 'codex' }, { id: 'aider', name: 'aider' }]
|
|
856
|
+
).map(a => (
|
|
857
|
+
<button key={a.id} onClick={() => setAgentId(a.id)}
|
|
858
|
+
className={`text-[9px] px-2 py-1 rounded border ${agentId === a.id ? 'border-[#58a6ff] text-[#58a6ff] bg-[#58a6ff]/10' : 'border-[#30363d] text-gray-400 hover:text-white'}`}>
|
|
859
|
+
{a.name}{a.isProfile ? ' ●' : ''}
|
|
860
|
+
</button>
|
|
861
|
+
))}
|
|
862
|
+
</div>
|
|
863
|
+
</div>
|
|
864
|
+
)}
|
|
865
|
+
{backend === 'api' && (
|
|
866
|
+
<div className="flex flex-col gap-1">
|
|
867
|
+
<label className="text-[9px] text-gray-500 uppercase">API Profile</label>
|
|
868
|
+
<div className="flex gap-1 flex-wrap">
|
|
869
|
+
{availableAgents.filter(a => a.backendType === 'api').map(a => (
|
|
870
|
+
<button key={a.id} onClick={() => setAgentId(a.id)}
|
|
871
|
+
className={`text-[9px] px-2 py-1 rounded border ${agentId === a.id ? 'border-[#58a6ff] text-[#58a6ff] bg-[#58a6ff]/10' : 'border-[#30363d] text-gray-400 hover:text-white'}`}>
|
|
872
|
+
{a.name}
|
|
873
|
+
</button>
|
|
874
|
+
))}
|
|
875
|
+
{availableAgents.filter(a => a.backendType === 'api').length === 0 && (
|
|
876
|
+
<span className="text-[9px] text-gray-600">No API profiles configured. Add in Settings.</span>
|
|
877
|
+
)}
|
|
878
|
+
</div>
|
|
879
|
+
</div>
|
|
880
|
+
)}
|
|
881
|
+
|
|
882
|
+
{/* Role */}
|
|
883
|
+
<div className="flex flex-col gap-1">
|
|
884
|
+
<label className="text-[9px] text-gray-500 uppercase">Role / System Prompt</label>
|
|
885
|
+
<textarea value={role} onChange={e => setRole(e.target.value)} rows={5}
|
|
886
|
+
placeholder="Describe this agent's role, responsibilities, available tools, and decision criteria. This will be synced to CLAUDE.md in the agent's working directory."
|
|
887
|
+
className="text-xs bg-[#161b22] border border-[#30363d] rounded px-2 py-1 text-white focus:outline-none focus:border-[#58a6ff] resize-y" />
|
|
888
|
+
</div>
|
|
889
|
+
|
|
890
|
+
{/* Plugin Instances grouped by plugin */}
|
|
891
|
+
<div className="flex flex-col gap-1">
|
|
892
|
+
<label className="text-[9px] text-gray-500 uppercase">Plugin Instances</label>
|
|
893
|
+
{(() => {
|
|
894
|
+
const withSource = pluginInstances.filter(i => i.source);
|
|
895
|
+
if (withSource.length === 0) return <span className="text-[8px] text-gray-600">No instances — create in Marketplace → Plugins</span>;
|
|
896
|
+
// Group by source plugin
|
|
897
|
+
const groups: Record<string, typeof withSource> = {};
|
|
898
|
+
for (const inst of withSource) {
|
|
899
|
+
const key = inst.source!;
|
|
900
|
+
if (!groups[key]) groups[key] = [];
|
|
901
|
+
groups[key].push(inst);
|
|
902
|
+
}
|
|
903
|
+
// Show recommended types that have no instances yet
|
|
904
|
+
const missingRecommended = recommendedTypes.filter(rt =>
|
|
905
|
+
!withSource.some(i => i.source === rt)
|
|
906
|
+
);
|
|
907
|
+
|
|
908
|
+
return <>
|
|
909
|
+
{Object.entries(groups).map(([sourceId, insts]) => {
|
|
910
|
+
const def = pluginDefs.find(d => d.id === sourceId);
|
|
911
|
+
const isRecommended = recommendedTypes.includes(sourceId);
|
|
912
|
+
return (
|
|
913
|
+
<div key={sourceId} className="flex items-start gap-2">
|
|
914
|
+
<span className={`text-[9px] shrink-0 w-20 pt-1 truncate ${isRecommended ? 'text-[#58a6ff]' : 'text-gray-500'}`} title={def?.name || sourceId}>
|
|
915
|
+
{def?.icon || '🔌'} {def?.name || sourceId}
|
|
916
|
+
{isRecommended && <span className="text-[7px] ml-0.5">★</span>}
|
|
917
|
+
</span>
|
|
918
|
+
<div className="flex flex-wrap gap-1 flex-1">
|
|
919
|
+
{insts.map(inst => {
|
|
920
|
+
const selected = selectedPlugins.includes(inst.id);
|
|
921
|
+
return (
|
|
922
|
+
<button key={inst.id}
|
|
923
|
+
onClick={() => setSelectedPlugins(prev => selected ? prev.filter(x => x !== inst.id) : [...prev, inst.id])}
|
|
924
|
+
className={`text-[9px] px-2 py-0.5 rounded border transition-colors ${
|
|
925
|
+
selected
|
|
926
|
+
? 'border-green-500/40 text-green-400 bg-green-500/10'
|
|
927
|
+
: isRecommended
|
|
928
|
+
? 'border-[#58a6ff]/30 text-[#58a6ff]/70 hover:text-[#58a6ff]'
|
|
929
|
+
: 'border-[#30363d] text-gray-500 hover:text-gray-300'
|
|
930
|
+
}`}>
|
|
931
|
+
{inst.name}
|
|
932
|
+
</button>
|
|
933
|
+
);
|
|
934
|
+
})}
|
|
935
|
+
</div>
|
|
936
|
+
</div>
|
|
937
|
+
);
|
|
938
|
+
})}
|
|
939
|
+
{missingRecommended.length > 0 && missingRecommended.map(rt => {
|
|
940
|
+
const def = pluginDefs.find(d => d.id === rt);
|
|
941
|
+
return (
|
|
942
|
+
<div key={rt} className="flex items-start gap-2">
|
|
943
|
+
<span className="text-[9px] text-[#58a6ff] shrink-0 w-20 pt-1 truncate">
|
|
944
|
+
{def?.icon || '🔌'} {def?.name || rt}<span className="text-[7px] ml-0.5">★</span>
|
|
945
|
+
</span>
|
|
946
|
+
<span className="text-[8px] text-[#58a6ff]/50 italic pt-1">No instances — create in Marketplace → Plugins</span>
|
|
947
|
+
</div>
|
|
948
|
+
);
|
|
949
|
+
})}
|
|
950
|
+
</>;
|
|
951
|
+
|
|
952
|
+
})()}
|
|
953
|
+
</div>
|
|
954
|
+
|
|
955
|
+
{/* Depends On — checkbox list of existing agents */}
|
|
956
|
+
{otherAgents.length > 0 && (
|
|
957
|
+
<div className="flex flex-col gap-1">
|
|
958
|
+
<label className="text-[9px] text-gray-500 uppercase">Depends On (upstream agents)</label>
|
|
959
|
+
<div className="flex flex-wrap gap-1.5">
|
|
960
|
+
{otherAgents.map(a => (
|
|
961
|
+
<button key={a.id} onClick={() => toggleDep(a.id)}
|
|
962
|
+
className={`text-[9px] px-2 py-1 rounded border flex items-center gap-1 ${
|
|
963
|
+
selectedDeps.has(a.id)
|
|
964
|
+
? 'border-[#58a6ff] text-[#58a6ff] bg-[#58a6ff]/10'
|
|
965
|
+
: 'border-[#30363d] text-gray-400 hover:text-white'}`}>
|
|
966
|
+
<span>{selectedDeps.has(a.id) ? '☑' : '☐'}</span>
|
|
967
|
+
<span>{a.icon} {a.label}</span>
|
|
968
|
+
</button>
|
|
969
|
+
))}
|
|
970
|
+
</div>
|
|
971
|
+
</div>
|
|
972
|
+
)}
|
|
973
|
+
|
|
974
|
+
{/* Work Dir + Outputs */}
|
|
975
|
+
<div className="flex gap-2">
|
|
976
|
+
<div className="flex flex-col gap-1 w-28">
|
|
977
|
+
<label className="text-[9px] text-gray-500 uppercase">Work Dir</label>
|
|
978
|
+
<input value={isPrimary ? './' : workDirVal} onChange={e => !isPrimary && setWorkDirVal(e.target.value)} placeholder={label ? `${label.toLowerCase().replace(/\s+/g, '-')}/` : 'engineer/'}
|
|
979
|
+
disabled={isPrimary}
|
|
980
|
+
className={`text-xs bg-[#161b22] border border-[#30363d] rounded px-2 py-1 text-white focus:outline-none focus:border-[#58a6ff] ${isPrimary ? 'opacity-50 cursor-not-allowed' : ''}`} />
|
|
981
|
+
<div className="text-[8px] text-gray-600 mt-0.5">
|
|
982
|
+
→ {'{project}/'}{(workDirVal || (label ? `${label.toLowerCase().replace(/\s+/g, '-')}/` : '')).replace(/^\.?\//, '')}
|
|
983
|
+
</div>
|
|
984
|
+
</div>
|
|
985
|
+
<div className="flex flex-col gap-1 flex-1">
|
|
986
|
+
<label className="text-[9px] text-gray-500 uppercase">Outputs</label>
|
|
987
|
+
<input value={outputs} onChange={e => setOutputs(e.target.value)} placeholder="docs/prd.md, src/"
|
|
988
|
+
className="text-xs bg-[#161b22] border border-[#30363d] rounded px-2 py-1 text-white focus:outline-none focus:border-[#58a6ff]" />
|
|
989
|
+
</div>
|
|
990
|
+
</div>
|
|
991
|
+
|
|
992
|
+
{/* Primary Agent */}
|
|
993
|
+
<div className="flex items-center gap-2">
|
|
994
|
+
<input type="checkbox" id="primaryAgent" checked={isPrimary}
|
|
995
|
+
onChange={e => {
|
|
996
|
+
const v = e.target.checked;
|
|
997
|
+
setIsPrimary(v);
|
|
998
|
+
if (v) { setPersistentSession(true); setWorkDirVal('./'); }
|
|
999
|
+
}}
|
|
1000
|
+
disabled={hasPrimaryAlready && !isPrimary}
|
|
1001
|
+
className={`accent-[#f0883e] ${hasPrimaryAlready && !isPrimary ? 'opacity-50 cursor-not-allowed' : ''}`} />
|
|
1002
|
+
<label htmlFor="primaryAgent" className={`text-[9px] ${isPrimary ? 'text-[#f0883e] font-medium' : 'text-gray-400'}`}>
|
|
1003
|
+
Primary agent (terminal-only, root directory, fixed session)
|
|
1004
|
+
{hasPrimaryAlready && !isPrimary && <span className="text-gray-600 ml-1">— already set on another agent</span>}
|
|
1005
|
+
</label>
|
|
1006
|
+
</div>
|
|
1007
|
+
|
|
1008
|
+
{/* Requires Approval */}
|
|
1009
|
+
<div className="flex items-center gap-2">
|
|
1010
|
+
<input type="checkbox" id="requiresApproval" checked={requiresApproval} onChange={e => setRequiresApproval(e.target.checked)}
|
|
1011
|
+
className="accent-[#58a6ff]" />
|
|
1012
|
+
<label htmlFor="requiresApproval" className="text-[9px] text-gray-400">Require approval before processing inbox messages</label>
|
|
1013
|
+
</div>
|
|
1014
|
+
|
|
1015
|
+
{/* Persistent Session — only for claude-code based agents */}
|
|
1016
|
+
{(() => {
|
|
1017
|
+
// Check if selected agent supports terminal mode (claude-code or its profiles)
|
|
1018
|
+
const selectedAgent = availableAgents.find(a => a.id === agentId);
|
|
1019
|
+
const isClaude = selectedAgent?.cliType === 'claude-code' || selectedAgent?.base === 'claude' || !selectedAgent;
|
|
1020
|
+
const canTerminal = isClaude || isPrimary;
|
|
1021
|
+
return canTerminal ? (
|
|
1022
|
+
<>
|
|
1023
|
+
<div className="flex items-center gap-2">
|
|
1024
|
+
<input type="checkbox" id="persistentSession" checked={persistentSession} onChange={e => !isPrimary && setPersistentSession(e.target.checked)}
|
|
1025
|
+
disabled={isPrimary}
|
|
1026
|
+
className={`accent-[#3fb950] ${isPrimary ? 'opacity-50 cursor-not-allowed' : ''}`} />
|
|
1027
|
+
<label htmlFor="persistentSession" className={`text-[9px] text-gray-400 ${isPrimary ? 'opacity-50' : ''}`}>
|
|
1028
|
+
Terminal mode {isPrimary ? '(required for primary)' : '— run in terminal instead of headless'}
|
|
1029
|
+
</label>
|
|
1030
|
+
</div>
|
|
1031
|
+
{persistentSession && (
|
|
1032
|
+
<div className="flex flex-col gap-1.5 ml-4">
|
|
1033
|
+
<div className="flex items-center gap-2">
|
|
1034
|
+
<input type="checkbox" id="skipPermissions" checked={skipPermissions} onChange={e => setSkipPermissions(e.target.checked)}
|
|
1035
|
+
className="accent-[#f0883e]" />
|
|
1036
|
+
<label htmlFor="skipPermissions" className="text-[9px] text-gray-400">Skip permissions (auto-approve all tool calls)</label>
|
|
1037
|
+
</div>
|
|
1038
|
+
</div>
|
|
1039
|
+
)}
|
|
1040
|
+
</>
|
|
1041
|
+
) : (
|
|
1042
|
+
<div className="text-[8px] text-gray-500 bg-gray-500/10 px-2 py-1 rounded">
|
|
1043
|
+
Headless mode only — {agentId} does not support terminal mode
|
|
1044
|
+
</div>
|
|
1045
|
+
);
|
|
1046
|
+
})()}
|
|
1047
|
+
|
|
1048
|
+
{/* Model override — only for claude-code agents */}
|
|
1049
|
+
{(() => {
|
|
1050
|
+
const sa = availableAgents.find(a => a.id === agentId);
|
|
1051
|
+
const ct = sa?.cliType || (agentId === 'claude' ? 'claude-code' : '');
|
|
1052
|
+
if (ct !== 'claude-code') return null;
|
|
1053
|
+
return (
|
|
1054
|
+
<div className="flex flex-col gap-0.5">
|
|
1055
|
+
<label className="text-[9px] text-gray-500 uppercase">Model</label>
|
|
1056
|
+
<input value={agentModel} onChange={e => setAgentModel(e.target.value)}
|
|
1057
|
+
placeholder="default (uses profile or system default)"
|
|
1058
|
+
list="workspace-model-list"
|
|
1059
|
+
className="text-xs bg-[#161b22] border border-[#30363d] rounded px-2 py-1 text-white focus:outline-none focus:border-[#58a6ff] font-mono" />
|
|
1060
|
+
<datalist id="workspace-model-list">
|
|
1061
|
+
<option value="claude-sonnet-4-6" />
|
|
1062
|
+
<option value="claude-opus-4-6" />
|
|
1063
|
+
<option value="claude-haiku-4-5-20251001" />
|
|
1064
|
+
</datalist>
|
|
1065
|
+
</div>
|
|
1066
|
+
);
|
|
1067
|
+
})()}
|
|
1068
|
+
|
|
1069
|
+
{/* Steps */}
|
|
1070
|
+
<div className="flex flex-col gap-1">
|
|
1071
|
+
<label className="text-[9px] text-gray-500 uppercase">Steps (one per line — Label: Prompt)</label>
|
|
1072
|
+
<textarea value={stepsText} onChange={e => setStepsText(e.target.value)} rows={4}
|
|
1073
|
+
placeholder="Analyze: Read docs and identify requirements Write: Write PRD to docs/prd.md Review: Review and improve"
|
|
1074
|
+
className="text-xs bg-[#161b22] border border-[#30363d] rounded px-2 py-1 text-white focus:outline-none focus:border-[#58a6ff] resize-none font-mono" />
|
|
1075
|
+
</div>
|
|
1076
|
+
|
|
1077
|
+
{/* Watch */}
|
|
1078
|
+
<div className="flex flex-col gap-1.5 border-t border-[#21262d] pt-2 mt-1">
|
|
1079
|
+
<div className="flex items-center gap-2">
|
|
1080
|
+
<label className="text-[9px] text-gray-500 uppercase">Watch</label>
|
|
1081
|
+
<input type="checkbox" checked={watchEnabled} onChange={e => setWatchEnabled(e.target.checked)}
|
|
1082
|
+
className="accent-[#58a6ff]" />
|
|
1083
|
+
<span className="text-[8px] text-gray-600">Autonomous periodic monitoring</span>
|
|
1084
|
+
</div>
|
|
1085
|
+
{watchEnabled && (<>
|
|
1086
|
+
<div className="flex gap-2">
|
|
1087
|
+
<div className="flex flex-col gap-0.5">
|
|
1088
|
+
<label className="text-[8px] text-gray-600">Interval (s)</label>
|
|
1089
|
+
<input value={watchInterval} onChange={e => setWatchInterval(e.target.value)} type="number" min="10"
|
|
1090
|
+
className="text-xs bg-[#161b22] border border-[#30363d] rounded px-2 py-1 text-white focus:outline-none focus:border-[#58a6ff] w-16" />
|
|
1091
|
+
</div>
|
|
1092
|
+
<div className="flex flex-col gap-0.5">
|
|
1093
|
+
<label className="text-[8px] text-gray-600">Debounce (s)</label>
|
|
1094
|
+
<input value={watchDebounce} onChange={e => setWatchDebounce(e.target.value)} type="number" min="0"
|
|
1095
|
+
className="text-xs bg-[#161b22] border border-[#30363d] rounded px-2 py-1 text-white focus:outline-none focus:border-[#58a6ff] w-16" />
|
|
1096
|
+
</div>
|
|
1097
|
+
<div className="flex flex-col gap-0.5 flex-1">
|
|
1098
|
+
<label className="text-[8px] text-gray-600">On Change</label>
|
|
1099
|
+
<select value={watchAction} onChange={e => setWatchAction(e.target.value as any)}
|
|
1100
|
+
className="text-xs bg-[#161b22] border border-[#30363d] rounded px-2 py-1 text-white focus:outline-none focus:border-[#58a6ff]">
|
|
1101
|
+
<option value="log">Log only</option>
|
|
1102
|
+
<option value="analyze">Auto analyze</option>
|
|
1103
|
+
<option value="approve">Require approval</option>
|
|
1104
|
+
<option value="send_message">Send to agent</option>
|
|
1105
|
+
</select>
|
|
1106
|
+
</div>
|
|
1107
|
+
{watchAction === 'send_message' && (
|
|
1108
|
+
<div className="flex flex-col gap-0.5 flex-1">
|
|
1109
|
+
<label className="text-[8px] text-gray-600">Send to</label>
|
|
1110
|
+
<select value={watchSendTo} onChange={e => setWatchSendTo(e.target.value)}
|
|
1111
|
+
className="text-xs bg-[#161b22] border border-[#30363d] rounded px-2 py-1 text-white focus:outline-none focus:border-[#58a6ff]">
|
|
1112
|
+
<option value="">Select agent...</option>
|
|
1113
|
+
{existingAgents.filter(a => a.id !== initial.id).map(a =>
|
|
1114
|
+
<option key={a.id} value={a.id}>{a.icon} {a.label}</option>
|
|
1115
|
+
)}
|
|
1116
|
+
</select>
|
|
1117
|
+
</div>
|
|
1118
|
+
)}
|
|
1119
|
+
</div>
|
|
1120
|
+
<div className="flex flex-col gap-1">
|
|
1121
|
+
<label className="text-[8px] text-gray-600">Targets</label>
|
|
1122
|
+
{watchTargets.map((t, i) => (
|
|
1123
|
+
<div key={i} className="flex items-center gap-1">
|
|
1124
|
+
<select value={t.type} onChange={e => {
|
|
1125
|
+
const next = [...watchTargets];
|
|
1126
|
+
next[i] = { type: e.target.value };
|
|
1127
|
+
setWatchTargets(next);
|
|
1128
|
+
}} className="text-[10px] bg-[#161b22] border border-[#30363d] rounded px-1 py-0.5 text-white w-24">
|
|
1129
|
+
<option value="directory">Directory</option>
|
|
1130
|
+
<option value="git">Git</option>
|
|
1131
|
+
<option value="agent_output">Agent Output</option>
|
|
1132
|
+
<option value="agent_log">Agent Log</option>
|
|
1133
|
+
<option value="session">Session Output</option>
|
|
1134
|
+
<option value="command">Command</option>
|
|
1135
|
+
<option value="agent_status">Agent Status</option>
|
|
1136
|
+
</select>
|
|
1137
|
+
{t.type === 'directory' && (
|
|
1138
|
+
<WatchPathPicker
|
|
1139
|
+
value={t.path || ''}
|
|
1140
|
+
projectPath={projectPath || ''}
|
|
1141
|
+
onChange={v => {
|
|
1142
|
+
const next = [...watchTargets];
|
|
1143
|
+
next[i] = { ...t, path: v };
|
|
1144
|
+
setWatchTargets(next);
|
|
1145
|
+
}}
|
|
1146
|
+
/>
|
|
1147
|
+
)}
|
|
1148
|
+
{t.type === 'agent_status' && (<>
|
|
1149
|
+
<select value={t.path || ''} onChange={e => {
|
|
1150
|
+
const next = [...watchTargets];
|
|
1151
|
+
next[i] = { ...t, path: e.target.value };
|
|
1152
|
+
setWatchTargets(next);
|
|
1153
|
+
}} className="text-[10px] bg-[#161b22] border border-[#30363d] rounded px-1 py-0.5 text-white flex-1">
|
|
1154
|
+
<option value="">Select agent...</option>
|
|
1155
|
+
{existingAgents.filter(a => a.id !== initial.id).map(a =>
|
|
1156
|
+
<option key={a.id} value={a.id}>{a.icon} {a.label}</option>
|
|
1157
|
+
)}
|
|
1158
|
+
</select>
|
|
1159
|
+
<select value={t.pattern || ''} onChange={e => {
|
|
1160
|
+
const next = [...watchTargets];
|
|
1161
|
+
next[i] = { ...t, pattern: e.target.value };
|
|
1162
|
+
setWatchTargets(next);
|
|
1163
|
+
}} className="text-[10px] bg-[#161b22] border border-[#30363d] rounded px-1 py-0.5 text-white w-20">
|
|
1164
|
+
<option value="">Any change</option>
|
|
1165
|
+
<option value="done">done</option>
|
|
1166
|
+
<option value="failed">failed</option>
|
|
1167
|
+
<option value="running">running</option>
|
|
1168
|
+
<option value="idle">idle</option>
|
|
1169
|
+
</select>
|
|
1170
|
+
</>)}
|
|
1171
|
+
{t.type === 'agent_output' && (
|
|
1172
|
+
<select value={t.path || ''} onChange={e => {
|
|
1173
|
+
const next = [...watchTargets];
|
|
1174
|
+
next[i] = { ...t, path: e.target.value };
|
|
1175
|
+
setWatchTargets(next);
|
|
1176
|
+
}} className="text-[10px] bg-[#161b22] border border-[#30363d] rounded px-1 py-0.5 text-white flex-1">
|
|
1177
|
+
<option value="">Select agent...</option>
|
|
1178
|
+
{existingAgents.filter(a => a.id !== initial.id).map(a =>
|
|
1179
|
+
<option key={a.id} value={a.id}>{a.icon} {a.label}</option>
|
|
1180
|
+
)}
|
|
1181
|
+
</select>
|
|
1182
|
+
)}
|
|
1183
|
+
{t.type === 'agent_log' && (<>
|
|
1184
|
+
<select value={t.path || ''} onChange={e => {
|
|
1185
|
+
const next = [...watchTargets];
|
|
1186
|
+
next[i] = { ...t, path: e.target.value };
|
|
1187
|
+
setWatchTargets(next);
|
|
1188
|
+
}} className="text-[10px] bg-[#161b22] border border-[#30363d] rounded px-1 py-0.5 text-white flex-1">
|
|
1189
|
+
<option value="">Select agent...</option>
|
|
1190
|
+
{existingAgents.filter(a => a.id !== initial.id).map(a =>
|
|
1191
|
+
<option key={a.id} value={a.id}>{a.icon} {a.label}</option>
|
|
1192
|
+
)}
|
|
1193
|
+
</select>
|
|
1194
|
+
<input value={t.pattern || ''} onChange={e => {
|
|
1195
|
+
const next = [...watchTargets];
|
|
1196
|
+
next[i] = { ...t, pattern: e.target.value };
|
|
1197
|
+
setWatchTargets(next);
|
|
1198
|
+
}} placeholder="keyword (optional)"
|
|
1199
|
+
className="text-[10px] bg-[#161b22] border border-[#30363d] rounded px-1 py-0.5 text-white w-24" />
|
|
1200
|
+
</>)}
|
|
1201
|
+
{t.type === 'session' && (
|
|
1202
|
+
<SessionTargetSelector
|
|
1203
|
+
target={t}
|
|
1204
|
+
agents={existingAgents.filter(a => a.id !== initial.id)}
|
|
1205
|
+
projectPath={projectPath}
|
|
1206
|
+
onChange={(updated) => {
|
|
1207
|
+
const next = [...watchTargets];
|
|
1208
|
+
next[i] = updated;
|
|
1209
|
+
setWatchTargets(next);
|
|
1210
|
+
}}
|
|
1211
|
+
/>
|
|
1212
|
+
)}
|
|
1213
|
+
{t.type === 'command' && (
|
|
1214
|
+
<input value={t.cmd || ''} onChange={e => {
|
|
1215
|
+
const next = [...watchTargets];
|
|
1216
|
+
next[i] = { ...t, cmd: e.target.value };
|
|
1217
|
+
setWatchTargets(next);
|
|
1218
|
+
}} placeholder="npm test"
|
|
1219
|
+
className="text-[10px] bg-[#161b22] border border-[#30363d] rounded px-1 py-0.5 text-white flex-1" />
|
|
1220
|
+
)}
|
|
1221
|
+
<button onClick={() => setWatchTargets(watchTargets.filter((_, j) => j !== i))}
|
|
1222
|
+
className="text-[9px] text-gray-500 hover:text-red-400">✕</button>
|
|
1223
|
+
</div>
|
|
1224
|
+
))}
|
|
1225
|
+
<button onClick={() => setWatchTargets([...watchTargets, { type: 'directory' }])}
|
|
1226
|
+
className="text-[8px] text-gray-500 hover:text-[#58a6ff] self-start">+ Add target</button>
|
|
1227
|
+
</div>
|
|
1228
|
+
{watchAction === 'analyze' && (
|
|
1229
|
+
<div className="flex flex-col gap-0.5">
|
|
1230
|
+
<label className="text-[8px] text-gray-600">Analysis prompt (optional)</label>
|
|
1231
|
+
<input value={watchPrompt} onChange={e => setWatchPrompt(e.target.value)}
|
|
1232
|
+
placeholder="Analyze these changes and check for issues..."
|
|
1233
|
+
className="text-xs bg-[#161b22] border border-[#30363d] rounded px-2 py-1 text-white focus:outline-none focus:border-[#58a6ff]" />
|
|
1234
|
+
</div>
|
|
1235
|
+
)}
|
|
1236
|
+
{watchAction === 'send_message' && (
|
|
1237
|
+
<div className="flex flex-col gap-0.5">
|
|
1238
|
+
<label className="text-[8px] text-gray-600">Message context (sent with detected changes)</label>
|
|
1239
|
+
<input value={watchPrompt} onChange={e => setWatchPrompt(e.target.value)}
|
|
1240
|
+
placeholder="Review the following changes and report issues..."
|
|
1241
|
+
className="text-xs bg-[#161b22] border border-[#30363d] rounded px-2 py-1 text-white focus:outline-none focus:border-[#58a6ff]" />
|
|
1242
|
+
</div>
|
|
1243
|
+
)}
|
|
1244
|
+
</>)}
|
|
1245
|
+
</div>
|
|
1246
|
+
</div>
|
|
1247
|
+
|
|
1248
|
+
<div className="flex justify-end gap-2 mt-4">
|
|
1249
|
+
{mode === 'edit' && (
|
|
1250
|
+
<button onClick={() => {
|
|
1251
|
+
const config = {
|
|
1252
|
+
label: label.trim(), icon: icon.trim() || '🤖', role: role.trim(),
|
|
1253
|
+
backend, agentId, workDir: workDirVal.trim() || './',
|
|
1254
|
+
outputs: outputs.split(',').map(s => s.trim()).filter(Boolean),
|
|
1255
|
+
steps: parseSteps(), plugins: selectedPlugins.length > 0 ? selectedPlugins : undefined,
|
|
1256
|
+
persistentSession: persistentSession || undefined, skipPermissions: persistentSession ? (skipPermissions ? undefined : false) : undefined,
|
|
1257
|
+
model: agentModel || undefined, requiresApproval: requiresApproval || undefined,
|
|
1258
|
+
watch: watchEnabled && watchTargets.length > 0 ? { enabled: true, interval: Math.max(10, parseInt(watchInterval) || 60), targets: watchTargets.map(t => ({ ...t, debounce: parseInt(watchDebounce) || 10 })), action: watchAction, prompt: watchPrompt || undefined, sendTo: watchSendTo || undefined } : undefined,
|
|
1259
|
+
};
|
|
1260
|
+
const blob = new Blob([JSON.stringify({ config, name: label.trim(), icon: icon.trim() || '🤖', exportedAt: Date.now() }, null, 2)], { type: 'application/json' });
|
|
1261
|
+
const url = URL.createObjectURL(blob);
|
|
1262
|
+
const a = document.createElement('a');
|
|
1263
|
+
a.href = url; a.download = `smith-${label.trim().toLowerCase().replace(/\s+/g, '-')}.json`; a.click();
|
|
1264
|
+
URL.revokeObjectURL(url);
|
|
1265
|
+
}} className="text-xs px-3 py-1.5 rounded border border-[#30363d] text-gray-400 hover:text-white mr-auto" title="Export config as file">
|
|
1266
|
+
📤 Export
|
|
1267
|
+
</button>
|
|
1268
|
+
)}
|
|
1269
|
+
<button onClick={onCancel} className="text-xs px-3 py-1.5 rounded border border-[#30363d] text-gray-400 hover:text-white">Cancel</button>
|
|
1270
|
+
<button disabled={!label.trim()} onClick={() => {
|
|
1271
|
+
onConfirm({
|
|
1272
|
+
label: label.trim(), icon: icon.trim() || '🤖', role: role.trim(),
|
|
1273
|
+
backend, agentId, dependsOn: Array.from(selectedDeps),
|
|
1274
|
+
workDir: isPrimary ? './' : (workDirVal.trim() || label.trim().toLowerCase().replace(/\s+/g, '-') + '/'),
|
|
1275
|
+
outputs: outputs.split(',').map(s => s.trim()).filter(Boolean),
|
|
1276
|
+
steps: parseSteps(),
|
|
1277
|
+
primary: isPrimary || undefined,
|
|
1278
|
+
requiresApproval: requiresApproval || undefined,
|
|
1279
|
+
persistentSession: (() => {
|
|
1280
|
+
if (isPrimary) return true;
|
|
1281
|
+
// Non-terminal agents (codex, aider, etc.) force headless
|
|
1282
|
+
const sa = availableAgents.find(a => a.id === agentId);
|
|
1283
|
+
const isClaude = sa?.cliType === 'claude-code' || sa?.base === 'claude' || !sa;
|
|
1284
|
+
return (isClaude || isPrimary) ? (persistentSession || undefined) : false;
|
|
1285
|
+
})(),
|
|
1286
|
+
skipPermissions: persistentSession ? (skipPermissions ? undefined : false) : undefined,
|
|
1287
|
+
model: agentModel || undefined,
|
|
1288
|
+
watch: watchEnabled && watchTargets.length > 0 ? {
|
|
1289
|
+
enabled: true,
|
|
1290
|
+
interval: Math.max(10, parseInt(watchInterval) || 60),
|
|
1291
|
+
targets: watchTargets.map(t => ({ ...t, debounce: parseInt(watchDebounce) || 10 })),
|
|
1292
|
+
action: watchAction,
|
|
1293
|
+
prompt: watchPrompt || undefined,
|
|
1294
|
+
sendTo: watchSendTo || undefined,
|
|
1295
|
+
} : undefined,
|
|
1296
|
+
plugins: selectedPlugins.length > 0 ? selectedPlugins : undefined,
|
|
1297
|
+
} as any);
|
|
1298
|
+
}} className="text-xs px-3 py-1.5 rounded bg-[#238636] text-white hover:bg-[#2ea043] disabled:opacity-40">
|
|
1299
|
+
{mode === 'add' ? 'Add' : 'Save'}
|
|
1300
|
+
</button>
|
|
1301
|
+
</div>
|
|
1302
|
+
</div>
|
|
1303
|
+
</div>
|
|
1304
|
+
);
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
// ─── Message Dialog ──────────────────────────────────────
|
|
1308
|
+
|
|
1309
|
+
function MessageDialog({ agentLabel, onSend, onCancel }: {
|
|
1310
|
+
agentLabel: string;
|
|
1311
|
+
onSend: (msg: string) => void;
|
|
1312
|
+
onCancel: () => void;
|
|
1313
|
+
}) {
|
|
1314
|
+
const [msg, setMsg] = useState('');
|
|
1315
|
+
return (
|
|
1316
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center" style={{ background: 'rgba(0,0,0,0.75)' }}
|
|
1317
|
+
onClick={e => { if (e.target === e.currentTarget) onCancel(); }}>
|
|
1318
|
+
<div className="w-96 rounded-lg border border-[#30363d] p-4 shadow-xl" style={{ background: '#0d1117' }}>
|
|
1319
|
+
<div className="text-sm font-bold text-white mb-2">Message to {agentLabel}</div>
|
|
1320
|
+
<textarea value={msg} onChange={e => setMsg(e.target.value)} rows={3} autoFocus
|
|
1321
|
+
placeholder="Type your message..."
|
|
1322
|
+
className="w-full text-xs bg-[#161b22] border border-[#30363d] rounded px-2 py-1.5 text-white focus:outline-none focus:border-[#58a6ff] resize-none" />
|
|
1323
|
+
<div className="flex justify-end gap-2 mt-3">
|
|
1324
|
+
<button onClick={onCancel} className="text-xs px-3 py-1.5 rounded border border-[#30363d] text-gray-400 hover:text-white">Cancel</button>
|
|
1325
|
+
<button onClick={() => { if (msg.trim()) onSend(msg.trim()); }}
|
|
1326
|
+
className="text-xs px-3 py-1.5 rounded bg-[#238636] text-white hover:bg-[#2ea043]">Send</button>
|
|
1327
|
+
</div>
|
|
1328
|
+
</div>
|
|
1329
|
+
</div>
|
|
1330
|
+
);
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
// ─── Run Prompt Dialog ───────────────────────────────────
|
|
1334
|
+
|
|
1335
|
+
function RunPromptDialog({ agentLabel, onRun, onCancel }: {
|
|
1336
|
+
agentLabel: string;
|
|
1337
|
+
onRun: (input: string) => void;
|
|
1338
|
+
onCancel: () => void;
|
|
1339
|
+
}) {
|
|
1340
|
+
const [input, setInput] = useState('');
|
|
1341
|
+
return (
|
|
1342
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center" style={{ background: 'rgba(0,0,0,0.75)' }}
|
|
1343
|
+
onClick={e => { if (e.target === e.currentTarget) onCancel(); }}>
|
|
1344
|
+
<div className="w-[460px] rounded-lg border border-[#30363d] p-4 shadow-xl" style={{ background: '#0d1117' }}>
|
|
1345
|
+
<div className="text-sm font-bold text-white mb-1">Run {agentLabel}</div>
|
|
1346
|
+
<div className="text-[9px] text-gray-500 mb-3">Describe the task or requirements. This will be the initial input for the agent.</div>
|
|
1347
|
+
<textarea value={input} onChange={e => setInput(e.target.value)} rows={5} autoFocus
|
|
1348
|
+
placeholder="e.g. Build a REST API for user management with login, registration, and profile endpoints. Use Express + TypeScript + PostgreSQL."
|
|
1349
|
+
className="w-full text-xs bg-[#161b22] border border-[#30363d] rounded px-2 py-1.5 text-white focus:outline-none focus:border-[#58a6ff] resize-none" />
|
|
1350
|
+
<div className="flex items-center justify-between mt-3">
|
|
1351
|
+
<span className="text-[8px] text-gray-600">Leave empty to run without specific input</span>
|
|
1352
|
+
<div className="flex gap-2">
|
|
1353
|
+
<button onClick={onCancel} className="text-xs px-3 py-1.5 rounded border border-[#30363d] text-gray-400 hover:text-white">Cancel</button>
|
|
1354
|
+
<button onClick={() => onRun(input.trim())}
|
|
1355
|
+
className="text-xs px-3 py-1.5 rounded bg-[#238636] text-white hover:bg-[#2ea043]">▶ Run</button>
|
|
1356
|
+
</div>
|
|
1357
|
+
</div>
|
|
1358
|
+
</div>
|
|
1359
|
+
</div>
|
|
1360
|
+
);
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
// ─── Log Panel (overlay) ─────────────────────────────────
|
|
1364
|
+
|
|
1365
|
+
/** Format log content: extract readable text from JSON, format nicely */
|
|
1366
|
+
function LogContent({ content, subtype }: { content: string; subtype?: string }) {
|
|
1367
|
+
if (!content) return null;
|
|
1368
|
+
const MAX_LINES = 40;
|
|
1369
|
+
const MAX_CHARS = 4000;
|
|
1370
|
+
|
|
1371
|
+
let text = content;
|
|
1372
|
+
|
|
1373
|
+
// Try to parse JSON and extract human-readable content
|
|
1374
|
+
if (text.startsWith('{') || text.startsWith('[')) {
|
|
1375
|
+
try {
|
|
1376
|
+
const parsed = JSON.parse(text);
|
|
1377
|
+
if (typeof parsed === 'string') {
|
|
1378
|
+
text = parsed;
|
|
1379
|
+
} else if (parsed.content) {
|
|
1380
|
+
text = String(parsed.content);
|
|
1381
|
+
} else if (parsed.text) {
|
|
1382
|
+
text = String(parsed.text);
|
|
1383
|
+
} else if (parsed.result) {
|
|
1384
|
+
text = typeof parsed.result === 'string' ? parsed.result : JSON.stringify(parsed.result, null, 2);
|
|
1385
|
+
} else if (parsed.message?.content) {
|
|
1386
|
+
// Claude stream-json format
|
|
1387
|
+
const blocks = Array.isArray(parsed.message.content) ? parsed.message.content : [parsed.message.content];
|
|
1388
|
+
text = blocks.map((b: any) => {
|
|
1389
|
+
if (typeof b === 'string') return b;
|
|
1390
|
+
if (b.type === 'text') return b.text;
|
|
1391
|
+
if (b.type === 'tool_use') return `🔧 ${b.name}(${typeof b.input === 'string' ? b.input : JSON.stringify(b.input).slice(0, 100)})`;
|
|
1392
|
+
if (b.type === 'tool_result') return `→ ${typeof b.content === 'string' ? b.content.slice(0, 200) : JSON.stringify(b.content).slice(0, 200)}`;
|
|
1393
|
+
return JSON.stringify(b).slice(0, 100);
|
|
1394
|
+
}).join('\n');
|
|
1395
|
+
} else if (Array.isArray(parsed)) {
|
|
1396
|
+
text = parsed.map((item: any) => typeof item === 'string' ? item : JSON.stringify(item)).join('\n');
|
|
1397
|
+
} else {
|
|
1398
|
+
// Generic object — show key fields only
|
|
1399
|
+
const keys = Object.keys(parsed);
|
|
1400
|
+
if (keys.length <= 5) {
|
|
1401
|
+
text = keys.map(k => `${k}: ${typeof parsed[k] === 'string' ? parsed[k] : JSON.stringify(parsed[k]).slice(0, 80)}`).join('\n');
|
|
1402
|
+
} else {
|
|
1403
|
+
text = JSON.stringify(parsed, null, 2);
|
|
1404
|
+
}
|
|
1405
|
+
}
|
|
1406
|
+
} catch {
|
|
1407
|
+
// Not valid JSON, keep as-is
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
// Truncate
|
|
1412
|
+
const lines = text.split('\n');
|
|
1413
|
+
const truncatedLines = lines.length > MAX_LINES;
|
|
1414
|
+
const truncatedChars = text.length > MAX_CHARS;
|
|
1415
|
+
if (truncatedLines) text = lines.slice(0, MAX_LINES).join('\n');
|
|
1416
|
+
if (truncatedChars) text = text.slice(0, MAX_CHARS);
|
|
1417
|
+
const truncated = truncatedLines || truncatedChars;
|
|
1418
|
+
|
|
1419
|
+
return (
|
|
1420
|
+
<span className="break-all">
|
|
1421
|
+
<pre className="whitespace-pre-wrap text-[10px] leading-relaxed inline">{text}</pre>
|
|
1422
|
+
{truncated && <span className="text-gray-600 text-[9px]"> ...({lines.length} lines)</span>}
|
|
1423
|
+
</span>
|
|
1424
|
+
);
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
function LogPanel({ agentId, agentLabel, workspaceId, onClose }: {
|
|
1428
|
+
agentId: string; agentLabel: string; workspaceId: string; onClose: () => void;
|
|
1429
|
+
}) {
|
|
1430
|
+
const [logs, setLogs] = useState<any[]>([]);
|
|
1431
|
+
const [filter, setFilter] = useState<'all' | 'messages' | 'summaries'>('all');
|
|
1432
|
+
const scrollRef = useRef<HTMLDivElement>(null);
|
|
1433
|
+
|
|
1434
|
+
useEffect(() => {
|
|
1435
|
+
// Read persistent logs from logs.jsonl (not in-memory state history)
|
|
1436
|
+
fetch(`/api/workspace/${workspaceId}/smith`, {
|
|
1437
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
1438
|
+
body: JSON.stringify({ action: 'logs', agentId }),
|
|
1439
|
+
}).then(r => r.json()).then(data => {
|
|
1440
|
+
if (data.logs?.length) setLogs(data.logs);
|
|
1441
|
+
}).catch(() => {});
|
|
1442
|
+
}, [workspaceId, agentId]);
|
|
1443
|
+
|
|
1444
|
+
useEffect(() => {
|
|
1445
|
+
scrollRef.current?.scrollTo(0, scrollRef.current.scrollHeight);
|
|
1446
|
+
}, [logs, filter]);
|
|
1447
|
+
|
|
1448
|
+
const filteredLogs = filter === 'all' ? logs :
|
|
1449
|
+
filter === 'messages' ? logs.filter((e: any) => e.subtype === 'bus_message' || e.subtype === 'revalidation_request' || e.subtype === 'user_message') :
|
|
1450
|
+
logs.filter((e: any) => e.subtype === 'step_summary' || e.subtype === 'final_summary');
|
|
1451
|
+
|
|
1452
|
+
const msgCount = logs.filter((e: any) => e.subtype === 'bus_message' || e.subtype === 'revalidation_request' || e.subtype === 'user_message').length;
|
|
1453
|
+
|
|
1454
|
+
return (
|
|
1455
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center" style={{ background: 'rgba(0,0,0,0.85)' }}
|
|
1456
|
+
onClick={e => { if (e.target === e.currentTarget) onClose(); }}>
|
|
1457
|
+
<div className="flex flex-col rounded-xl overflow-hidden shadow-2xl" style={{ width: '75vw', height: '65vh', border: '1px solid #30363d', background: '#0d1117' }}>
|
|
1458
|
+
<div className="flex items-center gap-2 px-4 py-2 border-b border-[#30363d] shrink-0">
|
|
1459
|
+
<span className="text-sm font-bold text-white">Logs: {agentLabel}</span>
|
|
1460
|
+
<span className="text-[9px] text-gray-500">{filteredLogs.length}/{logs.length}</span>
|
|
1461
|
+
{/* Filter tabs */}
|
|
1462
|
+
<div className="flex gap-1 ml-3">
|
|
1463
|
+
{([['all', 'All'], ['messages', `📨 Messages${msgCount > 0 ? ` (${msgCount})` : ''}`], ['summaries', '📊 Summaries']] as const).map(([key, label]) => (
|
|
1464
|
+
<button key={key} onClick={() => setFilter(key as any)}
|
|
1465
|
+
className={`text-[8px] px-2 py-0.5 rounded ${filter === key ? 'bg-[#21262d] text-white' : 'text-gray-500 hover:text-gray-300'}`}>
|
|
1466
|
+
{label}
|
|
1467
|
+
</button>
|
|
1468
|
+
))}
|
|
1469
|
+
</div>
|
|
1470
|
+
<button onClick={async () => {
|
|
1471
|
+
await fetch(`/api/workspace/${workspaceId}/smith`, {
|
|
1472
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
1473
|
+
body: JSON.stringify({ action: 'clear_logs', agentId }),
|
|
1474
|
+
});
|
|
1475
|
+
setLogs([]);
|
|
1476
|
+
}} className="text-[8px] text-gray-500 hover:text-red-400 ml-auto mr-2">Clear</button>
|
|
1477
|
+
<button onClick={onClose} className="text-gray-500 hover:text-white text-sm">✕</button>
|
|
1478
|
+
</div>
|
|
1479
|
+
<div ref={scrollRef} className="flex-1 overflow-auto p-3 font-mono text-[11px] space-y-0.5">
|
|
1480
|
+
{filteredLogs.length === 0 && <div className="text-gray-600 text-center mt-8">{filter === 'all' ? 'No logs yet' : 'No matching entries'}</div>}
|
|
1481
|
+
{filteredLogs.map((entry, i) => {
|
|
1482
|
+
const isSummary = entry.subtype === 'step_summary' || entry.subtype === 'final_summary';
|
|
1483
|
+
const isBusMsg = entry.subtype === 'bus_message' || entry.subtype === 'revalidation_request' || entry.subtype === 'user_message';
|
|
1484
|
+
return (
|
|
1485
|
+
<div key={i} className={`${
|
|
1486
|
+
isSummary ? 'my-1 px-2 py-1.5 rounded border border-[#21262d] text-[#58a6ff] bg-[#161b22]' :
|
|
1487
|
+
isBusMsg ? 'my-0.5 px-2 py-1 rounded border border-[#f0883e30] text-[#f0883e] bg-[#f0883e08]' :
|
|
1488
|
+
'flex gap-2 ' + (
|
|
1489
|
+
entry.type === 'system' ? 'text-gray-600' :
|
|
1490
|
+
entry.type === 'result' ? 'text-green-400' : 'text-gray-300'
|
|
1491
|
+
)
|
|
1492
|
+
}`}>
|
|
1493
|
+
{isSummary ? (
|
|
1494
|
+
<pre className="whitespace-pre-wrap text-[10px] leading-relaxed">{entry.content}</pre>
|
|
1495
|
+
) : isBusMsg ? (
|
|
1496
|
+
<div className="text-[10px] flex items-center gap-2">
|
|
1497
|
+
<span>📨</span>
|
|
1498
|
+
<span className="text-[8px] text-gray-500">{entry.timestamp ? new Date(entry.timestamp).toLocaleTimeString() : ''}</span>
|
|
1499
|
+
<span>{entry.content}</span>
|
|
1500
|
+
</div>
|
|
1501
|
+
) : (
|
|
1502
|
+
<>
|
|
1503
|
+
<span className="text-[8px] text-gray-600 shrink-0 w-16">{entry.timestamp ? new Date(entry.timestamp).toLocaleTimeString() : ''}</span>
|
|
1504
|
+
{entry.subtype === 'tool_use' && <span className="text-yellow-500 shrink-0">🔧 {entry.tool || 'tool'}</span>}
|
|
1505
|
+
{entry.subtype === 'tool_result' && <span className="text-cyan-500 shrink-0">→</span>}
|
|
1506
|
+
{entry.subtype === 'init' && <span className="text-blue-400 shrink-0">⚡</span>}
|
|
1507
|
+
{entry.subtype === 'daemon' && <span className="text-purple-400 shrink-0">👁</span>}
|
|
1508
|
+
{entry.subtype === 'watch_detected' && <span className="text-orange-400 shrink-0">🔍</span>}
|
|
1509
|
+
{entry.subtype === 'error' && <span className="text-red-400 shrink-0">❌</span>}
|
|
1510
|
+
{!entry.tool && entry.subtype === 'text' && <span className="text-gray-500 shrink-0">💬</span>}
|
|
1511
|
+
<LogContent content={entry.content} subtype={entry.subtype} />
|
|
1512
|
+
</>
|
|
1513
|
+
)}
|
|
1514
|
+
</div>
|
|
1515
|
+
);
|
|
1516
|
+
})}
|
|
1517
|
+
</div>
|
|
1518
|
+
</div>
|
|
1519
|
+
</div>
|
|
1520
|
+
);
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
// ─── Memory Panel ────────────────────────────────────────
|
|
1524
|
+
|
|
1525
|
+
const TYPE_COLORS: Record<string, string> = {
|
|
1526
|
+
decision: 'text-yellow-400', bugfix: 'text-red-400', feature: 'text-green-400',
|
|
1527
|
+
refactor: 'text-cyan-400', discovery: 'text-purple-400', change: 'text-gray-400', session: 'text-blue-400',
|
|
1528
|
+
};
|
|
1529
|
+
|
|
1530
|
+
function MemoryPanel({ agentId, agentLabel, workspaceId, onClose }: {
|
|
1531
|
+
agentId: string; agentLabel: string; workspaceId: string; onClose: () => void;
|
|
1532
|
+
}) {
|
|
1533
|
+
const [data, setData] = useState<any>(null);
|
|
1534
|
+
|
|
1535
|
+
useEffect(() => {
|
|
1536
|
+
fetch(`/api/workspace/${workspaceId}/memory?agentId=${encodeURIComponent(agentId)}`)
|
|
1537
|
+
.then(r => r.json()).then(setData).catch(() => {});
|
|
1538
|
+
}, [workspaceId, agentId]);
|
|
1539
|
+
|
|
1540
|
+
const stats = data?.stats;
|
|
1541
|
+
const display: any[] = data?.display || [];
|
|
1542
|
+
|
|
1543
|
+
return (
|
|
1544
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center" style={{ background: 'rgba(0,0,0,0.85)' }}
|
|
1545
|
+
onClick={e => { if (e.target === e.currentTarget) onClose(); }}>
|
|
1546
|
+
<div className="flex flex-col rounded-xl overflow-hidden shadow-2xl" style={{ width: '70vw', height: '65vh', border: '1px solid #30363d', background: '#0d1117' }}>
|
|
1547
|
+
{/* Header */}
|
|
1548
|
+
<div className="flex items-center gap-2 px-4 py-2 border-b border-[#30363d] shrink-0">
|
|
1549
|
+
<span className="text-sm">🧠</span>
|
|
1550
|
+
<span className="text-sm font-bold text-white">Memory: {agentLabel}</span>
|
|
1551
|
+
{stats && (
|
|
1552
|
+
<span className="text-[9px] text-gray-500">
|
|
1553
|
+
{stats.totalObservations} observations, {stats.totalSessions} sessions
|
|
1554
|
+
{stats.lastUpdated && ` · last updated ${new Date(stats.lastUpdated).toLocaleString()}`}
|
|
1555
|
+
</span>
|
|
1556
|
+
)}
|
|
1557
|
+
<button onClick={onClose} className="text-gray-500 hover:text-white text-sm ml-auto">✕</button>
|
|
1558
|
+
</div>
|
|
1559
|
+
|
|
1560
|
+
{/* Stats bar */}
|
|
1561
|
+
{stats?.typeBreakdown && Object.keys(stats.typeBreakdown).length > 0 && (
|
|
1562
|
+
<div className="flex items-center gap-3 px-4 py-1.5 border-b border-[#21262d] text-[9px]">
|
|
1563
|
+
{Object.entries(stats.typeBreakdown).map(([type, count]) => (
|
|
1564
|
+
<span key={type} className={TYPE_COLORS[type] || 'text-gray-400'}>
|
|
1565
|
+
{type}: {count as number}
|
|
1566
|
+
</span>
|
|
1567
|
+
))}
|
|
1568
|
+
</div>
|
|
1569
|
+
)}
|
|
1570
|
+
|
|
1571
|
+
{/* Entries */}
|
|
1572
|
+
<div className="flex-1 overflow-auto p-3 space-y-1.5">
|
|
1573
|
+
{display.length === 0 && (
|
|
1574
|
+
<div className="text-gray-600 text-center mt-8">No memory yet. Run this agent to build memory.</div>
|
|
1575
|
+
)}
|
|
1576
|
+
{display.map((entry: any) => (
|
|
1577
|
+
<div key={entry.id} className={`rounded px-3 py-2 ${entry.isCompact ? 'opacity-60' : ''}`}
|
|
1578
|
+
style={{ background: '#161b22', border: '1px solid #21262d' }}>
|
|
1579
|
+
<div className="flex items-center gap-2">
|
|
1580
|
+
<span className="text-[10px]">{entry.icon}</span>
|
|
1581
|
+
<span className={`text-[9px] font-medium ${TYPE_COLORS[entry.type] || 'text-gray-400'}`}>{entry.type}</span>
|
|
1582
|
+
<span className="text-[10px] text-white flex-1 truncate">{entry.title}</span>
|
|
1583
|
+
<span className="text-[8px] text-gray-600 shrink-0">
|
|
1584
|
+
{new Date(entry.timestamp).toLocaleString()}
|
|
1585
|
+
</span>
|
|
1586
|
+
</div>
|
|
1587
|
+
{!entry.isCompact && entry.subtitle && (
|
|
1588
|
+
<div className="text-[9px] text-gray-500 mt-1">{entry.subtitle}</div>
|
|
1589
|
+
)}
|
|
1590
|
+
{!entry.isCompact && entry.facts && entry.facts.length > 0 && (
|
|
1591
|
+
<div className="mt-1 space-y-0.5">
|
|
1592
|
+
{entry.facts.map((f: string, i: number) => (
|
|
1593
|
+
<div key={i} className="text-[8px] text-gray-500">• {f}</div>
|
|
1594
|
+
))}
|
|
1595
|
+
</div>
|
|
1596
|
+
)}
|
|
1597
|
+
{entry.files && entry.files.length > 0 && (
|
|
1598
|
+
<div className="text-[8px] text-gray-600 mt-1">
|
|
1599
|
+
Files: {entry.files.join(', ')}
|
|
1600
|
+
</div>
|
|
1601
|
+
)}
|
|
1602
|
+
</div>
|
|
1603
|
+
))}
|
|
1604
|
+
</div>
|
|
1605
|
+
</div>
|
|
1606
|
+
</div>
|
|
1607
|
+
);
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1610
|
+
// ─── Bus Message Panel ───────────────────────────────────
|
|
1611
|
+
|
|
1612
|
+
// ─── Agent Inbox/Outbox Panel ────────────────────────────
|
|
1613
|
+
|
|
1614
|
+
function InboxPanel({ agentId, agentLabel, busLog, agents, workspaceId, onClose }: {
|
|
1615
|
+
agentId: string; agentLabel: string; busLog: any[]; agents: AgentConfig[]; workspaceId: string; onClose: () => void;
|
|
1616
|
+
}) {
|
|
1617
|
+
const labelMap = new Map(agents.map(a => [a.id, `${a.icon} ${a.label}`]));
|
|
1618
|
+
const getLabel = (id: string) => labelMap.get(id) || id;
|
|
1619
|
+
const [deletedIds, setDeletedIds] = useState<Set<string>>(new Set());
|
|
1620
|
+
const [selected, setSelected] = useState<Set<string>>(new Set());
|
|
1621
|
+
|
|
1622
|
+
// Filter messages related to this agent, exclude locally deleted
|
|
1623
|
+
const inbox = busLog.filter(m => m.to === agentId && m.type !== 'ack' && !deletedIds.has(m.id));
|
|
1624
|
+
const outbox = busLog.filter(m => m.from === agentId && m.to !== '_system' && m.type !== 'ack' && !deletedIds.has(m.id));
|
|
1625
|
+
const [tab, setTab] = useState<'inbox' | 'outbox'>('inbox');
|
|
1626
|
+
const messages = tab === 'inbox' ? inbox : outbox;
|
|
1627
|
+
|
|
1628
|
+
const handleDelete = async (msgId: string) => {
|
|
1629
|
+
await wsApi(workspaceId, 'delete_message', { messageId: msgId });
|
|
1630
|
+
setDeletedIds(prev => new Set(prev).add(msgId));
|
|
1631
|
+
};
|
|
1632
|
+
|
|
1633
|
+
const toggleSelect = (msgId: string) => {
|
|
1634
|
+
setSelected(prev => { const s = new Set(prev); s.has(msgId) ? s.delete(msgId) : s.add(msgId); return s; });
|
|
1635
|
+
};
|
|
1636
|
+
|
|
1637
|
+
const selectAll = () => {
|
|
1638
|
+
const deletable = messages.filter(m => m.status === 'done' || m.status === 'failed');
|
|
1639
|
+
setSelected(new Set(deletable.map(m => m.id)));
|
|
1640
|
+
};
|
|
1641
|
+
|
|
1642
|
+
const handleBatchDelete = async () => {
|
|
1643
|
+
for (const id of selected) {
|
|
1644
|
+
await wsApi(workspaceId, 'delete_message', { messageId: id });
|
|
1645
|
+
setDeletedIds(prev => new Set(prev).add(id));
|
|
1646
|
+
}
|
|
1647
|
+
setSelected(new Set());
|
|
1648
|
+
};
|
|
1649
|
+
|
|
1650
|
+
const handleAbortAllPending = async () => {
|
|
1651
|
+
const pendingMsgs = messages.filter(m => m.status === 'pending');
|
|
1652
|
+
await Promise.all(pendingMsgs.map(m =>
|
|
1653
|
+
wsApi(workspaceId, 'abort_message', { messageId: m.id }).catch(() => {})
|
|
1654
|
+
));
|
|
1655
|
+
};
|
|
1656
|
+
|
|
1657
|
+
return (
|
|
1658
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center" style={{ background: 'rgba(0,0,0,0.85)' }}
|
|
1659
|
+
onClick={e => { if (e.target === e.currentTarget) onClose(); }}>
|
|
1660
|
+
<div className="flex flex-col rounded-xl overflow-hidden shadow-2xl" style={{ width: '60vw', height: '50vh', border: '1px solid #30363d', background: '#0d1117' }}>
|
|
1661
|
+
<div className="flex items-center gap-2 px-4 py-2 border-b border-[#30363d] shrink-0">
|
|
1662
|
+
<span className="text-sm">📨</span>
|
|
1663
|
+
<span className="text-sm font-bold text-white">{agentLabel}</span>
|
|
1664
|
+
<div className="flex gap-1 ml-3">
|
|
1665
|
+
<button onClick={() => setTab('inbox')}
|
|
1666
|
+
className={`text-[9px] px-2 py-0.5 rounded ${tab === 'inbox' ? 'bg-[#21262d] text-white' : 'text-gray-500 hover:text-gray-300'}`}>
|
|
1667
|
+
Inbox ({inbox.length})
|
|
1668
|
+
</button>
|
|
1669
|
+
<button onClick={() => setTab('outbox')}
|
|
1670
|
+
className={`text-[9px] px-2 py-0.5 rounded ${tab === 'outbox' ? 'bg-[#21262d] text-white' : 'text-gray-500 hover:text-gray-300'}`}>
|
|
1671
|
+
Outbox ({outbox.length})
|
|
1672
|
+
</button>
|
|
1673
|
+
</div>
|
|
1674
|
+
{selected.size > 0 && (
|
|
1675
|
+
<div className="flex items-center gap-2 ml-3">
|
|
1676
|
+
<span className="text-[9px] text-gray-400">{selected.size} selected</span>
|
|
1677
|
+
<button onClick={handleBatchDelete}
|
|
1678
|
+
className="text-[8px] px-2 py-0.5 rounded bg-red-600/20 text-red-400 hover:bg-red-600/30">
|
|
1679
|
+
Delete selected
|
|
1680
|
+
</button>
|
|
1681
|
+
<button onClick={() => setSelected(new Set())}
|
|
1682
|
+
className="text-[8px] px-2 py-0.5 rounded bg-gray-600/20 text-gray-400 hover:bg-gray-600/30">
|
|
1683
|
+
Clear
|
|
1684
|
+
</button>
|
|
1685
|
+
</div>
|
|
1686
|
+
)}
|
|
1687
|
+
{selected.size === 0 && (
|
|
1688
|
+
<div className="flex items-center gap-2 ml-3">
|
|
1689
|
+
{messages.some(m => m.status === 'done' || m.status === 'failed') && (
|
|
1690
|
+
<button onClick={selectAll}
|
|
1691
|
+
className="text-[8px] px-2 py-0.5 rounded text-gray-500 hover:text-gray-300">
|
|
1692
|
+
Select all completed
|
|
1693
|
+
</button>
|
|
1694
|
+
)}
|
|
1695
|
+
{messages.some(m => m.status === 'pending') && (
|
|
1696
|
+
<button onClick={handleAbortAllPending}
|
|
1697
|
+
className="text-[8px] px-2 py-0.5 rounded bg-red-600/20 text-red-400 hover:bg-red-600/30">
|
|
1698
|
+
Abort all pending ({messages.filter(m => m.status === 'pending').length})
|
|
1699
|
+
</button>
|
|
1700
|
+
)}
|
|
1701
|
+
</div>
|
|
1702
|
+
)}
|
|
1703
|
+
<button onClick={onClose} className="text-gray-500 hover:text-white text-sm ml-auto">✕</button>
|
|
1704
|
+
</div>
|
|
1705
|
+
<div className="flex-1 overflow-auto p-3 space-y-1.5">
|
|
1706
|
+
{messages.length === 0 && (
|
|
1707
|
+
<div className="text-gray-600 text-center mt-8">No {tab} messages</div>
|
|
1708
|
+
)}
|
|
1709
|
+
{[...messages].reverse().map((msg, i) => {
|
|
1710
|
+
const isTicket = msg.category === 'ticket';
|
|
1711
|
+
const canSelect = msg.status === 'done' || msg.status === 'failed';
|
|
1712
|
+
return (
|
|
1713
|
+
<div key={i} className="flex items-start gap-2 px-3 py-2 rounded text-[10px]" style={{
|
|
1714
|
+
background: '#161b22',
|
|
1715
|
+
border: `1px solid ${isTicket ? '#6e40c9' : '#21262d'}`,
|
|
1716
|
+
borderLeft: isTicket ? '3px solid #a371f7' : undefined,
|
|
1717
|
+
}}>
|
|
1718
|
+
{canSelect && (
|
|
1719
|
+
<input type="checkbox" checked={selected.has(msg.id)} onChange={() => toggleSelect(msg.id)}
|
|
1720
|
+
className="mt-1 shrink-0 accent-[#58a6ff]" />
|
|
1721
|
+
)}
|
|
1722
|
+
<div className="flex-1 min-w-0">
|
|
1723
|
+
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
|
1724
|
+
<span className="text-[8px] text-gray-600">{new Date(msg.timestamp).toLocaleString()}</span>
|
|
1725
|
+
{tab === 'inbox' ? (
|
|
1726
|
+
<span className="text-blue-400">← {getLabel(msg.from)}</span>
|
|
1727
|
+
) : (
|
|
1728
|
+
<span className="text-green-400">→ {getLabel(msg.to)}</span>
|
|
1729
|
+
)}
|
|
1730
|
+
{/* Category badge */}
|
|
1731
|
+
{isTicket && (
|
|
1732
|
+
<span className="px-1 py-0.5 rounded text-[7px] bg-purple-500/20 text-purple-400">TICKET</span>
|
|
1733
|
+
)}
|
|
1734
|
+
{/* Action badge */}
|
|
1735
|
+
<span className={`px-1.5 py-0.5 rounded text-[8px] ${
|
|
1736
|
+
msg.payload?.action === 'fix_request' || msg.payload?.action === 'bug_report' ? 'bg-red-500/20 text-red-400' :
|
|
1737
|
+
msg.payload?.action === 'update_notify' || msg.payload?.action === 'request_complete' ? 'bg-blue-500/20 text-blue-400' :
|
|
1738
|
+
msg.payload?.action === 'question' ? 'bg-yellow-500/20 text-yellow-400' :
|
|
1739
|
+
'bg-gray-500/20 text-gray-400'
|
|
1740
|
+
}`}>{msg.payload?.action}</span>
|
|
1741
|
+
{/* Ticket status */}
|
|
1742
|
+
{isTicket && msg.ticketStatus && (
|
|
1743
|
+
<span className={`text-[7px] px-1 rounded ${
|
|
1744
|
+
msg.ticketStatus === 'open' ? 'bg-yellow-500/20 text-yellow-400' :
|
|
1745
|
+
msg.ticketStatus === 'in_progress' ? 'bg-blue-500/20 text-blue-400' :
|
|
1746
|
+
msg.ticketStatus === 'fixed' ? 'bg-green-500/20 text-green-400' :
|
|
1747
|
+
msg.ticketStatus === 'verified' ? 'bg-green-600/20 text-green-300' :
|
|
1748
|
+
msg.ticketStatus === 'closed' ? 'bg-gray-500/20 text-gray-400' :
|
|
1749
|
+
'bg-gray-500/20 text-gray-400'
|
|
1750
|
+
}`}>{msg.ticketStatus}</span>
|
|
1751
|
+
)}
|
|
1752
|
+
{/* Message delivery status */}
|
|
1753
|
+
<span className={`text-[7px] ${msg.status === 'done' ? 'text-green-500' : msg.status === 'running' ? 'text-blue-400' : msg.status === 'failed' ? 'text-red-500' : msg.status === 'pending_approval' ? 'text-orange-400' : 'text-yellow-500'}`}>
|
|
1754
|
+
{msg.status || 'pending'}
|
|
1755
|
+
</span>
|
|
1756
|
+
{/* Retry count for tickets */}
|
|
1757
|
+
{isTicket && (msg.ticketRetries || 0) > 0 && (
|
|
1758
|
+
<span className="text-[7px] text-orange-400">retry {msg.ticketRetries}/{msg.maxRetries || 3}</span>
|
|
1759
|
+
)}
|
|
1760
|
+
{/* CausedBy trace */}
|
|
1761
|
+
{msg.causedBy && (
|
|
1762
|
+
<span className="text-[7px] text-gray-600" title={`Triggered by message from ${getLabel(msg.causedBy.from)}`}>
|
|
1763
|
+
← {getLabel(msg.causedBy.from)}
|
|
1764
|
+
</span>
|
|
1765
|
+
)}
|
|
1766
|
+
{/* Actions */}
|
|
1767
|
+
{msg.status === 'pending_approval' && (
|
|
1768
|
+
<div className="flex gap-1 ml-auto">
|
|
1769
|
+
<button onClick={() => wsApi(workspaceId, 'approve_message', { messageId: msg.id })}
|
|
1770
|
+
className="text-[7px] px-1.5 py-0.5 rounded bg-green-600/20 text-green-400 hover:bg-green-600/30">
|
|
1771
|
+
✓ Approve
|
|
1772
|
+
</button>
|
|
1773
|
+
<button onClick={() => wsApi(workspaceId, 'reject_message', { messageId: msg.id })}
|
|
1774
|
+
className="text-[7px] px-1.5 py-0.5 rounded bg-red-600/20 text-red-400 hover:bg-red-600/30">
|
|
1775
|
+
✕ Reject
|
|
1776
|
+
</button>
|
|
1777
|
+
</div>
|
|
1778
|
+
)}
|
|
1779
|
+
{(msg.status === 'pending' || msg.status === 'running') && msg.type !== 'ack' && (
|
|
1780
|
+
<div className="flex gap-1 ml-auto">
|
|
1781
|
+
<button onClick={() => wsApi(workspaceId, 'message_done', { messageId: msg.id })}
|
|
1782
|
+
className="text-[7px] px-1.5 py-0.5 rounded bg-green-600/20 text-green-400 hover:bg-green-600/30">
|
|
1783
|
+
✓ Done
|
|
1784
|
+
</button>
|
|
1785
|
+
<button onClick={() => wsApi(workspaceId, 'abort_message', { messageId: msg.id })}
|
|
1786
|
+
className="text-[7px] px-1.5 py-0.5 rounded bg-red-600/20 text-red-400 hover:bg-red-600/30">
|
|
1787
|
+
✕ Abort
|
|
1788
|
+
</button>
|
|
1789
|
+
</div>
|
|
1790
|
+
)}
|
|
1791
|
+
{(msg.status === 'done' || msg.status === 'failed') && msg.type !== 'ack' && (
|
|
1792
|
+
<div className="flex gap-1 ml-auto">
|
|
1793
|
+
<button onClick={() => wsApi(workspaceId, 'retry_message', { messageId: msg.id })}
|
|
1794
|
+
className="text-[7px] px-1.5 py-0.5 rounded bg-orange-600/20 text-orange-400 hover:bg-orange-600/30">
|
|
1795
|
+
{msg.status === 'done' ? '↻ Re-run' : '↻ Retry'}
|
|
1796
|
+
</button>
|
|
1797
|
+
<button onClick={() => handleDelete(msg.id)}
|
|
1798
|
+
className="text-[7px] px-1.5 py-0.5 rounded bg-gray-600/20 text-gray-400 hover:bg-red-600/20 hover:text-red-400">
|
|
1799
|
+
🗑
|
|
1800
|
+
</button>
|
|
1801
|
+
</div>
|
|
1802
|
+
)}
|
|
1803
|
+
</div>
|
|
1804
|
+
<div className="text-gray-300">{msg.payload?.content || ''}</div>
|
|
1805
|
+
{msg.payload?.files?.length > 0 && (
|
|
1806
|
+
<div className="text-[8px] text-gray-600 mt-1">Files: {msg.payload.files.join(', ')}</div>
|
|
1807
|
+
)}
|
|
1808
|
+
</div>
|
|
1809
|
+
</div>
|
|
1810
|
+
);
|
|
1811
|
+
})}
|
|
1812
|
+
</div>
|
|
1813
|
+
</div>
|
|
1814
|
+
</div>
|
|
1815
|
+
);
|
|
1816
|
+
}
|
|
1817
|
+
|
|
1818
|
+
function BusPanel({ busLog, agents, onClose }: {
|
|
1819
|
+
busLog: any[]; agents: AgentConfig[]; onClose: () => void;
|
|
1820
|
+
}) {
|
|
1821
|
+
const labelMap = new Map(agents.map(a => [a.id, `${a.icon} ${a.label}`]));
|
|
1822
|
+
const getLabel = (id: string) => labelMap.get(id) || id;
|
|
1823
|
+
|
|
1824
|
+
return (
|
|
1825
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center" style={{ background: 'rgba(0,0,0,0.85)' }}
|
|
1826
|
+
onClick={e => { if (e.target === e.currentTarget) onClose(); }}>
|
|
1827
|
+
<div className="flex flex-col rounded-xl overflow-hidden shadow-2xl" style={{ width: '65vw', height: '55vh', border: '1px solid #30363d', background: '#0d1117' }}>
|
|
1828
|
+
<div className="flex items-center gap-2 px-4 py-2 border-b border-[#30363d] shrink-0">
|
|
1829
|
+
<span className="text-sm">📡</span>
|
|
1830
|
+
<span className="text-sm font-bold text-white">Agent Communication Logs</span>
|
|
1831
|
+
<span className="text-[9px] text-gray-500">{busLog.length} messages</span>
|
|
1832
|
+
<button onClick={onClose} className="text-gray-500 hover:text-white text-sm ml-auto">✕</button>
|
|
1833
|
+
</div>
|
|
1834
|
+
<div className="flex-1 overflow-auto p-3 space-y-1">
|
|
1835
|
+
{busLog.length === 0 && <div className="text-gray-600 text-center mt-8">No messages yet</div>}
|
|
1836
|
+
{[...busLog].reverse().map((msg, i) => (
|
|
1837
|
+
<div key={i} className="flex items-start gap-2 text-[10px] px-3 py-1.5 rounded"
|
|
1838
|
+
style={{ background: '#161b22', border: '1px solid #21262d' }}>
|
|
1839
|
+
<span className="text-gray-600 shrink-0 w-14">{new Date(msg.timestamp).toLocaleTimeString()}</span>
|
|
1840
|
+
<span className="text-blue-400 shrink-0">{getLabel(msg.from)}</span>
|
|
1841
|
+
<span className="text-gray-600">→</span>
|
|
1842
|
+
<span className="text-green-400 shrink-0">{msg.to === '_system' ? '📡 system' : getLabel(msg.to)}</span>
|
|
1843
|
+
<span className={`px-1 rounded text-[8px] ${
|
|
1844
|
+
msg.payload?.action === 'fix_request' ? 'bg-red-500/20 text-red-400' :
|
|
1845
|
+
msg.payload?.action === 'task_complete' ? 'bg-green-500/20 text-green-400' :
|
|
1846
|
+
msg.payload?.action === 'ack' ? 'bg-gray-500/20 text-gray-500' :
|
|
1847
|
+
'bg-blue-500/20 text-blue-400'
|
|
1848
|
+
}`}>{msg.payload?.action}</span>
|
|
1849
|
+
<span className="text-gray-400 truncate flex-1">{msg.payload?.content || ''}</span>
|
|
1850
|
+
{msg.status && msg.status !== 'done' && (
|
|
1851
|
+
<span className={`text-[7px] px-1 rounded ${
|
|
1852
|
+
msg.status === 'done' ? 'text-green-500' : msg.status === 'failed' ? 'text-red-500' : 'text-yellow-500'
|
|
1853
|
+
}`}>{msg.status}</span>
|
|
1854
|
+
)}
|
|
1855
|
+
</div>
|
|
1856
|
+
))}
|
|
1857
|
+
</div>
|
|
1858
|
+
</div>
|
|
1859
|
+
</div>
|
|
1860
|
+
);
|
|
1861
|
+
}
|
|
1862
|
+
|
|
1863
|
+
// ─── Terminal Launch Dialog ───────────────────────────────
|
|
1864
|
+
|
|
1865
|
+
function SessionItem({ session, formatTime, formatSize, onSelect }: {
|
|
1866
|
+
session: { id: string; modified: string; size: number };
|
|
1867
|
+
formatTime: (iso: string) => string;
|
|
1868
|
+
formatSize: (bytes: number) => string;
|
|
1869
|
+
onSelect: () => void;
|
|
1870
|
+
}) {
|
|
1871
|
+
const [expanded, setExpanded] = useState(false);
|
|
1872
|
+
const [copied, setCopied] = useState(false);
|
|
1873
|
+
|
|
1874
|
+
const copyId = (e: React.MouseEvent) => {
|
|
1875
|
+
e.stopPropagation();
|
|
1876
|
+
navigator.clipboard.writeText(session.id).then(() => {
|
|
1877
|
+
setCopied(true);
|
|
1878
|
+
setTimeout(() => setCopied(false), 1500);
|
|
1879
|
+
});
|
|
1880
|
+
};
|
|
1881
|
+
|
|
1882
|
+
return (
|
|
1883
|
+
<div className="rounded border border-[#21262d] hover:border-[#30363d] hover:bg-[#161b22] transition-colors">
|
|
1884
|
+
<div className="flex items-center gap-2 px-3 py-1.5 cursor-pointer" onClick={() => setExpanded(!expanded)}>
|
|
1885
|
+
<span className="text-[8px] text-gray-600">{expanded ? '▼' : '▶'}</span>
|
|
1886
|
+
<span className="text-[9px] text-gray-400 font-mono">{session.id.slice(0, 8)}</span>
|
|
1887
|
+
<span className="text-[8px] text-gray-600">{formatTime(session.modified)}</span>
|
|
1888
|
+
<span className="text-[8px] text-gray-600">{formatSize(session.size)}</span>
|
|
1889
|
+
<button onClick={(e) => { e.stopPropagation(); onSelect(); }}
|
|
1890
|
+
className="ml-auto text-[8px] px-1.5 py-0.5 rounded bg-[#238636]/20 text-[#3fb950] hover:bg-[#238636]/40">Resume</button>
|
|
1891
|
+
</div>
|
|
1892
|
+
{expanded && (
|
|
1893
|
+
<div className="px-3 pb-2 flex items-center gap-1.5">
|
|
1894
|
+
<code className="text-[8px] text-gray-500 font-mono bg-[#161b22] px-1.5 py-0.5 rounded border border-[#21262d] select-all flex-1 overflow-hidden text-ellipsis">
|
|
1895
|
+
{session.id}
|
|
1896
|
+
</code>
|
|
1897
|
+
<button onClick={copyId}
|
|
1898
|
+
className="text-[8px] px-1.5 py-0.5 rounded bg-[#30363d] text-gray-400 hover:text-white hover:bg-[#484f58] shrink-0">
|
|
1899
|
+
{copied ? '✓' : 'Copy'}
|
|
1900
|
+
</button>
|
|
1901
|
+
</div>
|
|
1902
|
+
)}
|
|
1903
|
+
</div>
|
|
1904
|
+
);
|
|
1905
|
+
}
|
|
1906
|
+
|
|
1907
|
+
function TerminalLaunchDialog({ agent, workDir, sessName, projectPath, workspaceId, supportsSession, onLaunch, onCancel }: {
|
|
1908
|
+
agent: AgentConfig; workDir?: string; sessName: string; projectPath: string; workspaceId: string;
|
|
1909
|
+
supportsSession?: boolean;
|
|
1910
|
+
onLaunch: (resumeMode: boolean, sessionId?: string) => void; onCancel: () => void;
|
|
1911
|
+
}) {
|
|
1912
|
+
const [sessions, setSessions] = useState<{ id: string; modified: string; size: number }[]>([]);
|
|
1913
|
+
const [showSessions, setShowSessions] = useState(false);
|
|
1914
|
+
// Use resolved supportsSession from API (defaults to true for backwards compat)
|
|
1915
|
+
const isClaude = supportsSession !== false;
|
|
1916
|
+
|
|
1917
|
+
// Fetch recent sessions (only for claude-based agents)
|
|
1918
|
+
useEffect(() => {
|
|
1919
|
+
if (!isClaude) return;
|
|
1920
|
+
fetch(`/api/workspace/${workspaceId}/smith`, {
|
|
1921
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
1922
|
+
body: JSON.stringify({ action: 'sessions', agentId: agent.id }),
|
|
1923
|
+
}).then(r => r.json()).then(d => {
|
|
1924
|
+
if (d.sessions?.length) setSessions(d.sessions);
|
|
1925
|
+
}).catch(() => {});
|
|
1926
|
+
}, [workspaceId, isClaude]);
|
|
1927
|
+
|
|
1928
|
+
const formatTime = (iso: string) => {
|
|
1929
|
+
const d = new Date(iso);
|
|
1930
|
+
const now = new Date();
|
|
1931
|
+
const diff = now.getTime() - d.getTime();
|
|
1932
|
+
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`;
|
|
1933
|
+
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`;
|
|
1934
|
+
return d.toLocaleDateString();
|
|
1935
|
+
};
|
|
1936
|
+
|
|
1937
|
+
const formatSize = (bytes: number) => {
|
|
1938
|
+
if (bytes < 1024) return `${bytes}B`;
|
|
1939
|
+
if (bytes < 1048576) return `${(bytes / 1024).toFixed(0)}KB`;
|
|
1940
|
+
return `${(bytes / 1048576).toFixed(1)}MB`;
|
|
1941
|
+
};
|
|
1942
|
+
|
|
1943
|
+
return (
|
|
1944
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center" style={{ background: 'rgba(0,0,0,0.75)' }}
|
|
1945
|
+
onClick={e => { if (e.target === e.currentTarget) onCancel(); }}>
|
|
1946
|
+
<div className="w-80 rounded-lg border border-[#30363d] p-4 shadow-xl" style={{ background: '#0d1117' }}>
|
|
1947
|
+
<div className="text-sm font-bold text-white mb-3">⌨️ {agent.label}</div>
|
|
1948
|
+
|
|
1949
|
+
<div className="space-y-2">
|
|
1950
|
+
<button onClick={() => onLaunch(false)}
|
|
1951
|
+
className="w-full text-left px-3 py-2 rounded border border-[#30363d] hover:border-[#58a6ff] hover:bg-[#161b22] transition-colors">
|
|
1952
|
+
<div className="text-xs text-white font-semibold">{isClaude ? 'New Session' : 'Open Terminal'}</div>
|
|
1953
|
+
<div className="text-[9px] text-gray-500">{isClaude ? 'Start fresh claude session' : `Launch ${agent.agentId || 'agent'}`}</div>
|
|
1954
|
+
</button>
|
|
1955
|
+
|
|
1956
|
+
{isClaude && sessions.length > 0 && (
|
|
1957
|
+
<button onClick={() => onLaunch(true)}
|
|
1958
|
+
className="w-full text-left px-3 py-2 rounded border border-[#30363d] hover:border-[#3fb950] hover:bg-[#161b22] transition-colors">
|
|
1959
|
+
<div className="text-xs text-white font-semibold">Resume Latest</div>
|
|
1960
|
+
<div className="text-[9px] text-gray-500">
|
|
1961
|
+
{sessions[0].id.slice(0, 8)} · {formatTime(sessions[0].modified)} · {formatSize(sessions[0].size)}
|
|
1962
|
+
</div>
|
|
1963
|
+
</button>
|
|
1964
|
+
)}
|
|
1965
|
+
|
|
1966
|
+
{isClaude && sessions.length > 1 && (
|
|
1967
|
+
<button onClick={() => setShowSessions(!showSessions)}
|
|
1968
|
+
className="w-full text-[9px] text-gray-500 hover:text-white py-1">
|
|
1969
|
+
{showSessions ? '▼' : '▶'} All sessions ({sessions.length})
|
|
1970
|
+
</button>
|
|
1971
|
+
)}
|
|
1972
|
+
|
|
1973
|
+
{showSessions && sessions.map(s => (
|
|
1974
|
+
<SessionItem key={s.id} session={s} formatTime={formatTime} formatSize={formatSize}
|
|
1975
|
+
onSelect={() => onLaunch(true, s.id)} />
|
|
1976
|
+
))}
|
|
1977
|
+
</div>
|
|
1978
|
+
|
|
1979
|
+
<button onClick={onCancel}
|
|
1980
|
+
className="w-full mt-3 text-[9px] text-gray-500 hover:text-white">Cancel</button>
|
|
1981
|
+
</div>
|
|
1982
|
+
</div>
|
|
1983
|
+
);
|
|
1984
|
+
}
|
|
1985
|
+
|
|
1986
|
+
// ─── Floating Terminal ────────────────────────────────────
|
|
1987
|
+
|
|
1988
|
+
function getWsUrl() {
|
|
1989
|
+
if (typeof window === 'undefined') return 'ws://localhost:8404';
|
|
1990
|
+
const p = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
1991
|
+
const h = window.location.hostname;
|
|
1992
|
+
if (h !== 'localhost' && h !== '127.0.0.1') return `${p}//${window.location.host}/terminal-ws`;
|
|
1993
|
+
const port = parseInt(window.location.port) || 8403;
|
|
1994
|
+
return `${p}//${h}:${port + 1}`;
|
|
1995
|
+
}
|
|
1996
|
+
|
|
1997
|
+
// ─── Terminal Dock (right side panel with tabs) ──────────
|
|
1998
|
+
type TerminalEntry = { agentId: string; label: string; icon: string; cliId: string; cliCmd?: string; cliType?: string; workDir?: string; tmuxSession?: string; sessionName: string; resumeMode?: boolean; resumeSessionId?: string; profileEnv?: Record<string, string> };
|
|
1999
|
+
|
|
2000
|
+
function TerminalDock({ terminals, projectPath, workspaceId, onSessionReady, onClose }: {
|
|
2001
|
+
terminals: TerminalEntry[];
|
|
2002
|
+
projectPath: string;
|
|
2003
|
+
workspaceId: string | null;
|
|
2004
|
+
onSessionReady: (agentId: string, name: string) => void;
|
|
2005
|
+
onClose: (agentId: string) => void;
|
|
2006
|
+
}) {
|
|
2007
|
+
const [activeTab, setActiveTab] = useState(terminals[0]?.agentId || '');
|
|
2008
|
+
const [width, setWidth] = useState(520);
|
|
2009
|
+
const dragRef = useRef<{ startX: number; origW: number } | null>(null);
|
|
2010
|
+
|
|
2011
|
+
// Auto-select new tab when added
|
|
2012
|
+
useEffect(() => {
|
|
2013
|
+
if (terminals.length > 0 && !terminals.find(t => t.agentId === activeTab)) {
|
|
2014
|
+
setActiveTab(terminals[terminals.length - 1].agentId);
|
|
2015
|
+
}
|
|
2016
|
+
}, [terminals, activeTab]);
|
|
2017
|
+
|
|
2018
|
+
const active = terminals.find(t => t.agentId === activeTab);
|
|
2019
|
+
|
|
2020
|
+
return (
|
|
2021
|
+
<div className="flex shrink-0" style={{ width }}>
|
|
2022
|
+
{/* Resize handle */}
|
|
2023
|
+
<div
|
|
2024
|
+
className="w-1 cursor-col-resize hover:bg-[#58a6ff]/30 active:bg-[#58a6ff]/50 transition-colors"
|
|
2025
|
+
style={{ background: '#21262d' }}
|
|
2026
|
+
onMouseDown={(e) => {
|
|
2027
|
+
e.preventDefault();
|
|
2028
|
+
dragRef.current = { startX: e.clientX, origW: width };
|
|
2029
|
+
const onMove = (ev: MouseEvent) => {
|
|
2030
|
+
if (!dragRef.current) return;
|
|
2031
|
+
const newW = dragRef.current.origW - (ev.clientX - dragRef.current.startX);
|
|
2032
|
+
setWidth(Math.max(300, Math.min(1200, newW)));
|
|
2033
|
+
};
|
|
2034
|
+
const onUp = () => { dragRef.current = null; window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); };
|
|
2035
|
+
window.addEventListener('mousemove', onMove);
|
|
2036
|
+
window.addEventListener('mouseup', onUp);
|
|
2037
|
+
}}
|
|
2038
|
+
/>
|
|
2039
|
+
<div className="flex-1 flex flex-col min-w-0 bg-[#0d1117] border-l border-[#30363d]">
|
|
2040
|
+
{/* Tabs */}
|
|
2041
|
+
<div className="flex items-center bg-[#161b22] border-b border-[#30363d] overflow-x-auto shrink-0">
|
|
2042
|
+
{terminals.map(t => (
|
|
2043
|
+
<div
|
|
2044
|
+
key={t.agentId}
|
|
2045
|
+
onClick={() => setActiveTab(t.agentId)}
|
|
2046
|
+
className={`flex items-center gap-1.5 px-3 py-1.5 text-[10px] border-r border-[#30363d] shrink-0 cursor-pointer ${
|
|
2047
|
+
t.agentId === activeTab
|
|
2048
|
+
? 'bg-[#0d1117] text-white border-b-2 border-b-[#58a6ff]'
|
|
2049
|
+
: 'text-gray-500 hover:text-gray-300 hover:bg-[#1c2128]'
|
|
2050
|
+
}`}
|
|
2051
|
+
>
|
|
2052
|
+
<span>{t.icon}</span>
|
|
2053
|
+
<span className="font-medium">{t.label}</span>
|
|
2054
|
+
<span
|
|
2055
|
+
onClick={(e) => { e.stopPropagation(); onClose(t.agentId); }}
|
|
2056
|
+
className="ml-1 text-gray-600 hover:text-red-400 text-[8px] cursor-pointer"
|
|
2057
|
+
>✕</span>
|
|
2058
|
+
</div>
|
|
2059
|
+
))}
|
|
2060
|
+
</div>
|
|
2061
|
+
{/* Active terminal */}
|
|
2062
|
+
{active && (
|
|
2063
|
+
<div className="flex-1 min-h-0" key={active.agentId}>
|
|
2064
|
+
<FloatingTerminalInline
|
|
2065
|
+
agentLabel={active.label}
|
|
2066
|
+
agentIcon={active.icon}
|
|
2067
|
+
projectPath={projectPath}
|
|
2068
|
+
agentCliId={active.cliId}
|
|
2069
|
+
cliCmd={active.cliCmd}
|
|
2070
|
+
cliType={active.cliType}
|
|
2071
|
+
workDir={active.workDir}
|
|
2072
|
+
preferredSessionName={active.sessionName}
|
|
2073
|
+
existingSession={active.tmuxSession}
|
|
2074
|
+
resumeMode={active.resumeMode}
|
|
2075
|
+
resumeSessionId={active.resumeSessionId}
|
|
2076
|
+
profileEnv={active.profileEnv}
|
|
2077
|
+
onSessionReady={(name) => onSessionReady(active.agentId, name)}
|
|
2078
|
+
/>
|
|
2079
|
+
</div>
|
|
2080
|
+
)}
|
|
2081
|
+
</div>
|
|
2082
|
+
</div>
|
|
2083
|
+
);
|
|
2084
|
+
}
|
|
2085
|
+
|
|
2086
|
+
// ─── Inline Terminal (no drag/resize, fills parent) ──────
|
|
2087
|
+
function FloatingTerminalInline({ agentLabel, agentIcon, projectPath, agentCliId, cliCmd: cliCmdProp, cliType, workDir, preferredSessionName, existingSession, resumeMode, resumeSessionId, profileEnv, isPrimary, skipPermissions, boundSessionId, onSessionReady }: {
|
|
2088
|
+
agentLabel: string;
|
|
2089
|
+
agentIcon: string;
|
|
2090
|
+
projectPath: string;
|
|
2091
|
+
agentCliId: string;
|
|
2092
|
+
cliCmd?: string;
|
|
2093
|
+
cliType?: string;
|
|
2094
|
+
workDir?: string;
|
|
2095
|
+
preferredSessionName?: string;
|
|
2096
|
+
existingSession?: string;
|
|
2097
|
+
resumeMode?: boolean;
|
|
2098
|
+
resumeSessionId?: string;
|
|
2099
|
+
profileEnv?: Record<string, string>;
|
|
2100
|
+
isPrimary?: boolean;
|
|
2101
|
+
skipPermissions?: boolean;
|
|
2102
|
+
boundSessionId?: string;
|
|
2103
|
+
onSessionReady?: (name: string) => void;
|
|
2104
|
+
}) {
|
|
2105
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
2106
|
+
|
|
2107
|
+
useEffect(() => {
|
|
2108
|
+
const el = containerRef.current;
|
|
2109
|
+
if (!el) return;
|
|
2110
|
+
let disposed = false;
|
|
2111
|
+
|
|
2112
|
+
Promise.all([
|
|
2113
|
+
import('@xterm/xterm'),
|
|
2114
|
+
import('@xterm/addon-fit'),
|
|
2115
|
+
]).then(([{ Terminal }, { FitAddon }]) => {
|
|
2116
|
+
if (disposed) return;
|
|
2117
|
+
|
|
2118
|
+
const term = new Terminal({
|
|
2119
|
+
cursorBlink: true, fontSize: 13,
|
|
2120
|
+
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
|
|
2121
|
+
scrollback: 5000,
|
|
2122
|
+
theme: { background: '#0d1117', foreground: '#c9d1d9', cursor: '#58a6ff' },
|
|
2123
|
+
});
|
|
2124
|
+
const fitAddon = new FitAddon();
|
|
2125
|
+
term.loadAddon(fitAddon);
|
|
2126
|
+
term.open(el);
|
|
2127
|
+
setTimeout(() => { try { fitAddon.fit(); } catch {} }, 100);
|
|
2128
|
+
|
|
2129
|
+
const ro = new ResizeObserver(() => { try { fitAddon.fit(); } catch {} });
|
|
2130
|
+
ro.observe(el);
|
|
2131
|
+
|
|
2132
|
+
// Connect to terminal server
|
|
2133
|
+
const wsUrl = getWsUrl();
|
|
2134
|
+
const ws = new WebSocket(wsUrl);
|
|
2135
|
+
ws.binaryType = 'arraybuffer';
|
|
2136
|
+
const decoder = new TextDecoder();
|
|
2137
|
+
|
|
2138
|
+
ws.onopen = () => {
|
|
2139
|
+
ws.send(JSON.stringify({
|
|
2140
|
+
type: 'create',
|
|
2141
|
+
cols: term.cols, rows: term.rows,
|
|
2142
|
+
sessionName: existingSession || preferredSessionName,
|
|
2143
|
+
existingSession: existingSession || undefined,
|
|
2144
|
+
}));
|
|
2145
|
+
};
|
|
2146
|
+
ws.onmessage = async (event) => {
|
|
2147
|
+
try {
|
|
2148
|
+
const msg = JSON.parse(typeof event.data === 'string' ? event.data : decoder.decode(event.data));
|
|
2149
|
+
if (msg.type === 'data') {
|
|
2150
|
+
term.write(typeof msg.data === 'string' ? msg.data : new Uint8Array(Object.values(msg.data)));
|
|
2151
|
+
} else if (msg.type === 'created') {
|
|
2152
|
+
onSessionReady?.(msg.sessionName);
|
|
2153
|
+
// Auto-run CLI on newly created session
|
|
2154
|
+
if (!existingSession) {
|
|
2155
|
+
const cli = cliCmdProp || 'claude';
|
|
2156
|
+
const targetDir = workDir ? `${projectPath}/${workDir}` : projectPath;
|
|
2157
|
+
const cdCmd = `mkdir -p "${targetDir}" && cd "${targetDir}"`;
|
|
2158
|
+
const isClaude = (cliType || 'claude-code') === 'claude-code';
|
|
2159
|
+
const modelFlag = isClaude && profileEnv?.CLAUDE_MODEL ? ` --model ${profileEnv.CLAUDE_MODEL}` : '';
|
|
2160
|
+
const envWithoutModel = profileEnv ? Object.fromEntries(
|
|
2161
|
+
Object.entries(profileEnv).filter(([k]) => k !== 'CLAUDE_MODEL')
|
|
2162
|
+
) : {};
|
|
2163
|
+
// Build commands as separate short lines
|
|
2164
|
+
const commands: string[] = [];
|
|
2165
|
+
const profileVarsToReset = ['ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_BASE_URL', 'ANTHROPIC_SMALL_FAST_MODEL', 'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC', 'DISABLE_TELEMETRY', 'DISABLE_ERROR_REPORTING', 'DISABLE_AUTOUPDATER', 'DISABLE_NON_ESSENTIAL_MODEL_CALLS', 'CLAUDE_MODEL'];
|
|
2166
|
+
commands.push(profileVarsToReset.map(v => `unset ${v}`).join('; '));
|
|
2167
|
+
const envWithoutForge = Object.entries(envWithoutModel).filter(([k]) => !k.startsWith('FORGE_'));
|
|
2168
|
+
if (envWithoutForge.length > 0) {
|
|
2169
|
+
commands.push(envWithoutForge.map(([k, v]) => `export ${k}="${v}"`).join('; '));
|
|
2170
|
+
}
|
|
2171
|
+
const forgeVars = Object.entries(envWithoutModel).filter(([k]) => k.startsWith('FORGE_'));
|
|
2172
|
+
if (forgeVars.length > 0) {
|
|
2173
|
+
commands.push(forgeVars.map(([k, v]) => `export ${k}="${v}"`).join('; '));
|
|
2174
|
+
}
|
|
2175
|
+
let resumeId = resumeSessionId || boundSessionId;
|
|
2176
|
+
if (isClaude && !resumeId && isPrimary) {
|
|
2177
|
+
try {
|
|
2178
|
+
const { resolveFixedSession } = await import('@/lib/session-utils');
|
|
2179
|
+
resumeId = (await resolveFixedSession(projectPath)) || undefined;
|
|
2180
|
+
} catch {}
|
|
2181
|
+
}
|
|
2182
|
+
const resumeFlag = isClaude && resumeId ? ` --resume ${resumeId}` : '';
|
|
2183
|
+
let mcpFlag = '';
|
|
2184
|
+
if (isClaude) { try { const { getMcpFlag } = await import('@/lib/session-utils'); mcpFlag = await getMcpFlag(projectPath); } catch {} }
|
|
2185
|
+
const sf = skipPermissions ? (cliType === 'codex' ? ' --full-auto' : cliType === 'aider' ? ' --yes' : ' --dangerously-skip-permissions') : '';
|
|
2186
|
+
commands.push(`${cdCmd} && ${cli}${resumeFlag}${modelFlag}${sf}${mcpFlag}`);
|
|
2187
|
+
commands.forEach((cmd, i) => {
|
|
2188
|
+
setTimeout(() => {
|
|
2189
|
+
if (!disposed && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'input', data: cmd + '\n' }));
|
|
2190
|
+
}, 300 + i * 300);
|
|
2191
|
+
});
|
|
2192
|
+
}
|
|
2193
|
+
}
|
|
2194
|
+
} catch {}
|
|
2195
|
+
};
|
|
2196
|
+
|
|
2197
|
+
term.onData(data => { if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'input', data })); });
|
|
2198
|
+
term.onResize(({ cols, rows }) => { if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'resize', cols, rows })); });
|
|
2199
|
+
|
|
2200
|
+
return () => {
|
|
2201
|
+
disposed = true;
|
|
2202
|
+
ro.disconnect();
|
|
2203
|
+
ws.close();
|
|
2204
|
+
term.dispose();
|
|
2205
|
+
};
|
|
2206
|
+
});
|
|
2207
|
+
|
|
2208
|
+
return () => { disposed = true; };
|
|
2209
|
+
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
|
2210
|
+
|
|
2211
|
+
return <div ref={containerRef} className="w-full h-full" style={{ background: '#0d1117' }} />;
|
|
2212
|
+
}
|
|
2213
|
+
|
|
2214
|
+
function FloatingTerminal({ agentLabel, agentIcon, projectPath, agentCliId, cliCmd: cliCmdProp, cliType, workDir, preferredSessionName, existingSession, resumeMode, resumeSessionId, profileEnv, isPrimary, skipPermissions, persistentSession, boundSessionId, initialPos, onSessionReady, onClose }: {
|
|
2215
|
+
agentLabel: string;
|
|
2216
|
+
agentIcon: string;
|
|
2217
|
+
projectPath: string;
|
|
2218
|
+
agentCliId: string;
|
|
2219
|
+
cliCmd?: string; // resolved CLI binary (claude/codex/aider)
|
|
2220
|
+
cliType?: string; // claude-code/codex/aider/generic
|
|
2221
|
+
workDir?: string;
|
|
2222
|
+
preferredSessionName?: string;
|
|
2223
|
+
existingSession?: string;
|
|
2224
|
+
resumeMode?: boolean;
|
|
2225
|
+
resumeSessionId?: string;
|
|
2226
|
+
profileEnv?: Record<string, string>;
|
|
2227
|
+
isPrimary?: boolean;
|
|
2228
|
+
skipPermissions?: boolean;
|
|
2229
|
+
persistentSession?: boolean;
|
|
2230
|
+
boundSessionId?: string;
|
|
2231
|
+
initialPos?: { x: number; y: number };
|
|
2232
|
+
onSessionReady?: (name: string) => void;
|
|
2233
|
+
onClose: (killSession: boolean) => void;
|
|
2234
|
+
}) {
|
|
2235
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
2236
|
+
const wsRef = useRef<WebSocket | null>(null);
|
|
2237
|
+
const sessionNameRef = useRef('');
|
|
2238
|
+
const [pos, setPos] = useState(initialPos || { x: 80, y: 60 });
|
|
2239
|
+
const [userDragged, setUserDragged] = useState(false);
|
|
2240
|
+
// Follow node position unless user manually dragged the terminal
|
|
2241
|
+
useEffect(() => {
|
|
2242
|
+
if (initialPos && !userDragged) setPos(initialPos);
|
|
2243
|
+
}, [initialPos?.x, initialPos?.y]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
2244
|
+
const [size, setSize] = useState({ w: 500, h: 300 });
|
|
2245
|
+
const [showCloseDialog, setShowCloseDialog] = useState(false);
|
|
2246
|
+
const dragRef = useRef<{ startX: number; startY: number; origX: number; origY: number } | null>(null);
|
|
2247
|
+
const resizeRef = useRef<{ startX: number; startY: number; origW: number; origH: number } | null>(null);
|
|
2248
|
+
|
|
2249
|
+
useEffect(() => {
|
|
2250
|
+
const el = containerRef.current;
|
|
2251
|
+
if (!el) return;
|
|
2252
|
+
let disposed = false;
|
|
2253
|
+
|
|
2254
|
+
// Dynamic import xterm to avoid SSR issues
|
|
2255
|
+
Promise.all([
|
|
2256
|
+
import('@xterm/xterm'),
|
|
2257
|
+
import('@xterm/addon-fit'),
|
|
2258
|
+
]).then(([{ Terminal }, { FitAddon }]) => {
|
|
2259
|
+
if (disposed) return;
|
|
2260
|
+
|
|
2261
|
+
const term = new Terminal({
|
|
2262
|
+
cursorBlink: true, fontSize: 10,
|
|
2263
|
+
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
|
|
2264
|
+
scrollback: 5000,
|
|
2265
|
+
theme: { background: '#0d1117', foreground: '#c9d1d9', cursor: '#58a6ff' },
|
|
2266
|
+
});
|
|
2267
|
+
const fitAddon = new FitAddon();
|
|
2268
|
+
term.loadAddon(fitAddon);
|
|
2269
|
+
term.open(el);
|
|
2270
|
+
setTimeout(() => { try { fitAddon.fit(); } catch {} }, 100);
|
|
2271
|
+
|
|
2272
|
+
// Scale font: min 10 at small size, max 13 at large size
|
|
2273
|
+
const ro = new ResizeObserver(() => {
|
|
2274
|
+
try {
|
|
2275
|
+
const w = el.clientWidth;
|
|
2276
|
+
const newSize = Math.min(13, Math.max(10, Math.floor(w / 60)));
|
|
2277
|
+
if (term.options.fontSize !== newSize) term.options.fontSize = newSize;
|
|
2278
|
+
fitAddon.fit();
|
|
2279
|
+
} catch {}
|
|
2280
|
+
});
|
|
2281
|
+
ro.observe(el);
|
|
2282
|
+
|
|
2283
|
+
// Connect WebSocket — attach to existing or create new
|
|
2284
|
+
const ws = new WebSocket(getWsUrl());
|
|
2285
|
+
wsRef.current = ws;
|
|
2286
|
+
ws.onopen = () => {
|
|
2287
|
+
if (existingSession) {
|
|
2288
|
+
ws.send(JSON.stringify({ type: 'attach', sessionName: existingSession, cols: term.cols, rows: term.rows }));
|
|
2289
|
+
} else {
|
|
2290
|
+
// Use fixed session name so it survives refresh/suspend
|
|
2291
|
+
ws.send(JSON.stringify({ type: 'create', sessionName: preferredSessionName, cols: term.cols, rows: term.rows }));
|
|
2292
|
+
}
|
|
2293
|
+
};
|
|
2294
|
+
|
|
2295
|
+
ws.onerror = () => {
|
|
2296
|
+
if (!disposed) term.write('\r\n\x1b[91m[Connection error]\x1b[0m\r\n');
|
|
2297
|
+
};
|
|
2298
|
+
ws.onclose = () => {
|
|
2299
|
+
if (!disposed) term.write('\r\n\x1b[90m[Disconnected]\x1b[0m\r\n');
|
|
2300
|
+
};
|
|
2301
|
+
|
|
2302
|
+
let launched = false;
|
|
2303
|
+
ws.onmessage = async (event) => {
|
|
2304
|
+
if (disposed) return;
|
|
2305
|
+
try {
|
|
2306
|
+
const msg = JSON.parse(event.data);
|
|
2307
|
+
if (msg.type === 'output') { try { term.write(msg.data); } catch {} }
|
|
2308
|
+
else if (msg.type === 'error') {
|
|
2309
|
+
// Session no longer exists — fall back to creating a new one
|
|
2310
|
+
if (msg.message?.includes('no longer exists') || msg.message?.includes('not found')) {
|
|
2311
|
+
term.write(`\r\n\x1b[93m[Session lost — creating new one]\x1b[0m\r\n`);
|
|
2312
|
+
ws.send(JSON.stringify({ type: 'create', cols: term.cols, rows: term.rows }));
|
|
2313
|
+
// Clear existing session so next connected triggers CLI launch
|
|
2314
|
+
(existingSession as any) = undefined;
|
|
2315
|
+
} else {
|
|
2316
|
+
term.write(`\r\n\x1b[91m[${msg.message || 'error'}]\x1b[0m\r\n`);
|
|
2317
|
+
}
|
|
2318
|
+
}
|
|
2319
|
+
else if (msg.type === 'connected') {
|
|
2320
|
+
if (msg.sessionName) {
|
|
2321
|
+
sessionNameRef.current = msg.sessionName;
|
|
2322
|
+
// Save session name (on create or if session changed after fallback)
|
|
2323
|
+
onSessionReady?.(msg.sessionName);
|
|
2324
|
+
}
|
|
2325
|
+
if (launched) return;
|
|
2326
|
+
launched = true;
|
|
2327
|
+
if (existingSession) {
|
|
2328
|
+
// Force terminal redraw for attached session
|
|
2329
|
+
setTimeout(() => {
|
|
2330
|
+
if (!disposed && ws.readyState === WebSocket.OPEN) {
|
|
2331
|
+
ws.send(JSON.stringify({ type: 'resize', cols: term.cols - 1, rows: term.rows }));
|
|
2332
|
+
setTimeout(() => {
|
|
2333
|
+
if (!disposed && ws.readyState === WebSocket.OPEN)
|
|
2334
|
+
ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
|
|
2335
|
+
}, 50);
|
|
2336
|
+
}
|
|
2337
|
+
}, 200);
|
|
2338
|
+
return;
|
|
2339
|
+
}
|
|
2340
|
+
const targetDir = workDir ? `${projectPath}/${workDir}` : projectPath;
|
|
2341
|
+
const cli = cliCmdProp || 'claude';
|
|
2342
|
+
|
|
2343
|
+
const cdCmd = `mkdir -p "${targetDir}" && cd "${targetDir}"`;
|
|
2344
|
+
const isClaude = (cliType || 'claude-code') === 'claude-code';
|
|
2345
|
+
const modelFlag = isClaude && profileEnv?.CLAUDE_MODEL ? ` --model ${profileEnv.CLAUDE_MODEL}` : '';
|
|
2346
|
+
const envWithoutModel = profileEnv ? Object.fromEntries(
|
|
2347
|
+
Object.entries(profileEnv).filter(([k]) => k !== 'CLAUDE_MODEL')
|
|
2348
|
+
) : {};
|
|
2349
|
+
// Build commands as separate short lines to avoid terminal truncation
|
|
2350
|
+
const commands: string[] = [];
|
|
2351
|
+
|
|
2352
|
+
// 1. Unset old profile vars
|
|
2353
|
+
const profileVarsToReset = ['ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_BASE_URL', 'ANTHROPIC_SMALL_FAST_MODEL', 'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC', 'DISABLE_TELEMETRY', 'DISABLE_ERROR_REPORTING', 'DISABLE_AUTOUPDATER', 'DISABLE_NON_ESSENTIAL_MODEL_CALLS', 'CLAUDE_MODEL'];
|
|
2354
|
+
commands.push(profileVarsToReset.map(v => `unset ${v}`).join('; '));
|
|
2355
|
+
|
|
2356
|
+
// 2. Export new profile vars (if any)
|
|
2357
|
+
const envWithoutForge = Object.entries(envWithoutModel).filter(([k]) => !k.startsWith('FORGE_'));
|
|
2358
|
+
if (envWithoutForge.length > 0) {
|
|
2359
|
+
commands.push(envWithoutForge.map(([k, v]) => `export ${k}="${v}"`).join('; '));
|
|
2360
|
+
}
|
|
2361
|
+
|
|
2362
|
+
// 3. Export FORGE vars
|
|
2363
|
+
const forgeVars = Object.entries(envWithoutModel).filter(([k]) => k.startsWith('FORGE_'));
|
|
2364
|
+
if (forgeVars.length > 0) {
|
|
2365
|
+
commands.push(forgeVars.map(([k, v]) => `export ${k}="${v}"`).join('; '));
|
|
2366
|
+
}
|
|
2367
|
+
|
|
2368
|
+
// 4. CLI command
|
|
2369
|
+
let resumeId = resumeSessionId || boundSessionId;
|
|
2370
|
+
if (isClaude && !resumeId && isPrimary) {
|
|
2371
|
+
try {
|
|
2372
|
+
const { resolveFixedSession } = await import('@/lib/session-utils');
|
|
2373
|
+
resumeId = (await resolveFixedSession(projectPath)) || undefined;
|
|
2374
|
+
} catch {}
|
|
2375
|
+
}
|
|
2376
|
+
const resumeFlag = isClaude && resumeId ? ` --resume ${resumeId}` : '';
|
|
2377
|
+
let mcpFlag = '';
|
|
2378
|
+
if (isClaude) { try { const { getMcpFlag } = await import('@/lib/session-utils'); mcpFlag = await getMcpFlag(projectPath); } catch {} }
|
|
2379
|
+
const sf = skipPermissions ? (cliType === 'codex' ? ' --full-auto' : cliType === 'aider' ? ' --yes' : ' --dangerously-skip-permissions') : '';
|
|
2380
|
+
commands.push(`${cdCmd} && ${cli}${resumeFlag}${modelFlag}${sf}${mcpFlag}`);
|
|
2381
|
+
|
|
2382
|
+
// Send each command with delay between them
|
|
2383
|
+
commands.forEach((cmd, i) => {
|
|
2384
|
+
setTimeout(() => {
|
|
2385
|
+
if (!disposed && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'input', data: cmd + '\n' }));
|
|
2386
|
+
}, 300 + i * 300);
|
|
2387
|
+
});
|
|
2388
|
+
}
|
|
2389
|
+
} catch {}
|
|
2390
|
+
};
|
|
2391
|
+
|
|
2392
|
+
term.onData(data => {
|
|
2393
|
+
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'input', data }));
|
|
2394
|
+
});
|
|
2395
|
+
term.onResize(({ cols, rows }) => {
|
|
2396
|
+
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'resize', cols, rows }));
|
|
2397
|
+
});
|
|
2398
|
+
|
|
2399
|
+
return () => {
|
|
2400
|
+
disposed = true;
|
|
2401
|
+
ro.disconnect();
|
|
2402
|
+
ws.close();
|
|
2403
|
+
term.dispose();
|
|
2404
|
+
};
|
|
2405
|
+
});
|
|
2406
|
+
|
|
2407
|
+
return () => { disposed = true; };
|
|
2408
|
+
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
|
2409
|
+
|
|
2410
|
+
return (
|
|
2411
|
+
<div
|
|
2412
|
+
className="fixed z-50 bg-[#0d1117] border border-[#30363d] rounded-lg shadow-2xl flex flex-col overflow-hidden"
|
|
2413
|
+
style={{ left: pos.x, top: pos.y, width: size.w, height: size.h }}
|
|
2414
|
+
>
|
|
2415
|
+
{/* Draggable header */}
|
|
2416
|
+
<div
|
|
2417
|
+
className="flex items-center gap-2 px-3 py-1.5 bg-[#161b22] border-b border-[#30363d] cursor-move shrink-0 select-none"
|
|
2418
|
+
onMouseDown={(e) => {
|
|
2419
|
+
e.preventDefault();
|
|
2420
|
+
dragRef.current = { startX: e.clientX, startY: e.clientY, origX: pos.x, origY: pos.y };
|
|
2421
|
+
setUserDragged(true);
|
|
2422
|
+
const onMove = (ev: MouseEvent) => {
|
|
2423
|
+
if (!dragRef.current) return;
|
|
2424
|
+
setPos({ x: Math.max(0, dragRef.current.origX + ev.clientX - dragRef.current.startX), y: Math.max(0, dragRef.current.origY + ev.clientY - dragRef.current.startY) });
|
|
2425
|
+
};
|
|
2426
|
+
const onUp = () => { dragRef.current = null; window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); };
|
|
2427
|
+
window.addEventListener('mousemove', onMove);
|
|
2428
|
+
window.addEventListener('mouseup', onUp);
|
|
2429
|
+
}}
|
|
2430
|
+
>
|
|
2431
|
+
<span className="text-sm">{agentIcon}</span>
|
|
2432
|
+
<span className="text-[11px] font-semibold text-white">{agentLabel}</span>
|
|
2433
|
+
<span className="text-[8px] text-gray-500">⌨️ manual terminal</span>
|
|
2434
|
+
<button onClick={() => setShowCloseDialog(true)} className="ml-auto text-gray-500 hover:text-white text-sm">✕</button>
|
|
2435
|
+
</div>
|
|
2436
|
+
|
|
2437
|
+
{/* Terminal */}
|
|
2438
|
+
<div ref={containerRef} className="flex-1 min-h-0" style={{ background: '#0d1117' }} />
|
|
2439
|
+
|
|
2440
|
+
{/* Resize handle */}
|
|
2441
|
+
<div
|
|
2442
|
+
className="absolute bottom-0 right-0 w-4 h-4 cursor-se-resize"
|
|
2443
|
+
onMouseDown={(e) => {
|
|
2444
|
+
e.preventDefault();
|
|
2445
|
+
e.stopPropagation();
|
|
2446
|
+
resizeRef.current = { startX: e.clientX, startY: e.clientY, origW: size.w, origH: size.h };
|
|
2447
|
+
const onMove = (ev: MouseEvent) => {
|
|
2448
|
+
if (!resizeRef.current) return;
|
|
2449
|
+
setSize({ w: Math.max(400, resizeRef.current.origW + ev.clientX - resizeRef.current.startX), h: Math.max(250, resizeRef.current.origH + ev.clientY - resizeRef.current.startY) });
|
|
2450
|
+
};
|
|
2451
|
+
const onUp = () => { resizeRef.current = null; window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); };
|
|
2452
|
+
window.addEventListener('mousemove', onMove);
|
|
2453
|
+
window.addEventListener('mouseup', onUp);
|
|
2454
|
+
}}
|
|
2455
|
+
>
|
|
2456
|
+
<svg viewBox="0 0 16 16" className="w-3 h-3 absolute bottom-0.5 right-0.5 text-gray-600">
|
|
2457
|
+
<path d="M14 14L8 14L14 8Z" fill="currentColor" />
|
|
2458
|
+
</svg>
|
|
2459
|
+
</div>
|
|
2460
|
+
|
|
2461
|
+
{/* Close confirmation dialog */}
|
|
2462
|
+
{showCloseDialog && (
|
|
2463
|
+
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/50" onClick={() => setShowCloseDialog(false)}>
|
|
2464
|
+
<div className="bg-[#161b22] border border-[#30363d] rounded-lg p-4 shadow-xl max-w-sm" onClick={e => e.stopPropagation()}>
|
|
2465
|
+
<h3 className="text-sm font-semibold text-white mb-2">Close Terminal — {agentLabel}</h3>
|
|
2466
|
+
<p className="text-xs text-gray-400 mb-3">
|
|
2467
|
+
This agent has an active terminal session.
|
|
2468
|
+
</p>
|
|
2469
|
+
<div className="flex gap-2">
|
|
2470
|
+
<button onClick={() => { setShowCloseDialog(false); onClose(false); }}
|
|
2471
|
+
className="flex-1 px-3 py-1.5 text-[11px] rounded bg-[#2a2a4a] text-gray-300 hover:bg-[#3a3a5a] hover:text-white">
|
|
2472
|
+
Suspend
|
|
2473
|
+
<span className="block text-[9px] text-gray-500 mt-0.5">Hide panel, session keeps running</span>
|
|
2474
|
+
</button>
|
|
2475
|
+
<button onClick={() => {
|
|
2476
|
+
setShowCloseDialog(false);
|
|
2477
|
+
if (wsRef.current?.readyState === WebSocket.OPEN && sessionNameRef.current) {
|
|
2478
|
+
wsRef.current.send(JSON.stringify({ type: 'kill', sessionName: sessionNameRef.current }));
|
|
2479
|
+
}
|
|
2480
|
+
onClose(true);
|
|
2481
|
+
}}
|
|
2482
|
+
className={`flex-1 px-3 py-1.5 text-[11px] rounded ${persistentSession ? 'bg-yellow-500/20 text-yellow-400 hover:bg-yellow-500/30' : 'bg-red-500/20 text-red-400 hover:bg-red-500/30'}`}>
|
|
2483
|
+
{persistentSession ? 'Restart Session' : 'Kill Session'}
|
|
2484
|
+
<span className={`block text-[9px] mt-0.5 ${persistentSession ? 'text-yellow-400/60' : 'text-red-400/60'}`}>
|
|
2485
|
+
{persistentSession ? 'Kill and restart with fresh env' : 'End session permanently'}
|
|
2486
|
+
</span>
|
|
2487
|
+
</button>
|
|
2488
|
+
</div>
|
|
2489
|
+
<button onClick={() => setShowCloseDialog(false)}
|
|
2490
|
+
className="w-full mt-2 px-3 py-1 text-[10px] text-gray-500 hover:text-gray-300">
|
|
2491
|
+
Cancel
|
|
2492
|
+
</button>
|
|
2493
|
+
</div>
|
|
2494
|
+
</div>
|
|
2495
|
+
)}
|
|
2496
|
+
</div>
|
|
2497
|
+
);
|
|
2498
|
+
}
|
|
2499
|
+
|
|
2500
|
+
// ─── ReactFlow Input Node ────────────────────────────────
|
|
2501
|
+
|
|
2502
|
+
interface InputNodeData {
|
|
2503
|
+
config: AgentConfig;
|
|
2504
|
+
state: AgentState;
|
|
2505
|
+
onSubmit: (content: string) => void;
|
|
2506
|
+
onEdit: () => void;
|
|
2507
|
+
onRemove: () => void;
|
|
2508
|
+
[key: string]: unknown;
|
|
2509
|
+
}
|
|
2510
|
+
|
|
2511
|
+
function InputFlowNode({ data }: NodeProps<Node<InputNodeData>>) {
|
|
2512
|
+
const { config, state, onSubmit, onEdit, onRemove } = data;
|
|
2513
|
+
const isDone = state?.taskStatus === 'done';
|
|
2514
|
+
const [text, setText] = useState('');
|
|
2515
|
+
const entries = config.entries || [];
|
|
2516
|
+
|
|
2517
|
+
return (
|
|
2518
|
+
<div className="w-60 flex flex-col rounded-lg select-none"
|
|
2519
|
+
style={{ border: `1px solid ${isDone ? '#58a6ff60' : '#30363d50'}`, background: '#0d1117',
|
|
2520
|
+
boxShadow: isDone ? '0 0 10px #58a6ff15' : 'none' }}>
|
|
2521
|
+
<Handle type="source" position={Position.Right} style={{ background: '#58a6ff', width: 8, height: 8, border: 'none' }} />
|
|
2522
|
+
|
|
2523
|
+
{/* Header */}
|
|
2524
|
+
<div className="flex items-center gap-2 px-3 py-2" style={{ borderBottom: '1px solid #21262d' }}>
|
|
2525
|
+
<span className="text-sm">{config.icon || '📝'}</span>
|
|
2526
|
+
<span className="text-xs font-semibold text-white flex-1">{config.label || 'Input'}</span>
|
|
2527
|
+
{entries.length > 0 && <span className="text-[8px] text-gray-600">{entries.length}</span>}
|
|
2528
|
+
<div className="w-2 h-2 rounded-full" style={{ background: isDone ? '#58a6ff' : '#484f58', boxShadow: isDone ? '0 0 6px #58a6ff' : 'none' }} />
|
|
2529
|
+
</div>
|
|
2530
|
+
|
|
2531
|
+
{/* History entries (scrollable, compact) */}
|
|
2532
|
+
{entries.length > 0 && (
|
|
2533
|
+
<div className="max-h-24 overflow-auto px-3 py-1.5 space-y-1" style={{ borderBottom: '1px solid #21262d' }}
|
|
2534
|
+
onPointerDown={e => e.stopPropagation()}>
|
|
2535
|
+
{entries.map((e, i) => (
|
|
2536
|
+
<div key={i} className={`text-[9px] leading-relaxed ${i === entries.length - 1 ? 'text-gray-300' : 'text-gray-600'}`}>
|
|
2537
|
+
<span className="text-[7px] text-gray-700 mr-1">#{i + 1}</span>
|
|
2538
|
+
{e.content.length > 80 ? e.content.slice(0, 80) + '…' : e.content}
|
|
2539
|
+
</div>
|
|
2540
|
+
))}
|
|
2541
|
+
</div>
|
|
2542
|
+
)}
|
|
2543
|
+
|
|
2544
|
+
{/* New input */}
|
|
2545
|
+
<div className="px-3 py-2">
|
|
2546
|
+
<textarea value={text} onChange={e => setText(e.target.value)} rows={2}
|
|
2547
|
+
placeholder={entries.length > 0 ? 'Add new requirement or change...' : 'Describe requirements...'}
|
|
2548
|
+
className="w-full text-[10px] bg-[#0d1117] border border-[#21262d] rounded px-2 py-1.5 text-gray-300 placeholder-gray-600 focus:outline-none focus:border-[#58a6ff]/50 resize-none"
|
|
2549
|
+
onPointerDown={e => e.stopPropagation()} />
|
|
2550
|
+
</div>
|
|
2551
|
+
|
|
2552
|
+
{/* Actions */}
|
|
2553
|
+
<div className="flex items-center gap-1 px-2 py-1.5" style={{ borderTop: '1px solid #21262d' }}>
|
|
2554
|
+
<button onPointerDown={e => e.stopPropagation()} onClick={e => {
|
|
2555
|
+
e.stopPropagation();
|
|
2556
|
+
if (!text.trim()) return;
|
|
2557
|
+
onSubmit(text.trim());
|
|
2558
|
+
setText('');
|
|
2559
|
+
}}
|
|
2560
|
+
className="text-[9px] px-2 py-0.5 rounded bg-[#238636]/20 text-[#3fb950] hover:bg-[#238636]/30 disabled:opacity-30"
|
|
2561
|
+
disabled={!text.trim()}>
|
|
2562
|
+
{entries.length > 0 ? '+ Add' : '✓ Submit'}
|
|
2563
|
+
</button>
|
|
2564
|
+
<div className="flex-1" />
|
|
2565
|
+
<button onPointerDown={e => e.stopPropagation()} onClick={e => { e.stopPropagation(); onRemove(); }}
|
|
2566
|
+
className="text-[9px] text-gray-700 hover:text-red-400 px-1">✕</button>
|
|
2567
|
+
</div>
|
|
2568
|
+
</div>
|
|
2569
|
+
);
|
|
2570
|
+
}
|
|
2571
|
+
|
|
2572
|
+
// ─── ReactFlow Agent Node ────────────────────────────────
|
|
2573
|
+
|
|
2574
|
+
interface AgentNodeData {
|
|
2575
|
+
config: AgentConfig;
|
|
2576
|
+
state: AgentState;
|
|
2577
|
+
colorIdx: number;
|
|
2578
|
+
previewLines: string[];
|
|
2579
|
+
projectPath: string;
|
|
2580
|
+
workspaceId: string | null;
|
|
2581
|
+
onRun: () => void;
|
|
2582
|
+
onPause: () => void;
|
|
2583
|
+
onStop: () => void;
|
|
2584
|
+
onRetry: () => void;
|
|
2585
|
+
onEdit: () => void;
|
|
2586
|
+
onRemove: () => void;
|
|
2587
|
+
onMessage: () => void;
|
|
2588
|
+
onApprove: () => void;
|
|
2589
|
+
onShowLog: () => void;
|
|
2590
|
+
onShowMemory: () => void;
|
|
2591
|
+
onShowInbox: () => void;
|
|
2592
|
+
onOpenTerminal: () => void;
|
|
2593
|
+
onSwitchSession: () => void;
|
|
2594
|
+
onSaveAsTemplate: () => void;
|
|
2595
|
+
mascotTheme: MascotTheme;
|
|
2596
|
+
onMarkIdle?: () => void;
|
|
2597
|
+
onMarkDone?: (notify: boolean) => void;
|
|
2598
|
+
onMarkFailed?: (notify: boolean) => void;
|
|
2599
|
+
inboxPending?: number;
|
|
2600
|
+
inboxFailed?: number;
|
|
2601
|
+
[key: string]: unknown;
|
|
2602
|
+
}
|
|
2603
|
+
|
|
2604
|
+
// PortalTerminal/NodeTerminal removed — xterm cannot render inside React Flow nodes
|
|
2605
|
+
// and createPortal causes event routing issues. Using FloatingTerminal instead.
|
|
2606
|
+
|
|
2607
|
+
// ─── Worker Mascot — SVG stick figure with pose-based animations ──────────────
|
|
2608
|
+
const MASCOT_STYLES = `
|
|
2609
|
+
@keyframes mascot-sleep {
|
|
2610
|
+
0%, 100% { transform: translateY(0) rotate(-3deg); opacity: 0.6; }
|
|
2611
|
+
50% { transform: translateY(-2px) rotate(3deg); opacity: 0.9; }
|
|
2612
|
+
}
|
|
2613
|
+
@keyframes mascot-work {
|
|
2614
|
+
0%, 100% { transform: translateY(0) rotate(0deg); }
|
|
2615
|
+
25% { transform: translateY(-2px) rotate(-6deg); }
|
|
2616
|
+
50% { transform: translateY(0) rotate(0deg); }
|
|
2617
|
+
75% { transform: translateY(-2px) rotate(6deg); }
|
|
2618
|
+
}
|
|
2619
|
+
@keyframes mascot-celebrate {
|
|
2620
|
+
0% { transform: translateY(0) scale(1); }
|
|
2621
|
+
12% { transform: translateY(-6px) scale(1.15) rotate(-10deg); }
|
|
2622
|
+
25% { transform: translateY(-3px) scale(1.1) rotate(0deg); }
|
|
2623
|
+
37% { transform: translateY(-6px) scale(1.15) rotate(10deg); }
|
|
2624
|
+
50% { transform: translateY(0) scale(1) rotate(0deg); }
|
|
2625
|
+
100% { transform: translateY(0) scale(1) rotate(0deg); }
|
|
2626
|
+
}
|
|
2627
|
+
@keyframes mascot-fall {
|
|
2628
|
+
0% { transform: translateY(0) rotate(0deg); }
|
|
2629
|
+
30% { transform: translateY(2px) rotate(-15deg); }
|
|
2630
|
+
60% { transform: translateY(4px) rotate(-90deg); }
|
|
2631
|
+
100% { transform: translateY(4px) rotate(-90deg); opacity: 0.6; }
|
|
2632
|
+
}
|
|
2633
|
+
@keyframes mascot-idle {
|
|
2634
|
+
0%, 100% { transform: translateY(0); }
|
|
2635
|
+
50% { transform: translateY(-1px); }
|
|
2636
|
+
}
|
|
2637
|
+
@keyframes mascot-blink { 0%, 95%, 100% { opacity: 1; } 97% { opacity: 0.3; } }
|
|
2638
|
+
@keyframes stick-arm-hammer {
|
|
2639
|
+
0%, 100% { transform: rotate(-40deg); }
|
|
2640
|
+
50% { transform: rotate(20deg); }
|
|
2641
|
+
}
|
|
2642
|
+
@keyframes stick-arm-wave {
|
|
2643
|
+
0%, 100% { transform: rotate(-120deg); }
|
|
2644
|
+
50% { transform: rotate(-150deg); }
|
|
2645
|
+
}
|
|
2646
|
+
@keyframes stick-leg-walk-l {
|
|
2647
|
+
0%, 100% { transform: rotate(-10deg); }
|
|
2648
|
+
50% { transform: rotate(10deg); }
|
|
2649
|
+
}
|
|
2650
|
+
@keyframes stick-leg-walk-r {
|
|
2651
|
+
0%, 100% { transform: rotate(10deg); }
|
|
2652
|
+
50% { transform: rotate(-10deg); }
|
|
2653
|
+
}
|
|
2654
|
+
@keyframes stick-zzz {
|
|
2655
|
+
0% { opacity: 0; transform: translate(0, 0) scale(0.5); }
|
|
2656
|
+
50% { opacity: 1; transform: translate(4px, -6px) scale(1); }
|
|
2657
|
+
100% { opacity: 0; transform: translate(8px, -12px) scale(1.2); }
|
|
2658
|
+
}
|
|
2659
|
+
@keyframes stick-spark {
|
|
2660
|
+
0%, 100% { opacity: 0; }
|
|
2661
|
+
50% { opacity: 1; }
|
|
2662
|
+
}
|
|
2663
|
+
@keyframes stick-spark-burst {
|
|
2664
|
+
0% { opacity: 0; transform: scale(0.5); }
|
|
2665
|
+
30% { opacity: 1; transform: scale(1.2); }
|
|
2666
|
+
70% { opacity: 1; transform: scale(1); }
|
|
2667
|
+
100% { opacity: 0; transform: scale(0.8); }
|
|
2668
|
+
}
|
|
2669
|
+
`;
|
|
2670
|
+
type MascotPose = 'idle' | 'work' | 'done' | 'fail' | 'sleep' | 'wake';
|
|
2671
|
+
export type MascotTheme = 'off' | 'stick' | 'cat' | 'dog' | 'pig' | 'emoji';
|
|
2672
|
+
|
|
2673
|
+
function StickCat({ pose, color, accentColor }: { pose: MascotPose; color: string; accentColor: string }) {
|
|
2674
|
+
const strokeProps = { stroke: color, strokeWidth: 1.5, strokeLinecap: 'round' as const, fill: 'none' };
|
|
2675
|
+
const body = (tailAnim: string) => (
|
|
2676
|
+
<>
|
|
2677
|
+
{/* head */}
|
|
2678
|
+
<circle cx="10" cy="18" r="5" stroke={color} strokeWidth="1.5" fill="none" />
|
|
2679
|
+
{/* ears */}
|
|
2680
|
+
<path d="M 6 15 L 7 11 L 10 14 Z" fill={color} />
|
|
2681
|
+
<path d="M 14 15 L 13 11 L 10 14 Z" fill={color} />
|
|
2682
|
+
{/* eyes */}
|
|
2683
|
+
<circle cx="8" cy="18" r="0.8" fill={accentColor} />
|
|
2684
|
+
<circle cx="12" cy="18" r="0.8" fill={accentColor} />
|
|
2685
|
+
{/* nose */}
|
|
2686
|
+
<path d="M 9.5 19.5 L 10 20 L 10.5 19.5" stroke={accentColor} strokeWidth="0.8" fill="none" strokeLinecap="round" />
|
|
2687
|
+
{/* whiskers */}
|
|
2688
|
+
<line x1="5" y1="19" x2="2" y2="18" stroke={color} strokeWidth="0.6" />
|
|
2689
|
+
<line x1="5" y1="20" x2="2" y2="20" stroke={color} strokeWidth="0.6" />
|
|
2690
|
+
<line x1="15" y1="19" x2="18" y2="18" stroke={color} strokeWidth="0.6" />
|
|
2691
|
+
<line x1="15" y1="20" x2="18" y2="20" stroke={color} strokeWidth="0.6" />
|
|
2692
|
+
{/* body — oval */}
|
|
2693
|
+
<ellipse cx="18" cy="26" rx="8" ry="5" stroke={color} strokeWidth="1.5" fill="none" />
|
|
2694
|
+
{/* tail */}
|
|
2695
|
+
<g style={{ transformOrigin: '26px 26px', animation: tailAnim }}>
|
|
2696
|
+
<path d="M 26 26 Q 30 22 28 18" {...strokeProps} />
|
|
2697
|
+
</g>
|
|
2698
|
+
{/* legs */}
|
|
2699
|
+
<line x1="13" y1="30" x2="13" y2="36" {...strokeProps} />
|
|
2700
|
+
<line x1="23" y1="30" x2="23" y2="36" {...strokeProps} />
|
|
2701
|
+
<line x1="16" y1="31" x2="16" y2="36" {...strokeProps} />
|
|
2702
|
+
<line x1="20" y1="31" x2="20" y2="36" {...strokeProps} />
|
|
2703
|
+
</>
|
|
2704
|
+
);
|
|
2705
|
+
|
|
2706
|
+
if (pose === 'sleep') {
|
|
2707
|
+
return (
|
|
2708
|
+
<svg width="32" height="40" viewBox="0 0 32 40">
|
|
2709
|
+
{/* curled up cat — circle with tail */}
|
|
2710
|
+
<circle cx="16" cy="30" r="8" stroke={color} strokeWidth="1.5" fill="none" />
|
|
2711
|
+
<circle cx="10" cy="28" r="3" stroke={color} strokeWidth="1.5" fill="none" />
|
|
2712
|
+
<line x1="9" y1="27" x2="9" y2="29" stroke={color} strokeWidth="0.8" />
|
|
2713
|
+
<line x1="11" y1="27" x2="11" y2="29" stroke={color} strokeWidth="0.8" />
|
|
2714
|
+
<path d="M 23 32 Q 28 32 26 26" {...strokeProps} />
|
|
2715
|
+
{/* zzz */}
|
|
2716
|
+
<text x="20" y="20" fill={accentColor} fontSize="6" fontWeight="bold" style={{ animation: 'stick-zzz 2s ease-out infinite' }}>z</text>
|
|
2717
|
+
<text x="24" y="14" fill={accentColor} fontSize="4" fontWeight="bold" style={{ animation: 'stick-zzz 2s ease-out infinite 0.7s' }}>z</text>
|
|
2718
|
+
</svg>
|
|
2719
|
+
);
|
|
2720
|
+
}
|
|
2721
|
+
|
|
2722
|
+
if (pose === 'fail') {
|
|
2723
|
+
return (
|
|
2724
|
+
<svg width="32" height="40" viewBox="0 0 32 40">
|
|
2725
|
+
{/* belly up */}
|
|
2726
|
+
<ellipse cx="18" cy="26" rx="8" ry="5" stroke={color} strokeWidth="1.5" fill="none" />
|
|
2727
|
+
<circle cx="10" cy="24" r="4" stroke={color} strokeWidth="1.5" fill="none" />
|
|
2728
|
+
<line x1="8" y1="23" x2="9" y2="24" stroke={accentColor} strokeWidth="0.8" />
|
|
2729
|
+
<line x1="9" y1="23" x2="8" y2="24" stroke={accentColor} strokeWidth="0.8" />
|
|
2730
|
+
<line x1="11" y1="23" x2="12" y2="24" stroke={accentColor} strokeWidth="0.8" />
|
|
2731
|
+
<line x1="12" y1="23" x2="11" y2="24" stroke={accentColor} strokeWidth="0.8" />
|
|
2732
|
+
{/* legs up */}
|
|
2733
|
+
<line x1="14" y1="22" x2="14" y2="16" {...strokeProps} />
|
|
2734
|
+
<line x1="18" y1="22" x2="18" y2="15" {...strokeProps} />
|
|
2735
|
+
<line x1="22" y1="22" x2="22" y2="16" {...strokeProps} />
|
|
2736
|
+
</svg>
|
|
2737
|
+
);
|
|
2738
|
+
}
|
|
2739
|
+
|
|
2740
|
+
if (pose === 'done') {
|
|
2741
|
+
return (
|
|
2742
|
+
<svg width="32" height="40" viewBox="0 0 32 40">
|
|
2743
|
+
{/* jumping — body elevated */}
|
|
2744
|
+
<g style={{ transform: 'translateY(-2px)' }}>
|
|
2745
|
+
{body('none')}
|
|
2746
|
+
</g>
|
|
2747
|
+
<text x="2" y="8" fill="#ffd700" fontSize="6" style={{ animation: 'stick-spark-burst 1.2s ease-out forwards' }}>✦</text>
|
|
2748
|
+
<text x="26" y="10" fill="#ffd700" fontSize="8" style={{ animation: 'stick-spark-burst 1.2s ease-out forwards 0.3s' }}>✦</text>
|
|
2749
|
+
</svg>
|
|
2750
|
+
);
|
|
2751
|
+
}
|
|
2752
|
+
|
|
2753
|
+
if (pose === 'work') {
|
|
2754
|
+
return (
|
|
2755
|
+
<svg width="32" height="40" viewBox="0 0 32 40">
|
|
2756
|
+
{body('stick-arm-hammer 0.4s ease-in-out infinite')}
|
|
2757
|
+
</svg>
|
|
2758
|
+
);
|
|
2759
|
+
}
|
|
2760
|
+
|
|
2761
|
+
if (pose === 'wake') {
|
|
2762
|
+
return (
|
|
2763
|
+
<svg width="32" height="40" viewBox="0 0 32 40">
|
|
2764
|
+
{/* stretching — elongated body */}
|
|
2765
|
+
<circle cx="8" cy="22" r="4" stroke={color} strokeWidth="1.5" fill="none" />
|
|
2766
|
+
<path d="M 4 19 L 5 16 L 8 18 Z" fill={color} />
|
|
2767
|
+
<path d="M 12 19 L 11 16 L 8 18 Z" fill={color} />
|
|
2768
|
+
<circle cx="6.5" cy="22" r="0.6" fill={accentColor} />
|
|
2769
|
+
<circle cx="9.5" cy="22" r="0.6" fill={accentColor} />
|
|
2770
|
+
<ellipse cx="20" cy="28" rx="10" ry="4" stroke={color} strokeWidth="1.5" fill="none" />
|
|
2771
|
+
<line x1="14" y1="32" x2="14" y2="38" {...strokeProps} />
|
|
2772
|
+
<line x1="26" y1="32" x2="26" y2="38" {...strokeProps} />
|
|
2773
|
+
<path d="M 30 28 Q 32 24 30 20" {...strokeProps} />
|
|
2774
|
+
</svg>
|
|
2775
|
+
);
|
|
2776
|
+
}
|
|
2777
|
+
|
|
2778
|
+
// idle — tail swaying
|
|
2779
|
+
return (
|
|
2780
|
+
<svg width="32" height="40" viewBox="0 0 32 40">
|
|
2781
|
+
{body('stick-arm-wave 2s ease-in-out infinite')}
|
|
2782
|
+
</svg>
|
|
2783
|
+
);
|
|
2784
|
+
}
|
|
2785
|
+
|
|
2786
|
+
function StickDog({ pose, color, accentColor }: { pose: MascotPose; color: string; accentColor: string }) {
|
|
2787
|
+
// Side-profile dog — elongated snout forward, triangular perked ear, visible tail
|
|
2788
|
+
// Designed to read clearly at small sizes with distinct dog silhouette
|
|
2789
|
+
|
|
2790
|
+
if (pose === 'sleep') {
|
|
2791
|
+
return (
|
|
2792
|
+
<svg width="40" height="40" viewBox="0 0 40 40">
|
|
2793
|
+
{/* body lying down */}
|
|
2794
|
+
<ellipse cx="22" cy="32" rx="12" ry="4" stroke={color} strokeWidth="1.8" fill={color} fillOpacity="0.15" />
|
|
2795
|
+
{/* head resting on paws — side profile */}
|
|
2796
|
+
<path d="M 10 32 Q 6 30 4 32 Q 2 33 3 35 L 10 35 Z" stroke={color} strokeWidth="1.8" fill={color} fillOpacity="0.2" strokeLinejoin="round" />
|
|
2797
|
+
{/* long snout */}
|
|
2798
|
+
<path d="M 3 34 L 1 35 L 3 36" stroke={color} strokeWidth="1.5" fill="none" strokeLinecap="round" strokeLinejoin="round" />
|
|
2799
|
+
{/* floppy ear */}
|
|
2800
|
+
<path d="M 8 30 Q 6 33 9 34" stroke={color} strokeWidth="2" fill={color} fillOpacity="0.35" strokeLinecap="round" />
|
|
2801
|
+
{/* closed eye */}
|
|
2802
|
+
<path d="M 6 33 Q 7 32.5 8 33" stroke={color} strokeWidth="0.8" fill="none" strokeLinecap="round" />
|
|
2803
|
+
{/* nose */}
|
|
2804
|
+
<ellipse cx="1.5" cy="35" rx="0.9" ry="0.7" fill={color} />
|
|
2805
|
+
{/* curled tail */}
|
|
2806
|
+
<path d="M 33 32 Q 38 30 36 26 Q 35 25 36 24" stroke={color} strokeWidth="2" fill="none" strokeLinecap="round" />
|
|
2807
|
+
<text x="18" y="18" fill={accentColor} fontSize="7" fontWeight="bold" style={{ animation: 'stick-zzz 2s ease-out infinite' }}>z</text>
|
|
2808
|
+
<text x="24" y="12" fill={accentColor} fontSize="5" fontWeight="bold" style={{ animation: 'stick-zzz 2s ease-out infinite 0.7s' }}>z</text>
|
|
2809
|
+
</svg>
|
|
2810
|
+
);
|
|
2811
|
+
}
|
|
2812
|
+
|
|
2813
|
+
if (pose === 'fail') {
|
|
2814
|
+
return (
|
|
2815
|
+
<svg width="40" height="40" viewBox="0 0 40 40">
|
|
2816
|
+
{/* belly up */}
|
|
2817
|
+
<ellipse cx="22" cy="32" rx="12" ry="4" stroke={color} strokeWidth="1.8" fill={color} fillOpacity="0.15" />
|
|
2818
|
+
{/* head upside down */}
|
|
2819
|
+
<path d="M 10 32 Q 6 34 4 32 Q 2 31 3 29 L 10 29 Z" stroke={color} strokeWidth="1.8" fill={color} fillOpacity="0.2" strokeLinejoin="round" />
|
|
2820
|
+
<path d="M 3 30 L 1 29 L 3 28" stroke={color} strokeWidth="1.5" fill="none" strokeLinecap="round" strokeLinejoin="round" />
|
|
2821
|
+
{/* X eyes */}
|
|
2822
|
+
<line x1="5" y1="30" x2="6.5" y2="31.5" stroke={accentColor} strokeWidth="1.2" strokeLinecap="round" />
|
|
2823
|
+
<line x1="6.5" y1="30" x2="5" y2="31.5" stroke={accentColor} strokeWidth="1.2" strokeLinecap="round" />
|
|
2824
|
+
{/* tongue hanging out sideways */}
|
|
2825
|
+
<path d="M 2 30 Q 1 27 2 25" stroke="#ff6b9d" strokeWidth="1.5" fill="none" strokeLinecap="round" />
|
|
2826
|
+
{/* all 4 legs sticking up */}
|
|
2827
|
+
<line x1="14" y1="28" x2="13" y2="20" stroke={color} strokeWidth="1.8" strokeLinecap="round" />
|
|
2828
|
+
<line x1="18" y1="28" x2="18" y2="18" stroke={color} strokeWidth="1.8" strokeLinecap="round" />
|
|
2829
|
+
<line x1="26" y1="28" x2="26" y2="18" stroke={color} strokeWidth="1.8" strokeLinecap="round" />
|
|
2830
|
+
<line x1="30" y1="28" x2="31" y2="20" stroke={color} strokeWidth="1.8" strokeLinecap="round" />
|
|
2831
|
+
{/* limp tail */}
|
|
2832
|
+
<path d="M 33 32 L 37 34" stroke={color} strokeWidth="2" fill="none" strokeLinecap="round" />
|
|
2833
|
+
</svg>
|
|
2834
|
+
);
|
|
2835
|
+
}
|
|
2836
|
+
|
|
2837
|
+
// Standing side-profile dog
|
|
2838
|
+
const standingDog = (tailAnim: string, bounce: string = '') => (
|
|
2839
|
+
<g style={bounce ? { transform: bounce } : {}}>
|
|
2840
|
+
{/* body — side profile, clearly elongated horizontal */}
|
|
2841
|
+
<path d="M 11 22 L 28 22 Q 32 22 32 26 L 32 30 Q 32 32 30 32 L 9 32 Q 7 32 7 30 L 7 27 Q 7 23 11 22 Z"
|
|
2842
|
+
stroke={color} strokeWidth="1.8" fill={color} fillOpacity="0.15" strokeLinejoin="round" />
|
|
2843
|
+
{/* head — side profile with long snout pointing LEFT */}
|
|
2844
|
+
<path d="M 11 22 Q 8 22 6 20 Q 4 20 2 22 Q 0 23 1 25 Q 1 27 3 27 L 8 27 Q 10 27 11 25 Z"
|
|
2845
|
+
stroke={color} strokeWidth="1.8" fill={color} fillOpacity="0.2" strokeLinejoin="round" />
|
|
2846
|
+
{/* triangular perked ear (pointing up-back) */}
|
|
2847
|
+
<path d="M 8 22 L 10 15 L 12 20 Z" stroke={color} strokeWidth="1.5" fill={color} fillOpacity="0.4" strokeLinejoin="round" />
|
|
2848
|
+
{/* big black nose at tip */}
|
|
2849
|
+
<ellipse cx="1" cy="24" rx="1.3" ry="1" fill={color} />
|
|
2850
|
+
{/* eye */}
|
|
2851
|
+
<circle cx="7" cy="23" r="1" fill={accentColor} />
|
|
2852
|
+
<circle cx="6.7" cy="22.7" r="0.3" fill="#fff" />
|
|
2853
|
+
{/* mouth line */}
|
|
2854
|
+
<path d="M 1 25.5 Q 3 27 6 26" stroke={color} strokeWidth="0.8" fill="none" strokeLinecap="round" />
|
|
2855
|
+
{/* tongue hanging out */}
|
|
2856
|
+
<path d="M 2.5 26.5 Q 3 28.5 2 29" stroke="#ff6b9d" strokeWidth="1.2" fill="#ff6b9d" strokeLinecap="round" />
|
|
2857
|
+
{/* curled tail (pointing up-right) — wags */}
|
|
2858
|
+
<g style={{ transformOrigin: '32px 26px', animation: tailAnim }}>
|
|
2859
|
+
<path d="M 32 26 Q 37 24 36 19 Q 36 17 38 17" stroke={color} strokeWidth="2.2" fill="none" strokeLinecap="round" />
|
|
2860
|
+
</g>
|
|
2861
|
+
{/* 4 legs — visible in side profile (2 front + 2 back, front pair visible) */}
|
|
2862
|
+
<line x1="10" y1="32" x2="10" y2="38" stroke={color} strokeWidth="1.8" strokeLinecap="round" />
|
|
2863
|
+
<line x1="14" y1="32" x2="14" y2="38" stroke={color} strokeWidth="1.8" strokeLinecap="round" />
|
|
2864
|
+
<line x1="26" y1="32" x2="26" y2="38" stroke={color} strokeWidth="1.8" strokeLinecap="round" />
|
|
2865
|
+
<line x1="30" y1="32" x2="30" y2="38" stroke={color} strokeWidth="1.8" strokeLinecap="round" />
|
|
2866
|
+
{/* paws */}
|
|
2867
|
+
<rect x="8.5" y="37.5" width="3" height="1.5" fill={color} rx="0.5" />
|
|
2868
|
+
<rect x="12.5" y="37.5" width="3" height="1.5" fill={color} rx="0.5" />
|
|
2869
|
+
<rect x="24.5" y="37.5" width="3" height="1.5" fill={color} rx="0.5" />
|
|
2870
|
+
<rect x="28.5" y="37.5" width="3" height="1.5" fill={color} rx="0.5" />
|
|
2871
|
+
{/* collar */}
|
|
2872
|
+
<line x1="9" y1="27" x2="12" y2="27" stroke={accentColor} strokeWidth="1.5" strokeLinecap="round" />
|
|
2873
|
+
<circle cx="10.5" cy="28" r="0.9" fill={accentColor} />
|
|
2874
|
+
</g>
|
|
2875
|
+
);
|
|
2876
|
+
|
|
2877
|
+
if (pose === 'done') {
|
|
2878
|
+
return (
|
|
2879
|
+
<svg width="40" height="40" viewBox="0 0 40 40">
|
|
2880
|
+
{standingDog('stick-arm-wave 0.3s ease-in-out infinite', 'translateY(-3px)')}
|
|
2881
|
+
<text x="2" y="10" fill="#ffd700" fontSize="7" style={{ animation: 'stick-spark-burst 1.2s ease-out forwards' }}>✦</text>
|
|
2882
|
+
<text x="34" y="12" fill="#ffd700" fontSize="9" style={{ animation: 'stick-spark-burst 1.2s ease-out forwards 0.3s' }}>✦</text>
|
|
2883
|
+
<text x="18" y="6" fill="#ffd700" fontSize="6" style={{ animation: 'stick-spark-burst 1.2s ease-out forwards 0.5s' }}>✦</text>
|
|
2884
|
+
</svg>
|
|
2885
|
+
);
|
|
2886
|
+
}
|
|
2887
|
+
|
|
2888
|
+
if (pose === 'work') {
|
|
2889
|
+
return (
|
|
2890
|
+
<svg width="40" height="40" viewBox="0 0 40 40">
|
|
2891
|
+
{standingDog('stick-arm-wave 0.25s ease-in-out infinite')}
|
|
2892
|
+
{/* bone in mouth */}
|
|
2893
|
+
<g transform="translate(-6, 0)">
|
|
2894
|
+
<circle cx="0" cy="25" r="1.5" fill={accentColor} stroke={color} strokeWidth="0.6" />
|
|
2895
|
+
<rect x="0" y="24.2" width="5" height="1.6" fill={accentColor} stroke={color} strokeWidth="0.6" rx="0.4" />
|
|
2896
|
+
<circle cx="5" cy="25" r="1.5" fill={accentColor} stroke={color} strokeWidth="0.6" />
|
|
2897
|
+
</g>
|
|
2898
|
+
</svg>
|
|
2899
|
+
);
|
|
2900
|
+
}
|
|
2901
|
+
|
|
2902
|
+
if (pose === 'wake') {
|
|
2903
|
+
return (
|
|
2904
|
+
<svg width="40" height="40" viewBox="0 0 40 40">
|
|
2905
|
+
{standingDog('stick-arm-wave 1.5s ease-in-out infinite')}
|
|
2906
|
+
{/* yawn — open mouth replaces tongue */}
|
|
2907
|
+
<ellipse cx="2" cy="26" rx="1.3" ry="1.8" fill={color} opacity="0.5" />
|
|
2908
|
+
</svg>
|
|
2909
|
+
);
|
|
2910
|
+
}
|
|
2911
|
+
|
|
2912
|
+
// idle — standing, happy tail wag
|
|
2913
|
+
return (
|
|
2914
|
+
<svg width="40" height="40" viewBox="0 0 40 40">
|
|
2915
|
+
{standingDog('stick-arm-wave 0.6s ease-in-out infinite')}
|
|
2916
|
+
</svg>
|
|
2917
|
+
);
|
|
2918
|
+
}
|
|
2919
|
+
|
|
2920
|
+
function StickPig({ pose, color, accentColor }: { pose: MascotPose; color: string; accentColor: string }) {
|
|
2921
|
+
const pink = '#ff9ecb';
|
|
2922
|
+
const pinkFill = '#ff9ecb';
|
|
2923
|
+
|
|
2924
|
+
if (pose === 'sleep') {
|
|
2925
|
+
return (
|
|
2926
|
+
<svg width="32" height="40" viewBox="0 0 32 40">
|
|
2927
|
+
<ellipse cx="16" cy="30" rx="12" ry="7" stroke={pink} strokeWidth="1.8" fill={pinkFill} fillOpacity="0.25" />
|
|
2928
|
+
<circle cx="8" cy="28" r="4" stroke={pink} strokeWidth="1.8" fill={pinkFill} fillOpacity="0.3" />
|
|
2929
|
+
{/* pig snout disc */}
|
|
2930
|
+
<ellipse cx="4.5" cy="29" rx="2.5" ry="1.8" stroke={pink} strokeWidth="1.5" fill={pinkFill} fillOpacity="0.4" />
|
|
2931
|
+
<circle cx="4" cy="29" r="0.5" fill={color} />
|
|
2932
|
+
<circle cx="5" cy="29" r="0.5" fill={color} />
|
|
2933
|
+
{/* pointy triangular ears */}
|
|
2934
|
+
<path d="M 5 24 L 6 22 L 8 25 Z" fill={pinkFill} stroke={pink} strokeWidth="1" />
|
|
2935
|
+
<path d="M 9 24 L 10 22 L 11 25 Z" fill={pinkFill} stroke={pink} strokeWidth="1" />
|
|
2936
|
+
{/* closed eyes */}
|
|
2937
|
+
<path d="M 7 27 Q 7.5 26.5 8 27" stroke={color} strokeWidth="0.8" fill="none" strokeLinecap="round" />
|
|
2938
|
+
<path d="M 9 27 Q 9.5 26.5 10 27" stroke={color} strokeWidth="0.8" fill="none" strokeLinecap="round" />
|
|
2939
|
+
{/* curly tail */}
|
|
2940
|
+
<path d="M 28 28 Q 30 26 28 24 Q 26 24 28 22" stroke={pink} strokeWidth="2" fill="none" strokeLinecap="round" />
|
|
2941
|
+
<text x="16" y="16" fill={accentColor} fontSize="7" fontWeight="bold" style={{ animation: 'stick-zzz 2s ease-out infinite' }}>z</text>
|
|
2942
|
+
<text x="21" y="10" fill={accentColor} fontSize="5" fontWeight="bold" style={{ animation: 'stick-zzz 2s ease-out infinite 0.7s' }}>z</text>
|
|
2943
|
+
</svg>
|
|
2944
|
+
);
|
|
2945
|
+
}
|
|
2946
|
+
|
|
2947
|
+
if (pose === 'fail') {
|
|
2948
|
+
return (
|
|
2949
|
+
<svg width="32" height="40" viewBox="0 0 32 40">
|
|
2950
|
+
<ellipse cx="18" cy="29" rx="11" ry="5" stroke={pink} strokeWidth="1.8" fill={pinkFill} fillOpacity="0.25" />
|
|
2951
|
+
<circle cx="8" cy="27" r="4.5" stroke={pink} strokeWidth="1.8" fill={pinkFill} fillOpacity="0.3" />
|
|
2952
|
+
<ellipse cx="7" cy="23" rx="2.5" ry="2" stroke={pink} strokeWidth="1.5" fill={pinkFill} fillOpacity="0.5" />
|
|
2953
|
+
<circle cx="6.3" cy="23" r="0.5" fill={color} />
|
|
2954
|
+
<circle cx="7.7" cy="23" r="0.5" fill={color} />
|
|
2955
|
+
<path d="M 4 28 Q 0 30 2 34" fill={pinkFill} stroke={pink} strokeWidth="1.8" strokeLinecap="round" />
|
|
2956
|
+
<path d="M 12 28 Q 16 30 14 34" fill={pinkFill} stroke={pink} strokeWidth="1.8" strokeLinecap="round" />
|
|
2957
|
+
<line x1="6" y1="26" x2="7.5" y2="27.5" stroke={color} strokeWidth="1.2" strokeLinecap="round" />
|
|
2958
|
+
<line x1="7.5" y1="26" x2="6" y2="27.5" stroke={color} strokeWidth="1.2" strokeLinecap="round" />
|
|
2959
|
+
<line x1="9" y1="26" x2="10.5" y2="27.5" stroke={color} strokeWidth="1.2" strokeLinecap="round" />
|
|
2960
|
+
<line x1="10.5" y1="26" x2="9" y2="27.5" stroke={color} strokeWidth="1.2" strokeLinecap="round" />
|
|
2961
|
+
<line x1="14" y1="25" x2="13" y2="17" stroke={pink} strokeWidth="1.8" strokeLinecap="round" />
|
|
2962
|
+
<line x1="18" y1="25" x2="18" y2="16" stroke={pink} strokeWidth="1.8" strokeLinecap="round" />
|
|
2963
|
+
<line x1="22" y1="25" x2="22" y2="17" stroke={pink} strokeWidth="1.8" strokeLinecap="round" />
|
|
2964
|
+
<line x1="26" y1="25" x2="27" y2="18" stroke={pink} strokeWidth="1.8" strokeLinecap="round" />
|
|
2965
|
+
</svg>
|
|
2966
|
+
);
|
|
2967
|
+
}
|
|
2968
|
+
|
|
2969
|
+
const pigBody = (tailAnim: string, bounce: string = '') => (
|
|
2970
|
+
<g style={bounce ? { transform: bounce } : {}}>
|
|
2971
|
+
{/* round pig body */}
|
|
2972
|
+
<ellipse cx="18" cy="27" rx="11" ry="6.5" stroke={pink} strokeWidth="1.8" fill={pinkFill} fillOpacity="0.25" />
|
|
2973
|
+
{/* round head */}
|
|
2974
|
+
<circle cx="9" cy="18" r="6" stroke={pink} strokeWidth="1.8" fill={pinkFill} fillOpacity="0.3" />
|
|
2975
|
+
{/* pig snout — flat disc with nostrils */}
|
|
2976
|
+
<ellipse cx="5" cy="20" rx="3" ry="2.2" stroke={pink} strokeWidth="1.5" fill={pinkFill} fillOpacity="0.5" />
|
|
2977
|
+
<circle cx="4" cy="20" r="0.6" fill={color} />
|
|
2978
|
+
<circle cx="6" cy="20" r="0.6" fill={color} />
|
|
2979
|
+
{/* triangular pointed ears */}
|
|
2980
|
+
<path d="M 6 13 L 7 10 L 9 14 Z" fill={pinkFill} stroke={pink} strokeWidth="1.2" strokeLinejoin="round" />
|
|
2981
|
+
<path d="M 11 13 L 12 10 L 14 14 Z" fill={pinkFill} stroke={pink} strokeWidth="1.2" strokeLinejoin="round" />
|
|
2982
|
+
{/* eyes */}
|
|
2983
|
+
<circle cx="8" cy="17" r="1" fill={color} />
|
|
2984
|
+
<circle cx="12" cy="17" r="1" fill={color} />
|
|
2985
|
+
<circle cx="7.7" cy="16.7" r="0.3" fill="#fff" />
|
|
2986
|
+
<circle cx="11.7" cy="16.7" r="0.3" fill="#fff" />
|
|
2987
|
+
{/* smile */}
|
|
2988
|
+
<path d="M 7 22 Q 9 23 11 22" stroke={color} strokeWidth="0.8" fill="none" strokeLinecap="round" />
|
|
2989
|
+
{/* curly tail — wagging */}
|
|
2990
|
+
<g style={{ transformOrigin: '29px 25px', animation: tailAnim }}>
|
|
2991
|
+
<path d="M 29 25 Q 32 23 30 21 Q 28 21 30 19 Q 31 18 32 19" stroke={pink} strokeWidth="2" fill="none" strokeLinecap="round" />
|
|
2992
|
+
</g>
|
|
2993
|
+
{/* trotter legs */}
|
|
2994
|
+
<line x1="12" y1="32" x2="12" y2="38" stroke={pink} strokeWidth="2" strokeLinecap="round" />
|
|
2995
|
+
<line x1="16" y1="33" x2="16" y2="38" stroke={pink} strokeWidth="2" strokeLinecap="round" />
|
|
2996
|
+
<line x1="20" y1="33" x2="20" y2="38" stroke={pink} strokeWidth="2" strokeLinecap="round" />
|
|
2997
|
+
<line x1="24" y1="32" x2="24" y2="38" stroke={pink} strokeWidth="2" strokeLinecap="round" />
|
|
2998
|
+
{/* hooves */}
|
|
2999
|
+
<rect x="10.5" y="37.5" width="3" height="1.8" fill={color} rx="0.3" />
|
|
3000
|
+
<rect x="14.5" y="37.5" width="3" height="1.8" fill={color} rx="0.3" />
|
|
3001
|
+
<rect x="18.5" y="37.5" width="3" height="1.8" fill={color} rx="0.3" />
|
|
3002
|
+
<rect x="22.5" y="37.5" width="3" height="1.8" fill={color} rx="0.3" />
|
|
3003
|
+
</g>
|
|
3004
|
+
);
|
|
3005
|
+
|
|
3006
|
+
if (pose === 'done') {
|
|
3007
|
+
return (
|
|
3008
|
+
<svg width="32" height="40" viewBox="0 0 32 40">
|
|
3009
|
+
{pigBody('stick-arm-wave 0.4s ease-in-out infinite', 'translateY(-2px)')}
|
|
3010
|
+
<text x="2" y="8" fill="#ffd700" fontSize="6" style={{ animation: 'stick-spark-burst 1.2s ease-out forwards' }}>✦</text>
|
|
3011
|
+
<text x="26" y="10" fill="#ffd700" fontSize="8" style={{ animation: 'stick-spark-burst 1.2s ease-out forwards 0.3s' }}>✦</text>
|
|
3012
|
+
</svg>
|
|
3013
|
+
);
|
|
3014
|
+
}
|
|
3015
|
+
|
|
3016
|
+
if (pose === 'work') {
|
|
3017
|
+
return (
|
|
3018
|
+
<svg width="32" height="40" viewBox="0 0 32 40">
|
|
3019
|
+
{pigBody('stick-arm-wave 0.3s ease-in-out infinite')}
|
|
3020
|
+
</svg>
|
|
3021
|
+
);
|
|
3022
|
+
}
|
|
3023
|
+
|
|
3024
|
+
if (pose === 'wake') {
|
|
3025
|
+
return (
|
|
3026
|
+
<svg width="32" height="40" viewBox="0 0 32 40">
|
|
3027
|
+
{pigBody('stick-arm-wave 1.5s ease-in-out infinite')}
|
|
3028
|
+
<ellipse cx="9" cy="20" rx="1.3" ry="1.5" fill={color} opacity="0.4" />
|
|
3029
|
+
</svg>
|
|
3030
|
+
);
|
|
3031
|
+
}
|
|
3032
|
+
|
|
3033
|
+
return (
|
|
3034
|
+
<svg width="32" height="40" viewBox="0 0 32 40">
|
|
3035
|
+
{pigBody('stick-arm-wave 0.7s ease-in-out infinite')}
|
|
3036
|
+
</svg>
|
|
3037
|
+
);
|
|
3038
|
+
}
|
|
3039
|
+
|
|
3040
|
+
function EmojiMascot({ pose, seed }: { pose: MascotPose; seed: number }) {
|
|
3041
|
+
const characters = ['🦊', '🐱', '🐼', '🦉', '🐸', '🦝', '🐙', '🦖', '🐰', '🦄', '🐺', '🧙♂️', '🧝♀️', '🦸♂️', '🥷', '🐲'];
|
|
3042
|
+
const character = characters[seed % characters.length];
|
|
3043
|
+
let display = character;
|
|
3044
|
+
if (pose === 'sleep') display = ['😴', '💤', '🌙', '💤'][Math.floor(Date.now() / 1200) % 4];
|
|
3045
|
+
else if (pose === 'work') { const tools = ['🔨', '⚙️', '🛠️', '⚡']; const tick = Math.floor(Date.now() / 400); display = tick % 3 === 0 ? character : tools[tick % tools.length]; }
|
|
3046
|
+
else if (pose === 'done') display = ['🎉', '🎊', '🥳', '🌟'][Math.floor(Date.now() / 600) % 4];
|
|
3047
|
+
else if (pose === 'fail') display = ['😵', '💫', '🤕', '😿'][seed % 4];
|
|
3048
|
+
else if (pose === 'wake') display = ['🥱', '☕', '🌅'][Math.floor(Date.now() / 1000) % 3];
|
|
3049
|
+
return <div style={{ fontSize: '24px', lineHeight: 1 }}>{display}</div>;
|
|
3050
|
+
}
|
|
3051
|
+
|
|
3052
|
+
function StickFigure({ pose, color, accentColor }: { pose: MascotPose; color: string; accentColor: string }) {
|
|
3053
|
+
// viewBox 32×40: head at (16,8), body (16,12)→(16,26), arms from (16,14), legs from (16,26)
|
|
3054
|
+
const strokeProps = { stroke: color, strokeWidth: 2, strokeLinecap: 'round' as const, fill: 'none' };
|
|
3055
|
+
|
|
3056
|
+
if (pose === 'sleep') {
|
|
3057
|
+
// Lying down, sleeping
|
|
3058
|
+
return (
|
|
3059
|
+
<svg width="32" height="40" viewBox="0 0 32 40">
|
|
3060
|
+
{/* body horizontal */}
|
|
3061
|
+
<circle cx="8" cy="30" r="3" {...strokeProps} fill={color} />
|
|
3062
|
+
<line x1="11" y1="30" x2="26" y2="30" {...strokeProps} />
|
|
3063
|
+
<line x1="14" y1="30" x2="18" y2="26" {...strokeProps} />
|
|
3064
|
+
<line x1="20" y1="30" x2="24" y2="34" {...strokeProps} />
|
|
3065
|
+
{/* zzz */}
|
|
3066
|
+
<text x="18" y="14" fill={accentColor} fontSize="8" fontWeight="bold" style={{ animation: 'stick-zzz 2s ease-out infinite' }}>z</text>
|
|
3067
|
+
<text x="22" y="10" fill={accentColor} fontSize="6" fontWeight="bold" style={{ animation: 'stick-zzz 2s ease-out infinite 0.7s' }}>z</text>
|
|
3068
|
+
</svg>
|
|
3069
|
+
);
|
|
3070
|
+
}
|
|
3071
|
+
|
|
3072
|
+
if (pose === 'wake') {
|
|
3073
|
+
// Stretching — arms up
|
|
3074
|
+
return (
|
|
3075
|
+
<svg width="32" height="40" viewBox="0 0 32 40">
|
|
3076
|
+
<circle cx="16" cy="8" r="3" {...strokeProps} fill={color} />
|
|
3077
|
+
<line x1="16" y1="11" x2="16" y2="26" {...strokeProps} />
|
|
3078
|
+
<line x1="16" y1="14" x2="10" y2="6" {...strokeProps} />
|
|
3079
|
+
<line x1="16" y1="14" x2="22" y2="6" {...strokeProps} />
|
|
3080
|
+
<line x1="16" y1="26" x2="12" y2="34" {...strokeProps} />
|
|
3081
|
+
<line x1="16" y1="26" x2="20" y2="34" {...strokeProps} />
|
|
3082
|
+
{/* ☼ */}
|
|
3083
|
+
<circle cx="26" cy="6" r="2" fill={accentColor} opacity="0.8" />
|
|
3084
|
+
</svg>
|
|
3085
|
+
);
|
|
3086
|
+
}
|
|
3087
|
+
|
|
3088
|
+
if (pose === 'done') {
|
|
3089
|
+
// Victory pose — both arms up, legs apart
|
|
3090
|
+
return (
|
|
3091
|
+
<svg width="32" height="40" viewBox="0 0 32 40">
|
|
3092
|
+
<circle cx="16" cy="8" r="3" {...strokeProps} fill={color} />
|
|
3093
|
+
{/* smile */}
|
|
3094
|
+
<path d="M 14 8 Q 16 10 18 8" stroke={accentColor} strokeWidth="1" fill="none" strokeLinecap="round" />
|
|
3095
|
+
<line x1="16" y1="11" x2="16" y2="26" {...strokeProps} />
|
|
3096
|
+
<line x1="16" y1="14" x2="8" y2="4" {...strokeProps} />
|
|
3097
|
+
<line x1="16" y1="14" x2="24" y2="4" {...strokeProps} />
|
|
3098
|
+
<line x1="16" y1="26" x2="10" y2="36" {...strokeProps} />
|
|
3099
|
+
<line x1="16" y1="26" x2="22" y2="36" {...strokeProps} />
|
|
3100
|
+
{/* sparkles */}
|
|
3101
|
+
<text x="4" y="4" fill="#ffd700" fontSize="6" style={{ animation: 'stick-spark-burst 1.2s ease-out forwards' }}>✦</text>
|
|
3102
|
+
<text x="26" y="6" fill="#ffd700" fontSize="8" style={{ animation: 'stick-spark-burst 1.2s ease-out forwards 0.3s' }}>✦</text>
|
|
3103
|
+
<text x="2" y="20" fill="#ffd700" fontSize="5" style={{ animation: 'stick-spark-burst 1.2s ease-out forwards 0.5s' }}>✦</text>
|
|
3104
|
+
</svg>
|
|
3105
|
+
);
|
|
3106
|
+
}
|
|
3107
|
+
|
|
3108
|
+
if (pose === 'fail') {
|
|
3109
|
+
// Fallen down — lying on back, X eyes (handled via external rotate)
|
|
3110
|
+
return (
|
|
3111
|
+
<svg width="32" height="40" viewBox="0 0 32 40">
|
|
3112
|
+
<circle cx="16" cy="8" r="3" {...strokeProps} fill={color} />
|
|
3113
|
+
{/* X eyes */}
|
|
3114
|
+
<line x1="14" y1="6" x2="15" y2="7" stroke={accentColor} strokeWidth="1" strokeLinecap="round" />
|
|
3115
|
+
<line x1="15" y1="6" x2="14" y2="7" stroke={accentColor} strokeWidth="1" strokeLinecap="round" />
|
|
3116
|
+
<line x1="17" y1="6" x2="18" y2="7" stroke={accentColor} strokeWidth="1" strokeLinecap="round" />
|
|
3117
|
+
<line x1="18" y1="6" x2="17" y2="7" stroke={accentColor} strokeWidth="1" strokeLinecap="round" />
|
|
3118
|
+
<line x1="16" y1="11" x2="16" y2="26" {...strokeProps} />
|
|
3119
|
+
<line x1="16" y1="14" x2="8" y2="18" {...strokeProps} />
|
|
3120
|
+
<line x1="16" y1="14" x2="24" y2="18" {...strokeProps} />
|
|
3121
|
+
<line x1="16" y1="26" x2="10" y2="34" {...strokeProps} />
|
|
3122
|
+
<line x1="16" y1="26" x2="22" y2="34" {...strokeProps} />
|
|
3123
|
+
</svg>
|
|
3124
|
+
);
|
|
3125
|
+
}
|
|
3126
|
+
|
|
3127
|
+
if (pose === 'work') {
|
|
3128
|
+
// Hammering — left arm stable, right arm swinging with hammer
|
|
3129
|
+
return (
|
|
3130
|
+
<svg width="32" height="40" viewBox="0 0 32 40">
|
|
3131
|
+
<circle cx="16" cy="8" r="3" {...strokeProps} fill={color} />
|
|
3132
|
+
<line x1="16" y1="11" x2="16" y2="26" {...strokeProps} />
|
|
3133
|
+
{/* left arm holding nail */}
|
|
3134
|
+
<line x1="16" y1="14" x2="10" y2="20" {...strokeProps} />
|
|
3135
|
+
{/* right arm swinging hammer */}
|
|
3136
|
+
<g style={{ transformOrigin: '16px 14px', animation: 'stick-arm-hammer 0.5s ease-in-out infinite' }}>
|
|
3137
|
+
<line x1="16" y1="14" x2="24" y2="14" {...strokeProps} />
|
|
3138
|
+
{/* hammer */}
|
|
3139
|
+
<rect x="24" y="11" width="5" height="6" fill={accentColor} stroke={color} strokeWidth="1" rx="1" />
|
|
3140
|
+
</g>
|
|
3141
|
+
{/* legs walking */}
|
|
3142
|
+
<g style={{ transformOrigin: '16px 26px', animation: 'stick-leg-walk-l 0.5s ease-in-out infinite' }}>
|
|
3143
|
+
<line x1="16" y1="26" x2="12" y2="36" {...strokeProps} />
|
|
3144
|
+
</g>
|
|
3145
|
+
<g style={{ transformOrigin: '16px 26px', animation: 'stick-leg-walk-r 0.5s ease-in-out infinite' }}>
|
|
3146
|
+
<line x1="16" y1="26" x2="20" y2="36" {...strokeProps} />
|
|
3147
|
+
</g>
|
|
3148
|
+
{/* sparks from hammer */}
|
|
3149
|
+
<text x="26" y="22" fill="#ff9500" fontSize="6" style={{ animation: 'stick-spark 0.5s ease-in-out infinite' }}>✦</text>
|
|
3150
|
+
</svg>
|
|
3151
|
+
);
|
|
3152
|
+
}
|
|
3153
|
+
|
|
3154
|
+
// idle — standing, waving
|
|
3155
|
+
return (
|
|
3156
|
+
<svg width="32" height="40" viewBox="0 0 32 40">
|
|
3157
|
+
<circle cx="16" cy="8" r="3" {...strokeProps} fill={color} />
|
|
3158
|
+
{/* eyes dots */}
|
|
3159
|
+
<circle cx="15" cy="7" r="0.6" fill={accentColor} />
|
|
3160
|
+
<circle cx="17" cy="7" r="0.6" fill={accentColor} />
|
|
3161
|
+
<line x1="16" y1="11" x2="16" y2="26" {...strokeProps} />
|
|
3162
|
+
{/* left arm down */}
|
|
3163
|
+
<line x1="16" y1="14" x2="12" y2="22" {...strokeProps} />
|
|
3164
|
+
{/* right arm waving */}
|
|
3165
|
+
<g style={{ transformOrigin: '16px 14px', animation: 'stick-arm-wave 2s ease-in-out infinite' }}>
|
|
3166
|
+
<line x1="16" y1="14" x2="22" y2="14" {...strokeProps} />
|
|
3167
|
+
</g>
|
|
3168
|
+
<line x1="16" y1="26" x2="12" y2="36" {...strokeProps} />
|
|
3169
|
+
<line x1="16" y1="26" x2="20" y2="36" {...strokeProps} />
|
|
3170
|
+
</svg>
|
|
3171
|
+
);
|
|
3172
|
+
}
|
|
3173
|
+
|
|
3174
|
+
function WorkerMascot({ taskStatus, smithStatus, seed, accentColor, theme }: { taskStatus: string; smithStatus: string; seed: number; accentColor: string; theme: MascotTheme }) {
|
|
3175
|
+
if (theme === 'off') return null;
|
|
3176
|
+
|
|
3177
|
+
let pose: MascotPose = 'idle';
|
|
3178
|
+
let animation = 'mascot-idle 3s ease-in-out infinite';
|
|
3179
|
+
let title = 'Ready for work';
|
|
3180
|
+
const color = '#e6edf3';
|
|
3181
|
+
|
|
3182
|
+
if (smithStatus === 'down') {
|
|
3183
|
+
pose = 'sleep';
|
|
3184
|
+
animation = 'mascot-sleep 2.5s ease-in-out infinite';
|
|
3185
|
+
title = 'Smith is down — sleeping';
|
|
3186
|
+
} else if (taskStatus === 'running') {
|
|
3187
|
+
pose = 'work';
|
|
3188
|
+
animation = 'mascot-work 0.6s ease-in-out infinite';
|
|
3189
|
+
title = 'Hard at work!';
|
|
3190
|
+
} else if (taskStatus === 'done') {
|
|
3191
|
+
pose = 'done';
|
|
3192
|
+
// Celebrate 2 times (~2.4s total), then hold the pose quietly
|
|
3193
|
+
animation = 'mascot-celebrate 2.4s ease-in-out forwards';
|
|
3194
|
+
title = 'Task done!';
|
|
3195
|
+
} else if (taskStatus === 'failed') {
|
|
3196
|
+
pose = 'fail';
|
|
3197
|
+
animation = 'mascot-fall 0.8s ease-out forwards';
|
|
3198
|
+
title = 'Task failed';
|
|
3199
|
+
} else if (smithStatus === 'starting') {
|
|
3200
|
+
pose = 'wake';
|
|
3201
|
+
animation = 'mascot-sleep 1.8s ease-in-out infinite';
|
|
3202
|
+
title = 'Waking up...';
|
|
3203
|
+
} else {
|
|
3204
|
+
animation = 'mascot-idle 3s ease-in-out infinite';
|
|
3205
|
+
title = 'Ready for work';
|
|
3206
|
+
}
|
|
3207
|
+
|
|
3208
|
+
let figure: React.ReactNode;
|
|
3209
|
+
if (theme === 'stick') figure = <StickFigure pose={pose} color={color} accentColor={accentColor} />;
|
|
3210
|
+
else if (theme === 'cat') figure = <StickCat pose={pose} color={color} accentColor={accentColor} />;
|
|
3211
|
+
else if (theme === 'dog') figure = <StickDog pose={pose} color={color} accentColor={accentColor} />;
|
|
3212
|
+
else if (theme === 'pig') figure = <StickPig pose={pose} color={color} accentColor={accentColor} />;
|
|
3213
|
+
else figure = <EmojiMascot pose={pose} seed={seed} />;
|
|
3214
|
+
|
|
3215
|
+
return (
|
|
3216
|
+
<div
|
|
3217
|
+
className="absolute pointer-events-none select-none"
|
|
3218
|
+
style={{
|
|
3219
|
+
top: '-36px',
|
|
3220
|
+
right: '-8px',
|
|
3221
|
+
animation,
|
|
3222
|
+
filter: 'drop-shadow(0 2px 3px rgba(0,0,0,0.6))',
|
|
3223
|
+
zIndex: 10,
|
|
3224
|
+
transformOrigin: 'bottom center',
|
|
3225
|
+
}}
|
|
3226
|
+
title={title}
|
|
3227
|
+
>
|
|
3228
|
+
{figure}
|
|
3229
|
+
</div>
|
|
3230
|
+
);
|
|
3231
|
+
}
|
|
3232
|
+
|
|
3233
|
+
function AgentFlowNode({ data }: NodeProps<Node<AgentNodeData>>) {
|
|
3234
|
+
const { config, state, colorIdx, previewLines, projectPath, workspaceId, onRun, onPause, onStop, onRetry, onEdit, onRemove, onMessage, onApprove, onShowLog, onShowMemory, onShowInbox, onOpenTerminal, onSwitchSession, onSaveAsTemplate, mascotTheme, inboxPending = 0, inboxFailed = 0 } = data;
|
|
3235
|
+
const c = COLORS[colorIdx % COLORS.length];
|
|
3236
|
+
const smithStatus = state?.smithStatus || 'down';
|
|
3237
|
+
const taskStatus = state?.taskStatus || 'idle';
|
|
3238
|
+
const hasTmux = !!state?.tmuxSession;
|
|
3239
|
+
const smithInfo = SMITH_STATUS[smithStatus] || SMITH_STATUS.down;
|
|
3240
|
+
const taskInfo = TASK_STATUS[taskStatus] || TASK_STATUS.idle;
|
|
3241
|
+
const currentStep = state?.currentStep;
|
|
3242
|
+
const step = currentStep !== undefined ? config.steps[currentStep] : undefined;
|
|
3243
|
+
const isApprovalPending = taskStatus === 'idle' && smithStatus === 'active';
|
|
3244
|
+
|
|
3245
|
+
// Stable seed for mascot character from agent id
|
|
3246
|
+
const mascotSeed = config.id.split('').reduce((a, c) => a + c.charCodeAt(0), 0);
|
|
3247
|
+
|
|
3248
|
+
return (
|
|
3249
|
+
<div className="w-52 flex flex-col rounded-lg select-none relative"
|
|
3250
|
+
style={{ border: `1px solid ${c.border}${taskStatus === 'running' ? '90' : '40'}`, background: c.bg,
|
|
3251
|
+
boxShadow: taskInfo.glow ? `0 0 12px ${taskInfo.color}25` : smithInfo.glow ? `0 0 8px ${smithInfo.color}15` : 'none' }}>
|
|
3252
|
+
<style>{MASCOT_STYLES}</style>
|
|
3253
|
+
<WorkerMascot taskStatus={taskStatus} smithStatus={smithStatus} seed={mascotSeed} accentColor={c.accent} theme={mascotTheme} />
|
|
3254
|
+
<Handle type="target" position={Position.Left} style={{ background: c.accent, width: 8, height: 8, border: 'none' }} />
|
|
3255
|
+
<Handle type="source" position={Position.Right} style={{ background: c.accent, width: 8, height: 8, border: 'none' }} />
|
|
3256
|
+
|
|
3257
|
+
{/* Primary badge */}
|
|
3258
|
+
{config.primary && <div className="bg-[#f0883e]/20 text-[#f0883e] text-[7px] font-bold text-center py-0.5 rounded-t-lg">PRIMARY</div>}
|
|
3259
|
+
|
|
3260
|
+
{/* Header */}
|
|
3261
|
+
<div className="flex items-center gap-2 px-3 py-2">
|
|
3262
|
+
<span className="text-sm">{config.icon}</span>
|
|
3263
|
+
<div className="flex-1 min-w-0">
|
|
3264
|
+
<div className="text-xs font-semibold text-white truncate">{config.label}</div>
|
|
3265
|
+
<div className="text-[8px]" style={{ color: c.accent }}>{config.backend === 'api' ? config.provider || 'api' : config.agentId || 'cli'}</div>
|
|
3266
|
+
</div>
|
|
3267
|
+
{/* Status: smith + terminal + task */}
|
|
3268
|
+
<div className="flex flex-col items-end gap-0.5">
|
|
3269
|
+
<div className="flex items-center gap-1">
|
|
3270
|
+
<div className="w-1.5 h-1.5 rounded-full" style={{ background: smithInfo.color, boxShadow: smithInfo.glow ? `0 0 4px ${smithInfo.color}` : 'none' }} />
|
|
3271
|
+
<span className="text-[7px]" style={{ color: smithInfo.color }}>{smithInfo.label}</span>
|
|
3272
|
+
</div>
|
|
3273
|
+
<div className="flex items-center gap-1">
|
|
3274
|
+
{(() => {
|
|
3275
|
+
// Execution mode is determined by config, not tmux state
|
|
3276
|
+
const isTerminalMode = config.persistentSession;
|
|
3277
|
+
const isActive = smithStatus === 'active';
|
|
3278
|
+
const color = isTerminalMode
|
|
3279
|
+
? (hasTmux ? '#3fb950' : '#f0883e') // terminal: green (up) / orange (down)
|
|
3280
|
+
: (isActive ? '#58a6ff' : '#484f58'); // headless: blue (active) / gray (down)
|
|
3281
|
+
const label = isTerminalMode
|
|
3282
|
+
? (hasTmux ? 'terminal' : 'terminal (down)')
|
|
3283
|
+
: (isActive ? 'headless' : 'headless (down)');
|
|
3284
|
+
return (<>
|
|
3285
|
+
<div className="w-1.5 h-1.5 rounded-full" style={{ background: color }} />
|
|
3286
|
+
<span className="text-[7px] font-medium" style={{ color }}>{label}</span>
|
|
3287
|
+
</>);
|
|
3288
|
+
})()}
|
|
3289
|
+
</div>
|
|
3290
|
+
<div className="flex items-center gap-1">
|
|
3291
|
+
<div className="w-1.5 h-1.5 rounded-full" style={{ background: taskInfo.color, boxShadow: taskInfo.glow ? `0 0 4px ${taskInfo.color}` : 'none' }} />
|
|
3292
|
+
<span className="text-[7px]" style={{ color: taskInfo.color }}>{taskInfo.label}</span>
|
|
3293
|
+
</div>
|
|
3294
|
+
{config.watch?.enabled && (
|
|
3295
|
+
<div className="flex items-center gap-1">
|
|
3296
|
+
<span className="text-[7px]" style={{ color: (state as any)?.lastWatchAlert ? '#f0883e' : '#6e7681' }}>
|
|
3297
|
+
{(state as any)?.lastWatchAlert ? '👁 alert' : '👁 watching'}
|
|
3298
|
+
</span>
|
|
3299
|
+
</div>
|
|
3300
|
+
)}
|
|
3301
|
+
</div>
|
|
3302
|
+
</div>
|
|
3303
|
+
|
|
3304
|
+
{/* Current step */}
|
|
3305
|
+
{step && taskStatus === 'running' && (
|
|
3306
|
+
<div className="px-3 pb-1 text-[8px] text-yellow-400/80" style={{ borderTop: `1px solid ${c.border}15` }}>
|
|
3307
|
+
Step {(currentStep || 0) + 1}/{config.steps.length}: {step.label}
|
|
3308
|
+
</div>
|
|
3309
|
+
)}
|
|
3310
|
+
|
|
3311
|
+
{/* Error */}
|
|
3312
|
+
{state?.error && (
|
|
3313
|
+
<div className="px-3 pb-1 text-[8px] text-red-400 truncate" style={{ borderTop: `1px solid ${c.border}15` }}>
|
|
3314
|
+
{state.error}
|
|
3315
|
+
</div>
|
|
3316
|
+
)}
|
|
3317
|
+
|
|
3318
|
+
{/* Preview lines */}
|
|
3319
|
+
{previewLines.length > 0 && (
|
|
3320
|
+
<div className="px-3 pb-2 space-y-0.5 cursor-pointer" style={{ borderTop: `1px solid ${c.border}15` }}
|
|
3321
|
+
onPointerDown={e => e.stopPropagation()} onClick={e => { e.stopPropagation(); onShowLog(); }}>
|
|
3322
|
+
{previewLines.map((line, i) => (
|
|
3323
|
+
<div key={i} className="text-[8px] text-gray-500 font-mono truncate">{line}</div>
|
|
3324
|
+
))}
|
|
3325
|
+
</div>
|
|
3326
|
+
)}
|
|
3327
|
+
|
|
3328
|
+
{/* Inbox — prominent, shows pending/failed counts */}
|
|
3329
|
+
{(inboxPending > 0 || inboxFailed > 0) && (
|
|
3330
|
+
<div className="px-2 py-1" style={{ borderTop: `1px solid ${c.border}15` }}>
|
|
3331
|
+
<button onPointerDown={e => e.stopPropagation()} onClick={e => { e.stopPropagation(); onShowInbox(); }}
|
|
3332
|
+
className="w-full text-[9px] px-2 py-1 rounded flex items-center justify-center gap-1.5 bg-orange-600/15 text-orange-400 hover:bg-orange-600/25 border border-orange-600/30">
|
|
3333
|
+
📨 Inbox
|
|
3334
|
+
{inboxPending > 0 && <span className="px-1 rounded-full bg-yellow-600/30 text-yellow-400 text-[8px]">{inboxPending} pending</span>}
|
|
3335
|
+
{inboxFailed > 0 && <span className="px-1 rounded-full bg-red-600/30 text-red-400 text-[8px]">{inboxFailed} failed</span>}
|
|
3336
|
+
</button>
|
|
3337
|
+
</div>
|
|
3338
|
+
)}
|
|
3339
|
+
|
|
3340
|
+
{/* Actions */}
|
|
3341
|
+
<div className="flex items-center gap-1 px-2 py-1.5" style={{ borderTop: `1px solid ${c.border}15` }}>
|
|
3342
|
+
{taskStatus === 'running' && (
|
|
3343
|
+
<>
|
|
3344
|
+
<button onPointerDown={e => e.stopPropagation()} onClick={e => { e.stopPropagation(); data.onMarkIdle?.(); }}
|
|
3345
|
+
className="text-[9px] px-1 py-0.5 rounded bg-gray-600/20 text-gray-400 hover:bg-gray-600/30" title="Silent stop — no notifications">■</button>
|
|
3346
|
+
<button onPointerDown={e => e.stopPropagation()} onClick={e => { e.stopPropagation(); data.onMarkDone?.(true); }}
|
|
3347
|
+
className="text-[9px] px-1 py-0.5 rounded bg-green-600/20 text-green-400 hover:bg-green-600/30" title="Mark done + notify">✓</button>
|
|
3348
|
+
<button onPointerDown={e => e.stopPropagation()} onClick={e => { e.stopPropagation(); data.onMarkFailed?.(true); }}
|
|
3349
|
+
className="text-[9px] px-1 py-0.5 rounded bg-red-600/20 text-red-400 hover:bg-red-600/30" title="Mark failed + notify">✕</button>
|
|
3350
|
+
</>
|
|
3351
|
+
)}
|
|
3352
|
+
{/* Message button — send instructions to agent */}
|
|
3353
|
+
{smithStatus === 'active' && taskStatus !== 'running' && (
|
|
3354
|
+
<button onPointerDown={e => e.stopPropagation()} onClick={e => { e.stopPropagation(); onMessage(); }}
|
|
3355
|
+
className="text-[9px] px-1.5 py-0.5 rounded bg-blue-600/20 text-blue-400 hover:bg-blue-600/30">💬 Message</button>
|
|
3356
|
+
)}
|
|
3357
|
+
<div className="flex-1" />
|
|
3358
|
+
<span className="flex items-center">
|
|
3359
|
+
<button onPointerDown={e => e.stopPropagation()}
|
|
3360
|
+
onClick={e => { e.stopPropagation(); if (smithStatus === 'active') onOpenTerminal(); }}
|
|
3361
|
+
disabled={smithStatus !== 'active'}
|
|
3362
|
+
className={`text-[9px] px-1 ${smithStatus !== 'active' ? 'text-gray-700 cursor-not-allowed' : hasTmux && taskStatus === 'running' ? 'text-green-400 animate-pulse' : 'text-gray-600 hover:text-green-400'}`}
|
|
3363
|
+
title={smithStatus === 'starting' ? 'Starting session…' : smithStatus === 'down' ? 'Smith not started' : 'Open terminal'}>⌨️</button>
|
|
3364
|
+
{hasTmux && !config.primary && (
|
|
3365
|
+
<button onPointerDown={e => e.stopPropagation()} onClick={e => { e.stopPropagation(); onSwitchSession(); }}
|
|
3366
|
+
className="text-[10px] text-gray-600 hover:text-yellow-400 px-0.5 py-0.5" title="Switch session">▾</button>
|
|
3367
|
+
)}
|
|
3368
|
+
</span>
|
|
3369
|
+
<button onPointerDown={e => e.stopPropagation()} onClick={e => { e.stopPropagation(); onShowInbox(); }}
|
|
3370
|
+
className="text-[9px] text-gray-600 hover:text-orange-400 px-1" title="Messages (inbox/outbox)">📨</button>
|
|
3371
|
+
<button onPointerDown={e => e.stopPropagation()} onClick={e => { e.stopPropagation(); onShowMemory(); }}
|
|
3372
|
+
className="text-[9px] text-gray-600 hover:text-purple-400 px-1" title="Memory">🧠</button>
|
|
3373
|
+
<button onPointerDown={e => e.stopPropagation()} onClick={e => { e.stopPropagation(); onShowLog(); }}
|
|
3374
|
+
className="text-[9px] text-gray-600 hover:text-gray-300 px-1" title="Logs">📋</button>
|
|
3375
|
+
<button onPointerDown={e => e.stopPropagation()} onClick={e => { e.stopPropagation(); onSaveAsTemplate(); }}
|
|
3376
|
+
className="text-[9px] text-gray-600 hover:text-yellow-400 px-1" title="Save as template">💾</button>
|
|
3377
|
+
<button onPointerDown={e => e.stopPropagation()} onClick={e => { e.stopPropagation(); onEdit(); }}
|
|
3378
|
+
className="text-[9px] text-gray-600 hover:text-blue-400 px-1">✏️</button>
|
|
3379
|
+
<button onPointerDown={e => e.stopPropagation()} onClick={e => { e.stopPropagation(); onRemove(); }}
|
|
3380
|
+
className="text-[9px] text-gray-600 hover:text-red-400 px-1">✕</button>
|
|
3381
|
+
</div>
|
|
3382
|
+
</div>
|
|
3383
|
+
);
|
|
3384
|
+
}
|
|
3385
|
+
|
|
3386
|
+
const nodeTypes = { agent: AgentFlowNode, input: InputFlowNode };
|
|
3387
|
+
|
|
3388
|
+
// ─── Main Workspace ──────────────────────────────────────
|
|
3389
|
+
|
|
3390
|
+
export interface WorkspaceViewHandle {
|
|
3391
|
+
focusAgent: (agentId: string) => void;
|
|
3392
|
+
}
|
|
3393
|
+
|
|
3394
|
+
function WorkspaceViewInner({ projectPath, projectName, onClose }: {
|
|
3395
|
+
projectPath: string;
|
|
3396
|
+
projectName: string;
|
|
3397
|
+
onClose: () => void;
|
|
3398
|
+
}, ref: React.Ref<WorkspaceViewHandle>) {
|
|
3399
|
+
const reactFlow = useReactFlow();
|
|
3400
|
+
const [workspaceId, setWorkspaceId] = useState<string | null>(null);
|
|
3401
|
+
const [rfNodes, setRfNodes] = useState<Node<any>[]>([]);
|
|
3402
|
+
const [modal, setModal] = useState<{ mode: 'add' | 'edit'; initial: Partial<AgentConfig>; editId?: string } | null>(null);
|
|
3403
|
+
const [messageTarget, setMessageTarget] = useState<{ id: string; label: string } | null>(null);
|
|
3404
|
+
const [logTarget, setLogTarget] = useState<{ id: string; label: string } | null>(null);
|
|
3405
|
+
const [runPromptTarget, setRunPromptTarget] = useState<{ id: string; label: string } | null>(null);
|
|
3406
|
+
const [userInputRequest, setUserInputRequest] = useState<{ agentId: string; fromAgent: string; question: string } | null>(null);
|
|
3407
|
+
const [memoryTarget, setMemoryTarget] = useState<{ id: string; label: string } | null>(null);
|
|
3408
|
+
const [inboxTarget, setInboxTarget] = useState<{ id: string; label: string } | null>(null);
|
|
3409
|
+
const [showBusPanel, setShowBusPanel] = useState(false);
|
|
3410
|
+
const [mascotTheme, setMascotTheme] = useState<MascotTheme>(() => {
|
|
3411
|
+
if (typeof window === 'undefined') return 'off';
|
|
3412
|
+
return (localStorage.getItem('forge.mascotTheme') as MascotTheme) || 'off';
|
|
3413
|
+
});
|
|
3414
|
+
const updateMascotTheme = (t: MascotTheme) => {
|
|
3415
|
+
setMascotTheme(t);
|
|
3416
|
+
if (typeof window !== 'undefined') localStorage.setItem('forge.mascotTheme', t);
|
|
3417
|
+
};
|
|
3418
|
+
const [floatingTerminals, setFloatingTerminals] = useState<{ agentId: string; label: string; icon: string; cliId: string; cliCmd?: string; cliType?: string; workDir?: string; tmuxSession?: string; sessionName: string; resumeMode?: boolean; resumeSessionId?: string; profileEnv?: Record<string, string>; isPrimary?: boolean; skipPermissions?: boolean; persistentSession?: boolean; boundSessionId?: string; initialPos?: { x: number; y: number } }[]>([]);
|
|
3419
|
+
const [termPicker, setTermPicker] = useState<{ agent: AgentConfig; sessName: string; workDir?: string; supportsSession?: boolean; currentSessionId: string | null; initialPos?: { x: number; y: number } } | null>(null);
|
|
3420
|
+
|
|
3421
|
+
// Expose focusAgent to parent
|
|
3422
|
+
useImperativeHandle(ref, () => ({
|
|
3423
|
+
focusAgent(agentId: string) {
|
|
3424
|
+
const node = rfNodes.find(n => n.id === agentId);
|
|
3425
|
+
if (node && node.measured?.width) {
|
|
3426
|
+
reactFlow.setCenter(
|
|
3427
|
+
node.position.x + (node.measured.width / 2),
|
|
3428
|
+
node.position.y + ((node.measured.height || 100) / 2),
|
|
3429
|
+
{ zoom: 1.2, duration: 400 }
|
|
3430
|
+
);
|
|
3431
|
+
// Flash highlight via selection
|
|
3432
|
+
reactFlow.setNodes(nodes => nodes.map(n => ({ ...n, selected: n.id === agentId })));
|
|
3433
|
+
setTimeout(() => {
|
|
3434
|
+
reactFlow.setNodes(nodes => nodes.map(n => ({ ...n, selected: false })));
|
|
3435
|
+
}, 1500);
|
|
3436
|
+
}
|
|
3437
|
+
},
|
|
3438
|
+
}), [rfNodes, reactFlow]);
|
|
3439
|
+
|
|
3440
|
+
// Initialize workspace
|
|
3441
|
+
useEffect(() => {
|
|
3442
|
+
ensureWorkspace(projectPath, projectName).then(setWorkspaceId).catch(() => {});
|
|
3443
|
+
}, [projectPath, projectName]);
|
|
3444
|
+
|
|
3445
|
+
// SSE stream — server is the single source of truth
|
|
3446
|
+
const { agents, states, logPreview, busLog, daemonActive: daemonActiveFromStream, setDaemonActive: setDaemonActiveFromStream } = useWorkspaceStream(workspaceId, (event) => {
|
|
3447
|
+
if (event.type === 'user_input_request') {
|
|
3448
|
+
setUserInputRequest(event);
|
|
3449
|
+
}
|
|
3450
|
+
});
|
|
3451
|
+
|
|
3452
|
+
// Auto-open terminals removed — persistent sessions run in background tmux.
|
|
3453
|
+
// User opens terminal via ⌨️ button when needed.
|
|
3454
|
+
|
|
3455
|
+
// Rebuild nodes when agents/states/preview change — preserve existing positions + dimensions
|
|
3456
|
+
useEffect(() => {
|
|
3457
|
+
setRfNodes(prev => {
|
|
3458
|
+
const prevMap = new Map(prev.map(n => [n.id, n]));
|
|
3459
|
+
return agents.map((agent, i) => {
|
|
3460
|
+
const existing = prevMap.get(agent.id);
|
|
3461
|
+
const base = {
|
|
3462
|
+
id: agent.id,
|
|
3463
|
+
position: existing?.position ?? { x: i * 260, y: 60 },
|
|
3464
|
+
...(existing?.measured ? { measured: existing.measured } : {}),
|
|
3465
|
+
...(existing?.width ? { width: existing.width, height: existing.height } : {}),
|
|
3466
|
+
};
|
|
3467
|
+
|
|
3468
|
+
// Input node
|
|
3469
|
+
if (agent.type === 'input') {
|
|
3470
|
+
return {
|
|
3471
|
+
...base,
|
|
3472
|
+
type: 'input' as const,
|
|
3473
|
+
data: {
|
|
3474
|
+
config: agent,
|
|
3475
|
+
state: states[agent.id] || { smithStatus: 'down', taskStatus: 'idle', artifacts: [] },
|
|
3476
|
+
onSubmit: (content: string) => {
|
|
3477
|
+
// Optimistic update
|
|
3478
|
+
wsApi(workspaceId!, 'complete_input', { agentId: agent.id, content });
|
|
3479
|
+
},
|
|
3480
|
+
onEdit: () => setModal({ mode: 'edit', initial: agent, editId: agent.id }),
|
|
3481
|
+
onRemove: () => {
|
|
3482
|
+
if (!confirm(`Remove "${agent.label}"?`)) return;
|
|
3483
|
+
wsApi(workspaceId!, 'remove', { agentId: agent.id });
|
|
3484
|
+
},
|
|
3485
|
+
} satisfies InputNodeData,
|
|
3486
|
+
};
|
|
3487
|
+
}
|
|
3488
|
+
|
|
3489
|
+
// Agent node
|
|
3490
|
+
return {
|
|
3491
|
+
...base,
|
|
3492
|
+
type: 'agent' as const,
|
|
3493
|
+
data: {
|
|
3494
|
+
config: agent,
|
|
3495
|
+
state: states[agent.id] || { smithStatus: 'down', taskStatus: 'idle', artifacts: [] },
|
|
3496
|
+
colorIdx: i,
|
|
3497
|
+
previewLines: logPreview[agent.id] || [],
|
|
3498
|
+
projectPath,
|
|
3499
|
+
workspaceId,
|
|
3500
|
+
onRun: () => {
|
|
3501
|
+
wsApi(workspaceId!, 'run', { agentId: agent.id });
|
|
3502
|
+
},
|
|
3503
|
+
onPause: () => wsApi(workspaceId!, 'pause', { agentId: agent.id }),
|
|
3504
|
+
onStop: () => wsApi(workspaceId!, 'stop', { agentId: agent.id }),
|
|
3505
|
+
mascotTheme,
|
|
3506
|
+
onMarkIdle: () => wsApi(workspaceId!, 'mark_done', { agentId: agent.id, notify: false }),
|
|
3507
|
+
onMarkDone: (notify: boolean) => wsApi(workspaceId!, 'mark_done', { agentId: agent.id, notify }),
|
|
3508
|
+
onMarkFailed: (notify: boolean) => wsApi(workspaceId!, 'mark_failed', { agentId: agent.id, notify }),
|
|
3509
|
+
onRetry: () => wsApi(workspaceId!, 'retry', { agentId: agent.id }),
|
|
3510
|
+
onEdit: () => setModal({ mode: 'edit', initial: agent, editId: agent.id }),
|
|
3511
|
+
onRemove: () => {
|
|
3512
|
+
if (!confirm(`Remove "${agent.label}"?`)) return;
|
|
3513
|
+
wsApi(workspaceId!, 'remove', { agentId: agent.id });
|
|
3514
|
+
},
|
|
3515
|
+
onMessage: () => setMessageTarget({ id: agent.id, label: agent.label }),
|
|
3516
|
+
onApprove: () => wsApi(workspaceId!, 'approve', { agentId: agent.id }),
|
|
3517
|
+
onShowLog: () => setLogTarget({ id: agent.id, label: agent.label }),
|
|
3518
|
+
onShowMemory: () => setMemoryTarget({ id: agent.id, label: agent.label }),
|
|
3519
|
+
onShowInbox: () => setInboxTarget({ id: agent.id, label: agent.label }),
|
|
3520
|
+
inboxPending: busLog.filter(m => m.to === agent.id && (m.status === 'pending' || m.status === 'pending_approval') && m.type !== 'ack').length,
|
|
3521
|
+
inboxFailed: busLog.filter(m => m.to === agent.id && m.status === 'failed' && m.type !== 'ack').length,
|
|
3522
|
+
onOpenTerminal: async () => {
|
|
3523
|
+
if (!workspaceId) return;
|
|
3524
|
+
// Sync stale daemonActiveFromStream from agent states
|
|
3525
|
+
const anyActive = Object.values(states).some(s => s?.smithStatus === 'active');
|
|
3526
|
+
if (anyActive && !daemonActiveFromStream) setDaemonActiveFromStream(true);
|
|
3527
|
+
// Close existing terminal (config may have changed)
|
|
3528
|
+
setFloatingTerminals(prev => prev.filter(t => t.agentId !== agent.id));
|
|
3529
|
+
|
|
3530
|
+
const nodeEl = document.querySelector(`[data-id="${agent.id}"]`);
|
|
3531
|
+
const nodeRect = nodeEl?.getBoundingClientRect();
|
|
3532
|
+
const initialPos = nodeRect ? { x: nodeRect.left, y: nodeRect.bottom + 4 } : { x: 80, y: 60 };
|
|
3533
|
+
const safeName = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, '-').replace(/-+/g, '-').slice(0, 20);
|
|
3534
|
+
const sessName = `mw-forge-${safeName(projectName)}-${safeName(agent.label)}`;
|
|
3535
|
+
const workDir = agent.workDir && agent.workDir !== './' && agent.workDir !== '.' ? agent.workDir : undefined;
|
|
3536
|
+
// All agents: show picker (current session / new session / other sessions)
|
|
3537
|
+
const resolveRes = await wsApi(workspaceId, 'open_terminal', { agentId: agent.id, resolveOnly: true }).catch(() => ({})) as any;
|
|
3538
|
+
const currentSessionId = resolveRes?.currentSessionId ?? null;
|
|
3539
|
+
setTermPicker({ agent, sessName, workDir, supportsSession: resolveRes?.supportsSession ?? true, currentSessionId, initialPos });
|
|
3540
|
+
},
|
|
3541
|
+
onSaveAsTemplate: async () => {
|
|
3542
|
+
const name = prompt('Template name:', agent.label);
|
|
3543
|
+
if (!name) return;
|
|
3544
|
+
const desc = prompt('Description (optional):', '');
|
|
3545
|
+
try {
|
|
3546
|
+
await fetch('/api/smith-templates', {
|
|
3547
|
+
method: 'POST',
|
|
3548
|
+
headers: { 'Content-Type': 'application/json' },
|
|
3549
|
+
body: JSON.stringify({ config: agent, name, icon: agent.icon, description: desc || '' }),
|
|
3550
|
+
});
|
|
3551
|
+
} catch {
|
|
3552
|
+
alert('Failed to save template');
|
|
3553
|
+
}
|
|
3554
|
+
},
|
|
3555
|
+
onSwitchSession: async () => {
|
|
3556
|
+
if (!workspaceId) return;
|
|
3557
|
+
setFloatingTerminals(prev => prev.filter(t => t.agentId !== agent.id));
|
|
3558
|
+
if (agent.id) wsApi(workspaceId, 'close_terminal', { agentId: agent.id });
|
|
3559
|
+
const nodeEl = document.querySelector(`[data-id="${agent.id}"]`);
|
|
3560
|
+
const nodeRect = nodeEl?.getBoundingClientRect();
|
|
3561
|
+
const initialPos = nodeRect ? { x: nodeRect.left, y: nodeRect.bottom + 4 } : { x: 80, y: 60 };
|
|
3562
|
+
const safeName = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, '-').replace(/-+/g, '-').slice(0, 20);
|
|
3563
|
+
const sessName = `mw-forge-${safeName(projectName)}-${safeName(agent.label)}`;
|
|
3564
|
+
const workDir = agent.workDir && agent.workDir !== './' && agent.workDir !== '.' ? agent.workDir : undefined;
|
|
3565
|
+
const resolveRes = await wsApi(workspaceId, 'open_terminal', { agentId: agent.id, resolveOnly: true }).catch(() => ({})) as any;
|
|
3566
|
+
const currentSessionId = resolveRes?.currentSessionId ?? null;
|
|
3567
|
+
setTermPicker({ agent, sessName, workDir, supportsSession: resolveRes?.supportsSession ?? true, currentSessionId, initialPos });
|
|
3568
|
+
},
|
|
3569
|
+
} satisfies AgentNodeData,
|
|
3570
|
+
};
|
|
3571
|
+
});
|
|
3572
|
+
});
|
|
3573
|
+
}, [agents, states, logPreview, workspaceId, mascotTheme]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
3574
|
+
|
|
3575
|
+
// Derive edges from dependsOn
|
|
3576
|
+
const rfEdges = useMemo(() => {
|
|
3577
|
+
const edges: any[] = [];
|
|
3578
|
+
for (const agent of agents) {
|
|
3579
|
+
for (const depId of agent.dependsOn) {
|
|
3580
|
+
const depState = states[depId];
|
|
3581
|
+
const targetState = states[agent.id];
|
|
3582
|
+
const depTask = depState?.taskStatus || 'idle';
|
|
3583
|
+
const targetTask = targetState?.taskStatus || 'idle';
|
|
3584
|
+
const isFlowing = depTask === 'running' || targetTask === 'running';
|
|
3585
|
+
const isCompleted = depTask === 'done';
|
|
3586
|
+
const color = isFlowing ? '#58a6ff70' : isCompleted ? '#58a6ff40' : '#30363d60';
|
|
3587
|
+
|
|
3588
|
+
// Find last bus message between these two agents
|
|
3589
|
+
const lastMsg = [...busLog].reverse().find(m =>
|
|
3590
|
+
(m.from === depId && m.to === agent.id) || (m.from === agent.id && m.to === depId)
|
|
3591
|
+
);
|
|
3592
|
+
const edgeLabel = lastMsg?.payload?.action && lastMsg.payload.action !== 'task_complete' && lastMsg.payload.action !== 'ack'
|
|
3593
|
+
? `${lastMsg.payload.action}${lastMsg.payload.content ? ': ' + lastMsg.payload.content.slice(0, 30) : ''}`
|
|
3594
|
+
: undefined;
|
|
3595
|
+
|
|
3596
|
+
edges.push({
|
|
3597
|
+
id: `${depId}-${agent.id}`,
|
|
3598
|
+
source: depId,
|
|
3599
|
+
target: agent.id,
|
|
3600
|
+
animated: isFlowing,
|
|
3601
|
+
label: edgeLabel,
|
|
3602
|
+
labelStyle: { fill: '#8b949e', fontSize: 8 },
|
|
3603
|
+
labelBgStyle: { fill: '#0d1117', fillOpacity: 0.8 },
|
|
3604
|
+
labelBgPadding: [4, 2] as [number, number],
|
|
3605
|
+
style: { stroke: color, strokeWidth: isFlowing ? 2 : isCompleted ? 1.5 : 1 },
|
|
3606
|
+
markerEnd: { type: MarkerType.ArrowClosed, color },
|
|
3607
|
+
});
|
|
3608
|
+
}
|
|
3609
|
+
}
|
|
3610
|
+
return edges;
|
|
3611
|
+
}, [agents, states]);
|
|
3612
|
+
|
|
3613
|
+
// Let ReactFlow manage all node changes (position, dimensions, selection, etc.)
|
|
3614
|
+
const onNodesChange = useCallback((changes: NodeChange[]) => {
|
|
3615
|
+
setRfNodes(prev => applyNodeChanges(changes, prev) as Node<AgentNodeData>[]);
|
|
3616
|
+
}, []);
|
|
3617
|
+
|
|
3618
|
+
const handleAddAgent = async (cfg: Omit<AgentConfig, 'id'>) => {
|
|
3619
|
+
if (!workspaceId) return;
|
|
3620
|
+
const config: AgentConfig = { ...cfg, id: `${cfg.label.toLowerCase().replace(/\s+/g, '-')}-${Date.now()}` };
|
|
3621
|
+
// Auto-install base plugins if not already installed (for preset templates)
|
|
3622
|
+
// User-selected instances are already installed, so this is a no-op for them
|
|
3623
|
+
if (cfg.plugins?.length) {
|
|
3624
|
+
await Promise.all(cfg.plugins.map(pluginId =>
|
|
3625
|
+
fetch('/api/plugins', {
|
|
3626
|
+
method: 'POST',
|
|
3627
|
+
headers: { 'Content-Type': 'application/json' },
|
|
3628
|
+
body: JSON.stringify({ action: 'install', id: pluginId, config: {} }),
|
|
3629
|
+
}).catch(() => {})
|
|
3630
|
+
));
|
|
3631
|
+
}
|
|
3632
|
+
// Optimistic update — show immediately
|
|
3633
|
+
setModal(null);
|
|
3634
|
+
await wsApi(workspaceId, 'add', { config });
|
|
3635
|
+
};
|
|
3636
|
+
|
|
3637
|
+
const handleEditAgent = async (cfg: Omit<AgentConfig, 'id'>) => {
|
|
3638
|
+
if (!workspaceId || !modal?.editId) return;
|
|
3639
|
+
const config: AgentConfig = { ...cfg, id: modal.editId };
|
|
3640
|
+
// Optimistic update
|
|
3641
|
+
setModal(null);
|
|
3642
|
+
await wsApi(workspaceId, 'update', { agentId: modal.editId, config });
|
|
3643
|
+
};
|
|
3644
|
+
|
|
3645
|
+
const handleAddInput = async () => {
|
|
3646
|
+
if (!workspaceId) return;
|
|
3647
|
+
const config: AgentConfig = {
|
|
3648
|
+
id: `input-${Date.now()}`, label: 'Requirements', icon: '📝',
|
|
3649
|
+
type: 'input', content: '', entries: [], role: '', backend: 'cli',
|
|
3650
|
+
dependsOn: [], outputs: [], steps: [],
|
|
3651
|
+
};
|
|
3652
|
+
await wsApi(workspaceId, 'add', { config });
|
|
3653
|
+
};
|
|
3654
|
+
|
|
3655
|
+
const handleCreatePipeline = async () => {
|
|
3656
|
+
if (!workspaceId) return;
|
|
3657
|
+
// Create pipeline via API — server uses presets with full prompts
|
|
3658
|
+
const res = await fetch(`/api/workspace/${workspaceId}/agents`, {
|
|
3659
|
+
method: 'POST',
|
|
3660
|
+
headers: { 'Content-Type': 'application/json' },
|
|
3661
|
+
body: JSON.stringify({ action: 'create_pipeline' }),
|
|
3662
|
+
});
|
|
3663
|
+
const data = await res.json();
|
|
3664
|
+
if (!res.ok && data.error) alert(`Error: ${data.error}`);
|
|
3665
|
+
};
|
|
3666
|
+
|
|
3667
|
+
const handleExportTemplate = async () => {
|
|
3668
|
+
if (!workspaceId) return;
|
|
3669
|
+
try {
|
|
3670
|
+
const res = await fetch(`/api/workspace?export=${workspaceId}`);
|
|
3671
|
+
const template = await res.json();
|
|
3672
|
+
// Download as JSON file
|
|
3673
|
+
const blob = new Blob([JSON.stringify(template, null, 2)], { type: 'application/json' });
|
|
3674
|
+
const url = URL.createObjectURL(blob);
|
|
3675
|
+
const a = document.createElement('a');
|
|
3676
|
+
a.href = url;
|
|
3677
|
+
a.download = `workspace-template-${projectName.replace(/\s+/g, '-')}.json`;
|
|
3678
|
+
a.click();
|
|
3679
|
+
URL.revokeObjectURL(url);
|
|
3680
|
+
} catch {
|
|
3681
|
+
alert('Export failed');
|
|
3682
|
+
}
|
|
3683
|
+
};
|
|
3684
|
+
|
|
3685
|
+
const handleImportTemplate = async (file: File) => {
|
|
3686
|
+
if (!workspaceId) return;
|
|
3687
|
+
try {
|
|
3688
|
+
const text = await file.text();
|
|
3689
|
+
const template = JSON.parse(text);
|
|
3690
|
+
await fetch('/api/workspace', {
|
|
3691
|
+
method: 'POST',
|
|
3692
|
+
headers: { 'Content-Type': 'application/json' },
|
|
3693
|
+
body: JSON.stringify({ projectPath, projectName, template }),
|
|
3694
|
+
});
|
|
3695
|
+
// Reload page to pick up new workspace
|
|
3696
|
+
window.location.reload();
|
|
3697
|
+
} catch {
|
|
3698
|
+
alert('Import failed — invalid template file');
|
|
3699
|
+
}
|
|
3700
|
+
};
|
|
3701
|
+
|
|
3702
|
+
const handleRunAll = () => { if (workspaceId) wsApi(workspaceId, 'run_all'); };
|
|
3703
|
+
const handleStartDaemon = async () => {
|
|
3704
|
+
if (!workspaceId) return;
|
|
3705
|
+
const result = await wsApi(workspaceId, 'start_daemon');
|
|
3706
|
+
if (result.ok) setDaemonActiveFromStream(true);
|
|
3707
|
+
};
|
|
3708
|
+
const handleStopDaemon = async () => {
|
|
3709
|
+
if (!workspaceId) return;
|
|
3710
|
+
const result = await wsApi(workspaceId, 'stop_daemon');
|
|
3711
|
+
if (result.ok) setDaemonActiveFromStream(false);
|
|
3712
|
+
};
|
|
3713
|
+
|
|
3714
|
+
return (
|
|
3715
|
+
<div className="flex-1 flex flex-col min-h-0" style={{ background: '#080810' }}>
|
|
3716
|
+
{/* Header */}
|
|
3717
|
+
<div className="flex items-center gap-2 px-3 py-1.5 border-b border-[#2a2a3a] shrink-0">
|
|
3718
|
+
<button onClick={onClose} className="text-gray-400 hover:text-white text-sm">←</button>
|
|
3719
|
+
<span className="text-xs font-bold text-white">Workspace</span>
|
|
3720
|
+
<span className="text-[9px] text-gray-500">{projectName}</span>
|
|
3721
|
+
{agents.length > 0 && !daemonActiveFromStream && (
|
|
3722
|
+
<>
|
|
3723
|
+
<button onClick={handleRunAll}
|
|
3724
|
+
className="text-[8px] px-2 py-0.5 rounded bg-green-600/20 text-green-400 hover:bg-green-600/30 ml-2">
|
|
3725
|
+
▶ Run All
|
|
3726
|
+
</button>
|
|
3727
|
+
<button onClick={handleStartDaemon}
|
|
3728
|
+
className="text-[8px] px-2 py-0.5 rounded bg-emerald-600/20 text-emerald-400 hover:bg-emerald-600/30">
|
|
3729
|
+
⚡ Start Daemon
|
|
3730
|
+
</button>
|
|
3731
|
+
</>
|
|
3732
|
+
)}
|
|
3733
|
+
{daemonActiveFromStream && (
|
|
3734
|
+
<>
|
|
3735
|
+
<span className="text-[8px] px-2 py-0.5 rounded bg-green-600/30 text-green-400 ml-2 animate-pulse">
|
|
3736
|
+
● Daemon Active
|
|
3737
|
+
</span>
|
|
3738
|
+
<button onClick={handleStopDaemon}
|
|
3739
|
+
className="text-[8px] px-2 py-0.5 rounded bg-red-600/20 text-red-400 hover:bg-red-600/30">
|
|
3740
|
+
■ Stop
|
|
3741
|
+
</button>
|
|
3742
|
+
</>
|
|
3743
|
+
)}
|
|
3744
|
+
<div className="ml-auto flex items-center gap-2">
|
|
3745
|
+
<select value={mascotTheme} onChange={e => updateMascotTheme(e.target.value as MascotTheme)}
|
|
3746
|
+
className="text-[8px] px-1.5 py-0.5 rounded border border-[#30363d] bg-[#0d1117] text-gray-500 hover:text-white hover:border-[#58a6ff]/60 cursor-pointer focus:outline-none"
|
|
3747
|
+
title="Mascot theme">
|
|
3748
|
+
<option value="stick">🏃 Stick</option>
|
|
3749
|
+
<option value="cat">🐱 Cat</option>
|
|
3750
|
+
<option value="dog">🐶 Dog</option>
|
|
3751
|
+
<option value="pig">🐷 Pig</option>
|
|
3752
|
+
<option value="emoji">🎭 Emoji</option>
|
|
3753
|
+
<option value="off">⊘ Off</option>
|
|
3754
|
+
</select>
|
|
3755
|
+
<button onClick={() => setShowBusPanel(true)}
|
|
3756
|
+
className={`text-[8px] px-2 py-0.5 rounded border border-[#30363d] hover:border-[#58a6ff]/60 ${busLog.length > 0 ? 'text-[#58a6ff]' : 'text-gray-500'}`}>
|
|
3757
|
+
📡 Logs{busLog.length > 0 ? ` (${busLog.length})` : ''}
|
|
3758
|
+
</button>
|
|
3759
|
+
{agents.length > 0 && (
|
|
3760
|
+
<button onClick={handleExportTemplate}
|
|
3761
|
+
className="text-[8px] px-2 py-0.5 rounded border border-[#30363d] text-gray-500 hover:text-white hover:border-[#58a6ff]/60">
|
|
3762
|
+
📤 Export
|
|
3763
|
+
</button>
|
|
3764
|
+
)}
|
|
3765
|
+
<button onClick={handleAddInput}
|
|
3766
|
+
className="text-[8px] px-2 py-0.5 rounded border border-[#30363d] text-gray-400 hover:text-white hover:border-[#58a6ff]/60">
|
|
3767
|
+
📝 + Input
|
|
3768
|
+
</button>
|
|
3769
|
+
<button onClick={() => setModal({ mode: 'add', initial: {} })}
|
|
3770
|
+
className="text-[8px] px-2 py-0.5 rounded border border-[#30363d] text-gray-400 hover:text-white hover:border-[#58a6ff]/60">
|
|
3771
|
+
+ Add Agent
|
|
3772
|
+
</button>
|
|
3773
|
+
</div>
|
|
3774
|
+
</div>
|
|
3775
|
+
|
|
3776
|
+
{/* Graph area */}
|
|
3777
|
+
{agents.length === 0 ? (
|
|
3778
|
+
<div className="flex-1 flex flex-col items-center justify-center gap-3">
|
|
3779
|
+
<span className="text-3xl">🚀</span>
|
|
3780
|
+
<div className="text-sm text-gray-400">Set up your workspace</div>
|
|
3781
|
+
{/* Primary agent prompt */}
|
|
3782
|
+
<button onClick={() => setModal({ mode: 'add', initial: {
|
|
3783
|
+
label: 'Engineer', icon: '👨💻', primary: true, persistentSession: true,
|
|
3784
|
+
role: 'Primary engineer — handles coding tasks in the project root.',
|
|
3785
|
+
backend: 'cli' as const, agentId: 'claude', workDir: './', dependsOn: [], outputs: [], steps: [],
|
|
3786
|
+
}})}
|
|
3787
|
+
className="flex items-center gap-3 px-5 py-3 rounded-lg border-2 border-dashed border-[#f0883e]/50 bg-[#f0883e]/5 hover:bg-[#f0883e]/10 hover:border-[#f0883e]/80 transition-colors">
|
|
3788
|
+
<span className="text-2xl">👨💻</span>
|
|
3789
|
+
<div className="text-left">
|
|
3790
|
+
<div className="text-[11px] font-semibold text-[#f0883e]">Add Primary Agent</div>
|
|
3791
|
+
<div className="text-[9px] text-gray-500">Terminal-only, root directory, fixed session</div>
|
|
3792
|
+
</div>
|
|
3793
|
+
</button>
|
|
3794
|
+
<div className="text-[9px] text-gray-600 mt-1">or add other agents:</div>
|
|
3795
|
+
<div className="flex gap-2 flex-wrap justify-center">
|
|
3796
|
+
{PRESET_AGENTS.map((p, i) => (
|
|
3797
|
+
<button key={i} onClick={() => setModal({ mode: 'add', initial: p })}
|
|
3798
|
+
className="text-[10px] px-3 py-1.5 rounded border border-[#30363d] text-gray-300 hover:text-white hover:border-[#58a6ff]/60 flex items-center gap-1">
|
|
3799
|
+
{p.icon} {p.label}
|
|
3800
|
+
</button>
|
|
3801
|
+
))}
|
|
3802
|
+
</div>
|
|
3803
|
+
<div className="flex gap-2 mt-1">
|
|
3804
|
+
<button onClick={() => setModal({ mode: 'add', initial: {} })}
|
|
3805
|
+
className="text-[10px] px-3 py-1.5 rounded border border-dashed border-[#30363d] text-gray-500 hover:text-white hover:border-[#58a6ff]/60">
|
|
3806
|
+
⚙️ Custom
|
|
3807
|
+
</button>
|
|
3808
|
+
<button onClick={handleCreatePipeline}
|
|
3809
|
+
className="text-[10px] px-3 py-1.5 rounded border border-[#238636] text-[#3fb950] hover:bg-[#238636]/20">
|
|
3810
|
+
🚀 Dev Pipeline
|
|
3811
|
+
</button>
|
|
3812
|
+
<label className="text-[10px] px-3 py-1.5 rounded border border-dashed border-[#30363d] text-gray-500 hover:text-white hover:border-[#58a6ff]/60 cursor-pointer">
|
|
3813
|
+
📥 Import
|
|
3814
|
+
<input type="file" accept=".json" className="hidden" onChange={e => {
|
|
3815
|
+
const file = e.target.files?.[0];
|
|
3816
|
+
if (file) handleImportTemplate(file);
|
|
3817
|
+
e.target.value = '';
|
|
3818
|
+
}} />
|
|
3819
|
+
</label>
|
|
3820
|
+
</div>
|
|
3821
|
+
</div>
|
|
3822
|
+
) : (
|
|
3823
|
+
<div className="flex-1 min-h-0 flex flex-col">
|
|
3824
|
+
{/* No primary agent hint */}
|
|
3825
|
+
{!agents.some(a => a.primary) && (
|
|
3826
|
+
<div className="flex items-center gap-2 px-3 py-1.5 bg-[#f0883e]/10 border-b border-[#f0883e]/20 shrink-0">
|
|
3827
|
+
<span className="text-[10px] text-[#f0883e]">No primary agent set.</span>
|
|
3828
|
+
<button onClick={() => setModal({ mode: 'add', initial: {
|
|
3829
|
+
label: 'Engineer', icon: '👨💻', primary: true, persistentSession: true,
|
|
3830
|
+
role: 'Primary engineer — handles coding tasks in the project root.',
|
|
3831
|
+
backend: 'cli' as const, agentId: 'claude', workDir: './', dependsOn: [], outputs: [], steps: [],
|
|
3832
|
+
}})}
|
|
3833
|
+
className="text-[10px] text-[#f0883e] underline hover:text-white">Add one</button>
|
|
3834
|
+
<span className="text-[9px] text-gray-600">or edit an existing agent to set as primary.</span>
|
|
3835
|
+
</div>
|
|
3836
|
+
)}
|
|
3837
|
+
<ReactFlow
|
|
3838
|
+
nodes={rfNodes}
|
|
3839
|
+
edges={rfEdges}
|
|
3840
|
+
onNodesChange={onNodesChange}
|
|
3841
|
+
onNodeDragStop={() => {
|
|
3842
|
+
// Reposition terminals to follow their nodes
|
|
3843
|
+
setFloatingTerminals(prev => prev.map(ft => {
|
|
3844
|
+
const nodeEl = document.querySelector(`[data-id="${ft.agentId}"]`);
|
|
3845
|
+
const rect = nodeEl?.getBoundingClientRect();
|
|
3846
|
+
return rect ? { ...ft, initialPos: { x: rect.left, y: rect.bottom + 4 } } : ft;
|
|
3847
|
+
}));
|
|
3848
|
+
}}
|
|
3849
|
+
onMoveEnd={() => {
|
|
3850
|
+
// Reposition after pan/zoom
|
|
3851
|
+
setFloatingTerminals(prev => prev.map(ft => {
|
|
3852
|
+
const nodeEl = document.querySelector(`[data-id="${ft.agentId}"]`);
|
|
3853
|
+
const rect = nodeEl?.getBoundingClientRect();
|
|
3854
|
+
return rect ? { ...ft, initialPos: { x: rect.left, y: rect.bottom + 4 } } : ft;
|
|
3855
|
+
}));
|
|
3856
|
+
}}
|
|
3857
|
+
nodeTypes={nodeTypes}
|
|
3858
|
+
fitView
|
|
3859
|
+
fitViewOptions={{ padding: 0.3 }}
|
|
3860
|
+
minZoom={0.3}
|
|
3861
|
+
maxZoom={2}
|
|
3862
|
+
proOptions={{ hideAttribution: true }}
|
|
3863
|
+
>
|
|
3864
|
+
<Background color="#1a1a2e" gap={20} size={1} />
|
|
3865
|
+
<Controls style={{ background: '#0d1117', border: '1px solid #30363d' }} showInteractive={false} />
|
|
3866
|
+
</ReactFlow>
|
|
3867
|
+
</div>
|
|
3868
|
+
)}
|
|
3869
|
+
|
|
3870
|
+
{/* Config modal */}
|
|
3871
|
+
{modal && (
|
|
3872
|
+
<AgentConfigModal
|
|
3873
|
+
initial={modal.initial}
|
|
3874
|
+
mode={modal.mode}
|
|
3875
|
+
existingAgents={agents}
|
|
3876
|
+
projectPath={projectPath}
|
|
3877
|
+
onConfirm={modal.mode === 'add' ? handleAddAgent : handleEditAgent}
|
|
3878
|
+
onCancel={() => setModal(null)}
|
|
3879
|
+
/>
|
|
3880
|
+
)}
|
|
3881
|
+
|
|
3882
|
+
{/* Run prompt dialog (for agents with no dependencies) */}
|
|
3883
|
+
{runPromptTarget && workspaceId && (
|
|
3884
|
+
<RunPromptDialog
|
|
3885
|
+
agentLabel={runPromptTarget.label}
|
|
3886
|
+
onRun={input => {
|
|
3887
|
+
wsApi(workspaceId, 'run', { agentId: runPromptTarget.id, input: input || undefined });
|
|
3888
|
+
setRunPromptTarget(null);
|
|
3889
|
+
}}
|
|
3890
|
+
onCancel={() => setRunPromptTarget(null)}
|
|
3891
|
+
/>
|
|
3892
|
+
)}
|
|
3893
|
+
|
|
3894
|
+
{/* Message dialog */}
|
|
3895
|
+
{messageTarget && workspaceId && (
|
|
3896
|
+
<MessageDialog
|
|
3897
|
+
agentLabel={messageTarget.label}
|
|
3898
|
+
onSend={msg => {
|
|
3899
|
+
wsApi(workspaceId, 'message', { agentId: messageTarget.id, content: msg });
|
|
3900
|
+
setMessageTarget(null);
|
|
3901
|
+
}}
|
|
3902
|
+
onCancel={() => setMessageTarget(null)}
|
|
3903
|
+
/>
|
|
3904
|
+
)}
|
|
3905
|
+
|
|
3906
|
+
{/* Log panel */}
|
|
3907
|
+
{logTarget && workspaceId && (
|
|
3908
|
+
<LogPanel
|
|
3909
|
+
agentId={logTarget.id}
|
|
3910
|
+
agentLabel={logTarget.label}
|
|
3911
|
+
workspaceId={workspaceId}
|
|
3912
|
+
onClose={() => setLogTarget(null)}
|
|
3913
|
+
/>
|
|
3914
|
+
)}
|
|
3915
|
+
|
|
3916
|
+
{/* Bus message panel */}
|
|
3917
|
+
{showBusPanel && (
|
|
3918
|
+
<BusPanel busLog={busLog} agents={agents} onClose={() => setShowBusPanel(false)} />
|
|
3919
|
+
)}
|
|
3920
|
+
|
|
3921
|
+
{/* Memory panel */}
|
|
3922
|
+
{memoryTarget && workspaceId && (
|
|
3923
|
+
<MemoryPanel
|
|
3924
|
+
agentId={memoryTarget.id}
|
|
3925
|
+
agentLabel={memoryTarget.label}
|
|
3926
|
+
workspaceId={workspaceId}
|
|
3927
|
+
onClose={() => setMemoryTarget(null)}
|
|
3928
|
+
/>
|
|
3929
|
+
)}
|
|
3930
|
+
|
|
3931
|
+
{/* Inbox panel */}
|
|
3932
|
+
{inboxTarget && workspaceId && (
|
|
3933
|
+
<InboxPanel
|
|
3934
|
+
agentId={inboxTarget.id}
|
|
3935
|
+
agentLabel={inboxTarget.label}
|
|
3936
|
+
busLog={busLog}
|
|
3937
|
+
agents={agents}
|
|
3938
|
+
workspaceId={workspaceId}
|
|
3939
|
+
onClose={() => setInboxTarget(null)}
|
|
3940
|
+
/>
|
|
3941
|
+
)}
|
|
3942
|
+
|
|
3943
|
+
{/* Terminal session picker */}
|
|
3944
|
+
{termPicker && workspaceId && (
|
|
3945
|
+
<TerminalSessionPickerLazy
|
|
3946
|
+
agentLabel={termPicker.agent.label}
|
|
3947
|
+
currentSessionId={termPicker.currentSessionId}
|
|
3948
|
+
fetchSessions={() => fetchAgentSessions(workspaceId, termPicker.agent.id)}
|
|
3949
|
+
supportsSession={termPicker.supportsSession}
|
|
3950
|
+
onSelect={async (selection: PickerSelection) => {
|
|
3951
|
+
const { agent, sessName, workDir } = termPicker;
|
|
3952
|
+
const pickerInitialPos = termPicker.initialPos;
|
|
3953
|
+
setTermPicker(null);
|
|
3954
|
+
|
|
3955
|
+
let boundSessionId = agent.boundSessionId;
|
|
3956
|
+
if (selection.mode === 'session') {
|
|
3957
|
+
// Bind to a specific session
|
|
3958
|
+
await wsApi(workspaceId, 'update', { agentId: agent.id, config: { ...agent, boundSessionId: selection.sessionId } }).catch(() => {});
|
|
3959
|
+
boundSessionId = selection.sessionId;
|
|
3960
|
+
} else if (selection.mode === 'new') {
|
|
3961
|
+
// Clear bound session → fresh start
|
|
3962
|
+
if (agent.boundSessionId) {
|
|
3963
|
+
await wsApi(workspaceId, 'update', { agentId: agent.id, config: { ...agent, boundSessionId: undefined } }).catch(() => {});
|
|
3964
|
+
}
|
|
3965
|
+
boundSessionId = undefined;
|
|
3966
|
+
}
|
|
3967
|
+
// mode === 'current': keep existing boundSessionId
|
|
3968
|
+
|
|
3969
|
+
// 'current': just attach — claude is running, don't interrupt.
|
|
3970
|
+
// 'session' or 'new': forceRestart — rebuild launch script with correct --resume.
|
|
3971
|
+
const forceRestart = selection.mode !== 'current';
|
|
3972
|
+
const res = await wsApi(workspaceId, 'open_terminal', { agentId: agent.id, forceRestart }).catch(() => ({})) as any;
|
|
3973
|
+
const tmux = res?.tmuxSession || sessName;
|
|
3974
|
+
setFloatingTerminals(prev => [...prev, {
|
|
3975
|
+
agentId: agent.id, label: agent.label, icon: agent.icon,
|
|
3976
|
+
cliId: agent.agentId || 'claude', workDir,
|
|
3977
|
+
tmuxSession: tmux, sessionName: sessName,
|
|
3978
|
+
isPrimary: agent.primary, skipPermissions: agent.skipPermissions !== false,
|
|
3979
|
+
persistentSession: agent.persistentSession, boundSessionId, initialPos: pickerInitialPos,
|
|
3980
|
+
}]);
|
|
3981
|
+
}}
|
|
3982
|
+
onCancel={() => setTermPicker(null)}
|
|
3983
|
+
/>
|
|
3984
|
+
)}
|
|
3985
|
+
|
|
3986
|
+
{/* Floating terminals — positioned near their agent node */}
|
|
3987
|
+
{floatingTerminals.map(ft => (
|
|
3988
|
+
<FloatingTerminal
|
|
3989
|
+
key={ft.agentId}
|
|
3990
|
+
agentLabel={ft.label}
|
|
3991
|
+
agentIcon={ft.icon}
|
|
3992
|
+
projectPath={projectPath}
|
|
3993
|
+
agentCliId={ft.cliId}
|
|
3994
|
+
cliCmd={ft.cliCmd}
|
|
3995
|
+
cliType={ft.cliType}
|
|
3996
|
+
workDir={ft.workDir}
|
|
3997
|
+
preferredSessionName={ft.sessionName}
|
|
3998
|
+
existingSession={ft.tmuxSession}
|
|
3999
|
+
resumeMode={ft.resumeMode}
|
|
4000
|
+
resumeSessionId={ft.resumeSessionId}
|
|
4001
|
+
profileEnv={ft.profileEnv}
|
|
4002
|
+
isPrimary={ft.isPrimary}
|
|
4003
|
+
skipPermissions={ft.skipPermissions}
|
|
4004
|
+
persistentSession={ft.persistentSession}
|
|
4005
|
+
boundSessionId={ft.boundSessionId}
|
|
4006
|
+
initialPos={ft.initialPos}
|
|
4007
|
+
onSessionReady={(name) => {
|
|
4008
|
+
if (workspaceId) wsApi(workspaceId, 'set_tmux_session', { agentId: ft.agentId, sessionName: name });
|
|
4009
|
+
setFloatingTerminals(prev => prev.map(t => t.agentId === ft.agentId ? { ...t, tmuxSession: name } : t));
|
|
4010
|
+
}}
|
|
4011
|
+
onClose={(killSession) => {
|
|
4012
|
+
setFloatingTerminals(prev => prev.filter(t => t.agentId !== ft.agentId));
|
|
4013
|
+
if (workspaceId) wsApi(workspaceId, 'close_terminal', { agentId: ft.agentId, kill: killSession });
|
|
4014
|
+
}}
|
|
4015
|
+
/>
|
|
4016
|
+
))}
|
|
4017
|
+
|
|
4018
|
+
{/* User input request from agent (via bus) */}
|
|
4019
|
+
{userInputRequest && workspaceId && (
|
|
4020
|
+
<RunPromptDialog
|
|
4021
|
+
agentLabel={`${agents.find(a => a.id === userInputRequest.fromAgent)?.label || 'Agent'} asks`}
|
|
4022
|
+
onRun={input => {
|
|
4023
|
+
// Send response to the requesting agent's target (Input node)
|
|
4024
|
+
wsApi(workspaceId, 'complete_input', {
|
|
4025
|
+
agentId: userInputRequest.agentId,
|
|
4026
|
+
content: input || userInputRequest.question,
|
|
4027
|
+
});
|
|
4028
|
+
setUserInputRequest(null);
|
|
4029
|
+
}}
|
|
4030
|
+
onCancel={() => setUserInputRequest(null)}
|
|
4031
|
+
/>
|
|
4032
|
+
)}
|
|
4033
|
+
</div>
|
|
4034
|
+
);
|
|
4035
|
+
}
|
|
4036
|
+
|
|
4037
|
+
const WorkspaceViewWithRef = forwardRef(WorkspaceViewInner);
|
|
4038
|
+
|
|
4039
|
+
// Wrap with ReactFlowProvider so useReactFlow works
|
|
4040
|
+
export default forwardRef<WorkspaceViewHandle, { projectPath: string; projectName: string; onClose: () => void }>(
|
|
4041
|
+
function WorkspaceView(props, ref) {
|
|
4042
|
+
return (
|
|
4043
|
+
<ReactFlowProvider>
|
|
4044
|
+
<WorkspaceViewWithRef {...props} ref={ref} />
|
|
4045
|
+
</ReactFlowProvider>
|
|
4046
|
+
);
|
|
4047
|
+
}
|
|
4048
|
+
);
|