corporateai 0.0.1

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 (704) hide show
  1. package/.dockerignore +10 -0
  2. package/.env.example +3 -0
  3. package/.github/workflows/publish-cli.yml +49 -0
  4. package/.mailmap +1 -0
  5. package/AGENTS.md +148 -0
  6. package/CONTRIBUTING.md +75 -0
  7. package/Dockerfile +59 -0
  8. package/Dockerfile.onboard-smoke +42 -0
  9. package/LICENSE +21 -0
  10. package/README.md +93 -0
  11. package/cli/esbuild.config.mjs +11 -0
  12. package/cli/package.json +24 -0
  13. package/cli/scripts/build-cli.mjs +5 -0
  14. package/cli/src/index.ts +27 -0
  15. package/docker-compose.quickstart.yml +18 -0
  16. package/docker-compose.untrusted-review.yml +33 -0
  17. package/docker-compose.yml +38 -0
  18. package/package.json +56 -0
  19. package/patches/embedded-postgres@18.1.0-beta.16.patch +0 -0
  20. package/pnpm-workspace.yaml +4 -0
  21. package/releases/.gitkeep +0 -0
  22. package/releases/v0.0.1.md +36 -0
  23. package/releases/v0.2.7.md +15 -0
  24. package/releases/v0.3.0.md +54 -0
  25. package/releases/v0.3.1.md +55 -0
  26. package/releases/v2026.318.0.md +66 -0
  27. package/releases/v2026.325.0.md +78 -0
  28. package/report/2026-03-13-08-46-token-optimization-implementation.md +48 -0
  29. package/scripts/backup-db.sh +17 -0
  30. package/scripts/build-npm.sh +80 -0
  31. package/scripts/check-forbidden-tokens.mjs +115 -0
  32. package/scripts/clean-onboard-git.sh +14 -0
  33. package/scripts/clean-onboard-npm.sh +13 -0
  34. package/scripts/clean-onboard-ref.sh +86 -0
  35. package/scripts/create-github-release.sh +99 -0
  36. package/scripts/dev-runner-paths.mjs +38 -0
  37. package/scripts/dev-runner.mjs +606 -0
  38. package/scripts/docker-onboard-smoke.sh +306 -0
  39. package/scripts/ensure-plugin-build-deps.mjs +47 -0
  40. package/scripts/generate-company-assets.ts +365 -0
  41. package/scripts/generate-npm-package-json.mjs +113 -0
  42. package/scripts/generate-org-chart-images.ts +694 -0
  43. package/scripts/generate-org-chart-satori-comparison.ts +225 -0
  44. package/scripts/generate-ui-package-json.mjs +31 -0
  45. package/scripts/kill-dev.sh +71 -0
  46. package/scripts/migrate-inline-env-secrets.ts +126 -0
  47. package/scripts/prepare-server-ui-dist.sh +22 -0
  48. package/scripts/provision-worktree.sh +333 -0
  49. package/scripts/release-lib.sh +306 -0
  50. package/scripts/release-package-map.mjs +169 -0
  51. package/scripts/release.sh +312 -0
  52. package/scripts/rollback-latest.sh +111 -0
  53. package/scripts/smoke/openclaw-docker-ui.sh +329 -0
  54. package/scripts/smoke/openclaw-gateway-e2e.sh +954 -0
  55. package/scripts/smoke/openclaw-join.sh +295 -0
  56. package/scripts/smoke/openclaw-sse-standalone.sh +146 -0
  57. package/scripts/workspace-compat.mjs +60 -0
  58. package/server/CHANGELOG.md +130 -0
  59. package/server/package.json +96 -0
  60. package/server/scripts/copy-onboarding-assets.mjs +10 -0
  61. package/server/scripts/dev-watch.ts +33 -0
  62. package/server/src/__tests__/activity-routes.test.ts +70 -0
  63. package/server/src/__tests__/adapter-models.test.ts +105 -0
  64. package/server/src/__tests__/adapter-session-codecs.test.ts +194 -0
  65. package/server/src/__tests__/agent-auth-jwt.test.ts +79 -0
  66. package/server/src/__tests__/agent-instructions-routes.test.ts +318 -0
  67. package/server/src/__tests__/agent-instructions-service.test.ts +361 -0
  68. package/server/src/__tests__/agent-permissions-routes.test.ts +275 -0
  69. package/server/src/__tests__/agent-shortname-collision.test.ts +69 -0
  70. package/server/src/__tests__/agent-skill-contract.test.ts +50 -0
  71. package/server/src/__tests__/agent-skills-routes.test.ts +462 -0
  72. package/server/src/__tests__/app-hmr-port.test.ts +19 -0
  73. package/server/src/__tests__/approval-routes-idempotency.test.ts +110 -0
  74. package/server/src/__tests__/approvals-service.test.ts +107 -0
  75. package/server/src/__tests__/assets.test.ts +250 -0
  76. package/server/src/__tests__/attachment-types.test.ts +97 -0
  77. package/server/src/__tests__/board-mutation-guard.test.ts +105 -0
  78. package/server/src/__tests__/budgets-service.test.ts +311 -0
  79. package/server/src/__tests__/claude-local-adapter-environment.test.ts +92 -0
  80. package/server/src/__tests__/claude-local-adapter.test.ts +31 -0
  81. package/server/src/__tests__/claude-local-skill-sync.test.ts +111 -0
  82. package/server/src/__tests__/cli-auth-routes.test.ts +230 -0
  83. package/server/src/__tests__/codex-local-adapter-environment.test.ts +143 -0
  84. package/server/src/__tests__/codex-local-adapter.test.ts +253 -0
  85. package/server/src/__tests__/codex-local-execute.test.ts +391 -0
  86. package/server/src/__tests__/codex-local-skill-injection.test.ts +175 -0
  87. package/server/src/__tests__/codex-local-skill-sync.test.ts +123 -0
  88. package/server/src/__tests__/companies-route-path-guard.test.ts +56 -0
  89. package/server/src/__tests__/company-branding-route.test.ts +196 -0
  90. package/server/src/__tests__/company-portability-routes.test.ts +175 -0
  91. package/server/src/__tests__/company-portability.test.ts +2186 -0
  92. package/server/src/__tests__/company-skills-routes.test.ts +113 -0
  93. package/server/src/__tests__/company-skills.test.ts +229 -0
  94. package/server/src/__tests__/costs-service.test.ts +226 -0
  95. package/server/src/__tests__/cursor-local-adapter-environment.test.ts +196 -0
  96. package/server/src/__tests__/cursor-local-adapter.test.ts +406 -0
  97. package/server/src/__tests__/cursor-local-execute.test.ts +263 -0
  98. package/server/src/__tests__/cursor-local-skill-injection.test.ts +104 -0
  99. package/server/src/__tests__/cursor-local-skill-sync.test.ts +145 -0
  100. package/server/src/__tests__/dev-runner-paths.test.ts +25 -0
  101. package/server/src/__tests__/dev-server-status.test.ts +66 -0
  102. package/server/src/__tests__/dev-watch-ignore.test.ts +42 -0
  103. package/server/src/__tests__/documents.test.ts +29 -0
  104. package/server/src/__tests__/error-handler.test.ts +53 -0
  105. package/server/src/__tests__/execution-workspace-policy.test.ts +170 -0
  106. package/server/src/__tests__/forbidden-tokens.test.ts +77 -0
  107. package/server/src/__tests__/gemini-local-adapter-environment.test.ts +135 -0
  108. package/server/src/__tests__/gemini-local-adapter.test.ts +190 -0
  109. package/server/src/__tests__/gemini-local-execute.test.ts +172 -0
  110. package/server/src/__tests__/gemini-local-skill-sync.test.ts +90 -0
  111. package/server/src/__tests__/health.test.ts +16 -0
  112. package/server/src/__tests__/heartbeat-process-recovery.test.ts +256 -0
  113. package/server/src/__tests__/heartbeat-run-summary.test.ts +33 -0
  114. package/server/src/__tests__/heartbeat-workspace-session.test.ts +334 -0
  115. package/server/src/__tests__/helpers/embedded-postgres.ts +7 -0
  116. package/server/src/__tests__/hire-hook.test.ts +181 -0
  117. package/server/src/__tests__/instance-settings-routes.test.ts +156 -0
  118. package/server/src/__tests__/invite-accept-gateway-defaults.test.ts +119 -0
  119. package/server/src/__tests__/invite-accept-replay.test.ts +92 -0
  120. package/server/src/__tests__/invite-expiry.test.ts +10 -0
  121. package/server/src/__tests__/invite-join-grants.test.ts +57 -0
  122. package/server/src/__tests__/invite-join-manager.test.ts +33 -0
  123. package/server/src/__tests__/invite-onboarding-text.test.ts +116 -0
  124. package/server/src/__tests__/issue-comment-reopen-routes.test.ts +146 -0
  125. package/server/src/__tests__/issue-goal-fallback.test.ts +99 -0
  126. package/server/src/__tests__/issues-checkout-wakeup.test.ts +48 -0
  127. package/server/src/__tests__/issues-goal-context-routes.test.ts +187 -0
  128. package/server/src/__tests__/issues-service.test.ts +317 -0
  129. package/server/src/__tests__/issues-user-context.test.ts +113 -0
  130. package/server/src/__tests__/log-redaction.test.ts +74 -0
  131. package/server/src/__tests__/monthly-spend-service.test.ts +90 -0
  132. package/server/src/__tests__/normalize-agent-mention-token.test.ts +41 -0
  133. package/server/src/__tests__/openclaw-gateway-adapter.test.ts +626 -0
  134. package/server/src/__tests__/openclaw-invite-prompt-route.test.ts +192 -0
  135. package/server/src/__tests__/opencode-local-adapter-environment.test.ts +97 -0
  136. package/server/src/__tests__/opencode-local-adapter.test.ts +226 -0
  137. package/server/src/__tests__/opencode-local-skill-sync.test.ts +91 -0
  138. package/server/src/__tests__/paperclip-env.test.ts +58 -0
  139. package/server/src/__tests__/paperclip-skill-utils.test.ts +63 -0
  140. package/server/src/__tests__/pi-local-adapter-environment.test.ts +102 -0
  141. package/server/src/__tests__/pi-local-skill-sync.test.ts +95 -0
  142. package/server/src/__tests__/plugin-dev-watcher.test.ts +68 -0
  143. package/server/src/__tests__/plugin-worker-manager.test.ts +43 -0
  144. package/server/src/__tests__/private-hostname-guard.test.ts +56 -0
  145. package/server/src/__tests__/project-shortname-resolution.test.ts +45 -0
  146. package/server/src/__tests__/quota-windows-service.test.ts +56 -0
  147. package/server/src/__tests__/quota-windows.test.ts +1109 -0
  148. package/server/src/__tests__/redaction.test.ts +66 -0
  149. package/server/src/__tests__/routines-e2e.test.ts +276 -0
  150. package/server/src/__tests__/routines-routes.test.ts +271 -0
  151. package/server/src/__tests__/routines-service.test.ts +424 -0
  152. package/server/src/__tests__/storage-local-provider.test.ts +78 -0
  153. package/server/src/__tests__/ui-branding.test.ts +82 -0
  154. package/server/src/__tests__/work-products.test.ts +95 -0
  155. package/server/src/__tests__/workspace-runtime.test.ts +1131 -0
  156. package/server/src/__tests__/worktree-config.test.ts +426 -0
  157. package/server/src/adapters/codex-models.ts +105 -0
  158. package/server/src/adapters/cursor-models.ts +171 -0
  159. package/server/src/adapters/http/execute.ts +42 -0
  160. package/server/src/adapters/http/index.ts +21 -0
  161. package/server/src/adapters/http/test.ts +116 -0
  162. package/server/src/adapters/index.ts +18 -0
  163. package/server/src/adapters/process/execute.ts +77 -0
  164. package/server/src/adapters/process/index.ts +24 -0
  165. package/server/src/adapters/process/test.ts +89 -0
  166. package/server/src/adapters/registry.ts +225 -0
  167. package/server/src/adapters/server-utils-compat.ts +57 -0
  168. package/server/src/adapters/types.ts +30 -0
  169. package/server/src/adapters/utils.ts +48 -0
  170. package/server/src/agent-auth-jwt.ts +141 -0
  171. package/server/src/app.ts +321 -0
  172. package/server/src/attachment-types.ts +74 -0
  173. package/server/src/auth/better-auth.ts +148 -0
  174. package/server/src/board-claim.ts +150 -0
  175. package/server/src/config-file.ts +17 -0
  176. package/server/src/config.ts +260 -0
  177. package/server/src/dev-server-status.ts +103 -0
  178. package/server/src/dev-watch-ignore.ts +36 -0
  179. package/server/src/errors.ts +34 -0
  180. package/server/src/home-paths.ts +95 -0
  181. package/server/src/index.ts +799 -0
  182. package/server/src/log-redaction.ts +146 -0
  183. package/server/src/middleware/auth.ts +178 -0
  184. package/server/src/middleware/board-mutation-guard.ts +66 -0
  185. package/server/src/middleware/error-handler.ts +71 -0
  186. package/server/src/middleware/index.ts +3 -0
  187. package/server/src/middleware/logger.ts +90 -0
  188. package/server/src/middleware/private-hostname-guard.ts +92 -0
  189. package/server/src/middleware/validate.ts +9 -0
  190. package/server/src/onboarding-assets/ceo/AGENTS.md +54 -0
  191. package/server/src/onboarding-assets/ceo/HEARTBEAT.md +72 -0
  192. package/server/src/onboarding-assets/ceo/SOUL.md +33 -0
  193. package/server/src/onboarding-assets/ceo/TOOLS.md +3 -0
  194. package/server/src/onboarding-assets/default/AGENTS.md +3 -0
  195. package/server/src/paths.ts +34 -0
  196. package/server/src/realtime/live-events-ws.ts +274 -0
  197. package/server/src/redaction.ts +59 -0
  198. package/server/src/routes/access.ts +2888 -0
  199. package/server/src/routes/activity.ts +89 -0
  200. package/server/src/routes/agents.ts +2313 -0
  201. package/server/src/routes/approvals.ts +346 -0
  202. package/server/src/routes/assets.ts +341 -0
  203. package/server/src/routes/authz.ts +52 -0
  204. package/server/src/routes/companies.ts +343 -0
  205. package/server/src/routes/company-skills.ts +300 -0
  206. package/server/src/routes/costs.ts +335 -0
  207. package/server/src/routes/dashboard.ts +19 -0
  208. package/server/src/routes/execution-workspaces.ts +182 -0
  209. package/server/src/routes/goals.ts +107 -0
  210. package/server/src/routes/health.ts +94 -0
  211. package/server/src/routes/index.ts +17 -0
  212. package/server/src/routes/instance-settings.ts +95 -0
  213. package/server/src/routes/issues-checkout-wakeup.ts +14 -0
  214. package/server/src/routes/issues.ts +1680 -0
  215. package/server/src/routes/llms.ts +86 -0
  216. package/server/src/routes/org-chart-svg.ts +777 -0
  217. package/server/src/routes/plugin-ui-static.ts +497 -0
  218. package/server/src/routes/plugins.ts +2220 -0
  219. package/server/src/routes/projects.ts +295 -0
  220. package/server/src/routes/routines.ts +300 -0
  221. package/server/src/routes/secrets.ts +166 -0
  222. package/server/src/routes/sidebar-badges.ts +52 -0
  223. package/server/src/secrets/external-stub-providers.ts +32 -0
  224. package/server/src/secrets/local-encrypted-provider.ts +135 -0
  225. package/server/src/secrets/provider-registry.ts +31 -0
  226. package/server/src/secrets/types.ts +23 -0
  227. package/server/src/services/access.ts +381 -0
  228. package/server/src/services/activity-log.ts +95 -0
  229. package/server/src/services/activity.ts +164 -0
  230. package/server/src/services/agent-instructions.ts +735 -0
  231. package/server/src/services/agent-permissions.ts +27 -0
  232. package/server/src/services/agents.ts +694 -0
  233. package/server/src/services/approvals.ts +273 -0
  234. package/server/src/services/assets.ts +23 -0
  235. package/server/src/services/board-auth.ts +355 -0
  236. package/server/src/services/budgets.ts +959 -0
  237. package/server/src/services/companies.ts +313 -0
  238. package/server/src/services/company-export-readme.ts +173 -0
  239. package/server/src/services/company-portability.ts +4263 -0
  240. package/server/src/services/company-skills.ts +2356 -0
  241. package/server/src/services/costs.ts +365 -0
  242. package/server/src/services/cron.ts +373 -0
  243. package/server/src/services/dashboard.ts +110 -0
  244. package/server/src/services/default-agent-instructions.ts +27 -0
  245. package/server/src/services/documents.ts +434 -0
  246. package/server/src/services/execution-workspace-policy.ts +210 -0
  247. package/server/src/services/execution-workspaces.ts +100 -0
  248. package/server/src/services/finance.ts +135 -0
  249. package/server/src/services/goals.ts +81 -0
  250. package/server/src/services/heartbeat-run-summary.ts +35 -0
  251. package/server/src/services/heartbeat.ts +3863 -0
  252. package/server/src/services/hire-hook.ts +114 -0
  253. package/server/src/services/index.ts +32 -0
  254. package/server/src/services/instance-settings.ts +138 -0
  255. package/server/src/services/issue-approvals.ts +175 -0
  256. package/server/src/services/issue-assignment-wakeup.ts +48 -0
  257. package/server/src/services/issue-goal-fallback.ts +56 -0
  258. package/server/src/services/issues.ts +1828 -0
  259. package/server/src/services/live-events.ts +55 -0
  260. package/server/src/services/plugin-capability-validator.ts +450 -0
  261. package/server/src/services/plugin-config-validator.ts +55 -0
  262. package/server/src/services/plugin-dev-watcher.ts +339 -0
  263. package/server/src/services/plugin-event-bus.ts +413 -0
  264. package/server/src/services/plugin-host-service-cleanup.ts +59 -0
  265. package/server/src/services/plugin-host-services.ts +1132 -0
  266. package/server/src/services/plugin-job-coordinator.ts +261 -0
  267. package/server/src/services/plugin-job-scheduler.ts +753 -0
  268. package/server/src/services/plugin-job-store.ts +466 -0
  269. package/server/src/services/plugin-lifecycle.ts +822 -0
  270. package/server/src/services/plugin-loader.ts +1955 -0
  271. package/server/src/services/plugin-log-retention.ts +87 -0
  272. package/server/src/services/plugin-manifest-validator.ts +164 -0
  273. package/server/src/services/plugin-registry.ts +683 -0
  274. package/server/src/services/plugin-runtime-sandbox.ts +222 -0
  275. package/server/src/services/plugin-secrets-handler.ts +355 -0
  276. package/server/src/services/plugin-state-store.ts +238 -0
  277. package/server/src/services/plugin-stream-bus.ts +81 -0
  278. package/server/src/services/plugin-tool-dispatcher.ts +449 -0
  279. package/server/src/services/plugin-tool-registry.ts +450 -0
  280. package/server/src/services/plugin-worker-manager.ts +1343 -0
  281. package/server/src/services/projects.ts +860 -0
  282. package/server/src/services/quota-windows.ts +65 -0
  283. package/server/src/services/routines.ts +1269 -0
  284. package/server/src/services/run-log-store.ts +156 -0
  285. package/server/src/services/secrets.ts +370 -0
  286. package/server/src/services/sidebar-badges.ts +56 -0
  287. package/server/src/services/work-products.ts +124 -0
  288. package/server/src/services/workspace-operation-log-store.ts +156 -0
  289. package/server/src/services/workspace-operations.ts +262 -0
  290. package/server/src/services/workspace-runtime.ts +1565 -0
  291. package/server/src/startup-banner.ts +176 -0
  292. package/server/src/storage/index.ts +35 -0
  293. package/server/src/storage/local-disk-provider.ts +89 -0
  294. package/server/src/storage/provider-registry.ts +18 -0
  295. package/server/src/storage/s3-provider.ts +153 -0
  296. package/server/src/storage/service.ts +131 -0
  297. package/server/src/storage/types.ts +63 -0
  298. package/server/src/ui-branding.ts +217 -0
  299. package/server/src/version.ts +10 -0
  300. package/server/src/worktree-config.ts +468 -0
  301. package/server/tsconfig.json +9 -0
  302. package/server/vitest.config.ts +7 -0
  303. package/skills/paperclip/SKILL.md +365 -0
  304. package/skills/paperclip/references/api-reference.md +647 -0
  305. package/skills/paperclip/references/company-skills.md +193 -0
  306. package/skills/paperclip-create-agent/SKILL.md +142 -0
  307. package/skills/paperclip-create-agent/references/api-reference.md +105 -0
  308. package/skills/paperclip-create-plugin/SKILL.md +102 -0
  309. package/skills/para-memory-files/SKILL.md +104 -0
  310. package/skills/para-memory-files/references/schemas.md +35 -0
  311. package/tests/e2e/onboarding.spec.ts +142 -0
  312. package/tests/e2e/playwright.config.ts +35 -0
  313. package/tests/release-smoke/docker-auth-onboarding.spec.ts +141 -0
  314. package/tests/release-smoke/playwright.config.ts +28 -0
  315. package/tsconfig.base.json +18 -0
  316. package/tsconfig.json +18 -0
  317. package/ui/README.md +12 -0
  318. package/ui/components.json +21 -0
  319. package/ui/index.html +47 -0
  320. package/ui/package.json +73 -0
  321. package/ui/public/android-chrome-192x192.png +0 -0
  322. package/ui/public/android-chrome-512x512.png +0 -0
  323. package/ui/public/apple-touch-icon.png +0 -0
  324. package/ui/public/brands/opencode-logo-dark-square.svg +18 -0
  325. package/ui/public/brands/opencode-logo-light-square.svg +18 -0
  326. package/ui/public/favicon-16x16.png +0 -0
  327. package/ui/public/favicon-32x32.png +0 -0
  328. package/ui/public/favicon.ico +0 -0
  329. package/ui/public/favicon.svg +9 -0
  330. package/ui/public/site.webmanifest +30 -0
  331. package/ui/public/sprites/1-D-1.png +0 -0
  332. package/ui/public/sprites/1-D-2.png +0 -0
  333. package/ui/public/sprites/1-D-3.png +0 -0
  334. package/ui/public/sprites/1-L-1.png +0 -0
  335. package/ui/public/sprites/1-R-1.png +0 -0
  336. package/ui/public/sprites/10-D-1.png +0 -0
  337. package/ui/public/sprites/10-D-2.png +0 -0
  338. package/ui/public/sprites/10-D-3.png +0 -0
  339. package/ui/public/sprites/10-L-1.png +0 -0
  340. package/ui/public/sprites/10-R-1.png +0 -0
  341. package/ui/public/sprites/11-D-1.png +0 -0
  342. package/ui/public/sprites/11-D-2.png +0 -0
  343. package/ui/public/sprites/11-D-3.png +0 -0
  344. package/ui/public/sprites/11-L-1.png +0 -0
  345. package/ui/public/sprites/11-R-1.png +0 -0
  346. package/ui/public/sprites/12-D-1.png +0 -0
  347. package/ui/public/sprites/12-D-2.png +0 -0
  348. package/ui/public/sprites/12-D-3.png +0 -0
  349. package/ui/public/sprites/12-L-1.png +0 -0
  350. package/ui/public/sprites/12-R-1.png +0 -0
  351. package/ui/public/sprites/13-D-1.png +0 -0
  352. package/ui/public/sprites/13-D-2.png +0 -0
  353. package/ui/public/sprites/13-D-3.png +0 -0
  354. package/ui/public/sprites/13-L-1.png +0 -0
  355. package/ui/public/sprites/13-R-1.png +0 -0
  356. package/ui/public/sprites/14-D-1.png +0 -0
  357. package/ui/public/sprites/14-D-2.png +0 -0
  358. package/ui/public/sprites/14-D-3.png +0 -0
  359. package/ui/public/sprites/14-L-1.png +0 -0
  360. package/ui/public/sprites/14-R-1.png +0 -0
  361. package/ui/public/sprites/2-D-1.png +0 -0
  362. package/ui/public/sprites/2-D-2.png +0 -0
  363. package/ui/public/sprites/2-D-3.png +0 -0
  364. package/ui/public/sprites/2-L-1.png +0 -0
  365. package/ui/public/sprites/2-R-1.png +0 -0
  366. package/ui/public/sprites/3-D-1.png +0 -0
  367. package/ui/public/sprites/3-D-2.png +0 -0
  368. package/ui/public/sprites/3-D-3.png +0 -0
  369. package/ui/public/sprites/3-L-1.png +0 -0
  370. package/ui/public/sprites/3-R-1.png +0 -0
  371. package/ui/public/sprites/4-D-1.png +0 -0
  372. package/ui/public/sprites/4-D-2.png +0 -0
  373. package/ui/public/sprites/4-D-3.png +0 -0
  374. package/ui/public/sprites/4-L-1.png +0 -0
  375. package/ui/public/sprites/4-R-1.png +0 -0
  376. package/ui/public/sprites/5-D-1.png +0 -0
  377. package/ui/public/sprites/5-D-2.png +0 -0
  378. package/ui/public/sprites/5-D-3.png +0 -0
  379. package/ui/public/sprites/5-L-1.png +0 -0
  380. package/ui/public/sprites/5-R-1.png +0 -0
  381. package/ui/public/sprites/6-D-1.png +0 -0
  382. package/ui/public/sprites/6-D-2.png +0 -0
  383. package/ui/public/sprites/6-D-3.png +0 -0
  384. package/ui/public/sprites/6-L-1.png +0 -0
  385. package/ui/public/sprites/6-R-1.png +0 -0
  386. package/ui/public/sprites/7-D-1.png +0 -0
  387. package/ui/public/sprites/7-D-2.png +0 -0
  388. package/ui/public/sprites/7-D-3.png +0 -0
  389. package/ui/public/sprites/7-L-1.png +0 -0
  390. package/ui/public/sprites/7-R-1.png +0 -0
  391. package/ui/public/sprites/8-D-1.png +0 -0
  392. package/ui/public/sprites/8-D-2.png +0 -0
  393. package/ui/public/sprites/8-D-3.png +0 -0
  394. package/ui/public/sprites/8-L-1.png +0 -0
  395. package/ui/public/sprites/8-R-1.png +0 -0
  396. package/ui/public/sprites/9-D-1.png +0 -0
  397. package/ui/public/sprites/9-D-2.png +0 -0
  398. package/ui/public/sprites/9-D-3.png +0 -0
  399. package/ui/public/sprites/9-L-1.png +0 -0
  400. package/ui/public/sprites/9-R-1.png +0 -0
  401. package/ui/public/sprites/ceo-lobster.png +0 -0
  402. package/ui/public/sw.js +42 -0
  403. package/ui/public/worktree-favicon-16x16.png +0 -0
  404. package/ui/public/worktree-favicon-32x32.png +0 -0
  405. package/ui/public/worktree-favicon.ico +0 -0
  406. package/ui/public/worktree-favicon.svg +9 -0
  407. package/ui/src/App.tsx +354 -0
  408. package/ui/src/adapters/claude-local/config-fields.tsx +138 -0
  409. package/ui/src/adapters/claude-local/index.ts +13 -0
  410. package/ui/src/adapters/codex-local/config-fields.tsx +104 -0
  411. package/ui/src/adapters/codex-local/index.ts +13 -0
  412. package/ui/src/adapters/cursor/config-fields.tsx +49 -0
  413. package/ui/src/adapters/cursor/index.ts +13 -0
  414. package/ui/src/adapters/gemini-local/config-fields.tsx +51 -0
  415. package/ui/src/adapters/gemini-local/index.ts +13 -0
  416. package/ui/src/adapters/http/build-config.ts +9 -0
  417. package/ui/src/adapters/http/config-fields.tsx +38 -0
  418. package/ui/src/adapters/http/index.ts +12 -0
  419. package/ui/src/adapters/http/parse-stdout.ts +5 -0
  420. package/ui/src/adapters/index.ts +9 -0
  421. package/ui/src/adapters/local-workspace-runtime-fields.tsx +5 -0
  422. package/ui/src/adapters/openclaw-gateway/config-fields.tsx +237 -0
  423. package/ui/src/adapters/openclaw-gateway/index.ts +13 -0
  424. package/ui/src/adapters/opencode-local/config-fields.tsx +72 -0
  425. package/ui/src/adapters/opencode-local/index.ts +13 -0
  426. package/ui/src/adapters/pi-local/config-fields.tsx +49 -0
  427. package/ui/src/adapters/pi-local/index.ts +13 -0
  428. package/ui/src/adapters/process/build-config.ts +18 -0
  429. package/ui/src/adapters/process/config-fields.tsx +77 -0
  430. package/ui/src/adapters/process/index.ts +12 -0
  431. package/ui/src/adapters/process/parse-stdout.ts +5 -0
  432. package/ui/src/adapters/registry.ts +34 -0
  433. package/ui/src/adapters/runtime-json-fields.tsx +122 -0
  434. package/ui/src/adapters/transcript.test.ts +30 -0
  435. package/ui/src/adapters/transcript.ts +62 -0
  436. package/ui/src/adapters/types.ts +34 -0
  437. package/ui/src/api/access.ts +160 -0
  438. package/ui/src/api/activity.ts +37 -0
  439. package/ui/src/api/agents.ts +194 -0
  440. package/ui/src/api/approvals.ts +25 -0
  441. package/ui/src/api/assets.ts +30 -0
  442. package/ui/src/api/auth.ts +74 -0
  443. package/ui/src/api/budgets.ts +21 -0
  444. package/ui/src/api/client.ts +50 -0
  445. package/ui/src/api/companies.ts +59 -0
  446. package/ui/src/api/companySkills.ts +55 -0
  447. package/ui/src/api/costs.ts +60 -0
  448. package/ui/src/api/dashboard.ts +7 -0
  449. package/ui/src/api/execution-workspaces.ts +27 -0
  450. package/ui/src/api/goals.ts +12 -0
  451. package/ui/src/api/health.ts +41 -0
  452. package/ui/src/api/heartbeats.ts +62 -0
  453. package/ui/src/api/index.ts +18 -0
  454. package/ui/src/api/instanceSettings.ts +19 -0
  455. package/ui/src/api/issues.ts +115 -0
  456. package/ui/src/api/plugins.ts +424 -0
  457. package/ui/src/api/projects.ts +34 -0
  458. package/ui/src/api/routines.ts +59 -0
  459. package/ui/src/api/secrets.ts +26 -0
  460. package/ui/src/api/sidebarBadges.ts +7 -0
  461. package/ui/src/components/AccountingModelCard.tsx +69 -0
  462. package/ui/src/components/ActiveAgentsPanel.tsx +157 -0
  463. package/ui/src/components/ActivityCharts.tsx +264 -0
  464. package/ui/src/components/ActivityRow.tsx +147 -0
  465. package/ui/src/components/AgentActionButtons.tsx +51 -0
  466. package/ui/src/components/AgentConfigForm.tsx +1468 -0
  467. package/ui/src/components/AgentIconPicker.tsx +81 -0
  468. package/ui/src/components/AgentProperties.tsx +107 -0
  469. package/ui/src/components/ApprovalCard.tsx +107 -0
  470. package/ui/src/components/ApprovalPayload.tsx +134 -0
  471. package/ui/src/components/AsciiArtAnimation.tsx +355 -0
  472. package/ui/src/components/BillerSpendCard.tsx +146 -0
  473. package/ui/src/components/BreadcrumbBar.tsx +113 -0
  474. package/ui/src/components/BudgetIncidentCard.tsx +101 -0
  475. package/ui/src/components/BudgetPolicyCard.tsx +220 -0
  476. package/ui/src/components/BudgetSidebarMarker.tsx +13 -0
  477. package/ui/src/components/ClaudeSubscriptionPanel.tsx +141 -0
  478. package/ui/src/components/CodexSubscriptionPanel.tsx +158 -0
  479. package/ui/src/components/CommandPalette.tsx +239 -0
  480. package/ui/src/components/CommentThread.tsx +503 -0
  481. package/ui/src/components/CompanyPatternIcon.tsx +212 -0
  482. package/ui/src/components/CompanyRail.tsx +329 -0
  483. package/ui/src/components/CompanySwitcher.tsx +81 -0
  484. package/ui/src/components/CopyText.tsx +56 -0
  485. package/ui/src/components/DevRestartBanner.tsx +89 -0
  486. package/ui/src/components/EmptyState.tsx +27 -0
  487. package/ui/src/components/EntityRow.tsx +69 -0
  488. package/ui/src/components/FilterBar.tsx +39 -0
  489. package/ui/src/components/FinanceBillerCard.tsx +45 -0
  490. package/ui/src/components/FinanceKindCard.tsx +44 -0
  491. package/ui/src/components/FinanceTimelineCard.tsx +72 -0
  492. package/ui/src/components/GoalProperties.tsx +165 -0
  493. package/ui/src/components/GoalTree.tsx +118 -0
  494. package/ui/src/components/Identity.tsx +39 -0
  495. package/ui/src/components/InlineEditor.tsx +248 -0
  496. package/ui/src/components/InlineEntitySelector.tsx +206 -0
  497. package/ui/src/components/InstanceSidebar.tsx +53 -0
  498. package/ui/src/components/IssueDocumentsSection.tsx +892 -0
  499. package/ui/src/components/IssueProperties.tsx +621 -0
  500. package/ui/src/components/IssueRow.tsx +149 -0
  501. package/ui/src/components/IssueWorkspaceCard.tsx +404 -0
  502. package/ui/src/components/IssuesList.tsx +889 -0
  503. package/ui/src/components/JsonSchemaForm.tsx +1048 -0
  504. package/ui/src/components/KanbanBoard.tsx +275 -0
  505. package/ui/src/components/Layout.tsx +441 -0
  506. package/ui/src/components/LiveRunWidget.tsx +160 -0
  507. package/ui/src/components/MarkdownBody.test.tsx +50 -0
  508. package/ui/src/components/MarkdownBody.tsx +152 -0
  509. package/ui/src/components/MarkdownEditor.tsx +622 -0
  510. package/ui/src/components/MetricCard.tsx +53 -0
  511. package/ui/src/components/MobileBottomNav.tsx +123 -0
  512. package/ui/src/components/NewAgentDialog.tsx +223 -0
  513. package/ui/src/components/NewGoalDialog.tsx +283 -0
  514. package/ui/src/components/NewIssueDialog.tsx +1473 -0
  515. package/ui/src/components/NewProjectDialog.tsx +451 -0
  516. package/ui/src/components/OnboardingWizard.tsx +1392 -0
  517. package/ui/src/components/OpenCodeLogoIcon.tsx +22 -0
  518. package/ui/src/components/PackageFileTree.tsx +318 -0
  519. package/ui/src/components/PageSkeleton.tsx +180 -0
  520. package/ui/src/components/PageTabBar.tsx +45 -0
  521. package/ui/src/components/PathInstructionsModal.tsx +143 -0
  522. package/ui/src/components/PriorityIcon.tsx +77 -0
  523. package/ui/src/components/ProjectProperties.tsx +1127 -0
  524. package/ui/src/components/PropertiesPanel.tsx +29 -0
  525. package/ui/src/components/ProviderQuotaCard.tsx +417 -0
  526. package/ui/src/components/QuotaBar.tsx +65 -0
  527. package/ui/src/components/ReportsToPicker.tsx +127 -0
  528. package/ui/src/components/ScheduleEditor.tsx +344 -0
  529. package/ui/src/components/ScrollToBottom.tsx +79 -0
  530. package/ui/src/components/Sidebar.tsx +130 -0
  531. package/ui/src/components/SidebarAgents.tsx +146 -0
  532. package/ui/src/components/SidebarNavItem.tsx +92 -0
  533. package/ui/src/components/SidebarProjects.tsx +234 -0
  534. package/ui/src/components/SidebarSection.tsx +17 -0
  535. package/ui/src/components/StatusBadge.tsx +15 -0
  536. package/ui/src/components/StatusIcon.tsx +71 -0
  537. package/ui/src/components/SwipeToArchive.tsx +152 -0
  538. package/ui/src/components/ToastViewport.tsx +99 -0
  539. package/ui/src/components/WorktreeBanner.tsx +25 -0
  540. package/ui/src/components/agent-config-defaults.ts +31 -0
  541. package/ui/src/components/agent-config-primitives.tsx +476 -0
  542. package/ui/src/components/transcript/RunTranscriptView.test.tsx +84 -0
  543. package/ui/src/components/transcript/RunTranscriptView.tsx +1015 -0
  544. package/ui/src/components/transcript/useLiveRunTranscripts.ts +297 -0
  545. package/ui/src/components/ui/avatar.tsx +107 -0
  546. package/ui/src/components/ui/badge.tsx +48 -0
  547. package/ui/src/components/ui/breadcrumb.tsx +109 -0
  548. package/ui/src/components/ui/button.tsx +64 -0
  549. package/ui/src/components/ui/card.tsx +92 -0
  550. package/ui/src/components/ui/checkbox.tsx +32 -0
  551. package/ui/src/components/ui/collapsible.tsx +33 -0
  552. package/ui/src/components/ui/command.tsx +194 -0
  553. package/ui/src/components/ui/dialog.tsx +156 -0
  554. package/ui/src/components/ui/dropdown-menu.tsx +257 -0
  555. package/ui/src/components/ui/input.tsx +21 -0
  556. package/ui/src/components/ui/label.tsx +22 -0
  557. package/ui/src/components/ui/popover.tsx +88 -0
  558. package/ui/src/components/ui/scroll-area.tsx +56 -0
  559. package/ui/src/components/ui/select.tsx +188 -0
  560. package/ui/src/components/ui/separator.tsx +28 -0
  561. package/ui/src/components/ui/sheet.tsx +143 -0
  562. package/ui/src/components/ui/skeleton.tsx +13 -0
  563. package/ui/src/components/ui/tabs.tsx +89 -0
  564. package/ui/src/components/ui/textarea.tsx +18 -0
  565. package/ui/src/components/ui/tooltip.tsx +57 -0
  566. package/ui/src/components/visual-office/AgentAvatar.tsx +99 -0
  567. package/ui/src/components/visual-office/OfficeViewExact.tsx +417 -0
  568. package/ui/src/components/visual-office/i18n.ts +52 -0
  569. package/ui/src/components/visual-office/office-view/CliUsagePanel.tsx +240 -0
  570. package/ui/src/components/visual-office/office-view/VirtualPadOverlay.tsx +104 -0
  571. package/ui/src/components/visual-office/office-view/buildScene-break-room.ts +248 -0
  572. package/ui/src/components/visual-office/office-view/buildScene-ceo-hallway.ts +345 -0
  573. package/ui/src/components/visual-office/office-view/buildScene-department-agent.ts +242 -0
  574. package/ui/src/components/visual-office/office-view/buildScene-departments.ts +360 -0
  575. package/ui/src/components/visual-office/office-view/buildScene-final-layers.ts +113 -0
  576. package/ui/src/components/visual-office/office-view/buildScene-types.ts +91 -0
  577. package/ui/src/components/visual-office/office-view/buildScene.ts +232 -0
  578. package/ui/src/components/visual-office/office-view/drawing-core.ts +374 -0
  579. package/ui/src/components/visual-office/office-view/drawing-furniture-a.ts +338 -0
  580. package/ui/src/components/visual-office/office-view/drawing-furniture-b.ts +241 -0
  581. package/ui/src/components/visual-office/office-view/model.ts +301 -0
  582. package/ui/src/components/visual-office/office-view/officeTicker.ts +455 -0
  583. package/ui/src/components/visual-office/office-view/officeTickerRoomAndDelivery.ts +133 -0
  584. package/ui/src/components/visual-office/office-view/themes-locale.ts +460 -0
  585. package/ui/src/components/visual-office/office-view/useCliUsage.ts +37 -0
  586. package/ui/src/components/visual-office/office-view/useOfficeDeliveryEffects.ts +465 -0
  587. package/ui/src/components/visual-office/office-view/useOfficePixiRuntime.ts +282 -0
  588. package/ui/src/components/visual-office/types.ts +123 -0
  589. package/ui/src/context/BreadcrumbContext.tsx +44 -0
  590. package/ui/src/context/CompanyContext.tsx +151 -0
  591. package/ui/src/context/DialogContext.tsx +135 -0
  592. package/ui/src/context/LiveUpdatesProvider.test.ts +119 -0
  593. package/ui/src/context/LiveUpdatesProvider.tsx +760 -0
  594. package/ui/src/context/PanelContext.tsx +73 -0
  595. package/ui/src/context/SidebarContext.tsx +43 -0
  596. package/ui/src/context/ThemeContext.tsx +83 -0
  597. package/ui/src/context/ToastContext.tsx +172 -0
  598. package/ui/src/fixtures/runTranscriptFixtures.ts +226 -0
  599. package/ui/src/hooks/useAgentOrder.ts +105 -0
  600. package/ui/src/hooks/useAutosaveIndicator.ts +72 -0
  601. package/ui/src/hooks/useCompanyPageMemory.test.ts +90 -0
  602. package/ui/src/hooks/useCompanyPageMemory.ts +79 -0
  603. package/ui/src/hooks/useDateRange.ts +120 -0
  604. package/ui/src/hooks/useInboxBadge.ts +132 -0
  605. package/ui/src/hooks/useKeyboardShortcuts.ts +40 -0
  606. package/ui/src/hooks/useProjectOrder.ts +106 -0
  607. package/ui/src/index.css +770 -0
  608. package/ui/src/lib/agent-icons.ts +99 -0
  609. package/ui/src/lib/agent-order.ts +107 -0
  610. package/ui/src/lib/agent-skills-state.test.ts +90 -0
  611. package/ui/src/lib/agent-skills-state.ts +41 -0
  612. package/ui/src/lib/assignees.test.ts +92 -0
  613. package/ui/src/lib/assignees.ts +82 -0
  614. package/ui/src/lib/color-contrast.ts +107 -0
  615. package/ui/src/lib/company-export-selection.test.ts +41 -0
  616. package/ui/src/lib/company-export-selection.ts +57 -0
  617. package/ui/src/lib/company-page-memory.ts +65 -0
  618. package/ui/src/lib/company-portability-sidebar.test.ts +101 -0
  619. package/ui/src/lib/company-portability-sidebar.ts +62 -0
  620. package/ui/src/lib/company-routes.ts +88 -0
  621. package/ui/src/lib/company-selection.test.ts +34 -0
  622. package/ui/src/lib/company-selection.ts +18 -0
  623. package/ui/src/lib/groupBy.ts +11 -0
  624. package/ui/src/lib/inbox.test.ts +404 -0
  625. package/ui/src/lib/inbox.ts +292 -0
  626. package/ui/src/lib/instance-settings.test.ts +26 -0
  627. package/ui/src/lib/instance-settings.ts +25 -0
  628. package/ui/src/lib/issueDetailBreadcrumb.ts +24 -0
  629. package/ui/src/lib/legacy-agent-config.test.ts +40 -0
  630. package/ui/src/lib/legacy-agent-config.ts +17 -0
  631. package/ui/src/lib/mention-aware-link-node.test.ts +50 -0
  632. package/ui/src/lib/mention-aware-link-node.ts +67 -0
  633. package/ui/src/lib/mention-chips.ts +168 -0
  634. package/ui/src/lib/mention-deletion.test.ts +87 -0
  635. package/ui/src/lib/mention-deletion.ts +143 -0
  636. package/ui/src/lib/model-utils.ts +16 -0
  637. package/ui/src/lib/onboarding-goal.test.ts +22 -0
  638. package/ui/src/lib/onboarding-goal.ts +18 -0
  639. package/ui/src/lib/onboarding-launch.test.ts +131 -0
  640. package/ui/src/lib/onboarding-launch.ts +54 -0
  641. package/ui/src/lib/onboarding-route.test.ts +80 -0
  642. package/ui/src/lib/onboarding-route.ts +51 -0
  643. package/ui/src/lib/portable-files.ts +42 -0
  644. package/ui/src/lib/project-order.ts +71 -0
  645. package/ui/src/lib/queryKeys.ts +140 -0
  646. package/ui/src/lib/recent-assignees.ts +36 -0
  647. package/ui/src/lib/router.tsx +76 -0
  648. package/ui/src/lib/routine-trigger-patch.test.ts +72 -0
  649. package/ui/src/lib/routine-trigger-patch.ts +31 -0
  650. package/ui/src/lib/status-colors.ts +108 -0
  651. package/ui/src/lib/timeAgo.ts +31 -0
  652. package/ui/src/lib/utils.ts +168 -0
  653. package/ui/src/lib/worktree-branding.ts +65 -0
  654. package/ui/src/lib/zip.test.ts +289 -0
  655. package/ui/src/lib/zip.ts +284 -0
  656. package/ui/src/main.tsx +67 -0
  657. package/ui/src/pages/Activity.tsx +141 -0
  658. package/ui/src/pages/AgentDetail.tsx +4053 -0
  659. package/ui/src/pages/Agents.tsx +415 -0
  660. package/ui/src/pages/ApprovalDetail.tsx +369 -0
  661. package/ui/src/pages/Approvals.tsx +132 -0
  662. package/ui/src/pages/Auth.tsx +180 -0
  663. package/ui/src/pages/BoardClaim.tsx +125 -0
  664. package/ui/src/pages/CliAuth.tsx +184 -0
  665. package/ui/src/pages/Companies.tsx +297 -0
  666. package/ui/src/pages/CompanyExport.tsx +1019 -0
  667. package/ui/src/pages/CompanyImport.tsx +1355 -0
  668. package/ui/src/pages/CompanySettings.tsx +661 -0
  669. package/ui/src/pages/CompanySkills.tsx +1171 -0
  670. package/ui/src/pages/Costs.tsx +1103 -0
  671. package/ui/src/pages/Dashboard.tsx +388 -0
  672. package/ui/src/pages/DesignGuide.tsx +1330 -0
  673. package/ui/src/pages/ExecutionWorkspaceDetail.tsx +82 -0
  674. package/ui/src/pages/GoalDetail.tsx +197 -0
  675. package/ui/src/pages/Goals.tsx +63 -0
  676. package/ui/src/pages/Inbox.tsx +1291 -0
  677. package/ui/src/pages/InstanceExperimentalSettings.tsx +139 -0
  678. package/ui/src/pages/InstanceGeneralSettings.tsx +104 -0
  679. package/ui/src/pages/InstanceSettings.tsx +284 -0
  680. package/ui/src/pages/InviteLanding.tsx +320 -0
  681. package/ui/src/pages/IssueDetail.tsx +1201 -0
  682. package/ui/src/pages/Issues.tsx +116 -0
  683. package/ui/src/pages/MyIssues.tsx +72 -0
  684. package/ui/src/pages/NewAgent.tsx +353 -0
  685. package/ui/src/pages/NotFound.tsx +66 -0
  686. package/ui/src/pages/Org.tsx +132 -0
  687. package/ui/src/pages/OrgChart.tsx +447 -0
  688. package/ui/src/pages/PluginManager.tsx +510 -0
  689. package/ui/src/pages/PluginPage.tsx +156 -0
  690. package/ui/src/pages/PluginSettings.tsx +836 -0
  691. package/ui/src/pages/ProjectDetail.tsx +633 -0
  692. package/ui/src/pages/Projects.tsx +87 -0
  693. package/ui/src/pages/RoutineDetail.tsx +1022 -0
  694. package/ui/src/pages/Routines.tsx +661 -0
  695. package/ui/src/pages/RunTranscriptUxLab.tsx +334 -0
  696. package/ui/src/pages/VisualOffice.tsx +243 -0
  697. package/ui/src/plugins/bridge-init.ts +69 -0
  698. package/ui/src/plugins/bridge.ts +476 -0
  699. package/ui/src/plugins/launchers.tsx +834 -0
  700. package/ui/src/plugins/slots.tsx +855 -0
  701. package/ui/tsconfig.json +21 -0
  702. package/ui/vite.config.ts +23 -0
  703. package/ui/vitest.config.ts +14 -0
  704. package/vitest.config.ts +11 -0
@@ -0,0 +1,4053 @@
1
+ import { useCallback, useEffect, useMemo, useState, useRef } from "react";
2
+ import { useParams, useNavigate, Link, Navigate, useBeforeUnload } from "@/lib/router";
3
+ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
4
+ import {
5
+ agentsApi,
6
+ type AgentKey,
7
+ type ClaudeLoginResult,
8
+ type AgentPermissionUpdate,
9
+ } from "../api/agents";
10
+ import { companySkillsApi } from "../api/companySkills";
11
+ import { budgetsApi } from "../api/budgets";
12
+ import { heartbeatsApi } from "../api/heartbeats";
13
+ import { instanceSettingsApi } from "../api/instanceSettings";
14
+ import { ApiError } from "../api/client";
15
+ import { ChartCard, RunActivityChart, PriorityChart, IssueStatusChart, SuccessRateChart } from "../components/ActivityCharts";
16
+ import { activityApi } from "../api/activity";
17
+ import { issuesApi } from "../api/issues";
18
+ import { usePanel } from "../context/PanelContext";
19
+ import { useSidebar } from "../context/SidebarContext";
20
+ import { useCompany } from "../context/CompanyContext";
21
+ import { useToast } from "../context/ToastContext";
22
+ import { useDialog } from "../context/DialogContext";
23
+ import { useBreadcrumbs } from "../context/BreadcrumbContext";
24
+ import { queryKeys } from "../lib/queryKeys";
25
+ import { AgentConfigForm } from "../components/AgentConfigForm";
26
+ import { PageTabBar } from "../components/PageTabBar";
27
+ import { adapterLabels, roleLabels, help } from "../components/agent-config-primitives";
28
+ import { MarkdownEditor } from "../components/MarkdownEditor";
29
+ import { assetsApi } from "../api/assets";
30
+ import { getUIAdapter, buildTranscript } from "../adapters";
31
+ import { StatusBadge } from "../components/StatusBadge";
32
+ import { agentStatusDot, agentStatusDotDefault } from "../lib/status-colors";
33
+ import { MarkdownBody } from "../components/MarkdownBody";
34
+ import { CopyText } from "../components/CopyText";
35
+ import { EntityRow } from "../components/EntityRow";
36
+ import { Identity } from "../components/Identity";
37
+ import { PageSkeleton } from "../components/PageSkeleton";
38
+ import { RunButton, PauseResumeButton } from "../components/AgentActionButtons";
39
+ import { BudgetPolicyCard } from "../components/BudgetPolicyCard";
40
+ import { PackageFileTree, buildFileTree } from "../components/PackageFileTree";
41
+ import { ScrollToBottom } from "../components/ScrollToBottom";
42
+ import { formatCents, formatDate, relativeTime, formatTokens, visibleRunCostUsd } from "../lib/utils";
43
+ import { cn } from "../lib/utils";
44
+ import { Button } from "@/components/ui/button";
45
+ import { Skeleton } from "@/components/ui/skeleton";
46
+ import { Tabs } from "@/components/ui/tabs";
47
+ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
48
+ import {
49
+ Popover,
50
+ PopoverContent,
51
+ PopoverTrigger,
52
+ } from "@/components/ui/popover";
53
+ import {
54
+ MoreHorizontal,
55
+ CheckCircle2,
56
+ XCircle,
57
+ Clock,
58
+ Timer,
59
+ Loader2,
60
+ Slash,
61
+ RotateCcw,
62
+ Trash2,
63
+ Plus,
64
+ Key,
65
+ Eye,
66
+ EyeOff,
67
+ Copy,
68
+ ChevronRight,
69
+ ChevronDown,
70
+ ArrowLeft,
71
+ HelpCircle,
72
+ FolderOpen,
73
+ } from "lucide-react";
74
+ import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@/components/ui/collapsible";
75
+ import { TooltipProvider } from "@/components/ui/tooltip";
76
+ import { Input } from "@/components/ui/input";
77
+ import { AgentIcon, AgentIconPicker } from "../components/AgentIconPicker";
78
+ import { RunTranscriptView, type TranscriptMode } from "../components/transcript/RunTranscriptView";
79
+ import {
80
+ isUuidLike,
81
+ type Agent,
82
+ type AgentSkillEntry,
83
+ type AgentSkillSnapshot,
84
+ type AgentDetail as AgentDetailRecord,
85
+ type BudgetPolicySummary,
86
+ type HeartbeatRun,
87
+ type HeartbeatRunEvent,
88
+ type AgentRuntimeState,
89
+ type LiveEvent,
90
+ type WorkspaceOperation,
91
+ } from "@corporateai/shared";
92
+ import { redactHomePathUserSegments, redactHomePathUserSegmentsInValue } from "@corporateai/adapter-utils";
93
+ import { agentRouteRef } from "../lib/utils";
94
+ import {
95
+ applyAgentSkillSnapshot,
96
+ arraysEqual,
97
+ isReadOnlyUnmanagedSkillEntry,
98
+ } from "../lib/agent-skills-state";
99
+
100
+ const runStatusIcons: Record<string, { icon: typeof CheckCircle2; color: string }> = {
101
+ succeeded: { icon: CheckCircle2, color: "text-green-600 dark:text-green-400" },
102
+ failed: { icon: XCircle, color: "text-red-600 dark:text-red-400" },
103
+ running: { icon: Loader2, color: "text-cyan-600 dark:text-cyan-400" },
104
+ queued: { icon: Clock, color: "text-yellow-600 dark:text-yellow-400" },
105
+ timed_out: { icon: Timer, color: "text-orange-600 dark:text-orange-400" },
106
+ cancelled: { icon: Slash, color: "text-neutral-500 dark:text-neutral-400" },
107
+ };
108
+
109
+ const REDACTED_ENV_VALUE = "***REDACTED***";
110
+ const SECRET_ENV_KEY_RE =
111
+ /(api[-_]?key|access[-_]?token|auth(?:_?token)?|authorization|bearer|secret|passwd|password|credential|jwt|private[-_]?key|cookie|connectionstring)/i;
112
+ const JWT_VALUE_RE = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)?$/;
113
+
114
+ function redactPathText(value: string, censorUsernameInLogs: boolean) {
115
+ return redactHomePathUserSegments(value, { enabled: censorUsernameInLogs });
116
+ }
117
+
118
+ function redactPathValue<T>(value: T, censorUsernameInLogs: boolean): T {
119
+ return redactHomePathUserSegmentsInValue(value, { enabled: censorUsernameInLogs });
120
+ }
121
+
122
+ function shouldRedactSecretValue(key: string, value: unknown): boolean {
123
+ if (SECRET_ENV_KEY_RE.test(key)) return true;
124
+ if (typeof value !== "string") return false;
125
+ return JWT_VALUE_RE.test(value);
126
+ }
127
+
128
+ function redactEnvValue(key: string, value: unknown, censorUsernameInLogs: boolean): string {
129
+ if (
130
+ typeof value === "object" &&
131
+ value !== null &&
132
+ !Array.isArray(value) &&
133
+ (value as { type?: unknown }).type === "secret_ref"
134
+ ) {
135
+ return "***SECRET_REF***";
136
+ }
137
+ if (shouldRedactSecretValue(key, value)) return REDACTED_ENV_VALUE;
138
+ if (value === null || value === undefined) return "";
139
+ if (typeof value === "string") return redactPathText(value, censorUsernameInLogs);
140
+ try {
141
+ return JSON.stringify(redactPathValue(value, censorUsernameInLogs));
142
+ } catch {
143
+ return redactPathText(String(value), censorUsernameInLogs);
144
+ }
145
+ }
146
+
147
+ function isMarkdown(pathValue: string) {
148
+ return pathValue.toLowerCase().endsWith(".md");
149
+ }
150
+
151
+ function formatEnvForDisplay(envValue: unknown, censorUsernameInLogs: boolean): string {
152
+ const env = asRecord(envValue);
153
+ if (!env) return "<unable-to-parse>";
154
+
155
+ const keys = Object.keys(env);
156
+ if (keys.length === 0) return "<empty>";
157
+
158
+ return keys
159
+ .sort()
160
+ .map((key) => `${key}=${redactEnvValue(key, env[key], censorUsernameInLogs)}`)
161
+ .join("\n");
162
+ }
163
+
164
+ const sourceLabels: Record<string, string> = {
165
+ timer: "Timer",
166
+ assignment: "Assignment",
167
+ on_demand: "On-demand",
168
+ automation: "Automation",
169
+ };
170
+
171
+ const LIVE_SCROLL_BOTTOM_TOLERANCE_PX = 32;
172
+ type ScrollContainer = Window | HTMLElement;
173
+
174
+ function isWindowContainer(container: ScrollContainer): container is Window {
175
+ return container === window;
176
+ }
177
+
178
+ function isElementScrollContainer(element: HTMLElement): boolean {
179
+ const overflowY = window.getComputedStyle(element).overflowY;
180
+ return overflowY === "auto" || overflowY === "scroll" || overflowY === "overlay";
181
+ }
182
+
183
+ function findScrollContainer(anchor: HTMLElement | null): ScrollContainer {
184
+ let parent = anchor?.parentElement ?? null;
185
+ while (parent) {
186
+ if (isElementScrollContainer(parent)) return parent;
187
+ parent = parent.parentElement;
188
+ }
189
+ return window;
190
+ }
191
+
192
+ function readScrollMetrics(container: ScrollContainer): { scrollHeight: number; distanceFromBottom: number } {
193
+ if (isWindowContainer(container)) {
194
+ const pageHeight = Math.max(
195
+ document.documentElement.scrollHeight,
196
+ document.body.scrollHeight,
197
+ );
198
+ const viewportBottom = window.scrollY + window.innerHeight;
199
+ return {
200
+ scrollHeight: pageHeight,
201
+ distanceFromBottom: Math.max(0, pageHeight - viewportBottom),
202
+ };
203
+ }
204
+
205
+ const viewportBottom = container.scrollTop + container.clientHeight;
206
+ return {
207
+ scrollHeight: container.scrollHeight,
208
+ distanceFromBottom: Math.max(0, container.scrollHeight - viewportBottom),
209
+ };
210
+ }
211
+
212
+ function scrollToContainerBottom(container: ScrollContainer, behavior: ScrollBehavior = "auto") {
213
+ if (isWindowContainer(container)) {
214
+ const pageHeight = Math.max(
215
+ document.documentElement.scrollHeight,
216
+ document.body.scrollHeight,
217
+ );
218
+ window.scrollTo({ top: pageHeight, behavior });
219
+ return;
220
+ }
221
+
222
+ container.scrollTo({ top: container.scrollHeight, behavior });
223
+ }
224
+
225
+ type AgentDetailView = "dashboard" | "instructions" | "configuration" | "skills" | "runs" | "budget";
226
+
227
+ function parseAgentDetailView(value: string | null): AgentDetailView {
228
+ if (value === "instructions" || value === "prompts") return "instructions";
229
+ if (value === "configure" || value === "configuration") return "configuration";
230
+ if (value === "skills") return "skills";
231
+ if (value === "budget") return "budget";
232
+ if (value === "runs") return value;
233
+ return "dashboard";
234
+ }
235
+
236
+ function usageNumber(usage: Record<string, unknown> | null, ...keys: string[]) {
237
+ if (!usage) return 0;
238
+ for (const key of keys) {
239
+ const value = usage[key];
240
+ if (typeof value === "number" && Number.isFinite(value)) return value;
241
+ }
242
+ return 0;
243
+ }
244
+
245
+ function setsEqual<T>(left: Set<T>, right: Set<T>) {
246
+ if (left.size !== right.size) return false;
247
+ for (const value of left) {
248
+ if (!right.has(value)) return false;
249
+ }
250
+ return true;
251
+ }
252
+
253
+ function runMetrics(run: HeartbeatRun) {
254
+ const usage = (run.usageJson ?? null) as Record<string, unknown> | null;
255
+ const result = (run.resultJson ?? null) as Record<string, unknown> | null;
256
+ const input = usageNumber(usage, "inputTokens", "input_tokens");
257
+ const output = usageNumber(usage, "outputTokens", "output_tokens");
258
+ const cached = usageNumber(
259
+ usage,
260
+ "cachedInputTokens",
261
+ "cached_input_tokens",
262
+ "cache_read_input_tokens",
263
+ );
264
+ const cost =
265
+ visibleRunCostUsd(usage, result);
266
+ return {
267
+ input,
268
+ output,
269
+ cached,
270
+ cost,
271
+ totalTokens: input + output,
272
+ };
273
+ }
274
+
275
+ type RunLogChunk = { ts: string; stream: "stdout" | "stderr" | "system"; chunk: string };
276
+
277
+ function asRecord(value: unknown): Record<string, unknown> | null {
278
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
279
+ return value as Record<string, unknown>;
280
+ }
281
+
282
+ function asNonEmptyString(value: unknown): string | null {
283
+ if (typeof value !== "string") return null;
284
+ const trimmed = value.trim();
285
+ return trimmed.length > 0 ? trimmed : null;
286
+ }
287
+
288
+ function parseStoredLogContent(content: string): RunLogChunk[] {
289
+ const parsed: RunLogChunk[] = [];
290
+ for (const line of content.split("\n")) {
291
+ const trimmed = line.trim();
292
+ if (!trimmed) continue;
293
+ try {
294
+ const raw = JSON.parse(trimmed) as { ts?: unknown; stream?: unknown; chunk?: unknown };
295
+ const stream =
296
+ raw.stream === "stderr" || raw.stream === "system" ? raw.stream : "stdout";
297
+ const chunk = typeof raw.chunk === "string" ? raw.chunk : "";
298
+ const ts = typeof raw.ts === "string" ? raw.ts : new Date().toISOString();
299
+ if (!chunk) continue;
300
+ parsed.push({ ts, stream, chunk });
301
+ } catch {
302
+ // Ignore malformed log lines.
303
+ }
304
+ }
305
+ return parsed;
306
+ }
307
+
308
+ function workspaceOperationPhaseLabel(phase: WorkspaceOperation["phase"]) {
309
+ switch (phase) {
310
+ case "worktree_prepare":
311
+ return "Worktree setup";
312
+ case "workspace_provision":
313
+ return "Provision";
314
+ case "workspace_teardown":
315
+ return "Teardown";
316
+ case "worktree_cleanup":
317
+ return "Worktree cleanup";
318
+ default:
319
+ return phase;
320
+ }
321
+ }
322
+
323
+ function workspaceOperationStatusTone(status: WorkspaceOperation["status"]) {
324
+ switch (status) {
325
+ case "succeeded":
326
+ return "border-green-500/20 bg-green-500/10 text-green-700 dark:text-green-300";
327
+ case "failed":
328
+ return "border-red-500/20 bg-red-500/10 text-red-700 dark:text-red-300";
329
+ case "running":
330
+ return "border-cyan-500/20 bg-cyan-500/10 text-cyan-700 dark:text-cyan-300";
331
+ case "skipped":
332
+ return "border-yellow-500/20 bg-yellow-500/10 text-yellow-700 dark:text-yellow-300";
333
+ default:
334
+ return "border-border bg-muted/40 text-muted-foreground";
335
+ }
336
+ }
337
+
338
+ function WorkspaceOperationStatusBadge({ status }: { status: WorkspaceOperation["status"] }) {
339
+ return (
340
+ <span
341
+ className={cn(
342
+ "inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] font-medium capitalize",
343
+ workspaceOperationStatusTone(status),
344
+ )}
345
+ >
346
+ {status.replace("_", " ")}
347
+ </span>
348
+ );
349
+ }
350
+
351
+ function WorkspaceOperationLogViewer({
352
+ operation,
353
+ censorUsernameInLogs,
354
+ }: {
355
+ operation: WorkspaceOperation;
356
+ censorUsernameInLogs: boolean;
357
+ }) {
358
+ const [open, setOpen] = useState(false);
359
+ const { data: logData, isLoading, error } = useQuery({
360
+ queryKey: ["workspace-operation-log", operation.id],
361
+ queryFn: () => heartbeatsApi.workspaceOperationLog(operation.id),
362
+ enabled: open && Boolean(operation.logRef),
363
+ refetchInterval: open && operation.status === "running" ? 2000 : false,
364
+ });
365
+
366
+ const chunks = useMemo(
367
+ () => (logData?.content ? parseStoredLogContent(logData.content) : []),
368
+ [logData?.content],
369
+ );
370
+
371
+ return (
372
+ <div className="space-y-2">
373
+ <button
374
+ type="button"
375
+ className="text-[11px] text-muted-foreground underline underline-offset-2 hover:text-foreground"
376
+ onClick={() => setOpen((value) => !value)}
377
+ >
378
+ {open ? "Hide full log" : "Show full log"}
379
+ </button>
380
+ {open && (
381
+ <div className="rounded-md border border-border bg-background/70 p-2">
382
+ {isLoading && <div className="text-xs text-muted-foreground">Loading log...</div>}
383
+ {error && (
384
+ <div className="text-xs text-destructive">
385
+ {error instanceof Error ? error.message : "Failed to load workspace operation log"}
386
+ </div>
387
+ )}
388
+ {!isLoading && !error && chunks.length === 0 && (
389
+ <div className="text-xs text-muted-foreground">No persisted log lines.</div>
390
+ )}
391
+ {chunks.length > 0 && (
392
+ <div className="max-h-64 overflow-y-auto rounded bg-neutral-100 p-2 font-mono text-xs dark:bg-neutral-950">
393
+ {chunks.map((chunk, index) => (
394
+ <div key={`${chunk.ts}-${index}`} className="flex gap-2">
395
+ <span className="shrink-0 text-neutral-500">
396
+ {new Date(chunk.ts).toLocaleTimeString("en-US", { hour12: false })}
397
+ </span>
398
+ <span
399
+ className={cn(
400
+ "shrink-0 w-14",
401
+ chunk.stream === "stderr"
402
+ ? "text-red-600 dark:text-red-300"
403
+ : chunk.stream === "system"
404
+ ? "text-blue-600 dark:text-blue-300"
405
+ : "text-muted-foreground",
406
+ )}
407
+ >
408
+ [{chunk.stream}]
409
+ </span>
410
+ <span className="whitespace-pre-wrap break-all">{redactPathText(chunk.chunk, censorUsernameInLogs)}</span>
411
+ </div>
412
+ ))}
413
+ </div>
414
+ )}
415
+ </div>
416
+ )}
417
+ </div>
418
+ );
419
+ }
420
+
421
+ function WorkspaceOperationsSection({
422
+ operations,
423
+ censorUsernameInLogs,
424
+ }: {
425
+ operations: WorkspaceOperation[];
426
+ censorUsernameInLogs: boolean;
427
+ }) {
428
+ if (operations.length === 0) return null;
429
+
430
+ return (
431
+ <div className="rounded-lg border border-border bg-background/60 p-3 space-y-3">
432
+ <div className="text-xs font-medium text-muted-foreground">
433
+ Workspace ({operations.length})
434
+ </div>
435
+ <div className="space-y-3">
436
+ {operations.map((operation) => {
437
+ const metadata = asRecord(operation.metadata);
438
+ return (
439
+ <div key={operation.id} className="rounded-md border border-border/70 bg-background/70 p-3 space-y-2">
440
+ <div className="flex flex-wrap items-center gap-2">
441
+ <div className="text-sm font-medium">{workspaceOperationPhaseLabel(operation.phase)}</div>
442
+ <WorkspaceOperationStatusBadge status={operation.status} />
443
+ <div className="text-[11px] text-muted-foreground">
444
+ {relativeTime(operation.startedAt)}
445
+ {operation.finishedAt && ` to ${relativeTime(operation.finishedAt)}`}
446
+ </div>
447
+ </div>
448
+ {operation.command && (
449
+ <div className="text-xs break-all">
450
+ <span className="text-muted-foreground">Command: </span>
451
+ <span className="font-mono">{operation.command}</span>
452
+ </div>
453
+ )}
454
+ {operation.cwd && (
455
+ <div className="text-xs break-all">
456
+ <span className="text-muted-foreground">Working dir: </span>
457
+ <span className="font-mono">{operation.cwd}</span>
458
+ </div>
459
+ )}
460
+ {(asNonEmptyString(metadata?.branchName)
461
+ || asNonEmptyString(metadata?.baseRef)
462
+ || asNonEmptyString(metadata?.worktreePath)
463
+ || asNonEmptyString(metadata?.repoRoot)
464
+ || asNonEmptyString(metadata?.cleanupAction)) && (
465
+ <div className="grid gap-1 text-xs sm:grid-cols-2">
466
+ {asNonEmptyString(metadata?.branchName) && (
467
+ <div><span className="text-muted-foreground">Branch: </span><span className="font-mono">{metadata?.branchName as string}</span></div>
468
+ )}
469
+ {asNonEmptyString(metadata?.baseRef) && (
470
+ <div><span className="text-muted-foreground">Base ref: </span><span className="font-mono">{metadata?.baseRef as string}</span></div>
471
+ )}
472
+ {asNonEmptyString(metadata?.worktreePath) && (
473
+ <div className="break-all"><span className="text-muted-foreground">Worktree: </span><span className="font-mono">{metadata?.worktreePath as string}</span></div>
474
+ )}
475
+ {asNonEmptyString(metadata?.repoRoot) && (
476
+ <div className="break-all"><span className="text-muted-foreground">Repo root: </span><span className="font-mono">{metadata?.repoRoot as string}</span></div>
477
+ )}
478
+ {asNonEmptyString(metadata?.cleanupAction) && (
479
+ <div><span className="text-muted-foreground">Cleanup: </span><span className="font-mono">{metadata?.cleanupAction as string}</span></div>
480
+ )}
481
+ </div>
482
+ )}
483
+ {typeof metadata?.created === "boolean" && (
484
+ <div className="text-xs text-muted-foreground">
485
+ {metadata.created ? "Created by this run" : "Reused existing workspace"}
486
+ </div>
487
+ )}
488
+ {operation.stderrExcerpt && operation.stderrExcerpt.trim() && (
489
+ <div>
490
+ <div className="mb-1 text-xs text-red-700 dark:text-red-300">stderr excerpt</div>
491
+ <pre className="rounded-md bg-red-50 p-2 text-xs whitespace-pre-wrap break-all text-red-800 dark:bg-neutral-950 dark:text-red-100">
492
+ {redactPathText(operation.stderrExcerpt, censorUsernameInLogs)}
493
+ </pre>
494
+ </div>
495
+ )}
496
+ {operation.stdoutExcerpt && operation.stdoutExcerpt.trim() && (
497
+ <div>
498
+ <div className="mb-1 text-xs text-muted-foreground">stdout excerpt</div>
499
+ <pre className="rounded-md bg-neutral-100 p-2 text-xs whitespace-pre-wrap break-all dark:bg-neutral-950">
500
+ {redactPathText(operation.stdoutExcerpt, censorUsernameInLogs)}
501
+ </pre>
502
+ </div>
503
+ )}
504
+ {operation.logRef && (
505
+ <WorkspaceOperationLogViewer
506
+ operation={operation}
507
+ censorUsernameInLogs={censorUsernameInLogs}
508
+ />
509
+ )}
510
+ </div>
511
+ );
512
+ })}
513
+ </div>
514
+ </div>
515
+ );
516
+ }
517
+
518
+ export function AgentDetail() {
519
+ const { companyPrefix, agentId, tab: urlTab, runId: urlRunId } = useParams<{
520
+ companyPrefix?: string;
521
+ agentId: string;
522
+ tab?: string;
523
+ runId?: string;
524
+ }>();
525
+ const { companies, selectedCompanyId, setSelectedCompanyId } = useCompany();
526
+ const { closePanel } = usePanel();
527
+ const { openNewIssue } = useDialog();
528
+ const { setBreadcrumbs } = useBreadcrumbs();
529
+ const queryClient = useQueryClient();
530
+ const navigate = useNavigate();
531
+ const [actionError, setActionError] = useState<string | null>(null);
532
+ const [moreOpen, setMoreOpen] = useState(false);
533
+ const activeView = urlRunId ? "runs" as AgentDetailView : parseAgentDetailView(urlTab ?? null);
534
+ const needsDashboardData = activeView === "dashboard";
535
+ const needsRunData = activeView === "runs" || Boolean(urlRunId);
536
+ const shouldLoadHeartbeats = needsDashboardData || needsRunData;
537
+ const [configDirty, setConfigDirty] = useState(false);
538
+ const [configSaving, setConfigSaving] = useState(false);
539
+ const saveConfigActionRef = useRef<(() => void) | null>(null);
540
+ const cancelConfigActionRef = useRef<(() => void) | null>(null);
541
+ const { isMobile } = useSidebar();
542
+ const routeAgentRef = agentId ?? "";
543
+ const routeCompanyId = useMemo(() => {
544
+ if (!companyPrefix) return null;
545
+ const requestedPrefix = companyPrefix.toUpperCase();
546
+ return companies.find((company) => company.issuePrefix.toUpperCase() === requestedPrefix)?.id ?? null;
547
+ }, [companies, companyPrefix]);
548
+ const lookupCompanyId = routeCompanyId ?? selectedCompanyId ?? undefined;
549
+ const canFetchAgent = routeAgentRef.length > 0 && (isUuidLike(routeAgentRef) || Boolean(lookupCompanyId));
550
+ const setSaveConfigAction = useCallback((fn: (() => void) | null) => { saveConfigActionRef.current = fn; }, []);
551
+ const setCancelConfigAction = useCallback((fn: (() => void) | null) => { cancelConfigActionRef.current = fn; }, []);
552
+
553
+ const { data: agent, isLoading, error } = useQuery<AgentDetailRecord>({
554
+ queryKey: [...queryKeys.agents.detail(routeAgentRef), lookupCompanyId ?? null],
555
+ queryFn: () => agentsApi.get(routeAgentRef, lookupCompanyId),
556
+ enabled: canFetchAgent,
557
+ });
558
+ const resolvedCompanyId = agent?.companyId ?? selectedCompanyId;
559
+ const canonicalAgentRef = agent ? agentRouteRef(agent) : routeAgentRef;
560
+ const agentLookupRef = agent?.id ?? routeAgentRef;
561
+ const resolvedAgentId = agent?.id ?? null;
562
+
563
+ const { data: runtimeState } = useQuery({
564
+ queryKey: queryKeys.agents.runtimeState(resolvedAgentId ?? routeAgentRef),
565
+ queryFn: () => agentsApi.runtimeState(resolvedAgentId!, resolvedCompanyId ?? undefined),
566
+ enabled: Boolean(resolvedAgentId) && needsDashboardData,
567
+ });
568
+
569
+ const { data: heartbeats } = useQuery({
570
+ queryKey: queryKeys.heartbeats(resolvedCompanyId!, agent?.id ?? undefined),
571
+ queryFn: () => heartbeatsApi.list(resolvedCompanyId!, agent?.id ?? undefined),
572
+ enabled: !!resolvedCompanyId && !!agent?.id && shouldLoadHeartbeats,
573
+ });
574
+
575
+ const { data: allIssues } = useQuery({
576
+ queryKey: [...queryKeys.issues.list(resolvedCompanyId!), "participant-agent", resolvedAgentId ?? "__none__"],
577
+ queryFn: () => issuesApi.list(resolvedCompanyId!, { participantAgentId: resolvedAgentId! }),
578
+ enabled: !!resolvedCompanyId && !!resolvedAgentId && needsDashboardData,
579
+ });
580
+
581
+ const { data: allAgents } = useQuery({
582
+ queryKey: queryKeys.agents.list(resolvedCompanyId!),
583
+ queryFn: () => agentsApi.list(resolvedCompanyId!),
584
+ enabled: !!resolvedCompanyId && needsDashboardData,
585
+ });
586
+
587
+ const { data: budgetOverview } = useQuery({
588
+ queryKey: queryKeys.budgets.overview(resolvedCompanyId ?? "__none__"),
589
+ queryFn: () => budgetsApi.overview(resolvedCompanyId!),
590
+ enabled: !!resolvedCompanyId,
591
+ refetchInterval: 30_000,
592
+ staleTime: 5_000,
593
+ });
594
+
595
+ const assignedIssues = (allIssues ?? [])
596
+ .sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
597
+ const reportsToAgent = (allAgents ?? []).find((a) => a.id === agent?.reportsTo);
598
+ const directReports = (allAgents ?? []).filter((a) => a.reportsTo === agent?.id && a.status !== "terminated");
599
+ const agentBudgetSummary = useMemo(() => {
600
+ const matched = budgetOverview?.policies.find(
601
+ (policy) => policy.scopeType === "agent" && policy.scopeId === (agent?.id ?? routeAgentRef),
602
+ );
603
+ if (matched) return matched;
604
+ const budgetMonthlyCents = agent?.budgetMonthlyCents ?? 0;
605
+ const spentMonthlyCents = agent?.spentMonthlyCents ?? 0;
606
+ return {
607
+ policyId: "",
608
+ companyId: resolvedCompanyId ?? "",
609
+ scopeType: "agent",
610
+ scopeId: agent?.id ?? routeAgentRef,
611
+ scopeName: agent?.name ?? "Agent",
612
+ metric: "billed_cents",
613
+ windowKind: "calendar_month_utc",
614
+ amount: budgetMonthlyCents,
615
+ observedAmount: spentMonthlyCents,
616
+ remainingAmount: Math.max(0, budgetMonthlyCents - spentMonthlyCents),
617
+ utilizationPercent:
618
+ budgetMonthlyCents > 0 ? Number(((spentMonthlyCents / budgetMonthlyCents) * 100).toFixed(2)) : 0,
619
+ warnPercent: 80,
620
+ hardStopEnabled: true,
621
+ notifyEnabled: true,
622
+ isActive: budgetMonthlyCents > 0,
623
+ status: budgetMonthlyCents > 0 && spentMonthlyCents >= budgetMonthlyCents ? "hard_stop" : "ok",
624
+ paused: agent?.status === "paused",
625
+ pauseReason: agent?.pauseReason ?? null,
626
+ windowStart: new Date(),
627
+ windowEnd: new Date(),
628
+ } satisfies BudgetPolicySummary;
629
+ }, [agent, budgetOverview?.policies, resolvedCompanyId, routeAgentRef]);
630
+ const mobileLiveRun = useMemo(
631
+ () => (heartbeats ?? []).find((r) => r.status === "running" || r.status === "queued") ?? null,
632
+ [heartbeats],
633
+ );
634
+
635
+ useEffect(() => {
636
+ if (!agent) return;
637
+ if (urlRunId) {
638
+ if (routeAgentRef !== canonicalAgentRef) {
639
+ navigate(`/agents/${canonicalAgentRef}/runs/${urlRunId}`, { replace: true });
640
+ }
641
+ return;
642
+ }
643
+ const canonicalTab =
644
+ activeView === "instructions"
645
+ ? "instructions"
646
+ : activeView === "configuration"
647
+ ? "configuration"
648
+ : activeView === "skills"
649
+ ? "skills"
650
+ : activeView === "runs"
651
+ ? "runs"
652
+ : activeView === "budget"
653
+ ? "budget"
654
+ : "dashboard";
655
+ if (routeAgentRef !== canonicalAgentRef || urlTab !== canonicalTab) {
656
+ navigate(`/agents/${canonicalAgentRef}/${canonicalTab}`, { replace: true });
657
+ return;
658
+ }
659
+ }, [agent, routeAgentRef, canonicalAgentRef, urlRunId, urlTab, activeView, navigate]);
660
+
661
+ useEffect(() => {
662
+ if (!agent?.companyId || agent.companyId === selectedCompanyId) return;
663
+ setSelectedCompanyId(agent.companyId, { source: "route_sync" });
664
+ }, [agent?.companyId, selectedCompanyId, setSelectedCompanyId]);
665
+
666
+ const agentAction = useMutation({
667
+ mutationFn: async (action: "invoke" | "pause" | "resume" | "terminate") => {
668
+ if (!agentLookupRef) return Promise.reject(new Error("No agent reference"));
669
+ switch (action) {
670
+ case "invoke": return agentsApi.invoke(agentLookupRef, resolvedCompanyId ?? undefined);
671
+ case "pause": return agentsApi.pause(agentLookupRef, resolvedCompanyId ?? undefined);
672
+ case "resume": return agentsApi.resume(agentLookupRef, resolvedCompanyId ?? undefined);
673
+ case "terminate": return agentsApi.terminate(agentLookupRef, resolvedCompanyId ?? undefined);
674
+ }
675
+ },
676
+ onSuccess: (data, action) => {
677
+ setActionError(null);
678
+ queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(routeAgentRef) });
679
+ queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agentLookupRef) });
680
+ queryClient.invalidateQueries({ queryKey: queryKeys.agents.runtimeState(agentLookupRef) });
681
+ queryClient.invalidateQueries({ queryKey: queryKeys.agents.taskSessions(agentLookupRef) });
682
+ if (resolvedCompanyId) {
683
+ queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(resolvedCompanyId) });
684
+ if (agent?.id) {
685
+ queryClient.invalidateQueries({ queryKey: queryKeys.heartbeats(resolvedCompanyId, agent.id) });
686
+ }
687
+ }
688
+ if (action === "invoke" && data && typeof data === "object" && "id" in data) {
689
+ navigate(`/agents/${canonicalAgentRef}/runs/${(data as HeartbeatRun).id}`);
690
+ }
691
+ },
692
+ onError: (err) => {
693
+ setActionError(err instanceof Error ? err.message : "Action failed");
694
+ },
695
+ });
696
+
697
+ const budgetMutation = useMutation({
698
+ mutationFn: (amount: number) =>
699
+ budgetsApi.upsertPolicy(resolvedCompanyId!, {
700
+ scopeType: "agent",
701
+ scopeId: agent?.id ?? routeAgentRef,
702
+ amount,
703
+ windowKind: "calendar_month_utc",
704
+ }),
705
+ onSuccess: () => {
706
+ if (!resolvedCompanyId) return;
707
+ queryClient.invalidateQueries({ queryKey: queryKeys.budgets.overview(resolvedCompanyId) });
708
+ queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(routeAgentRef) });
709
+ queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agentLookupRef) });
710
+ queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(resolvedCompanyId) });
711
+ queryClient.invalidateQueries({ queryKey: queryKeys.dashboard(resolvedCompanyId) });
712
+ },
713
+ });
714
+
715
+ const updateIcon = useMutation({
716
+ mutationFn: (icon: string) => agentsApi.update(agentLookupRef, { icon }, resolvedCompanyId ?? undefined),
717
+ onSuccess: () => {
718
+ queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(routeAgentRef) });
719
+ queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agentLookupRef) });
720
+ if (resolvedCompanyId) {
721
+ queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(resolvedCompanyId) });
722
+ }
723
+ },
724
+ });
725
+
726
+ const resetTaskSession = useMutation({
727
+ mutationFn: (taskKey: string | null) =>
728
+ agentsApi.resetSession(agentLookupRef, taskKey, resolvedCompanyId ?? undefined),
729
+ onSuccess: () => {
730
+ setActionError(null);
731
+ queryClient.invalidateQueries({ queryKey: queryKeys.agents.runtimeState(agentLookupRef) });
732
+ queryClient.invalidateQueries({ queryKey: queryKeys.agents.taskSessions(agentLookupRef) });
733
+ },
734
+ onError: (err) => {
735
+ setActionError(err instanceof Error ? err.message : "Failed to reset session");
736
+ },
737
+ });
738
+
739
+ const updatePermissions = useMutation({
740
+ mutationFn: (permissions: AgentPermissionUpdate) =>
741
+ agentsApi.updatePermissions(agentLookupRef, permissions, resolvedCompanyId ?? undefined),
742
+ onSuccess: () => {
743
+ setActionError(null);
744
+ queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(routeAgentRef) });
745
+ queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agentLookupRef) });
746
+ if (resolvedCompanyId) {
747
+ queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(resolvedCompanyId) });
748
+ }
749
+ },
750
+ onError: (err) => {
751
+ setActionError(err instanceof Error ? err.message : "Failed to update permissions");
752
+ },
753
+ });
754
+
755
+ useEffect(() => {
756
+ const crumbs: { label: string; href?: string }[] = [
757
+ { label: "Agents", href: "/agents" },
758
+ ];
759
+ const agentName = agent?.name ?? routeAgentRef ?? "Agent";
760
+ if (activeView === "dashboard" && !urlRunId) {
761
+ crumbs.push({ label: agentName });
762
+ } else {
763
+ crumbs.push({ label: agentName, href: `/agents/${canonicalAgentRef}/dashboard` });
764
+ if (urlRunId) {
765
+ crumbs.push({ label: "Runs", href: `/agents/${canonicalAgentRef}/runs` });
766
+ crumbs.push({ label: `Run ${urlRunId.slice(0, 8)}` });
767
+ } else if (activeView === "instructions") {
768
+ crumbs.push({ label: "Instructions" });
769
+ } else if (activeView === "configuration") {
770
+ crumbs.push({ label: "Configuration" });
771
+ // } else if (activeView === "skills") { // TODO: bring back later
772
+ // crumbs.push({ label: "Skills" });
773
+ } else if (activeView === "runs") {
774
+ crumbs.push({ label: "Runs" });
775
+ } else if (activeView === "budget") {
776
+ crumbs.push({ label: "Budget" });
777
+ } else {
778
+ crumbs.push({ label: "Dashboard" });
779
+ }
780
+ }
781
+ setBreadcrumbs(crumbs);
782
+ }, [setBreadcrumbs, agent, routeAgentRef, canonicalAgentRef, activeView, urlRunId]);
783
+
784
+ useEffect(() => {
785
+ closePanel();
786
+ return () => closePanel();
787
+ }, [closePanel]);
788
+
789
+ useBeforeUnload(
790
+ useCallback((event) => {
791
+ if (!configDirty) return;
792
+ event.preventDefault();
793
+ event.returnValue = "";
794
+ }, [configDirty]),
795
+ );
796
+
797
+ if (isLoading) return <PageSkeleton variant="detail" />;
798
+ if (error) return <p className="text-sm text-destructive">{error.message}</p>;
799
+ if (!agent) return null;
800
+ if (!urlRunId && !urlTab) {
801
+ return <Navigate to={`/agents/${canonicalAgentRef}/dashboard`} replace />;
802
+ }
803
+ const isPendingApproval = agent.status === "pending_approval";
804
+ const showConfigActionBar = (activeView === "configuration" || activeView === "instructions") && (configDirty || configSaving);
805
+
806
+ return (
807
+ <div className={cn("space-y-6", isMobile && showConfigActionBar && "pb-24")}>
808
+ {/* Header */}
809
+ <div className="flex items-center justify-between gap-2">
810
+ <div className="flex items-center gap-3 min-w-0">
811
+ <AgentIconPicker
812
+ value={agent.icon}
813
+ onChange={(icon) => updateIcon.mutate(icon)}
814
+ >
815
+ <button className="shrink-0 flex items-center justify-center h-12 w-12 rounded-lg bg-accent hover:bg-accent/80 transition-colors">
816
+ <AgentIcon icon={agent.icon} className="h-6 w-6" />
817
+ </button>
818
+ </AgentIconPicker>
819
+ <div className="min-w-0">
820
+ <h2 className="text-2xl font-bold truncate">{agent.name}</h2>
821
+ <p className="text-sm text-muted-foreground truncate">
822
+ {roleLabels[agent.role] ?? agent.role}
823
+ {agent.title ? ` - ${agent.title}` : ""}
824
+ </p>
825
+ </div>
826
+ </div>
827
+ <div className="flex items-center gap-1 sm:gap-2 shrink-0">
828
+ <Button
829
+ variant="outline"
830
+ size="sm"
831
+ onClick={() => openNewIssue({ assigneeAgentId: agent.id })}
832
+ >
833
+ <Plus className="h-3.5 w-3.5 sm:mr-1" />
834
+ <span className="hidden sm:inline">Assign Task</span>
835
+ </Button>
836
+ <RunButton
837
+ onClick={() => agentAction.mutate("invoke")}
838
+ disabled={agentAction.isPending || isPendingApproval}
839
+ label="Run Heartbeat"
840
+ />
841
+ <PauseResumeButton
842
+ isPaused={agent.status === "paused"}
843
+ onPause={() => agentAction.mutate("pause")}
844
+ onResume={() => agentAction.mutate("resume")}
845
+ disabled={agentAction.isPending || isPendingApproval}
846
+ />
847
+ <span className="hidden sm:inline"><StatusBadge status={agent.status} /></span>
848
+ {mobileLiveRun && (
849
+ <Link
850
+ to={`/agents/${canonicalAgentRef}/runs/${mobileLiveRun.id}`}
851
+ className="sm:hidden flex items-center gap-1.5 px-2 py-0.5 rounded-full bg-blue-500/10 hover:bg-blue-500/20 transition-colors no-underline"
852
+ >
853
+ <span className="relative flex h-2 w-2">
854
+ <span className="animate-pulse absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75" />
855
+ <span className="relative inline-flex rounded-full h-2 w-2 bg-blue-500" />
856
+ </span>
857
+ <span className="text-[11px] font-medium text-blue-600 dark:text-blue-400">Live</span>
858
+ </Link>
859
+ )}
860
+
861
+ {/* Overflow menu */}
862
+ <Popover open={moreOpen} onOpenChange={setMoreOpen}>
863
+ <PopoverTrigger asChild>
864
+ <Button variant="ghost" size="icon-xs">
865
+ <MoreHorizontal className="h-4 w-4" />
866
+ </Button>
867
+ </PopoverTrigger>
868
+ <PopoverContent className="w-44 p-1" align="end">
869
+ <button
870
+ className="flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50"
871
+ onClick={() => {
872
+ navigator.clipboard.writeText(agent.id);
873
+ setMoreOpen(false);
874
+ }}
875
+ >
876
+ <Copy className="h-3 w-3" />
877
+ Copy Agent ID
878
+ </button>
879
+ <button
880
+ className="flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50"
881
+ onClick={() => {
882
+ resetTaskSession.mutate(null);
883
+ setMoreOpen(false);
884
+ }}
885
+ >
886
+ <RotateCcw className="h-3 w-3" />
887
+ Reset Sessions
888
+ </button>
889
+ <button
890
+ className="flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 text-destructive"
891
+ onClick={() => {
892
+ agentAction.mutate("terminate");
893
+ setMoreOpen(false);
894
+ }}
895
+ >
896
+ <Trash2 className="h-3 w-3" />
897
+ Terminate
898
+ </button>
899
+ </PopoverContent>
900
+ </Popover>
901
+ </div>
902
+ </div>
903
+
904
+ {!urlRunId && (
905
+ <Tabs
906
+ value={activeView}
907
+ onValueChange={(value) => navigate(`/agents/${canonicalAgentRef}/${value}`)}
908
+ >
909
+ <PageTabBar
910
+ items={[
911
+ { value: "dashboard", label: "Dashboard" },
912
+ { value: "instructions", label: "Instructions" },
913
+ { value: "skills", label: "Skills" },
914
+ { value: "configuration", label: "Configuration" },
915
+ { value: "runs", label: "Runs" },
916
+ { value: "budget", label: "Budget" },
917
+ ]}
918
+ value={activeView}
919
+ onValueChange={(value) => navigate(`/agents/${canonicalAgentRef}/${value}`)}
920
+ />
921
+ </Tabs>
922
+ )}
923
+
924
+ {actionError && <p className="text-sm text-destructive">{actionError}</p>}
925
+ {isPendingApproval && (
926
+ <p className="text-sm text-amber-500">
927
+ This agent is pending board approval and cannot be invoked yet.
928
+ </p>
929
+ )}
930
+
931
+ {/* Floating Save/Cancel (desktop) */}
932
+ {!isMobile && (
933
+ <div
934
+ className={cn(
935
+ "sticky top-6 z-10 float-right transition-opacity duration-150",
936
+ showConfigActionBar
937
+ ? "opacity-100"
938
+ : "opacity-0 pointer-events-none"
939
+ )}
940
+ >
941
+ <div className="flex items-center gap-2 bg-background/90 backdrop-blur-sm border border-border rounded-lg px-3 py-1.5 shadow-lg">
942
+ <Button
943
+ variant="ghost"
944
+ size="sm"
945
+ onClick={() => cancelConfigActionRef.current?.()}
946
+ disabled={configSaving}
947
+ >
948
+ Cancel
949
+ </Button>
950
+ <Button
951
+ size="sm"
952
+ onClick={() => saveConfigActionRef.current?.()}
953
+ disabled={configSaving}
954
+ >
955
+ {configSaving ? "Saving…" : "Save"}
956
+ </Button>
957
+ </div>
958
+ </div>
959
+ )}
960
+
961
+ {/* Mobile bottom Save/Cancel bar */}
962
+ {isMobile && showConfigActionBar && (
963
+ <div className="fixed inset-x-0 bottom-0 z-30 border-t border-border bg-background/95 backdrop-blur-sm">
964
+ <div
965
+ className="flex items-center justify-end gap-2 px-3 py-2"
966
+ style={{ paddingBottom: "max(env(safe-area-inset-bottom), 0.5rem)" }}
967
+ >
968
+ <Button
969
+ variant="ghost"
970
+ size="sm"
971
+ onClick={() => cancelConfigActionRef.current?.()}
972
+ disabled={configSaving}
973
+ >
974
+ Cancel
975
+ </Button>
976
+ <Button
977
+ size="sm"
978
+ onClick={() => saveConfigActionRef.current?.()}
979
+ disabled={configSaving}
980
+ >
981
+ {configSaving ? "Saving…" : "Save"}
982
+ </Button>
983
+ </div>
984
+ </div>
985
+ )}
986
+
987
+ {/* View content */}
988
+ {activeView === "dashboard" && (
989
+ <AgentOverview
990
+ agent={agent}
991
+ runs={heartbeats ?? []}
992
+ assignedIssues={assignedIssues}
993
+ runtimeState={runtimeState}
994
+ agentId={agent.id}
995
+ agentRouteId={canonicalAgentRef}
996
+ />
997
+ )}
998
+
999
+ {activeView === "instructions" && (
1000
+ <PromptsTab
1001
+ agent={agent}
1002
+ companyId={resolvedCompanyId ?? undefined}
1003
+ onDirtyChange={setConfigDirty}
1004
+ onSaveActionChange={setSaveConfigAction}
1005
+ onCancelActionChange={setCancelConfigAction}
1006
+ onSavingChange={setConfigSaving}
1007
+ />
1008
+ )}
1009
+
1010
+ {activeView === "configuration" && (
1011
+ <AgentConfigurePage
1012
+ agent={agent}
1013
+ agentId={agent.id}
1014
+ companyId={resolvedCompanyId ?? undefined}
1015
+ onDirtyChange={setConfigDirty}
1016
+ onSaveActionChange={setSaveConfigAction}
1017
+ onCancelActionChange={setCancelConfigAction}
1018
+ onSavingChange={setConfigSaving}
1019
+ updatePermissions={updatePermissions}
1020
+ />
1021
+ )}
1022
+
1023
+ {activeView === "skills" && (
1024
+ <AgentSkillsTab
1025
+ agent={agent}
1026
+ companyId={resolvedCompanyId ?? undefined}
1027
+ />
1028
+ )}
1029
+
1030
+ {activeView === "runs" && (
1031
+ <RunsTab
1032
+ runs={heartbeats ?? []}
1033
+ companyId={resolvedCompanyId!}
1034
+ agentId={agent.id}
1035
+ agentRouteId={canonicalAgentRef}
1036
+ selectedRunId={urlRunId ?? null}
1037
+ adapterType={agent.adapterType}
1038
+ />
1039
+ )}
1040
+
1041
+ {activeView === "budget" && resolvedCompanyId ? (
1042
+ <div className="max-w-3xl">
1043
+ <BudgetPolicyCard
1044
+ summary={agentBudgetSummary}
1045
+ isSaving={budgetMutation.isPending}
1046
+ onSave={(amount) => budgetMutation.mutate(amount)}
1047
+ variant="plain"
1048
+ />
1049
+ </div>
1050
+ ) : null}
1051
+ </div>
1052
+ );
1053
+ }
1054
+
1055
+ /* ---- Helper components ---- */
1056
+
1057
+ function SummaryRow({ label, children }: { label: string; children: React.ReactNode }) {
1058
+ return (
1059
+ <div className="flex items-center justify-between">
1060
+ <span className="text-muted-foreground text-xs">{label}</span>
1061
+ <div className="flex items-center gap-1">{children}</div>
1062
+ </div>
1063
+ );
1064
+ }
1065
+
1066
+ function LatestRunCard({ runs, agentId }: { runs: HeartbeatRun[]; agentId: string }) {
1067
+ if (runs.length === 0) return null;
1068
+
1069
+ const sorted = [...runs].sort(
1070
+ (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
1071
+ );
1072
+
1073
+ const liveRun = sorted.find((r) => r.status === "running" || r.status === "queued");
1074
+ const run = liveRun ?? sorted[0];
1075
+ const isLive = run.status === "running" || run.status === "queued";
1076
+ const statusInfo = runStatusIcons[run.status] ?? { icon: Clock, color: "text-neutral-400" };
1077
+ const StatusIcon = statusInfo.icon;
1078
+ const summary = run.resultJson
1079
+ ? String((run.resultJson as Record<string, unknown>).summary ?? (run.resultJson as Record<string, unknown>).result ?? "")
1080
+ : run.error ?? "";
1081
+
1082
+ return (
1083
+ <div className="space-y-3">
1084
+ <div className="flex w-full items-center justify-between">
1085
+ <h3 className="flex items-center gap-2 text-sm font-medium">
1086
+ {isLive && (
1087
+ <span className="relative flex h-2 w-2">
1088
+ <span className="animate-pulse absolute inline-flex h-full w-full rounded-full bg-cyan-400 opacity-75" />
1089
+ <span className="relative inline-flex rounded-full h-2 w-2 bg-cyan-400" />
1090
+ </span>
1091
+ )}
1092
+ {isLive ? "Live Run" : "Latest Run"}
1093
+ </h3>
1094
+ <Link
1095
+ to={`/agents/${agentId}/runs/${run.id}`}
1096
+ className="shrink-0 text-xs text-muted-foreground hover:text-foreground transition-colors no-underline"
1097
+ >
1098
+ View details &rarr;
1099
+ </Link>
1100
+ </div>
1101
+
1102
+ <Link
1103
+ to={`/agents/${agentId}/runs/${run.id}`}
1104
+ className={cn(
1105
+ "block border rounded-lg p-4 space-y-2 w-full no-underline transition-colors hover:bg-muted/50 cursor-pointer",
1106
+ isLive ? "border-cyan-500/30 shadow-[0_0_12px_rgba(6,182,212,0.08)]" : "border-border"
1107
+ )}
1108
+ >
1109
+ <div className="flex items-center gap-2">
1110
+ <StatusIcon className={cn("h-3.5 w-3.5", statusInfo.color, run.status === "running" && "animate-spin")} />
1111
+ <StatusBadge status={run.status} />
1112
+ <span className="font-mono text-xs text-muted-foreground">{run.id.slice(0, 8)}</span>
1113
+ <span className={cn(
1114
+ "inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-medium",
1115
+ run.invocationSource === "timer" ? "bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-300"
1116
+ : run.invocationSource === "assignment" ? "bg-violet-100 text-violet-700 dark:bg-violet-900/50 dark:text-violet-300"
1117
+ : run.invocationSource === "on_demand" ? "bg-cyan-100 text-cyan-700 dark:bg-cyan-900/50 dark:text-cyan-300"
1118
+ : "bg-muted text-muted-foreground"
1119
+ )}>
1120
+ {sourceLabels[run.invocationSource] ?? run.invocationSource}
1121
+ </span>
1122
+ <span className="ml-auto text-xs text-muted-foreground">{relativeTime(run.createdAt)}</span>
1123
+ </div>
1124
+
1125
+ {summary && (
1126
+ <div className="overflow-hidden max-h-16">
1127
+ <MarkdownBody className="[&>*:first-child]:mt-0 [&>*:last-child]:mb-0">{summary}</MarkdownBody>
1128
+ </div>
1129
+ )}
1130
+ </Link>
1131
+ </div>
1132
+ );
1133
+ }
1134
+
1135
+ /* ---- Agent Overview (main single-page view) ---- */
1136
+
1137
+ function AgentOverview({
1138
+ agent,
1139
+ runs,
1140
+ assignedIssues,
1141
+ runtimeState,
1142
+ agentId,
1143
+ agentRouteId,
1144
+ }: {
1145
+ agent: AgentDetailRecord;
1146
+ runs: HeartbeatRun[];
1147
+ assignedIssues: { id: string; title: string; status: string; priority: string; identifier?: string | null; createdAt: Date }[];
1148
+ runtimeState?: AgentRuntimeState;
1149
+ agentId: string;
1150
+ agentRouteId: string;
1151
+ }) {
1152
+ return (
1153
+ <div className="space-y-8">
1154
+ {/* Latest Run */}
1155
+ <LatestRunCard runs={runs} agentId={agentRouteId} />
1156
+
1157
+ {/* Charts */}
1158
+ <div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
1159
+ <ChartCard title="Run Activity" subtitle="Last 14 days">
1160
+ <RunActivityChart runs={runs} />
1161
+ </ChartCard>
1162
+ <ChartCard title="Issues by Priority" subtitle="Last 14 days">
1163
+ <PriorityChart issues={assignedIssues} />
1164
+ </ChartCard>
1165
+ <ChartCard title="Issues by Status" subtitle="Last 14 days">
1166
+ <IssueStatusChart issues={assignedIssues} />
1167
+ </ChartCard>
1168
+ <ChartCard title="Success Rate" subtitle="Last 14 days">
1169
+ <SuccessRateChart runs={runs} />
1170
+ </ChartCard>
1171
+ </div>
1172
+
1173
+ {/* Recent Issues */}
1174
+ <div className="space-y-3">
1175
+ <div className="flex items-center justify-between">
1176
+ <h3 className="text-sm font-medium">Recent Issues</h3>
1177
+ <Link
1178
+ to={`/issues?participantAgentId=${agentId}`}
1179
+ className="text-xs text-muted-foreground hover:text-foreground transition-colors"
1180
+ >
1181
+ See All &rarr;
1182
+ </Link>
1183
+ </div>
1184
+ {assignedIssues.length === 0 ? (
1185
+ <p className="text-sm text-muted-foreground">No recent issues.</p>
1186
+ ) : (
1187
+ <div className="border border-border rounded-lg">
1188
+ {assignedIssues.slice(0, 10).map((issue) => (
1189
+ <EntityRow
1190
+ key={issue.id}
1191
+ identifier={issue.identifier ?? issue.id.slice(0, 8)}
1192
+ title={issue.title}
1193
+ to={`/issues/${issue.identifier ?? issue.id}`}
1194
+ trailing={<StatusBadge status={issue.status} />}
1195
+ />
1196
+ ))}
1197
+ {assignedIssues.length > 10 && (
1198
+ <div className="px-3 py-2 text-xs text-muted-foreground text-center border-t border-border">
1199
+ +{assignedIssues.length - 10} more issues
1200
+ </div>
1201
+ )}
1202
+ </div>
1203
+ )}
1204
+ </div>
1205
+
1206
+ {/* Costs */}
1207
+ <div className="space-y-3">
1208
+ <h3 className="text-sm font-medium">Costs</h3>
1209
+ <CostsSection runtimeState={runtimeState} runs={runs} />
1210
+ </div>
1211
+ </div>
1212
+ );
1213
+ }
1214
+
1215
+ /* ---- Costs Section (inline) ---- */
1216
+
1217
+ function CostsSection({
1218
+ runtimeState,
1219
+ runs,
1220
+ }: {
1221
+ runtimeState?: AgentRuntimeState;
1222
+ runs: HeartbeatRun[];
1223
+ }) {
1224
+ const runsWithCost = runs
1225
+ .filter((r) => {
1226
+ const metrics = runMetrics(r);
1227
+ return metrics.cost > 0 || metrics.input > 0 || metrics.output > 0 || metrics.cached > 0;
1228
+ })
1229
+ .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
1230
+
1231
+ return (
1232
+ <div className="space-y-4">
1233
+ {runtimeState && (
1234
+ <div className="border border-border rounded-lg p-4">
1235
+ <div className="grid grid-cols-2 md:grid-cols-4 gap-4 tabular-nums">
1236
+ <div>
1237
+ <span className="text-xs text-muted-foreground block">Input tokens</span>
1238
+ <span className="text-lg font-semibold">{formatTokens(runtimeState.totalInputTokens)}</span>
1239
+ </div>
1240
+ <div>
1241
+ <span className="text-xs text-muted-foreground block">Output tokens</span>
1242
+ <span className="text-lg font-semibold">{formatTokens(runtimeState.totalOutputTokens)}</span>
1243
+ </div>
1244
+ <div>
1245
+ <span className="text-xs text-muted-foreground block">Cached tokens</span>
1246
+ <span className="text-lg font-semibold">{formatTokens(runtimeState.totalCachedInputTokens)}</span>
1247
+ </div>
1248
+ <div>
1249
+ <span className="text-xs text-muted-foreground block">Total cost</span>
1250
+ <span className="text-lg font-semibold">{formatCents(runtimeState.totalCostCents)}</span>
1251
+ </div>
1252
+ </div>
1253
+ </div>
1254
+ )}
1255
+ {runsWithCost.length > 0 && (
1256
+ <div className="border border-border rounded-lg overflow-hidden">
1257
+ <table className="w-full text-xs">
1258
+ <thead>
1259
+ <tr className="border-b border-border bg-accent/20">
1260
+ <th className="text-left px-3 py-2 font-medium text-muted-foreground">Date</th>
1261
+ <th className="text-left px-3 py-2 font-medium text-muted-foreground">Run</th>
1262
+ <th className="text-right px-3 py-2 font-medium text-muted-foreground">Input</th>
1263
+ <th className="text-right px-3 py-2 font-medium text-muted-foreground">Output</th>
1264
+ <th className="text-right px-3 py-2 font-medium text-muted-foreground">Cost</th>
1265
+ </tr>
1266
+ </thead>
1267
+ <tbody>
1268
+ {runsWithCost.slice(0, 10).map((run) => {
1269
+ const metrics = runMetrics(run);
1270
+ return (
1271
+ <tr key={run.id} className="border-b border-border last:border-b-0">
1272
+ <td className="px-3 py-2">{formatDate(run.createdAt)}</td>
1273
+ <td className="px-3 py-2 font-mono">{run.id.slice(0, 8)}</td>
1274
+ <td className="px-3 py-2 text-right tabular-nums">{formatTokens(metrics.input)}</td>
1275
+ <td className="px-3 py-2 text-right tabular-nums">{formatTokens(metrics.output)}</td>
1276
+ <td className="px-3 py-2 text-right tabular-nums">
1277
+ {metrics.cost > 0
1278
+ ? `$${metrics.cost.toFixed(4)}`
1279
+ : "-"
1280
+ }
1281
+ </td>
1282
+ </tr>
1283
+ );
1284
+ })}
1285
+ </tbody>
1286
+ </table>
1287
+ </div>
1288
+ )}
1289
+ </div>
1290
+ );
1291
+ }
1292
+
1293
+ /* ---- Agent Configure Page ---- */
1294
+
1295
+ function AgentConfigurePage({
1296
+ agent,
1297
+ agentId,
1298
+ companyId,
1299
+ onDirtyChange,
1300
+ onSaveActionChange,
1301
+ onCancelActionChange,
1302
+ onSavingChange,
1303
+ updatePermissions,
1304
+ }: {
1305
+ agent: AgentDetailRecord;
1306
+ agentId: string;
1307
+ companyId?: string;
1308
+ onDirtyChange: (dirty: boolean) => void;
1309
+ onSaveActionChange: (save: (() => void) | null) => void;
1310
+ onCancelActionChange: (cancel: (() => void) | null) => void;
1311
+ onSavingChange: (saving: boolean) => void;
1312
+ updatePermissions: { mutate: (permissions: AgentPermissionUpdate) => void; isPending: boolean };
1313
+ }) {
1314
+ const queryClient = useQueryClient();
1315
+ const [revisionsOpen, setRevisionsOpen] = useState(false);
1316
+
1317
+ const { data: configRevisions } = useQuery({
1318
+ queryKey: queryKeys.agents.configRevisions(agent.id),
1319
+ queryFn: () => agentsApi.listConfigRevisions(agent.id, companyId),
1320
+ });
1321
+
1322
+ const rollbackConfig = useMutation({
1323
+ mutationFn: (revisionId: string) => agentsApi.rollbackConfigRevision(agent.id, revisionId, companyId),
1324
+ onSuccess: () => {
1325
+ queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.id) });
1326
+ queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.urlKey) });
1327
+ queryClient.invalidateQueries({ queryKey: queryKeys.agents.configRevisions(agent.id) });
1328
+ },
1329
+ });
1330
+
1331
+ return (
1332
+ <div className="max-w-3xl space-y-6">
1333
+ <ConfigurationTab
1334
+ agent={agent}
1335
+ onDirtyChange={onDirtyChange}
1336
+ onSaveActionChange={onSaveActionChange}
1337
+ onCancelActionChange={onCancelActionChange}
1338
+ onSavingChange={onSavingChange}
1339
+ updatePermissions={updatePermissions}
1340
+ companyId={companyId}
1341
+ hidePromptTemplate
1342
+ hideInstructionsFile
1343
+ />
1344
+ <div>
1345
+ <h3 className="text-sm font-medium mb-3">API Keys</h3>
1346
+ <KeysTab agentId={agentId} companyId={companyId} />
1347
+ </div>
1348
+
1349
+ {/* Configuration Revisions — collapsible at the bottom */}
1350
+ <div>
1351
+ <button
1352
+ className="flex items-center gap-2 text-sm font-medium hover:text-foreground transition-colors"
1353
+ onClick={() => setRevisionsOpen((v) => !v)}
1354
+ >
1355
+ {revisionsOpen
1356
+ ? <ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
1357
+ : <ChevronRight className="h-3.5 w-3.5 text-muted-foreground" />
1358
+ }
1359
+ Configuration Revisions
1360
+ <span className="text-xs font-normal text-muted-foreground">{configRevisions?.length ?? 0}</span>
1361
+ </button>
1362
+ {revisionsOpen && (
1363
+ <div className="mt-3">
1364
+ {(configRevisions ?? []).length === 0 ? (
1365
+ <p className="text-sm text-muted-foreground">No configuration revisions yet.</p>
1366
+ ) : (
1367
+ <div className="space-y-2">
1368
+ {(configRevisions ?? []).slice(0, 10).map((revision) => (
1369
+ <div key={revision.id} className="border border-border/70 rounded-md p-3 space-y-2">
1370
+ <div className="flex items-center justify-between gap-3">
1371
+ <div className="text-xs text-muted-foreground">
1372
+ <span className="font-mono">{revision.id.slice(0, 8)}</span>
1373
+ <span className="mx-1">·</span>
1374
+ <span>{formatDate(revision.createdAt)}</span>
1375
+ <span className="mx-1">·</span>
1376
+ <span>{revision.source}</span>
1377
+ </div>
1378
+ <Button
1379
+ size="sm"
1380
+ variant="outline"
1381
+ className="h-7 px-2.5 text-xs"
1382
+ onClick={() => rollbackConfig.mutate(revision.id)}
1383
+ disabled={rollbackConfig.isPending}
1384
+ >
1385
+ Restore
1386
+ </Button>
1387
+ </div>
1388
+ <p className="text-xs text-muted-foreground">
1389
+ Changed:{" "}
1390
+ {revision.changedKeys.length > 0 ? revision.changedKeys.join(", ") : "no tracked changes"}
1391
+ </p>
1392
+ </div>
1393
+ ))}
1394
+ </div>
1395
+ )}
1396
+ </div>
1397
+ )}
1398
+ </div>
1399
+ </div>
1400
+ );
1401
+ }
1402
+
1403
+ /* ---- Configuration Tab ---- */
1404
+
1405
+ function ConfigurationTab({
1406
+ agent,
1407
+ companyId,
1408
+ onDirtyChange,
1409
+ onSaveActionChange,
1410
+ onCancelActionChange,
1411
+ onSavingChange,
1412
+ updatePermissions,
1413
+ hidePromptTemplate,
1414
+ hideInstructionsFile,
1415
+ }: {
1416
+ agent: AgentDetailRecord;
1417
+ companyId?: string;
1418
+ onDirtyChange: (dirty: boolean) => void;
1419
+ onSaveActionChange: (save: (() => void) | null) => void;
1420
+ onCancelActionChange: (cancel: (() => void) | null) => void;
1421
+ onSavingChange: (saving: boolean) => void;
1422
+ updatePermissions: { mutate: (permissions: AgentPermissionUpdate) => void; isPending: boolean };
1423
+ hidePromptTemplate?: boolean;
1424
+ hideInstructionsFile?: boolean;
1425
+ }) {
1426
+ const queryClient = useQueryClient();
1427
+ const { pushToast } = useToast();
1428
+ const [awaitingRefreshAfterSave, setAwaitingRefreshAfterSave] = useState(false);
1429
+ const lastAgentRef = useRef(agent);
1430
+
1431
+ const { data: adapterModels } = useQuery({
1432
+ queryKey:
1433
+ companyId
1434
+ ? queryKeys.agents.adapterModels(companyId, agent.adapterType)
1435
+ : ["agents", "none", "adapter-models", agent.adapterType],
1436
+ queryFn: () => agentsApi.adapterModels(companyId!, agent.adapterType),
1437
+ enabled: Boolean(companyId),
1438
+ });
1439
+
1440
+ const updateAgent = useMutation({
1441
+ mutationFn: (data: Record<string, unknown>) => agentsApi.update(agent.id, data, companyId),
1442
+ onMutate: () => {
1443
+ setAwaitingRefreshAfterSave(true);
1444
+ },
1445
+ onSuccess: () => {
1446
+ queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.id) });
1447
+ queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.urlKey) });
1448
+ queryClient.invalidateQueries({ queryKey: queryKeys.agents.configRevisions(agent.id) });
1449
+ queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(agent.companyId) });
1450
+ },
1451
+ onError: (err) => {
1452
+ setAwaitingRefreshAfterSave(false);
1453
+ const message =
1454
+ err instanceof ApiError
1455
+ ? err.message
1456
+ : err instanceof Error
1457
+ ? err.message
1458
+ : "Could not save agent";
1459
+ pushToast({ title: "Save failed", body: message, tone: "error" });
1460
+ },
1461
+ });
1462
+
1463
+ useEffect(() => {
1464
+ if (awaitingRefreshAfterSave && agent !== lastAgentRef.current) {
1465
+ setAwaitingRefreshAfterSave(false);
1466
+ }
1467
+ lastAgentRef.current = agent;
1468
+ }, [agent, awaitingRefreshAfterSave]);
1469
+ const isConfigSaving = updateAgent.isPending || awaitingRefreshAfterSave;
1470
+
1471
+ useEffect(() => {
1472
+ onSavingChange(isConfigSaving);
1473
+ }, [onSavingChange, isConfigSaving]);
1474
+
1475
+ const canCreateAgents = Boolean(agent.permissions?.canCreateAgents);
1476
+ const canAssignTasks = Boolean(agent.access?.canAssignTasks);
1477
+ const taskAssignSource = agent.access?.taskAssignSource ?? "none";
1478
+ const taskAssignLocked = agent.role === "ceo" || canCreateAgents;
1479
+ const taskAssignHint =
1480
+ taskAssignSource === "ceo_role"
1481
+ ? "Enabled automatically for CEO agents."
1482
+ : taskAssignSource === "agent_creator"
1483
+ ? "Enabled automatically while this agent can create new agents."
1484
+ : taskAssignSource === "explicit_grant"
1485
+ ? "Enabled via explicit company permission grant."
1486
+ : "Disabled unless explicitly granted.";
1487
+
1488
+ return (
1489
+ <div className="space-y-6">
1490
+ <AgentConfigForm
1491
+ mode="edit"
1492
+ agent={agent}
1493
+ onSave={(patch) => updateAgent.mutate(patch)}
1494
+ isSaving={isConfigSaving}
1495
+ adapterModels={adapterModels}
1496
+ onDirtyChange={onDirtyChange}
1497
+ onSaveActionChange={onSaveActionChange}
1498
+ onCancelActionChange={onCancelActionChange}
1499
+ hideInlineSave
1500
+ hidePromptTemplate={hidePromptTemplate}
1501
+ hideInstructionsFile={hideInstructionsFile}
1502
+ sectionLayout="cards"
1503
+ />
1504
+
1505
+ <div>
1506
+ <h3 className="text-sm font-medium mb-3">Permissions</h3>
1507
+ <div className="border border-border rounded-lg p-4 space-y-4">
1508
+ <div className="flex items-center justify-between gap-4 text-sm">
1509
+ <div className="space-y-1">
1510
+ <div>Can create new agents</div>
1511
+ <p className="text-xs text-muted-foreground">
1512
+ Lets this agent create or hire agents and implicitly assign tasks.
1513
+ </p>
1514
+ </div>
1515
+ <button
1516
+ type="button"
1517
+ role="switch"
1518
+ data-slot="toggle"
1519
+ aria-checked={canCreateAgents}
1520
+ className={cn(
1521
+ "relative inline-flex h-5 w-9 items-center rounded-full transition-colors shrink-0 disabled:cursor-not-allowed disabled:opacity-50",
1522
+ canCreateAgents ? "bg-green-600" : "bg-muted",
1523
+ )}
1524
+ onClick={() =>
1525
+ updatePermissions.mutate({
1526
+ canCreateAgents: !canCreateAgents,
1527
+ canAssignTasks: !canCreateAgents ? true : canAssignTasks,
1528
+ })
1529
+ }
1530
+ disabled={updatePermissions.isPending}
1531
+ >
1532
+ <span
1533
+ className={cn(
1534
+ "inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
1535
+ canCreateAgents ? "translate-x-4.5" : "translate-x-0.5",
1536
+ )}
1537
+ />
1538
+ </button>
1539
+ </div>
1540
+ <div className="flex items-center justify-between gap-4 text-sm">
1541
+ <div className="space-y-1">
1542
+ <div>Can assign tasks</div>
1543
+ <p className="text-xs text-muted-foreground">
1544
+ {taskAssignHint}
1545
+ </p>
1546
+ </div>
1547
+ <button
1548
+ type="button"
1549
+ role="switch"
1550
+ data-slot="toggle"
1551
+ aria-checked={canAssignTasks}
1552
+ className={cn(
1553
+ "relative inline-flex h-5 w-9 items-center rounded-full transition-colors shrink-0 disabled:cursor-not-allowed disabled:opacity-50",
1554
+ canAssignTasks ? "bg-green-600" : "bg-muted",
1555
+ )}
1556
+ onClick={() =>
1557
+ updatePermissions.mutate({
1558
+ canCreateAgents,
1559
+ canAssignTasks: !canAssignTasks,
1560
+ })
1561
+ }
1562
+ disabled={updatePermissions.isPending || taskAssignLocked}
1563
+ >
1564
+ <span
1565
+ className={cn(
1566
+ "inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
1567
+ canAssignTasks ? "translate-x-4.5" : "translate-x-0.5",
1568
+ )}
1569
+ />
1570
+ </button>
1571
+ </div>
1572
+ </div>
1573
+ </div>
1574
+ </div>
1575
+ );
1576
+ }
1577
+
1578
+ /* ---- Prompts Tab ---- */
1579
+
1580
+ function PromptsTab({
1581
+ agent,
1582
+ companyId,
1583
+ onDirtyChange,
1584
+ onSaveActionChange,
1585
+ onCancelActionChange,
1586
+ onSavingChange,
1587
+ }: {
1588
+ agent: Agent;
1589
+ companyId?: string;
1590
+ onDirtyChange: (dirty: boolean) => void;
1591
+ onSaveActionChange: (save: (() => void) | null) => void;
1592
+ onCancelActionChange: (cancel: (() => void) | null) => void;
1593
+ onSavingChange: (saving: boolean) => void;
1594
+ }) {
1595
+ const queryClient = useQueryClient();
1596
+ const { selectedCompanyId } = useCompany();
1597
+ const { isMobile } = useSidebar();
1598
+ const [selectedFile, setSelectedFile] = useState<string>("AGENTS.md");
1599
+ const [showFilePanel, setShowFilePanel] = useState(false);
1600
+ const [draft, setDraft] = useState<string | null>(null);
1601
+ const [bundleDraft, setBundleDraft] = useState<{
1602
+ mode: "managed" | "external";
1603
+ rootPath: string;
1604
+ entryFile: string;
1605
+ } | null>(null);
1606
+ const [newFilePath, setNewFilePath] = useState("");
1607
+ const [showNewFileInput, setShowNewFileInput] = useState(false);
1608
+ const [pendingFiles, setPendingFiles] = useState<string[]>([]);
1609
+ const [expandedDirs, setExpandedDirs] = useState<Set<string>>(new Set());
1610
+ const [filePanelWidth, setFilePanelWidth] = useState(260);
1611
+ const containerRef = useRef<HTMLDivElement>(null);
1612
+ const [awaitingRefresh, setAwaitingRefresh] = useState(false);
1613
+ const lastFileVersionRef = useRef<string | null>(null);
1614
+ const externalBundleRef = useRef<{
1615
+ rootPath: string;
1616
+ entryFile: string;
1617
+ selectedFile: string;
1618
+ } | null>(null);
1619
+
1620
+ useEffect(() => {
1621
+ setSelectedFile("AGENTS.md");
1622
+ setShowFilePanel(false);
1623
+ setDraft(null);
1624
+ setBundleDraft(null);
1625
+ setNewFilePath("");
1626
+ setShowNewFileInput(false);
1627
+ setPendingFiles([]);
1628
+ setExpandedDirs(new Set());
1629
+ setAwaitingRefresh(false);
1630
+ lastFileVersionRef.current = null;
1631
+ externalBundleRef.current = null;
1632
+ }, [agent.id]);
1633
+
1634
+ const isLocal =
1635
+ agent.adapterType === "claude_local" ||
1636
+ agent.adapterType === "codex_local" ||
1637
+ agent.adapterType === "opencode_local" ||
1638
+ agent.adapterType === "pi_local" ||
1639
+ agent.adapterType === "hermes_local" ||
1640
+ agent.adapterType === "cursor";
1641
+
1642
+ const { data: bundle, isLoading: bundleLoading } = useQuery({
1643
+ queryKey: queryKeys.agents.instructionsBundle(agent.id),
1644
+ queryFn: () => agentsApi.instructionsBundle(agent.id, companyId),
1645
+ enabled: Boolean(companyId && isLocal),
1646
+ });
1647
+
1648
+ const persistedMode = bundle?.mode ?? "managed";
1649
+ const persistedRootPath = persistedMode === "managed"
1650
+ ? (bundle?.managedRootPath ?? bundle?.rootPath ?? "")
1651
+ : (bundle?.rootPath ?? "");
1652
+ const currentMode = bundleDraft?.mode ?? persistedMode;
1653
+ const currentEntryFile = bundleDraft?.entryFile ?? bundle?.entryFile ?? "AGENTS.md";
1654
+ const currentRootPath = bundleDraft?.rootPath ?? persistedRootPath;
1655
+ const fileOptions = useMemo(
1656
+ () => bundle?.files.map((file) => file.path) ?? [],
1657
+ [bundle],
1658
+ );
1659
+ const bundleMatchesDraft = Boolean(
1660
+ bundle &&
1661
+ currentMode === persistedMode &&
1662
+ currentEntryFile === bundle.entryFile &&
1663
+ currentRootPath === persistedRootPath,
1664
+ );
1665
+ const visibleFilePaths = useMemo(
1666
+ () => bundleMatchesDraft
1667
+ ? [...new Set([currentEntryFile, ...fileOptions, ...pendingFiles])]
1668
+ : [currentEntryFile, ...pendingFiles],
1669
+ [bundleMatchesDraft, currentEntryFile, fileOptions, pendingFiles],
1670
+ );
1671
+ const fileTree = useMemo(
1672
+ () => buildFileTree(Object.fromEntries(visibleFilePaths.map((filePath) => [filePath, ""]))),
1673
+ [visibleFilePaths],
1674
+ );
1675
+ const selectedOrEntryFile = selectedFile || currentEntryFile;
1676
+ const selectedFileExists = bundleMatchesDraft && fileOptions.includes(selectedOrEntryFile);
1677
+ const selectedFileSummary = bundle?.files.find((file) => file.path === selectedOrEntryFile) ?? null;
1678
+
1679
+ const { data: selectedFileDetail, isLoading: fileLoading } = useQuery({
1680
+ queryKey: queryKeys.agents.instructionsFile(agent.id, selectedOrEntryFile),
1681
+ queryFn: () => agentsApi.instructionsFile(agent.id, selectedOrEntryFile, companyId),
1682
+ enabled: Boolean(companyId && isLocal && selectedFileExists),
1683
+ });
1684
+
1685
+ const updateBundle = useMutation({
1686
+ mutationFn: (data: {
1687
+ mode?: "managed" | "external";
1688
+ rootPath?: string | null;
1689
+ entryFile?: string;
1690
+ clearLegacyPromptTemplate?: boolean;
1691
+ }) => agentsApi.updateInstructionsBundle(agent.id, data, companyId),
1692
+ onMutate: () => setAwaitingRefresh(true),
1693
+ onSuccess: () => {
1694
+ queryClient.invalidateQueries({ queryKey: queryKeys.agents.instructionsBundle(agent.id) });
1695
+ queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.id) });
1696
+ queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.urlKey) });
1697
+ },
1698
+ onError: () => setAwaitingRefresh(false),
1699
+ });
1700
+
1701
+ const saveFile = useMutation({
1702
+ mutationFn: (data: { path: string; content: string; clearLegacyPromptTemplate?: boolean }) =>
1703
+ agentsApi.saveInstructionsFile(agent.id, data, companyId),
1704
+ onMutate: () => setAwaitingRefresh(true),
1705
+ onSuccess: (_, variables) => {
1706
+ setPendingFiles((prev) => prev.filter((f) => f !== variables.path));
1707
+ queryClient.invalidateQueries({ queryKey: queryKeys.agents.instructionsBundle(agent.id) });
1708
+ queryClient.invalidateQueries({ queryKey: queryKeys.agents.instructionsFile(agent.id, variables.path) });
1709
+ queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.id) });
1710
+ queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.urlKey) });
1711
+ },
1712
+ onError: () => setAwaitingRefresh(false),
1713
+ });
1714
+
1715
+ const deleteFile = useMutation({
1716
+ mutationFn: (relativePath: string) => agentsApi.deleteInstructionsFile(agent.id, relativePath, companyId),
1717
+ onMutate: () => setAwaitingRefresh(true),
1718
+ onSuccess: (_, relativePath) => {
1719
+ queryClient.invalidateQueries({ queryKey: queryKeys.agents.instructionsBundle(agent.id) });
1720
+ queryClient.removeQueries({ queryKey: queryKeys.agents.instructionsFile(agent.id, relativePath) });
1721
+ queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.id) });
1722
+ queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.urlKey) });
1723
+ },
1724
+ onError: () => setAwaitingRefresh(false),
1725
+ });
1726
+
1727
+ const uploadMarkdownImage = useMutation({
1728
+ mutationFn: async ({ file, namespace }: { file: File; namespace: string }) => {
1729
+ if (!selectedCompanyId) throw new Error("Select a company to upload images");
1730
+ return assetsApi.uploadImage(selectedCompanyId, file, namespace);
1731
+ },
1732
+ });
1733
+
1734
+ useEffect(() => {
1735
+ if (!bundle) return;
1736
+ if (!bundleMatchesDraft) {
1737
+ if (selectedFile !== currentEntryFile) setSelectedFile(currentEntryFile);
1738
+ return;
1739
+ }
1740
+ const availablePaths = bundle.files.map((file) => file.path);
1741
+ if (availablePaths.length === 0) {
1742
+ if (selectedFile !== bundle.entryFile) setSelectedFile(bundle.entryFile);
1743
+ return;
1744
+ }
1745
+ if (!availablePaths.includes(selectedFile) && selectedFile !== currentEntryFile && !pendingFiles.includes(selectedFile)) {
1746
+ setSelectedFile(availablePaths.includes(bundle.entryFile) ? bundle.entryFile : availablePaths[0]!);
1747
+ }
1748
+ }, [bundle, bundleMatchesDraft, currentEntryFile, pendingFiles, selectedFile]);
1749
+
1750
+ useEffect(() => {
1751
+ const nextExpanded = new Set<string>();
1752
+ for (const filePath of visibleFilePaths) {
1753
+ const parts = filePath.split("/");
1754
+ let currentPath = "";
1755
+ for (let i = 0; i < parts.length - 1; i++) {
1756
+ currentPath = currentPath ? `${currentPath}/${parts[i]}` : parts[i]!;
1757
+ nextExpanded.add(currentPath);
1758
+ }
1759
+ }
1760
+ setExpandedDirs((current) => (setsEqual(current, nextExpanded) ? current : nextExpanded));
1761
+ }, [visibleFilePaths]);
1762
+
1763
+ useEffect(() => {
1764
+ const versionKey = selectedFileExists && selectedFileDetail
1765
+ ? `${selectedFileDetail.path}:${selectedFileDetail.content}`
1766
+ : `draft:${currentMode}:${currentRootPath}:${selectedOrEntryFile}`;
1767
+ if (awaitingRefresh) {
1768
+ setAwaitingRefresh(false);
1769
+ setBundleDraft(null);
1770
+ setDraft(null);
1771
+ lastFileVersionRef.current = versionKey;
1772
+ return;
1773
+ }
1774
+ if (lastFileVersionRef.current !== versionKey) {
1775
+ setDraft(null);
1776
+ lastFileVersionRef.current = versionKey;
1777
+ }
1778
+ }, [awaitingRefresh, currentMode, currentRootPath, selectedFileDetail, selectedFileExists, selectedOrEntryFile]);
1779
+
1780
+ useEffect(() => {
1781
+ if (!bundle) return;
1782
+ setBundleDraft((current) => {
1783
+ if (current) return current;
1784
+ return {
1785
+ mode: persistedMode,
1786
+ rootPath: persistedRootPath,
1787
+ entryFile: bundle.entryFile,
1788
+ };
1789
+ });
1790
+ }, [bundle, persistedMode, persistedRootPath]);
1791
+
1792
+ useEffect(() => {
1793
+ if (!bundle || currentMode !== "external") return;
1794
+ externalBundleRef.current = {
1795
+ rootPath: currentRootPath,
1796
+ entryFile: currentEntryFile,
1797
+ selectedFile: selectedOrEntryFile,
1798
+ };
1799
+ }, [bundle, currentEntryFile, currentMode, currentRootPath, selectedOrEntryFile]);
1800
+
1801
+ const currentContent = selectedFileExists ? (selectedFileDetail?.content ?? "") : "";
1802
+ const displayValue = draft ?? currentContent;
1803
+ const bundleDirty = Boolean(
1804
+ bundleDraft &&
1805
+ (
1806
+ bundleDraft.mode !== persistedMode ||
1807
+ bundleDraft.rootPath !== persistedRootPath ||
1808
+ bundleDraft.entryFile !== (bundle?.entryFile ?? "AGENTS.md")
1809
+ ),
1810
+ );
1811
+ const fileDirty = draft !== null && draft !== currentContent;
1812
+ const isDirty = bundleDirty || fileDirty;
1813
+ const isSaving = updateBundle.isPending || saveFile.isPending || deleteFile.isPending || awaitingRefresh;
1814
+
1815
+ useEffect(() => { onSavingChange(isSaving); }, [onSavingChange, isSaving]);
1816
+ useEffect(() => { onDirtyChange(isDirty); }, [onDirtyChange, isDirty]);
1817
+
1818
+ useEffect(() => {
1819
+ onSaveActionChange(isDirty ? () => {
1820
+ const save = async () => {
1821
+ const shouldClearLegacy =
1822
+ Boolean(bundle?.legacyPromptTemplateActive) || Boolean(bundle?.legacyBootstrapPromptTemplateActive);
1823
+ if (bundleDirty && bundleDraft) {
1824
+ await updateBundle.mutateAsync({
1825
+ mode: bundleDraft.mode,
1826
+ rootPath: bundleDraft.mode === "external" ? bundleDraft.rootPath : null,
1827
+ entryFile: bundleDraft.entryFile,
1828
+ });
1829
+ }
1830
+ if (fileDirty) {
1831
+ await saveFile.mutateAsync({
1832
+ path: selectedOrEntryFile,
1833
+ content: displayValue,
1834
+ clearLegacyPromptTemplate: shouldClearLegacy,
1835
+ });
1836
+ }
1837
+ };
1838
+ void save().catch(() => undefined);
1839
+ } : null);
1840
+ }, [
1841
+ bundle,
1842
+ bundleDirty,
1843
+ bundleDraft,
1844
+ displayValue,
1845
+ fileDirty,
1846
+ isDirty,
1847
+ onSaveActionChange,
1848
+ saveFile,
1849
+ selectedOrEntryFile,
1850
+ updateBundle,
1851
+ ]);
1852
+
1853
+ useEffect(() => {
1854
+ onCancelActionChange(isDirty ? () => {
1855
+ setDraft(null);
1856
+ if (bundle) {
1857
+ setBundleDraft({
1858
+ mode: persistedMode,
1859
+ rootPath: persistedRootPath,
1860
+ entryFile: bundle.entryFile,
1861
+ });
1862
+ }
1863
+ } : null);
1864
+ }, [bundle, isDirty, onCancelActionChange, persistedMode, persistedRootPath]);
1865
+
1866
+ const handleSeparatorDrag = useCallback((event: React.MouseEvent) => {
1867
+ event.preventDefault();
1868
+ const startX = event.clientX;
1869
+ const startWidth = filePanelWidth;
1870
+ const onMouseMove = (moveEvent: MouseEvent) => {
1871
+ const delta = moveEvent.clientX - startX;
1872
+ const next = Math.max(180, Math.min(500, startWidth + delta));
1873
+ setFilePanelWidth(next);
1874
+ };
1875
+ const onMouseUp = () => {
1876
+ document.removeEventListener("mousemove", onMouseMove);
1877
+ document.removeEventListener("mouseup", onMouseUp);
1878
+ document.body.style.cursor = "";
1879
+ document.body.style.userSelect = "";
1880
+ };
1881
+ document.addEventListener("mousemove", onMouseMove);
1882
+ document.addEventListener("mouseup", onMouseUp);
1883
+ document.body.style.cursor = "col-resize";
1884
+ document.body.style.userSelect = "none";
1885
+ }, [filePanelWidth]);
1886
+
1887
+ if (!isLocal) {
1888
+ return (
1889
+ <div className="max-w-3xl">
1890
+ <p className="text-sm text-muted-foreground">
1891
+ Instructions bundles are only available for local adapters.
1892
+ </p>
1893
+ </div>
1894
+ );
1895
+ }
1896
+
1897
+ if (bundleLoading && !bundle) {
1898
+ return <PromptsTabSkeleton />;
1899
+ }
1900
+
1901
+ return (
1902
+ <div className="max-w-6xl space-y-6">
1903
+ {(bundle?.warnings ?? []).length > 0 && (
1904
+ <div className="space-y-2">
1905
+ {(bundle?.warnings ?? []).map((warning) => (
1906
+ <div key={warning} className="rounded-md border border-sky-500/25 bg-sky-500/10 px-3 py-2 text-xs text-sky-100">
1907
+ {warning}
1908
+ </div>
1909
+ ))}
1910
+ </div>
1911
+ )}
1912
+
1913
+ <Collapsible defaultOpen={currentMode === "external"}>
1914
+ <CollapsibleTrigger className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors group">
1915
+ <ChevronRight className="h-3 w-3 transition-transform group-data-[state=open]:rotate-90" />
1916
+ Advanced
1917
+ </CollapsibleTrigger>
1918
+ <CollapsibleContent className="pt-4 pb-6">
1919
+ <TooltipProvider>
1920
+ <div className="grid gap-x-6 gap-y-4 sm:grid-cols-[auto_1fr_1fr]">
1921
+ <label className="space-y-1.5">
1922
+ <span className="text-xs font-medium text-muted-foreground flex items-center gap-1">
1923
+ Mode
1924
+ <Tooltip>
1925
+ <TooltipTrigger asChild>
1926
+ <HelpCircle className="h-3 w-3 text-muted-foreground cursor-help" />
1927
+ </TooltipTrigger>
1928
+ <TooltipContent side="right" sideOffset={4}>
1929
+ Managed: Corporate stores and serves the instructions bundle. External: you provide a path on disk where the instructions live.
1930
+ </TooltipContent>
1931
+ </Tooltip>
1932
+ </span>
1933
+ <div className="flex gap-2">
1934
+ <Button
1935
+ type="button"
1936
+ size="sm"
1937
+ variant={currentMode === "managed" ? "default" : "outline"}
1938
+ onClick={() => {
1939
+ if (currentMode === "external") {
1940
+ externalBundleRef.current = {
1941
+ rootPath: currentRootPath,
1942
+ entryFile: currentEntryFile,
1943
+ selectedFile: selectedOrEntryFile,
1944
+ };
1945
+ }
1946
+ const nextEntryFile = currentEntryFile || "AGENTS.md";
1947
+ setBundleDraft({
1948
+ mode: "managed",
1949
+ rootPath: bundle?.managedRootPath ?? currentRootPath,
1950
+ entryFile: nextEntryFile,
1951
+ });
1952
+ setSelectedFile(nextEntryFile);
1953
+ }}
1954
+ >
1955
+ Managed
1956
+ </Button>
1957
+ <Button
1958
+ type="button"
1959
+ size="sm"
1960
+ variant={currentMode === "external" ? "default" : "outline"}
1961
+ onClick={() => {
1962
+ const externalBundle = externalBundleRef.current;
1963
+ const nextEntryFile = externalBundle?.entryFile ?? currentEntryFile ?? "AGENTS.md";
1964
+ setBundleDraft({
1965
+ mode: "external",
1966
+ rootPath: externalBundle?.rootPath ?? (bundle?.mode === "external" ? (bundle.rootPath ?? "") : ""),
1967
+ entryFile: nextEntryFile,
1968
+ });
1969
+ setSelectedFile(externalBundle?.selectedFile ?? nextEntryFile);
1970
+ }}
1971
+ >
1972
+ External
1973
+ </Button>
1974
+ </div>
1975
+ </label>
1976
+ <label className="space-y-1.5 min-w-0">
1977
+ <span className="text-xs font-medium text-muted-foreground flex items-center gap-1">
1978
+ Root path
1979
+ <Tooltip>
1980
+ <TooltipTrigger asChild>
1981
+ <HelpCircle className="h-3 w-3 text-muted-foreground cursor-help" />
1982
+ </TooltipTrigger>
1983
+ <TooltipContent side="right" sideOffset={4}>
1984
+ The absolute directory on disk where the instructions bundle lives. In managed mode this is set by Corporate automatically.
1985
+ </TooltipContent>
1986
+ </Tooltip>
1987
+ </span>
1988
+ {currentMode === "managed" ? (
1989
+ <div className="flex items-center gap-1.5 font-mono text-xs text-muted-foreground pt-1.5">
1990
+ <span className="min-w-0 truncate" title={currentRootPath || undefined}>{currentRootPath || "(managed)"}</span>
1991
+ {currentRootPath && (
1992
+ <CopyText text={currentRootPath} className="shrink-0">
1993
+ <Copy className="h-3.5 w-3.5" />
1994
+ </CopyText>
1995
+ )}
1996
+ </div>
1997
+ ) : (
1998
+ <div className="flex items-center gap-1.5">
1999
+ <Input
2000
+ value={currentRootPath}
2001
+ onChange={(event) => {
2002
+ const nextRootPath = event.target.value;
2003
+ externalBundleRef.current = {
2004
+ rootPath: nextRootPath,
2005
+ entryFile: currentEntryFile,
2006
+ selectedFile: selectedOrEntryFile,
2007
+ };
2008
+ setBundleDraft({
2009
+ mode: "external",
2010
+ rootPath: nextRootPath,
2011
+ entryFile: currentEntryFile,
2012
+ });
2013
+ }}
2014
+ className="font-mono text-sm"
2015
+ placeholder="/absolute/path/to/agent/prompts"
2016
+ />
2017
+ {currentRootPath && (
2018
+ <CopyText text={currentRootPath} className="shrink-0">
2019
+ <Copy className="h-3.5 w-3.5" />
2020
+ </CopyText>
2021
+ )}
2022
+ </div>
2023
+ )}
2024
+ </label>
2025
+ <label className="space-y-1.5">
2026
+ <span className="text-xs font-medium text-muted-foreground flex items-center gap-1">
2027
+ Entry file
2028
+ <Tooltip>
2029
+ <TooltipTrigger asChild>
2030
+ <HelpCircle className="h-3 w-3 text-muted-foreground cursor-help" />
2031
+ </TooltipTrigger>
2032
+ <TooltipContent side="right" sideOffset={4}>
2033
+ The main file the agent reads first when loading instructions. Defaults to AGENTS.md.
2034
+ </TooltipContent>
2035
+ </Tooltip>
2036
+ </span>
2037
+ <Input
2038
+ value={currentEntryFile}
2039
+ onChange={(event) => {
2040
+ const nextEntryFile = event.target.value || "AGENTS.md";
2041
+ const nextSelectedFile = selectedOrEntryFile === currentEntryFile
2042
+ ? nextEntryFile
2043
+ : selectedOrEntryFile;
2044
+ if (currentMode === "external") {
2045
+ externalBundleRef.current = {
2046
+ rootPath: currentRootPath,
2047
+ entryFile: nextEntryFile,
2048
+ selectedFile: nextSelectedFile,
2049
+ };
2050
+ }
2051
+ if (selectedOrEntryFile === currentEntryFile) setSelectedFile(nextEntryFile);
2052
+ setBundleDraft({
2053
+ mode: currentMode,
2054
+ rootPath: currentRootPath,
2055
+ entryFile: nextEntryFile,
2056
+ });
2057
+ }}
2058
+ className="font-mono text-sm"
2059
+ />
2060
+ </label>
2061
+ </div>
2062
+ </TooltipProvider>
2063
+ </CollapsibleContent>
2064
+ </Collapsible>
2065
+
2066
+ <div ref={containerRef} className={cn("flex gap-0", isMobile && "flex-col gap-3")}>
2067
+ <div className={cn(
2068
+ "border border-border rounded-lg p-3 space-y-3 shrink-0",
2069
+ isMobile && showFilePanel && "block",
2070
+ isMobile && !showFilePanel && "hidden",
2071
+ )} style={isMobile ? undefined : { width: filePanelWidth }}>
2072
+ <div className="flex items-center justify-between">
2073
+ <h4 className="text-sm font-medium">Files</h4>
2074
+ <div className="flex items-center gap-1">
2075
+ {!showNewFileInput && (
2076
+ <Button
2077
+ type="button"
2078
+ size="icon"
2079
+ variant="outline"
2080
+ className="h-7 w-7"
2081
+ onClick={() => setShowNewFileInput(true)}
2082
+ >
2083
+ +
2084
+ </Button>
2085
+ )}
2086
+ {isMobile && (
2087
+ <Button
2088
+ type="button"
2089
+ size="icon"
2090
+ variant="ghost"
2091
+ className="h-7 w-7"
2092
+ onClick={() => setShowFilePanel(false)}
2093
+ >
2094
+
2095
+ </Button>
2096
+ )}
2097
+ </div>
2098
+ </div>
2099
+ {showNewFileInput && (
2100
+ <div className="space-y-2">
2101
+ <Input
2102
+ value={newFilePath}
2103
+ onChange={(event) => setNewFilePath(event.target.value)}
2104
+ placeholder="TOOLS.md"
2105
+ className="font-mono text-sm"
2106
+ autoFocus
2107
+ onKeyDown={(event) => {
2108
+ if (event.key === "Escape") {
2109
+ setShowNewFileInput(false);
2110
+ setNewFilePath("");
2111
+ }
2112
+ }}
2113
+ />
2114
+ <div className="flex gap-2">
2115
+ <Button
2116
+ type="button"
2117
+ size="sm"
2118
+ variant="default"
2119
+ className="flex-1"
2120
+ disabled={!newFilePath.trim() || newFilePath.includes("..")}
2121
+ onClick={() => {
2122
+ const candidate = newFilePath.trim();
2123
+ if (!candidate || candidate.includes("..")) return;
2124
+ setPendingFiles((prev) => prev.includes(candidate) ? prev : [...prev, candidate]);
2125
+ setSelectedFile(candidate);
2126
+ setDraft("");
2127
+ setNewFilePath("");
2128
+ setShowNewFileInput(false);
2129
+ }}
2130
+ >
2131
+ Create
2132
+ </Button>
2133
+ <Button
2134
+ type="button"
2135
+ size="sm"
2136
+ variant="outline"
2137
+ className="flex-1"
2138
+ onClick={() => {
2139
+ setShowNewFileInput(false);
2140
+ setNewFilePath("");
2141
+ }}
2142
+ >
2143
+ Cancel
2144
+ </Button>
2145
+ </div>
2146
+ </div>
2147
+ )}
2148
+ <PackageFileTree
2149
+ nodes={fileTree}
2150
+ selectedFile={selectedOrEntryFile}
2151
+ expandedDirs={expandedDirs}
2152
+ checkedFiles={new Set()}
2153
+ onToggleDir={(dirPath) => setExpandedDirs((current) => {
2154
+ const next = new Set(current);
2155
+ if (next.has(dirPath)) next.delete(dirPath);
2156
+ else next.add(dirPath);
2157
+ return next;
2158
+ })}
2159
+ onSelectFile={(filePath) => {
2160
+ setSelectedFile(filePath);
2161
+ if (!fileOptions.includes(filePath)) setDraft("");
2162
+ if (isMobile) setShowFilePanel(false);
2163
+ }}
2164
+ onToggleCheck={() => {}}
2165
+ showCheckboxes={false}
2166
+ renderFileExtra={(node) => {
2167
+ const file = bundle?.files.find((entry) => entry.path === node.path);
2168
+ if (!file) return null;
2169
+ if (file.deprecated) {
2170
+ return (
2171
+ <Tooltip>
2172
+ <TooltipTrigger asChild>
2173
+ <span className="ml-3 shrink-0 rounded border border-amber-500/40 bg-amber-500/10 text-amber-200 px-1.5 py-0.5 text-[10px] uppercase tracking-wide cursor-help">
2174
+ virtual file
2175
+ </span>
2176
+ </TooltipTrigger>
2177
+ <TooltipContent side="right" sideOffset={4}>
2178
+ Legacy inline prompt — this deprecated virtual file preserves the old promptTemplate content
2179
+ </TooltipContent>
2180
+ </Tooltip>
2181
+ );
2182
+ }
2183
+ return (
2184
+ <span className="ml-3 shrink-0 rounded border border-border text-muted-foreground px-1.5 py-0.5 text-[10px] uppercase tracking-wide">
2185
+ {file.isEntryFile ? "entry" : `${file.size}b`}
2186
+ </span>
2187
+ );
2188
+ }}
2189
+ />
2190
+ </div>
2191
+
2192
+ {/* Draggable separator */}
2193
+ {!isMobile && (
2194
+ <div
2195
+ className="w-1 shrink-0 cursor-col-resize hover:bg-border active:bg-primary/50 rounded transition-colors mx-1"
2196
+ onMouseDown={handleSeparatorDrag}
2197
+ />
2198
+ )}
2199
+
2200
+ <div className={cn("border border-border rounded-lg p-4 space-y-3 min-w-0 flex-1", isMobile && showFilePanel && "hidden")}>
2201
+ <div className="flex items-center justify-between gap-3">
2202
+ <div className="flex items-center gap-2 min-w-0">
2203
+ {isMobile && (
2204
+ <Button
2205
+ type="button"
2206
+ size="icon"
2207
+ variant="outline"
2208
+ className="h-7 w-7 shrink-0"
2209
+ onClick={() => setShowFilePanel(true)}
2210
+ >
2211
+ <FolderOpen className="h-3.5 w-3.5" />
2212
+ </Button>
2213
+ )}
2214
+ <div className="min-w-0">
2215
+ <h4 className="text-sm font-medium font-mono truncate">{selectedOrEntryFile}</h4>
2216
+ <p className="text-xs text-muted-foreground">
2217
+ {selectedFileExists
2218
+ ? selectedFileSummary?.deprecated
2219
+ ? "Deprecated virtual file"
2220
+ : `${selectedFileDetail?.language ?? "text"} file`
2221
+ : "New file in this bundle"}
2222
+ </p>
2223
+ </div>
2224
+ </div>
2225
+ {selectedFileExists && !selectedFileSummary?.deprecated && selectedOrEntryFile !== currentEntryFile && (
2226
+ <Button
2227
+ type="button"
2228
+ size="sm"
2229
+ variant="outline"
2230
+ onClick={() => {
2231
+ if (confirm(`Delete ${selectedOrEntryFile}?`)) {
2232
+ deleteFile.mutate(selectedOrEntryFile, {
2233
+ onSuccess: () => {
2234
+ setSelectedFile(currentEntryFile);
2235
+ setDraft(null);
2236
+ },
2237
+ });
2238
+ }
2239
+ }}
2240
+ disabled={deleteFile.isPending}
2241
+ >
2242
+ Delete
2243
+ </Button>
2244
+ )}
2245
+ </div>
2246
+
2247
+ {selectedFileExists && fileLoading && !selectedFileDetail ? (
2248
+ <PromptEditorSkeleton />
2249
+ ) : isMarkdown(selectedOrEntryFile) ? (
2250
+ <MarkdownEditor
2251
+ key={selectedOrEntryFile}
2252
+ value={displayValue}
2253
+ onChange={(value) => setDraft(value ?? "")}
2254
+ placeholder="# Agent instructions"
2255
+ contentClassName="min-h-[420px] text-sm font-mono"
2256
+ imageUploadHandler={async (file) => {
2257
+ const namespace = `agents/${agent.id}/instructions/${selectedOrEntryFile.replaceAll("/", "-")}`;
2258
+ const asset = await uploadMarkdownImage.mutateAsync({ file, namespace });
2259
+ return asset.contentPath;
2260
+ }}
2261
+ />
2262
+ ) : (
2263
+ <textarea
2264
+ value={displayValue}
2265
+ onChange={(event) => setDraft(event.target.value)}
2266
+ className="min-h-[420px] w-full rounded-md border border-border bg-transparent px-3 py-2 font-mono text-sm outline-none"
2267
+ placeholder="File contents"
2268
+ />
2269
+ )}
2270
+ </div>
2271
+ </div>
2272
+
2273
+ </div>
2274
+ );
2275
+ }
2276
+
2277
+ function PromptsTabSkeleton() {
2278
+ return (
2279
+ <div className="max-w-5xl space-y-4">
2280
+ <div className="rounded-lg border border-border p-4 space-y-4">
2281
+ <div className="flex items-start justify-between gap-4">
2282
+ <div className="space-y-2">
2283
+ <Skeleton className="h-4 w-40" />
2284
+ <Skeleton className="h-4 w-[30rem] max-w-full" />
2285
+ </div>
2286
+ <Skeleton className="h-4 w-16" />
2287
+ </div>
2288
+ <div className="grid gap-3 md:grid-cols-3">
2289
+ {Array.from({ length: 3 }).map((_, index) => (
2290
+ <div key={index} className="space-y-2">
2291
+ <Skeleton className="h-3 w-20" />
2292
+ <Skeleton className="h-10 w-full" />
2293
+ </div>
2294
+ ))}
2295
+ </div>
2296
+ </div>
2297
+ <div className="grid gap-4 lg:grid-cols-[260px_minmax(0,1fr)]">
2298
+ <div className="rounded-lg border border-border p-3 space-y-3">
2299
+ <div className="flex items-center justify-between">
2300
+ <Skeleton className="h-4 w-12" />
2301
+ <Skeleton className="h-8 w-16" />
2302
+ </div>
2303
+ <Skeleton className="h-10 w-full" />
2304
+ <div className="space-y-2">
2305
+ {Array.from({ length: 5 }).map((_, index) => (
2306
+ <Skeleton key={index} className="h-9 w-full rounded-none" />
2307
+ ))}
2308
+ </div>
2309
+ </div>
2310
+ <div className="rounded-lg border border-border p-4 space-y-3">
2311
+ <div className="space-y-2">
2312
+ <Skeleton className="h-4 w-48" />
2313
+ <Skeleton className="h-3 w-28" />
2314
+ </div>
2315
+ <PromptEditorSkeleton />
2316
+ </div>
2317
+ </div>
2318
+ </div>
2319
+ );
2320
+ }
2321
+
2322
+ function PromptEditorSkeleton() {
2323
+ return (
2324
+ <div className="space-y-3">
2325
+ <Skeleton className="h-10 w-full" />
2326
+ <Skeleton className="h-[420px] w-full" />
2327
+ </div>
2328
+ );
2329
+ }
2330
+
2331
+ function AgentSkillsTab({
2332
+ agent,
2333
+ companyId,
2334
+ }: {
2335
+ agent: Agent;
2336
+ companyId?: string;
2337
+ }) {
2338
+ type SkillRow = {
2339
+ id: string;
2340
+ key: string;
2341
+ name: string;
2342
+ description: string | null;
2343
+ detail: string | null;
2344
+ locationLabel: string | null;
2345
+ originLabel: string | null;
2346
+ linkTo: string | null;
2347
+ readOnly: boolean;
2348
+ adapterEntry: AgentSkillEntry | null;
2349
+ };
2350
+
2351
+ const queryClient = useQueryClient();
2352
+ const [skillDraft, setSkillDraft] = useState<string[]>([]);
2353
+ const [lastSavedSkills, setLastSavedSkills] = useState<string[]>([]);
2354
+ const lastSavedSkillsRef = useRef<string[]>([]);
2355
+ const hasHydratedSkillSnapshotRef = useRef(false);
2356
+ const skipNextSkillAutosaveRef = useRef(true);
2357
+
2358
+ const { data: skillSnapshot, isLoading } = useQuery({
2359
+ queryKey: queryKeys.agents.skills(agent.id),
2360
+ queryFn: () => agentsApi.skills(agent.id, companyId),
2361
+ enabled: Boolean(companyId),
2362
+ });
2363
+
2364
+ const { data: companySkills } = useQuery({
2365
+ queryKey: queryKeys.companySkills.list(companyId ?? ""),
2366
+ queryFn: () => companySkillsApi.list(companyId!),
2367
+ enabled: Boolean(companyId),
2368
+ });
2369
+
2370
+ const syncSkills = useMutation({
2371
+ mutationFn: (desiredSkills: string[]) => agentsApi.syncSkills(agent.id, desiredSkills, companyId),
2372
+ onSuccess: async (snapshot) => {
2373
+ queryClient.setQueryData(queryKeys.agents.skills(agent.id), snapshot);
2374
+ lastSavedSkillsRef.current = snapshot.desiredSkills;
2375
+ setLastSavedSkills(snapshot.desiredSkills);
2376
+ await Promise.all([
2377
+ queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.id) }),
2378
+ queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.urlKey) }),
2379
+ ]);
2380
+ },
2381
+ });
2382
+
2383
+ useEffect(() => {
2384
+ setSkillDraft([]);
2385
+ setLastSavedSkills([]);
2386
+ lastSavedSkillsRef.current = [];
2387
+ hasHydratedSkillSnapshotRef.current = false;
2388
+ skipNextSkillAutosaveRef.current = true;
2389
+ }, [agent.id]);
2390
+
2391
+ useEffect(() => {
2392
+ if (!skillSnapshot) return;
2393
+ const nextState = applyAgentSkillSnapshot(
2394
+ {
2395
+ draft: skillDraft,
2396
+ lastSaved: lastSavedSkillsRef.current,
2397
+ hasHydratedSnapshot: hasHydratedSkillSnapshotRef.current,
2398
+ },
2399
+ skillSnapshot.desiredSkills,
2400
+ );
2401
+ skipNextSkillAutosaveRef.current = nextState.shouldSkipAutosave;
2402
+ hasHydratedSkillSnapshotRef.current = nextState.hasHydratedSnapshot;
2403
+ setSkillDraft(nextState.draft);
2404
+ lastSavedSkillsRef.current = nextState.lastSaved;
2405
+ setLastSavedSkills(nextState.lastSaved);
2406
+ }, [skillDraft, skillSnapshot]);
2407
+
2408
+ useEffect(() => {
2409
+ if (!skillSnapshot) return;
2410
+ if (skipNextSkillAutosaveRef.current) {
2411
+ skipNextSkillAutosaveRef.current = false;
2412
+ return;
2413
+ }
2414
+ if (syncSkills.isPending) return;
2415
+ if (arraysEqual(skillDraft, lastSavedSkillsRef.current)) return;
2416
+
2417
+ const timeout = window.setTimeout(() => {
2418
+ if (!arraysEqual(skillDraft, lastSavedSkillsRef.current)) {
2419
+ syncSkills.mutate(skillDraft);
2420
+ }
2421
+ }, 250);
2422
+
2423
+ return () => window.clearTimeout(timeout);
2424
+ }, [skillDraft, skillSnapshot, syncSkills.isPending, syncSkills.mutate]);
2425
+
2426
+ const companySkillByKey = useMemo(
2427
+ () => new Map((companySkills ?? []).map((skill) => [skill.key, skill])),
2428
+ [companySkills],
2429
+ );
2430
+ const companySkillKeys = useMemo(
2431
+ () => new Set((companySkills ?? []).map((skill) => skill.key)),
2432
+ [companySkills],
2433
+ );
2434
+ const adapterEntryByKey = useMemo(
2435
+ () => new Map((skillSnapshot?.entries ?? []).map((entry) => [entry.key, entry])),
2436
+ [skillSnapshot],
2437
+ );
2438
+ const optionalSkillRows = useMemo<SkillRow[]>(
2439
+ () =>
2440
+ (companySkills ?? [])
2441
+ .filter((skill) => !adapterEntryByKey.get(skill.key)?.required)
2442
+ .map((skill) => ({
2443
+ id: skill.id,
2444
+ key: skill.key,
2445
+ name: skill.name,
2446
+ description: skill.description,
2447
+ detail: adapterEntryByKey.get(skill.key)?.detail ?? null,
2448
+ locationLabel: adapterEntryByKey.get(skill.key)?.locationLabel ?? null,
2449
+ originLabel: adapterEntryByKey.get(skill.key)?.originLabel ?? null,
2450
+ linkTo: `/skills/${skill.id}`,
2451
+ readOnly: false,
2452
+ adapterEntry: adapterEntryByKey.get(skill.key) ?? null,
2453
+ })),
2454
+ [adapterEntryByKey, companySkills],
2455
+ );
2456
+ const requiredSkillRows = useMemo<SkillRow[]>(
2457
+ () =>
2458
+ (skillSnapshot?.entries ?? [])
2459
+ .filter((entry) => entry.required)
2460
+ .map((entry) => {
2461
+ const companySkill = companySkillByKey.get(entry.key);
2462
+ return {
2463
+ id: companySkill?.id ?? `required:${entry.key}`,
2464
+ key: entry.key,
2465
+ name: companySkill?.name ?? entry.key,
2466
+ description: companySkill?.description ?? null,
2467
+ detail: entry.detail ?? null,
2468
+ locationLabel: entry.locationLabel ?? null,
2469
+ originLabel: entry.originLabel ?? null,
2470
+ linkTo: companySkill ? `/skills/${companySkill.id}` : null,
2471
+ readOnly: false,
2472
+ adapterEntry: entry,
2473
+ };
2474
+ }),
2475
+ [companySkillByKey, skillSnapshot],
2476
+ );
2477
+ const unmanagedSkillRows = useMemo<SkillRow[]>(
2478
+ () =>
2479
+ (skillSnapshot?.entries ?? [])
2480
+ .filter((entry) => isReadOnlyUnmanagedSkillEntry(entry, companySkillKeys))
2481
+ .map((entry) => ({
2482
+ id: `external:${entry.key}`,
2483
+ key: entry.key,
2484
+ name: entry.runtimeName ?? entry.key,
2485
+ description: null,
2486
+ detail: entry.detail ?? null,
2487
+ locationLabel: entry.locationLabel ?? null,
2488
+ originLabel: entry.originLabel ?? null,
2489
+ linkTo: null,
2490
+ readOnly: true,
2491
+ adapterEntry: entry,
2492
+ })),
2493
+ [companySkillKeys, skillSnapshot],
2494
+ );
2495
+ const desiredOnlyMissingSkills = useMemo(
2496
+ () => skillDraft.filter((key) => !companySkillByKey.has(key)),
2497
+ [companySkillByKey, skillDraft],
2498
+ );
2499
+ const skillApplicationLabel = useMemo(() => {
2500
+ switch (skillSnapshot?.mode) {
2501
+ case "persistent":
2502
+ return "Kept in the workspace";
2503
+ case "ephemeral":
2504
+ return "Applied when the agent runs";
2505
+ case "unsupported":
2506
+ return "Tracked only";
2507
+ default:
2508
+ return "Unknown";
2509
+ }
2510
+ }, [skillSnapshot?.mode]);
2511
+ const unsupportedSkillMessage = useMemo(() => {
2512
+ if (skillSnapshot?.mode !== "unsupported") return null;
2513
+ if (agent.adapterType === "openclaw_gateway") {
2514
+ return "Corporate cannot manage OpenClaw skills here. Visit your OpenClaw instance to manage this agent's skills.";
2515
+ }
2516
+ return "Corporate cannot manage skills for this adapter yet. Manage them in the adapter directly.";
2517
+ }, [agent.adapterType, skillSnapshot?.mode]);
2518
+ const hasUnsavedChanges = !arraysEqual(skillDraft, lastSavedSkills);
2519
+ const saveStatusLabel = syncSkills.isPending
2520
+ ? "Saving changes..."
2521
+ : hasUnsavedChanges
2522
+ ? "Saving soon..."
2523
+ : null;
2524
+
2525
+ return (
2526
+ <div className="max-w-4xl space-y-5">
2527
+ <div className="flex flex-wrap items-center justify-between gap-3">
2528
+ <Link
2529
+ to="/skills"
2530
+ className="text-sm font-medium text-foreground underline-offset-4 no-underline transition-colors hover:text-foreground/70 hover:underline"
2531
+ >
2532
+ View company skills library
2533
+ </Link>
2534
+ {saveStatusLabel ? (
2535
+ <div className="flex items-center gap-2 text-xs text-muted-foreground">
2536
+ {syncSkills.isPending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : null}
2537
+ <span>{saveStatusLabel}</span>
2538
+ </div>
2539
+ ) : null}
2540
+ </div>
2541
+
2542
+ {skillSnapshot?.warnings.length ? (
2543
+ <div className="space-y-1 rounded-xl border border-amber-300/60 bg-amber-50/60 px-4 py-3 text-sm text-amber-800 dark:border-amber-500/30 dark:bg-amber-950/20 dark:text-amber-200">
2544
+ {skillSnapshot.warnings.map((warning) => (
2545
+ <div key={warning}>{warning}</div>
2546
+ ))}
2547
+ </div>
2548
+ ) : null}
2549
+
2550
+ {unsupportedSkillMessage ? (
2551
+ <div className="rounded-xl border border-border px-4 py-3 text-sm text-muted-foreground">
2552
+ {unsupportedSkillMessage}
2553
+ </div>
2554
+ ) : null}
2555
+
2556
+ {isLoading ? (
2557
+ <PageSkeleton variant="list" />
2558
+ ) : (
2559
+ <>
2560
+ {(() => {
2561
+ const renderSkillRow = (skill: SkillRow) => {
2562
+ const adapterEntry = skill.adapterEntry ?? adapterEntryByKey.get(skill.key);
2563
+ const required = Boolean(adapterEntry?.required);
2564
+ const rowClassName = cn(
2565
+ "flex items-start gap-3 border-b border-border px-3 py-3 text-sm last:border-b-0",
2566
+ skill.readOnly ? "bg-muted/20" : "hover:bg-accent/20",
2567
+ );
2568
+ const body = (
2569
+ <div className="min-w-0 flex-1">
2570
+ <div className="flex items-center justify-between gap-3">
2571
+ <div className="min-w-0">
2572
+ <span className="truncate font-medium">{skill.name}</span>
2573
+ </div>
2574
+ {skill.linkTo ? (
2575
+ <Link
2576
+ to={skill.linkTo}
2577
+ className="shrink-0 text-xs text-muted-foreground no-underline hover:text-foreground"
2578
+ >
2579
+ View
2580
+ </Link>
2581
+ ) : null}
2582
+ </div>
2583
+ {skill.description && (
2584
+ <MarkdownBody className="mt-1 text-xs text-muted-foreground prose-p:my-1 prose-ul:my-1 prose-ol:my-1 prose-li:my-0 [&>*:first-child]:mt-0 [&>*:last-child]:mb-0">
2585
+ {skill.description}
2586
+ </MarkdownBody>
2587
+ )}
2588
+ {skill.readOnly && skill.originLabel && (
2589
+ <p className="mt-1 text-xs text-muted-foreground">{skill.originLabel}</p>
2590
+ )}
2591
+ {skill.readOnly && skill.locationLabel && (
2592
+ <p className="mt-1 text-xs text-muted-foreground">Location: {skill.locationLabel}</p>
2593
+ )}
2594
+ {skill.detail && (
2595
+ <p className="mt-1 text-xs text-muted-foreground">{skill.detail}</p>
2596
+ )}
2597
+ </div>
2598
+ );
2599
+
2600
+ if (skill.readOnly) {
2601
+ return (
2602
+ <div key={skill.id} className={rowClassName}>
2603
+ <span className="mt-1 h-2 w-2 rounded-full bg-muted-foreground/40" />
2604
+ {body}
2605
+ </div>
2606
+ );
2607
+ }
2608
+
2609
+ const checked = required || skillDraft.includes(skill.key);
2610
+ const disabled = required || skillSnapshot?.mode === "unsupported";
2611
+ const checkbox = (
2612
+ <input
2613
+ type="checkbox"
2614
+ checked={checked}
2615
+ disabled={disabled}
2616
+ onChange={(event) => {
2617
+ const next = event.target.checked
2618
+ ? Array.from(new Set([...skillDraft, skill.key]))
2619
+ : skillDraft.filter((value) => value !== skill.key);
2620
+ setSkillDraft(next);
2621
+ }}
2622
+ className="mt-0.5 disabled:cursor-not-allowed disabled:opacity-60"
2623
+ />
2624
+ );
2625
+
2626
+ return (
2627
+ <label key={skill.id} className={rowClassName}>
2628
+ {required && adapterEntry?.requiredReason ? (
2629
+ <Tooltip>
2630
+ <TooltipTrigger asChild>
2631
+ <span>{checkbox}</span>
2632
+ </TooltipTrigger>
2633
+ <TooltipContent side="top">{adapterEntry.requiredReason}</TooltipContent>
2634
+ </Tooltip>
2635
+ ) : skillSnapshot?.mode === "unsupported" ? (
2636
+ <Tooltip>
2637
+ <TooltipTrigger asChild>
2638
+ <span>{checkbox}</span>
2639
+ </TooltipTrigger>
2640
+ <TooltipContent side="top">
2641
+ {unsupportedSkillMessage ?? "Manage skills in the adapter directly."}
2642
+ </TooltipContent>
2643
+ </Tooltip>
2644
+ ) : (
2645
+ checkbox
2646
+ )}
2647
+ {body}
2648
+ </label>
2649
+ );
2650
+ };
2651
+
2652
+ if (optionalSkillRows.length === 0 && requiredSkillRows.length === 0 && unmanagedSkillRows.length === 0) {
2653
+ return (
2654
+ <section className="border-y border-border">
2655
+ <div className="px-3 py-6 text-sm text-muted-foreground">
2656
+ Import skills into the company library first, then attach them here.
2657
+ </div>
2658
+ </section>
2659
+ );
2660
+ }
2661
+
2662
+ return (
2663
+ <>
2664
+ {optionalSkillRows.length > 0 && (
2665
+ <section className="border-y border-border">
2666
+ {optionalSkillRows.map(renderSkillRow)}
2667
+ </section>
2668
+ )}
2669
+
2670
+ {requiredSkillRows.length > 0 && (
2671
+ <section className="border-y border-border">
2672
+ <div className="border-b border-border bg-muted/40 px-3 py-2">
2673
+ <span className="text-xs font-medium text-muted-foreground">
2674
+ Required by Corporate
2675
+ </span>
2676
+ </div>
2677
+ {requiredSkillRows.map(renderSkillRow)}
2678
+ </section>
2679
+ )}
2680
+
2681
+ {unmanagedSkillRows.length > 0 && (
2682
+ <section className="border-y border-border">
2683
+ <div className="border-b border-border bg-muted/40 px-3 py-2">
2684
+ <span className="text-xs font-medium text-muted-foreground">
2685
+ User-installed skills, not managed by Corporate
2686
+ </span>
2687
+ </div>
2688
+ {unmanagedSkillRows.map(renderSkillRow)}
2689
+ </section>
2690
+ )}
2691
+ </>
2692
+ );
2693
+ })()}
2694
+
2695
+ {desiredOnlyMissingSkills.length > 0 && (
2696
+ <div className="rounded-xl border border-amber-300/60 bg-amber-50/60 px-4 py-3 text-sm text-amber-800 dark:border-amber-500/30 dark:bg-amber-950/20 dark:text-amber-200">
2697
+ <div className="font-medium">Requested skills missing from the company library</div>
2698
+ <div className="mt-1 text-xs">
2699
+ {desiredOnlyMissingSkills.join(", ")}
2700
+ </div>
2701
+ </div>
2702
+ )}
2703
+
2704
+ <section className="border-t border-border pt-4">
2705
+ <div className="grid gap-2 text-sm sm:grid-cols-2">
2706
+ <div className="flex items-center justify-between gap-3 border-b border-border/60 py-2">
2707
+ <span className="text-muted-foreground">Adapter</span>
2708
+ <span className="font-medium">{adapterLabels[agent.adapterType] ?? agent.adapterType}</span>
2709
+ </div>
2710
+ <div className="flex items-center justify-between gap-3 border-b border-border/60 py-2">
2711
+ <span className="text-muted-foreground">Skills applied</span>
2712
+ <span>{skillApplicationLabel}</span>
2713
+ </div>
2714
+ <div className="flex items-center justify-between gap-3 border-b border-border/60 py-2">
2715
+ <span className="text-muted-foreground">Selected skills</span>
2716
+ <span>{skillDraft.length}</span>
2717
+ </div>
2718
+ </div>
2719
+
2720
+ {syncSkills.isError && (
2721
+ <p className="mt-3 text-xs text-destructive">
2722
+ {syncSkills.error instanceof Error ? syncSkills.error.message : "Failed to update skills"}
2723
+ </p>
2724
+ )}
2725
+ </section>
2726
+ </>
2727
+ )}
2728
+ </div>
2729
+ );
2730
+ }
2731
+
2732
+ /* ---- Runs Tab ---- */
2733
+
2734
+ function RunListItem({ run, isSelected, agentId }: { run: HeartbeatRun; isSelected: boolean; agentId: string }) {
2735
+ const statusInfo = runStatusIcons[run.status] ?? { icon: Clock, color: "text-neutral-400" };
2736
+ const StatusIcon = statusInfo.icon;
2737
+ const metrics = runMetrics(run);
2738
+ const summary = run.resultJson
2739
+ ? String((run.resultJson as Record<string, unknown>).summary ?? (run.resultJson as Record<string, unknown>).result ?? "")
2740
+ : run.error ?? "";
2741
+
2742
+ return (
2743
+ <Link
2744
+ to={isSelected ? `/agents/${agentId}/runs` : `/agents/${agentId}/runs/${run.id}`}
2745
+ className={cn(
2746
+ "flex flex-col gap-1 w-full px-3 py-2.5 text-left border-b border-border last:border-b-0 transition-colors no-underline text-inherit",
2747
+ isSelected ? "bg-accent/40" : "hover:bg-accent/20",
2748
+ )}
2749
+ >
2750
+ <div className="flex items-center gap-2">
2751
+ <StatusIcon className={cn("h-3.5 w-3.5 shrink-0", statusInfo.color, run.status === "running" && "animate-spin")} />
2752
+ <span className="font-mono text-xs text-muted-foreground">
2753
+ {run.id.slice(0, 8)}
2754
+ </span>
2755
+ <span className={cn(
2756
+ "inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-medium shrink-0",
2757
+ run.invocationSource === "timer" ? "bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-300"
2758
+ : run.invocationSource === "assignment" ? "bg-violet-100 text-violet-700 dark:bg-violet-900/50 dark:text-violet-300"
2759
+ : run.invocationSource === "on_demand" ? "bg-cyan-100 text-cyan-700 dark:bg-cyan-900/50 dark:text-cyan-300"
2760
+ : "bg-muted text-muted-foreground"
2761
+ )}>
2762
+ {sourceLabels[run.invocationSource] ?? run.invocationSource}
2763
+ </span>
2764
+ <span className="ml-auto text-[11px] text-muted-foreground shrink-0">
2765
+ {relativeTime(run.createdAt)}
2766
+ </span>
2767
+ </div>
2768
+ {summary && (
2769
+ <span className="text-xs text-muted-foreground truncate pl-5.5">
2770
+ {summary.slice(0, 60)}
2771
+ </span>
2772
+ )}
2773
+ {(metrics.totalTokens > 0 || metrics.cost > 0) && (
2774
+ <div className="flex items-center gap-2 pl-5.5 text-[11px] text-muted-foreground tabular-nums">
2775
+ {metrics.totalTokens > 0 && <span>{formatTokens(metrics.totalTokens)} tok</span>}
2776
+ {metrics.cost > 0 && <span>${metrics.cost.toFixed(3)}</span>}
2777
+ </div>
2778
+ )}
2779
+ </Link>
2780
+ );
2781
+ }
2782
+
2783
+ function RunsTab({
2784
+ runs,
2785
+ companyId,
2786
+ agentId,
2787
+ agentRouteId,
2788
+ selectedRunId,
2789
+ adapterType,
2790
+ }: {
2791
+ runs: HeartbeatRun[];
2792
+ companyId: string;
2793
+ agentId: string;
2794
+ agentRouteId: string;
2795
+ selectedRunId: string | null;
2796
+ adapterType: string;
2797
+ }) {
2798
+ const { isMobile } = useSidebar();
2799
+
2800
+ if (runs.length === 0) {
2801
+ return <p className="text-sm text-muted-foreground">No runs yet.</p>;
2802
+ }
2803
+
2804
+ // Sort by created descending
2805
+ const sorted = [...runs].sort(
2806
+ (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
2807
+ );
2808
+
2809
+ // On mobile, don't auto-select so the list shows first; on desktop, auto-select latest
2810
+ const effectiveRunId = isMobile ? selectedRunId : (selectedRunId ?? sorted[0]?.id ?? null);
2811
+ const selectedRun = sorted.find((r) => r.id === effectiveRunId) ?? null;
2812
+
2813
+ // Mobile: show either run list OR run detail with back button
2814
+ if (isMobile) {
2815
+ if (selectedRun) {
2816
+ return (
2817
+ <div className="space-y-3 min-w-0 overflow-x-hidden">
2818
+ <Link
2819
+ to={`/agents/${agentRouteId}/runs`}
2820
+ className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors no-underline"
2821
+ >
2822
+ <ArrowLeft className="h-3.5 w-3.5" />
2823
+ Back to runs
2824
+ </Link>
2825
+ <RunDetail key={selectedRun.id} run={selectedRun} agentRouteId={agentRouteId} adapterType={adapterType} />
2826
+ </div>
2827
+ );
2828
+ }
2829
+ return (
2830
+ <div className="border border-border rounded-lg overflow-x-hidden">
2831
+ {sorted.map((run) => (
2832
+ <RunListItem key={run.id} run={run} isSelected={false} agentId={agentRouteId} />
2833
+ ))}
2834
+ </div>
2835
+ );
2836
+ }
2837
+
2838
+ // Desktop: side-by-side layout
2839
+ return (
2840
+ <div className="flex gap-0">
2841
+ {/* Left: run list — border stretches full height, content sticks */}
2842
+ <div className={cn(
2843
+ "shrink-0 border border-border rounded-lg",
2844
+ selectedRun ? "w-72" : "w-full",
2845
+ )}>
2846
+ <div className="sticky top-4 overflow-y-auto" style={{ maxHeight: "calc(100vh - 2rem)" }}>
2847
+ {sorted.map((run) => (
2848
+ <RunListItem key={run.id} run={run} isSelected={run.id === effectiveRunId} agentId={agentRouteId} />
2849
+ ))}
2850
+ </div>
2851
+ </div>
2852
+
2853
+ {/* Right: run detail — natural height, page scrolls */}
2854
+ {selectedRun && (
2855
+ <div className="flex-1 min-w-0 pl-4">
2856
+ <RunDetail key={selectedRun.id} run={selectedRun} agentRouteId={agentRouteId} adapterType={adapterType} />
2857
+ </div>
2858
+ )}
2859
+ </div>
2860
+ );
2861
+ }
2862
+
2863
+ /* ---- Run Detail (expanded) ---- */
2864
+
2865
+ function RunDetail({ run: initialRun, agentRouteId, adapterType }: { run: HeartbeatRun; agentRouteId: string; adapterType: string }) {
2866
+ const queryClient = useQueryClient();
2867
+ const navigate = useNavigate();
2868
+ const { data: hydratedRun } = useQuery({
2869
+ queryKey: queryKeys.runDetail(initialRun.id),
2870
+ queryFn: () => heartbeatsApi.get(initialRun.id),
2871
+ enabled: Boolean(initialRun.id),
2872
+ });
2873
+ const run = hydratedRun ?? initialRun;
2874
+ const metrics = runMetrics(run);
2875
+ const [sessionOpen, setSessionOpen] = useState(false);
2876
+ const [claudeLoginResult, setClaudeLoginResult] = useState<ClaudeLoginResult | null>(null);
2877
+
2878
+ useEffect(() => {
2879
+ setClaudeLoginResult(null);
2880
+ }, [run.id]);
2881
+
2882
+ const cancelRun = useMutation({
2883
+ mutationFn: () => heartbeatsApi.cancel(run.id),
2884
+ onSuccess: () => {
2885
+ queryClient.invalidateQueries({ queryKey: queryKeys.heartbeats(run.companyId, run.agentId) });
2886
+ },
2887
+ });
2888
+ const canResumeLostRun = run.errorCode === "process_lost" && run.status === "failed";
2889
+ const resumePayload = useMemo(() => {
2890
+ const payload: Record<string, unknown> = {
2891
+ resumeFromRunId: run.id,
2892
+ };
2893
+ const context = asRecord(run.contextSnapshot);
2894
+ if (!context) return payload;
2895
+ const issueId = asNonEmptyString(context.issueId);
2896
+ const taskId = asNonEmptyString(context.taskId);
2897
+ const taskKey = asNonEmptyString(context.taskKey);
2898
+ const commentId = asNonEmptyString(context.wakeCommentId) ?? asNonEmptyString(context.commentId);
2899
+ if (issueId) payload.issueId = issueId;
2900
+ if (taskId) payload.taskId = taskId;
2901
+ if (taskKey) payload.taskKey = taskKey;
2902
+ if (commentId) payload.commentId = commentId;
2903
+ return payload;
2904
+ }, [run.contextSnapshot, run.id]);
2905
+ const resumeRun = useMutation({
2906
+ mutationFn: async () => {
2907
+ const result = await agentsApi.wakeup(run.agentId, {
2908
+ source: "on_demand",
2909
+ triggerDetail: "manual",
2910
+ reason: "resume_process_lost_run",
2911
+ payload: resumePayload,
2912
+ }, run.companyId);
2913
+ if (!("id" in result)) {
2914
+ throw new Error("Resume request was skipped because the agent is not currently invokable.");
2915
+ }
2916
+ return result;
2917
+ },
2918
+ onSuccess: (resumedRun) => {
2919
+ queryClient.invalidateQueries({ queryKey: queryKeys.heartbeats(run.companyId, run.agentId) });
2920
+ navigate(`/agents/${agentRouteId}/runs/${resumedRun.id}`);
2921
+ },
2922
+ });
2923
+
2924
+ const canRetryRun = run.status === "failed" || run.status === "timed_out";
2925
+ const retryPayload = useMemo(() => {
2926
+ const payload: Record<string, unknown> = {};
2927
+ const context = asRecord(run.contextSnapshot);
2928
+ if (!context) return payload;
2929
+ const issueId = asNonEmptyString(context.issueId);
2930
+ const taskId = asNonEmptyString(context.taskId);
2931
+ const taskKey = asNonEmptyString(context.taskKey);
2932
+ if (issueId) payload.issueId = issueId;
2933
+ if (taskId) payload.taskId = taskId;
2934
+ if (taskKey) payload.taskKey = taskKey;
2935
+ return payload;
2936
+ }, [run.contextSnapshot]);
2937
+ const retryRun = useMutation({
2938
+ mutationFn: async () => {
2939
+ const result = await agentsApi.wakeup(run.agentId, {
2940
+ source: "on_demand",
2941
+ triggerDetail: "manual",
2942
+ reason: "retry_failed_run",
2943
+ payload: retryPayload,
2944
+ }, run.companyId);
2945
+ if (!("id" in result)) {
2946
+ throw new Error("Retry was skipped because the agent is not currently invokable.");
2947
+ }
2948
+ return result;
2949
+ },
2950
+ onSuccess: (newRun) => {
2951
+ queryClient.invalidateQueries({ queryKey: queryKeys.heartbeats(run.companyId, run.agentId) });
2952
+ navigate(`/agents/${agentRouteId}/runs/${newRun.id}`);
2953
+ },
2954
+ });
2955
+
2956
+ const { data: touchedIssues } = useQuery({
2957
+ queryKey: queryKeys.runIssues(run.id),
2958
+ queryFn: () => activityApi.issuesForRun(run.id),
2959
+ });
2960
+ const touchedIssueIds = useMemo(
2961
+ () => Array.from(new Set((touchedIssues ?? []).map((issue) => issue.issueId))),
2962
+ [touchedIssues],
2963
+ );
2964
+
2965
+ const clearSessionsForTouchedIssues = useMutation({
2966
+ mutationFn: async () => {
2967
+ if (touchedIssueIds.length === 0) return 0;
2968
+ await Promise.all(touchedIssueIds.map((issueId) => agentsApi.resetSession(run.agentId, issueId, run.companyId)));
2969
+ return touchedIssueIds.length;
2970
+ },
2971
+ onSuccess: () => {
2972
+ queryClient.invalidateQueries({ queryKey: queryKeys.agents.runtimeState(run.agentId) });
2973
+ queryClient.invalidateQueries({ queryKey: queryKeys.agents.taskSessions(run.agentId) });
2974
+ queryClient.invalidateQueries({ queryKey: queryKeys.runIssues(run.id) });
2975
+ },
2976
+ });
2977
+
2978
+ const runClaudeLogin = useMutation({
2979
+ mutationFn: () => agentsApi.loginWithClaude(run.agentId, run.companyId),
2980
+ onSuccess: (data) => {
2981
+ setClaudeLoginResult(data);
2982
+ },
2983
+ });
2984
+
2985
+ const isRunning = run.status === "running" && !!run.startedAt && !run.finishedAt;
2986
+ const [elapsedSec, setElapsedSec] = useState<number>(() => {
2987
+ if (!run.startedAt) return 0;
2988
+ return Math.max(0, Math.round((Date.now() - new Date(run.startedAt).getTime()) / 1000));
2989
+ });
2990
+
2991
+ useEffect(() => {
2992
+ if (!isRunning || !run.startedAt) return;
2993
+ const startMs = new Date(run.startedAt).getTime();
2994
+ setElapsedSec(Math.max(0, Math.round((Date.now() - startMs) / 1000)));
2995
+ const id = setInterval(() => {
2996
+ setElapsedSec(Math.max(0, Math.round((Date.now() - startMs) / 1000)));
2997
+ }, 1000);
2998
+ return () => clearInterval(id);
2999
+ }, [isRunning, run.startedAt]);
3000
+
3001
+ const timeFormat: Intl.DateTimeFormatOptions = { hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false };
3002
+ const startTime = run.startedAt ? new Date(run.startedAt).toLocaleTimeString("en-US", timeFormat) : null;
3003
+ const endTime = run.finishedAt ? new Date(run.finishedAt).toLocaleTimeString("en-US", timeFormat) : null;
3004
+ const durationSec = run.startedAt && run.finishedAt
3005
+ ? Math.round((new Date(run.finishedAt).getTime() - new Date(run.startedAt).getTime()) / 1000)
3006
+ : null;
3007
+ const displayDurationSec = durationSec ?? (isRunning ? elapsedSec : null);
3008
+ const hasMetrics = metrics.input > 0 || metrics.output > 0 || metrics.cached > 0 || metrics.cost > 0;
3009
+ const hasSession = !!(run.sessionIdBefore || run.sessionIdAfter);
3010
+ const sessionChanged = run.sessionIdBefore && run.sessionIdAfter && run.sessionIdBefore !== run.sessionIdAfter;
3011
+ const sessionId = run.sessionIdAfter || run.sessionIdBefore;
3012
+ const hasNonZeroExit = run.exitCode !== null && run.exitCode !== 0;
3013
+
3014
+ return (
3015
+ <div className="space-y-4 min-w-0">
3016
+ {/* Run summary card */}
3017
+ <div className="border border-border rounded-lg overflow-hidden">
3018
+ <div className="flex flex-col sm:flex-row">
3019
+ {/* Left column: status + timing */}
3020
+ <div className="flex-1 p-4 space-y-3">
3021
+ <div className="flex items-center gap-2">
3022
+ <StatusBadge status={run.status} />
3023
+ {(run.status === "running" || run.status === "queued") && (
3024
+ <Button
3025
+ variant="ghost"
3026
+ size="sm"
3027
+ className="text-destructive hover:text-destructive text-xs h-6 px-2"
3028
+ onClick={() => cancelRun.mutate()}
3029
+ disabled={cancelRun.isPending}
3030
+ >
3031
+ {cancelRun.isPending ? "Cancelling…" : "Cancel"}
3032
+ </Button>
3033
+ )}
3034
+ {canResumeLostRun && (
3035
+ <Button
3036
+ variant="ghost"
3037
+ size="sm"
3038
+ className="text-xs h-6 px-2"
3039
+ onClick={() => resumeRun.mutate()}
3040
+ disabled={resumeRun.isPending}
3041
+ >
3042
+ <RotateCcw className="h-3.5 w-3.5 mr-1" />
3043
+ {resumeRun.isPending ? "Resuming…" : "Resume"}
3044
+ </Button>
3045
+ )}
3046
+ {canRetryRun && !canResumeLostRun && (
3047
+ <Button
3048
+ variant="ghost"
3049
+ size="sm"
3050
+ className="text-xs h-6 px-2"
3051
+ onClick={() => retryRun.mutate()}
3052
+ disabled={retryRun.isPending}
3053
+ >
3054
+ <RotateCcw className="h-3.5 w-3.5 mr-1" />
3055
+ {retryRun.isPending ? "Retrying…" : "Retry"}
3056
+ </Button>
3057
+ )}
3058
+ </div>
3059
+ {resumeRun.isError && (
3060
+ <div className="text-xs text-destructive">
3061
+ {resumeRun.error instanceof Error ? resumeRun.error.message : "Failed to resume run"}
3062
+ </div>
3063
+ )}
3064
+ {retryRun.isError && (
3065
+ <div className="text-xs text-destructive">
3066
+ {retryRun.error instanceof Error ? retryRun.error.message : "Failed to retry run"}
3067
+ </div>
3068
+ )}
3069
+ {startTime && (
3070
+ <div className="space-y-0.5">
3071
+ <div className="text-sm font-mono">
3072
+ {startTime}
3073
+ {endTime && <span className="text-muted-foreground"> &rarr; </span>}
3074
+ {endTime}
3075
+ </div>
3076
+ <div className="text-[11px] text-muted-foreground">
3077
+ {relativeTime(run.startedAt!)}
3078
+ {run.finishedAt && <> &rarr; {relativeTime(run.finishedAt)}</>}
3079
+ </div>
3080
+ {displayDurationSec !== null && (
3081
+ <div className="text-xs text-muted-foreground">
3082
+ Duration: {displayDurationSec >= 60 ? `${Math.floor(displayDurationSec / 60)}m ${displayDurationSec % 60}s` : `${displayDurationSec}s`}
3083
+ </div>
3084
+ )}
3085
+ </div>
3086
+ )}
3087
+ {run.error && (
3088
+ <div className="text-xs">
3089
+ <span className="text-red-600 dark:text-red-400">{run.error}</span>
3090
+ {run.errorCode && <span className="text-muted-foreground ml-1">({run.errorCode})</span>}
3091
+ </div>
3092
+ )}
3093
+ {run.errorCode === "claude_auth_required" && adapterType === "claude_local" && (
3094
+ <div className="space-y-2">
3095
+ <Button
3096
+ variant="outline"
3097
+ size="sm"
3098
+ className="h-7 px-2 text-xs"
3099
+ onClick={() => runClaudeLogin.mutate()}
3100
+ disabled={runClaudeLogin.isPending}
3101
+ >
3102
+ {runClaudeLogin.isPending ? "Running claude login..." : "Login to Claude Code"}
3103
+ </Button>
3104
+ {runClaudeLogin.isError && (
3105
+ <p className="text-xs text-destructive">
3106
+ {runClaudeLogin.error instanceof Error
3107
+ ? runClaudeLogin.error.message
3108
+ : "Failed to run Claude login"}
3109
+ </p>
3110
+ )}
3111
+ {claudeLoginResult?.loginUrl && (
3112
+ <p className="text-xs">
3113
+ Login URL:
3114
+ <a
3115
+ href={claudeLoginResult.loginUrl}
3116
+ className="text-blue-600 underline underline-offset-2 ml-1 break-all dark:text-blue-400"
3117
+ target="_blank"
3118
+ rel="noreferrer"
3119
+ >
3120
+ {claudeLoginResult.loginUrl}
3121
+ </a>
3122
+ </p>
3123
+ )}
3124
+ {claudeLoginResult && (
3125
+ <>
3126
+ {!!claudeLoginResult.stdout && (
3127
+ <pre className="bg-neutral-100 dark:bg-neutral-950 rounded-md p-3 text-xs font-mono text-foreground overflow-x-auto whitespace-pre-wrap">
3128
+ {claudeLoginResult.stdout}
3129
+ </pre>
3130
+ )}
3131
+ {!!claudeLoginResult.stderr && (
3132
+ <pre className="bg-neutral-100 dark:bg-neutral-950 rounded-md p-3 text-xs font-mono text-red-700 dark:text-red-300 overflow-x-auto whitespace-pre-wrap">
3133
+ {claudeLoginResult.stderr}
3134
+ </pre>
3135
+ )}
3136
+ </>
3137
+ )}
3138
+ </div>
3139
+ )}
3140
+ {hasNonZeroExit && (
3141
+ <div className="text-xs text-red-600 dark:text-red-400">
3142
+ Exit code {run.exitCode}
3143
+ {run.signal && <span className="text-muted-foreground ml-1">(signal: {run.signal})</span>}
3144
+ </div>
3145
+ )}
3146
+ </div>
3147
+
3148
+ {/* Right column: metrics */}
3149
+ {hasMetrics && (
3150
+ <div className="border-t sm:border-t-0 sm:border-l border-border p-4 grid grid-cols-2 gap-x-4 sm:gap-x-8 gap-y-3 content-center tabular-nums">
3151
+ <div>
3152
+ <div className="text-xs text-muted-foreground">Input</div>
3153
+ <div className="text-sm font-medium font-mono">{formatTokens(metrics.input)}</div>
3154
+ </div>
3155
+ <div>
3156
+ <div className="text-xs text-muted-foreground">Output</div>
3157
+ <div className="text-sm font-medium font-mono">{formatTokens(metrics.output)}</div>
3158
+ </div>
3159
+ <div>
3160
+ <div className="text-xs text-muted-foreground">Cached</div>
3161
+ <div className="text-sm font-medium font-mono">{formatTokens(metrics.cached)}</div>
3162
+ </div>
3163
+ <div>
3164
+ <div className="text-xs text-muted-foreground">Cost</div>
3165
+ <div className="text-sm font-medium font-mono">{metrics.cost > 0 ? `$${metrics.cost.toFixed(4)}` : "-"}</div>
3166
+ </div>
3167
+ </div>
3168
+ )}
3169
+ </div>
3170
+
3171
+ {/* Collapsible session row */}
3172
+ {hasSession && (
3173
+ <div className="border-t border-border">
3174
+ <button
3175
+ className="flex items-center gap-1.5 w-full px-4 py-2 text-xs text-muted-foreground hover:text-foreground transition-colors"
3176
+ onClick={() => setSessionOpen((v) => !v)}
3177
+ >
3178
+ <ChevronRight className={cn("h-3 w-3 transition-transform", sessionOpen && "rotate-90")} />
3179
+ Session
3180
+ {sessionChanged && <span className="text-yellow-400 ml-1">(changed)</span>}
3181
+ </button>
3182
+ {sessionOpen && (
3183
+ <div className="px-4 pb-3 space-y-1 text-xs">
3184
+ {run.sessionIdBefore && (
3185
+ <div className="flex items-center gap-2">
3186
+ <span className="text-muted-foreground w-12">{sessionChanged ? "Before" : "ID"}</span>
3187
+ <CopyText text={run.sessionIdBefore} className="font-mono" />
3188
+ </div>
3189
+ )}
3190
+ {sessionChanged && run.sessionIdAfter && (
3191
+ <div className="flex items-center gap-2">
3192
+ <span className="text-muted-foreground w-12">After</span>
3193
+ <CopyText text={run.sessionIdAfter} className="font-mono" />
3194
+ </div>
3195
+ )}
3196
+ {touchedIssueIds.length > 0 && (
3197
+ <div className="pt-1">
3198
+ <button
3199
+ type="button"
3200
+ className="text-[11px] text-muted-foreground underline underline-offset-2 hover:text-foreground disabled:opacity-60"
3201
+ disabled={clearSessionsForTouchedIssues.isPending}
3202
+ onClick={() => {
3203
+ const issueCount = touchedIssueIds.length;
3204
+ const confirmed = window.confirm(
3205
+ `Clear session for ${issueCount} issue${issueCount === 1 ? "" : "s"} touched by this run?`,
3206
+ );
3207
+ if (!confirmed) return;
3208
+ clearSessionsForTouchedIssues.mutate();
3209
+ }}
3210
+ >
3211
+ {clearSessionsForTouchedIssues.isPending
3212
+ ? "clearing session..."
3213
+ : "clear session for these issues"}
3214
+ </button>
3215
+ {clearSessionsForTouchedIssues.isError && (
3216
+ <p className="text-[11px] text-destructive mt-1">
3217
+ {clearSessionsForTouchedIssues.error instanceof Error
3218
+ ? clearSessionsForTouchedIssues.error.message
3219
+ : "Failed to clear sessions"}
3220
+ </p>
3221
+ )}
3222
+ </div>
3223
+ )}
3224
+ </div>
3225
+ )}
3226
+ </div>
3227
+ )}
3228
+ </div>
3229
+
3230
+ {/* Issues touched by this run */}
3231
+ {touchedIssues && touchedIssues.length > 0 && (
3232
+ <div className="space-y-2">
3233
+ <span className="text-xs font-medium text-muted-foreground">Issues Touched ({touchedIssues.length})</span>
3234
+ <div className="border border-border rounded-lg divide-y divide-border">
3235
+ {touchedIssues.map((issue) => (
3236
+ <Link
3237
+ key={issue.issueId}
3238
+ to={`/issues/${issue.identifier ?? issue.issueId}`}
3239
+ className="flex items-center justify-between w-full px-3 py-2 text-xs hover:bg-accent/20 transition-colors text-left no-underline text-inherit"
3240
+ >
3241
+ <div className="flex items-center gap-2 min-w-0">
3242
+ <StatusBadge status={issue.status} />
3243
+ <span className="truncate">{issue.title}</span>
3244
+ </div>
3245
+ <span className="font-mono text-muted-foreground shrink-0 ml-2">{issue.identifier ?? issue.issueId.slice(0, 8)}</span>
3246
+ </Link>
3247
+ ))}
3248
+ </div>
3249
+ </div>
3250
+ )}
3251
+
3252
+ {/* stderr excerpt for failed runs */}
3253
+ {run.stderrExcerpt && (
3254
+ <div className="space-y-1">
3255
+ <span className="text-xs font-medium text-red-600 dark:text-red-400">stderr</span>
3256
+ <pre className="bg-neutral-100 dark:bg-neutral-950 rounded-md p-3 text-xs font-mono text-red-700 dark:text-red-300 overflow-x-auto whitespace-pre-wrap">{run.stderrExcerpt}</pre>
3257
+ </div>
3258
+ )}
3259
+
3260
+ {/* stdout excerpt when no log is available */}
3261
+ {run.stdoutExcerpt && !run.logRef && (
3262
+ <div className="space-y-1">
3263
+ <span className="text-xs font-medium text-muted-foreground">stdout</span>
3264
+ <pre className="bg-neutral-100 dark:bg-neutral-950 rounded-md p-3 text-xs font-mono text-foreground overflow-x-auto whitespace-pre-wrap">{run.stdoutExcerpt}</pre>
3265
+ </div>
3266
+ )}
3267
+
3268
+ {/* Log viewer */}
3269
+ <LogViewer run={run} adapterType={adapterType} />
3270
+ <ScrollToBottom />
3271
+ </div>
3272
+ );
3273
+ }
3274
+
3275
+ /* ---- Log Viewer ---- */
3276
+
3277
+ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: string }) {
3278
+ const [events, setEvents] = useState<HeartbeatRunEvent[]>([]);
3279
+ const [logLines, setLogLines] = useState<Array<{ ts: string; stream: "stdout" | "stderr" | "system"; chunk: string }>>([]);
3280
+ const [loading, setLoading] = useState(true);
3281
+ const [logLoading, setLogLoading] = useState(!!run.logRef);
3282
+ const [logError, setLogError] = useState<string | null>(null);
3283
+ const [logOffset, setLogOffset] = useState(0);
3284
+ const [isFollowing, setIsFollowing] = useState(false);
3285
+ const [isStreamingConnected, setIsStreamingConnected] = useState(false);
3286
+ const [transcriptMode, setTranscriptMode] = useState<TranscriptMode>("nice");
3287
+ const logEndRef = useRef<HTMLDivElement>(null);
3288
+ const pendingLogLineRef = useRef("");
3289
+ const scrollContainerRef = useRef<ScrollContainer | null>(null);
3290
+ const isFollowingRef = useRef(false);
3291
+ const lastMetricsRef = useRef<{ scrollHeight: number; distanceFromBottom: number }>({
3292
+ scrollHeight: 0,
3293
+ distanceFromBottom: Number.POSITIVE_INFINITY,
3294
+ });
3295
+ const isLive = run.status === "running" || run.status === "queued";
3296
+ const { data: workspaceOperations = [] } = useQuery({
3297
+ queryKey: queryKeys.runWorkspaceOperations(run.id),
3298
+ queryFn: () => heartbeatsApi.workspaceOperations(run.id),
3299
+ refetchInterval: isLive ? 2000 : false,
3300
+ });
3301
+
3302
+ function isRunLogUnavailable(err: unknown): boolean {
3303
+ return err instanceof ApiError && err.status === 404;
3304
+ }
3305
+
3306
+ function appendLogContent(content: string, finalize = false) {
3307
+ if (!content && !finalize) return;
3308
+ const combined = `${pendingLogLineRef.current}${content}`;
3309
+ const split = combined.split("\n");
3310
+ pendingLogLineRef.current = split.pop() ?? "";
3311
+ if (finalize && pendingLogLineRef.current) {
3312
+ split.push(pendingLogLineRef.current);
3313
+ pendingLogLineRef.current = "";
3314
+ }
3315
+
3316
+ const parsed: Array<{ ts: string; stream: "stdout" | "stderr" | "system"; chunk: string }> = [];
3317
+ for (const line of split) {
3318
+ const trimmed = line.trim();
3319
+ if (!trimmed) continue;
3320
+ try {
3321
+ const raw = JSON.parse(trimmed) as { ts?: unknown; stream?: unknown; chunk?: unknown };
3322
+ const stream =
3323
+ raw.stream === "stderr" || raw.stream === "system" ? raw.stream : "stdout";
3324
+ const chunk = typeof raw.chunk === "string" ? raw.chunk : "";
3325
+ const ts = typeof raw.ts === "string" ? raw.ts : new Date().toISOString();
3326
+ if (!chunk) continue;
3327
+ parsed.push({ ts, stream, chunk });
3328
+ } catch {
3329
+ // ignore malformed lines
3330
+ }
3331
+ }
3332
+
3333
+ if (parsed.length > 0) {
3334
+ setLogLines((prev) => [...prev, ...parsed]);
3335
+ }
3336
+ }
3337
+
3338
+ // Fetch events
3339
+ const { data: initialEvents } = useQuery({
3340
+ queryKey: ["run-events", run.id],
3341
+ queryFn: () => heartbeatsApi.events(run.id, 0, 200),
3342
+ });
3343
+
3344
+ useEffect(() => {
3345
+ if (initialEvents) {
3346
+ setEvents(initialEvents);
3347
+ setLoading(false);
3348
+ }
3349
+ }, [initialEvents]);
3350
+
3351
+ const getScrollContainer = useCallback((): ScrollContainer => {
3352
+ if (scrollContainerRef.current) return scrollContainerRef.current;
3353
+ const container = findScrollContainer(logEndRef.current);
3354
+ scrollContainerRef.current = container;
3355
+ return container;
3356
+ }, []);
3357
+
3358
+ const updateFollowingState = useCallback(() => {
3359
+ const container = getScrollContainer();
3360
+ const metrics = readScrollMetrics(container);
3361
+ lastMetricsRef.current = metrics;
3362
+ const nearBottom = metrics.distanceFromBottom <= LIVE_SCROLL_BOTTOM_TOLERANCE_PX;
3363
+ isFollowingRef.current = nearBottom;
3364
+ setIsFollowing((prev) => (prev === nearBottom ? prev : nearBottom));
3365
+ }, [getScrollContainer]);
3366
+
3367
+ useEffect(() => {
3368
+ scrollContainerRef.current = null;
3369
+ lastMetricsRef.current = {
3370
+ scrollHeight: 0,
3371
+ distanceFromBottom: Number.POSITIVE_INFINITY,
3372
+ };
3373
+
3374
+ if (!isLive) {
3375
+ isFollowingRef.current = false;
3376
+ setIsFollowing(false);
3377
+ return;
3378
+ }
3379
+
3380
+ updateFollowingState();
3381
+ }, [isLive, run.id, updateFollowingState]);
3382
+
3383
+ useEffect(() => {
3384
+ if (!isLive) return;
3385
+ const container = getScrollContainer();
3386
+ updateFollowingState();
3387
+
3388
+ if (container === window) {
3389
+ window.addEventListener("scroll", updateFollowingState, { passive: true });
3390
+ } else {
3391
+ container.addEventListener("scroll", updateFollowingState, { passive: true });
3392
+ }
3393
+ window.addEventListener("resize", updateFollowingState);
3394
+ return () => {
3395
+ if (container === window) {
3396
+ window.removeEventListener("scroll", updateFollowingState);
3397
+ } else {
3398
+ container.removeEventListener("scroll", updateFollowingState);
3399
+ }
3400
+ window.removeEventListener("resize", updateFollowingState);
3401
+ };
3402
+ }, [isLive, run.id, getScrollContainer, updateFollowingState]);
3403
+
3404
+ // Auto-scroll only for live runs when following
3405
+ useEffect(() => {
3406
+ if (!isLive || !isFollowingRef.current) return;
3407
+
3408
+ const container = getScrollContainer();
3409
+ const previous = lastMetricsRef.current;
3410
+ const current = readScrollMetrics(container);
3411
+ const growth = Math.max(0, current.scrollHeight - previous.scrollHeight);
3412
+ const expectedDistance = previous.distanceFromBottom + growth;
3413
+ const movedAwayBy = current.distanceFromBottom - expectedDistance;
3414
+
3415
+ // If user moved away from bottom between updates, release auto-follow immediately.
3416
+ if (movedAwayBy > LIVE_SCROLL_BOTTOM_TOLERANCE_PX) {
3417
+ isFollowingRef.current = false;
3418
+ setIsFollowing(false);
3419
+ lastMetricsRef.current = current;
3420
+ return;
3421
+ }
3422
+
3423
+ scrollToContainerBottom(container, "auto");
3424
+ const after = readScrollMetrics(container);
3425
+ lastMetricsRef.current = after;
3426
+ if (!isFollowingRef.current) {
3427
+ isFollowingRef.current = true;
3428
+ }
3429
+ setIsFollowing((prev) => (prev ? prev : true));
3430
+ }, [events.length, logLines.length, isLive, getScrollContainer]);
3431
+
3432
+ // Fetch persisted shell log
3433
+ useEffect(() => {
3434
+ let cancelled = false;
3435
+ pendingLogLineRef.current = "";
3436
+ setLogLines([]);
3437
+ setLogOffset(0);
3438
+ setLogError(null);
3439
+
3440
+ if (!run.logRef && !isLive) {
3441
+ setLogLoading(false);
3442
+ return () => {
3443
+ cancelled = true;
3444
+ };
3445
+ }
3446
+
3447
+ setLogLoading(true);
3448
+ const firstLimit =
3449
+ typeof run.logBytes === "number" && run.logBytes > 0
3450
+ ? Math.min(Math.max(run.logBytes + 1024, 256_000), 2_000_000)
3451
+ : 256_000;
3452
+
3453
+ const load = async () => {
3454
+ try {
3455
+ let offset = 0;
3456
+ let first = true;
3457
+ while (!cancelled) {
3458
+ const result = await heartbeatsApi.log(run.id, offset, first ? firstLimit : 256_000);
3459
+ if (cancelled) break;
3460
+ appendLogContent(result.content, result.nextOffset === undefined);
3461
+ const next = result.nextOffset ?? offset + result.content.length;
3462
+ setLogOffset(next);
3463
+ offset = next;
3464
+ first = false;
3465
+ if (result.nextOffset === undefined || isLive) break;
3466
+ }
3467
+ } catch (err) {
3468
+ if (!cancelled) {
3469
+ if (isLive && isRunLogUnavailable(err)) {
3470
+ setLogLoading(false);
3471
+ return;
3472
+ }
3473
+ setLogError(err instanceof Error ? err.message : "Failed to load run log");
3474
+ }
3475
+ } finally {
3476
+ if (!cancelled) setLogLoading(false);
3477
+ }
3478
+ };
3479
+
3480
+ void load();
3481
+ return () => {
3482
+ cancelled = true;
3483
+ };
3484
+ }, [run.id, run.logRef, run.logBytes, isLive]);
3485
+
3486
+ // Poll for live updates
3487
+ useEffect(() => {
3488
+ if (!isLive || isStreamingConnected) return;
3489
+ const interval = setInterval(async () => {
3490
+ const maxSeq = events.length > 0 ? Math.max(...events.map((e) => e.seq)) : 0;
3491
+ try {
3492
+ const newEvents = await heartbeatsApi.events(run.id, maxSeq, 100);
3493
+ if (newEvents.length > 0) {
3494
+ setEvents((prev) => [...prev, ...newEvents]);
3495
+ }
3496
+ } catch {
3497
+ // ignore polling errors
3498
+ }
3499
+ }, 2000);
3500
+ return () => clearInterval(interval);
3501
+ }, [run.id, isLive, isStreamingConnected, events]);
3502
+
3503
+ // Poll shell log for running runs
3504
+ useEffect(() => {
3505
+ if (!isLive || isStreamingConnected) return;
3506
+ const interval = setInterval(async () => {
3507
+ try {
3508
+ const result = await heartbeatsApi.log(run.id, logOffset, 256_000);
3509
+ if (result.content) {
3510
+ appendLogContent(result.content, result.nextOffset === undefined);
3511
+ }
3512
+ if (result.nextOffset !== undefined) {
3513
+ setLogOffset(result.nextOffset);
3514
+ } else if (result.content.length > 0) {
3515
+ setLogOffset((prev) => prev + result.content.length);
3516
+ }
3517
+ } catch (err) {
3518
+ if (isRunLogUnavailable(err)) return;
3519
+ // ignore polling errors
3520
+ }
3521
+ }, 2000);
3522
+ return () => clearInterval(interval);
3523
+ }, [run.id, isLive, isStreamingConnected, logOffset]);
3524
+
3525
+ // Stream live updates from websocket (primary path for running runs).
3526
+ useEffect(() => {
3527
+ if (!isLive) return;
3528
+
3529
+ let closed = false;
3530
+ let reconnectTimer: number | null = null;
3531
+ let socket: WebSocket | null = null;
3532
+
3533
+ const scheduleReconnect = () => {
3534
+ if (closed) return;
3535
+ reconnectTimer = window.setTimeout(connect, 1500);
3536
+ };
3537
+
3538
+ const connect = () => {
3539
+ if (closed) return;
3540
+ const protocol = window.location.protocol === "https:" ? "wss" : "ws";
3541
+ const url = `${protocol}://${window.location.host}/api/companies/${encodeURIComponent(run.companyId)}/events/ws`;
3542
+ socket = new WebSocket(url);
3543
+
3544
+ socket.onopen = () => {
3545
+ setIsStreamingConnected(true);
3546
+ };
3547
+
3548
+ socket.onmessage = (message) => {
3549
+ const rawMessage = typeof message.data === "string" ? message.data : "";
3550
+ if (!rawMessage) return;
3551
+
3552
+ let event: LiveEvent;
3553
+ try {
3554
+ event = JSON.parse(rawMessage) as LiveEvent;
3555
+ } catch {
3556
+ return;
3557
+ }
3558
+
3559
+ if (event.companyId !== run.companyId) return;
3560
+ const payload = asRecord(event.payload);
3561
+ const eventRunId = asNonEmptyString(payload?.runId);
3562
+ if (!payload || eventRunId !== run.id) return;
3563
+
3564
+ if (event.type === "heartbeat.run.log") {
3565
+ const chunk = typeof payload.chunk === "string" ? payload.chunk : "";
3566
+ if (!chunk) return;
3567
+ const streamRaw = asNonEmptyString(payload.stream);
3568
+ const stream = streamRaw === "stderr" || streamRaw === "system" ? streamRaw : "stdout";
3569
+ const ts = asNonEmptyString((payload as Record<string, unknown>).ts) ?? event.createdAt;
3570
+ setLogLines((prev) => [...prev, { ts, stream, chunk }]);
3571
+ return;
3572
+ }
3573
+
3574
+ if (event.type !== "heartbeat.run.event") return;
3575
+
3576
+ const seq = typeof payload.seq === "number" ? payload.seq : null;
3577
+ if (seq === null || !Number.isFinite(seq)) return;
3578
+
3579
+ const streamRaw = asNonEmptyString(payload.stream);
3580
+ const stream =
3581
+ streamRaw === "stdout" || streamRaw === "stderr" || streamRaw === "system"
3582
+ ? streamRaw
3583
+ : null;
3584
+ const levelRaw = asNonEmptyString(payload.level);
3585
+ const level =
3586
+ levelRaw === "info" || levelRaw === "warn" || levelRaw === "error"
3587
+ ? levelRaw
3588
+ : null;
3589
+
3590
+ const liveEvent: HeartbeatRunEvent = {
3591
+ id: seq,
3592
+ companyId: run.companyId,
3593
+ runId: run.id,
3594
+ agentId: run.agentId,
3595
+ seq,
3596
+ eventType: asNonEmptyString(payload.eventType) ?? "event",
3597
+ stream,
3598
+ level,
3599
+ color: asNonEmptyString(payload.color),
3600
+ message: asNonEmptyString(payload.message),
3601
+ payload: asRecord(payload.payload),
3602
+ createdAt: new Date(event.createdAt),
3603
+ };
3604
+
3605
+ setEvents((prev) => {
3606
+ if (prev.some((existing) => existing.seq === seq)) return prev;
3607
+ return [...prev, liveEvent];
3608
+ });
3609
+ };
3610
+
3611
+ socket.onerror = () => {
3612
+ socket?.close();
3613
+ };
3614
+
3615
+ socket.onclose = () => {
3616
+ setIsStreamingConnected(false);
3617
+ scheduleReconnect();
3618
+ };
3619
+ };
3620
+
3621
+ connect();
3622
+
3623
+ return () => {
3624
+ closed = true;
3625
+ setIsStreamingConnected(false);
3626
+ if (reconnectTimer !== null) window.clearTimeout(reconnectTimer);
3627
+ if (socket) {
3628
+ socket.onopen = null;
3629
+ socket.onmessage = null;
3630
+ socket.onerror = null;
3631
+ socket.onclose = null;
3632
+ socket.close(1000, "run_detail_unmount");
3633
+ }
3634
+ };
3635
+ }, [isLive, run.companyId, run.id, run.agentId]);
3636
+
3637
+ const censorUsernameInLogs = useQuery({
3638
+ queryKey: queryKeys.instance.generalSettings,
3639
+ queryFn: () => instanceSettingsApi.getGeneral(),
3640
+ }).data?.censorUsernameInLogs === true;
3641
+
3642
+ const adapterInvokePayload = useMemo(() => {
3643
+ const evt = events.find((e) => e.eventType === "adapter.invoke");
3644
+ return redactPathValue(asRecord(evt?.payload ?? null), censorUsernameInLogs);
3645
+ }, [censorUsernameInLogs, events]);
3646
+
3647
+ const adapter = useMemo(() => getUIAdapter(adapterType), [adapterType]);
3648
+ const transcript = useMemo(
3649
+ () => buildTranscript(logLines, adapter.parseStdoutLine, { censorUsernameInLogs }),
3650
+ [adapter, censorUsernameInLogs, logLines],
3651
+ );
3652
+
3653
+ useEffect(() => {
3654
+ setTranscriptMode("nice");
3655
+ }, [run.id]);
3656
+
3657
+ if (loading && logLoading) {
3658
+ return <p className="text-xs text-muted-foreground">Loading run logs...</p>;
3659
+ }
3660
+
3661
+ if (events.length === 0 && logLines.length === 0 && !logError) {
3662
+ return <p className="text-xs text-muted-foreground">No log events.</p>;
3663
+ }
3664
+
3665
+ const levelColors: Record<string, string> = {
3666
+ info: "text-foreground",
3667
+ warn: "text-yellow-600 dark:text-yellow-400",
3668
+ error: "text-red-600 dark:text-red-400",
3669
+ };
3670
+
3671
+ const streamColors: Record<string, string> = {
3672
+ stdout: "text-foreground",
3673
+ stderr: "text-red-600 dark:text-red-300",
3674
+ system: "text-blue-600 dark:text-blue-300",
3675
+ };
3676
+
3677
+ return (
3678
+ <div className="space-y-3">
3679
+ <WorkspaceOperationsSection
3680
+ operations={workspaceOperations}
3681
+ censorUsernameInLogs={censorUsernameInLogs}
3682
+ />
3683
+ {adapterInvokePayload && (
3684
+ <div className="rounded-lg border border-border bg-background/60 p-3 space-y-2">
3685
+ <div className="text-xs font-medium text-muted-foreground">Invocation</div>
3686
+ {typeof adapterInvokePayload.adapterType === "string" && (
3687
+ <div className="text-xs"><span className="text-muted-foreground">Adapter: </span>{adapterInvokePayload.adapterType}</div>
3688
+ )}
3689
+ {typeof adapterInvokePayload.cwd === "string" && (
3690
+ <div className="text-xs break-all"><span className="text-muted-foreground">Working dir: </span><span className="font-mono">{adapterInvokePayload.cwd}</span></div>
3691
+ )}
3692
+ {typeof adapterInvokePayload.command === "string" && (
3693
+ <div className="text-xs break-all">
3694
+ <span className="text-muted-foreground">Command: </span>
3695
+ <span className="font-mono">
3696
+ {[
3697
+ adapterInvokePayload.command,
3698
+ ...(Array.isArray(adapterInvokePayload.commandArgs)
3699
+ ? adapterInvokePayload.commandArgs.filter((v): v is string => typeof v === "string")
3700
+ : []),
3701
+ ].join(" ")}
3702
+ </span>
3703
+ </div>
3704
+ )}
3705
+ {Array.isArray(adapterInvokePayload.commandNotes) && adapterInvokePayload.commandNotes.length > 0 && (
3706
+ <div>
3707
+ <div className="text-xs text-muted-foreground mb-1">Command notes</div>
3708
+ <ul className="list-disc pl-5 space-y-1">
3709
+ {adapterInvokePayload.commandNotes
3710
+ .filter((value): value is string => typeof value === "string" && value.trim().length > 0)
3711
+ .map((note, idx) => (
3712
+ <li key={`${idx}-${note}`} className="text-xs break-all font-mono">
3713
+ {note}
3714
+ </li>
3715
+ ))}
3716
+ </ul>
3717
+ </div>
3718
+ )}
3719
+ {adapterInvokePayload.prompt !== undefined && (
3720
+ <div>
3721
+ <div className="text-xs text-muted-foreground mb-1">Prompt</div>
3722
+ <pre className="bg-neutral-100 dark:bg-neutral-950 rounded-md p-2 text-xs overflow-x-auto whitespace-pre-wrap">
3723
+ {typeof adapterInvokePayload.prompt === "string"
3724
+ ? redactPathText(adapterInvokePayload.prompt, censorUsernameInLogs)
3725
+ : JSON.stringify(redactPathValue(adapterInvokePayload.prompt, censorUsernameInLogs), null, 2)}
3726
+ </pre>
3727
+ </div>
3728
+ )}
3729
+ {adapterInvokePayload.context !== undefined && (
3730
+ <div>
3731
+ <div className="text-xs text-muted-foreground mb-1">Context</div>
3732
+ <pre className="bg-neutral-100 dark:bg-neutral-950 rounded-md p-2 text-xs overflow-x-auto whitespace-pre-wrap">
3733
+ {JSON.stringify(redactPathValue(adapterInvokePayload.context, censorUsernameInLogs), null, 2)}
3734
+ </pre>
3735
+ </div>
3736
+ )}
3737
+ {adapterInvokePayload.env !== undefined && (
3738
+ <div>
3739
+ <div className="text-xs text-muted-foreground mb-1">Environment</div>
3740
+ <pre className="bg-neutral-100 dark:bg-neutral-950 rounded-md p-2 text-xs overflow-x-auto whitespace-pre-wrap font-mono">
3741
+ {formatEnvForDisplay(adapterInvokePayload.env, censorUsernameInLogs)}
3742
+ </pre>
3743
+ </div>
3744
+ )}
3745
+ </div>
3746
+ )}
3747
+
3748
+ <div className="flex items-center justify-between">
3749
+ <span className="text-xs font-medium text-muted-foreground">
3750
+ Transcript ({transcript.length})
3751
+ </span>
3752
+ <div className="flex items-center gap-2">
3753
+ <div className="inline-flex rounded-lg border border-border/70 bg-background/70 p-0.5">
3754
+ {(["nice", "raw"] as const).map((mode) => (
3755
+ <button
3756
+ key={mode}
3757
+ type="button"
3758
+ className={cn(
3759
+ "rounded-md px-2.5 py-1 text-[11px] font-medium capitalize transition-colors",
3760
+ transcriptMode === mode
3761
+ ? "bg-accent text-foreground shadow-sm"
3762
+ : "text-muted-foreground hover:text-foreground",
3763
+ )}
3764
+ onClick={() => setTranscriptMode(mode)}
3765
+ >
3766
+ {mode}
3767
+ </button>
3768
+ ))}
3769
+ </div>
3770
+ {isLive && !isFollowing && (
3771
+ <Button
3772
+ variant="ghost"
3773
+ size="xs"
3774
+ onClick={() => {
3775
+ const container = getScrollContainer();
3776
+ isFollowingRef.current = true;
3777
+ setIsFollowing(true);
3778
+ scrollToContainerBottom(container, "auto");
3779
+ lastMetricsRef.current = readScrollMetrics(container);
3780
+ }}
3781
+ >
3782
+ Jump to live
3783
+ </Button>
3784
+ )}
3785
+ {isLive && (
3786
+ <span className="flex items-center gap-1 text-xs text-cyan-400">
3787
+ <span className="relative flex h-2 w-2">
3788
+ <span className="animate-pulse absolute inline-flex h-full w-full rounded-full bg-cyan-400 opacity-75" />
3789
+ <span className="relative inline-flex rounded-full h-2 w-2 bg-cyan-400" />
3790
+ </span>
3791
+ Live
3792
+ </span>
3793
+ )}
3794
+ </div>
3795
+ </div>
3796
+ <div className="max-h-[38rem] overflow-y-auto rounded-2xl border border-border/70 bg-background/40 p-3 sm:p-4">
3797
+ <RunTranscriptView
3798
+ entries={transcript}
3799
+ mode={transcriptMode}
3800
+ streaming={isLive}
3801
+ emptyMessage={run.logRef ? "Waiting for transcript..." : "No persisted transcript for this run."}
3802
+ />
3803
+ {logError && (
3804
+ <div className="mt-3 rounded-xl border border-red-500/20 bg-red-500/[0.06] px-3 py-2 text-xs text-red-700 dark:text-red-300">
3805
+ {logError}
3806
+ </div>
3807
+ )}
3808
+ <div ref={logEndRef} />
3809
+ </div>
3810
+
3811
+ {(run.status === "failed" || run.status === "timed_out") && (
3812
+ <div className="rounded-lg border border-red-300 dark:border-red-500/30 bg-red-50 dark:bg-red-950/20 p-3 space-y-2">
3813
+ <div className="text-xs font-medium text-red-700 dark:text-red-300">Failure details</div>
3814
+ {run.error && (
3815
+ <div className="text-xs text-red-600 dark:text-red-200">
3816
+ <span className="text-red-700 dark:text-red-300">Error: </span>
3817
+ {redactPathText(run.error, censorUsernameInLogs)}
3818
+ </div>
3819
+ )}
3820
+ {run.stderrExcerpt && run.stderrExcerpt.trim() && (
3821
+ <div>
3822
+ <div className="text-xs text-red-700 dark:text-red-300 mb-1">stderr excerpt</div>
3823
+ <pre className="bg-red-50 dark:bg-neutral-950 rounded-md p-2 text-xs overflow-x-auto whitespace-pre-wrap text-red-800 dark:text-red-100">
3824
+ {redactPathText(run.stderrExcerpt, censorUsernameInLogs)}
3825
+ </pre>
3826
+ </div>
3827
+ )}
3828
+ {run.resultJson && (
3829
+ <div>
3830
+ <div className="text-xs text-red-700 dark:text-red-300 mb-1">adapter result JSON</div>
3831
+ <pre className="bg-red-50 dark:bg-neutral-950 rounded-md p-2 text-xs overflow-x-auto whitespace-pre-wrap text-red-800 dark:text-red-100">
3832
+ {JSON.stringify(redactPathValue(run.resultJson, censorUsernameInLogs), null, 2)}
3833
+ </pre>
3834
+ </div>
3835
+ )}
3836
+ {run.stdoutExcerpt && run.stdoutExcerpt.trim() && !run.resultJson && (
3837
+ <div>
3838
+ <div className="text-xs text-red-700 dark:text-red-300 mb-1">stdout excerpt</div>
3839
+ <pre className="bg-red-50 dark:bg-neutral-950 rounded-md p-2 text-xs overflow-x-auto whitespace-pre-wrap text-red-800 dark:text-red-100">
3840
+ {redactPathText(run.stdoutExcerpt, censorUsernameInLogs)}
3841
+ </pre>
3842
+ </div>
3843
+ )}
3844
+ </div>
3845
+ )}
3846
+
3847
+ {events.length > 0 && (
3848
+ <div>
3849
+ <div className="mb-2 text-xs font-medium text-muted-foreground">Events ({events.length})</div>
3850
+ <div className="bg-neutral-100 dark:bg-neutral-950 rounded-lg p-3 font-mono text-xs space-y-0.5">
3851
+ {events.map((evt) => {
3852
+ const color = evt.color
3853
+ ?? (evt.level ? levelColors[evt.level] : null)
3854
+ ?? (evt.stream ? streamColors[evt.stream] : null)
3855
+ ?? "text-foreground";
3856
+
3857
+ return (
3858
+ <div key={evt.id} className="flex gap-2">
3859
+ <span className="text-neutral-400 dark:text-neutral-600 shrink-0 select-none w-16">
3860
+ {new Date(evt.createdAt).toLocaleTimeString("en-US", { hour12: false })}
3861
+ </span>
3862
+ <span className={cn("shrink-0 w-14", evt.stream ? (streamColors[evt.stream] ?? "text-neutral-500") : "text-neutral-500")}>
3863
+ {evt.stream ? `[${evt.stream}]` : ""}
3864
+ </span>
3865
+ <span className={cn("break-all", color)}>
3866
+ {evt.message
3867
+ ? redactPathText(evt.message, censorUsernameInLogs)
3868
+ : evt.payload
3869
+ ? JSON.stringify(redactPathValue(evt.payload, censorUsernameInLogs))
3870
+ : ""}
3871
+ </span>
3872
+ </div>
3873
+ );
3874
+ })}
3875
+ </div>
3876
+ </div>
3877
+ )}
3878
+ </div>
3879
+ );
3880
+ }
3881
+
3882
+ /* ---- Keys Tab ---- */
3883
+
3884
+ function KeysTab({ agentId, companyId }: { agentId: string; companyId?: string }) {
3885
+ const queryClient = useQueryClient();
3886
+ const [newKeyName, setNewKeyName] = useState("");
3887
+ const [newToken, setNewToken] = useState<string | null>(null);
3888
+ const [tokenVisible, setTokenVisible] = useState(false);
3889
+ const [copied, setCopied] = useState(false);
3890
+
3891
+ const { data: keys, isLoading } = useQuery({
3892
+ queryKey: queryKeys.agents.keys(agentId),
3893
+ queryFn: () => agentsApi.listKeys(agentId, companyId),
3894
+ });
3895
+
3896
+ const createKey = useMutation({
3897
+ mutationFn: () => agentsApi.createKey(agentId, newKeyName.trim() || "Default", companyId),
3898
+ onSuccess: (data) => {
3899
+ setNewToken(data.token);
3900
+ setTokenVisible(true);
3901
+ setNewKeyName("");
3902
+ queryClient.invalidateQueries({ queryKey: queryKeys.agents.keys(agentId) });
3903
+ },
3904
+ });
3905
+
3906
+ const revokeKey = useMutation({
3907
+ mutationFn: (keyId: string) => agentsApi.revokeKey(agentId, keyId, companyId),
3908
+ onSuccess: () => {
3909
+ queryClient.invalidateQueries({ queryKey: queryKeys.agents.keys(agentId) });
3910
+ },
3911
+ });
3912
+
3913
+ function copyToken() {
3914
+ if (!newToken) return;
3915
+ navigator.clipboard.writeText(newToken);
3916
+ setCopied(true);
3917
+ setTimeout(() => setCopied(false), 2000);
3918
+ }
3919
+
3920
+ const activeKeys = (keys ?? []).filter((k: AgentKey) => !k.revokedAt);
3921
+ const revokedKeys = (keys ?? []).filter((k: AgentKey) => k.revokedAt);
3922
+
3923
+ return (
3924
+ <div className="space-y-6">
3925
+ {/* New token banner */}
3926
+ {newToken && (
3927
+ <div className="border border-yellow-300 dark:border-yellow-600/40 bg-yellow-50 dark:bg-yellow-500/5 rounded-lg p-4 space-y-2">
3928
+ <p className="text-sm font-medium text-yellow-700 dark:text-yellow-400">
3929
+ API key created — copy it now, it will not be shown again.
3930
+ </p>
3931
+ <div className="flex items-center gap-2">
3932
+ <code className="flex-1 bg-neutral-100 dark:bg-neutral-950 rounded px-3 py-1.5 text-xs font-mono text-green-700 dark:text-green-300 truncate">
3933
+ {tokenVisible ? newToken : newToken.replace(/./g, "•")}
3934
+ </code>
3935
+ <Button
3936
+ variant="ghost"
3937
+ size="icon-sm"
3938
+ onClick={() => setTokenVisible((v) => !v)}
3939
+ title={tokenVisible ? "Hide" : "Show"}
3940
+ >
3941
+ {tokenVisible ? <EyeOff className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
3942
+ </Button>
3943
+ <Button
3944
+ variant="ghost"
3945
+ size="icon-sm"
3946
+ onClick={copyToken}
3947
+ title="Copy"
3948
+ >
3949
+ <Copy className="h-3.5 w-3.5" />
3950
+ </Button>
3951
+ {copied && <span className="text-xs text-green-400">Copied!</span>}
3952
+ </div>
3953
+ <Button
3954
+ variant="ghost"
3955
+ size="sm"
3956
+ className="text-muted-foreground text-xs"
3957
+ onClick={() => setNewToken(null)}
3958
+ >
3959
+ Dismiss
3960
+ </Button>
3961
+ </div>
3962
+ )}
3963
+
3964
+ {/* Create new key */}
3965
+ <div className="border border-border rounded-lg p-4 space-y-3">
3966
+ <h3 className="text-xs font-medium text-muted-foreground flex items-center gap-2">
3967
+ <Key className="h-3.5 w-3.5" />
3968
+ Create API Key
3969
+ </h3>
3970
+ <p className="text-xs text-muted-foreground">
3971
+ API keys allow this agent to authenticate calls to the Corporate server.
3972
+ </p>
3973
+ <div className="flex items-center gap-2">
3974
+ <Input
3975
+ placeholder="Key name (e.g. production)"
3976
+ value={newKeyName}
3977
+ onChange={(e) => setNewKeyName(e.target.value)}
3978
+ className="h-8 text-sm"
3979
+ onKeyDown={(e) => {
3980
+ if (e.key === "Enter") createKey.mutate();
3981
+ }}
3982
+ />
3983
+ <Button
3984
+ size="sm"
3985
+ onClick={() => createKey.mutate()}
3986
+ disabled={createKey.isPending}
3987
+ >
3988
+ <Plus className="h-3.5 w-3.5 mr-1" />
3989
+ Create
3990
+ </Button>
3991
+ </div>
3992
+ </div>
3993
+
3994
+ {/* Active keys */}
3995
+ {isLoading && <p className="text-sm text-muted-foreground">Loading keys...</p>}
3996
+
3997
+ {!isLoading && activeKeys.length === 0 && !newToken && (
3998
+ <p className="text-sm text-muted-foreground">No active API keys.</p>
3999
+ )}
4000
+
4001
+ {activeKeys.length > 0 && (
4002
+ <div>
4003
+ <h3 className="text-xs font-medium text-muted-foreground mb-2">
4004
+ Active Keys
4005
+ </h3>
4006
+ <div className="border border-border rounded-lg divide-y divide-border">
4007
+ {activeKeys.map((key: AgentKey) => (
4008
+ <div key={key.id} className="flex items-center justify-between px-4 py-2.5">
4009
+ <div>
4010
+ <span className="text-sm font-medium">{key.name}</span>
4011
+ <span className="text-xs text-muted-foreground ml-3">
4012
+ Created {formatDate(key.createdAt)}
4013
+ </span>
4014
+ </div>
4015
+ <Button
4016
+ variant="ghost"
4017
+ size="sm"
4018
+ className="text-destructive hover:text-destructive text-xs"
4019
+ onClick={() => revokeKey.mutate(key.id)}
4020
+ disabled={revokeKey.isPending}
4021
+ >
4022
+ Revoke
4023
+ </Button>
4024
+ </div>
4025
+ ))}
4026
+ </div>
4027
+ </div>
4028
+ )}
4029
+
4030
+ {/* Revoked keys */}
4031
+ {revokedKeys.length > 0 && (
4032
+ <div>
4033
+ <h3 className="text-xs font-medium text-muted-foreground mb-2">
4034
+ Revoked Keys
4035
+ </h3>
4036
+ <div className="border border-border rounded-lg divide-y divide-border opacity-50">
4037
+ {revokedKeys.map((key: AgentKey) => (
4038
+ <div key={key.id} className="flex items-center justify-between px-4 py-2.5">
4039
+ <div>
4040
+ <span className="text-sm line-through">{key.name}</span>
4041
+ <span className="text-xs text-muted-foreground ml-3">
4042
+ Revoked {key.revokedAt ? formatDate(key.revokedAt) : ""}
4043
+ </span>
4044
+ </div>
4045
+ </div>
4046
+ ))}
4047
+ </div>
4048
+ </div>
4049
+ )}
4050
+ </div>
4051
+ );
4052
+ }
4053
+