@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,1618 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { useState, useEffect, useCallback, useRef, memo, lazy, Suspense } from 'react';
|
|
4
|
+
import { useSidebarResize } from '@/hooks/useSidebarResize';
|
|
5
|
+
|
|
6
|
+
import { TerminalSessionPickerLazy, fetchProjectSessions } from './TerminalLauncher';
|
|
7
|
+
const InlinePipelineView = lazy(() => import('./InlinePipelineView'));
|
|
8
|
+
const WorkspaceViewLazy = lazy(() => import('./WorkspaceView'));
|
|
9
|
+
const SessionViewLazy = lazy(() => import('./SessionView'));
|
|
10
|
+
|
|
11
|
+
// ─── Syntax highlighting ─────────────────────────────────
|
|
12
|
+
const KEYWORDS = new Set([
|
|
13
|
+
'import', 'export', 'from', 'const', 'let', 'var', 'function', 'return',
|
|
14
|
+
'if', 'else', 'for', 'while', 'switch', 'case', 'break', 'continue',
|
|
15
|
+
'class', 'extends', 'new', 'this', 'super', 'typeof', 'instanceof',
|
|
16
|
+
'try', 'catch', 'finally', 'throw', 'async', 'await', 'yield',
|
|
17
|
+
'default', 'interface', 'type', 'enum', 'implements', 'readonly',
|
|
18
|
+
'public', 'private', 'protected', 'static', 'abstract',
|
|
19
|
+
'true', 'false', 'null', 'undefined', 'void',
|
|
20
|
+
'def', 'self', 'None', 'True', 'False', 'lambda', 'with', 'as', 'in', 'not', 'and', 'or',
|
|
21
|
+
'package', 'final', 'synchronized', 'volatile', 'transient', 'native',
|
|
22
|
+
'throws', 'int', 'long', 'double', 'float', 'char', 'byte', 'short', 'boolean',
|
|
23
|
+
'override', 'struct', 'func', 'go', 'defer', 'select', 'chan', 'range',
|
|
24
|
+
'val', 'var', 'def', 'object', 'trait', 'sealed', 'implicit', 'lazy', 'match',
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
function highlightLine(line: string): React.ReactNode {
|
|
28
|
+
if (!line) return ' ';
|
|
29
|
+
const commentIdx = line.indexOf('//');
|
|
30
|
+
if (commentIdx === 0 || (commentIdx > 0 && /^\s*$/.test(line.slice(0, commentIdx)))) {
|
|
31
|
+
return <span className="text-gray-500 italic">{line}</span>;
|
|
32
|
+
}
|
|
33
|
+
const parts: React.ReactNode[] = [];
|
|
34
|
+
let lastIdx = 0;
|
|
35
|
+
const regex = /("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|`(?:[^`\\]|\\.)*`)|(\b\d+\.?\d*\b)|(\/\/.*$|#.*$)|(\b[A-Z_][A-Z_0-9]+\b)|(\b\w+\b)/g;
|
|
36
|
+
let match;
|
|
37
|
+
while ((match = regex.exec(line)) !== null) {
|
|
38
|
+
if (match.index > lastIdx) parts.push(line.slice(lastIdx, match.index));
|
|
39
|
+
if (match[1]) parts.push(<span key={match.index} className="text-green-400">{match[0]}</span>);
|
|
40
|
+
else if (match[2]) parts.push(<span key={match.index} className="text-orange-300">{match[0]}</span>);
|
|
41
|
+
else if (match[3]) parts.push(<span key={match.index} className="text-gray-500 italic">{match[0]}</span>);
|
|
42
|
+
else if (match[4]) parts.push(<span key={match.index} className="text-cyan-300">{match[0]}</span>);
|
|
43
|
+
else if (match[5] && KEYWORDS.has(match[5])) parts.push(<span key={match.index} className="text-purple-400">{match[0]}</span>);
|
|
44
|
+
else parts.push(match[0]);
|
|
45
|
+
lastIdx = match.index + match[0].length;
|
|
46
|
+
}
|
|
47
|
+
if (lastIdx < line.length) parts.push(line.slice(lastIdx));
|
|
48
|
+
return parts.length > 0 ? <>{parts}</> : line;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface GitInfo {
|
|
52
|
+
branch: string;
|
|
53
|
+
branches: { name: string; upstream: string; hash: string; current: boolean }[];
|
|
54
|
+
changes: { status: string; path: string }[];
|
|
55
|
+
remote: string;
|
|
56
|
+
ahead: number;
|
|
57
|
+
behind: number;
|
|
58
|
+
lastCommit: string;
|
|
59
|
+
log: { hash: string; message: string; author: string; date: string }[];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export default memo(function ProjectDetail({ projectPath, projectName, hasGit }: { projectPath: string; projectName: string; hasGit: boolean }) {
|
|
63
|
+
const { sidebarWidth, onSidebarDragStart } = useSidebarResize({ defaultWidth: 208, minWidth: 120, maxWidth: 400 });
|
|
64
|
+
const [gitInfo, setGitInfo] = useState<GitInfo | null>(null);
|
|
65
|
+
const [loading, setLoading] = useState(false);
|
|
66
|
+
const [commitMsg, setCommitMsg] = useState('');
|
|
67
|
+
const [gitLoading, setGitLoading] = useState(false);
|
|
68
|
+
const [gitResult, setGitResult] = useState<{ ok?: boolean; error?: string } | null>(null);
|
|
69
|
+
const [fileTree, setFileTree] = useState<any[]>([]);
|
|
70
|
+
const [selectedFile, setSelectedFile] = useState<string | null>(null);
|
|
71
|
+
const [fileContent, setFileContent] = useState<string | null>(null);
|
|
72
|
+
const [fileImageUrl, setFileImageUrl] = useState<string | null>(null);
|
|
73
|
+
const [fileBinaryInfo, setFileBinaryInfo] = useState<{ fileType: string; sizeLabel: string; message?: string } | null>(null);
|
|
74
|
+
const [fileLanguage, setFileLanguage] = useState('');
|
|
75
|
+
const [fileLoading, setFileLoading] = useState(false);
|
|
76
|
+
const [showLog, setShowLog] = useState(false);
|
|
77
|
+
const [changesExpanded, setChangesExpanded] = useState(false);
|
|
78
|
+
const [codeSearch, setCodeSearch] = useState('');
|
|
79
|
+
const [codeSearchResults, setCodeSearchResults] = useState<{ file: string; line: number; content: string }[]>([]);
|
|
80
|
+
const [codeSearching, setCodeSearching] = useState(false);
|
|
81
|
+
const [changesHeight, setChangesHeight] = useState(120);
|
|
82
|
+
const changesResizeRef = useRef<{ startY: number; origH: number } | null>(null);
|
|
83
|
+
const [diffContent, setDiffContent] = useState<string | null>(null);
|
|
84
|
+
const [diffFile, setDiffFile] = useState<string | null>(null);
|
|
85
|
+
const [projectSkills, setProjectSkills] = useState<{ name: string; displayName: string; type: string; scope: string; version: string; installedVersion: string; hasUpdate: boolean; source: 'registry' | 'local' }[]>([]);
|
|
86
|
+
const [showSkillsDetail, setShowSkillsDetail] = useState(false);
|
|
87
|
+
const [projectTab, setProjectTab] = useState<'workspace' | 'sessions' | 'code' | 'skills' | 'claudemd' | 'pipelines'>('code');
|
|
88
|
+
const wsViewRef = useRef<import('./WorkspaceView').WorkspaceViewHandle>(null);
|
|
89
|
+
// Pipeline bindings state
|
|
90
|
+
const [pipelineBindings, setPipelineBindings] = useState<{ id: number; workflowName: string; enabled: boolean; config: any; lastRunAt: string | null; nextRunAt: string | null }[]>([]);
|
|
91
|
+
const [pipelineRuns, setPipelineRuns] = useState<{ id: string; workflowName: string; pipelineId: string; status: string; summary: string; dedupKey: string | null; createdAt: string }[]>([]);
|
|
92
|
+
const [availableWorkflows, setAvailableWorkflows] = useState<{ name: string; description?: string; builtin?: boolean; type?: string }[]>([]);
|
|
93
|
+
const [boundSession, setBoundSession] = useState<{ sessionId: string } | null>(null);
|
|
94
|
+
const [showSessionPicker, setShowSessionPicker] = useState(false);
|
|
95
|
+
const [availableSessions, setAvailableSessions] = useState<{ id: string; modified: string; size: number }[]>([]);
|
|
96
|
+
const [expandedRunId, setExpandedRunId] = useState<string | null>(null);
|
|
97
|
+
const [expandedPipeline, setExpandedPipeline] = useState<any>(null);
|
|
98
|
+
const [showAddPipeline, setShowAddPipeline] = useState(false);
|
|
99
|
+
const [triggerInput, setTriggerInput] = useState<Record<string, string>>({});
|
|
100
|
+
const [runMenu, setRunMenu] = useState<string | null>(null); // workflowName of open run menu
|
|
101
|
+
const [issueInput, setIssueInput] = useState('');
|
|
102
|
+
const [claudeMdContent, setClaudeMdContent] = useState('');
|
|
103
|
+
const [claudeMdExists, setClaudeMdExists] = useState(false);
|
|
104
|
+
const [claudeTemplates, setClaudeTemplates] = useState<{ id: string; name: string; description: string; tags: string[]; builtin: boolean; content: string }[]>([]);
|
|
105
|
+
const [claudeInjectedIds, setClaudeInjectedIds] = useState<Set<string>>(new Set());
|
|
106
|
+
const [claudeEditing, setClaudeEditing] = useState(false);
|
|
107
|
+
const [claudeEditContent, setClaudeEditContent] = useState('');
|
|
108
|
+
const [claudeSelectedTemplate, setClaudeSelectedTemplate] = useState<string | null>(null);
|
|
109
|
+
const [expandedSkillItem, setExpandedSkillItem] = useState<string | null>(null);
|
|
110
|
+
const [skillItemFiles, setSkillItemFiles] = useState<{ path: string; size: number }[]>([]);
|
|
111
|
+
const [skillFileContent, setSkillFileContent] = useState('');
|
|
112
|
+
const [skillFileHash, setSkillFileHash] = useState('');
|
|
113
|
+
const [skillActivePath, setSkillActivePath] = useState('');
|
|
114
|
+
const [skillEditing, setSkillEditing] = useState(false);
|
|
115
|
+
const [skillEditContent, setSkillEditContent] = useState('');
|
|
116
|
+
const [skillSaving, setSkillSaving] = useState(false);
|
|
117
|
+
|
|
118
|
+
// Fetch git info
|
|
119
|
+
const fetchGitInfo = useCallback(async () => {
|
|
120
|
+
if (!hasGit) { setGitInfo(null); return; }
|
|
121
|
+
setLoading(true);
|
|
122
|
+
try {
|
|
123
|
+
const res = await fetch(`/api/git?dir=${encodeURIComponent(projectPath)}`);
|
|
124
|
+
const data = await res.json();
|
|
125
|
+
if (!data.error) setGitInfo(data);
|
|
126
|
+
else setGitInfo(null);
|
|
127
|
+
} catch { setGitInfo(null); }
|
|
128
|
+
setLoading(false);
|
|
129
|
+
}, [projectPath, hasGit]);
|
|
130
|
+
|
|
131
|
+
// Fetch file tree
|
|
132
|
+
const fetchTree = useCallback(async () => {
|
|
133
|
+
try {
|
|
134
|
+
const res = await fetch(`/api/code?dir=${encodeURIComponent(projectPath)}`);
|
|
135
|
+
const data = await res.json();
|
|
136
|
+
setFileTree(data.tree || []);
|
|
137
|
+
} catch { setFileTree([]); }
|
|
138
|
+
}, [projectPath]);
|
|
139
|
+
|
|
140
|
+
const openFile = useCallback(async (path: string) => {
|
|
141
|
+
setSelectedFile(path);
|
|
142
|
+
setDiffContent(null);
|
|
143
|
+
setDiffFile(null);
|
|
144
|
+
setFileContent(null);
|
|
145
|
+
setFileImageUrl(null);
|
|
146
|
+
setFileBinaryInfo(null);
|
|
147
|
+
setFileLoading(true);
|
|
148
|
+
try {
|
|
149
|
+
const res = await fetch(`/api/code?dir=${encodeURIComponent(projectPath)}&file=${encodeURIComponent(path)}`);
|
|
150
|
+
const data = await res.json();
|
|
151
|
+
if (data.image) {
|
|
152
|
+
setFileImageUrl(data.dataUrl);
|
|
153
|
+
} else if (data.binary) {
|
|
154
|
+
setFileBinaryInfo({ fileType: data.fileType, sizeLabel: data.sizeLabel, message: data.message });
|
|
155
|
+
} else if (data.tooLarge) {
|
|
156
|
+
setFileBinaryInfo({ fileType: '', sizeLabel: data.sizeLabel, message: data.message });
|
|
157
|
+
} else {
|
|
158
|
+
setFileContent(data.content || null);
|
|
159
|
+
setFileLanguage(data.language || '');
|
|
160
|
+
}
|
|
161
|
+
} catch { setFileContent(null); }
|
|
162
|
+
setFileLoading(false);
|
|
163
|
+
}, [projectPath]);
|
|
164
|
+
|
|
165
|
+
const openDiff = useCallback(async (filePath: string) => {
|
|
166
|
+
setDiffFile(filePath);
|
|
167
|
+
setDiffContent(null);
|
|
168
|
+
setSelectedFile(null);
|
|
169
|
+
setFileContent(null);
|
|
170
|
+
try {
|
|
171
|
+
const res = await fetch(`/api/code?dir=${encodeURIComponent(projectPath)}&diff=${encodeURIComponent(filePath)}`);
|
|
172
|
+
const data = await res.json();
|
|
173
|
+
setDiffContent(data.diff || 'No changes');
|
|
174
|
+
} catch { setDiffContent('(Failed to load diff)'); }
|
|
175
|
+
}, [projectPath]);
|
|
176
|
+
|
|
177
|
+
const toggleSkillItem = useCallback(async (name: string, type: string, scope: string) => {
|
|
178
|
+
if (expandedSkillItem === name) {
|
|
179
|
+
setExpandedSkillItem(null);
|
|
180
|
+
setSkillEditing(false);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
setExpandedSkillItem(name);
|
|
184
|
+
setSkillItemFiles([]);
|
|
185
|
+
setSkillFileContent('');
|
|
186
|
+
setSkillActivePath('');
|
|
187
|
+
setSkillEditing(false);
|
|
188
|
+
const isGlobal = scope === 'global';
|
|
189
|
+
const project = isGlobal ? '' : projectPath;
|
|
190
|
+
try {
|
|
191
|
+
const res = await fetch(`/api/skills/local?action=files&name=${encodeURIComponent(name)}&type=${type}&project=${encodeURIComponent(project)}`);
|
|
192
|
+
const data = await res.json();
|
|
193
|
+
setSkillItemFiles(data.files || []);
|
|
194
|
+
const firstMd = (data.files || []).find((f: any) => f.path.endsWith('.md'));
|
|
195
|
+
if (firstMd) loadSkillFile(name, type, firstMd.path, project);
|
|
196
|
+
} catch {}
|
|
197
|
+
}, [expandedSkillItem, projectPath]);
|
|
198
|
+
|
|
199
|
+
const loadSkillFile = async (name: string, type: string, path: string, project: string) => {
|
|
200
|
+
setSkillActivePath(path);
|
|
201
|
+
setSkillEditing(false);
|
|
202
|
+
setSkillFileContent('Loading...');
|
|
203
|
+
try {
|
|
204
|
+
const res = await fetch(`/api/skills/local?action=read&name=${encodeURIComponent(name)}&type=${type}&path=${encodeURIComponent(path)}&project=${encodeURIComponent(project)}`);
|
|
205
|
+
const data = await res.json();
|
|
206
|
+
setSkillFileContent(data.content || '');
|
|
207
|
+
setSkillFileHash(data.hash || '');
|
|
208
|
+
} catch { setSkillFileContent('(Failed to load)'); }
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
const saveSkillFile = async (name: string, type: string, path: string) => {
|
|
212
|
+
setSkillSaving(true);
|
|
213
|
+
const res = await fetch('/api/skills/local', {
|
|
214
|
+
method: 'POST',
|
|
215
|
+
headers: { 'Content-Type': 'application/json' },
|
|
216
|
+
body: JSON.stringify({ name, type, project: projectPath, path, content: skillEditContent, expectedHash: skillFileHash }),
|
|
217
|
+
});
|
|
218
|
+
const data = await res.json();
|
|
219
|
+
if (data.ok) {
|
|
220
|
+
setSkillFileContent(skillEditContent);
|
|
221
|
+
setSkillFileHash(data.hash);
|
|
222
|
+
setSkillEditing(false);
|
|
223
|
+
} else {
|
|
224
|
+
alert(data.error || 'Save failed');
|
|
225
|
+
}
|
|
226
|
+
setSkillSaving(false);
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
const handleUpdate = async (name: string) => {
|
|
230
|
+
const checkRes = await fetch('/api/skills', {
|
|
231
|
+
method: 'POST',
|
|
232
|
+
headers: { 'Content-Type': 'application/json' },
|
|
233
|
+
body: JSON.stringify({ action: 'check-modified', name }),
|
|
234
|
+
});
|
|
235
|
+
const checkData = await checkRes.json();
|
|
236
|
+
if (checkData.modified) {
|
|
237
|
+
if (!confirm('Local files have been modified. Overwrite with remote version?')) return;
|
|
238
|
+
}
|
|
239
|
+
const target = projectPath || 'global';
|
|
240
|
+
await fetch('/api/skills', {
|
|
241
|
+
method: 'POST',
|
|
242
|
+
headers: { 'Content-Type': 'application/json' },
|
|
243
|
+
body: JSON.stringify({ action: 'install', name, target, force: true }),
|
|
244
|
+
});
|
|
245
|
+
fetchProjectSkills();
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
const uninstallSkill = async (name: string, scope: string) => {
|
|
249
|
+
const target = scope === 'global' ? 'global' : projectPath;
|
|
250
|
+
const label = scope === 'global' ? 'global' : projectName;
|
|
251
|
+
if (!confirm(`Uninstall "${name}" from ${label}?`)) return;
|
|
252
|
+
await fetch('/api/skills', {
|
|
253
|
+
method: 'POST',
|
|
254
|
+
headers: { 'Content-Type': 'application/json' },
|
|
255
|
+
body: JSON.stringify({ action: 'uninstall', name, target }),
|
|
256
|
+
});
|
|
257
|
+
fetchProjectSkills();
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
const fetchPipelineBindings = useCallback(async () => {
|
|
261
|
+
try {
|
|
262
|
+
const res = await fetch(`/api/project-pipelines?project=${encodeURIComponent(projectPath)}`);
|
|
263
|
+
if (!res.ok) return;
|
|
264
|
+
const data = await res.json();
|
|
265
|
+
setPipelineBindings(data.bindings || []);
|
|
266
|
+
setPipelineRuns(data.runs || []);
|
|
267
|
+
setAvailableWorkflows(data.workflows || []);
|
|
268
|
+
} catch {}
|
|
269
|
+
}, [projectPath]);
|
|
270
|
+
|
|
271
|
+
const triggerProjectPipeline = async (workflowName: string, input?: Record<string, string>) => {
|
|
272
|
+
try {
|
|
273
|
+
const res = await fetch('/api/project-pipelines', {
|
|
274
|
+
method: 'POST',
|
|
275
|
+
headers: { 'Content-Type': 'application/json' },
|
|
276
|
+
body: JSON.stringify({ action: 'trigger', projectPath, projectName, workflowName, input }),
|
|
277
|
+
});
|
|
278
|
+
const data = await res.json();
|
|
279
|
+
if (data.ok) { fetchPipelineBindings(); }
|
|
280
|
+
else { alert(data.error || 'Failed'); }
|
|
281
|
+
} catch { alert('Failed to trigger pipeline'); }
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
const fetchClaudeMd = useCallback(async () => {
|
|
285
|
+
try {
|
|
286
|
+
const [contentRes, statusRes, listRes] = await Promise.all([
|
|
287
|
+
fetch(`/api/claude-templates?action=read-claude-md&project=${encodeURIComponent(projectPath)}`),
|
|
288
|
+
fetch(`/api/claude-templates?action=status&project=${encodeURIComponent(projectPath)}`),
|
|
289
|
+
fetch('/api/claude-templates?action=list'),
|
|
290
|
+
]);
|
|
291
|
+
const contentData = await contentRes.json();
|
|
292
|
+
setClaudeMdContent(contentData.content || '');
|
|
293
|
+
setClaudeMdExists(contentData.exists || false);
|
|
294
|
+
const statusData = await statusRes.json();
|
|
295
|
+
setClaudeInjectedIds(new Set((statusData.status || []).filter((s: any) => s.injected).map((s: any) => s.id)));
|
|
296
|
+
const listData = await listRes.json();
|
|
297
|
+
setClaudeTemplates(listData.templates || []);
|
|
298
|
+
} catch {}
|
|
299
|
+
}, [projectPath]);
|
|
300
|
+
|
|
301
|
+
const injectToProject = async (templateId: string) => {
|
|
302
|
+
await fetch('/api/claude-templates', {
|
|
303
|
+
method: 'POST',
|
|
304
|
+
headers: { 'Content-Type': 'application/json' },
|
|
305
|
+
body: JSON.stringify({ action: 'inject', templateId, projects: [projectPath] }),
|
|
306
|
+
});
|
|
307
|
+
fetchClaudeMd();
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
const removeFromProject = async (templateId: string) => {
|
|
311
|
+
if (!confirm(`Remove template from this project's CLAUDE.md?`)) return;
|
|
312
|
+
await fetch('/api/claude-templates', {
|
|
313
|
+
method: 'POST',
|
|
314
|
+
headers: { 'Content-Type': 'application/json' },
|
|
315
|
+
body: JSON.stringify({ action: 'remove', templateId, project: projectPath }),
|
|
316
|
+
});
|
|
317
|
+
fetchClaudeMd();
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
const saveClaudeMd = async (content: string) => {
|
|
321
|
+
await fetch('/api/claude-templates', {
|
|
322
|
+
method: 'POST',
|
|
323
|
+
headers: { 'Content-Type': 'application/json' },
|
|
324
|
+
body: JSON.stringify({ action: 'save-claude-md', project: projectPath, content }),
|
|
325
|
+
});
|
|
326
|
+
setClaudeMdContent(content);
|
|
327
|
+
setClaudeEditing(false);
|
|
328
|
+
fetchClaudeMd();
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
const fetchProjectSkills = useCallback(async () => {
|
|
332
|
+
try {
|
|
333
|
+
const [registryRes, localRes] = await Promise.all([
|
|
334
|
+
fetch('/api/skills'),
|
|
335
|
+
fetch(`/api/skills/local?action=scan&project=${encodeURIComponent(projectPath)}`),
|
|
336
|
+
]);
|
|
337
|
+
const registryData = await registryRes.json();
|
|
338
|
+
const localData = await localRes.json();
|
|
339
|
+
|
|
340
|
+
const registryItems = (registryData.skills || []).filter((s: any) =>
|
|
341
|
+
s.installedGlobal || (s.installedProjects || []).includes(projectPath)
|
|
342
|
+
).map((s: any) => ({
|
|
343
|
+
name: s.name,
|
|
344
|
+
displayName: s.displayName,
|
|
345
|
+
type: s.type || 'command',
|
|
346
|
+
version: s.version || '',
|
|
347
|
+
installedVersion: s.installedVersion || '',
|
|
348
|
+
hasUpdate: s.hasUpdate || false,
|
|
349
|
+
source: 'registry' as const,
|
|
350
|
+
scope: s.installedGlobal && (s.installedProjects || []).includes(projectPath) ? 'global + project'
|
|
351
|
+
: s.installedGlobal ? 'global'
|
|
352
|
+
: 'project',
|
|
353
|
+
}));
|
|
354
|
+
|
|
355
|
+
const registryNames = new Set(registryItems.map((s: any) => s.name));
|
|
356
|
+
const localItems = (localData.items || [])
|
|
357
|
+
.filter((item: any) => !registryNames.has(item.name))
|
|
358
|
+
.map((item: any) => ({
|
|
359
|
+
name: item.name,
|
|
360
|
+
displayName: item.name,
|
|
361
|
+
type: item.type,
|
|
362
|
+
version: '',
|
|
363
|
+
installedVersion: '',
|
|
364
|
+
hasUpdate: false,
|
|
365
|
+
source: 'local' as const,
|
|
366
|
+
scope: item.scope,
|
|
367
|
+
}));
|
|
368
|
+
|
|
369
|
+
const merged = new Map<string, any>();
|
|
370
|
+
for (const item of [...registryItems, ...localItems]) {
|
|
371
|
+
const existing = merged.get(item.name);
|
|
372
|
+
if (existing) {
|
|
373
|
+
if (existing.scope !== item.scope) {
|
|
374
|
+
existing.scope = existing.scope.includes(item.scope) ? existing.scope : `${existing.scope} + ${item.scope}`;
|
|
375
|
+
}
|
|
376
|
+
if (item.source === 'registry') {
|
|
377
|
+
Object.assign(existing, { ...item, scope: existing.scope });
|
|
378
|
+
}
|
|
379
|
+
} else {
|
|
380
|
+
merged.set(item.name, { ...item });
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
setProjectSkills([...merged.values()]);
|
|
384
|
+
} catch { setProjectSkills([]); }
|
|
385
|
+
}, [projectPath]);
|
|
386
|
+
|
|
387
|
+
// Git operations
|
|
388
|
+
const gitAction = async (action: string, extra?: any) => {
|
|
389
|
+
setGitLoading(true);
|
|
390
|
+
setGitResult(null);
|
|
391
|
+
try {
|
|
392
|
+
const res = await fetch('/api/git', {
|
|
393
|
+
method: 'POST',
|
|
394
|
+
headers: { 'Content-Type': 'application/json' },
|
|
395
|
+
body: JSON.stringify({ action, dir: projectPath, ...extra }),
|
|
396
|
+
});
|
|
397
|
+
const data = await res.json();
|
|
398
|
+
setGitResult(data);
|
|
399
|
+
if (data.ok) fetchGitInfo();
|
|
400
|
+
} catch (e: any) {
|
|
401
|
+
setGitResult({ error: e.message });
|
|
402
|
+
}
|
|
403
|
+
setGitLoading(false);
|
|
404
|
+
};
|
|
405
|
+
|
|
406
|
+
// Load essential data on mount (git + file tree only)
|
|
407
|
+
useEffect(() => {
|
|
408
|
+
setSelectedFile(null);
|
|
409
|
+
setFileContent(null);
|
|
410
|
+
setGitResult(null);
|
|
411
|
+
setCommitMsg('');
|
|
412
|
+
// Fetch git info and file tree in parallel
|
|
413
|
+
fetchGitInfo();
|
|
414
|
+
fetchTree();
|
|
415
|
+
// Fetch project-level fixed session
|
|
416
|
+
fetchBoundSession();
|
|
417
|
+
}, [projectPath, fetchGitInfo, fetchTree]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
418
|
+
|
|
419
|
+
const fetchBoundSession = useCallback(() => {
|
|
420
|
+
fetch(`/api/project-sessions?projectPath=${encodeURIComponent(projectPath)}`)
|
|
421
|
+
.then(r => r.json())
|
|
422
|
+
.then(data => {
|
|
423
|
+
if (data?.fixedSessionId) setBoundSession({ sessionId: data.fixedSessionId });
|
|
424
|
+
else setBoundSession(null);
|
|
425
|
+
})
|
|
426
|
+
.catch(() => {});
|
|
427
|
+
}, [projectPath]);
|
|
428
|
+
|
|
429
|
+
// Listen for session binding changes (from SessionView or other components)
|
|
430
|
+
useEffect(() => {
|
|
431
|
+
const handler = () => fetchBoundSession();
|
|
432
|
+
window.addEventListener('forge:session-bound', handler);
|
|
433
|
+
return () => window.removeEventListener('forge:session-bound', handler);
|
|
434
|
+
}, [fetchBoundSession]);
|
|
435
|
+
|
|
436
|
+
// Lazy load tab-specific data only when switching to that tab
|
|
437
|
+
useEffect(() => {
|
|
438
|
+
if (projectTab === 'skills') fetchProjectSkills();
|
|
439
|
+
if (projectTab === 'pipelines') fetchPipelineBindings();
|
|
440
|
+
if (projectTab === 'claudemd') fetchClaudeMd();
|
|
441
|
+
}, [projectTab, fetchProjectSkills, fetchPipelineBindings, fetchClaudeMd]);
|
|
442
|
+
|
|
443
|
+
// Auto-refresh pipeline runs while any is running
|
|
444
|
+
useEffect(() => {
|
|
445
|
+
if (projectTab !== 'pipelines') return;
|
|
446
|
+
const hasRunning = pipelineRuns.some(r => r.status === 'running');
|
|
447
|
+
if (!hasRunning) return;
|
|
448
|
+
const timer = setInterval(fetchPipelineBindings, 4000);
|
|
449
|
+
return () => clearInterval(timer);
|
|
450
|
+
}, [projectTab, pipelineRuns, fetchPipelineBindings]);
|
|
451
|
+
|
|
452
|
+
return (
|
|
453
|
+
<>
|
|
454
|
+
{/* Project header */}
|
|
455
|
+
<div className="px-4 py-2 border-b border-[var(--border)] shrink-0">
|
|
456
|
+
<div className="flex items-center gap-2">
|
|
457
|
+
<span className="text-sm font-semibold text-[var(--text-primary)]">{projectName}</span>
|
|
458
|
+
{gitInfo?.branch && (
|
|
459
|
+
<div className="relative">
|
|
460
|
+
<select
|
|
461
|
+
value={gitInfo.branch}
|
|
462
|
+
onChange={async (e) => {
|
|
463
|
+
const branch = e.target.value;
|
|
464
|
+
if (branch === gitInfo.branch) return;
|
|
465
|
+
try {
|
|
466
|
+
await fetch('/api/git', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'checkout', dir: projectPath, branch }) });
|
|
467
|
+
fetchGitInfo(); fetchTree();
|
|
468
|
+
} catch {}
|
|
469
|
+
}}
|
|
470
|
+
className="text-[9px] text-[var(--accent)] bg-[var(--accent)]/10 px-1.5 py-0.5 rounded border-none cursor-pointer appearance-none pr-4 focus:outline-none"
|
|
471
|
+
style={{ backgroundImage: 'url("data:image/svg+xml,%3Csvg xmlns=\'http://www.w3.org/2000/svg\' width=\'8\' height=\'8\' viewBox=\'0 0 8 8\'%3E%3Cpath d=\'M0 2l4 4 4-4z\' fill=\'%2358a6ff\'/%3E%3C/svg%3E")', backgroundRepeat: 'no-repeat', backgroundPosition: 'right 4px center' }}
|
|
472
|
+
>
|
|
473
|
+
{(gitInfo.branches || []).map(b => (
|
|
474
|
+
<option key={b.name} value={b.name}>{b.name}{b.current ? ' ●' : ''}</option>
|
|
475
|
+
))}
|
|
476
|
+
</select>
|
|
477
|
+
</div>
|
|
478
|
+
)}
|
|
479
|
+
{gitInfo?.ahead ? <span className="text-[9px] text-green-400">↑{gitInfo.ahead}</span> : null}
|
|
480
|
+
{gitInfo?.behind ? <span className="text-[9px] text-yellow-400">↓{gitInfo.behind}</span> : null}
|
|
481
|
+
{/* Action buttons */}
|
|
482
|
+
<div className="flex items-center gap-1.5 ml-auto">
|
|
483
|
+
{/* Open Terminal with agent selection */}
|
|
484
|
+
<AgentTerminalButton projectPath={projectPath} projectName={projectName} />
|
|
485
|
+
<button
|
|
486
|
+
onClick={() => { fetchGitInfo(); fetchTree(); if (selectedFile) openFile(selectedFile); }}
|
|
487
|
+
className="text-[10px] text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
|
488
|
+
title="Refresh"
|
|
489
|
+
>
|
|
490
|
+
↻
|
|
491
|
+
</button>
|
|
492
|
+
</div>
|
|
493
|
+
</div>
|
|
494
|
+
<div className="text-[9px] text-[var(--text-secondary)] mt-0.5 flex items-center gap-2 flex-wrap">
|
|
495
|
+
<span>{projectPath}</span>
|
|
496
|
+
{gitInfo?.remote && (
|
|
497
|
+
<span>{gitInfo.remote.replace(/^https?:\/\//, '').replace(/^git@github\.com:/, 'github.com/').replace(/\.git$/, '')}</span>
|
|
498
|
+
)}
|
|
499
|
+
{/* Fixed session: show current or "set session" */}
|
|
500
|
+
<span className="inline-flex items-center gap-1 px-1.5 py-0 rounded bg-[#f0883e]/10 text-[#f0883e] relative">
|
|
501
|
+
{boundSession?.sessionId ? (
|
|
502
|
+
<>
|
|
503
|
+
<span className="text-[8px]">session:</span>
|
|
504
|
+
<button onClick={() => {
|
|
505
|
+
fetch(`/api/claude-sessions/${encodeURIComponent(projectName)}`)
|
|
506
|
+
.then(r => r.json())
|
|
507
|
+
.then(data => { if (Array.isArray(data)) setAvailableSessions(data.map((s: any) => ({ id: s.sessionId || s.id, modified: s.modified || '', size: s.size || 0 }))); })
|
|
508
|
+
.catch(() => {});
|
|
509
|
+
setShowSessionPicker(!showSessionPicker);
|
|
510
|
+
}}
|
|
511
|
+
className="font-mono text-[8px] hover:text-white underline decoration-dotted" title="Click to change">
|
|
512
|
+
{boundSession.sessionId.slice(0, 8)}
|
|
513
|
+
</button>
|
|
514
|
+
<button onClick={() => navigator.clipboard.writeText(boundSession.sessionId)}
|
|
515
|
+
className="text-[7px] hover:text-white" title={boundSession.sessionId}>copy</button>
|
|
516
|
+
</>
|
|
517
|
+
) : (
|
|
518
|
+
<button onClick={() => {
|
|
519
|
+
fetch(`/api/claude-sessions/${encodeURIComponent(projectName)}`)
|
|
520
|
+
.then(r => r.json())
|
|
521
|
+
.then(data => { if (Array.isArray(data)) setAvailableSessions(data.map((s: any) => ({ id: s.sessionId || s.id, modified: s.modified || '', size: s.size || 0 }))); })
|
|
522
|
+
.catch(() => {});
|
|
523
|
+
setShowSessionPicker(!showSessionPicker);
|
|
524
|
+
}}
|
|
525
|
+
className="text-[8px] hover:text-white underline decoration-dotted">
|
|
526
|
+
set session
|
|
527
|
+
</button>
|
|
528
|
+
)}
|
|
529
|
+
{/* Session picker dropdown */}
|
|
530
|
+
{showSessionPicker && (
|
|
531
|
+
<div className="absolute top-full left-0 mt-1 z-50 bg-[var(--bg-primary)] border border-[var(--border)] rounded-lg shadow-xl p-2 min-w-[280px]"
|
|
532
|
+
onClick={e => e.stopPropagation()}>
|
|
533
|
+
<div className="text-[9px] text-[var(--text-secondary)] mb-1.5 font-medium">{boundSession?.sessionId ? 'Change session' : 'Select session'}</div>
|
|
534
|
+
<div className="max-h-48 overflow-y-auto space-y-1">
|
|
535
|
+
{availableSessions.map(s => (
|
|
536
|
+
<button key={s.id} onClick={async () => {
|
|
537
|
+
try {
|
|
538
|
+
await fetch('/api/project-sessions', {
|
|
539
|
+
method: 'POST',
|
|
540
|
+
headers: { 'Content-Type': 'application/json' },
|
|
541
|
+
body: JSON.stringify({ projectPath, fixedSessionId: s.id }),
|
|
542
|
+
});
|
|
543
|
+
setBoundSession({ sessionId: s.id });
|
|
544
|
+
window.dispatchEvent(new Event('forge:session-bound'));
|
|
545
|
+
} catch {}
|
|
546
|
+
setShowSessionPicker(false);
|
|
547
|
+
}}
|
|
548
|
+
className={`w-full text-left px-2 py-1.5 rounded text-[9px] flex items-center gap-2 ${
|
|
549
|
+
s.id === boundSession?.sessionId
|
|
550
|
+
? 'bg-[#f0883e]/20 text-[#f0883e]'
|
|
551
|
+
: 'hover:bg-[var(--bg-secondary)] text-[var(--text-secondary)]'
|
|
552
|
+
}`}>
|
|
553
|
+
<span className="font-mono">{s.id.slice(0, 8)}</span>
|
|
554
|
+
{s.id === boundSession?.sessionId && <span className="text-[7px]">current</span>}
|
|
555
|
+
{s.modified && <span className="text-[8px] opacity-60">{(() => { const d = Date.now() - new Date(s.modified).getTime(); return d < 3600000 ? `${Math.floor(d/60000)}m ago` : d < 86400000 ? `${Math.floor(d/3600000)}h ago` : new Date(s.modified).toLocaleDateString(); })()}</span>}
|
|
556
|
+
{s.size > 0 && <span className="text-[8px] opacity-60">{s.size < 1048576 ? `${(s.size/1024).toFixed(0)}KB` : `${(s.size/1048576).toFixed(1)}MB`}</span>}
|
|
557
|
+
</button>
|
|
558
|
+
))}
|
|
559
|
+
{availableSessions.length === 0 && <div className="text-[9px] text-[var(--text-secondary)] px-2 py-1">No sessions found</div>}
|
|
560
|
+
</div>
|
|
561
|
+
<button onClick={() => setShowSessionPicker(false)}
|
|
562
|
+
className="w-full mt-1.5 text-[8px] text-[var(--text-secondary)] hover:text-[var(--text-primary)] pt-1 border-t border-[var(--border)]">Close</button>
|
|
563
|
+
</div>
|
|
564
|
+
)}
|
|
565
|
+
</span>
|
|
566
|
+
</div>
|
|
567
|
+
{/* Tab switcher */}
|
|
568
|
+
<div className="flex items-center gap-2 mt-1.5">
|
|
569
|
+
<div className="flex bg-[var(--bg-tertiary)] rounded p-0.5">
|
|
570
|
+
<button
|
|
571
|
+
onClick={() => setProjectTab('code')}
|
|
572
|
+
className={`text-[9px] px-2 py-0.5 rounded transition-colors ${
|
|
573
|
+
projectTab === 'code' ? 'bg-[var(--bg-secondary)] text-[var(--text-primary)] shadow-sm' : 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
|
|
574
|
+
}`}
|
|
575
|
+
>Code</button>
|
|
576
|
+
<button
|
|
577
|
+
onClick={() => setProjectTab('workspace')}
|
|
578
|
+
className={`text-[9px] px-2 py-0.5 rounded transition-colors ${
|
|
579
|
+
projectTab === 'workspace' ? 'bg-[var(--accent)]/20 text-[var(--accent)] shadow-sm' : 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
|
|
580
|
+
}`}
|
|
581
|
+
>🔨 Workspace</button>
|
|
582
|
+
<button
|
|
583
|
+
onClick={() => setProjectTab('sessions')}
|
|
584
|
+
className={`text-[9px] px-2 py-0.5 rounded transition-colors ${
|
|
585
|
+
projectTab === 'sessions' ? 'bg-[var(--bg-secondary)] text-[var(--text-primary)] shadow-sm' : 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
|
|
586
|
+
}`}
|
|
587
|
+
>Sessions</button>
|
|
588
|
+
<button
|
|
589
|
+
onClick={() => setProjectTab('skills')}
|
|
590
|
+
className={`text-[9px] px-2 py-0.5 rounded transition-colors ${
|
|
591
|
+
projectTab === 'skills' ? 'bg-[var(--bg-secondary)] text-[var(--text-primary)] shadow-sm' : 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
|
|
592
|
+
}`}
|
|
593
|
+
>
|
|
594
|
+
Skills & Cmds
|
|
595
|
+
{projectSkills.length > 0 && <span className="ml-1 text-[8px] text-[var(--text-secondary)]">({projectSkills.length})</span>}
|
|
596
|
+
{projectSkills.some(s => s.hasUpdate) && <span className="ml-1 text-[8px] text-[var(--yellow)]">!</span>}
|
|
597
|
+
</button>
|
|
598
|
+
<button
|
|
599
|
+
onClick={() => setProjectTab('claudemd')}
|
|
600
|
+
className={`text-[9px] px-2 py-0.5 rounded transition-colors ${
|
|
601
|
+
projectTab === 'claudemd' ? 'bg-[var(--bg-secondary)] text-[var(--text-primary)] shadow-sm' : 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
|
|
602
|
+
}`}
|
|
603
|
+
>
|
|
604
|
+
CLAUDE.md
|
|
605
|
+
{claudeMdExists && <span className="ml-1 text-[8px] text-[var(--green)]">•</span>}
|
|
606
|
+
</button>
|
|
607
|
+
<button
|
|
608
|
+
onClick={() => setProjectTab('pipelines')}
|
|
609
|
+
className={`text-[9px] px-2 py-0.5 rounded transition-colors ${
|
|
610
|
+
projectTab === 'pipelines' ? 'bg-[var(--bg-secondary)] text-[var(--text-primary)] shadow-sm' : 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
|
|
611
|
+
}`}
|
|
612
|
+
>
|
|
613
|
+
Pipelines
|
|
614
|
+
{pipelineBindings.length > 0 && <span className="ml-1 text-[8px] text-[var(--text-secondary)]">({pipelineBindings.length})</span>}
|
|
615
|
+
</button>
|
|
616
|
+
</div>
|
|
617
|
+
</div>
|
|
618
|
+
{projectTab === 'code' && gitInfo?.lastCommit && (
|
|
619
|
+
<div className="flex items-center gap-2 mt-0.5">
|
|
620
|
+
<span className="text-[9px] text-[var(--text-secondary)] font-mono truncate">{gitInfo.lastCommit}</span>
|
|
621
|
+
<button
|
|
622
|
+
onClick={() => setShowLog(v => !v)}
|
|
623
|
+
className={`text-[9px] px-1.5 py-0.5 rounded shrink-0 ${showLog ? 'text-white bg-[var(--accent)]/30' : 'text-[var(--accent)] hover:bg-[var(--accent)]/10'}`}
|
|
624
|
+
>
|
|
625
|
+
History
|
|
626
|
+
</button>
|
|
627
|
+
</div>
|
|
628
|
+
)}
|
|
629
|
+
</div>
|
|
630
|
+
|
|
631
|
+
{/* Git log */}
|
|
632
|
+
{projectTab === 'code' && showLog && gitInfo?.log && gitInfo.log.length > 0 && (
|
|
633
|
+
<div className="max-h-48 overflow-y-auto border-b border-[var(--border)] bg-[var(--bg-tertiary)]">
|
|
634
|
+
{gitInfo.log.map(c => (
|
|
635
|
+
<div key={c.hash} className="px-4 py-1.5 border-b border-[var(--border)]/30 text-xs flex items-start gap-2">
|
|
636
|
+
<span className="font-mono text-[var(--accent)] shrink-0 text-[10px]">{c.hash}</span>
|
|
637
|
+
<span className="text-[var(--text-primary)] truncate flex-1">{c.message}</span>
|
|
638
|
+
<span className="text-[var(--text-secondary)] text-[9px] shrink-0">{c.author}</span>
|
|
639
|
+
<span className="text-[var(--text-secondary)] text-[9px] shrink-0 w-16 text-right">{c.date}</span>
|
|
640
|
+
</div>
|
|
641
|
+
))}
|
|
642
|
+
</div>
|
|
643
|
+
)}
|
|
644
|
+
|
|
645
|
+
{/* Workspace tab */}
|
|
646
|
+
{projectTab === 'workspace' && (
|
|
647
|
+
<div className="flex-1 flex min-h-0 overflow-hidden">
|
|
648
|
+
<Suspense fallback={<div className="flex-1 flex items-center justify-center text-[var(--text-secondary)]">Loading...</div>}>
|
|
649
|
+
<WorkspaceViewLazy
|
|
650
|
+
ref={wsViewRef}
|
|
651
|
+
projectPath={projectPath}
|
|
652
|
+
projectName={projectName}
|
|
653
|
+
onClose={() => setProjectTab('code')}
|
|
654
|
+
/>
|
|
655
|
+
</Suspense>
|
|
656
|
+
</div>
|
|
657
|
+
)}
|
|
658
|
+
|
|
659
|
+
{/* Sessions tab */}
|
|
660
|
+
{projectTab === 'sessions' && (
|
|
661
|
+
<div className="flex-1 flex min-h-0 overflow-hidden">
|
|
662
|
+
<Suspense fallback={<div className="flex-1 flex items-center justify-center text-[var(--text-secondary)]">Loading...</div>}>
|
|
663
|
+
<SessionViewLazy
|
|
664
|
+
projectName={projectName}
|
|
665
|
+
projects={[{ name: projectName, path: projectPath, language: null }]}
|
|
666
|
+
singleProject
|
|
667
|
+
/>
|
|
668
|
+
</Suspense>
|
|
669
|
+
</div>
|
|
670
|
+
)}
|
|
671
|
+
|
|
672
|
+
{/* Code content area */}
|
|
673
|
+
{projectTab === 'code' && <div className="flex-1 flex min-h-0 overflow-hidden">
|
|
674
|
+
{/* File tree + search */}
|
|
675
|
+
<div style={{ width: sidebarWidth }} className="flex flex-col shrink-0">
|
|
676
|
+
{/* Search input */}
|
|
677
|
+
<div className="p-1 border-b border-[var(--border)]">
|
|
678
|
+
<input
|
|
679
|
+
value={codeSearch}
|
|
680
|
+
onChange={e => setCodeSearch(e.target.value)}
|
|
681
|
+
onKeyDown={async e => {
|
|
682
|
+
if (e.key === 'Enter' && codeSearch.trim()) {
|
|
683
|
+
setCodeSearching(true);
|
|
684
|
+
try {
|
|
685
|
+
const res = await fetch(`/api/code?dir=${encodeURIComponent(projectPath)}&search=${encodeURIComponent(codeSearch.trim())}`);
|
|
686
|
+
const data = await res.json();
|
|
687
|
+
setCodeSearchResults(data.matches || []);
|
|
688
|
+
} catch { setCodeSearchResults([]); }
|
|
689
|
+
setCodeSearching(false);
|
|
690
|
+
}
|
|
691
|
+
if (e.key === 'Escape') { setCodeSearch(''); setCodeSearchResults([]); }
|
|
692
|
+
}}
|
|
693
|
+
placeholder="Search code... (Enter)"
|
|
694
|
+
className="w-full text-[10px] bg-[var(--bg-tertiary)] border border-[var(--border)] rounded px-2 py-1 text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] placeholder-[var(--text-secondary)]"
|
|
695
|
+
/>
|
|
696
|
+
</div>
|
|
697
|
+
{/* Search results */}
|
|
698
|
+
{codeSearchResults.length > 0 && (
|
|
699
|
+
<div className="overflow-y-auto border-b border-[var(--border)] max-h-60">
|
|
700
|
+
<div className="px-2 py-0.5 text-[8px] text-[var(--text-secondary)] bg-[var(--bg-tertiary)] sticky top-0 flex items-center justify-between">
|
|
701
|
+
<span>{codeSearchResults.length} results for "{codeSearch}"</span>
|
|
702
|
+
<button onClick={() => { setCodeSearch(''); setCodeSearchResults([]); }} className="text-[var(--text-secondary)] hover:text-[var(--text-primary)]">✕</button>
|
|
703
|
+
</div>
|
|
704
|
+
{codeSearchResults.map((r, i) => (
|
|
705
|
+
<div key={i} onClick={() => openFile(r.file)} className="px-2 py-0.5 cursor-pointer hover:bg-[var(--bg-tertiary)] border-b border-[var(--border)]/30">
|
|
706
|
+
<div className="text-[9px] text-[var(--accent)] truncate">{r.file}:{r.line}</div>
|
|
707
|
+
<div className="text-[8px] text-[var(--text-secondary)] font-mono truncate">{r.content}</div>
|
|
708
|
+
</div>
|
|
709
|
+
))}
|
|
710
|
+
</div>
|
|
711
|
+
)}
|
|
712
|
+
{codeSearching && <div className="px-2 py-1 text-[9px] text-[var(--text-secondary)]">Searching...</div>}
|
|
713
|
+
{/* File tree */}
|
|
714
|
+
<div className="overflow-y-auto flex-1 p-1">
|
|
715
|
+
{fileTree.map((node: any) => (
|
|
716
|
+
<FileTreeNode key={node.path} node={node} depth={0} selected={selectedFile} onSelect={openFile} />
|
|
717
|
+
))}
|
|
718
|
+
</div>
|
|
719
|
+
</div>
|
|
720
|
+
|
|
721
|
+
{/* Sidebar resize handle */}
|
|
722
|
+
<div
|
|
723
|
+
onMouseDown={onSidebarDragStart}
|
|
724
|
+
className="w-1 bg-[var(--border)] cursor-col-resize shrink-0 hover:bg-[var(--accent)]/50 transition-colors"
|
|
725
|
+
/>
|
|
726
|
+
|
|
727
|
+
{/* File content */}
|
|
728
|
+
<div className="flex-1 min-w-0 overflow-auto bg-[var(--bg-primary)]" style={{ width: 0 }}>
|
|
729
|
+
{/* Diff view */}
|
|
730
|
+
{diffContent !== null && diffFile ? (
|
|
731
|
+
<>
|
|
732
|
+
<div className="px-3 py-1 border-b border-[var(--border)] text-[10px] sticky top-0 bg-[var(--bg-primary)] z-10 flex items-center gap-2">
|
|
733
|
+
<span className="text-[var(--yellow)]">DIFF</span>
|
|
734
|
+
<span className="text-[var(--text-secondary)]">{diffFile}</span>
|
|
735
|
+
<button onClick={() => { if (diffFile) openFile(diffFile); }} className="ml-auto text-[9px] text-[var(--accent)] hover:underline">Open Source</button>
|
|
736
|
+
</div>
|
|
737
|
+
<pre className="p-4 text-[12px] leading-[1.5] font-mono whitespace-pre" style={{ fontFamily: 'Menlo, Monaco, "Courier New", monospace', tabSize: 2 }}>
|
|
738
|
+
{diffContent.split('\n').map((line, i) => {
|
|
739
|
+
const color = line.startsWith('+') ? 'text-green-400 bg-green-900/20'
|
|
740
|
+
: line.startsWith('-') ? 'text-red-400 bg-red-900/20'
|
|
741
|
+
: line.startsWith('@@') ? 'text-cyan-400'
|
|
742
|
+
: line.startsWith('diff') || line.startsWith('index') ? 'text-[var(--text-secondary)]'
|
|
743
|
+
: 'text-[var(--text-primary)]';
|
|
744
|
+
return <div key={i} className={`${color} px-2`}>{line || ' '}</div>;
|
|
745
|
+
})}
|
|
746
|
+
</pre>
|
|
747
|
+
</>
|
|
748
|
+
) : fileLoading ? (
|
|
749
|
+
<div className="h-full flex items-center justify-center text-xs text-[var(--text-secondary)]">Loading...</div>
|
|
750
|
+
) : selectedFile && fileImageUrl ? (
|
|
751
|
+
<>
|
|
752
|
+
<div className="px-3 py-1 border-b border-[var(--border)] text-[10px] text-[var(--text-secondary)] sticky top-0 bg-[var(--bg-primary)] z-10">{selectedFile}</div>
|
|
753
|
+
<div className="flex-1 flex items-center justify-center p-4 overflow-auto">
|
|
754
|
+
<img src={fileImageUrl} alt={selectedFile} className="max-w-full max-h-full object-contain rounded" />
|
|
755
|
+
</div>
|
|
756
|
+
</>
|
|
757
|
+
) : selectedFile && fileBinaryInfo ? (
|
|
758
|
+
<>
|
|
759
|
+
<div className="px-3 py-1 border-b border-[var(--border)] text-[10px] text-[var(--text-secondary)] sticky top-0 bg-[var(--bg-primary)] z-10">{selectedFile}</div>
|
|
760
|
+
<div className="flex-1 flex flex-col items-center justify-center gap-2 text-[var(--text-secondary)]">
|
|
761
|
+
<span className="text-2xl">📄</span>
|
|
762
|
+
<span className="text-xs">{fileBinaryInfo.fileType ? fileBinaryInfo.fileType.toUpperCase() + ' file' : 'File'} — {fileBinaryInfo.sizeLabel}</span>
|
|
763
|
+
{fileBinaryInfo.message && <span className="text-[10px]">{fileBinaryInfo.message}</span>}
|
|
764
|
+
<span className="text-[10px]">Binary file cannot be displayed</span>
|
|
765
|
+
</div>
|
|
766
|
+
</>
|
|
767
|
+
) : selectedFile && fileContent !== null ? (
|
|
768
|
+
<>
|
|
769
|
+
<div className="px-3 py-1 border-b border-[var(--border)] text-[10px] text-[var(--text-secondary)] sticky top-0 bg-[var(--bg-primary)] z-10">{selectedFile}</div>
|
|
770
|
+
<pre className="p-4 text-[12px] leading-[1.5] font-mono text-[var(--text-primary)] whitespace-pre" style={{ fontFamily: 'Menlo, Monaco, "Courier New", monospace', tabSize: 2 }}>
|
|
771
|
+
{fileContent.split('\n').map((line, i) => (
|
|
772
|
+
<div key={i} className="flex hover:bg-[var(--bg-tertiary)]/50">
|
|
773
|
+
<span className="select-none text-[var(--text-secondary)]/40 text-right pr-4 w-10 shrink-0">{i + 1}</span>
|
|
774
|
+
<span className="flex-1">{highlightLine(line)}</span>
|
|
775
|
+
</div>
|
|
776
|
+
))}
|
|
777
|
+
</pre>
|
|
778
|
+
</>
|
|
779
|
+
) : (
|
|
780
|
+
<div className="flex-1 flex items-center justify-center text-xs text-[var(--text-secondary)]">
|
|
781
|
+
Select a file to view
|
|
782
|
+
</div>
|
|
783
|
+
)}
|
|
784
|
+
</div>
|
|
785
|
+
</div>}
|
|
786
|
+
|
|
787
|
+
{/* Skills & Commands tab */}
|
|
788
|
+
{projectTab === 'skills' && (
|
|
789
|
+
<div className="flex-1 flex min-h-0 overflow-hidden">
|
|
790
|
+
{/* Left: skill/command tree */}
|
|
791
|
+
<div style={{ width: sidebarWidth }} className="overflow-y-auto p-1 shrink-0">
|
|
792
|
+
{projectSkills.length === 0 ? (
|
|
793
|
+
<p className="text-[9px] text-[var(--text-secondary)] p-2">No skills or commands installed</p>
|
|
794
|
+
) : (
|
|
795
|
+
projectSkills.map(s => (
|
|
796
|
+
<div key={`${s.name}-${s.scope}-${s.source}`}>
|
|
797
|
+
<button
|
|
798
|
+
onClick={() => toggleSkillItem(s.name, s.type, s.scope)}
|
|
799
|
+
className={`w-full text-left px-2 py-1 text-[10px] rounded flex items-center gap-1.5 group ${
|
|
800
|
+
expandedSkillItem === s.name ? 'bg-[var(--accent)]/10 text-[var(--accent)]' : 'text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)]'
|
|
801
|
+
}`}
|
|
802
|
+
>
|
|
803
|
+
<span className="text-[8px] text-[var(--text-secondary)]">{expandedSkillItem === s.name ? '▾' : '▸'}</span>
|
|
804
|
+
<span className={`text-[7px] px-1 rounded font-medium shrink-0 ${
|
|
805
|
+
s.type === 'skill' ? 'bg-purple-500/20 text-purple-400' : 'bg-blue-500/20 text-blue-400'
|
|
806
|
+
}`}>{s.type === 'skill' ? 'S' : 'C'}</span>
|
|
807
|
+
<span className="truncate flex-1">{s.name}</span>
|
|
808
|
+
<span className={`text-[7px] shrink-0 ${s.scope === 'global' ? 'text-green-400' : 'text-[var(--accent)]'}`}>{s.scope === 'global' ? 'G' : s.scope === 'project' ? 'P' : 'G+P'}</span>
|
|
809
|
+
{s.hasUpdate && <span className="text-[7px] text-[var(--yellow)] shrink-0">!</span>}
|
|
810
|
+
{s.source === 'local' && <span className="text-[7px] text-[var(--text-secondary)] shrink-0">local</span>}
|
|
811
|
+
{s.source === 'registry' && <span className="text-[7px] text-[var(--accent)] shrink-0">mkt</span>}
|
|
812
|
+
<span
|
|
813
|
+
onClick={(e) => { e.stopPropagation(); uninstallSkill(s.name, s.scope); }}
|
|
814
|
+
className="text-[8px] text-[var(--text-secondary)] hover:text-[var(--red)] shrink-0 opacity-0 group-hover:opacity-100 cursor-pointer"
|
|
815
|
+
>x</span>
|
|
816
|
+
</button>
|
|
817
|
+
{/* Expanded file list */}
|
|
818
|
+
{expandedSkillItem === s.name && skillItemFiles.length > 0 && (
|
|
819
|
+
<div className="ml-4">
|
|
820
|
+
{skillItemFiles.map(f => (
|
|
821
|
+
<button
|
|
822
|
+
key={f.path}
|
|
823
|
+
onClick={() => loadSkillFile(s.name, s.type, f.path, s.scope === 'global' ? '' : projectPath)}
|
|
824
|
+
className={`w-full text-left px-2 py-0.5 text-[9px] rounded truncate ${
|
|
825
|
+
skillActivePath === f.path ? 'text-[var(--accent)] bg-[var(--accent)]/10' : 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
|
|
826
|
+
}`}
|
|
827
|
+
title={f.path}
|
|
828
|
+
>
|
|
829
|
+
{f.path.split('/').pop()}
|
|
830
|
+
</button>
|
|
831
|
+
))}
|
|
832
|
+
</div>
|
|
833
|
+
)}
|
|
834
|
+
</div>
|
|
835
|
+
))
|
|
836
|
+
)}
|
|
837
|
+
</div>
|
|
838
|
+
|
|
839
|
+
{/* Sidebar resize handle */}
|
|
840
|
+
<div
|
|
841
|
+
onMouseDown={onSidebarDragStart}
|
|
842
|
+
className="w-1 bg-[var(--border)] cursor-col-resize shrink-0 hover:bg-[var(--accent)]/50 transition-colors"
|
|
843
|
+
/>
|
|
844
|
+
|
|
845
|
+
{/* Right: file content / editor */}
|
|
846
|
+
<div className="flex-1 min-w-0 flex flex-col overflow-hidden bg-[var(--bg-primary)]">
|
|
847
|
+
{skillActivePath ? (
|
|
848
|
+
<>
|
|
849
|
+
<div className="flex items-center gap-2 px-3 py-1 border-b border-[var(--border)] shrink-0">
|
|
850
|
+
<span className="text-[10px] text-[var(--text-secondary)] font-mono truncate flex-1">{skillActivePath}</span>
|
|
851
|
+
{expandedSkillItem && (() => {
|
|
852
|
+
const s = projectSkills.find(x => x.name === expandedSkillItem);
|
|
853
|
+
return s && (
|
|
854
|
+
<div className="flex items-center gap-2 shrink-0">
|
|
855
|
+
{s.version && <span className="text-[8px] text-[var(--text-secondary)] font-mono">v{s.installedVersion || s.version}</span>}
|
|
856
|
+
{s.hasUpdate && (
|
|
857
|
+
<button
|
|
858
|
+
onClick={() => handleUpdate(s.name)}
|
|
859
|
+
className="text-[8px] px-1.5 py-0.5 rounded bg-[var(--yellow)]/20 text-[var(--yellow)] hover:bg-[var(--yellow)]/30"
|
|
860
|
+
>Update → v{s.version}</button>
|
|
861
|
+
)}
|
|
862
|
+
</div>
|
|
863
|
+
);
|
|
864
|
+
})()}
|
|
865
|
+
{!skillEditing ? (
|
|
866
|
+
<button
|
|
867
|
+
onClick={() => { setSkillEditing(true); setSkillEditContent(skillFileContent); }}
|
|
868
|
+
className="text-[9px] text-[var(--accent)] hover:underline shrink-0"
|
|
869
|
+
>Edit</button>
|
|
870
|
+
) : (
|
|
871
|
+
<div className="flex gap-1 shrink-0">
|
|
872
|
+
<button
|
|
873
|
+
onClick={() => { if (expandedSkillItem) saveSkillFile(expandedSkillItem, projectSkills.find(x => x.name === expandedSkillItem)?.type || 'command', skillActivePath); }}
|
|
874
|
+
disabled={skillSaving}
|
|
875
|
+
className="text-[9px] px-2 py-0.5 bg-[var(--accent)] text-white rounded hover:opacity-90 disabled:opacity-50"
|
|
876
|
+
>{skillSaving ? '...' : 'Save'}</button>
|
|
877
|
+
<button
|
|
878
|
+
onClick={() => setSkillEditing(false)}
|
|
879
|
+
className="text-[9px] text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
|
880
|
+
>Cancel</button>
|
|
881
|
+
</div>
|
|
882
|
+
)}
|
|
883
|
+
</div>
|
|
884
|
+
<div className="flex-1 overflow-auto">
|
|
885
|
+
{skillEditing ? (
|
|
886
|
+
<textarea
|
|
887
|
+
value={skillEditContent}
|
|
888
|
+
onChange={e => setSkillEditContent(e.target.value)}
|
|
889
|
+
className="w-full h-full p-3 text-[11px] font-mono bg-[var(--bg-primary)] text-[var(--text-primary)] border-none outline-none resize-none"
|
|
890
|
+
spellCheck={false}
|
|
891
|
+
/>
|
|
892
|
+
) : (
|
|
893
|
+
<pre className="p-3 text-[11px] leading-[1.5] font-mono text-[var(--text-primary)] whitespace-pre-wrap break-words">
|
|
894
|
+
{skillFileContent}
|
|
895
|
+
</pre>
|
|
896
|
+
)}
|
|
897
|
+
</div>
|
|
898
|
+
</>
|
|
899
|
+
) : (
|
|
900
|
+
<div className="flex-1 flex items-center justify-center text-xs text-[var(--text-secondary)]">
|
|
901
|
+
Select a skill or command to view
|
|
902
|
+
</div>
|
|
903
|
+
)}
|
|
904
|
+
</div>
|
|
905
|
+
</div>
|
|
906
|
+
)}
|
|
907
|
+
|
|
908
|
+
{/* CLAUDE.md tab */}
|
|
909
|
+
{projectTab === 'claudemd' && (
|
|
910
|
+
<div className="flex-1 flex min-h-0 overflow-hidden">
|
|
911
|
+
{/* Left: templates list */}
|
|
912
|
+
<div style={{ width: sidebarWidth }} className="overflow-y-auto shrink-0 flex flex-col">
|
|
913
|
+
<button
|
|
914
|
+
onClick={() => { setClaudeSelectedTemplate(null); setClaudeEditing(false); }}
|
|
915
|
+
className={`w-full px-2 py-1.5 border-b border-[var(--border)] text-[10px] text-left flex items-center gap-1 ${
|
|
916
|
+
!claudeSelectedTemplate && !claudeEditing ? 'text-[var(--accent)] bg-[var(--accent)]/5' : 'text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)]'
|
|
917
|
+
}`}
|
|
918
|
+
>
|
|
919
|
+
<span className="font-mono">CLAUDE.md</span>
|
|
920
|
+
{claudeMdExists && <span className="text-[var(--green)] text-[8px]">•</span>}
|
|
921
|
+
</button>
|
|
922
|
+
<div className="px-2 py-1 border-b border-[var(--border)] text-[8px] text-[var(--text-secondary)] uppercase">Templates</div>
|
|
923
|
+
<div className="flex-1 overflow-y-auto">
|
|
924
|
+
{claudeTemplates.map(t => {
|
|
925
|
+
const injected = claudeInjectedIds.has(t.id);
|
|
926
|
+
const isSelected = claudeSelectedTemplate === t.id;
|
|
927
|
+
return (
|
|
928
|
+
<div
|
|
929
|
+
key={t.id}
|
|
930
|
+
className={`px-2 py-1.5 border-b border-[var(--border)]/30 cursor-pointer ${isSelected ? 'bg-[var(--accent)]/10' : 'hover:bg-[var(--bg-tertiary)]'}`}
|
|
931
|
+
onClick={() => setClaudeSelectedTemplate(isSelected ? null : t.id)}
|
|
932
|
+
>
|
|
933
|
+
<div className="flex items-center gap-1.5">
|
|
934
|
+
<span className="text-[10px] text-[var(--text-primary)] truncate flex-1">{t.name}</span>
|
|
935
|
+
{t.builtin && <span className="text-[7px] text-[var(--text-secondary)]">built-in</span>}
|
|
936
|
+
{injected ? (
|
|
937
|
+
<button
|
|
938
|
+
onClick={(e) => { e.stopPropagation(); removeFromProject(t.id); }}
|
|
939
|
+
className="text-[7px] px-1 rounded bg-green-500/10 text-green-400 hover:bg-red-500/10 hover:text-red-400"
|
|
940
|
+
title="Remove from CLAUDE.md"
|
|
941
|
+
>added</button>
|
|
942
|
+
) : (
|
|
943
|
+
<button
|
|
944
|
+
onClick={(e) => { e.stopPropagation(); injectToProject(t.id); }}
|
|
945
|
+
className="text-[7px] px-1 rounded bg-[var(--accent)]/10 text-[var(--accent)] hover:bg-[var(--accent)]/20"
|
|
946
|
+
title="Add to CLAUDE.md"
|
|
947
|
+
>+ add</button>
|
|
948
|
+
)}
|
|
949
|
+
</div>
|
|
950
|
+
<p className="text-[8px] text-[var(--text-secondary)] mt-0.5 line-clamp-1">{t.description}</p>
|
|
951
|
+
</div>
|
|
952
|
+
);
|
|
953
|
+
})}
|
|
954
|
+
</div>
|
|
955
|
+
</div>
|
|
956
|
+
|
|
957
|
+
{/* Sidebar resize handle */}
|
|
958
|
+
<div
|
|
959
|
+
onMouseDown={onSidebarDragStart}
|
|
960
|
+
className="w-1 bg-[var(--border)] cursor-col-resize shrink-0 hover:bg-[var(--accent)]/50 transition-colors"
|
|
961
|
+
/>
|
|
962
|
+
|
|
963
|
+
{/* Right: CLAUDE.md content or template preview */}
|
|
964
|
+
<div className="flex-1 min-w-0 flex flex-col overflow-hidden bg-[var(--bg-primary)]">
|
|
965
|
+
{/* Header bar */}
|
|
966
|
+
<div className="flex items-center gap-2 px-3 py-1.5 border-b border-[var(--border)] shrink-0">
|
|
967
|
+
{claudeSelectedTemplate ? (
|
|
968
|
+
<>
|
|
969
|
+
<span className="text-[10px] text-[var(--text-secondary)]">Preview:</span>
|
|
970
|
+
<span className="text-[10px] text-[var(--text-primary)] font-semibold">{claudeTemplates.find(t => t.id === claudeSelectedTemplate)?.name}</span>
|
|
971
|
+
<button
|
|
972
|
+
onClick={() => setClaudeSelectedTemplate(null)}
|
|
973
|
+
className="text-[9px] text-[var(--text-secondary)] hover:text-[var(--text-primary)] ml-auto"
|
|
974
|
+
>Show CLAUDE.md</button>
|
|
975
|
+
</>
|
|
976
|
+
) : (
|
|
977
|
+
<>
|
|
978
|
+
<span className="text-[10px] text-[var(--text-primary)] font-mono">CLAUDE.md</span>
|
|
979
|
+
{!claudeMdExists && <span className="text-[8px] text-[var(--yellow)]">not created</span>}
|
|
980
|
+
<div className="flex items-center gap-1 ml-auto">
|
|
981
|
+
{!claudeEditing ? (
|
|
982
|
+
<button
|
|
983
|
+
onClick={() => { setClaudeEditing(true); setClaudeEditContent(claudeMdContent); }}
|
|
984
|
+
className="text-[9px] text-[var(--accent)] hover:underline"
|
|
985
|
+
>Edit</button>
|
|
986
|
+
) : (
|
|
987
|
+
<>
|
|
988
|
+
<button
|
|
989
|
+
onClick={() => saveClaudeMd(claudeEditContent)}
|
|
990
|
+
className="text-[9px] px-2 py-0.5 bg-[var(--accent)] text-white rounded hover:opacity-90"
|
|
991
|
+
>Save</button>
|
|
992
|
+
<button
|
|
993
|
+
onClick={() => setClaudeEditing(false)}
|
|
994
|
+
className="text-[9px] text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
|
995
|
+
>Cancel</button>
|
|
996
|
+
</>
|
|
997
|
+
)}
|
|
998
|
+
</div>
|
|
999
|
+
</>
|
|
1000
|
+
)}
|
|
1001
|
+
</div>
|
|
1002
|
+
|
|
1003
|
+
{/* Content */}
|
|
1004
|
+
<div className="flex-1 overflow-auto" style={{ width: 0, minWidth: '100%' }}>
|
|
1005
|
+
{claudeSelectedTemplate ? (
|
|
1006
|
+
<pre className="p-3 text-[11px] font-mono text-[var(--text-primary)] whitespace-pre-wrap break-words">
|
|
1007
|
+
{claudeTemplates.find(t => t.id === claudeSelectedTemplate)?.content || ''}
|
|
1008
|
+
</pre>
|
|
1009
|
+
) : claudeEditing ? (
|
|
1010
|
+
<textarea
|
|
1011
|
+
value={claudeEditContent}
|
|
1012
|
+
onChange={e => setClaudeEditContent(e.target.value)}
|
|
1013
|
+
className="w-full h-full p-3 text-[11px] font-mono bg-[var(--bg-primary)] text-[var(--text-primary)] border-none outline-none resize-none"
|
|
1014
|
+
spellCheck={false}
|
|
1015
|
+
/>
|
|
1016
|
+
) : (
|
|
1017
|
+
<pre className="p-3 text-[11px] font-mono text-[var(--text-primary)] whitespace-pre-wrap break-words">
|
|
1018
|
+
{claudeMdContent || '(Empty — add templates or edit directly)'}
|
|
1019
|
+
</pre>
|
|
1020
|
+
)}
|
|
1021
|
+
</div>
|
|
1022
|
+
</div>
|
|
1023
|
+
</div>
|
|
1024
|
+
)}
|
|
1025
|
+
|
|
1026
|
+
{/* Pipelines tab */}
|
|
1027
|
+
{projectTab === 'pipelines' && (
|
|
1028
|
+
<div className="flex-1 overflow-auto p-4 space-y-4">
|
|
1029
|
+
{/* Bound workflows */}
|
|
1030
|
+
<div className="space-y-3">
|
|
1031
|
+
<div className="flex items-center gap-2">
|
|
1032
|
+
<span className="text-xs font-semibold text-[var(--text-primary)]">Bound Pipelines</span>
|
|
1033
|
+
<button
|
|
1034
|
+
onClick={() => setShowAddPipeline(v => !v)}
|
|
1035
|
+
className="text-[9px] px-2 py-0.5 bg-[var(--accent)] text-white rounded hover:opacity-90 ml-auto"
|
|
1036
|
+
>+ Add</button>
|
|
1037
|
+
</div>
|
|
1038
|
+
|
|
1039
|
+
{/* Add pipeline form */}
|
|
1040
|
+
{showAddPipeline && (
|
|
1041
|
+
<div className="border border-[var(--border)] rounded p-2 space-y-2">
|
|
1042
|
+
{availableWorkflows.filter(w => !pipelineBindings.find(b => b.workflowName === w.name)).map(w => (
|
|
1043
|
+
<button
|
|
1044
|
+
key={w.name}
|
|
1045
|
+
onClick={async () => {
|
|
1046
|
+
await fetch('/api/project-pipelines', {
|
|
1047
|
+
method: 'POST',
|
|
1048
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1049
|
+
body: JSON.stringify({ action: 'add', projectPath, projectName, workflowName: w.name }),
|
|
1050
|
+
});
|
|
1051
|
+
setShowAddPipeline(false);
|
|
1052
|
+
fetchPipelineBindings();
|
|
1053
|
+
}}
|
|
1054
|
+
className="w-full text-left px-2 py-1.5 rounded hover:bg-[var(--bg-tertiary)] text-[10px] flex items-center gap-2"
|
|
1055
|
+
>
|
|
1056
|
+
{w.builtin && <span className="text-[7px] text-[var(--text-secondary)]">⚙</span>}
|
|
1057
|
+
<span className="text-[var(--text-primary)]">{w.name}</span>
|
|
1058
|
+
{w.description && <span className="text-[var(--text-secondary)] truncate ml-auto text-[8px]">{w.description}</span>}
|
|
1059
|
+
</button>
|
|
1060
|
+
))}
|
|
1061
|
+
{availableWorkflows.filter(w => !pipelineBindings.find(b => b.workflowName === w.name)).length === 0 && (
|
|
1062
|
+
<p className="text-[9px] text-[var(--text-secondary)] p-2">All workflows already bound</p>
|
|
1063
|
+
)}
|
|
1064
|
+
</div>
|
|
1065
|
+
)}
|
|
1066
|
+
|
|
1067
|
+
{/* Bound pipeline list */}
|
|
1068
|
+
{pipelineBindings.length === 0 ? (
|
|
1069
|
+
<p className="text-[10px] text-[var(--text-secondary)]">No pipelines bound. Click + Add to attach a workflow.</p>
|
|
1070
|
+
) : (
|
|
1071
|
+
pipelineBindings.map(b => (
|
|
1072
|
+
<div key={b.workflowName} className="border border-[var(--border)] rounded p-3 space-y-2">
|
|
1073
|
+
<div className="flex items-center gap-2">
|
|
1074
|
+
<span className="text-[11px] font-semibold text-[var(--text-primary)]">{b.workflowName}</span>
|
|
1075
|
+
<label className="flex items-center gap-1 text-[9px] text-[var(--text-secondary)] cursor-pointer ml-auto">
|
|
1076
|
+
<input type="checkbox" checked={b.enabled} onChange={async (e) => {
|
|
1077
|
+
await fetch('/api/project-pipelines', {
|
|
1078
|
+
method: 'POST',
|
|
1079
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1080
|
+
body: JSON.stringify({ action: 'update', projectPath, workflowName: b.workflowName, enabled: e.target.checked }),
|
|
1081
|
+
});
|
|
1082
|
+
fetchPipelineBindings();
|
|
1083
|
+
}} className="accent-[var(--accent)]" />
|
|
1084
|
+
Enabled
|
|
1085
|
+
</label>
|
|
1086
|
+
<div className="relative">
|
|
1087
|
+
<button
|
|
1088
|
+
onClick={() => {
|
|
1089
|
+
const isIssueWf = b.workflowName === 'issue-auto-fix' || b.workflowName === 'issue-fix-and-review';
|
|
1090
|
+
if (!isIssueWf) {
|
|
1091
|
+
triggerProjectPipeline(b.workflowName, triggerInput);
|
|
1092
|
+
} else {
|
|
1093
|
+
setRunMenu(runMenu === b.workflowName ? null : b.workflowName);
|
|
1094
|
+
setIssueInput('');
|
|
1095
|
+
}
|
|
1096
|
+
}}
|
|
1097
|
+
className="text-[9px] px-2 py-0.5 border border-[var(--accent)] text-[var(--accent)] rounded hover:bg-[var(--accent)] hover:text-white"
|
|
1098
|
+
>Run</button>
|
|
1099
|
+
{runMenu === b.workflowName && (
|
|
1100
|
+
<div className="absolute top-full right-0 mt-1 z-20 bg-[var(--bg-secondary)] border border-[var(--border)] rounded shadow-lg p-2 space-y-2 w-[200px]">
|
|
1101
|
+
<button
|
|
1102
|
+
onClick={async () => {
|
|
1103
|
+
setRunMenu(null);
|
|
1104
|
+
try {
|
|
1105
|
+
const res = await fetch('/api/project-pipelines', {
|
|
1106
|
+
method: 'POST',
|
|
1107
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1108
|
+
body: JSON.stringify({ action: 'scan-now', projectPath, projectName, workflowName: b.workflowName }),
|
|
1109
|
+
});
|
|
1110
|
+
const data = await res.json();
|
|
1111
|
+
if (data.error) alert(`Scan error: ${data.error}`);
|
|
1112
|
+
else alert(`Scanned ${data.total} issues, triggered ${data.triggered} fix${data.pending > 0 ? ` (${data.pending} more pending)` : ''}`);
|
|
1113
|
+
fetchPipelineBindings();
|
|
1114
|
+
} catch { alert('Scan failed'); }
|
|
1115
|
+
}}
|
|
1116
|
+
className="w-full text-[9px] px-2 py-1.5 rounded border border-green-500/50 text-green-400 hover:bg-green-500/10 font-medium"
|
|
1117
|
+
>Auto Scan — fix all new issues</button>
|
|
1118
|
+
<div className="border-t border-[var(--border)]/50 my-1" />
|
|
1119
|
+
<div className="flex items-center gap-1">
|
|
1120
|
+
<input
|
|
1121
|
+
type="text"
|
|
1122
|
+
value={issueInput}
|
|
1123
|
+
onChange={e => setIssueInput(e.target.value)}
|
|
1124
|
+
placeholder="Issue #"
|
|
1125
|
+
className="flex-1 bg-[var(--bg-tertiary)] border border-[var(--border)] rounded px-2 py-1 text-[9px] text-[var(--text-primary)]"
|
|
1126
|
+
onKeyDown={e => {
|
|
1127
|
+
if (e.key === 'Enter' && issueInput.trim()) {
|
|
1128
|
+
setRunMenu(null);
|
|
1129
|
+
triggerProjectPipeline(b.workflowName, {
|
|
1130
|
+
...triggerInput,
|
|
1131
|
+
issue_id: issueInput.trim(),
|
|
1132
|
+
base_branch: b.config.baseBranch || 'auto-detect',
|
|
1133
|
+
});
|
|
1134
|
+
}
|
|
1135
|
+
}}
|
|
1136
|
+
autoFocus
|
|
1137
|
+
/>
|
|
1138
|
+
<button
|
|
1139
|
+
onClick={() => {
|
|
1140
|
+
if (!issueInput.trim()) return;
|
|
1141
|
+
setRunMenu(null);
|
|
1142
|
+
triggerProjectPipeline(b.workflowName, {
|
|
1143
|
+
...triggerInput,
|
|
1144
|
+
issue_id: issueInput.trim(),
|
|
1145
|
+
base_branch: b.config.baseBranch || 'auto-detect',
|
|
1146
|
+
});
|
|
1147
|
+
}}
|
|
1148
|
+
className="text-[9px] px-2 py-1 bg-[var(--accent)] text-white rounded hover:opacity-80"
|
|
1149
|
+
>Fix</button>
|
|
1150
|
+
</div>
|
|
1151
|
+
</div>
|
|
1152
|
+
)}
|
|
1153
|
+
</div>
|
|
1154
|
+
<button
|
|
1155
|
+
onClick={async () => {
|
|
1156
|
+
if (!confirm(`Remove "${b.workflowName}" from this project?`)) return;
|
|
1157
|
+
await fetch('/api/project-pipelines', {
|
|
1158
|
+
method: 'POST',
|
|
1159
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1160
|
+
body: JSON.stringify({ action: 'remove', projectPath, workflowName: b.workflowName }),
|
|
1161
|
+
});
|
|
1162
|
+
fetchPipelineBindings();
|
|
1163
|
+
}}
|
|
1164
|
+
className="text-[9px] text-[var(--red)] hover:underline"
|
|
1165
|
+
>Remove</button>
|
|
1166
|
+
</div>
|
|
1167
|
+
{/* Schedule config */}
|
|
1168
|
+
<div className="flex items-center gap-2 text-[9px]">
|
|
1169
|
+
<span className="text-[var(--text-secondary)]">Schedule:</span>
|
|
1170
|
+
<select
|
|
1171
|
+
value={b.config.interval || 0}
|
|
1172
|
+
onChange={async (e) => {
|
|
1173
|
+
const interval = Number(e.target.value);
|
|
1174
|
+
const newConfig = { ...b.config, interval };
|
|
1175
|
+
await fetch('/api/project-pipelines', {
|
|
1176
|
+
method: 'POST',
|
|
1177
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1178
|
+
body: JSON.stringify({ action: 'update', projectPath, workflowName: b.workflowName, config: newConfig }),
|
|
1179
|
+
});
|
|
1180
|
+
fetchPipelineBindings();
|
|
1181
|
+
}}
|
|
1182
|
+
className="bg-[var(--bg-secondary)] border border-[var(--border)] rounded px-1.5 py-0.5 text-[9px] text-[var(--text-primary)]"
|
|
1183
|
+
>
|
|
1184
|
+
<option value={0}>Manual only</option>
|
|
1185
|
+
<option value={15}>Every 15 min</option>
|
|
1186
|
+
<option value={30}>Every 30 min</option>
|
|
1187
|
+
<option value={60}>Every 1 hour</option>
|
|
1188
|
+
<option value={120}>Every 2 hours</option>
|
|
1189
|
+
<option value={360}>Every 6 hours</option>
|
|
1190
|
+
<option value={720}>Every 12 hours</option>
|
|
1191
|
+
<option value={1440}>Every 24 hours</option>
|
|
1192
|
+
</select>
|
|
1193
|
+
{b.config.interval > 0 && b.nextRunAt && (
|
|
1194
|
+
<span className="text-[8px] text-[var(--text-secondary)]">
|
|
1195
|
+
Next: {new Date(b.nextRunAt).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}
|
|
1196
|
+
</span>
|
|
1197
|
+
)}
|
|
1198
|
+
{b.lastRunAt && (
|
|
1199
|
+
<span className="text-[8px] text-[var(--text-secondary)] ml-auto">
|
|
1200
|
+
Last: {new Date(b.lastRunAt).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}
|
|
1201
|
+
</span>
|
|
1202
|
+
)}
|
|
1203
|
+
</div>
|
|
1204
|
+
{/* Issue scan config (for issue-fix-and-review workflow) */}
|
|
1205
|
+
{(b.workflowName === 'issue-auto-fix' || b.workflowName === 'issue-fix-and-review') && (
|
|
1206
|
+
<div className="space-y-1.5 pt-1 border-t border-[var(--border)]/30">
|
|
1207
|
+
<div className="text-[8px] text-[var(--text-secondary)]">
|
|
1208
|
+
{b.config.interval > 0
|
|
1209
|
+
? 'Scheduled mode: auto-scans GitHub issues and fixes new ones'
|
|
1210
|
+
: 'Requires: gh auth login (run in terminal first)'}
|
|
1211
|
+
</div>
|
|
1212
|
+
<div className="flex items-center gap-2 text-[9px]">
|
|
1213
|
+
<label className="text-[var(--text-secondary)]">Labels:</label>
|
|
1214
|
+
<input
|
|
1215
|
+
type="text"
|
|
1216
|
+
defaultValue={(b.config.labels || []).join(', ')}
|
|
1217
|
+
placeholder="bug, autofix (empty = all)"
|
|
1218
|
+
onBlur={async (e) => {
|
|
1219
|
+
const labels = e.target.value.split(',').map((s: string) => s.trim()).filter(Boolean);
|
|
1220
|
+
const newConfig = { ...b.config, labels };
|
|
1221
|
+
await fetch('/api/project-pipelines', {
|
|
1222
|
+
method: 'POST',
|
|
1223
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1224
|
+
body: JSON.stringify({ action: 'update', projectPath, workflowName: b.workflowName, config: newConfig }),
|
|
1225
|
+
});
|
|
1226
|
+
fetchPipelineBindings();
|
|
1227
|
+
}}
|
|
1228
|
+
className="flex-1 bg-[var(--bg-secondary)] border border-[var(--border)] rounded px-1.5 py-0.5 text-[9px] text-[var(--text-primary)]"
|
|
1229
|
+
/>
|
|
1230
|
+
<label className="text-[var(--text-secondary)]">Base:</label>
|
|
1231
|
+
<input
|
|
1232
|
+
type="text"
|
|
1233
|
+
defaultValue={b.config.baseBranch || ''}
|
|
1234
|
+
placeholder="auto-detect"
|
|
1235
|
+
onBlur={async (e) => {
|
|
1236
|
+
const newConfig = { ...b.config, baseBranch: e.target.value.trim() || undefined };
|
|
1237
|
+
await fetch('/api/project-pipelines', {
|
|
1238
|
+
method: 'POST',
|
|
1239
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1240
|
+
body: JSON.stringify({ action: 'update', projectPath, workflowName: b.workflowName, config: newConfig }),
|
|
1241
|
+
});
|
|
1242
|
+
fetchPipelineBindings();
|
|
1243
|
+
}}
|
|
1244
|
+
className="w-20 bg-[var(--bg-secondary)] border border-[var(--border)] rounded px-1.5 py-0.5 text-[9px] text-[var(--text-primary)]"
|
|
1245
|
+
/>
|
|
1246
|
+
</div>
|
|
1247
|
+
</div>
|
|
1248
|
+
)}
|
|
1249
|
+
</div>
|
|
1250
|
+
))
|
|
1251
|
+
)}
|
|
1252
|
+
</div>
|
|
1253
|
+
|
|
1254
|
+
{/* Execution history */}
|
|
1255
|
+
{pipelineRuns.length > 0 && (
|
|
1256
|
+
<div className="border-t border-[var(--border)] pt-3">
|
|
1257
|
+
<div className="text-[9px] text-[var(--text-secondary)] uppercase mb-2">Execution History</div>
|
|
1258
|
+
<div className="border border-[var(--border)] rounded overflow-hidden">
|
|
1259
|
+
{pipelineRuns.map(run => (
|
|
1260
|
+
<div key={run.id} className="border-b border-[var(--border)]/30 last:border-b-0">
|
|
1261
|
+
<div className="flex items-start gap-2 px-3 py-2 text-[10px]">
|
|
1262
|
+
<span className={`shrink-0 mt-0.5 ${
|
|
1263
|
+
run.status === 'done' ? 'text-green-400' : run.status === 'failed' ? 'text-red-400' : run.status === 'skipped' ? 'text-gray-400' : 'text-yellow-400'
|
|
1264
|
+
}`}>{run.status === 'running' ? '●' : '●'}</span>
|
|
1265
|
+
<div className="flex-1 min-w-0">
|
|
1266
|
+
<div className="flex items-center gap-2">
|
|
1267
|
+
<span className="text-[var(--text-primary)] font-medium">{run.workflowName}</span>
|
|
1268
|
+
{run.dedupKey && (
|
|
1269
|
+
<span className="text-[8px] text-[var(--accent)] font-mono">{run.dedupKey.replace('issue:', '#')}</span>
|
|
1270
|
+
)}
|
|
1271
|
+
<button
|
|
1272
|
+
onClick={async () => {
|
|
1273
|
+
if (expandedRunId === run.pipelineId) {
|
|
1274
|
+
setExpandedRunId(null);
|
|
1275
|
+
setExpandedPipeline(null);
|
|
1276
|
+
} else {
|
|
1277
|
+
setExpandedRunId(run.pipelineId);
|
|
1278
|
+
const res = await fetch(`/api/pipelines/${run.pipelineId}`);
|
|
1279
|
+
if (res.ok) setExpandedPipeline(await res.json());
|
|
1280
|
+
}
|
|
1281
|
+
}}
|
|
1282
|
+
className={`text-[8px] font-mono hover:underline ${expandedRunId === run.pipelineId ? 'text-[var(--accent)] font-bold' : 'text-[var(--accent)]'}`}
|
|
1283
|
+
title="Expand / View in Pipelines"
|
|
1284
|
+
>{run.status === 'running' ? '▾ ' : ''}{run.pipelineId.slice(0, 8)}</button>
|
|
1285
|
+
<button
|
|
1286
|
+
onClick={() => window.dispatchEvent(new CustomEvent('forge:navigate', { detail: { view: 'pipelines', pipelineId: run.pipelineId } }))}
|
|
1287
|
+
className="text-[7px] text-[var(--text-secondary)] hover:text-[var(--accent)]"
|
|
1288
|
+
title="Open in Pipeline page"
|
|
1289
|
+
>↗</button>
|
|
1290
|
+
<span className="text-[8px] text-[var(--text-secondary)] ml-auto">{new Date(run.createdAt).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}</span>
|
|
1291
|
+
</div>
|
|
1292
|
+
{!expandedRunId && run.summary && (
|
|
1293
|
+
<pre className="text-[9px] text-[var(--text-secondary)] mt-1 whitespace-pre-wrap break-words line-clamp-3">{run.summary}</pre>
|
|
1294
|
+
)}
|
|
1295
|
+
</div>
|
|
1296
|
+
<div className="flex items-center gap-1 shrink-0">
|
|
1297
|
+
{run.status === 'running' && (
|
|
1298
|
+
<button
|
|
1299
|
+
onClick={async () => {
|
|
1300
|
+
await fetch(`/api/pipelines/${run.pipelineId}`, {
|
|
1301
|
+
method: 'POST',
|
|
1302
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1303
|
+
body: JSON.stringify({ action: 'cancel' }),
|
|
1304
|
+
});
|
|
1305
|
+
fetchPipelineBindings();
|
|
1306
|
+
}}
|
|
1307
|
+
className="text-[8px] text-red-400 hover:underline"
|
|
1308
|
+
>Cancel</button>
|
|
1309
|
+
)}
|
|
1310
|
+
{run.status === 'failed' && run.dedupKey && (
|
|
1311
|
+
<button
|
|
1312
|
+
onClick={async () => {
|
|
1313
|
+
await fetch('/api/project-pipelines', {
|
|
1314
|
+
method: 'POST',
|
|
1315
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1316
|
+
body: JSON.stringify({ action: 'reset-dedup', projectPath, workflowName: run.workflowName, dedupKey: run.dedupKey }),
|
|
1317
|
+
});
|
|
1318
|
+
await fetch('/api/project-pipelines', {
|
|
1319
|
+
method: 'POST',
|
|
1320
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1321
|
+
body: JSON.stringify({ action: 'delete-run', id: run.id }),
|
|
1322
|
+
});
|
|
1323
|
+
fetchPipelineBindings();
|
|
1324
|
+
}}
|
|
1325
|
+
className="text-[8px] text-[var(--accent)] hover:underline"
|
|
1326
|
+
>Retry</button>
|
|
1327
|
+
)}
|
|
1328
|
+
<button
|
|
1329
|
+
onClick={async () => {
|
|
1330
|
+
if (!confirm('Delete this run?')) return;
|
|
1331
|
+
await fetch('/api/project-pipelines', {
|
|
1332
|
+
method: 'POST',
|
|
1333
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1334
|
+
body: JSON.stringify({ action: 'delete-run', id: run.id }),
|
|
1335
|
+
});
|
|
1336
|
+
if (expandedRunId === run.pipelineId) { setExpandedRunId(null); setExpandedPipeline(null); }
|
|
1337
|
+
fetchPipelineBindings();
|
|
1338
|
+
}}
|
|
1339
|
+
className="text-[8px] text-[var(--text-secondary)] hover:text-[var(--red)]"
|
|
1340
|
+
>×</button>
|
|
1341
|
+
</div>
|
|
1342
|
+
</div>
|
|
1343
|
+
{/* Expanded inline pipeline view */}
|
|
1344
|
+
{expandedRunId === run.pipelineId && expandedPipeline && (
|
|
1345
|
+
<Suspense fallback={<div className="p-2 text-[9px] text-[var(--text-secondary)]">Loading...</div>}>
|
|
1346
|
+
<InlinePipelineView
|
|
1347
|
+
pipeline={expandedPipeline}
|
|
1348
|
+
onRefresh={async () => {
|
|
1349
|
+
const res = await fetch(`/api/pipelines/${run.pipelineId}`);
|
|
1350
|
+
if (res.ok) setExpandedPipeline(await res.json());
|
|
1351
|
+
}}
|
|
1352
|
+
/>
|
|
1353
|
+
</Suspense>
|
|
1354
|
+
)}
|
|
1355
|
+
</div>
|
|
1356
|
+
))}
|
|
1357
|
+
</div>
|
|
1358
|
+
</div>
|
|
1359
|
+
)}
|
|
1360
|
+
</div>
|
|
1361
|
+
)}
|
|
1362
|
+
|
|
1363
|
+
{/* Git panel — bottom (code tab only) */}
|
|
1364
|
+
{projectTab === 'code' && gitInfo && (
|
|
1365
|
+
<div className="border-t border-[var(--border)] shrink-0">
|
|
1366
|
+
{/* Changes list */}
|
|
1367
|
+
{gitInfo.changes.length > 0 && (
|
|
1368
|
+
<>
|
|
1369
|
+
<div className="overflow-y-auto border-b border-[var(--border)]" style={{ height: changesHeight }}>
|
|
1370
|
+
<div className="px-3 py-1 text-[9px] text-[var(--text-secondary)] bg-[var(--bg-tertiary)] sticky top-0 flex items-center gap-1 cursor-pointer z-10" onClick={() => {
|
|
1371
|
+
setChangesExpanded(!changesExpanded);
|
|
1372
|
+
setChangesHeight(changesExpanded ? 120 : Math.min(400, gitInfo.changes.length * 22 + 24));
|
|
1373
|
+
}}>
|
|
1374
|
+
<span>{changesExpanded ? '▼' : '▶'}</span>
|
|
1375
|
+
<span>{gitInfo.changes.length} changes</span>
|
|
1376
|
+
<button onClick={(e) => { e.stopPropagation(); fetchGitInfo(); }} className="ml-auto text-[8px] hover:text-[var(--accent)]" title="Refresh">↻</button>
|
|
1377
|
+
</div>
|
|
1378
|
+
{gitInfo.changes.map(g => (
|
|
1379
|
+
<div key={g.path} className="flex items-center px-3 py-0.5 text-xs hover:bg-[var(--bg-tertiary)] group">
|
|
1380
|
+
<span className={`text-[10px] font-mono w-4 shrink-0 ${
|
|
1381
|
+
g.status.includes('M') ? 'text-yellow-500' :
|
|
1382
|
+
g.status.includes('?') ? 'text-green-500' :
|
|
1383
|
+
g.status.includes('D') ? 'text-red-500' : 'text-[var(--text-secondary)]'
|
|
1384
|
+
}`}>
|
|
1385
|
+
{g.status.includes('?') ? '+' : g.status[0]}
|
|
1386
|
+
</span>
|
|
1387
|
+
<button
|
|
1388
|
+
onClick={() => openDiff(g.path)}
|
|
1389
|
+
className={`truncate flex-1 text-left ml-1 ${diffFile === g.path ? 'text-[var(--accent)]' : 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'}`}
|
|
1390
|
+
title="View diff"
|
|
1391
|
+
>
|
|
1392
|
+
{g.path}
|
|
1393
|
+
</button>
|
|
1394
|
+
<button
|
|
1395
|
+
onClick={() => openFile(g.path)}
|
|
1396
|
+
className="text-[8px] text-[var(--text-secondary)] hover:text-[var(--accent)] opacity-0 group-hover:opacity-100 shrink-0 ml-1"
|
|
1397
|
+
title="Open source file"
|
|
1398
|
+
>
|
|
1399
|
+
src
|
|
1400
|
+
</button>
|
|
1401
|
+
</div>
|
|
1402
|
+
))}
|
|
1403
|
+
</div>
|
|
1404
|
+
{/* Drag handle to resize changes list */}
|
|
1405
|
+
<div
|
|
1406
|
+
className="h-1 cursor-ns-resize hover:bg-[var(--accent)]/30 border-b border-[var(--border)]"
|
|
1407
|
+
onMouseDown={(e) => {
|
|
1408
|
+
e.preventDefault();
|
|
1409
|
+
changesResizeRef.current = { startY: e.clientY, origH: changesHeight };
|
|
1410
|
+
const onMove = (ev: MouseEvent) => {
|
|
1411
|
+
if (!changesResizeRef.current) return;
|
|
1412
|
+
setChangesHeight(Math.max(60, Math.min(600, changesResizeRef.current.origH + ev.clientY - changesResizeRef.current.startY)));
|
|
1413
|
+
};
|
|
1414
|
+
const onUp = () => { changesResizeRef.current = null; window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); };
|
|
1415
|
+
window.addEventListener('mousemove', onMove);
|
|
1416
|
+
window.addEventListener('mouseup', onUp);
|
|
1417
|
+
}}
|
|
1418
|
+
/>
|
|
1419
|
+
</>
|
|
1420
|
+
)}
|
|
1421
|
+
|
|
1422
|
+
{/* Git actions */}
|
|
1423
|
+
<div className="px-3 py-2 flex items-center gap-2">
|
|
1424
|
+
<input
|
|
1425
|
+
value={commitMsg}
|
|
1426
|
+
onChange={e => setCommitMsg(e.target.value)}
|
|
1427
|
+
onKeyDown={e => e.key === 'Enter' && commitMsg.trim() && gitAction('commit', { message: commitMsg.trim() })}
|
|
1428
|
+
placeholder="Commit message..."
|
|
1429
|
+
className="flex-1 text-xs bg-[var(--bg-tertiary)] border border-[var(--border)] rounded px-2 py-1.5 text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)]"
|
|
1430
|
+
/>
|
|
1431
|
+
<button
|
|
1432
|
+
onClick={() => commitMsg.trim() && gitAction('commit', { message: commitMsg.trim() })}
|
|
1433
|
+
disabled={gitLoading || !commitMsg.trim() || gitInfo.changes.length === 0}
|
|
1434
|
+
className="text-[10px] px-3 py-1.5 bg-[var(--accent)] text-white rounded hover:opacity-90 disabled:opacity-50 shrink-0"
|
|
1435
|
+
>
|
|
1436
|
+
Commit
|
|
1437
|
+
</button>
|
|
1438
|
+
<button
|
|
1439
|
+
onClick={() => gitAction('push')}
|
|
1440
|
+
disabled={gitLoading || gitInfo.ahead === 0}
|
|
1441
|
+
className="text-[10px] px-3 py-1.5 border border-[var(--accent)] text-[var(--accent)] rounded hover:bg-[var(--accent)] hover:text-white disabled:opacity-50 shrink-0"
|
|
1442
|
+
>
|
|
1443
|
+
Push{gitInfo.ahead > 0 ? ` (${gitInfo.ahead})` : ''}
|
|
1444
|
+
</button>
|
|
1445
|
+
<button
|
|
1446
|
+
onClick={() => gitAction('pull')}
|
|
1447
|
+
disabled={gitLoading}
|
|
1448
|
+
className="text-[10px] px-3 py-1.5 text-[var(--text-secondary)] hover:text-[var(--text-primary)] shrink-0"
|
|
1449
|
+
>
|
|
1450
|
+
Pull{gitInfo.behind > 0 ? ` (${gitInfo.behind})` : ''}
|
|
1451
|
+
</button>
|
|
1452
|
+
</div>
|
|
1453
|
+
|
|
1454
|
+
{/* Result */}
|
|
1455
|
+
{gitResult && (
|
|
1456
|
+
<div className={`px-3 py-1 text-[10px] ${gitResult.ok ? 'text-green-400' : 'text-red-400'}`}>
|
|
1457
|
+
{gitResult.ok ? 'Done' : gitResult.error}
|
|
1458
|
+
</div>
|
|
1459
|
+
)}
|
|
1460
|
+
</div>
|
|
1461
|
+
)}
|
|
1462
|
+
|
|
1463
|
+
</>
|
|
1464
|
+
);
|
|
1465
|
+
});
|
|
1466
|
+
|
|
1467
|
+
// Simple file tree node
|
|
1468
|
+
const FileTreeNode = memo(function FileTreeNode({ node, depth, selected, onSelect }: {
|
|
1469
|
+
node: { name: string; path: string; type: string; children?: any[] };
|
|
1470
|
+
depth: number;
|
|
1471
|
+
selected: string | null;
|
|
1472
|
+
onSelect: (path: string) => void;
|
|
1473
|
+
}) {
|
|
1474
|
+
const [expanded, setExpanded] = useState(depth < 1);
|
|
1475
|
+
|
|
1476
|
+
if (node.type === 'dir') {
|
|
1477
|
+
return (
|
|
1478
|
+
<div>
|
|
1479
|
+
<button
|
|
1480
|
+
onClick={() => setExpanded(v => !v)}
|
|
1481
|
+
className="w-full text-left flex items-center gap-1 px-1 py-0.5 hover:bg-[var(--bg-tertiary)] rounded text-xs"
|
|
1482
|
+
style={{ paddingLeft: depth * 12 + 4 }}
|
|
1483
|
+
>
|
|
1484
|
+
<span className="text-[10px] text-[var(--text-secondary)] w-3">{expanded ? '▾' : '▸'}</span>
|
|
1485
|
+
<span className="text-[var(--text-primary)]">{node.name}</span>
|
|
1486
|
+
</button>
|
|
1487
|
+
{expanded && node.children?.map((child: any) => (
|
|
1488
|
+
<FileTreeNode key={child.path} node={child} depth={depth + 1} selected={selected} onSelect={onSelect} />
|
|
1489
|
+
))}
|
|
1490
|
+
</div>
|
|
1491
|
+
);
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
return (
|
|
1495
|
+
<button
|
|
1496
|
+
onClick={() => onSelect(node.path)}
|
|
1497
|
+
className={`w-full text-left px-1 py-0.5 rounded text-xs truncate ${
|
|
1498
|
+
selected === node.path ? 'bg-[var(--accent)]/20 text-[var(--accent)]' : 'hover:bg-[var(--bg-tertiary)] text-[var(--text-secondary)]'
|
|
1499
|
+
}`}
|
|
1500
|
+
style={{ paddingLeft: depth * 12 + 16 }}
|
|
1501
|
+
>
|
|
1502
|
+
{node.name}
|
|
1503
|
+
</button>
|
|
1504
|
+
);
|
|
1505
|
+
});
|
|
1506
|
+
|
|
1507
|
+
// ─── Agent Terminal Button ───────────────────────────────
|
|
1508
|
+
|
|
1509
|
+
function AgentTerminalButton({ projectPath, projectName }: { projectPath: string; projectName: string }) {
|
|
1510
|
+
const [agents, setAgents] = useState<{ id: string; name: string; detected?: boolean; isProfile?: boolean; base?: string; backendType?: string; env?: Record<string, string>; model?: string }[]>([]);
|
|
1511
|
+
const [showMenu, setShowMenu] = useState(false);
|
|
1512
|
+
const [pickerInfo, setPickerInfo] = useState<{ agentId: string; agentName: string; env?: Record<string, string>; model?: string; supportsSession: boolean; currentSessionId: string | null } | null>(null);
|
|
1513
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
1514
|
+
|
|
1515
|
+
useEffect(() => {
|
|
1516
|
+
fetch('/api/agents').then(r => r.json())
|
|
1517
|
+
.then(d => setAgents(d.agents || []))
|
|
1518
|
+
.catch(() => {});
|
|
1519
|
+
}, []);
|
|
1520
|
+
|
|
1521
|
+
useEffect(() => {
|
|
1522
|
+
if (!showMenu) return;
|
|
1523
|
+
const h = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as globalThis.Node)) setShowMenu(false); };
|
|
1524
|
+
document.addEventListener('mousedown', h);
|
|
1525
|
+
return () => document.removeEventListener('mousedown', h);
|
|
1526
|
+
}, [showMenu]);
|
|
1527
|
+
|
|
1528
|
+
const openTerminal = (agentId: string, resumeMode?: boolean, sessionId?: string, env?: Record<string, string>, model?: string) => {
|
|
1529
|
+
setPickerInfo(null);
|
|
1530
|
+
setShowMenu(false);
|
|
1531
|
+
const profileEnv: Record<string, string> = { ...(env || {}) };
|
|
1532
|
+
if (model) profileEnv.CLAUDE_MODEL = model;
|
|
1533
|
+
window.dispatchEvent(new CustomEvent('forge:open-terminal', {
|
|
1534
|
+
detail: { projectPath, projectName, agentId, resumeMode, sessionId, profileEnv: Object.keys(profileEnv).length > 0 ? profileEnv : undefined },
|
|
1535
|
+
}));
|
|
1536
|
+
};
|
|
1537
|
+
|
|
1538
|
+
const handleAgentClick = async (a: typeof agents[0]) => {
|
|
1539
|
+
setShowMenu(false);
|
|
1540
|
+
try {
|
|
1541
|
+
const res = await fetch(`/api/agents?resolve=${encodeURIComponent(a.id)}`);
|
|
1542
|
+
const info = await res.json();
|
|
1543
|
+
// Resolve current session (fixedSession for this project)
|
|
1544
|
+
let currentSessionId: string | null = null;
|
|
1545
|
+
if (info.supportsSession) {
|
|
1546
|
+
try {
|
|
1547
|
+
const { resolveFixedSession } = await import('@/lib/session-utils');
|
|
1548
|
+
currentSessionId = await resolveFixedSession(projectPath) || null;
|
|
1549
|
+
} catch {}
|
|
1550
|
+
}
|
|
1551
|
+
setPickerInfo({
|
|
1552
|
+
agentId: a.id, agentName: a.name,
|
|
1553
|
+
env: info.env, model: info.model,
|
|
1554
|
+
supportsSession: info.supportsSession ?? true,
|
|
1555
|
+
currentSessionId,
|
|
1556
|
+
});
|
|
1557
|
+
} catch {
|
|
1558
|
+
openTerminal(a.id);
|
|
1559
|
+
}
|
|
1560
|
+
};
|
|
1561
|
+
|
|
1562
|
+
const allAgents = agents.filter(a => a.detected !== false || a.isProfile);
|
|
1563
|
+
|
|
1564
|
+
return (
|
|
1565
|
+
<>
|
|
1566
|
+
<div ref={ref} className="relative">
|
|
1567
|
+
<div className="flex items-center">
|
|
1568
|
+
<button
|
|
1569
|
+
onClick={() => handleAgentClick({ id: 'claude', name: 'Claude' })}
|
|
1570
|
+
className="text-[9px] px-2 py-0.5 border border-[var(--accent)] text-[var(--accent)] rounded-l hover:bg-[var(--accent)] hover:text-white transition-colors"
|
|
1571
|
+
title="Open terminal"
|
|
1572
|
+
>
|
|
1573
|
+
Terminal
|
|
1574
|
+
</button>
|
|
1575
|
+
{allAgents.length > 1 && (
|
|
1576
|
+
<button
|
|
1577
|
+
onClick={() => setShowMenu(v => !v)}
|
|
1578
|
+
className="text-[9px] px-1 py-0.5 border border-l-0 border-[var(--accent)] text-[var(--accent)] rounded-r hover:bg-[var(--accent)] hover:text-white transition-colors"
|
|
1579
|
+
>
|
|
1580
|
+
▾
|
|
1581
|
+
</button>
|
|
1582
|
+
)}
|
|
1583
|
+
</div>
|
|
1584
|
+
{showMenu && (
|
|
1585
|
+
<div className="absolute right-0 top-full mt-1 w-44 rounded border border-[var(--border)] shadow-lg z-40 overflow-hidden" style={{ background: 'var(--bg-primary)' }}>
|
|
1586
|
+
{allAgents.map(a => (
|
|
1587
|
+
<button key={a.id} onClick={() => handleAgentClick(a)}
|
|
1588
|
+
className="w-full flex items-center gap-2 px-3 py-1.5 text-[10px] text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-secondary)] text-left">
|
|
1589
|
+
<span className="font-bold w-4 text-center text-[var(--accent)]">
|
|
1590
|
+
{a.isProfile ? '●' : a.id === 'claude' ? 'C' : a.id === 'codex' ? 'X' : a.id === 'aider' ? 'A' : a.id.charAt(0).toUpperCase()}
|
|
1591
|
+
</span>
|
|
1592
|
+
<span>{a.name}</span>
|
|
1593
|
+
</button>
|
|
1594
|
+
))}
|
|
1595
|
+
</div>
|
|
1596
|
+
)}
|
|
1597
|
+
</div>
|
|
1598
|
+
|
|
1599
|
+
{/* Unified Terminal Session Picker */}
|
|
1600
|
+
{pickerInfo && (
|
|
1601
|
+
<TerminalSessionPickerLazy
|
|
1602
|
+
agentLabel={pickerInfo.agentName}
|
|
1603
|
+
currentSessionId={pickerInfo.currentSessionId}
|
|
1604
|
+
supportsSession={pickerInfo.supportsSession}
|
|
1605
|
+
fetchSessions={() => fetchProjectSessions(projectName)}
|
|
1606
|
+
onSelect={(sel) => {
|
|
1607
|
+
if (sel.mode === 'new') {
|
|
1608
|
+
openTerminal(pickerInfo.agentId, false, undefined, pickerInfo.env, pickerInfo.model);
|
|
1609
|
+
} else {
|
|
1610
|
+
openTerminal(pickerInfo.agentId, true, sel.sessionId, pickerInfo.env, pickerInfo.model);
|
|
1611
|
+
}
|
|
1612
|
+
}}
|
|
1613
|
+
onCancel={() => setPickerInfo(null)}
|
|
1614
|
+
/>
|
|
1615
|
+
)}
|
|
1616
|
+
</>
|
|
1617
|
+
);
|
|
1618
|
+
}
|