@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.
Files changed (255) hide show
  1. package/.forge/worktrees/pipeline-4dd8dc2d/CLAUDE.md +86 -0
  2. package/.forge/worktrees/pipeline-4dd8dc2d/README.md +136 -0
  3. package/.forge/worktrees/pipeline-4dd8dc2d/RELEASE_NOTES.md +36 -0
  4. package/.forge/worktrees/pipeline-4dd8dc2d/app/api/agents/route.ts +17 -0
  5. package/.forge/worktrees/pipeline-4dd8dc2d/app/api/auth/[...nextauth]/route.ts +3 -0
  6. package/.forge/worktrees/pipeline-4dd8dc2d/app/api/auth/verify/route.ts +46 -0
  7. package/.forge/worktrees/pipeline-4dd8dc2d/app/api/claude/[id]/route.ts +31 -0
  8. package/.forge/worktrees/pipeline-4dd8dc2d/app/api/claude/[id]/stream/route.ts +63 -0
  9. package/.forge/worktrees/pipeline-4dd8dc2d/app/api/claude/route.ts +28 -0
  10. package/.forge/worktrees/pipeline-4dd8dc2d/app/api/claude-sessions/[projectName]/entries/route.ts +23 -0
  11. package/.forge/worktrees/pipeline-4dd8dc2d/app/api/claude-sessions/[projectName]/live/route.ts +72 -0
  12. package/.forge/worktrees/pipeline-4dd8dc2d/app/api/claude-sessions/[projectName]/route.ts +37 -0
  13. package/.forge/worktrees/pipeline-4dd8dc2d/app/api/claude-sessions/sync/route.ts +17 -0
  14. package/.forge/worktrees/pipeline-4dd8dc2d/app/api/claude-templates/route.ts +145 -0
  15. package/.forge/worktrees/pipeline-4dd8dc2d/app/api/code/route.ts +299 -0
  16. package/.forge/worktrees/pipeline-4dd8dc2d/app/api/delivery/[id]/route.ts +62 -0
  17. package/.forge/worktrees/pipeline-4dd8dc2d/app/api/delivery/route.ts +40 -0
  18. package/.forge/worktrees/pipeline-4dd8dc2d/app/api/detect-cli/route.ts +46 -0
  19. package/.forge/worktrees/pipeline-4dd8dc2d/app/api/docs/route.ts +176 -0
  20. package/.forge/worktrees/pipeline-4dd8dc2d/app/api/docs/sessions/route.ts +54 -0
  21. package/.forge/worktrees/pipeline-4dd8dc2d/app/api/favorites/route.ts +26 -0
  22. package/.forge/worktrees/pipeline-4dd8dc2d/app/api/flows/route.ts +6 -0
  23. package/.forge/worktrees/pipeline-4dd8dc2d/app/api/flows/run/route.ts +19 -0
  24. package/.forge/worktrees/pipeline-4dd8dc2d/app/api/git/route.ts +149 -0
  25. package/.forge/worktrees/pipeline-4dd8dc2d/app/api/help/route.ts +84 -0
  26. package/.forge/worktrees/pipeline-4dd8dc2d/app/api/issue-scanner/route.ts +116 -0
  27. package/.forge/worktrees/pipeline-4dd8dc2d/app/api/logs/route.ts +100 -0
  28. package/.forge/worktrees/pipeline-4dd8dc2d/app/api/mobile-chat/route.ts +115 -0
  29. package/.forge/worktrees/pipeline-4dd8dc2d/app/api/monitor/route.ts +74 -0
  30. package/.forge/worktrees/pipeline-4dd8dc2d/app/api/notifications/route.ts +42 -0
  31. package/.forge/worktrees/pipeline-4dd8dc2d/app/api/notify/test/route.ts +33 -0
  32. package/.forge/worktrees/pipeline-4dd8dc2d/app/api/online/route.ts +40 -0
  33. package/.forge/worktrees/pipeline-4dd8dc2d/app/api/pipelines/[id]/route.ts +41 -0
  34. package/.forge/worktrees/pipeline-4dd8dc2d/app/api/pipelines/route.ts +90 -0
  35. package/.forge/worktrees/pipeline-4dd8dc2d/app/api/plugins/route.ts +75 -0
  36. package/.forge/worktrees/pipeline-4dd8dc2d/app/api/preview/[...path]/route.ts +64 -0
  37. package/.forge/worktrees/pipeline-4dd8dc2d/app/api/preview/route.ts +156 -0
  38. package/.forge/worktrees/pipeline-4dd8dc2d/app/api/project-pipelines/route.ts +91 -0
  39. package/.forge/worktrees/pipeline-4dd8dc2d/app/api/project-sessions/route.ts +61 -0
  40. package/.forge/worktrees/pipeline-4dd8dc2d/app/api/projects/route.ts +26 -0
  41. package/.forge/worktrees/pipeline-4dd8dc2d/app/api/sessions/[id]/chat/route.ts +64 -0
  42. package/.forge/worktrees/pipeline-4dd8dc2d/app/api/sessions/[id]/messages/route.ts +9 -0
  43. package/.forge/worktrees/pipeline-4dd8dc2d/app/api/sessions/[id]/route.ts +17 -0
  44. package/.forge/worktrees/pipeline-4dd8dc2d/app/api/sessions/route.ts +20 -0
  45. package/.forge/worktrees/pipeline-4dd8dc2d/app/api/settings/route.ts +64 -0
  46. package/.forge/worktrees/pipeline-4dd8dc2d/app/api/skills/local/route.ts +228 -0
  47. package/.forge/worktrees/pipeline-4dd8dc2d/app/api/skills/route.ts +182 -0
  48. package/.forge/worktrees/pipeline-4dd8dc2d/app/api/smith-templates/route.ts +81 -0
  49. package/.forge/worktrees/pipeline-4dd8dc2d/app/api/status/route.ts +12 -0
  50. package/.forge/worktrees/pipeline-4dd8dc2d/app/api/tabs/route.ts +25 -0
  51. package/.forge/worktrees/pipeline-4dd8dc2d/app/api/tasks/[id]/route.ts +51 -0
  52. package/.forge/worktrees/pipeline-4dd8dc2d/app/api/tasks/[id]/stream/route.ts +77 -0
  53. package/.forge/worktrees/pipeline-4dd8dc2d/app/api/tasks/link/route.ts +37 -0
  54. package/.forge/worktrees/pipeline-4dd8dc2d/app/api/tasks/route.ts +44 -0
  55. package/.forge/worktrees/pipeline-4dd8dc2d/app/api/tasks/session/route.ts +14 -0
  56. package/.forge/worktrees/pipeline-4dd8dc2d/app/api/telegram/route.ts +23 -0
  57. package/.forge/worktrees/pipeline-4dd8dc2d/app/api/templates/route.ts +6 -0
  58. package/.forge/worktrees/pipeline-4dd8dc2d/app/api/terminal-bell/route.ts +39 -0
  59. package/.forge/worktrees/pipeline-4dd8dc2d/app/api/terminal-cwd/route.ts +19 -0
  60. package/.forge/worktrees/pipeline-4dd8dc2d/app/api/terminal-state/route.ts +15 -0
  61. package/.forge/worktrees/pipeline-4dd8dc2d/app/api/tunnel/route.ts +26 -0
  62. package/.forge/worktrees/pipeline-4dd8dc2d/app/api/upgrade/route.ts +43 -0
  63. package/.forge/worktrees/pipeline-4dd8dc2d/app/api/usage/route.ts +20 -0
  64. package/.forge/worktrees/pipeline-4dd8dc2d/app/api/version/route.ts +78 -0
  65. package/.forge/worktrees/pipeline-4dd8dc2d/app/api/watchers/route.ts +33 -0
  66. package/.forge/worktrees/pipeline-4dd8dc2d/app/api/workspace/[id]/agents/route.ts +35 -0
  67. package/.forge/worktrees/pipeline-4dd8dc2d/app/api/workspace/[id]/memory/route.ts +23 -0
  68. package/.forge/worktrees/pipeline-4dd8dc2d/app/api/workspace/[id]/smith/route.ts +22 -0
  69. package/.forge/worktrees/pipeline-4dd8dc2d/app/api/workspace/[id]/stream/route.ts +31 -0
  70. package/.forge/worktrees/pipeline-4dd8dc2d/app/api/workspace/route.ts +79 -0
  71. package/.forge/worktrees/pipeline-4dd8dc2d/app/global-error.tsx +21 -0
  72. package/.forge/worktrees/pipeline-4dd8dc2d/app/globals.css +52 -0
  73. package/.forge/worktrees/pipeline-4dd8dc2d/app/icon.ico +0 -0
  74. package/.forge/worktrees/pipeline-4dd8dc2d/app/icon.png +0 -0
  75. package/.forge/worktrees/pipeline-4dd8dc2d/app/icon.svg +106 -0
  76. package/.forge/worktrees/pipeline-4dd8dc2d/app/layout.tsx +17 -0
  77. package/.forge/worktrees/pipeline-4dd8dc2d/app/login/LoginForm.tsx +96 -0
  78. package/.forge/worktrees/pipeline-4dd8dc2d/app/login/page.tsx +10 -0
  79. package/.forge/worktrees/pipeline-4dd8dc2d/app/mobile/page.tsx +10 -0
  80. package/.forge/worktrees/pipeline-4dd8dc2d/app/page.tsx +22 -0
  81. package/.forge/worktrees/pipeline-4dd8dc2d/bin/forge-server.mjs +484 -0
  82. package/.forge/worktrees/pipeline-4dd8dc2d/check-forge-status.sh +71 -0
  83. package/.forge/worktrees/pipeline-4dd8dc2d/cli/mw.ts +579 -0
  84. package/.forge/worktrees/pipeline-4dd8dc2d/components/BrowserPanel.tsx +175 -0
  85. package/.forge/worktrees/pipeline-4dd8dc2d/components/ChatPanel.tsx +191 -0
  86. package/.forge/worktrees/pipeline-4dd8dc2d/components/ClaudeTerminal.tsx +267 -0
  87. package/.forge/worktrees/pipeline-4dd8dc2d/components/CodeViewer.tsx +787 -0
  88. package/.forge/worktrees/pipeline-4dd8dc2d/components/ConversationEditor.tsx +411 -0
  89. package/.forge/worktrees/pipeline-4dd8dc2d/components/ConversationGraphView.tsx +347 -0
  90. package/.forge/worktrees/pipeline-4dd8dc2d/components/ConversationTerminalView.tsx +303 -0
  91. package/.forge/worktrees/pipeline-4dd8dc2d/components/Dashboard.tsx +807 -0
  92. package/.forge/worktrees/pipeline-4dd8dc2d/components/DashboardWrapper.tsx +9 -0
  93. package/.forge/worktrees/pipeline-4dd8dc2d/components/DeliveryFlowEditor.tsx +491 -0
  94. package/.forge/worktrees/pipeline-4dd8dc2d/components/DeliveryList.tsx +230 -0
  95. package/.forge/worktrees/pipeline-4dd8dc2d/components/DeliveryWorkspace.tsx +589 -0
  96. package/.forge/worktrees/pipeline-4dd8dc2d/components/DocTerminal.tsx +187 -0
  97. package/.forge/worktrees/pipeline-4dd8dc2d/components/DocsViewer.tsx +574 -0
  98. package/.forge/worktrees/pipeline-4dd8dc2d/components/HelpDialog.tsx +169 -0
  99. package/.forge/worktrees/pipeline-4dd8dc2d/components/HelpTerminal.tsx +141 -0
  100. package/.forge/worktrees/pipeline-4dd8dc2d/components/InlinePipelineView.tsx +111 -0
  101. package/.forge/worktrees/pipeline-4dd8dc2d/components/LogViewer.tsx +194 -0
  102. package/.forge/worktrees/pipeline-4dd8dc2d/components/MarkdownContent.tsx +73 -0
  103. package/.forge/worktrees/pipeline-4dd8dc2d/components/MobileView.tsx +385 -0
  104. package/.forge/worktrees/pipeline-4dd8dc2d/components/MonitorPanel.tsx +122 -0
  105. package/.forge/worktrees/pipeline-4dd8dc2d/components/NewSessionModal.tsx +93 -0
  106. package/.forge/worktrees/pipeline-4dd8dc2d/components/NewTaskModal.tsx +492 -0
  107. package/.forge/worktrees/pipeline-4dd8dc2d/components/PipelineEditor.tsx +570 -0
  108. package/.forge/worktrees/pipeline-4dd8dc2d/components/PipelineView.tsx +1018 -0
  109. package/.forge/worktrees/pipeline-4dd8dc2d/components/PluginsPanel.tsx +472 -0
  110. package/.forge/worktrees/pipeline-4dd8dc2d/components/ProjectDetail.tsx +1618 -0
  111. package/.forge/worktrees/pipeline-4dd8dc2d/components/ProjectList.tsx +108 -0
  112. package/.forge/worktrees/pipeline-4dd8dc2d/components/ProjectManager.tsx +401 -0
  113. package/.forge/worktrees/pipeline-4dd8dc2d/components/SessionList.tsx +74 -0
  114. package/.forge/worktrees/pipeline-4dd8dc2d/components/SessionView.tsx +726 -0
  115. package/.forge/worktrees/pipeline-4dd8dc2d/components/SettingsModal.tsx +1647 -0
  116. package/.forge/worktrees/pipeline-4dd8dc2d/components/SkillsPanel.tsx +969 -0
  117. package/.forge/worktrees/pipeline-4dd8dc2d/components/StatusBar.tsx +99 -0
  118. package/.forge/worktrees/pipeline-4dd8dc2d/components/TabBar.tsx +46 -0
  119. package/.forge/worktrees/pipeline-4dd8dc2d/components/TaskBoard.tsx +113 -0
  120. package/.forge/worktrees/pipeline-4dd8dc2d/components/TaskDetail.tsx +372 -0
  121. package/.forge/worktrees/pipeline-4dd8dc2d/components/TerminalLauncher.tsx +398 -0
  122. package/.forge/worktrees/pipeline-4dd8dc2d/components/TunnelToggle.tsx +206 -0
  123. package/.forge/worktrees/pipeline-4dd8dc2d/components/UsagePanel.tsx +207 -0
  124. package/.forge/worktrees/pipeline-4dd8dc2d/components/WebTerminal.tsx +1743 -0
  125. package/.forge/worktrees/pipeline-4dd8dc2d/components/WorkspaceTree.tsx +221 -0
  126. package/.forge/worktrees/pipeline-4dd8dc2d/components/WorkspaceView.tsx +4048 -0
  127. package/.forge/worktrees/pipeline-4dd8dc2d/dev-test.sh +5 -0
  128. package/.forge/worktrees/pipeline-4dd8dc2d/docs/Forge_Memory_Layer_Design.docx +0 -0
  129. package/.forge/worktrees/pipeline-4dd8dc2d/docs/Forge_Strategy_Research_2026.docx +0 -0
  130. package/.forge/worktrees/pipeline-4dd8dc2d/docs/LOCAL-DEPLOY.md +144 -0
  131. package/.forge/worktrees/pipeline-4dd8dc2d/docs/roadmap-multi-agent-workflow.md +330 -0
  132. package/.forge/worktrees/pipeline-4dd8dc2d/forge-logo.png +0 -0
  133. package/.forge/worktrees/pipeline-4dd8dc2d/forge-logo.svg +106 -0
  134. package/.forge/worktrees/pipeline-4dd8dc2d/hooks/useSidebarResize.ts +52 -0
  135. package/.forge/worktrees/pipeline-4dd8dc2d/install.sh +29 -0
  136. package/.forge/worktrees/pipeline-4dd8dc2d/instrumentation.ts +35 -0
  137. package/.forge/worktrees/pipeline-4dd8dc2d/lib/agents/claude-adapter.ts +104 -0
  138. package/.forge/worktrees/pipeline-4dd8dc2d/lib/agents/generic-adapter.ts +64 -0
  139. package/.forge/worktrees/pipeline-4dd8dc2d/lib/agents/index.ts +245 -0
  140. package/.forge/worktrees/pipeline-4dd8dc2d/lib/agents/types.ts +70 -0
  141. package/.forge/worktrees/pipeline-4dd8dc2d/lib/artifacts.ts +106 -0
  142. package/.forge/worktrees/pipeline-4dd8dc2d/lib/auth.ts +62 -0
  143. package/.forge/worktrees/pipeline-4dd8dc2d/lib/builtin-plugins/docker.yaml +70 -0
  144. package/.forge/worktrees/pipeline-4dd8dc2d/lib/builtin-plugins/http.yaml +66 -0
  145. package/.forge/worktrees/pipeline-4dd8dc2d/lib/builtin-plugins/jenkins.yaml +92 -0
  146. package/.forge/worktrees/pipeline-4dd8dc2d/lib/builtin-plugins/llm-vision.yaml +85 -0
  147. package/.forge/worktrees/pipeline-4dd8dc2d/lib/builtin-plugins/playwright.yaml +111 -0
  148. package/.forge/worktrees/pipeline-4dd8dc2d/lib/builtin-plugins/shell-command.yaml +60 -0
  149. package/.forge/worktrees/pipeline-4dd8dc2d/lib/builtin-plugins/slack.yaml +48 -0
  150. package/.forge/worktrees/pipeline-4dd8dc2d/lib/builtin-plugins/webhook.yaml +56 -0
  151. package/.forge/worktrees/pipeline-4dd8dc2d/lib/claude-process.ts +361 -0
  152. package/.forge/worktrees/pipeline-4dd8dc2d/lib/claude-sessions.ts +266 -0
  153. package/.forge/worktrees/pipeline-4dd8dc2d/lib/claude-templates.ts +227 -0
  154. package/.forge/worktrees/pipeline-4dd8dc2d/lib/cloudflared.ts +424 -0
  155. package/.forge/worktrees/pipeline-4dd8dc2d/lib/crypto.ts +67 -0
  156. package/.forge/worktrees/pipeline-4dd8dc2d/lib/delivery.ts +787 -0
  157. package/.forge/worktrees/pipeline-4dd8dc2d/lib/dirs.ts +99 -0
  158. package/.forge/worktrees/pipeline-4dd8dc2d/lib/flows.ts +86 -0
  159. package/.forge/worktrees/pipeline-4dd8dc2d/lib/forge-mcp-server.ts +732 -0
  160. package/.forge/worktrees/pipeline-4dd8dc2d/lib/forge-skills/forge-inbox.md +38 -0
  161. package/.forge/worktrees/pipeline-4dd8dc2d/lib/forge-skills/forge-send.md +47 -0
  162. package/.forge/worktrees/pipeline-4dd8dc2d/lib/forge-skills/forge-status.md +32 -0
  163. package/.forge/worktrees/pipeline-4dd8dc2d/lib/forge-skills/forge-workspace-sync.md +37 -0
  164. package/.forge/worktrees/pipeline-4dd8dc2d/lib/help-docs/00-overview.md +40 -0
  165. package/.forge/worktrees/pipeline-4dd8dc2d/lib/help-docs/01-settings.md +194 -0
  166. package/.forge/worktrees/pipeline-4dd8dc2d/lib/help-docs/02-telegram.md +41 -0
  167. package/.forge/worktrees/pipeline-4dd8dc2d/lib/help-docs/03-tunnel.md +31 -0
  168. package/.forge/worktrees/pipeline-4dd8dc2d/lib/help-docs/04-tasks.md +52 -0
  169. package/.forge/worktrees/pipeline-4dd8dc2d/lib/help-docs/05-pipelines.md +460 -0
  170. package/.forge/worktrees/pipeline-4dd8dc2d/lib/help-docs/06-skills.md +43 -0
  171. package/.forge/worktrees/pipeline-4dd8dc2d/lib/help-docs/07-projects.md +73 -0
  172. package/.forge/worktrees/pipeline-4dd8dc2d/lib/help-docs/08-rules.md +53 -0
  173. package/.forge/worktrees/pipeline-4dd8dc2d/lib/help-docs/09-issue-autofix.md +55 -0
  174. package/.forge/worktrees/pipeline-4dd8dc2d/lib/help-docs/10-troubleshooting.md +89 -0
  175. package/.forge/worktrees/pipeline-4dd8dc2d/lib/help-docs/11-workspace.md +810 -0
  176. package/.forge/worktrees/pipeline-4dd8dc2d/lib/help-docs/CLAUDE.md +62 -0
  177. package/.forge/worktrees/pipeline-4dd8dc2d/lib/init.ts +266 -0
  178. package/.forge/worktrees/pipeline-4dd8dc2d/lib/issue-scanner.ts +298 -0
  179. package/.forge/worktrees/pipeline-4dd8dc2d/lib/logger.ts +79 -0
  180. package/.forge/worktrees/pipeline-4dd8dc2d/lib/notifications.ts +75 -0
  181. package/.forge/worktrees/pipeline-4dd8dc2d/lib/notify.ts +108 -0
  182. package/.forge/worktrees/pipeline-4dd8dc2d/lib/password.ts +97 -0
  183. package/.forge/worktrees/pipeline-4dd8dc2d/lib/pipeline-scheduler.ts +373 -0
  184. package/.forge/worktrees/pipeline-4dd8dc2d/lib/pipeline.ts +1565 -0
  185. package/.forge/worktrees/pipeline-4dd8dc2d/lib/plugins/executor.ts +347 -0
  186. package/.forge/worktrees/pipeline-4dd8dc2d/lib/plugins/registry.ts +228 -0
  187. package/.forge/worktrees/pipeline-4dd8dc2d/lib/plugins/types.ts +103 -0
  188. package/.forge/worktrees/pipeline-4dd8dc2d/lib/project-sessions.ts +53 -0
  189. package/.forge/worktrees/pipeline-4dd8dc2d/lib/projects.ts +86 -0
  190. package/.forge/worktrees/pipeline-4dd8dc2d/lib/session-manager.ts +156 -0
  191. package/.forge/worktrees/pipeline-4dd8dc2d/lib/session-utils.ts +53 -0
  192. package/.forge/worktrees/pipeline-4dd8dc2d/lib/session-watcher.ts +345 -0
  193. package/.forge/worktrees/pipeline-4dd8dc2d/lib/settings.ts +195 -0
  194. package/.forge/worktrees/pipeline-4dd8dc2d/lib/skills.ts +458 -0
  195. package/.forge/worktrees/pipeline-4dd8dc2d/lib/task-manager.ts +951 -0
  196. package/.forge/worktrees/pipeline-4dd8dc2d/lib/telegram-bot.ts +1477 -0
  197. package/.forge/worktrees/pipeline-4dd8dc2d/lib/telegram-standalone.ts +83 -0
  198. package/.forge/worktrees/pipeline-4dd8dc2d/lib/terminal-server.ts +70 -0
  199. package/.forge/worktrees/pipeline-4dd8dc2d/lib/terminal-standalone.ts +438 -0
  200. package/.forge/worktrees/pipeline-4dd8dc2d/lib/usage-scanner.ts +249 -0
  201. package/.forge/worktrees/pipeline-4dd8dc2d/lib/workspace/__tests__/state-machine.test.ts +388 -0
  202. package/.forge/worktrees/pipeline-4dd8dc2d/lib/workspace/__tests__/workspace.test.ts +311 -0
  203. package/.forge/worktrees/pipeline-4dd8dc2d/lib/workspace/agent-bus.ts +416 -0
  204. package/.forge/worktrees/pipeline-4dd8dc2d/lib/workspace/agent-worker.ts +655 -0
  205. package/.forge/worktrees/pipeline-4dd8dc2d/lib/workspace/backends/api-backend.ts +262 -0
  206. package/.forge/worktrees/pipeline-4dd8dc2d/lib/workspace/backends/cli-backend.ts +491 -0
  207. package/.forge/worktrees/pipeline-4dd8dc2d/lib/workspace/index.ts +84 -0
  208. package/.forge/worktrees/pipeline-4dd8dc2d/lib/workspace/manager.ts +136 -0
  209. package/.forge/worktrees/pipeline-4dd8dc2d/lib/workspace/orchestrator.ts +3415 -0
  210. package/.forge/worktrees/pipeline-4dd8dc2d/lib/workspace/persistence.ts +309 -0
  211. package/.forge/worktrees/pipeline-4dd8dc2d/lib/workspace/presets.ts +649 -0
  212. package/.forge/worktrees/pipeline-4dd8dc2d/lib/workspace/requests.ts +287 -0
  213. package/.forge/worktrees/pipeline-4dd8dc2d/lib/workspace/session-monitor.ts +240 -0
  214. package/.forge/worktrees/pipeline-4dd8dc2d/lib/workspace/skill-installer.ts +275 -0
  215. package/.forge/worktrees/pipeline-4dd8dc2d/lib/workspace/smith-memory.ts +498 -0
  216. package/.forge/worktrees/pipeline-4dd8dc2d/lib/workspace/types.ts +241 -0
  217. package/.forge/worktrees/pipeline-4dd8dc2d/lib/workspace/watch-manager.ts +560 -0
  218. package/.forge/worktrees/pipeline-4dd8dc2d/lib/workspace-standalone.ts +978 -0
  219. package/.forge/worktrees/pipeline-4dd8dc2d/middleware.ts +51 -0
  220. package/.forge/worktrees/pipeline-4dd8dc2d/next.config.ts +26 -0
  221. package/.forge/worktrees/pipeline-4dd8dc2d/package.json +74 -0
  222. package/.forge/worktrees/pipeline-4dd8dc2d/pnpm-lock.yaml +3719 -0
  223. package/.forge/worktrees/pipeline-4dd8dc2d/pnpm-workspace.yaml +1 -0
  224. package/.forge/worktrees/pipeline-4dd8dc2d/postcss.config.mjs +7 -0
  225. package/.forge/worktrees/pipeline-4dd8dc2d/publish.sh +133 -0
  226. package/.forge/worktrees/pipeline-4dd8dc2d/scripts/bench/README.md +66 -0
  227. package/.forge/worktrees/pipeline-4dd8dc2d/scripts/bench/results/.gitignore +2 -0
  228. package/.forge/worktrees/pipeline-4dd8dc2d/scripts/bench/run.ts +635 -0
  229. package/.forge/worktrees/pipeline-4dd8dc2d/scripts/bench/tasks/01-text-utils/task.md +26 -0
  230. package/.forge/worktrees/pipeline-4dd8dc2d/scripts/bench/tasks/01-text-utils/validator.sh +46 -0
  231. package/.forge/worktrees/pipeline-4dd8dc2d/scripts/bench/tasks/02-pagination/setup.sh +19 -0
  232. package/.forge/worktrees/pipeline-4dd8dc2d/scripts/bench/tasks/02-pagination/task.md +48 -0
  233. package/.forge/worktrees/pipeline-4dd8dc2d/scripts/bench/tasks/02-pagination/validator.sh +69 -0
  234. package/.forge/worktrees/pipeline-4dd8dc2d/scripts/bench/tasks/03-bug-fix/setup.sh +82 -0
  235. package/.forge/worktrees/pipeline-4dd8dc2d/scripts/bench/tasks/03-bug-fix/task.md +30 -0
  236. package/.forge/worktrees/pipeline-4dd8dc2d/scripts/bench/tasks/03-bug-fix/validator.sh +29 -0
  237. package/.forge/worktrees/pipeline-4dd8dc2d/scripts/verify-usage.ts +178 -0
  238. package/.forge/worktrees/pipeline-4dd8dc2d/src/config/index.ts +129 -0
  239. package/.forge/worktrees/pipeline-4dd8dc2d/src/core/db/database.ts +259 -0
  240. package/.forge/worktrees/pipeline-4dd8dc2d/src/core/memory/strategy.ts +32 -0
  241. package/.forge/worktrees/pipeline-4dd8dc2d/src/core/providers/chat.ts +65 -0
  242. package/.forge/worktrees/pipeline-4dd8dc2d/src/core/providers/registry.ts +60 -0
  243. package/.forge/worktrees/pipeline-4dd8dc2d/src/core/session/manager.ts +190 -0
  244. package/.forge/worktrees/pipeline-4dd8dc2d/src/types/index.ts +129 -0
  245. package/.forge/worktrees/pipeline-4dd8dc2d/start.sh +32 -0
  246. package/.forge/worktrees/pipeline-4dd8dc2d/templates/smith-lead.json +45 -0
  247. package/.forge/worktrees/pipeline-4dd8dc2d/tsconfig.json +42 -0
  248. package/RELEASE_NOTES.md +10 -29
  249. package/app/api/terminal-bell/route.ts +6 -2
  250. package/app/api/terminal-cwd/route.ts +7 -4
  251. package/components/CodeViewer.tsx +3 -31
  252. package/components/Dashboard.tsx +34 -20
  253. package/components/WebTerminal.tsx +36 -2
  254. package/lib/terminal-standalone.ts +19 -2
  255. 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
+ }