slackhive 0.1.37 → 0.1.39
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.dockerignore +14 -0
- package/.env.example +44 -0
- package/.github/ISSUE_TEMPLATE/bug_report.yml +65 -0
- package/.github/ISSUE_TEMPLATE/config.yml +5 -0
- package/.github/ISSUE_TEMPLATE/feature_request.yml +38 -0
- package/.github/PULL_REQUEST_TEMPLATE.md +27 -0
- package/.github/dependabot.yml +20 -0
- package/.github/workflows/audit.yml +149 -0
- package/.github/workflows/ci.yml +135 -0
- package/CHANGELOG.md +52 -0
- package/CODE_OF_CONDUCT.md +37 -0
- package/CONTRIBUTING.md +204 -0
- package/LICENSE +21 -0
- package/README.md +19 -0
- package/SECURITY.md +47 -0
- package/apps/runner/Dockerfile +33 -0
- package/apps/runner/dist/__tests__/channel-restrictions.test.d.ts +8 -0
- package/apps/runner/dist/__tests__/channel-restrictions.test.js +63 -0
- package/apps/runner/dist/__tests__/channel-restrictions.test.js.map +1 -0
- package/apps/runner/dist/__tests__/claude-handler-resolve.test.d.ts +20 -0
- package/apps/runner/dist/__tests__/claude-handler-resolve.test.js +178 -0
- package/apps/runner/dist/__tests__/claude-handler-resolve.test.js.map +1 -0
- package/apps/runner/dist/__tests__/compile-claude-md.test.d.ts +13 -0
- package/apps/runner/dist/__tests__/compile-claude-md.test.js +144 -0
- package/apps/runner/dist/__tests__/compile-claude-md.test.js.map +1 -0
- package/apps/runner/dist/__tests__/memory-sync.test.d.ts +11 -0
- package/apps/runner/dist/__tests__/memory-sync.test.js +56 -0
- package/apps/runner/dist/__tests__/memory-sync.test.js.map +1 -0
- package/apps/runner/dist/__tests__/slack-file-support.test.d.ts +9 -0
- package/apps/runner/dist/__tests__/slack-file-support.test.js +271 -0
- package/apps/runner/dist/__tests__/slack-file-support.test.js.map +1 -0
- package/apps/runner/dist/__tests__/slack-formatting.test.d.ts +12 -0
- package/apps/runner/dist/__tests__/slack-formatting.test.js +400 -0
- package/apps/runner/dist/__tests__/slack-formatting.test.js.map +1 -0
- package/apps/runner/dist/__tests__/thread-context.test.d.ts +12 -0
- package/apps/runner/dist/__tests__/thread-context.test.js +182 -0
- package/apps/runner/dist/__tests__/thread-context.test.js.map +1 -0
- package/apps/runner/dist/agent-runner.d.ts +118 -0
- package/apps/runner/dist/agent-runner.js +352 -0
- package/apps/runner/dist/agent-runner.js.map +1 -0
- package/apps/runner/dist/claude-handler.d.ts +122 -0
- package/apps/runner/dist/claude-handler.js +402 -0
- package/apps/runner/dist/claude-handler.js.map +1 -0
- package/apps/runner/dist/compile-claude-md.d.ts +59 -0
- package/apps/runner/dist/compile-claude-md.js +291 -0
- package/apps/runner/dist/compile-claude-md.js.map +1 -0
- package/apps/runner/dist/correction-handler.d.ts +46 -0
- package/apps/runner/dist/correction-handler.js +162 -0
- package/apps/runner/dist/correction-handler.js.map +1 -0
- package/apps/runner/dist/correction-manager.d.ts +53 -0
- package/apps/runner/dist/correction-manager.js +241 -0
- package/apps/runner/dist/correction-manager.js.map +1 -0
- package/apps/runner/dist/db.d.ts +193 -0
- package/apps/runner/dist/db.js +492 -0
- package/apps/runner/dist/db.js.map +1 -0
- package/apps/runner/dist/index.d.ts +9 -0
- package/apps/runner/dist/index.js +43 -0
- package/apps/runner/dist/index.js.map +1 -0
- package/apps/runner/dist/job-scheduler.d.ts +57 -0
- package/apps/runner/dist/job-scheduler.js +150 -0
- package/apps/runner/dist/job-scheduler.js.map +1 -0
- package/apps/runner/dist/logger.d.ts +32 -0
- package/apps/runner/dist/logger.js +52 -0
- package/apps/runner/dist/logger.js.map +1 -0
- package/apps/runner/dist/mcp-process-manager.d.ts +38 -0
- package/apps/runner/dist/mcp-process-manager.js +189 -0
- package/apps/runner/dist/mcp-process-manager.js.map +1 -0
- package/apps/runner/dist/memory-mcp.d.ts +14 -0
- package/apps/runner/dist/memory-mcp.js +88 -0
- package/apps/runner/dist/memory-mcp.js.map +1 -0
- package/apps/runner/dist/memory-watcher.d.ts +78 -0
- package/apps/runner/dist/memory-watcher.js +220 -0
- package/apps/runner/dist/memory-watcher.js.map +1 -0
- package/apps/runner/dist/slack-handler.d.ts +120 -0
- package/apps/runner/dist/slack-handler.js +843 -0
- package/apps/runner/dist/slack-handler.js.map +1 -0
- package/apps/runner/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
- package/apps/runner/package.json +42 -0
- package/apps/runner/src/__tests__/channel-restrictions.test.ts +75 -0
- package/apps/runner/src/__tests__/claude-handler-resolve.test.ts +160 -0
- package/apps/runner/src/__tests__/compile-claude-md.test.ts +139 -0
- package/apps/runner/src/__tests__/memory-sync.test.ts +59 -0
- package/apps/runner/src/__tests__/slack-file-support.test.ts +376 -0
- package/apps/runner/src/__tests__/slack-formatting.test.ts +495 -0
- package/apps/runner/src/__tests__/thread-context.test.ts +215 -0
- package/apps/runner/src/agent-runner.ts +397 -0
- package/apps/runner/src/claude-handler.ts +475 -0
- package/apps/runner/src/compile-claude-md.ts +283 -0
- package/apps/runner/src/correction-handler.ts +191 -0
- package/apps/runner/src/correction-manager.ts +285 -0
- package/apps/runner/src/db.ts +604 -0
- package/apps/runner/src/index.ts +46 -0
- package/apps/runner/src/job-scheduler.ts +165 -0
- package/apps/runner/src/logger.ts +49 -0
- package/apps/runner/src/mcp-process-manager.ts +195 -0
- package/apps/runner/src/memory-mcp.ts +85 -0
- package/apps/runner/src/memory-watcher.ts +215 -0
- package/apps/runner/src/slack-handler.ts +929 -0
- package/apps/runner/tsconfig.json +17 -0
- package/apps/runner/vitest.config.mts +17 -0
- package/apps/web/.eslintrc.json +3 -0
- package/apps/web/.next/app-build-manifest.json +323 -0
- package/apps/web/.next/app-path-routes-manifest.json +46 -0
- package/apps/web/.next/build-manifest.json +33 -0
- package/apps/web/.next/cache/.previewinfo +1 -0
- package/apps/web/.next/cache/.rscinfo +1 -0
- package/apps/web/.next/cache/webpack/client-production/0.pack +0 -0
- package/apps/web/.next/cache/webpack/client-production/1.pack +0 -0
- package/apps/web/.next/cache/webpack/client-production/2.pack +0 -0
- package/apps/web/.next/cache/webpack/client-production/3.pack +0 -0
- package/apps/web/.next/cache/webpack/client-production/4.pack +0 -0
- package/apps/web/.next/cache/webpack/client-production/index.pack +0 -0
- package/apps/web/.next/cache/webpack/client-production/index.pack.old +0 -0
- package/apps/web/.next/cache/webpack/edge-server-production/0.pack +0 -0
- package/apps/web/.next/cache/webpack/edge-server-production/1.pack +0 -0
- package/apps/web/.next/cache/webpack/edge-server-production/index.pack +0 -0
- package/apps/web/.next/cache/webpack/edge-server-production/index.pack.old +0 -0
- package/apps/web/.next/cache/webpack/server-production/0.pack +0 -0
- package/apps/web/.next/cache/webpack/server-production/1.pack +0 -0
- package/apps/web/.next/cache/webpack/server-production/2.pack +0 -0
- package/apps/web/.next/cache/webpack/server-production/index.pack +0 -0
- package/apps/web/.next/cache/webpack/server-production/index.pack.old +0 -0
- package/apps/web/.next/diagnostics/build-diagnostics.json +6 -0
- package/apps/web/.next/diagnostics/framework.json +1 -0
- package/apps/web/.next/package.json +1 -0
- package/apps/web/.next/react-loadable-manifest.json +1 -0
- package/apps/web/.next/server/app/_not-found/page.js +2 -0
- package/apps/web/.next/server/app/_not-found/page.js.nft.json +1 -0
- package/apps/web/.next/server/app/_not-found/page_client-reference-manifest.js +1 -0
- package/apps/web/.next/server/app/agents/[slug]/page.js +4 -0
- package/apps/web/.next/server/app/agents/[slug]/page.js.nft.json +1 -0
- package/apps/web/.next/server/app/agents/[slug]/page_client-reference-manifest.js +1 -0
- package/apps/web/.next/server/app/agents/new/page.js +2 -0
- package/apps/web/.next/server/app/agents/new/page.js.nft.json +1 -0
- package/apps/web/.next/server/app/agents/new/page_client-reference-manifest.js +1 -0
- package/apps/web/.next/server/app/api/agents/[id]/access/route.js +1 -0
- package/apps/web/.next/server/app/api/agents/[id]/access/route.js.nft.json +1 -0
- package/apps/web/.next/server/app/api/agents/[id]/access/route_client-reference-manifest.js +1 -0
- package/apps/web/.next/server/app/api/agents/[id]/claude-md/route.js +6 -0
- package/apps/web/.next/server/app/api/agents/[id]/claude-md/route.js.nft.json +1 -0
- package/apps/web/.next/server/app/api/agents/[id]/claude-md/route_client-reference-manifest.js +1 -0
- package/apps/web/.next/server/app/api/agents/[id]/logs/route.js +3 -0
- package/apps/web/.next/server/app/api/agents/[id]/logs/route.js.nft.json +1 -0
- package/apps/web/.next/server/app/api/agents/[id]/logs/route_client-reference-manifest.js +1 -0
- package/apps/web/.next/server/app/api/agents/[id]/manifest/route.js +1 -0
- package/apps/web/.next/server/app/api/agents/[id]/manifest/route.js.nft.json +1 -0
- package/apps/web/.next/server/app/api/agents/[id]/manifest/route_client-reference-manifest.js +1 -0
- package/apps/web/.next/server/app/api/agents/[id]/mcps/route.js +1 -0
- package/apps/web/.next/server/app/api/agents/[id]/mcps/route.js.nft.json +1 -0
- package/apps/web/.next/server/app/api/agents/[id]/mcps/route_client-reference-manifest.js +1 -0
- package/apps/web/.next/server/app/api/agents/[id]/memories/[memId]/route.js +1 -0
- package/apps/web/.next/server/app/api/agents/[id]/memories/[memId]/route.js.nft.json +1 -0
- package/apps/web/.next/server/app/api/agents/[id]/memories/[memId]/route_client-reference-manifest.js +1 -0
- package/apps/web/.next/server/app/api/agents/[id]/memories/route.js +1 -0
- package/apps/web/.next/server/app/api/agents/[id]/memories/route.js.nft.json +1 -0
- package/apps/web/.next/server/app/api/agents/[id]/memories/route_client-reference-manifest.js +1 -0
- package/apps/web/.next/server/app/api/agents/[id]/permissions/route.js +1 -0
- package/apps/web/.next/server/app/api/agents/[id]/permissions/route.js.nft.json +1 -0
- package/apps/web/.next/server/app/api/agents/[id]/permissions/route_client-reference-manifest.js +1 -0
- package/apps/web/.next/server/app/api/agents/[id]/reload/route.js +1 -0
- package/apps/web/.next/server/app/api/agents/[id]/reload/route.js.nft.json +1 -0
- package/apps/web/.next/server/app/api/agents/[id]/reload/route_client-reference-manifest.js +1 -0
- package/apps/web/.next/server/app/api/agents/[id]/restrictions/route.js +1 -0
- package/apps/web/.next/server/app/api/agents/[id]/restrictions/route.js.nft.json +1 -0
- package/apps/web/.next/server/app/api/agents/[id]/restrictions/route_client-reference-manifest.js +1 -0
- package/apps/web/.next/server/app/api/agents/[id]/route.js +33 -0
- package/apps/web/.next/server/app/api/agents/[id]/route.js.nft.json +1 -0
- package/apps/web/.next/server/app/api/agents/[id]/route_client-reference-manifest.js +1 -0
- package/apps/web/.next/server/app/api/agents/[id]/skills/[skillId]/route.js +1 -0
- package/apps/web/.next/server/app/api/agents/[id]/skills/[skillId]/route.js.nft.json +1 -0
- package/apps/web/.next/server/app/api/agents/[id]/skills/[skillId]/route_client-reference-manifest.js +1 -0
- package/apps/web/.next/server/app/api/agents/[id]/skills/route.js +1 -0
- package/apps/web/.next/server/app/api/agents/[id]/skills/route.js.nft.json +1 -0
- package/apps/web/.next/server/app/api/agents/[id]/skills/route_client-reference-manifest.js +1 -0
- package/apps/web/.next/server/app/api/agents/[id]/slack-info/route.js +1 -0
- package/apps/web/.next/server/app/api/agents/[id]/slack-info/route.js.nft.json +1 -0
- package/apps/web/.next/server/app/api/agents/[id]/slack-info/route_client-reference-manifest.js +1 -0
- package/apps/web/.next/server/app/api/agents/[id]/snapshots/[sid]/restore/route.js +1 -0
- package/apps/web/.next/server/app/api/agents/[id]/snapshots/[sid]/restore/route.js.nft.json +1 -0
- package/apps/web/.next/server/app/api/agents/[id]/snapshots/[sid]/restore/route_client-reference-manifest.js +1 -0
- package/apps/web/.next/server/app/api/agents/[id]/snapshots/[sid]/route.js +1 -0
- package/apps/web/.next/server/app/api/agents/[id]/snapshots/[sid]/route.js.nft.json +1 -0
- package/apps/web/.next/server/app/api/agents/[id]/snapshots/[sid]/route_client-reference-manifest.js +1 -0
- package/apps/web/.next/server/app/api/agents/[id]/snapshots/route.js +1 -0
- package/apps/web/.next/server/app/api/agents/[id]/snapshots/route.js.nft.json +1 -0
- package/apps/web/.next/server/app/api/agents/[id]/snapshots/route_client-reference-manifest.js +1 -0
- package/apps/web/.next/server/app/api/agents/[id]/start/route.js +1 -0
- package/apps/web/.next/server/app/api/agents/[id]/start/route.js.nft.json +1 -0
- package/apps/web/.next/server/app/api/agents/[id]/start/route_client-reference-manifest.js +1 -0
- package/apps/web/.next/server/app/api/agents/[id]/stop/route.js +1 -0
- package/apps/web/.next/server/app/api/agents/[id]/stop/route.js.nft.json +1 -0
- package/apps/web/.next/server/app/api/agents/[id]/stop/route_client-reference-manifest.js +1 -0
- package/apps/web/.next/server/app/api/agents/route.js +91 -0
- package/apps/web/.next/server/app/api/agents/route.js.nft.json +1 -0
- package/apps/web/.next/server/app/api/agents/route_client-reference-manifest.js +1 -0
- package/apps/web/.next/server/app/api/auth/login/route.js +1 -0
- package/apps/web/.next/server/app/api/auth/login/route.js.nft.json +1 -0
- package/apps/web/.next/server/app/api/auth/login/route_client-reference-manifest.js +1 -0
- package/apps/web/.next/server/app/api/auth/logout/route.js +1 -0
- package/apps/web/.next/server/app/api/auth/logout/route.js.nft.json +1 -0
- package/apps/web/.next/server/app/api/auth/logout/route_client-reference-manifest.js +1 -0
- package/apps/web/.next/server/app/api/auth/me/route.js +1 -0
- package/apps/web/.next/server/app/api/auth/me/route.js.nft.json +1 -0
- package/apps/web/.next/server/app/api/auth/me/route_client-reference-manifest.js +1 -0
- package/apps/web/.next/server/app/api/auth/users/[id]/route.js +1 -0
- package/apps/web/.next/server/app/api/auth/users/[id]/route.js.nft.json +1 -0
- package/apps/web/.next/server/app/api/auth/users/[id]/route_client-reference-manifest.js +1 -0
- package/apps/web/.next/server/app/api/auth/users/route.js +1 -0
- package/apps/web/.next/server/app/api/auth/users/route.js.nft.json +1 -0
- package/apps/web/.next/server/app/api/auth/users/route_client-reference-manifest.js +1 -0
- package/apps/web/.next/server/app/api/env-vars/[key]/route.js +1 -0
- package/apps/web/.next/server/app/api/env-vars/[key]/route.js.nft.json +1 -0
- package/apps/web/.next/server/app/api/env-vars/[key]/route_client-reference-manifest.js +1 -0
- package/apps/web/.next/server/app/api/env-vars/route.js +1 -0
- package/apps/web/.next/server/app/api/env-vars/route.js.nft.json +1 -0
- package/apps/web/.next/server/app/api/env-vars/route_client-reference-manifest.js +1 -0
- package/apps/web/.next/server/app/api/jobs/[id]/route.js +1 -0
- package/apps/web/.next/server/app/api/jobs/[id]/route.js.nft.json +1 -0
- package/apps/web/.next/server/app/api/jobs/[id]/route_client-reference-manifest.js +1 -0
- package/apps/web/.next/server/app/api/jobs/[id]/runs/route.js +1 -0
- package/apps/web/.next/server/app/api/jobs/[id]/runs/route.js.nft.json +1 -0
- package/apps/web/.next/server/app/api/jobs/[id]/runs/route_client-reference-manifest.js +1 -0
- package/apps/web/.next/server/app/api/jobs/route.js +1 -0
- package/apps/web/.next/server/app/api/jobs/route.js.nft.json +1 -0
- package/apps/web/.next/server/app/api/jobs/route_client-reference-manifest.js +1 -0
- package/apps/web/.next/server/app/api/mcps/[id]/route.js +1 -0
- package/apps/web/.next/server/app/api/mcps/[id]/route.js.nft.json +1 -0
- package/apps/web/.next/server/app/api/mcps/[id]/route_client-reference-manifest.js +1 -0
- package/apps/web/.next/server/app/api/mcps/[id]/test/route.js +1 -0
- package/apps/web/.next/server/app/api/mcps/[id]/test/route.js.nft.json +1 -0
- package/apps/web/.next/server/app/api/mcps/[id]/test/route_client-reference-manifest.js +1 -0
- package/apps/web/.next/server/app/api/mcps/route.js +1 -0
- package/apps/web/.next/server/app/api/mcps/route.js.nft.json +1 -0
- package/apps/web/.next/server/app/api/mcps/route_client-reference-manifest.js +1 -0
- package/apps/web/.next/server/app/api/settings/route.js +1 -0
- package/apps/web/.next/server/app/api/settings/route.js.nft.json +1 -0
- package/apps/web/.next/server/app/api/settings/route_client-reference-manifest.js +1 -0
- package/apps/web/.next/server/app/icon.svg/route.js +1 -0
- package/apps/web/.next/server/app/icon.svg/route.js.nft.json +1 -0
- package/apps/web/.next/server/app/jobs/page.js +2 -0
- package/apps/web/.next/server/app/jobs/page.js.nft.json +1 -0
- package/apps/web/.next/server/app/jobs/page_client-reference-manifest.js +1 -0
- package/apps/web/.next/server/app/login/page.js +2 -0
- package/apps/web/.next/server/app/login/page.js.nft.json +1 -0
- package/apps/web/.next/server/app/login/page_client-reference-manifest.js +1 -0
- package/apps/web/.next/server/app/page.js +2 -0
- package/apps/web/.next/server/app/page.js.nft.json +1 -0
- package/apps/web/.next/server/app/page_client-reference-manifest.js +1 -0
- package/apps/web/.next/server/app/settings/env-vars/page.js +2 -0
- package/apps/web/.next/server/app/settings/env-vars/page.js.nft.json +1 -0
- package/apps/web/.next/server/app/settings/env-vars/page_client-reference-manifest.js +1 -0
- package/apps/web/.next/server/app/settings/mcps/page.js +2 -0
- package/apps/web/.next/server/app/settings/mcps/page.js.nft.json +1 -0
- package/apps/web/.next/server/app/settings/mcps/page_client-reference-manifest.js +1 -0
- package/apps/web/.next/server/app/settings/page.js +2 -0
- package/apps/web/.next/server/app/settings/page.js.nft.json +1 -0
- package/apps/web/.next/server/app/settings/page_client-reference-manifest.js +1 -0
- package/apps/web/.next/server/app-paths-manifest.json +46 -0
- package/apps/web/.next/server/chunks/1157.js +9 -0
- package/apps/web/.next/server/chunks/2287.js +1 -0
- package/apps/web/.next/server/chunks/3444.js +1 -0
- package/apps/web/.next/server/chunks/383.js +6 -0
- package/apps/web/.next/server/chunks/4012.js +58 -0
- package/apps/web/.next/server/chunks/6791.js +1 -0
- package/apps/web/.next/server/chunks/7171.js +1 -0
- package/apps/web/.next/server/chunks/8819.js +22 -0
- package/apps/web/.next/server/edge-runtime-webpack.js +2 -0
- package/apps/web/.next/server/edge-runtime-webpack.js.map +1 -0
- package/apps/web/.next/server/interception-route-rewrite-manifest.js +1 -0
- package/apps/web/.next/server/middleware-build-manifest.js +1 -0
- package/apps/web/.next/server/middleware-manifest.json +32 -0
- package/apps/web/.next/server/middleware-react-loadable-manifest.js +1 -0
- package/apps/web/.next/server/next-font-manifest.js +1 -0
- package/apps/web/.next/server/next-font-manifest.json +1 -0
- package/apps/web/.next/server/pages/_app.js +1 -0
- package/apps/web/.next/server/pages/_app.js.nft.json +1 -0
- package/apps/web/.next/server/pages/_document.js +1 -0
- package/apps/web/.next/server/pages/_document.js.nft.json +1 -0
- package/apps/web/.next/server/pages/_error.js +19 -0
- package/apps/web/.next/server/pages/_error.js.nft.json +1 -0
- package/apps/web/.next/server/pages-manifest.json +5 -0
- package/apps/web/.next/server/server-reference-manifest.js +1 -0
- package/apps/web/.next/server/server-reference-manifest.json +1 -0
- package/apps/web/.next/server/src/middleware.js +14 -0
- package/apps/web/.next/server/src/middleware.js.map +1 -0
- package/apps/web/.next/server/webpack-runtime.js +1 -0
- package/apps/web/.next/static/chunks/18-90b700ea37b686a2.js +1 -0
- package/apps/web/.next/static/chunks/87c73c54-24122e7b92478d00.js +1 -0
- package/apps/web/.next/static/chunks/9664-af80478aa73ba424.js +1 -0
- package/apps/web/.next/static/chunks/app/_not-found/page-b9cee17ed89ca24a.js +1 -0
- package/apps/web/.next/static/chunks/app/agents/[slug]/page-18369fc3fe1a9a7b.js +1 -0
- package/apps/web/.next/static/chunks/app/agents/new/page-bf11cf8901c7e2cd.js +1 -0
- package/apps/web/.next/static/chunks/app/api/agents/[id]/access/route-07f0f73ac9839899.js +1 -0
- package/apps/web/.next/static/chunks/app/api/agents/[id]/claude-md/route-07f0f73ac9839899.js +1 -0
- package/apps/web/.next/static/chunks/app/api/agents/[id]/logs/route-07f0f73ac9839899.js +1 -0
- package/apps/web/.next/static/chunks/app/api/agents/[id]/manifest/route-07f0f73ac9839899.js +1 -0
- package/apps/web/.next/static/chunks/app/api/agents/[id]/mcps/route-07f0f73ac9839899.js +1 -0
- package/apps/web/.next/static/chunks/app/api/agents/[id]/memories/[memId]/route-07f0f73ac9839899.js +1 -0
- package/apps/web/.next/static/chunks/app/api/agents/[id]/memories/route-07f0f73ac9839899.js +1 -0
- package/apps/web/.next/static/chunks/app/api/agents/[id]/permissions/route-07f0f73ac9839899.js +1 -0
- package/apps/web/.next/static/chunks/app/api/agents/[id]/reload/route-07f0f73ac9839899.js +1 -0
- package/apps/web/.next/static/chunks/app/api/agents/[id]/restrictions/route-07f0f73ac9839899.js +1 -0
- package/apps/web/.next/static/chunks/app/api/agents/[id]/route-07f0f73ac9839899.js +1 -0
- package/apps/web/.next/static/chunks/app/api/agents/[id]/skills/[skillId]/route-07f0f73ac9839899.js +1 -0
- package/apps/web/.next/static/chunks/app/api/agents/[id]/skills/route-07f0f73ac9839899.js +1 -0
- package/apps/web/.next/static/chunks/app/api/agents/[id]/slack-info/route-07f0f73ac9839899.js +1 -0
- package/apps/web/.next/static/chunks/app/api/agents/[id]/snapshots/[sid]/restore/route-07f0f73ac9839899.js +1 -0
- package/apps/web/.next/static/chunks/app/api/agents/[id]/snapshots/[sid]/route-07f0f73ac9839899.js +1 -0
- package/apps/web/.next/static/chunks/app/api/agents/[id]/snapshots/route-07f0f73ac9839899.js +1 -0
- package/apps/web/.next/static/chunks/app/api/agents/[id]/start/route-07f0f73ac9839899.js +1 -0
- package/apps/web/.next/static/chunks/app/api/agents/[id]/stop/route-07f0f73ac9839899.js +1 -0
- package/apps/web/.next/static/chunks/app/api/agents/route-07f0f73ac9839899.js +1 -0
- package/apps/web/.next/static/chunks/app/api/auth/login/route-07f0f73ac9839899.js +1 -0
- package/apps/web/.next/static/chunks/app/api/auth/logout/route-07f0f73ac9839899.js +1 -0
- package/apps/web/.next/static/chunks/app/api/auth/me/route-07f0f73ac9839899.js +1 -0
- package/apps/web/.next/static/chunks/app/api/auth/users/[id]/route-07f0f73ac9839899.js +1 -0
- package/apps/web/.next/static/chunks/app/api/auth/users/route-07f0f73ac9839899.js +1 -0
- package/apps/web/.next/static/chunks/app/api/env-vars/[key]/route-07f0f73ac9839899.js +1 -0
- package/apps/web/.next/static/chunks/app/api/env-vars/route-07f0f73ac9839899.js +1 -0
- package/apps/web/.next/static/chunks/app/api/jobs/[id]/route-07f0f73ac9839899.js +1 -0
- package/apps/web/.next/static/chunks/app/api/jobs/[id]/runs/route-07f0f73ac9839899.js +1 -0
- package/apps/web/.next/static/chunks/app/api/jobs/route-07f0f73ac9839899.js +1 -0
- package/apps/web/.next/static/chunks/app/api/mcps/[id]/route-07f0f73ac9839899.js +1 -0
- package/apps/web/.next/static/chunks/app/api/mcps/[id]/test/route-07f0f73ac9839899.js +1 -0
- package/apps/web/.next/static/chunks/app/api/mcps/route-07f0f73ac9839899.js +1 -0
- package/apps/web/.next/static/chunks/app/api/settings/route-07f0f73ac9839899.js +1 -0
- package/apps/web/.next/static/chunks/app/jobs/page-f5aa89a47c50efd8.js +1 -0
- package/apps/web/.next/static/chunks/app/layout-2079f4964aa7314e.js +1 -0
- package/apps/web/.next/static/chunks/app/login/layout-07f0f73ac9839899.js +1 -0
- package/apps/web/.next/static/chunks/app/login/page-aa259283dc38e8f9.js +1 -0
- package/apps/web/.next/static/chunks/app/page-e83437b608104dff.js +1 -0
- package/apps/web/.next/static/chunks/app/settings/env-vars/page-06479dbdfb78b76b.js +1 -0
- package/apps/web/.next/static/chunks/app/settings/mcps/page-75650686ed6490c7.js +1 -0
- package/apps/web/.next/static/chunks/app/settings/page-e1e62fc41ff6cddd.js +1 -0
- package/apps/web/.next/static/chunks/framework-811407f832a33072.js +1 -0
- package/apps/web/.next/static/chunks/main-3f1cddbdd67b1546.js +1 -0
- package/apps/web/.next/static/chunks/main-app-cebd8a6a5ccbf72d.js +1 -0
- package/apps/web/.next/static/chunks/pages/_app-50fa07b56b2d29ac.js +1 -0
- package/apps/web/.next/static/chunks/pages/_error-fed8688bdd23f211.js +1 -0
- package/apps/web/.next/static/chunks/polyfills-42372ed130431b0a.js +1 -0
- package/apps/web/.next/static/chunks/webpack-6c05566dba553c97.js +1 -0
- package/apps/web/.next/static/css/15371687405525e2.css +5 -0
- package/apps/web/.next/static/ikfNbLhuw7jntn35bz0lk/_buildManifest.js +1 -0
- package/apps/web/.next/static/ikfNbLhuw7jntn35bz0lk/_ssgManifest.js +1 -0
- package/apps/web/.next/trace +5 -0
- package/apps/web/.next/types/app/agents/[slug]/page.ts +84 -0
- package/apps/web/.next/types/app/agents/new/page.ts +84 -0
- package/apps/web/.next/types/app/api/agents/[id]/access/route.ts +347 -0
- package/apps/web/.next/types/app/api/agents/[id]/claude-md/route.ts +347 -0
- package/apps/web/.next/types/app/api/agents/[id]/logs/route.ts +347 -0
- package/apps/web/.next/types/app/api/agents/[id]/manifest/route.ts +347 -0
- package/apps/web/.next/types/app/api/agents/[id]/mcps/route.ts +347 -0
- package/apps/web/.next/types/app/api/agents/[id]/memories/[memId]/route.ts +347 -0
- package/apps/web/.next/types/app/api/agents/[id]/memories/route.ts +347 -0
- package/apps/web/.next/types/app/api/agents/[id]/permissions/route.ts +347 -0
- package/apps/web/.next/types/app/api/agents/[id]/reload/route.ts +347 -0
- package/apps/web/.next/types/app/api/agents/[id]/restrictions/route.ts +347 -0
- package/apps/web/.next/types/app/api/agents/[id]/route.ts +347 -0
- package/apps/web/.next/types/app/api/agents/[id]/skills/[skillId]/route.ts +347 -0
- package/apps/web/.next/types/app/api/agents/[id]/skills/route.ts +347 -0
- package/apps/web/.next/types/app/api/agents/[id]/slack-info/route.ts +347 -0
- package/apps/web/.next/types/app/api/agents/[id]/snapshots/[sid]/restore/route.ts +347 -0
- package/apps/web/.next/types/app/api/agents/[id]/snapshots/[sid]/route.ts +347 -0
- package/apps/web/.next/types/app/api/agents/[id]/snapshots/route.ts +347 -0
- package/apps/web/.next/types/app/api/agents/[id]/start/route.ts +347 -0
- package/apps/web/.next/types/app/api/agents/[id]/stop/route.ts +347 -0
- package/apps/web/.next/types/app/api/agents/route.ts +347 -0
- package/apps/web/.next/types/app/api/auth/login/route.ts +347 -0
- package/apps/web/.next/types/app/api/auth/logout/route.ts +347 -0
- package/apps/web/.next/types/app/api/auth/me/route.ts +347 -0
- package/apps/web/.next/types/app/api/auth/users/[id]/route.ts +347 -0
- package/apps/web/.next/types/app/api/auth/users/route.ts +347 -0
- package/apps/web/.next/types/app/api/env-vars/[key]/route.ts +347 -0
- package/apps/web/.next/types/app/api/env-vars/route.ts +347 -0
- package/apps/web/.next/types/app/api/jobs/[id]/route.ts +347 -0
- package/apps/web/.next/types/app/api/jobs/[id]/runs/route.ts +347 -0
- package/apps/web/.next/types/app/api/jobs/route.ts +347 -0
- package/apps/web/.next/types/app/api/mcps/[id]/route.ts +347 -0
- package/apps/web/.next/types/app/api/mcps/[id]/test/route.ts +347 -0
- package/apps/web/.next/types/app/api/mcps/route.ts +347 -0
- package/apps/web/.next/types/app/api/settings/route.ts +347 -0
- package/apps/web/.next/types/app/jobs/page.ts +84 -0
- package/apps/web/.next/types/app/login/layout.ts +84 -0
- package/apps/web/.next/types/app/login/page.ts +84 -0
- package/apps/web/.next/types/app/page.ts +84 -0
- package/apps/web/.next/types/app/settings/env-vars/page.ts +84 -0
- package/apps/web/.next/types/app/settings/mcps/page.ts +84 -0
- package/apps/web/.next/types/app/settings/page.ts +84 -0
- package/apps/web/.next/types/cache-life.d.ts +141 -0
- package/apps/web/.next/types/package.json +1 -0
- package/apps/web/.next/types/routes.d.ts +114 -0
- package/apps/web/.next/types/validator.ts +448 -0
- package/apps/web/Dockerfile +37 -0
- package/apps/web/next-env.d.ts +6 -0
- package/apps/web/next.config.js +6 -0
- package/apps/web/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
- package/apps/web/package.json +48 -0
- package/apps/web/postcss.config.js +3 -0
- package/apps/web/public/logo.svg +17 -0
- package/apps/web/src/app/agents/[slug]/page.tsx +2235 -0
- package/apps/web/src/app/agents/new/page.tsx +1161 -0
- package/apps/web/src/app/api/agents/[id]/access/route.ts +76 -0
- package/apps/web/src/app/api/agents/[id]/claude-md/route.ts +111 -0
- package/apps/web/src/app/api/agents/[id]/logs/route.ts +84 -0
- package/apps/web/src/app/api/agents/[id]/manifest/route.ts +32 -0
- package/apps/web/src/app/api/agents/[id]/mcps/route.ts +73 -0
- package/apps/web/src/app/api/agents/[id]/memories/[memId]/route.ts +31 -0
- package/apps/web/src/app/api/agents/[id]/memories/route.ts +56 -0
- package/apps/web/src/app/api/agents/[id]/permissions/route.ts +74 -0
- package/apps/web/src/app/api/agents/[id]/reload/route.ts +33 -0
- package/apps/web/src/app/api/agents/[id]/restrictions/route.ts +85 -0
- package/apps/web/src/app/api/agents/[id]/route.ts +81 -0
- package/apps/web/src/app/api/agents/[id]/skills/[skillId]/route.ts +52 -0
- package/apps/web/src/app/api/agents/[id]/skills/route.ts +80 -0
- package/apps/web/src/app/api/agents/[id]/slack-info/route.ts +38 -0
- package/apps/web/src/app/api/agents/[id]/snapshots/[sid]/restore/route.ts +61 -0
- package/apps/web/src/app/api/agents/[id]/snapshots/[sid]/route.ts +53 -0
- package/apps/web/src/app/api/agents/[id]/snapshots/route.ts +84 -0
- package/apps/web/src/app/api/agents/[id]/start/route.ts +35 -0
- package/apps/web/src/app/api/agents/[id]/stop/route.ts +35 -0
- package/apps/web/src/app/api/agents/route.ts +99 -0
- package/apps/web/src/app/api/auth/login/route.ts +39 -0
- package/apps/web/src/app/api/auth/logout/route.ts +21 -0
- package/apps/web/src/app/api/auth/me/route.ts +24 -0
- package/apps/web/src/app/api/auth/users/[id]/route.ts +48 -0
- package/apps/web/src/app/api/auth/users/route.ts +63 -0
- package/apps/web/src/app/api/env-vars/[key]/route.ts +66 -0
- package/apps/web/src/app/api/env-vars/route.ts +59 -0
- package/apps/web/src/app/api/jobs/[id]/route.ts +51 -0
- package/apps/web/src/app/api/jobs/[id]/runs/route.ts +24 -0
- package/apps/web/src/app/api/jobs/route.ts +42 -0
- package/apps/web/src/app/api/mcps/[id]/route.ts +60 -0
- package/apps/web/src/app/api/mcps/[id]/test/route.ts +195 -0
- package/apps/web/src/app/api/mcps/route.ts +72 -0
- package/apps/web/src/app/api/settings/route.ts +42 -0
- package/apps/web/src/app/globals.css +124 -0
- package/apps/web/src/app/icon.svg +17 -0
- package/apps/web/src/app/jobs/page.tsx +543 -0
- package/apps/web/src/app/layout-shell.tsx +89 -0
- package/apps/web/src/app/layout.tsx +18 -0
- package/apps/web/src/app/login/layout.tsx +9 -0
- package/apps/web/src/app/login/page.tsx +150 -0
- package/apps/web/src/app/page.tsx +573 -0
- package/apps/web/src/app/settings/env-vars/page.tsx +216 -0
- package/apps/web/src/app/settings/mcps/page.tsx +763 -0
- package/apps/web/src/app/settings/page.tsx +528 -0
- package/apps/web/src/app/sidebar.tsx +345 -0
- package/apps/web/src/lib/__tests__/api-guard.test.ts +189 -0
- package/apps/web/src/lib/__tests__/auth.test.ts +262 -0
- package/apps/web/src/lib/__tests__/boss-registry.test.ts +323 -0
- package/apps/web/src/lib/__tests__/compile.test.ts +161 -0
- package/apps/web/src/lib/__tests__/db-agent-hierarchy.test.ts +136 -0
- package/apps/web/src/lib/__tests__/db-env-vars.test.ts +216 -0
- package/apps/web/src/lib/__tests__/db-restrictions.test.ts +117 -0
- package/apps/web/src/lib/__tests__/db.integration.test.ts +271 -0
- package/apps/web/src/lib/__tests__/diff.test.ts +102 -0
- package/apps/web/src/lib/__tests__/mcp-mask.test.ts +274 -0
- package/apps/web/src/lib/__tests__/skill-templates.test.ts +237 -0
- package/apps/web/src/lib/__tests__/slack-manifest.test.ts +105 -0
- package/apps/web/src/lib/api-guard.ts +68 -0
- package/apps/web/src/lib/auth-context.tsx +71 -0
- package/apps/web/src/lib/auth.ts +128 -0
- package/apps/web/src/lib/boss-registry.ts +90 -0
- package/apps/web/src/lib/compile.ts +51 -0
- package/apps/web/src/lib/db.ts +1196 -0
- package/apps/web/src/lib/diff.ts +43 -0
- package/apps/web/src/lib/mcp-mask.ts +91 -0
- package/apps/web/src/lib/portal.tsx +23 -0
- package/apps/web/src/lib/skill-templates.ts +148 -0
- package/apps/web/src/lib/slack-manifest.ts +85 -0
- package/apps/web/src/middleware.ts +68 -0
- package/apps/web/tailwind.config.js +6 -0
- package/apps/web/tsconfig.json +23 -0
- package/apps/web/vitest.config.mts +21 -0
- package/cli/.claude/settings.local.json +6 -0
- package/cli/README.md +281 -0
- package/cli/node_modules/.package-lock.json +427 -0
- package/cli/node_modules/commander/LICENSE +22 -0
- package/cli/node_modules/commander/Readme.md +1157 -0
- package/cli/node_modules/commander/esm.mjs +16 -0
- package/cli/node_modules/commander/index.js +24 -0
- package/cli/node_modules/commander/lib/argument.js +149 -0
- package/cli/node_modules/commander/lib/command.js +2509 -0
- package/cli/node_modules/commander/lib/error.js +39 -0
- package/cli/node_modules/commander/lib/help.js +520 -0
- package/cli/node_modules/commander/lib/option.js +330 -0
- package/cli/node_modules/commander/lib/suggestSimilar.js +101 -0
- package/cli/node_modules/commander/package-support.json +16 -0
- package/cli/node_modules/commander/package.json +84 -0
- package/cli/node_modules/commander/typings/esm.d.mts +3 -0
- package/cli/node_modules/commander/typings/index.d.ts +969 -0
- package/cli/package-lock.json +449 -0
- package/cli/package.json +44 -0
- package/cli/src/commands/init.ts +514 -0
- package/cli/src/commands/manage.ts +115 -0
- package/cli/src/index.ts +63 -0
- package/cli/tsconfig.json +14 -0
- package/docker-compose.yml +122 -0
- package/docs/agents/boss-agents.mdx +108 -0
- package/docs/agents/creating-agents.mdx +132 -0
- package/docs/agents/memory.mdx +113 -0
- package/docs/agents/tools.mdx +103 -0
- package/docs/configuration/env-vars.mdx +166 -0
- package/docs/configuration/mcp-servers.mdx +203 -0
- package/docs/configuration/slack-app.mdx +175 -0
- package/docs/docs.json +79 -0
- package/docs/favicon.svg +17 -0
- package/docs/features/history.mdx +60 -0
- package/docs/features/import-export.mdx +77 -0
- package/docs/features/logs.mdx +131 -0
- package/docs/features/multi-workspace.mdx +90 -0
- package/docs/features/scheduled-jobs.mdx +231 -0
- package/docs/features/users.mdx +92 -0
- package/docs/introduction.mdx +160 -0
- package/docs/logo/dark.svg +17 -0
- package/docs/logo/light.svg +17 -0
- package/docs/logo/wide-dark.svg +12 -0
- package/docs/logo/wide-light.svg +12 -0
- package/docs/quickstart.mdx +270 -0
- package/docs/self-hosting/docker.mdx +151 -0
- package/docs/self-hosting/production.mdx +176 -0
- package/package.json +20 -36
- package/packages/shared/dist/index.d.ts +8 -0
- package/packages/shared/dist/index.d.ts.map +1 -0
- package/packages/shared/dist/index.js +24 -0
- package/packages/shared/dist/index.js.map +1 -0
- package/packages/shared/dist/types.d.ts +584 -0
- package/packages/shared/dist/types.d.ts.map +1 -0
- package/packages/shared/dist/types.js +39 -0
- package/packages/shared/dist/types.js.map +1 -0
- package/packages/shared/package.json +15 -0
- package/packages/shared/src/db/schema.sql +354 -0
- package/packages/shared/src/index.ts +8 -0
- package/packages/shared/src/types.ts +683 -0
- package/packages/shared/tsconfig.json +17 -0
- package/scripts/dev.sh +45 -0
- /package/{dist → cli/dist}/commands/init.d.ts +0 -0
- /package/{dist → cli/dist}/commands/init.js +0 -0
- /package/{dist → cli/dist}/commands/manage.d.ts +0 -0
- /package/{dist → cli/dist}/commands/manage.js +0 -0
- /package/{dist → cli/dist}/index.d.ts +0 -0
- /package/{dist → cli/dist}/index.js +0 -0
|
@@ -0,0 +1,2235 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @fileoverview Agent detail page — tabbed control panel.
|
|
5
|
+
*
|
|
6
|
+
* Tabs: Overview · Skills · MCPs · Permissions · Memory · Logs
|
|
7
|
+
*
|
|
8
|
+
* Route: /agents/[slug]
|
|
9
|
+
* @module web/app/agents/[slug]
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import React, { useEffect, useState, useRef, use } from 'react';
|
|
13
|
+
import { Brain, Camera, Clock, History, Upload, Download } from 'lucide-react';
|
|
14
|
+
import Link from 'next/link';
|
|
15
|
+
import { useRouter } from 'next/navigation';
|
|
16
|
+
import type { Agent, Skill, McpServer, Memory, Permission, Restriction, AgentSnapshot } from '@slackhive/shared';
|
|
17
|
+
import { Portal } from '@/lib/portal';
|
|
18
|
+
import { useAuth } from '@/lib/auth-context';
|
|
19
|
+
import { lineDiff, type DiffLine } from '@/lib/diff';
|
|
20
|
+
|
|
21
|
+
type Tab = 'overview' | 'skills' | 'claude-md' | 'mcps' | 'permissions' | 'memory' | 'logs' | 'history';
|
|
22
|
+
|
|
23
|
+
interface AgentExportPayload {
|
|
24
|
+
version: number;
|
|
25
|
+
exportedAt?: string;
|
|
26
|
+
claudeMd: string;
|
|
27
|
+
skills: { category: string; filename: string; content: string; sortOrder: number }[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const TABS: { id: Tab; label: string }[] = [
|
|
31
|
+
{ id: 'overview', label: 'Overview' },
|
|
32
|
+
{ id: 'skills', label: 'Skills' },
|
|
33
|
+
{ id: 'claude-md', label: 'System Prompt' },
|
|
34
|
+
{ id: 'mcps', label: 'MCPs' },
|
|
35
|
+
{ id: 'permissions', label: 'Tools' },
|
|
36
|
+
{ id: 'memory', label: 'Memory' },
|
|
37
|
+
{ id: 'logs', label: 'Logs' },
|
|
38
|
+
{ id: 'history', label: 'History' },
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
const STATUS_COLOR = { running: '#16a34a', stopped: 'var(--border-2)', error: '#ef4444' } as const;
|
|
42
|
+
|
|
43
|
+
// ─── Page ─────────────────────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Agent detail page — loads the agent by slug then renders the tabbed UI.
|
|
47
|
+
*
|
|
48
|
+
* @param {{ params: Promise<{ slug: string }> }} props
|
|
49
|
+
*/
|
|
50
|
+
export default function AgentPage({ params }: { params: Promise<{ slug: string }> }) {
|
|
51
|
+
const { slug } = use(params);
|
|
52
|
+
const { role, canManageUsers } = useAuth();
|
|
53
|
+
const [agent, setAgent] = useState<Agent | null>(null);
|
|
54
|
+
const [allAgents, setAllAgents] = useState<Agent[]>([]);
|
|
55
|
+
const [canEdit, setCanEdit] = useState(false);
|
|
56
|
+
const [tab, setTab] = useState<Tab>('overview');
|
|
57
|
+
const [loading, setLoading] = useState(true);
|
|
58
|
+
const [actionMsg, setActionMsg] = useState('');
|
|
59
|
+
const [exporting, setExporting] = useState(false);
|
|
60
|
+
const [importPreview, setImportPreview] = useState<AgentExportPayload | null>(null);
|
|
61
|
+
const [importing, setImporting] = useState(false);
|
|
62
|
+
const [importError, setImportError] = useState('');
|
|
63
|
+
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
64
|
+
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
fetch('/api/agents')
|
|
67
|
+
.then(r => r.json())
|
|
68
|
+
.then((agents: Agent[]) => {
|
|
69
|
+
setAllAgents(agents);
|
|
70
|
+
const found = agents.find(a => a.slug === slug) ?? null;
|
|
71
|
+
setAgent(found);
|
|
72
|
+
if (found) {
|
|
73
|
+
if (role === 'admin' || role === 'superadmin') {
|
|
74
|
+
setCanEdit(true);
|
|
75
|
+
} else if (role === 'editor' || role === 'viewer') {
|
|
76
|
+
fetch(`/api/agents/${found.id}/access`)
|
|
77
|
+
.then(r => r.json())
|
|
78
|
+
.then(data => setCanEdit(role === 'editor' && (data.canWrite ?? false)));
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
})
|
|
82
|
+
.finally(() => setLoading(false));
|
|
83
|
+
}, [slug, role]);
|
|
84
|
+
|
|
85
|
+
const triggerAction = async (action: 'start' | 'stop' | 'reload') => {
|
|
86
|
+
if (!agent) return;
|
|
87
|
+
setActionMsg(action === 'start' ? 'Starting…' : action === 'stop' ? 'Stopping…' : 'Reloading…');
|
|
88
|
+
await fetch(`/api/agents/${agent.id}/${action}`, { method: 'POST' });
|
|
89
|
+
const r = await fetch(`/api/agents/${agent.id}`);
|
|
90
|
+
setAgent(await r.json());
|
|
91
|
+
setActionMsg('Done');
|
|
92
|
+
setTimeout(() => setActionMsg(''), 2000);
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const handleExport = async () => {
|
|
96
|
+
if (!agent) return;
|
|
97
|
+
setExporting(true);
|
|
98
|
+
try {
|
|
99
|
+
const [skillsRes, mdRes] = await Promise.all([
|
|
100
|
+
fetch(`/api/agents/${agent.id}/skills`),
|
|
101
|
+
fetch(`/api/agents/${agent.id}/claude-md`),
|
|
102
|
+
]);
|
|
103
|
+
const skills: Skill[] = await skillsRes.json();
|
|
104
|
+
const claudeMd = await mdRes.text();
|
|
105
|
+
const payload: AgentExportPayload = {
|
|
106
|
+
version: 1,
|
|
107
|
+
exportedAt: new Date().toISOString(),
|
|
108
|
+
claudeMd,
|
|
109
|
+
skills: skills.map(s => ({ category: s.category, filename: s.filename, content: s.content, sortOrder: s.sortOrder })),
|
|
110
|
+
};
|
|
111
|
+
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
|
|
112
|
+
const url = URL.createObjectURL(blob);
|
|
113
|
+
const a = document.createElement('a');
|
|
114
|
+
a.href = url; a.download = `${agent.slug}-export.json`; a.click();
|
|
115
|
+
URL.revokeObjectURL(url);
|
|
116
|
+
} finally { setExporting(false); }
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const handleImportFile = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
120
|
+
const file = e.target.files?.[0];
|
|
121
|
+
if (!file) return;
|
|
122
|
+
e.target.value = '';
|
|
123
|
+
setImportError('');
|
|
124
|
+
const reader = new FileReader();
|
|
125
|
+
reader.onload = (ev) => {
|
|
126
|
+
try {
|
|
127
|
+
const data = JSON.parse(ev.target?.result as string);
|
|
128
|
+
if (typeof data.claudeMd !== 'string' || !Array.isArray(data.skills)) {
|
|
129
|
+
setImportError('Invalid export file'); return;
|
|
130
|
+
}
|
|
131
|
+
setImportPreview(data);
|
|
132
|
+
} catch { setImportError('Could not parse file'); }
|
|
133
|
+
};
|
|
134
|
+
reader.readAsText(file);
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const applyImport = async () => {
|
|
138
|
+
if (!agent || !importPreview) return;
|
|
139
|
+
setImporting(true);
|
|
140
|
+
try {
|
|
141
|
+
await fetch(`/api/agents/${agent.id}/claude-md`, {
|
|
142
|
+
method: 'PUT', headers: { 'Content-Type': 'text/plain' },
|
|
143
|
+
body: importPreview.claudeMd,
|
|
144
|
+
});
|
|
145
|
+
await Promise.all(importPreview.skills.map(s =>
|
|
146
|
+
fetch(`/api/agents/${agent.id}/skills?noSnapshot=1`, {
|
|
147
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
148
|
+
body: JSON.stringify(s),
|
|
149
|
+
})
|
|
150
|
+
));
|
|
151
|
+
const updated = await fetch(`/api/agents/${agent.id}`).then(r => r.json());
|
|
152
|
+
setAgent(updated);
|
|
153
|
+
setImportPreview(null);
|
|
154
|
+
} finally { setImporting(false); }
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
if (loading) return <PageLoader />;
|
|
158
|
+
if (!agent) return <NotFound slug={slug} />;
|
|
159
|
+
|
|
160
|
+
const statusColor = STATUS_COLOR[agent.status] ?? 'var(--border-2)';
|
|
161
|
+
|
|
162
|
+
return (
|
|
163
|
+
<div style={{ minHeight: '100vh' }} className="fade-up">
|
|
164
|
+
|
|
165
|
+
{/* ── Top bar ──────────────────────────────────────────────────────── */}
|
|
166
|
+
<div style={{
|
|
167
|
+
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
|
168
|
+
padding: '28px 40px 0',
|
|
169
|
+
borderBottom: '1px solid var(--border)',
|
|
170
|
+
paddingBottom: 0,
|
|
171
|
+
flexWrap: 'wrap', gap: 12,
|
|
172
|
+
}}>
|
|
173
|
+
<div>
|
|
174
|
+
{/* Breadcrumb */}
|
|
175
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 10, fontSize: 12, color: 'var(--muted)' }}>
|
|
176
|
+
<Link href="/" style={{ color: 'var(--muted)', textDecoration: 'none' }}>Agents</Link>
|
|
177
|
+
<span style={{ color: 'var(--subtle)' }}>/</span>
|
|
178
|
+
<span style={{ color: 'var(--text)' }}>{agent.name}</span>
|
|
179
|
+
</div>
|
|
180
|
+
|
|
181
|
+
{/* Agent name + status */}
|
|
182
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 16 }}>
|
|
183
|
+
<div style={{
|
|
184
|
+
width: 36, height: 36, borderRadius: 10, flexShrink: 0,
|
|
185
|
+
background: agent.isBoss
|
|
186
|
+
? 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)'
|
|
187
|
+
: 'linear-gradient(135deg, #3b82f6 0%, #6366f1 100%)',
|
|
188
|
+
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
189
|
+
fontSize: 14, fontWeight: 700, color: '#fff',
|
|
190
|
+
}}>
|
|
191
|
+
{agent.name.charAt(0)}
|
|
192
|
+
</div>
|
|
193
|
+
<div>
|
|
194
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
195
|
+
<h1 style={{ margin: 0, fontSize: 18, fontWeight: 600, letterSpacing: '-0.02em', color: 'var(--text)' }}>
|
|
196
|
+
{agent.name}
|
|
197
|
+
</h1>
|
|
198
|
+
{agent.isBoss && (
|
|
199
|
+
<span style={{
|
|
200
|
+
fontSize: 10, fontWeight: 600, letterSpacing: '0.06em',
|
|
201
|
+
background: 'rgba(245,158,11,0.15)', color: '#f59e0b',
|
|
202
|
+
padding: '2px 7px', borderRadius: 5,
|
|
203
|
+
border: '1px solid rgba(245,158,11,0.25)',
|
|
204
|
+
textTransform: 'uppercase',
|
|
205
|
+
}}>Boss</span>
|
|
206
|
+
)}
|
|
207
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
|
208
|
+
<div
|
|
209
|
+
className={agent.status === 'running' ? 'status-running' : ''}
|
|
210
|
+
style={{ width: 7, height: 7, borderRadius: '50%', background: statusColor }}
|
|
211
|
+
/>
|
|
212
|
+
<span style={{ fontSize: 12, color: statusColor, fontWeight: 500, textTransform: 'capitalize' }}>
|
|
213
|
+
{agent.status}
|
|
214
|
+
</span>
|
|
215
|
+
</div>
|
|
216
|
+
</div>
|
|
217
|
+
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 11.5, color: 'var(--muted)', marginTop: 1 }}>
|
|
218
|
+
@{agent.slug} · {agent.model.replace('claude-', '').split('-20')[0]}
|
|
219
|
+
</div>
|
|
220
|
+
</div>
|
|
221
|
+
</div>
|
|
222
|
+
</div>
|
|
223
|
+
|
|
224
|
+
{/* Controls */}
|
|
225
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 8, paddingBottom: 16 }}>
|
|
226
|
+
{actionMsg && <span style={{ fontSize: 12, color: 'var(--muted)' }}>{actionMsg}</span>}
|
|
227
|
+
{importError && <span style={{ fontSize: 12, color: 'var(--danger)' }}>{importError}</span>}
|
|
228
|
+
|
|
229
|
+
{/* Export / Import icon buttons */}
|
|
230
|
+
<IconBtn title="Export config" onClick={handleExport} loading={exporting}>
|
|
231
|
+
<Download size={15} />
|
|
232
|
+
</IconBtn>
|
|
233
|
+
{canEdit && (
|
|
234
|
+
<IconBtn title="Import config" onClick={() => fileInputRef.current?.click()}>
|
|
235
|
+
<Upload size={15} />
|
|
236
|
+
</IconBtn>
|
|
237
|
+
)}
|
|
238
|
+
<input ref={fileInputRef} type="file" accept=".json" style={{ display: 'none' }} onChange={handleImportFile} />
|
|
239
|
+
|
|
240
|
+
<div style={{ width: 1, height: 20, background: 'var(--border)', margin: '0 2px' }} />
|
|
241
|
+
|
|
242
|
+
{canEdit && agent.status !== 'running' && (
|
|
243
|
+
<Btn color="#22c55e" onClick={() => triggerAction('start')}>Start</Btn>
|
|
244
|
+
)}
|
|
245
|
+
{canEdit && agent.status === 'running' && (
|
|
246
|
+
<Btn color="var(--border-2)" textColor="var(--muted)" onClick={() => triggerAction('reload')}>Reload</Btn>
|
|
247
|
+
)}
|
|
248
|
+
{canEdit && agent.status === 'running' && (
|
|
249
|
+
<Btn color="#ef4444" onClick={() => triggerAction('stop')}>Stop</Btn>
|
|
250
|
+
)}
|
|
251
|
+
</div>
|
|
252
|
+
</div>
|
|
253
|
+
|
|
254
|
+
{/* Import confirmation modal */}
|
|
255
|
+
{importPreview && (
|
|
256
|
+
<Portal>
|
|
257
|
+
<div style={{
|
|
258
|
+
position: 'fixed', inset: 0, zIndex: 1000,
|
|
259
|
+
background: 'rgba(0,0,0,0.45)', backdropFilter: 'blur(4px)',
|
|
260
|
+
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
261
|
+
}} onClick={() => setImportPreview(null)}>
|
|
262
|
+
<div style={{
|
|
263
|
+
background: '#fff', borderRadius: 14, padding: '28px 32px',
|
|
264
|
+
maxWidth: 480, width: '90%',
|
|
265
|
+
boxShadow: '0 20px 60px rgba(0,0,0,0.15)',
|
|
266
|
+
}} onClick={e => e.stopPropagation()}>
|
|
267
|
+
<h3 style={{ margin: '0 0 16px', fontSize: 16, fontWeight: 700, color: 'var(--text)', letterSpacing: '-0.02em' }}>
|
|
268
|
+
Import agent config
|
|
269
|
+
</h3>
|
|
270
|
+
|
|
271
|
+
{/* Danger warning — shown first */}
|
|
272
|
+
<div style={{
|
|
273
|
+
display: 'flex', gap: 10, padding: '12px 14px', marginBottom: 16,
|
|
274
|
+
background: '#fff1f2', border: '1.5px solid #fecdd3', borderRadius: 8,
|
|
275
|
+
}}>
|
|
276
|
+
<span style={{ fontSize: 16, flexShrink: 0 }}>⚠️</span>
|
|
277
|
+
<div>
|
|
278
|
+
<div style={{ fontSize: 13, fontWeight: 600, color: '#be123c', marginBottom: 2 }}>
|
|
279
|
+
This will overwrite current CLAUDE.md and skills
|
|
280
|
+
</div>
|
|
281
|
+
<div style={{ fontSize: 12, color: '#9f1239' }}>
|
|
282
|
+
Existing CLAUDE.md will be replaced. Skills with matching category/filename will be overwritten. A snapshot is saved automatically before applying.
|
|
283
|
+
</div>
|
|
284
|
+
</div>
|
|
285
|
+
</div>
|
|
286
|
+
|
|
287
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 20 }}>
|
|
288
|
+
{importPreview.exportedAt && <InfoRow label="Exported at" value={new Date(importPreview.exportedAt).toLocaleString()} />}
|
|
289
|
+
<InfoRow label="Skills" value={`${importPreview.skills.length} skill${importPreview.skills.length !== 1 ? 's' : ''} will be upserted`} />
|
|
290
|
+
</div>
|
|
291
|
+
<div style={{ display: 'flex', gap: 10 }}>
|
|
292
|
+
<PrimaryBtn onClick={applyImport} loading={importing}>Apply Import</PrimaryBtn>
|
|
293
|
+
<GhostBtn onClick={() => setImportPreview(null)}>Cancel</GhostBtn>
|
|
294
|
+
</div>
|
|
295
|
+
</div>
|
|
296
|
+
</div>
|
|
297
|
+
</Portal>
|
|
298
|
+
)}
|
|
299
|
+
|
|
300
|
+
{/* ── Tab bar ──────────────────────────────────────────────────────── */}
|
|
301
|
+
<div style={{
|
|
302
|
+
display: 'flex', gap: 0, padding: '0 36px',
|
|
303
|
+
borderBottom: '1px solid var(--border)',
|
|
304
|
+
background: 'var(--surface)',
|
|
305
|
+
overflowX: 'auto', WebkitOverflowScrolling: 'touch',
|
|
306
|
+
}}>
|
|
307
|
+
{TABS.map(t => (
|
|
308
|
+
<button
|
|
309
|
+
key={t.id}
|
|
310
|
+
onClick={() => setTab(t.id)}
|
|
311
|
+
className={tab === t.id ? 'tab-active' : ''}
|
|
312
|
+
style={{
|
|
313
|
+
background: 'none', border: 'none', cursor: 'pointer',
|
|
314
|
+
padding: '10px 14px', fontSize: 13,
|
|
315
|
+
color: tab === t.id ? 'var(--text)' : 'var(--muted)',
|
|
316
|
+
fontWeight: tab === t.id ? 500 : 400,
|
|
317
|
+
transition: 'color 0.15s',
|
|
318
|
+
fontFamily: 'var(--font-sans)',
|
|
319
|
+
}}
|
|
320
|
+
>
|
|
321
|
+
{t.label}
|
|
322
|
+
</button>
|
|
323
|
+
))}
|
|
324
|
+
</div>
|
|
325
|
+
|
|
326
|
+
{/* ── Tab content ──────────────────────────────────────────────────── */}
|
|
327
|
+
<div style={{ padding: '28px 36px' }}>
|
|
328
|
+
{tab === 'overview' && <OverviewTab agent={agent} onUpdate={setAgent} canEdit={canEdit} allAgents={allAgents} role={role} />}
|
|
329
|
+
{tab === 'skills' && <SkillsTab agentId={agent.id} canEdit={canEdit} />}
|
|
330
|
+
{tab === 'claude-md' && <ClaudeMdTab agentId={agent.id} canEdit={canEdit} />}
|
|
331
|
+
{tab === 'mcps' && <McpsTab agentId={agent.id} canEdit={canEdit} />}
|
|
332
|
+
{tab === 'permissions' && <PermissionsTab agentId={agent.id} canEdit={canEdit} />}
|
|
333
|
+
{tab === 'memory' && <MemoryTab agentId={agent.id} canEdit={canEdit} />}
|
|
334
|
+
{tab === 'logs' && <LogsTab agentId={agent.id} slug={agent.slug} />}
|
|
335
|
+
{tab === 'history' && <HistoryTab agentId={agent.id} canEdit={canEdit} />}
|
|
336
|
+
</div>
|
|
337
|
+
</div>
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// ─── Overview ─────────────────────────────────────────────────────────────────
|
|
342
|
+
|
|
343
|
+
function OverviewTab({ agent, onUpdate, canEdit, allAgents, role }: { agent: Agent; onUpdate: (a: Agent) => void; canEdit: boolean; allAgents: Agent[]; role: string | null }) {
|
|
344
|
+
const [form, setForm] = useState({
|
|
345
|
+
name: agent.name,
|
|
346
|
+
description: agent.description ?? '',
|
|
347
|
+
persona: agent.persona ?? '',
|
|
348
|
+
model: agent.model,
|
|
349
|
+
slackBotToken: agent.slackBotToken,
|
|
350
|
+
slackAppToken: agent.slackAppToken,
|
|
351
|
+
slackSigningSecret: agent.slackSigningSecret,
|
|
352
|
+
isBoss: agent.isBoss,
|
|
353
|
+
reportsTo: agent.reportsTo ?? [] as string[],
|
|
354
|
+
});
|
|
355
|
+
const [saving, setSaving] = useState(false);
|
|
356
|
+
const [msg, setMsg] = useState('');
|
|
357
|
+
const [manifest, setManifest] = useState('');
|
|
358
|
+
const [showManifest, setShowManifest] = useState(false);
|
|
359
|
+
const [deleting, setDeleting] = useState(false);
|
|
360
|
+
const [slackInfo, setSlackInfo] = useState<{ displayName: string; handle: string; teamName: string } | null>(null);
|
|
361
|
+
const router = useRouter();
|
|
362
|
+
|
|
363
|
+
useEffect(() => {
|
|
364
|
+
if (!agent.slackBotToken) return;
|
|
365
|
+
fetch(`/api/agents/${agent.id}/slack-info`)
|
|
366
|
+
.then(r => r.ok ? r.json() : null)
|
|
367
|
+
.then(d => d && setSlackInfo(d))
|
|
368
|
+
.catch(() => {});
|
|
369
|
+
}, [agent.id, agent.slackBotToken]);
|
|
370
|
+
|
|
371
|
+
// Channel restrictions state
|
|
372
|
+
const [allowedChannels, setAllowedChannels] = useState('');
|
|
373
|
+
|
|
374
|
+
useEffect(() => {
|
|
375
|
+
fetch(`/api/agents/${agent.id}/restrictions`)
|
|
376
|
+
.then(r => r.json())
|
|
377
|
+
.then((d: Restriction) => setAllowedChannels((d.allowedChannels ?? []).join('\n')));
|
|
378
|
+
}, [agent.id]);
|
|
379
|
+
|
|
380
|
+
const save = async () => {
|
|
381
|
+
setSaving(true);
|
|
382
|
+
try {
|
|
383
|
+
const [r] = await Promise.all([
|
|
384
|
+
fetch(`/api/agents/${agent.id}`, {
|
|
385
|
+
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
|
|
386
|
+
body: JSON.stringify(form),
|
|
387
|
+
}),
|
|
388
|
+
fetch(`/api/agents/${agent.id}/restrictions`, {
|
|
389
|
+
method: 'PUT', headers: { 'Content-Type': 'application/json' },
|
|
390
|
+
body: JSON.stringify({ allowedChannels: allowedChannels.split('\n').map(s => s.trim()).filter(Boolean) }),
|
|
391
|
+
}),
|
|
392
|
+
]);
|
|
393
|
+
const data = await r.json();
|
|
394
|
+
if (r.ok) { onUpdate(data); setMsg('Saved'); } else setMsg(data.error ?? 'Error');
|
|
395
|
+
} finally { setSaving(false); setTimeout(() => setMsg(''), 3000); }
|
|
396
|
+
};
|
|
397
|
+
|
|
398
|
+
const loadManifest = async () => {
|
|
399
|
+
const r = await fetch(`/api/agents/${agent.id}/manifest`);
|
|
400
|
+
setManifest(JSON.stringify(await r.json(), null, 2));
|
|
401
|
+
setShowManifest(true);
|
|
402
|
+
};
|
|
403
|
+
|
|
404
|
+
const handleDelete = async () => {
|
|
405
|
+
if (!confirm(`Permanently delete agent "${agent.name}"? This cannot be undone.`)) return;
|
|
406
|
+
setDeleting(true);
|
|
407
|
+
const r = await fetch(`/api/agents/${agent.id}`, { method: 'DELETE' });
|
|
408
|
+
if (r.ok) {
|
|
409
|
+
router.push('/');
|
|
410
|
+
} else {
|
|
411
|
+
const err = await r.json();
|
|
412
|
+
setMsg(err.error ?? 'Delete failed');
|
|
413
|
+
setDeleting(false);
|
|
414
|
+
}
|
|
415
|
+
};
|
|
416
|
+
|
|
417
|
+
const isAdmin = role === 'admin' || role === 'superadmin';
|
|
418
|
+
|
|
419
|
+
return (
|
|
420
|
+
<div style={{ maxWidth: 640 }} className="fade-up">
|
|
421
|
+
<Section title="Identity">
|
|
422
|
+
<Grid2>
|
|
423
|
+
<Field label="Name" value={form.name} onChange={v => setForm(f => ({ ...f, name: v }))} readOnly={!canEdit}
|
|
424
|
+
hint="This is the internal agent name. To update the Slack bot display name, change it in your Slack App settings → App Home." />
|
|
425
|
+
<Field label="Model" value={form.model} onChange={v => setForm(f => ({ ...f, model: v }))}
|
|
426
|
+
hint="claude-opus-4-6 · claude-sonnet-4-6 · claude-haiku-4-5-20251001" readOnly={!canEdit} />
|
|
427
|
+
</Grid2>
|
|
428
|
+
<Field label="Description" value={form.description}
|
|
429
|
+
onChange={v => setForm(f => ({ ...f, description: v }))}
|
|
430
|
+
hint="Shown to the boss agent for delegation decisions." readOnly={!canEdit} />
|
|
431
|
+
<TextArea label="Persona" value={form.persona}
|
|
432
|
+
onChange={v => setForm(f => ({ ...f, persona: v }))}
|
|
433
|
+
hint="Injected into CLAUDE.md — who is this agent?" rows={4} readOnly={!canEdit} />
|
|
434
|
+
</Section>
|
|
435
|
+
|
|
436
|
+
<Section title="Role & Hierarchy">
|
|
437
|
+
{/* Boss toggle */}
|
|
438
|
+
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
|
|
439
|
+
<div>
|
|
440
|
+
<div style={{ fontSize: 13, fontWeight: 500, color: 'var(--text)', marginBottom: 2 }}>Boss Agent</div>
|
|
441
|
+
<div style={{ fontSize: 12, color: 'var(--muted)' }}>Boss agents orchestrate other agents and delegate tasks</div>
|
|
442
|
+
</div>
|
|
443
|
+
<button
|
|
444
|
+
disabled={!canEdit}
|
|
445
|
+
onClick={() => setForm(f => ({ ...f, isBoss: !f.isBoss }))}
|
|
446
|
+
style={{
|
|
447
|
+
width: 44, height: 24, borderRadius: 12, border: 'none',
|
|
448
|
+
background: form.isBoss ? '#d97706' : 'var(--border-2)',
|
|
449
|
+
cursor: canEdit ? 'pointer' : 'default',
|
|
450
|
+
position: 'relative', transition: 'background 0.2s', flexShrink: 0,
|
|
451
|
+
}}
|
|
452
|
+
>
|
|
453
|
+
<div style={{
|
|
454
|
+
position: 'absolute', top: 3, left: form.isBoss ? 23 : 3,
|
|
455
|
+
width: 18, height: 18, borderRadius: '50%', background: '#fff',
|
|
456
|
+
transition: 'left 0.2s', boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
|
|
457
|
+
}} />
|
|
458
|
+
</button>
|
|
459
|
+
</div>
|
|
460
|
+
|
|
461
|
+
{/* Reports To — only show for non-boss agents */}
|
|
462
|
+
{!form.isBoss && (() => {
|
|
463
|
+
const bosses = allAgents.filter(a => a.isBoss && a.id !== agent.id);
|
|
464
|
+
if (bosses.length === 0) return (
|
|
465
|
+
<div style={{ fontSize: 12, color: 'var(--subtle)', fontStyle: 'italic' }}>
|
|
466
|
+
No boss agents available. Create a boss agent first.
|
|
467
|
+
</div>
|
|
468
|
+
);
|
|
469
|
+
return (
|
|
470
|
+
<div>
|
|
471
|
+
<div style={{ fontSize: 12, fontWeight: 500, color: 'var(--text)', marginBottom: 6 }}>Reports To</div>
|
|
472
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
|
473
|
+
{bosses.map(boss => {
|
|
474
|
+
const checked = form.reportsTo.includes(boss.id);
|
|
475
|
+
return (
|
|
476
|
+
<label key={boss.id} style={{
|
|
477
|
+
display: 'flex', alignItems: 'center', gap: 10,
|
|
478
|
+
padding: '8px 12px', borderRadius: 8,
|
|
479
|
+
border: `1px solid ${checked ? 'rgba(217,119,6,0.3)' : 'var(--border)'}`,
|
|
480
|
+
background: checked ? 'rgba(217,119,6,0.04)' : 'var(--surface)',
|
|
481
|
+
cursor: canEdit ? 'pointer' : 'default',
|
|
482
|
+
transition: 'all 0.15s',
|
|
483
|
+
}}>
|
|
484
|
+
<input
|
|
485
|
+
type="checkbox"
|
|
486
|
+
checked={checked}
|
|
487
|
+
disabled={!canEdit}
|
|
488
|
+
onChange={() => setForm(f => ({
|
|
489
|
+
...f,
|
|
490
|
+
reportsTo: checked
|
|
491
|
+
? f.reportsTo.filter(id => id !== boss.id)
|
|
492
|
+
: [...f.reportsTo, boss.id],
|
|
493
|
+
}))}
|
|
494
|
+
style={{ accentColor: '#d97706', width: 14, height: 14 }}
|
|
495
|
+
/>
|
|
496
|
+
<div style={{
|
|
497
|
+
width: 24, height: 24, borderRadius: 6, background: '#171717',
|
|
498
|
+
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
499
|
+
fontSize: 11, fontWeight: 600, color: '#fff', flexShrink: 0,
|
|
500
|
+
}}>
|
|
501
|
+
{boss.name.charAt(0).toUpperCase()}
|
|
502
|
+
</div>
|
|
503
|
+
<div style={{ minWidth: 0 }}>
|
|
504
|
+
<div style={{ fontSize: 13, fontWeight: 500, color: 'var(--text)' }}>{boss.name}</div>
|
|
505
|
+
<div style={{ fontSize: 11, color: 'var(--muted)', fontFamily: 'var(--font-mono)' }}>@{boss.slug}</div>
|
|
506
|
+
</div>
|
|
507
|
+
{checked && (
|
|
508
|
+
<span style={{
|
|
509
|
+
marginLeft: 'auto', fontSize: 10, fontWeight: 600,
|
|
510
|
+
color: '#d97706', letterSpacing: '0.04em', textTransform: 'uppercase',
|
|
511
|
+
}}>Reports to</span>
|
|
512
|
+
)}
|
|
513
|
+
</label>
|
|
514
|
+
);
|
|
515
|
+
})}
|
|
516
|
+
</div>
|
|
517
|
+
<div style={{ fontSize: 11.5, color: 'var(--subtle)', marginTop: 8 }}>
|
|
518
|
+
An agent can report to multiple bosses.
|
|
519
|
+
</div>
|
|
520
|
+
</div>
|
|
521
|
+
);
|
|
522
|
+
})()}
|
|
523
|
+
</Section>
|
|
524
|
+
|
|
525
|
+
<Section title="Slack Credentials">
|
|
526
|
+
<Field label="Bot Token" value={form.slackBotToken}
|
|
527
|
+
onChange={v => setForm(f => ({ ...f, slackBotToken: v }))} type="password" readOnly={!canEdit}
|
|
528
|
+
hint={<>api.slack.com/apps → your app → <strong>OAuth & Permissions</strong> → Bot User OAuth Token</>} />
|
|
529
|
+
<Field label="App-Level Token" value={form.slackAppToken}
|
|
530
|
+
onChange={v => setForm(f => ({ ...f, slackAppToken: v }))} type="password" readOnly={!canEdit}
|
|
531
|
+
hint={<>Basic Information → <strong>App-Level Tokens</strong> → Generate with scope <code style={{ fontFamily: 'var(--font-mono)', fontSize: 11 }}>connections:write</code></>} />
|
|
532
|
+
<Field label="Signing Secret" value={form.slackSigningSecret}
|
|
533
|
+
onChange={v => setForm(f => ({ ...f, slackSigningSecret: v }))} type="password" readOnly={!canEdit}
|
|
534
|
+
hint="Basic Information → App Credentials → Signing Secret" />
|
|
535
|
+
{slackInfo && (
|
|
536
|
+
<div style={{
|
|
537
|
+
background: '#f0fdf4', border: '1px solid #bbf7d0',
|
|
538
|
+
borderRadius: 7, padding: '10px 14px', fontSize: 12,
|
|
539
|
+
}}>
|
|
540
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 8 }}>
|
|
541
|
+
<div style={{ width: 7, height: 7, borderRadius: '50%', background: '#16a34a', flexShrink: 0 }} />
|
|
542
|
+
<span style={{ color: '#15803d', fontWeight: 600 }}>Connected to Slack</span>
|
|
543
|
+
<span style={{ color: '#86efac', marginLeft: 'auto', fontSize: 11 }}>{slackInfo.teamName}</span>
|
|
544
|
+
</div>
|
|
545
|
+
<div style={{ display: 'grid', gridTemplateColumns: 'max-content 1fr', gap: '4px 16px' }}>
|
|
546
|
+
<span style={{ color: '#6b7280' }}>Display name</span>
|
|
547
|
+
<span style={{ color: '#166534', fontWeight: 500 }}>{slackInfo.displayName}</span>
|
|
548
|
+
<span style={{ color: '#6b7280' }}>@handle</span>
|
|
549
|
+
<span style={{ color: '#166534', fontFamily: 'var(--font-mono)' }}>@{slackInfo.handle}</span>
|
|
550
|
+
{agent.slackBotUserId && <>
|
|
551
|
+
<span style={{ color: '#6b7280' }}>Bot User ID</span>
|
|
552
|
+
<span style={{ color: '#166534', fontFamily: 'var(--font-mono)' }}>{agent.slackBotUserId}</span>
|
|
553
|
+
</>}
|
|
554
|
+
</div>
|
|
555
|
+
</div>
|
|
556
|
+
)}
|
|
557
|
+
{!slackInfo && agent.slackBotUserId && (
|
|
558
|
+
<div style={{
|
|
559
|
+
display: 'flex', alignItems: 'center', gap: 8,
|
|
560
|
+
background: '#f0fdf4', border: '1px solid #bbf7d0',
|
|
561
|
+
borderRadius: 7, padding: '8px 12px', fontSize: 12,
|
|
562
|
+
}}>
|
|
563
|
+
<div style={{ width: 7, height: 7, borderRadius: '50%', background: '#16a34a', flexShrink: 0 }} />
|
|
564
|
+
<span style={{ color: '#15803d' }}>Connected ·</span>
|
|
565
|
+
<span style={{ color: '#166534', fontFamily: 'var(--font-mono)' }}>Bot User ID: {agent.slackBotUserId}</span>
|
|
566
|
+
</div>
|
|
567
|
+
)}
|
|
568
|
+
</Section>
|
|
569
|
+
|
|
570
|
+
<Section title="Allowed Channels">
|
|
571
|
+
<p style={{ margin: '0 0 10px', fontSize: 12.5, color: 'var(--muted)', lineHeight: 1.6 }}>
|
|
572
|
+
Restrict this bot to specific Slack channels. Enter one Slack channel ID per line (e.g. <code style={{ fontFamily: 'var(--font-mono)', fontSize: 11 }}>C01234ABCDE</code>).
|
|
573
|
+
If empty, the bot responds in all channels it's invited to.
|
|
574
|
+
When invited to a non-allowed channel, it will post a notice and leave automatically.
|
|
575
|
+
Bot-initiated messages from scheduled jobs are not affected.
|
|
576
|
+
</p>
|
|
577
|
+
<textarea
|
|
578
|
+
value={allowedChannels}
|
|
579
|
+
onChange={e => setAllowedChannels(e.target.value)}
|
|
580
|
+
rows={4}
|
|
581
|
+
readOnly={!canEdit}
|
|
582
|
+
placeholder={'C01234ABCDE\nC09876ZYXWV'}
|
|
583
|
+
style={{
|
|
584
|
+
width: '100%', background: 'var(--surface)', border: '1px solid var(--border)',
|
|
585
|
+
borderRadius: 8, padding: '10px 12px', color: 'var(--text)',
|
|
586
|
+
fontFamily: 'var(--font-mono)', fontSize: 12, lineHeight: 1.7,
|
|
587
|
+
outline: 'none', resize: 'vertical', boxSizing: 'border-box',
|
|
588
|
+
}}
|
|
589
|
+
onFocus={e => (e.currentTarget.style.borderColor = 'var(--accent)')}
|
|
590
|
+
onBlur={e => (e.currentTarget.style.borderColor = 'var(--border)')}
|
|
591
|
+
/>
|
|
592
|
+
</Section>
|
|
593
|
+
|
|
594
|
+
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
|
|
595
|
+
{canEdit && <PrimaryBtn onClick={save} loading={saving}>Save Changes</PrimaryBtn>}
|
|
596
|
+
<GhostBtn onClick={loadManifest}>View Slack Manifest</GhostBtn>
|
|
597
|
+
{msg && <span style={{ fontSize: 12, color: '#16a34a' }}>{msg}</span>}
|
|
598
|
+
</div>
|
|
599
|
+
|
|
600
|
+
{showManifest && (
|
|
601
|
+
<div style={{
|
|
602
|
+
marginTop: 20, background: 'var(--surface-2)',
|
|
603
|
+
border: '1px solid var(--border)', borderRadius: 10, overflow: 'hidden',
|
|
604
|
+
}}>
|
|
605
|
+
<div style={{
|
|
606
|
+
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
|
607
|
+
padding: '10px 16px', borderBottom: '1px solid var(--border)',
|
|
608
|
+
background: 'var(--surface-2)',
|
|
609
|
+
}}>
|
|
610
|
+
<span style={{ fontSize: 11.5, color: 'var(--muted)', fontFamily: 'var(--font-mono)' }}>
|
|
611
|
+
slack-manifest.json
|
|
612
|
+
</span>
|
|
613
|
+
<button
|
|
614
|
+
onClick={() => navigator.clipboard.writeText(manifest)}
|
|
615
|
+
style={{ fontSize: 11.5, color: 'var(--accent)', background: 'none', border: 'none', cursor: 'pointer', fontFamily: 'var(--font-sans)' }}
|
|
616
|
+
>Copy</button>
|
|
617
|
+
</div>
|
|
618
|
+
<pre style={{
|
|
619
|
+
margin: 0, padding: '16px', fontSize: 11.5, color: 'var(--accent)',
|
|
620
|
+
fontFamily: 'var(--font-mono)', overflow: 'auto', maxHeight: 320,
|
|
621
|
+
}}>{manifest}</pre>
|
|
622
|
+
</div>
|
|
623
|
+
)}
|
|
624
|
+
|
|
625
|
+
{/* ── Danger Zone ── */}
|
|
626
|
+
{isAdmin && (
|
|
627
|
+
<div style={{
|
|
628
|
+
marginTop: 40, borderTop: '1px solid #fecaca', paddingTop: 28,
|
|
629
|
+
}}>
|
|
630
|
+
<div style={{ fontSize: 11, fontWeight: 700, color: '#dc2626', letterSpacing: '0.08em', textTransform: 'uppercase', marginBottom: 16 }}>
|
|
631
|
+
Danger Zone
|
|
632
|
+
</div>
|
|
633
|
+
<div style={{
|
|
634
|
+
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
|
635
|
+
background: '#fff8f8', border: '1px solid #fecaca', borderRadius: 8, padding: '14px 18px',
|
|
636
|
+
}}>
|
|
637
|
+
<div>
|
|
638
|
+
<div style={{ fontSize: 13, fontWeight: 500, color: 'var(--text)', marginBottom: 3 }}>Delete this agent</div>
|
|
639
|
+
<div style={{ fontSize: 12, color: 'var(--muted)' }}>Permanently removes the agent, all its skills, memories, and history. This cannot be undone.</div>
|
|
640
|
+
</div>
|
|
641
|
+
<button
|
|
642
|
+
onClick={handleDelete}
|
|
643
|
+
disabled={deleting}
|
|
644
|
+
style={{
|
|
645
|
+
flexShrink: 0, marginLeft: 24,
|
|
646
|
+
padding: '8px 18px', borderRadius: 7, border: '1px solid #dc2626',
|
|
647
|
+
background: deleting ? '#fef2f2' : '#fff', color: '#dc2626',
|
|
648
|
+
fontSize: 13, fontWeight: 600, cursor: deleting ? 'not-allowed' : 'pointer',
|
|
649
|
+
fontFamily: 'var(--font-sans)', whiteSpace: 'nowrap',
|
|
650
|
+
}}
|
|
651
|
+
>{deleting ? 'Deleting…' : 'Delete Agent'}</button>
|
|
652
|
+
</div>
|
|
653
|
+
</div>
|
|
654
|
+
)}
|
|
655
|
+
</div>
|
|
656
|
+
);
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
// ─── CLAUDE.md viewer ─────────────────────────────────────────────────────────
|
|
660
|
+
|
|
661
|
+
function ClaudeMdTab({ agentId, canEdit }: { agentId: string; canEdit: boolean }) {
|
|
662
|
+
const [content, setContent] = useState<string>('');
|
|
663
|
+
const [draft, setDraft] = useState<string>('');
|
|
664
|
+
const [editing, setEditing] = useState(false);
|
|
665
|
+
const [loading, setLoading] = useState(true);
|
|
666
|
+
const [saving, setSaving] = useState(false);
|
|
667
|
+
const [msg, setMsg] = useState('');
|
|
668
|
+
|
|
669
|
+
const load = () => {
|
|
670
|
+
setLoading(true);
|
|
671
|
+
fetch(`/api/agents/${agentId}/claude-md`)
|
|
672
|
+
.then(r => r.text())
|
|
673
|
+
.then(t => { setContent(t); setDraft(t); })
|
|
674
|
+
.catch(() => setContent('Failed to load CLAUDE.md'))
|
|
675
|
+
.finally(() => setLoading(false));
|
|
676
|
+
};
|
|
677
|
+
|
|
678
|
+
useEffect(() => { load(); }, [agentId]);
|
|
679
|
+
|
|
680
|
+
const save = async () => {
|
|
681
|
+
setSaving(true);
|
|
682
|
+
try {
|
|
683
|
+
const res = await fetch(`/api/agents/${agentId}/claude-md`, {
|
|
684
|
+
method: 'PUT',
|
|
685
|
+
headers: { 'Content-Type': 'text/plain' },
|
|
686
|
+
body: draft,
|
|
687
|
+
});
|
|
688
|
+
if (!res.ok) throw new Error(await res.text());
|
|
689
|
+
setContent(draft);
|
|
690
|
+
setEditing(false);
|
|
691
|
+
setMsg('Saved — agent will use this on next reload.');
|
|
692
|
+
setTimeout(() => setMsg(''), 4000);
|
|
693
|
+
} catch (e: any) {
|
|
694
|
+
setMsg(`Error: ${e.message}`);
|
|
695
|
+
} finally {
|
|
696
|
+
setSaving(false);
|
|
697
|
+
}
|
|
698
|
+
};
|
|
699
|
+
|
|
700
|
+
if (loading) return <p style={{ color: 'var(--muted)', fontSize: 14 }}>Loading...</p>;
|
|
701
|
+
|
|
702
|
+
return (
|
|
703
|
+
<div>
|
|
704
|
+
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
|
|
705
|
+
<div>
|
|
706
|
+
<h3 style={{ margin: 0, fontSize: 15, fontWeight: 600 }}>CLAUDE.md</h3>
|
|
707
|
+
<p style={{ margin: '4px 0 0', fontSize: 13, color: 'var(--muted)' }}>
|
|
708
|
+
{editing ? 'Editing raw system prompt — this overrides all individual skills.' : 'Compiled system prompt sent to Claude.'}
|
|
709
|
+
</p>
|
|
710
|
+
</div>
|
|
711
|
+
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
|
712
|
+
{!editing && (
|
|
713
|
+
<span style={{ fontSize: 12, color: 'var(--muted)', background: 'var(--surface-2)', padding: '4px 10px', borderRadius: 6, border: '1px solid var(--border)' }}>
|
|
714
|
+
{(content.length / 1024).toFixed(1)} KB · {content.split('\n').length} lines
|
|
715
|
+
</span>
|
|
716
|
+
)}
|
|
717
|
+
{editing ? (
|
|
718
|
+
<>
|
|
719
|
+
<button onClick={() => { setEditing(false); setDraft(content); }} style={{ padding: '6px 14px', borderRadius: 7, border: '1px solid var(--border)', background: 'var(--surface)', color: 'var(--text)', fontSize: 13, cursor: 'pointer' }}>
|
|
720
|
+
Cancel
|
|
721
|
+
</button>
|
|
722
|
+
<button onClick={save} disabled={saving} style={{ padding: '6px 16px', borderRadius: 7, border: 'none', background: 'var(--accent)', color: '#fff', fontSize: 13, fontWeight: 600, cursor: 'pointer', opacity: saving ? 0.7 : 1 }}>
|
|
723
|
+
{saving ? 'Saving…' : 'Save'}
|
|
724
|
+
</button>
|
|
725
|
+
</>
|
|
726
|
+
) : (
|
|
727
|
+
canEdit && <button onClick={() => setEditing(true)} style={{ padding: '6px 16px', borderRadius: 7, border: '1px solid var(--border)', background: 'var(--surface)', color: 'var(--text)', fontSize: 13, cursor: 'pointer' }}>
|
|
728
|
+
Edit
|
|
729
|
+
</button>
|
|
730
|
+
)}
|
|
731
|
+
</div>
|
|
732
|
+
</div>
|
|
733
|
+
|
|
734
|
+
{msg && <p style={{ fontSize: 13, color: msg.startsWith('Error') ? 'var(--danger)' : 'var(--success)', marginBottom: 12 }}>{msg}</p>}
|
|
735
|
+
|
|
736
|
+
{editing ? (
|
|
737
|
+
<textarea
|
|
738
|
+
value={draft}
|
|
739
|
+
onChange={e => setDraft(e.target.value)}
|
|
740
|
+
style={{
|
|
741
|
+
width: '100%', height: '70vh', background: 'var(--surface)',
|
|
742
|
+
border: '1px solid var(--accent)', borderRadius: 10,
|
|
743
|
+
padding: '20px 24px', fontSize: 12.5, lineHeight: 1.7,
|
|
744
|
+
color: 'var(--text)', fontFamily: 'var(--font-mono)',
|
|
745
|
+
resize: 'vertical', outline: 'none', boxSizing: 'border-box',
|
|
746
|
+
}}
|
|
747
|
+
/>
|
|
748
|
+
) : (
|
|
749
|
+
<pre style={{
|
|
750
|
+
background: 'var(--surface-2)', border: '1px solid var(--border)',
|
|
751
|
+
borderRadius: 10, padding: '20px 24px', fontSize: 12.5, lineHeight: 1.7,
|
|
752
|
+
overflowX: 'auto', overflowY: 'auto', maxHeight: '70vh', margin: 0,
|
|
753
|
+
whiteSpace: 'pre-wrap', wordBreak: 'break-word',
|
|
754
|
+
color: 'var(--text)', fontFamily: 'var(--font-mono)',
|
|
755
|
+
}}>
|
|
756
|
+
{content}
|
|
757
|
+
</pre>
|
|
758
|
+
)}
|
|
759
|
+
</div>
|
|
760
|
+
);
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
// ─── Skills ───────────────────────────────────────────────────────────────────
|
|
764
|
+
|
|
765
|
+
function SkillsTab({ agentId, canEdit }: { agentId: string; canEdit: boolean }) {
|
|
766
|
+
const [skills, setSkills] = useState<Skill[]>([]);
|
|
767
|
+
const [selected, setSelected] = useState<Skill | null>(null);
|
|
768
|
+
const [content, setContent] = useState('');
|
|
769
|
+
const [saving, setSaving] = useState(false);
|
|
770
|
+
const [msg, setMsg] = useState('');
|
|
771
|
+
const [showNew, setShowNew] = useState(false);
|
|
772
|
+
const [newSkill, setNewSkill] = useState({ category: '', filename: '', content: '' });
|
|
773
|
+
|
|
774
|
+
const load = () =>
|
|
775
|
+
fetch(`/api/agents/${agentId}/skills`).then(r => r.json()).then(setSkills);
|
|
776
|
+
|
|
777
|
+
useEffect(() => { load(); }, [agentId]);
|
|
778
|
+
|
|
779
|
+
const select = (s: Skill) => { setSelected(s); setContent(s.content); };
|
|
780
|
+
|
|
781
|
+
const save = async () => {
|
|
782
|
+
if (!selected) return;
|
|
783
|
+
setSaving(true);
|
|
784
|
+
await fetch(`/api/agents/${agentId}/skills`, {
|
|
785
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
786
|
+
body: JSON.stringify({ category: selected.category, filename: selected.filename, content, sortOrder: selected.sortOrder }),
|
|
787
|
+
});
|
|
788
|
+
setSaving(false); setMsg('Saved'); setTimeout(() => setMsg(''), 2000); load();
|
|
789
|
+
};
|
|
790
|
+
|
|
791
|
+
const remove = async (s: Skill) => {
|
|
792
|
+
if (!confirm(`Delete ${s.category}/${s.filename}?`)) return;
|
|
793
|
+
await fetch(`/api/agents/${agentId}/skills/${s.id}`, { method: 'DELETE' });
|
|
794
|
+
if (selected?.id === s.id) { setSelected(null); setContent(''); }
|
|
795
|
+
load();
|
|
796
|
+
};
|
|
797
|
+
|
|
798
|
+
const create = async () => {
|
|
799
|
+
if (!newSkill.category || !newSkill.filename) return;
|
|
800
|
+
await fetch(`/api/agents/${agentId}/skills`, {
|
|
801
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
802
|
+
body: JSON.stringify(newSkill),
|
|
803
|
+
});
|
|
804
|
+
setShowNew(false); setNewSkill({ category: '', filename: '', content: '' }); load();
|
|
805
|
+
};
|
|
806
|
+
|
|
807
|
+
const grouped = skills.reduce<Record<string, Skill[]>>((acc, s) => {
|
|
808
|
+
(acc[s.category] ??= []).push(s); return acc;
|
|
809
|
+
}, {});
|
|
810
|
+
|
|
811
|
+
return (
|
|
812
|
+
<div className="fade-up" style={{ display: 'flex', gap: 14, height: 580 }}>
|
|
813
|
+
{/* File tree */}
|
|
814
|
+
<div style={{
|
|
815
|
+
width: 220, flexShrink: 0,
|
|
816
|
+
background: 'var(--surface)', border: '1px solid var(--border)',
|
|
817
|
+
borderRadius: 10, overflow: 'auto', display: 'flex', flexDirection: 'column',
|
|
818
|
+
}}>
|
|
819
|
+
<div style={{
|
|
820
|
+
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
|
821
|
+
padding: '10px 12px', borderBottom: '1px solid var(--border)',
|
|
822
|
+
}}>
|
|
823
|
+
<span style={{ fontSize: 11, fontWeight: 600, color: 'var(--muted)', letterSpacing: '0.06em', textTransform: 'uppercase' }}>
|
|
824
|
+
Files
|
|
825
|
+
</span>
|
|
826
|
+
{canEdit && <button onClick={() => setShowNew(true)} style={{
|
|
827
|
+
background: 'none', border: 'none', cursor: 'pointer',
|
|
828
|
+
fontSize: 12, color: 'var(--accent)', fontFamily: 'var(--font-sans)',
|
|
829
|
+
}}>+ New</button>}
|
|
830
|
+
</div>
|
|
831
|
+
<div style={{ padding: '6px 6px', flex: 1, overflow: 'auto' }}>
|
|
832
|
+
{Object.entries(grouped).sort(([a], [b]) => a.localeCompare(b)).map(([cat, catSkills]) => (
|
|
833
|
+
<div key={cat}>
|
|
834
|
+
<div style={{
|
|
835
|
+
fontSize: 10.5, color: 'var(--subtle)', padding: '6px 6px 2px',
|
|
836
|
+
fontFamily: 'var(--font-mono)', letterSpacing: '0.02em',
|
|
837
|
+
}}>{cat}/</div>
|
|
838
|
+
{catSkills.map(s => (
|
|
839
|
+
<div
|
|
840
|
+
key={s.id}
|
|
841
|
+
onClick={() => select(s)}
|
|
842
|
+
className="skill-row"
|
|
843
|
+
style={{
|
|
844
|
+
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
|
845
|
+
padding: '5px 8px', borderRadius: 6, cursor: 'pointer',
|
|
846
|
+
fontSize: 12, fontFamily: 'var(--font-mono)',
|
|
847
|
+
background: selected?.id === s.id ? 'rgba(59,130,246,0.12)' : 'transparent',
|
|
848
|
+
color: selected?.id === s.id ? 'var(--accent)' : 'var(--muted)',
|
|
849
|
+
transition: 'background 0.12s, color 0.12s',
|
|
850
|
+
}}
|
|
851
|
+
onMouseEnter={e => {
|
|
852
|
+
if (selected?.id !== s.id) (e.currentTarget as HTMLElement).style.background = 'rgba(255,255,255,0.04)';
|
|
853
|
+
const btn = (e.currentTarget as HTMLElement).querySelector('.delete-btn') as HTMLElement | null;
|
|
854
|
+
if (btn) btn.style.opacity = '1';
|
|
855
|
+
}}
|
|
856
|
+
onMouseLeave={e => {
|
|
857
|
+
if (selected?.id !== s.id) (e.currentTarget as HTMLElement).style.background = 'transparent';
|
|
858
|
+
const btn = (e.currentTarget as HTMLElement).querySelector('.delete-btn') as HTMLElement | null;
|
|
859
|
+
if (btn) btn.style.opacity = '0';
|
|
860
|
+
}}
|
|
861
|
+
>
|
|
862
|
+
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{s.filename}</span>
|
|
863
|
+
{canEdit && <button
|
|
864
|
+
onClick={e => { e.stopPropagation(); remove(s); }}
|
|
865
|
+
className="delete-btn"
|
|
866
|
+
style={{
|
|
867
|
+
background: 'none', border: 'none', cursor: 'pointer',
|
|
868
|
+
color: '#ef4444', fontSize: 14, opacity: 0, transition: 'opacity 0.12s',
|
|
869
|
+
fontFamily: 'var(--font-sans)', lineHeight: 1, padding: '0 2px', flexShrink: 0,
|
|
870
|
+
}}
|
|
871
|
+
>×</button>}
|
|
872
|
+
</div>
|
|
873
|
+
))}
|
|
874
|
+
</div>
|
|
875
|
+
))}
|
|
876
|
+
</div>
|
|
877
|
+
</div>
|
|
878
|
+
|
|
879
|
+
{/* Editor */}
|
|
880
|
+
<div style={{
|
|
881
|
+
flex: 1, background: 'var(--surface)', border: '1px solid var(--border)',
|
|
882
|
+
borderRadius: 10, display: 'flex', flexDirection: 'column', overflow: 'hidden',
|
|
883
|
+
}}>
|
|
884
|
+
{selected ? (
|
|
885
|
+
<>
|
|
886
|
+
<div style={{
|
|
887
|
+
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
|
888
|
+
padding: '10px 16px', borderBottom: '1px solid var(--border)',
|
|
889
|
+
background: 'var(--surface-2)',
|
|
890
|
+
}}>
|
|
891
|
+
<span style={{ fontSize: 12, color: 'var(--muted)', fontFamily: 'var(--font-mono)' }}>
|
|
892
|
+
{selected.category}/{selected.filename}
|
|
893
|
+
</span>
|
|
894
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
|
895
|
+
{msg && <span style={{ fontSize: 11.5, color: '#16a34a' }}>{msg}</span>}
|
|
896
|
+
{canEdit && <button
|
|
897
|
+
onClick={save} disabled={saving}
|
|
898
|
+
style={{
|
|
899
|
+
background: saving ? 'var(--border)' : 'var(--accent)',
|
|
900
|
+
color: '#fff', border: 'none', borderRadius: 6,
|
|
901
|
+
padding: '5px 14px', fontSize: 12, fontWeight: 500,
|
|
902
|
+
cursor: saving ? 'not-allowed' : 'pointer',
|
|
903
|
+
fontFamily: 'var(--font-sans)',
|
|
904
|
+
}}
|
|
905
|
+
>
|
|
906
|
+
{saving ? 'Saving…' : 'Save'}
|
|
907
|
+
</button>}
|
|
908
|
+
</div>
|
|
909
|
+
</div>
|
|
910
|
+
<textarea
|
|
911
|
+
value={content}
|
|
912
|
+
onChange={e => setContent(e.target.value)}
|
|
913
|
+
readOnly={!canEdit}
|
|
914
|
+
style={{
|
|
915
|
+
flex: 1, border: 'none', outline: 'none', resize: 'none',
|
|
916
|
+
background: 'transparent', color: 'var(--text)',
|
|
917
|
+
fontFamily: 'var(--font-mono)', fontSize: 12.5, lineHeight: 1.65,
|
|
918
|
+
padding: '16px', caretColor: 'var(--accent)',
|
|
919
|
+
}}
|
|
920
|
+
spellCheck={false}
|
|
921
|
+
/>
|
|
922
|
+
</>
|
|
923
|
+
) : (
|
|
924
|
+
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--subtle)', fontSize: 13 }}>
|
|
925
|
+
Select a file to edit
|
|
926
|
+
</div>
|
|
927
|
+
)}
|
|
928
|
+
</div>
|
|
929
|
+
|
|
930
|
+
{/* New skill modal */}
|
|
931
|
+
{showNew && (
|
|
932
|
+
<Modal title="New Skill File" onClose={() => setShowNew(false)}>
|
|
933
|
+
<Field label="Category" value={newSkill.category}
|
|
934
|
+
onChange={v => setNewSkill(s => ({ ...s, category: v }))} hint="e.g. 00-core" />
|
|
935
|
+
<Field label="Filename" value={newSkill.filename}
|
|
936
|
+
onChange={v => setNewSkill(s => ({ ...s, filename: v }))} hint="e.g. identity.md" />
|
|
937
|
+
<TextArea label="Content (optional)" value={newSkill.content}
|
|
938
|
+
onChange={v => setNewSkill(s => ({ ...s, content: v }))} rows={4} />
|
|
939
|
+
<div style={{ display: 'flex', gap: 8, marginTop: 4 }}>
|
|
940
|
+
<PrimaryBtn onClick={create}>Create</PrimaryBtn>
|
|
941
|
+
<GhostBtn onClick={() => setShowNew(false)}>Cancel</GhostBtn>
|
|
942
|
+
</div>
|
|
943
|
+
</Modal>
|
|
944
|
+
)}
|
|
945
|
+
</div>
|
|
946
|
+
);
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
// ─── MCPs ─────────────────────────────────────────────────────────────────────
|
|
950
|
+
|
|
951
|
+
function McpsTab({ agentId, canEdit }: { agentId: string; canEdit: boolean }) {
|
|
952
|
+
const [all, setAll] = useState<McpServer[]>([]);
|
|
953
|
+
const [assigned, setAssigned] = useState<Set<string>>(new Set());
|
|
954
|
+
const [saving, setSaving] = useState(false);
|
|
955
|
+
const [msg, setMsg] = useState('');
|
|
956
|
+
|
|
957
|
+
useEffect(() => {
|
|
958
|
+
Promise.all([
|
|
959
|
+
fetch('/api/mcps').then(r => r.json()),
|
|
960
|
+
fetch(`/api/agents/${agentId}/mcps`).then(r => r.json()),
|
|
961
|
+
]).then(([a, b]: [McpServer[], McpServer[]]) => {
|
|
962
|
+
setAll(a); setAssigned(new Set(b.map(m => m.id)));
|
|
963
|
+
});
|
|
964
|
+
}, [agentId]);
|
|
965
|
+
|
|
966
|
+
const toggle = (id: string) =>
|
|
967
|
+
setAssigned(prev => { const n = new Set(prev); n.has(id) ? n.delete(id) : n.add(id); return n; });
|
|
968
|
+
|
|
969
|
+
const save = async () => {
|
|
970
|
+
setSaving(true);
|
|
971
|
+
await fetch(`/api/agents/${agentId}/mcps`, {
|
|
972
|
+
method: 'PUT', headers: { 'Content-Type': 'application/json' },
|
|
973
|
+
body: JSON.stringify({ mcpIds: [...assigned] }),
|
|
974
|
+
});
|
|
975
|
+
setSaving(false); setMsg('Saved & reload triggered');
|
|
976
|
+
setTimeout(() => setMsg(''), 3000);
|
|
977
|
+
};
|
|
978
|
+
|
|
979
|
+
return (
|
|
980
|
+
<div style={{ maxWidth: 560 }} className="fade-up">
|
|
981
|
+
<p style={{ margin: '0 0 16px', fontSize: 13, color: 'var(--muted)' }}>
|
|
982
|
+
Select MCP servers from the platform catalog to enable for this agent.
|
|
983
|
+
</p>
|
|
984
|
+
<div style={{ border: '1px solid var(--border)', borderRadius: 10, overflow: 'hidden', marginBottom: 16 }}>
|
|
985
|
+
{all.length === 0 ? (
|
|
986
|
+
<div style={{ padding: '24px', textAlign: 'center', color: 'var(--muted)', fontSize: 13 }}>
|
|
987
|
+
No MCP servers yet.{' '}
|
|
988
|
+
<Link href="/settings/mcps" style={{ color: 'var(--accent)', textDecoration: 'none' }}>Add some →</Link>
|
|
989
|
+
</div>
|
|
990
|
+
) : all.map((mcp, i) => (
|
|
991
|
+
<label
|
|
992
|
+
key={mcp.id}
|
|
993
|
+
style={{
|
|
994
|
+
display: 'flex', alignItems: 'center', gap: 12,
|
|
995
|
+
padding: '13px 16px', cursor: mcp.enabled ? 'pointer' : 'not-allowed',
|
|
996
|
+
borderBottom: i < all.length - 1 ? '1px solid var(--border)' : 'none',
|
|
997
|
+
background: 'transparent', transition: 'background 0.12s',
|
|
998
|
+
opacity: mcp.enabled ? 1 : 0.45,
|
|
999
|
+
}}
|
|
1000
|
+
onMouseEnter={e => { if (mcp.enabled) (e.currentTarget as HTMLElement).style.background = 'rgba(255,255,255,0.03)'; }}
|
|
1001
|
+
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.background = 'transparent'; }}
|
|
1002
|
+
>
|
|
1003
|
+
<input
|
|
1004
|
+
type="checkbox"
|
|
1005
|
+
checked={assigned.has(mcp.id)}
|
|
1006
|
+
onChange={() => toggle(mcp.id)}
|
|
1007
|
+
disabled={!mcp.enabled || !canEdit}
|
|
1008
|
+
style={{ accentColor: 'var(--accent)', width: 14, height: 14, flexShrink: 0 }}
|
|
1009
|
+
/>
|
|
1010
|
+
<div style={{ flex: 1, minWidth: 0 }}>
|
|
1011
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
1012
|
+
<span style={{ fontSize: 13, fontWeight: 500, color: 'var(--text)' }}>{mcp.name}</span>
|
|
1013
|
+
<span style={{
|
|
1014
|
+
fontSize: 10.5, fontFamily: 'var(--font-mono)',
|
|
1015
|
+
color: 'var(--muted)', background: 'var(--border)',
|
|
1016
|
+
padding: '1px 6px', borderRadius: 4,
|
|
1017
|
+
}}>{mcp.type}</span>
|
|
1018
|
+
{!mcp.enabled && <span style={{ fontSize: 11, color: 'var(--subtle)' }}>disabled</span>}
|
|
1019
|
+
</div>
|
|
1020
|
+
{mcp.description && <p style={{ margin: 0, fontSize: 12, color: 'var(--muted)', marginTop: 1 }}>{mcp.description}</p>}
|
|
1021
|
+
</div>
|
|
1022
|
+
</label>
|
|
1023
|
+
))}
|
|
1024
|
+
</div>
|
|
1025
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
|
1026
|
+
{canEdit && <PrimaryBtn onClick={save} loading={saving}>Save Assignments</PrimaryBtn>}
|
|
1027
|
+
{msg && <span style={{ fontSize: 12, color: '#16a34a' }}>{msg}</span>}
|
|
1028
|
+
</div>
|
|
1029
|
+
</div>
|
|
1030
|
+
);
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
// ─── Permissions ──────────────────────────────────────────────────────────────
|
|
1034
|
+
|
|
1035
|
+
const QUICK_TOOLS = ['Read', 'Write', 'Edit', 'Bash', 'Glob', 'Grep', 'WebFetch', 'WebSearch'];
|
|
1036
|
+
|
|
1037
|
+
function PermissionsTab({ agentId, canEdit }: { agentId: string; canEdit: boolean }) {
|
|
1038
|
+
const [allowed, setAllowed] = useState('');
|
|
1039
|
+
const [denied, setDenied] = useState('');
|
|
1040
|
+
const [saving, setSaving] = useState(false);
|
|
1041
|
+
const [msg, setMsg] = useState('');
|
|
1042
|
+
|
|
1043
|
+
useEffect(() => {
|
|
1044
|
+
fetch(`/api/agents/${agentId}/permissions`).then(r => r.json()).then((p: Permission) => {
|
|
1045
|
+
setAllowed((p.allowedTools ?? []).join('\n'));
|
|
1046
|
+
setDenied((p.deniedTools ?? []).join('\n'));
|
|
1047
|
+
});
|
|
1048
|
+
}, [agentId]);
|
|
1049
|
+
|
|
1050
|
+
const addTool = (tool: string, list: 'allowed' | 'denied') => {
|
|
1051
|
+
const setter = list === 'allowed' ? setAllowed : setDenied;
|
|
1052
|
+
const current = (list === 'allowed' ? allowed : denied).split('\n').map(s => s.trim()).filter(Boolean);
|
|
1053
|
+
if (!current.includes(tool)) setter([...current, tool].join('\n'));
|
|
1054
|
+
};
|
|
1055
|
+
|
|
1056
|
+
const save = async () => {
|
|
1057
|
+
setSaving(true);
|
|
1058
|
+
await fetch(`/api/agents/${agentId}/permissions`, {
|
|
1059
|
+
method: 'PUT', headers: { 'Content-Type': 'application/json' },
|
|
1060
|
+
body: JSON.stringify({
|
|
1061
|
+
allowedTools: allowed.split('\n').map(s => s.trim()).filter(Boolean),
|
|
1062
|
+
deniedTools: denied.split('\n').map(s => s.trim()).filter(Boolean),
|
|
1063
|
+
}),
|
|
1064
|
+
});
|
|
1065
|
+
setSaving(false); setMsg('Saved & reload triggered');
|
|
1066
|
+
setTimeout(() => setMsg(''), 3000);
|
|
1067
|
+
};
|
|
1068
|
+
|
|
1069
|
+
return (
|
|
1070
|
+
<div style={{ maxWidth: 660 }} className="fade-up">
|
|
1071
|
+
{/* Quick add */}
|
|
1072
|
+
<div style={{
|
|
1073
|
+
background: 'var(--surface)', border: '1px solid var(--border)',
|
|
1074
|
+
borderRadius: 10, padding: '12px 16px', marginBottom: 18,
|
|
1075
|
+
}}>
|
|
1076
|
+
<div style={{ fontSize: 11, color: 'var(--muted)', marginBottom: 8, fontWeight: 500, letterSpacing: '0.04em', textTransform: 'uppercase' }}>
|
|
1077
|
+
Quick add built-in tools
|
|
1078
|
+
</div>
|
|
1079
|
+
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
|
1080
|
+
{QUICK_TOOLS.map(t => (
|
|
1081
|
+
<button
|
|
1082
|
+
key={t}
|
|
1083
|
+
onClick={() => addTool(t, 'allowed')}
|
|
1084
|
+
disabled={!canEdit}
|
|
1085
|
+
style={{
|
|
1086
|
+
background: 'var(--border)', border: '1px solid var(--border-2)',
|
|
1087
|
+
color: 'var(--text)', padding: '3px 10px', borderRadius: 5,
|
|
1088
|
+
fontSize: 11.5, fontFamily: 'var(--font-mono)', cursor: 'pointer',
|
|
1089
|
+
transition: 'background 0.12s, border-color 0.12s',
|
|
1090
|
+
}}
|
|
1091
|
+
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.background = 'var(--border-2)'; (e.currentTarget as HTMLElement).style.borderColor = 'var(--accent)'; }}
|
|
1092
|
+
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.background = 'var(--border)'; (e.currentTarget as HTMLElement).style.borderColor = 'var(--border-2)'; }}
|
|
1093
|
+
>{t}</button>
|
|
1094
|
+
))}
|
|
1095
|
+
</div>
|
|
1096
|
+
<p style={{ margin: '8px 0 0', fontSize: 11, color: 'var(--subtle)' }}>
|
|
1097
|
+
MCP tools pattern: <code style={{ fontFamily: 'var(--font-mono)', color: 'var(--muted)' }}>mcp__serverName__toolName</code>
|
|
1098
|
+
</p>
|
|
1099
|
+
</div>
|
|
1100
|
+
|
|
1101
|
+
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 14, marginBottom: 16 }}>
|
|
1102
|
+
<div>
|
|
1103
|
+
<label style={{ display: 'block', fontSize: 12, fontWeight: 500, color: 'var(--muted)', marginBottom: 6 }}>
|
|
1104
|
+
Allowed Tools <span style={{ color: 'var(--subtle)', fontWeight: 400 }}>· one per line</span>
|
|
1105
|
+
</label>
|
|
1106
|
+
<textarea
|
|
1107
|
+
value={allowed} onChange={e => setAllowed(e.target.value)}
|
|
1108
|
+
rows={12} readOnly={!canEdit}
|
|
1109
|
+
style={{
|
|
1110
|
+
width: '100%', background: 'var(--surface)', border: '1px solid var(--border)',
|
|
1111
|
+
borderRadius: 8, padding: '10px 12px', color: 'var(--text)',
|
|
1112
|
+
fontFamily: 'var(--font-mono)', fontSize: 12, lineHeight: 1.7,
|
|
1113
|
+
outline: 'none', resize: 'vertical',
|
|
1114
|
+
}}
|
|
1115
|
+
onFocus={e => (e.currentTarget.style.borderColor = 'var(--accent)')}
|
|
1116
|
+
onBlur={e => (e.currentTarget.style.borderColor = 'var(--border)')}
|
|
1117
|
+
placeholder={'Read\nWrite\nmcp__redshift-mcp__query'}
|
|
1118
|
+
/>
|
|
1119
|
+
</div>
|
|
1120
|
+
<div>
|
|
1121
|
+
<label style={{ display: 'block', fontSize: 12, fontWeight: 500, color: 'var(--muted)', marginBottom: 6 }}>
|
|
1122
|
+
Denied Tools <span style={{ color: 'var(--subtle)', fontWeight: 400 }}>· overrides allowed</span>
|
|
1123
|
+
</label>
|
|
1124
|
+
<textarea
|
|
1125
|
+
value={denied} onChange={e => setDenied(e.target.value)}
|
|
1126
|
+
rows={12} readOnly={!canEdit}
|
|
1127
|
+
style={{
|
|
1128
|
+
width: '100%', background: 'var(--surface)', border: '1px solid var(--border)',
|
|
1129
|
+
borderRadius: 8, padding: '10px 12px', color: 'var(--danger)',
|
|
1130
|
+
fontFamily: 'var(--font-mono)', fontSize: 12, lineHeight: 1.7,
|
|
1131
|
+
outline: 'none', resize: 'vertical',
|
|
1132
|
+
}}
|
|
1133
|
+
onFocus={e => (e.currentTarget.style.borderColor = '#ef4444')}
|
|
1134
|
+
onBlur={e => (e.currentTarget.style.borderColor = 'var(--border)')}
|
|
1135
|
+
placeholder={'Bash'}
|
|
1136
|
+
/>
|
|
1137
|
+
</div>
|
|
1138
|
+
</div>
|
|
1139
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
|
1140
|
+
{canEdit && <PrimaryBtn onClick={save} loading={saving}>Save Permissions</PrimaryBtn>}
|
|
1141
|
+
{msg && <span style={{ fontSize: 12, color: '#16a34a' }}>{msg}</span>}
|
|
1142
|
+
</div>
|
|
1143
|
+
</div>
|
|
1144
|
+
);
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
// ─── Memory ───────────────────────────────────────────────────────────────────
|
|
1148
|
+
|
|
1149
|
+
const MEM_TYPE_STYLE: Record<string, { bg: string; color: string }> = {
|
|
1150
|
+
user: { bg: '#f3f0ff', color: '#7c3aed' },
|
|
1151
|
+
feedback: { bg: '#eff6ff', color: '#2563eb' },
|
|
1152
|
+
project: { bg: '#fffbeb', color: '#b45309' },
|
|
1153
|
+
reference: { bg: '#f0fdf4', color: '#15803d' },
|
|
1154
|
+
};
|
|
1155
|
+
|
|
1156
|
+
function MemoryTab({ agentId, canEdit }: { agentId: string; canEdit: boolean }) {
|
|
1157
|
+
const [memories, setMemories] = useState<Memory[]>([]);
|
|
1158
|
+
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
|
1159
|
+
|
|
1160
|
+
const load = () => fetch(`/api/agents/${agentId}/memories`).then(r => r.json()).then(setMemories);
|
|
1161
|
+
useEffect(() => { load(); }, [agentId]);
|
|
1162
|
+
|
|
1163
|
+
const remove = async (id: string) => {
|
|
1164
|
+
await fetch(`/api/agents/${agentId}/memories/${id}`, { method: 'DELETE' });
|
|
1165
|
+
load();
|
|
1166
|
+
};
|
|
1167
|
+
|
|
1168
|
+
const toggle = (id: string) =>
|
|
1169
|
+
setExpanded(prev => { const n = new Set(prev); n.has(id) ? n.delete(id) : n.add(id); return n; });
|
|
1170
|
+
|
|
1171
|
+
const grouped = memories.reduce<Record<string, Memory[]>>((acc, m) => {
|
|
1172
|
+
(acc[m.type] ??= []).push(m); return acc;
|
|
1173
|
+
}, {});
|
|
1174
|
+
|
|
1175
|
+
if (memories.length === 0) {
|
|
1176
|
+
return (
|
|
1177
|
+
<div className="fade-up" style={{
|
|
1178
|
+
display: 'flex', flexDirection: 'column', alignItems: 'center',
|
|
1179
|
+
paddingTop: 80, color: 'var(--muted)',
|
|
1180
|
+
}}>
|
|
1181
|
+
<Brain size={36} style={{ marginBottom: 12, color: 'var(--border-2)' }} />
|
|
1182
|
+
<p style={{ margin: '0 0 4px', fontSize: 15, fontWeight: 600, color: 'var(--text)', textAlign: 'center' }}>
|
|
1183
|
+
No memories yet
|
|
1184
|
+
</p>
|
|
1185
|
+
<p style={{ fontSize: 13, color: 'var(--muted)', maxWidth: 300, margin: '0', textAlign: 'center' }}>
|
|
1186
|
+
The agent will automatically accumulate memories as it interacts in Slack.
|
|
1187
|
+
</p>
|
|
1188
|
+
</div>
|
|
1189
|
+
);
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
return (
|
|
1193
|
+
<div style={{ maxWidth: 720 }} className="fade-up">
|
|
1194
|
+
<div style={{ fontSize: 13, color: 'var(--muted)', marginBottom: 18 }}>
|
|
1195
|
+
{memories.length} memories across {Object.keys(grouped).length} categories
|
|
1196
|
+
</div>
|
|
1197
|
+
{(['feedback', 'user', 'project', 'reference'] as const).map(type => {
|
|
1198
|
+
const items = grouped[type];
|
|
1199
|
+
if (!items?.length) return null;
|
|
1200
|
+
const style = MEM_TYPE_STYLE[type] ?? { bg: 'var(--border)', color: 'var(--muted)' };
|
|
1201
|
+
return (
|
|
1202
|
+
<div key={type} style={{ marginBottom: 20 }}>
|
|
1203
|
+
<div style={{
|
|
1204
|
+
display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8,
|
|
1205
|
+
}}>
|
|
1206
|
+
<span style={{
|
|
1207
|
+
fontSize: 10.5, fontWeight: 600, letterSpacing: '0.06em',
|
|
1208
|
+
textTransform: 'uppercase',
|
|
1209
|
+
background: style.bg, color: style.color,
|
|
1210
|
+
padding: '2px 8px', borderRadius: 5,
|
|
1211
|
+
}}>{type}</span>
|
|
1212
|
+
<span style={{ fontSize: 11.5, color: 'var(--subtle)' }}>{items.length}</span>
|
|
1213
|
+
</div>
|
|
1214
|
+
<div style={{ border: '1px solid var(--border)', borderRadius: 10, overflow: 'hidden' }}>
|
|
1215
|
+
{items.map((m, i) => (
|
|
1216
|
+
<div key={m.id} style={{ borderBottom: i < items.length - 1 ? '1px solid var(--border)' : 'none' }}>
|
|
1217
|
+
<div style={{
|
|
1218
|
+
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
|
1219
|
+
padding: '10px 14px', cursor: 'pointer',
|
|
1220
|
+
}} onClick={() => toggle(m.id)}>
|
|
1221
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
|
|
1222
|
+
<span style={{ fontSize: 11, color: 'var(--subtle)' }}>
|
|
1223
|
+
{expanded.has(m.id) ? '▼' : '▶'}
|
|
1224
|
+
</span>
|
|
1225
|
+
<span style={{ fontSize: 13, color: 'var(--text)', fontWeight: 500, fontFamily: 'var(--font-mono)' }}>
|
|
1226
|
+
{m.name}
|
|
1227
|
+
</span>
|
|
1228
|
+
</div>
|
|
1229
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
|
1230
|
+
<span style={{ fontSize: 11, color: 'var(--subtle)' }}>
|
|
1231
|
+
{new Date(m.updatedAt).toLocaleDateString()}
|
|
1232
|
+
</span>
|
|
1233
|
+
{canEdit && <button
|
|
1234
|
+
onClick={e => { e.stopPropagation(); remove(m.id); }}
|
|
1235
|
+
style={{
|
|
1236
|
+
background: 'none', border: 'none', cursor: 'pointer',
|
|
1237
|
+
color: '#ef4444', fontSize: 13, opacity: 0.5, transition: 'opacity 0.12s',
|
|
1238
|
+
fontFamily: 'var(--font-sans)',
|
|
1239
|
+
}}
|
|
1240
|
+
onMouseEnter={e => (e.currentTarget.style.opacity = '1')}
|
|
1241
|
+
onMouseLeave={e => (e.currentTarget.style.opacity = '0.5')}
|
|
1242
|
+
>Delete</button>}
|
|
1243
|
+
</div>
|
|
1244
|
+
</div>
|
|
1245
|
+
{expanded.has(m.id) && (
|
|
1246
|
+
<pre style={{
|
|
1247
|
+
margin: 0, padding: '12px 14px',
|
|
1248
|
+
background: 'var(--surface-2)',
|
|
1249
|
+
borderTop: '1px solid var(--border)',
|
|
1250
|
+
fontFamily: 'var(--font-mono)', fontSize: 11.5,
|
|
1251
|
+
color: 'var(--muted)', whiteSpace: 'pre-wrap', lineHeight: 1.6,
|
|
1252
|
+
}}>{m.content}</pre>
|
|
1253
|
+
)}
|
|
1254
|
+
</div>
|
|
1255
|
+
))}
|
|
1256
|
+
</div>
|
|
1257
|
+
</div>
|
|
1258
|
+
);
|
|
1259
|
+
})}
|
|
1260
|
+
</div>
|
|
1261
|
+
);
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
// ─── Logs ─────────────────────────────────────────────────────────────────────
|
|
1265
|
+
|
|
1266
|
+
type LogLevel = 'all' | 'debug' | 'info' | 'warn' | 'error';
|
|
1267
|
+
|
|
1268
|
+
interface ParsedLog {
|
|
1269
|
+
raw: string;
|
|
1270
|
+
level: LogLevel;
|
|
1271
|
+
time: string;
|
|
1272
|
+
message: string;
|
|
1273
|
+
fields: Record<string, string>;
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
function parseLine(raw: string): ParsedLog {
|
|
1277
|
+
const stripped = raw.replace(/\x1b\[[0-9;]*m/g, '');
|
|
1278
|
+
try {
|
|
1279
|
+
const obj = JSON.parse(stripped);
|
|
1280
|
+
const level: LogLevel =
|
|
1281
|
+
obj.level === 'error' || obj.level === 50 ? 'error' :
|
|
1282
|
+
obj.level === 'warn' || obj.level === 40 ? 'warn' :
|
|
1283
|
+
obj.level === 'debug' || obj.level === 20 ? 'debug' : 'info';
|
|
1284
|
+
const ts = obj.timestamp ? new Date(obj.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }) : '';
|
|
1285
|
+
const rawMsg = obj.message ?? obj.msg ?? '';
|
|
1286
|
+
const msg = rawMsg.replace(/^(error|warn|info|debug|trace):\s*/i, '');
|
|
1287
|
+
const skip = new Set(['level', 'message', 'msg', 'timestamp', 'agent', 'service']);
|
|
1288
|
+
const fields: Record<string, string> = {};
|
|
1289
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
1290
|
+
if (!skip.has(k)) fields[k] = typeof v === 'object' ? JSON.stringify(v) : String(v);
|
|
1291
|
+
}
|
|
1292
|
+
return { raw: stripped, level, time: ts, message: msg, fields };
|
|
1293
|
+
} catch {
|
|
1294
|
+
const lo = stripped.toLowerCase();
|
|
1295
|
+
const level: LogLevel =
|
|
1296
|
+
lo.includes('"level":"error"') || lo.includes('"level":50') || lo.includes('error:') ? 'error' :
|
|
1297
|
+
lo.includes('"level":"warn"') || lo.includes('"level":40') || lo.includes('warn:') ? 'warn' :
|
|
1298
|
+
lo.includes('"level":"debug"') || lo.includes('"level":20') || lo.includes('debug:') ? 'debug' : 'info';
|
|
1299
|
+
const plainMsg = stripped.replace(/^(error|warn|info|debug|trace):\s*/i, '');
|
|
1300
|
+
const tsMatch = stripped.match(/"timestamp":"([^"]+)"/);
|
|
1301
|
+
const plainTime = tsMatch ? new Date(tsMatch[1]).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }) : '';
|
|
1302
|
+
return { raw: stripped, level, time: plainTime, message: plainMsg, fields: {} };
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
const LOG_META: Record<LogLevel, { label: string; color: string; bg: string; border: string; rowBg: string }> = {
|
|
1307
|
+
all: { label: 'ALL', color: '#6b7280', bg: '#f3f4f6', border: '#e5e7eb', rowBg: 'transparent' },
|
|
1308
|
+
info: { label: 'INFO', color: '#1d4ed8', bg: '#eff6ff', border: '#bfdbfe', rowBg: 'transparent' },
|
|
1309
|
+
debug: { label: 'DEBUG', color: '#9ca3af', bg: '#f9fafb', border: '#e5e7eb', rowBg: 'transparent' },
|
|
1310
|
+
warn: { label: 'WARN', color: '#92400e', bg: '#fffbeb', border: '#fde68a', rowBg: '#fffdf0' },
|
|
1311
|
+
error: { label: 'ERR', color: '#991b1b', bg: '#fef2f2', border: '#fecaca', rowBg: '#fff8f8' },
|
|
1312
|
+
};
|
|
1313
|
+
|
|
1314
|
+
function LogRow({ log }: { log: ParsedLog }) {
|
|
1315
|
+
const [expanded, setExpanded] = useState(false);
|
|
1316
|
+
const [hovered, setHovered] = useState(false);
|
|
1317
|
+
const m = LOG_META[log.level];
|
|
1318
|
+
const hasFields = Object.keys(log.fields).length > 0;
|
|
1319
|
+
const msgColor = log.level === 'error' ? '#7f1d1d' : log.level === 'warn' ? '#78350f' : log.level === 'debug' ? '#9ca3af' : 'var(--text)';
|
|
1320
|
+
|
|
1321
|
+
return (
|
|
1322
|
+
<div
|
|
1323
|
+
onClick={() => setExpanded(e => !e)}
|
|
1324
|
+
onMouseEnter={() => setHovered(true)}
|
|
1325
|
+
onMouseLeave={() => setHovered(false)}
|
|
1326
|
+
style={{
|
|
1327
|
+
cursor: 'pointer',
|
|
1328
|
+
background: hovered ? 'var(--surface-2)' : (expanded ? 'var(--surface-2)' : m.rowBg),
|
|
1329
|
+
borderLeft: `3px solid ${expanded ? m.border : 'transparent'}`,
|
|
1330
|
+
borderBottom: '1px solid var(--border)',
|
|
1331
|
+
transition: 'background 0.1s',
|
|
1332
|
+
}}
|
|
1333
|
+
>
|
|
1334
|
+
{/* Compact single row */}
|
|
1335
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '5px 10px', minHeight: 28 }}>
|
|
1336
|
+
<span style={{ color: 'var(--subtle)', flexShrink: 0, fontSize: 10.5, fontVariantNumeric: 'tabular-nums', minWidth: 68 }}>
|
|
1337
|
+
{log.time}
|
|
1338
|
+
</span>
|
|
1339
|
+
<span style={{
|
|
1340
|
+
flexShrink: 0, fontSize: 9.5, fontWeight: 700, letterSpacing: '0.06em',
|
|
1341
|
+
padding: '1px 6px', borderRadius: 3, border: `1px solid ${m.border}`,
|
|
1342
|
+
background: m.bg, color: m.color, minWidth: 34, textAlign: 'center',
|
|
1343
|
+
}}>{m.label}</span>
|
|
1344
|
+
<span style={{ flex: 1, color: msgColor, fontSize: 11.5, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
|
1345
|
+
{log.message}
|
|
1346
|
+
</span>
|
|
1347
|
+
{!expanded && hasFields && (
|
|
1348
|
+
<span style={{ flexShrink: 0, display: 'flex', gap: 3 }}>
|
|
1349
|
+
{Object.keys(log.fields).slice(0, 3).map(k => (
|
|
1350
|
+
<span key={k} style={{ fontSize: 9.5, color: 'var(--muted)', background: 'var(--surface-2)', border: '1px solid var(--border)', borderRadius: 3, padding: '0 4px' }}>{k}</span>
|
|
1351
|
+
))}
|
|
1352
|
+
{Object.keys(log.fields).length > 3 && <span style={{ fontSize: 9.5, color: 'var(--subtle)' }}>+{Object.keys(log.fields).length - 3}</span>}
|
|
1353
|
+
</span>
|
|
1354
|
+
)}
|
|
1355
|
+
<span style={{ flexShrink: 0, color: 'var(--subtle)', fontSize: 9, transform: expanded ? 'rotate(90deg)' : 'none', transition: 'transform 0.15s' }}>▶</span>
|
|
1356
|
+
</div>
|
|
1357
|
+
|
|
1358
|
+
{/* Expanded detail */}
|
|
1359
|
+
{expanded && (
|
|
1360
|
+
<div style={{ padding: '8px 14px 12px 92px', borderTop: '1px solid var(--border)', background: 'var(--surface-2)' }}>
|
|
1361
|
+
{log.message.includes('\n') && (
|
|
1362
|
+
<pre style={{ margin: '0 0 10px', color: 'var(--text)', fontSize: 11.5, whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>{log.message}</pre>
|
|
1363
|
+
)}
|
|
1364
|
+
{hasFields && (
|
|
1365
|
+
<div style={{ display: 'grid', gridTemplateColumns: 'max-content 1fr', gap: '4px 16px', marginBottom: 8 }}>
|
|
1366
|
+
{Object.entries(log.fields).map(([k, v]) => (
|
|
1367
|
+
<>
|
|
1368
|
+
<span key={`k-${k}`} style={{ color: 'var(--accent)', fontSize: 11, fontWeight: 500 }}>{k}</span>
|
|
1369
|
+
<span key={`v-${k}`} style={{ color: 'var(--muted)', fontSize: 11, wordBreak: 'break-all' }}>{v}</span>
|
|
1370
|
+
</>
|
|
1371
|
+
))}
|
|
1372
|
+
</div>
|
|
1373
|
+
)}
|
|
1374
|
+
{log.raw && (
|
|
1375
|
+
<>
|
|
1376
|
+
<div style={{ fontSize: 10, color: 'var(--subtle)', marginTop: 8, marginBottom: 4 }}>Raw</div>
|
|
1377
|
+
<pre style={{
|
|
1378
|
+
margin: 0, padding: '8px 10px', background: 'var(--surface-2)',
|
|
1379
|
+
border: '1px solid var(--border)', borderRadius: 4,
|
|
1380
|
+
fontSize: 10.5, color: 'var(--muted)', whiteSpace: 'pre-wrap', wordBreak: 'break-all', maxHeight: 180, overflow: 'auto',
|
|
1381
|
+
}}>{log.raw}</pre>
|
|
1382
|
+
</>
|
|
1383
|
+
)}
|
|
1384
|
+
</div>
|
|
1385
|
+
)}
|
|
1386
|
+
</div>
|
|
1387
|
+
);
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
function LogsTab({ agentId, slug }: { agentId: string; slug: string }) {
|
|
1391
|
+
const [lines, setLines] = useState<ParsedLog[]>([]);
|
|
1392
|
+
const [connected, setConnected] = useState(false);
|
|
1393
|
+
const [levelFilter, setLevelFilter] = useState<LogLevel>('all');
|
|
1394
|
+
const [search, setSearch] = useState('');
|
|
1395
|
+
const [autoScroll, setAutoScroll] = useState(true);
|
|
1396
|
+
const bottomRef = useRef<HTMLDivElement>(null);
|
|
1397
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
1398
|
+
|
|
1399
|
+
useEffect(() => {
|
|
1400
|
+
const es = new EventSource(`/api/agents/${agentId}/logs`);
|
|
1401
|
+
setConnected(true);
|
|
1402
|
+
es.onmessage = e => {
|
|
1403
|
+
const raw = JSON.parse(e.data) as string;
|
|
1404
|
+
setLines(prev => [...prev.slice(-1000), parseLine(raw)]);
|
|
1405
|
+
};
|
|
1406
|
+
es.onerror = () => setConnected(false);
|
|
1407
|
+
return () => es.close();
|
|
1408
|
+
}, [agentId]);
|
|
1409
|
+
|
|
1410
|
+
useEffect(() => {
|
|
1411
|
+
if (autoScroll) bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
|
|
1412
|
+
}, [lines, autoScroll]);
|
|
1413
|
+
|
|
1414
|
+
const LEVEL_ORDER: LogLevel[] = ['error', 'warn', 'info', 'debug'];
|
|
1415
|
+
|
|
1416
|
+
const counts = lines.reduce<Record<LogLevel, number>>((acc, l) => {
|
|
1417
|
+
acc[l.level] = (acc[l.level] ?? 0) + 1; return acc;
|
|
1418
|
+
}, { all: lines.length, error: 0, warn: 0, info: 0, debug: 0 });
|
|
1419
|
+
|
|
1420
|
+
const visibleLines = lines.filter(l => {
|
|
1421
|
+
if (levelFilter !== 'all' && l.level !== levelFilter) return false;
|
|
1422
|
+
if (search) {
|
|
1423
|
+
const q = search.toLowerCase();
|
|
1424
|
+
return l.message.toLowerCase().includes(q) ||
|
|
1425
|
+
Object.values(l.fields).some(v => v.toLowerCase().includes(q));
|
|
1426
|
+
}
|
|
1427
|
+
return true;
|
|
1428
|
+
});
|
|
1429
|
+
|
|
1430
|
+
return (
|
|
1431
|
+
<div className="fade-up">
|
|
1432
|
+
{/* Toolbar */}
|
|
1433
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8, flexWrap: 'wrap' }}>
|
|
1434
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 5 }}>
|
|
1435
|
+
<div className={connected ? 'status-running' : ''}
|
|
1436
|
+
style={{ width: 6, height: 6, borderRadius: '50%', background: connected ? '#16a34a' : 'var(--border-2)' }} />
|
|
1437
|
+
<span style={{ fontSize: 11, color: 'var(--muted)' }}>{connected ? 'Live' : 'Disconnected'}</span>
|
|
1438
|
+
</div>
|
|
1439
|
+
<div style={{ width: 1, height: 14, background: 'var(--border)', margin: '0 2px' }} />
|
|
1440
|
+
{/* Level filters with counts */}
|
|
1441
|
+
{(['all', ...LEVEL_ORDER] as LogLevel[]).map(lvl => {
|
|
1442
|
+
const m = LOG_META[lvl];
|
|
1443
|
+
const active = levelFilter === lvl;
|
|
1444
|
+
return (
|
|
1445
|
+
<button key={lvl} onClick={() => setLevelFilter(lvl)} style={{
|
|
1446
|
+
padding: '2px 8px', borderRadius: 4,
|
|
1447
|
+
border: `1px solid ${active ? m.border : 'var(--border)'}`,
|
|
1448
|
+
fontSize: 10.5, fontFamily: 'var(--font-sans)', cursor: 'pointer',
|
|
1449
|
+
background: active ? m.bg : 'transparent',
|
|
1450
|
+
color: active ? m.color : 'var(--muted)',
|
|
1451
|
+
fontWeight: active ? 700 : 400,
|
|
1452
|
+
display: 'flex', alignItems: 'center', gap: 4,
|
|
1453
|
+
}}>
|
|
1454
|
+
{m.label}
|
|
1455
|
+
{counts[lvl] > 0 && <span style={{ fontSize: 9.5, opacity: 0.75 }}>{counts[lvl]}</span>}
|
|
1456
|
+
</button>
|
|
1457
|
+
);
|
|
1458
|
+
})}
|
|
1459
|
+
<input value={search} onChange={e => setSearch(e.target.value)} placeholder="Filter logs…"
|
|
1460
|
+
style={{
|
|
1461
|
+
padding: '3px 10px', borderRadius: 4, border: '1px solid var(--border)',
|
|
1462
|
+
fontSize: 11, fontFamily: 'var(--font-mono)', background: 'transparent',
|
|
1463
|
+
color: 'var(--text)', outline: 'none', width: 180,
|
|
1464
|
+
}} />
|
|
1465
|
+
<button onClick={() => setLines([])} style={{
|
|
1466
|
+
marginLeft: 'auto', background: 'none', border: 'none', cursor: 'pointer',
|
|
1467
|
+
fontSize: 11, color: 'var(--subtle)', fontFamily: 'var(--font-sans)',
|
|
1468
|
+
}}>Clear</button>
|
|
1469
|
+
</div>
|
|
1470
|
+
|
|
1471
|
+
{/* Log pane */}
|
|
1472
|
+
<div ref={containerRef} onScroll={e => {
|
|
1473
|
+
const el = e.currentTarget;
|
|
1474
|
+
setAutoScroll(el.scrollTop + el.clientHeight >= el.scrollHeight - 40);
|
|
1475
|
+
}} style={{
|
|
1476
|
+
background: '#fff', border: '1px solid var(--border)', borderRadius: 8,
|
|
1477
|
+
height: 520, overflow: 'auto', fontFamily: 'var(--font-mono)',
|
|
1478
|
+
}}>
|
|
1479
|
+
{visibleLines.length === 0 ? (
|
|
1480
|
+
<div style={{ padding: '40px 20px', textAlign: 'center', color: 'var(--subtle)', fontSize: 12 }}>
|
|
1481
|
+
{lines.length === 0 ? 'Waiting for log lines…' : 'No matching lines.'}
|
|
1482
|
+
</div>
|
|
1483
|
+
) : (
|
|
1484
|
+
visibleLines.map((log, i) => <LogRow key={i} log={log} />)
|
|
1485
|
+
)}
|
|
1486
|
+
<div ref={bottomRef} />
|
|
1487
|
+
</div>
|
|
1488
|
+
|
|
1489
|
+
<div style={{ display: 'flex', justifyContent: 'space-between', marginTop: 5, padding: '0 2px' }}>
|
|
1490
|
+
<span style={{ fontSize: 10.5, color: 'var(--subtle)' }}>
|
|
1491
|
+
{visibleLines.length}{visibleLines.length !== lines.length ? ` / ${lines.length}` : ''} line{visibleLines.length !== 1 ? 's' : ''}
|
|
1492
|
+
</span>
|
|
1493
|
+
{!autoScroll && (
|
|
1494
|
+
<button onClick={() => { setAutoScroll(true); bottomRef.current?.scrollIntoView({ behavior: 'smooth' }); }}
|
|
1495
|
+
style={{ fontSize: 10.5, color: 'var(--accent)', background: 'none', border: 'none', cursor: 'pointer', fontFamily: 'var(--font-sans)' }}>
|
|
1496
|
+
↓ Jump to latest
|
|
1497
|
+
</button>
|
|
1498
|
+
)}
|
|
1499
|
+
</div>
|
|
1500
|
+
</div>
|
|
1501
|
+
);
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
// ─── Shared UI primitives ─────────────────────────────────────────────────────
|
|
1505
|
+
|
|
1506
|
+
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
|
1507
|
+
return (
|
|
1508
|
+
<div style={{
|
|
1509
|
+
marginBottom: 32, paddingBottom: 28,
|
|
1510
|
+
borderBottom: '1px solid var(--border)',
|
|
1511
|
+
}}>
|
|
1512
|
+
<div style={{ fontSize: 11, fontWeight: 700, color: 'var(--muted)', letterSpacing: '0.08em',
|
|
1513
|
+
textTransform: 'uppercase', marginBottom: 16 }}>
|
|
1514
|
+
{title}
|
|
1515
|
+
</div>
|
|
1516
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>{children}</div>
|
|
1517
|
+
</div>
|
|
1518
|
+
);
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
function Grid2({ children }: { children: React.ReactNode }) {
|
|
1522
|
+
return <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>{children}</div>;
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
function Field({ label, value, onChange, hint, type = 'text', readOnly }: {
|
|
1526
|
+
label: string; value: string; onChange: (v: string) => void;
|
|
1527
|
+
hint?: React.ReactNode; type?: string; readOnly?: boolean;
|
|
1528
|
+
}) {
|
|
1529
|
+
return (
|
|
1530
|
+
<div>
|
|
1531
|
+
<label style={{ display: 'block', fontSize: 12, fontWeight: 500, color: 'var(--muted)', marginBottom: 5 }}>
|
|
1532
|
+
{label}
|
|
1533
|
+
</label>
|
|
1534
|
+
<input
|
|
1535
|
+
type={type} value={value} onChange={e => onChange(e.target.value)} readOnly={readOnly}
|
|
1536
|
+
style={{
|
|
1537
|
+
width: '100%', background: 'var(--surface)', border: '1.5px solid var(--border)',
|
|
1538
|
+
borderRadius: 'var(--radius)', padding: '10px 14px', color: 'var(--text)',
|
|
1539
|
+
fontSize: 14, fontFamily: 'var(--font-sans)', outline: 'none',
|
|
1540
|
+
transition: 'border-color 0.15s',
|
|
1541
|
+
}}
|
|
1542
|
+
onFocus={e => (e.currentTarget.style.borderColor = 'var(--accent)')}
|
|
1543
|
+
onBlur={e => (e.currentTarget.style.borderColor = 'var(--border)')}
|
|
1544
|
+
/>
|
|
1545
|
+
{hint && <p style={{ margin: '5px 0 0', fontSize: 12, color: 'var(--subtle)' }}>{hint}</p>}
|
|
1546
|
+
</div>
|
|
1547
|
+
);
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
function TextArea({ label, value, onChange, hint, rows = 3, readOnly }: {
|
|
1551
|
+
label: string; value: string; onChange: (v: string) => void;
|
|
1552
|
+
hint?: string; rows?: number; readOnly?: boolean;
|
|
1553
|
+
}) {
|
|
1554
|
+
return (
|
|
1555
|
+
<div>
|
|
1556
|
+
<label style={{ display: 'block', fontSize: 12, fontWeight: 500, color: 'var(--muted)', marginBottom: 5 }}>
|
|
1557
|
+
{label}
|
|
1558
|
+
</label>
|
|
1559
|
+
<textarea
|
|
1560
|
+
value={value} onChange={e => onChange(e.target.value)} rows={rows} readOnly={readOnly}
|
|
1561
|
+
style={{
|
|
1562
|
+
width: '100%', background: 'var(--surface)', border: '1.5px solid var(--border)',
|
|
1563
|
+
borderRadius: 'var(--radius)', padding: '10px 14px', color: 'var(--text)',
|
|
1564
|
+
fontSize: 14, fontFamily: 'var(--font-sans)', outline: 'none', resize: 'vertical',
|
|
1565
|
+
transition: 'border-color 0.15s',
|
|
1566
|
+
}}
|
|
1567
|
+
onFocus={e => (e.currentTarget.style.borderColor = 'var(--accent)')}
|
|
1568
|
+
onBlur={e => (e.currentTarget.style.borderColor = 'var(--border)')}
|
|
1569
|
+
/>
|
|
1570
|
+
{hint && <p style={{ margin: '5px 0 0', fontSize: 12, color: 'var(--subtle)' }}>{hint}</p>}
|
|
1571
|
+
</div>
|
|
1572
|
+
);
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
function PrimaryBtn({ children, onClick, loading }: {
|
|
1576
|
+
children: React.ReactNode; onClick?: () => void; loading?: boolean;
|
|
1577
|
+
}) {
|
|
1578
|
+
return (
|
|
1579
|
+
<button onClick={onClick} disabled={loading} style={{
|
|
1580
|
+
background: loading ? 'var(--border)' : 'var(--accent)',
|
|
1581
|
+
color: '#fff', border: 'none', borderRadius: 'var(--radius)',
|
|
1582
|
+
padding: '10px 22px', fontSize: 14, fontWeight: 600,
|
|
1583
|
+
letterSpacing: '-0.01em',
|
|
1584
|
+
cursor: loading ? 'not-allowed' : 'pointer',
|
|
1585
|
+
fontFamily: 'var(--font-sans)',
|
|
1586
|
+
boxShadow: loading ? 'none' : 'var(--shadow-sm)',
|
|
1587
|
+
transition: 'opacity 0.15s, transform 0.15s, box-shadow 0.15s',
|
|
1588
|
+
}}
|
|
1589
|
+
onMouseEnter={e => { if (!loading) { (e.currentTarget as HTMLElement).style.opacity = '0.88'; (e.currentTarget as HTMLElement).style.transform = 'translateY(-1px)'; (e.currentTarget as HTMLElement).style.boxShadow = 'var(--shadow-hover)'; }}}
|
|
1590
|
+
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.opacity = '1'; (e.currentTarget as HTMLElement).style.transform = 'translateY(0)'; (e.currentTarget as HTMLElement).style.boxShadow = 'var(--shadow-sm)'; }}
|
|
1591
|
+
>{loading ? 'Saving…' : children}</button>
|
|
1592
|
+
);
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
function GhostBtn({ children, onClick, loading }: { children: React.ReactNode; onClick?: () => void; loading?: boolean }) {
|
|
1596
|
+
return (
|
|
1597
|
+
<button onClick={onClick} disabled={loading} style={{
|
|
1598
|
+
background: 'transparent', color: 'var(--muted)',
|
|
1599
|
+
border: '1.5px solid var(--border-2)', borderRadius: 'var(--radius)',
|
|
1600
|
+
padding: '10px 20px', fontSize: 14, fontWeight: 500, fontFamily: 'var(--font-sans)',
|
|
1601
|
+
cursor: loading ? 'default' : 'pointer', opacity: loading ? 0.6 : 1,
|
|
1602
|
+
transition: 'border-color 0.15s, color 0.15s',
|
|
1603
|
+
}}
|
|
1604
|
+
onMouseEnter={e => { if (!loading) { (e.currentTarget as HTMLElement).style.borderColor = 'var(--accent)'; (e.currentTarget as HTMLElement).style.color = 'var(--text)'; }}}
|
|
1605
|
+
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.borderColor = 'var(--border-2)'; (e.currentTarget as HTMLElement).style.color = 'var(--muted)'; }}
|
|
1606
|
+
>{loading ? '…' : children}</button>
|
|
1607
|
+
);
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1610
|
+
function InfoRow({ label, value }: { label: string; value: string }) {
|
|
1611
|
+
return (
|
|
1612
|
+
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontSize: 13 }}>
|
|
1613
|
+
<span style={{ color: 'var(--muted)' }}>{label}</span>
|
|
1614
|
+
<span style={{ color: 'var(--text)', fontWeight: 500 }}>{value}</span>
|
|
1615
|
+
</div>
|
|
1616
|
+
);
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1619
|
+
function IconBtn({ children, onClick, title, loading }: { children: React.ReactNode; onClick?: () => void; title?: string; loading?: boolean }) {
|
|
1620
|
+
return (
|
|
1621
|
+
<button
|
|
1622
|
+
onClick={onClick}
|
|
1623
|
+
title={title}
|
|
1624
|
+
disabled={loading}
|
|
1625
|
+
style={{
|
|
1626
|
+
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
1627
|
+
width: 32, height: 32, borderRadius: 8, border: '1px solid var(--border)',
|
|
1628
|
+
background: 'var(--surface)', color: 'var(--muted)',
|
|
1629
|
+
cursor: loading ? 'default' : 'pointer', opacity: loading ? 0.5 : 1,
|
|
1630
|
+
transition: 'all 0.15s',
|
|
1631
|
+
}}
|
|
1632
|
+
onMouseEnter={e => { if (!loading) { const el = e.currentTarget as HTMLElement; el.style.borderColor = 'var(--border-2)'; el.style.color = 'var(--text)'; el.style.background = 'var(--surface-2)'; }}}
|
|
1633
|
+
onMouseLeave={e => { const el = e.currentTarget as HTMLElement; el.style.borderColor = 'var(--border)'; el.style.color = 'var(--muted)'; el.style.background = 'var(--surface)'; }}
|
|
1634
|
+
>
|
|
1635
|
+
{loading ? <span style={{ fontSize: 11 }}>…</span> : children}
|
|
1636
|
+
</button>
|
|
1637
|
+
);
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1640
|
+
function Btn({ children, onClick, color, textColor }: {
|
|
1641
|
+
children: React.ReactNode; onClick?: () => void;
|
|
1642
|
+
color?: string; textColor?: string;
|
|
1643
|
+
}) {
|
|
1644
|
+
return (
|
|
1645
|
+
<button onClick={onClick} style={{
|
|
1646
|
+
background: color ?? 'var(--border)', color: textColor ?? '#fff',
|
|
1647
|
+
border: 'none', borderRadius: 'var(--radius)', padding: '8px 18px',
|
|
1648
|
+
fontSize: 13, fontWeight: 600, cursor: 'pointer',
|
|
1649
|
+
fontFamily: 'var(--font-sans)', transition: 'opacity 0.15s, transform 0.15s',
|
|
1650
|
+
}}
|
|
1651
|
+
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.opacity = '0.85'; (e.currentTarget as HTMLElement).style.transform = 'translateY(-1px)'; }}
|
|
1652
|
+
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.opacity = '1'; (e.currentTarget as HTMLElement).style.transform = 'translateY(0)'; }}
|
|
1653
|
+
>{children}</button>
|
|
1654
|
+
);
|
|
1655
|
+
}
|
|
1656
|
+
|
|
1657
|
+
function Modal({ title, children, onClose }: {
|
|
1658
|
+
title: string; children: React.ReactNode; onClose: () => void;
|
|
1659
|
+
}) {
|
|
1660
|
+
return (
|
|
1661
|
+
<Portal>
|
|
1662
|
+
<div style={{
|
|
1663
|
+
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.6)',
|
|
1664
|
+
display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 9999,
|
|
1665
|
+
backdropFilter: 'blur(4px)',
|
|
1666
|
+
}}>
|
|
1667
|
+
<div style={{
|
|
1668
|
+
background: 'var(--surface)', border: '1px solid var(--border)',
|
|
1669
|
+
borderRadius: 'var(--radius-lg)', padding: '28px', width: 440,
|
|
1670
|
+
boxShadow: 'var(--shadow-modal)',
|
|
1671
|
+
display: 'flex', flexDirection: 'column', gap: 16,
|
|
1672
|
+
maxHeight: '90vh', overflow: 'auto',
|
|
1673
|
+
}}>
|
|
1674
|
+
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
|
1675
|
+
<h3 style={{ margin: 0, fontSize: 15, fontWeight: 600, color: 'var(--text)' }}>{title}</h3>
|
|
1676
|
+
<button onClick={onClose} style={{
|
|
1677
|
+
background: 'none', border: 'none', cursor: 'pointer',
|
|
1678
|
+
color: 'var(--muted)', fontSize: 18, lineHeight: 1, fontFamily: 'var(--font-sans)',
|
|
1679
|
+
}}>×</button>
|
|
1680
|
+
</div>
|
|
1681
|
+
{children}
|
|
1682
|
+
</div>
|
|
1683
|
+
</div>
|
|
1684
|
+
</Portal>
|
|
1685
|
+
);
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1688
|
+
function PageLoader() {
|
|
1689
|
+
return (
|
|
1690
|
+
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100vh', color: 'var(--muted)', fontSize: 13 }}>
|
|
1691
|
+
Loading…
|
|
1692
|
+
</div>
|
|
1693
|
+
);
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1696
|
+
function NotFound({ slug }: { slug: string }) {
|
|
1697
|
+
return (
|
|
1698
|
+
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100vh', gap: 12 }}>
|
|
1699
|
+
<p style={{ color: 'var(--muted)', fontSize: 13 }}>Agent not found: <code style={{ fontFamily: 'var(--font-mono)' }}>{slug}</code></p>
|
|
1700
|
+
<Link href="/" style={{ color: 'var(--accent)', fontSize: 13, textDecoration: 'none' }}>← Back to dashboard</Link>
|
|
1701
|
+
</div>
|
|
1702
|
+
);
|
|
1703
|
+
}
|
|
1704
|
+
|
|
1705
|
+
// ─── History ──────────────────────────────────────────────────────────────────
|
|
1706
|
+
|
|
1707
|
+
// ── Diff panel ───────────────────────────────────────────────────────────────
|
|
1708
|
+
|
|
1709
|
+
function SkillDiff({ snapshot, current }: { snapshot: AgentSnapshot; current: AgentSnapshot | null }) {
|
|
1710
|
+
const snapSkills = snapshot.skillsJson;
|
|
1711
|
+
const currSkills = current ? current.skillsJson : null;
|
|
1712
|
+
|
|
1713
|
+
// Build lookup maps
|
|
1714
|
+
const snapMap = new Map(snapSkills.map(s => [`${s.category}/${s.filename}`, s.content]));
|
|
1715
|
+
const currMap = currSkills ? new Map(currSkills.map(s => [`${s.category}/${s.filename}`, s.content])) : new Map<string, string>();
|
|
1716
|
+
|
|
1717
|
+
const allKeys = new Set([...snapMap.keys(), ...currMap.keys()]);
|
|
1718
|
+
const files: { key: string; status: 'added' | 'removed' | 'modified' | 'same'; diff?: DiffLine[] }[] = [];
|
|
1719
|
+
|
|
1720
|
+
for (const key of allKeys) {
|
|
1721
|
+
const snapContent = snapMap.get(key);
|
|
1722
|
+
const currContent = currMap.get(key);
|
|
1723
|
+
if (snapContent === undefined) {
|
|
1724
|
+
files.push({ key, status: 'added' });
|
|
1725
|
+
} else if (currContent === undefined) {
|
|
1726
|
+
files.push({ key, status: 'removed' });
|
|
1727
|
+
} else if (snapContent !== currContent) {
|
|
1728
|
+
files.push({ key, status: 'modified', diff: lineDiff(snapContent, currContent) });
|
|
1729
|
+
}
|
|
1730
|
+
}
|
|
1731
|
+
|
|
1732
|
+
if (files.length === 0) {
|
|
1733
|
+
return <p style={{ fontSize: 13, color: 'var(--subtle)', margin: 0 }}>No skill changes.</p>;
|
|
1734
|
+
}
|
|
1735
|
+
|
|
1736
|
+
return (
|
|
1737
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
|
1738
|
+
{files.map(f => (
|
|
1739
|
+
<div key={f.key} style={{ border: '1px solid var(--border)', borderRadius: 8, overflow: 'hidden' }}>
|
|
1740
|
+
<div style={{
|
|
1741
|
+
padding: '7px 12px', background: 'var(--surface-2)',
|
|
1742
|
+
borderBottom: '1px solid var(--border)',
|
|
1743
|
+
display: 'flex', alignItems: 'center', gap: 8,
|
|
1744
|
+
}}>
|
|
1745
|
+
<span style={{ fontSize: 11, fontFamily: 'var(--font-mono)', color: 'var(--text)' }}>{f.key}</span>
|
|
1746
|
+
<span style={{
|
|
1747
|
+
fontSize: 10, padding: '1px 6px', borderRadius: 4, fontWeight: 600,
|
|
1748
|
+
background: f.status === 'added' ? 'rgba(22,163,74,0.15)' : f.status === 'removed' ? 'rgba(239,68,68,0.15)' : 'rgba(234,179,8,0.15)',
|
|
1749
|
+
color: f.status === 'added' ? '#16a34a' : f.status === 'removed' ? '#ef4444' : '#ca8a04',
|
|
1750
|
+
}}>{f.status}</span>
|
|
1751
|
+
</div>
|
|
1752
|
+
{f.diff && (
|
|
1753
|
+
<pre style={{
|
|
1754
|
+
margin: 0, padding: '10px 0', fontSize: 11.5, fontFamily: 'var(--font-mono)',
|
|
1755
|
+
lineHeight: 1.6, overflow: 'auto', maxHeight: 320,
|
|
1756
|
+
}}>
|
|
1757
|
+
{f.diff.map((line, i) => (
|
|
1758
|
+
<div key={i} style={{
|
|
1759
|
+
padding: '0 12px',
|
|
1760
|
+
background: line.type === 'add' ? 'rgba(22,163,74,0.1)' : line.type === 'remove' ? 'rgba(239,68,68,0.1)' : 'transparent',
|
|
1761
|
+
color: line.type === 'add' ? '#16a34a' : line.type === 'remove' ? '#ef4444' : 'var(--muted)',
|
|
1762
|
+
}}>
|
|
1763
|
+
{line.type === 'add' ? '+ ' : line.type === 'remove' ? '- ' : ' '}{line.line}
|
|
1764
|
+
</div>
|
|
1765
|
+
))}
|
|
1766
|
+
</pre>
|
|
1767
|
+
)}
|
|
1768
|
+
{f.status === 'added' && <p style={{ margin: '8px 12px', fontSize: 12, color: '#16a34a' }}>File added since this snapshot.</p>}
|
|
1769
|
+
{f.status === 'removed' && <p style={{ margin: '8px 12px', fontSize: 12, color: '#ef4444' }}>File deleted since this snapshot.</p>}
|
|
1770
|
+
</div>
|
|
1771
|
+
))}
|
|
1772
|
+
</div>
|
|
1773
|
+
);
|
|
1774
|
+
}
|
|
1775
|
+
|
|
1776
|
+
function PermsDiff({ snapshot, current }: { snapshot: AgentSnapshot; current: AgentSnapshot | null }) {
|
|
1777
|
+
const currAllowed = new Set(current ? current.allowedTools : []);
|
|
1778
|
+
const currDenied = new Set(current ? current.deniedTools : []);
|
|
1779
|
+
const snapAllowed = new Set(snapshot.allowedTools);
|
|
1780
|
+
const snapDenied = new Set(snapshot.deniedTools);
|
|
1781
|
+
|
|
1782
|
+
const addedAllowed = [...currAllowed].filter(t => !snapAllowed.has(t));
|
|
1783
|
+
const removedAllowed = [...snapAllowed].filter(t => !currAllowed.has(t));
|
|
1784
|
+
const addedDenied = [...currDenied].filter(t => !snapDenied.has(t));
|
|
1785
|
+
const removedDenied = [...snapDenied].filter(t => !currDenied.has(t));
|
|
1786
|
+
|
|
1787
|
+
if (!addedAllowed.length && !removedAllowed.length && !addedDenied.length && !removedDenied.length) {
|
|
1788
|
+
return <p style={{ fontSize: 13, color: 'var(--subtle)', margin: 0 }}>No permission changes.</p>;
|
|
1789
|
+
}
|
|
1790
|
+
const row = (label: string, items: string[], color: string) => items.length > 0 && (
|
|
1791
|
+
<div key={label}>
|
|
1792
|
+
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--muted)', marginBottom: 4 }}>{label}</div>
|
|
1793
|
+
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
|
1794
|
+
{items.map(t => (
|
|
1795
|
+
<span key={t} style={{ fontSize: 11.5, fontFamily: 'var(--font-mono)', padding: '2px 8px', borderRadius: 4, background: `${color}22`, color }}>{t}</span>
|
|
1796
|
+
))}
|
|
1797
|
+
</div>
|
|
1798
|
+
</div>
|
|
1799
|
+
);
|
|
1800
|
+
return (
|
|
1801
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
|
1802
|
+
{row('Allowed tools added', addedAllowed, '#16a34a')}
|
|
1803
|
+
{row('Allowed tools removed', removedAllowed, '#ef4444')}
|
|
1804
|
+
{row('Denied tools added', addedDenied, '#ef4444')}
|
|
1805
|
+
{row('Denied tools removed', removedDenied, '#16a34a')}
|
|
1806
|
+
</div>
|
|
1807
|
+
);
|
|
1808
|
+
}
|
|
1809
|
+
|
|
1810
|
+
function McpsDiff({ snapshot, current, allMcps }: { snapshot: AgentSnapshot; current: AgentSnapshot | null; allMcps: McpServer[] }) {
|
|
1811
|
+
const nameFor = (id: string) => allMcps.find(m => m.id === id)?.name ?? id;
|
|
1812
|
+
const currIds = new Set(current ? current.mcpIds : []);
|
|
1813
|
+
const snapIds = new Set(snapshot.mcpIds);
|
|
1814
|
+
const added = [...currIds].filter(id => !snapIds.has(id));
|
|
1815
|
+
const removed = [...snapIds].filter(id => !currIds.has(id));
|
|
1816
|
+
if (!added.length && !removed.length) return <p style={{ fontSize: 13, color: 'var(--subtle)', margin: 0 }}>No MCP changes.</p>;
|
|
1817
|
+
return (
|
|
1818
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
|
1819
|
+
{added.length > 0 && <div>
|
|
1820
|
+
<div style={{ fontSize: 11, fontWeight: 600, color: '#16a34a', marginBottom: 4 }}>Added</div>
|
|
1821
|
+
{added.map(id => <div key={id} style={{ fontSize: 12.5, color: '#16a34a' }}>+ {nameFor(id)}</div>)}
|
|
1822
|
+
</div>}
|
|
1823
|
+
{removed.length > 0 && <div>
|
|
1824
|
+
<div style={{ fontSize: 11, fontWeight: 600, color: '#ef4444', marginBottom: 4 }}>Removed</div>
|
|
1825
|
+
{removed.map(id => <div key={id} style={{ fontSize: 12.5, color: '#ef4444' }}>- {nameFor(id)}</div>)}
|
|
1826
|
+
</div>}
|
|
1827
|
+
</div>
|
|
1828
|
+
);
|
|
1829
|
+
}
|
|
1830
|
+
|
|
1831
|
+
// ── Trigger badge ─────────────────────────────────────────────────────────────
|
|
1832
|
+
|
|
1833
|
+
const TRIGGER_COLORS: Record<string, { bg: string; color: string }> = {
|
|
1834
|
+
skills: { bg: 'rgba(59,130,246,0.12)', color: '#3b82f6' },
|
|
1835
|
+
permissions: { bg: 'rgba(234,179,8,0.12)', color: '#ca8a04' },
|
|
1836
|
+
mcps: { bg: 'rgba(168,85,247,0.12)', color: '#a855f7' },
|
|
1837
|
+
'claude-md': { bg: 'rgba(236,72,153,0.12)', color: '#ec4899' },
|
|
1838
|
+
manual: { bg: 'rgba(22,163,74,0.12)', color: '#16a34a' },
|
|
1839
|
+
};
|
|
1840
|
+
|
|
1841
|
+
function TriggerBadge({ trigger }: { trigger: string }) {
|
|
1842
|
+
const c = TRIGGER_COLORS[trigger] ?? { bg: 'var(--surface-2)', color: 'var(--muted)' };
|
|
1843
|
+
const label: Record<string, string> = {
|
|
1844
|
+
skills: 'Skills', permissions: 'Tools', mcps: 'MCPs',
|
|
1845
|
+
'claude-md': 'System Prompt', manual: 'Manual', restrictions: 'Channels',
|
|
1846
|
+
};
|
|
1847
|
+
return (
|
|
1848
|
+
<span style={{
|
|
1849
|
+
fontSize: 10.5, fontWeight: 600, padding: '3px 8px', borderRadius: 6,
|
|
1850
|
+
background: c.bg, color: c.color, letterSpacing: '0.03em',
|
|
1851
|
+
}}>{label[trigger] ?? trigger}</span>
|
|
1852
|
+
);
|
|
1853
|
+
}
|
|
1854
|
+
|
|
1855
|
+
// ── Main HistoryTab component ─────────────────────────────────────────────────
|
|
1856
|
+
|
|
1857
|
+
function HistoryTab({ agentId, canEdit }: { agentId: string; canEdit: boolean }) {
|
|
1858
|
+
const [snapshots, setSnapshots] = useState<AgentSnapshot[]>([]);
|
|
1859
|
+
const [loading, setLoading] = useState(true);
|
|
1860
|
+
const [selectedId, setSelectedId] = useState<string | null>(null);
|
|
1861
|
+
const [fullSnapshot, setFullSnapshot] = useState<AgentSnapshot | null>(null);
|
|
1862
|
+
const [compareId, setCompareId] = useState<string>('__current__');
|
|
1863
|
+
const [compareSnapshot, setCompareSnapshot] = useState<AgentSnapshot | null>(null);
|
|
1864
|
+
// Live current state — fetched once and used as the "Current state" comparison target
|
|
1865
|
+
const [liveSnapshot, setLiveSnapshot] = useState<AgentSnapshot | null>(null);
|
|
1866
|
+
const [allMcps, setAllMcps] = useState<McpServer[]>([]);
|
|
1867
|
+
const [restoring, setRestoring] = useState(false);
|
|
1868
|
+
const [loadingDetail, setLoadingDetail] = useState(false);
|
|
1869
|
+
const [msg, setMsg] = useState('');
|
|
1870
|
+
|
|
1871
|
+
// Load snapshot list + MCP catalog (fast path — no live state on mount)
|
|
1872
|
+
useEffect(() => {
|
|
1873
|
+
Promise.all([
|
|
1874
|
+
fetch(`/api/agents/${agentId}/snapshots`).then(r => r.json()),
|
|
1875
|
+
fetch('/api/mcps').then(r => r.json()),
|
|
1876
|
+
]).then(([snaps, mcps]) => {
|
|
1877
|
+
setSnapshots(Array.isArray(snaps) ? snaps : []);
|
|
1878
|
+
setAllMcps(mcps);
|
|
1879
|
+
setLoading(false);
|
|
1880
|
+
});
|
|
1881
|
+
}, [agentId]);
|
|
1882
|
+
|
|
1883
|
+
// Lazy-load live state only when user picks "Compare with current"
|
|
1884
|
+
useEffect(() => {
|
|
1885
|
+
if (compareId !== '__current__' || liveSnapshot) return;
|
|
1886
|
+
Promise.all([
|
|
1887
|
+
fetch(`/api/agents/${agentId}/skills`).then(r => r.json()),
|
|
1888
|
+
fetch(`/api/agents/${agentId}/permissions`).then(r => r.json()),
|
|
1889
|
+
fetch(`/api/agents/${agentId}/mcps`).then(r => r.json()),
|
|
1890
|
+
fetch(`/api/agents/${agentId}/claude-md`).then(r => r.text()),
|
|
1891
|
+
]).then(([skills, perms, agentMcps, claudeMd]) => {
|
|
1892
|
+
setLiveSnapshot({
|
|
1893
|
+
id: '__current__',
|
|
1894
|
+
agentId,
|
|
1895
|
+
trigger: 'manual',
|
|
1896
|
+
createdBy: 'current',
|
|
1897
|
+
skillsJson: skills.map((s: Skill) => ({
|
|
1898
|
+
category: s.category,
|
|
1899
|
+
filename: s.filename,
|
|
1900
|
+
content: s.content,
|
|
1901
|
+
sort_order: s.sortOrder,
|
|
1902
|
+
})),
|
|
1903
|
+
allowedTools: perms?.allowedTools ?? [],
|
|
1904
|
+
deniedTools: perms?.deniedTools ?? [],
|
|
1905
|
+
mcpIds: (agentMcps as McpServer[]).map(m => m.id),
|
|
1906
|
+
compiledMd: claudeMd ?? '',
|
|
1907
|
+
allowedChannels: [],
|
|
1908
|
+
createdAt: new Date(),
|
|
1909
|
+
});
|
|
1910
|
+
});
|
|
1911
|
+
}, [agentId, compareId, liveSnapshot]);
|
|
1912
|
+
|
|
1913
|
+
// Load full snapshot when selected
|
|
1914
|
+
useEffect(() => {
|
|
1915
|
+
if (!selectedId) { setFullSnapshot(null); return; }
|
|
1916
|
+
setFullSnapshot(null);
|
|
1917
|
+
setLoadingDetail(true);
|
|
1918
|
+
fetch(`/api/agents/${agentId}/snapshots/${selectedId}`)
|
|
1919
|
+
.then(r => r.json())
|
|
1920
|
+
.then(snap => { setFullSnapshot(snap); setLoadingDetail(false); })
|
|
1921
|
+
.catch(() => setLoadingDetail(false));
|
|
1922
|
+
}, [agentId, selectedId]);
|
|
1923
|
+
|
|
1924
|
+
// Load compare snapshot when compareId changes
|
|
1925
|
+
useEffect(() => {
|
|
1926
|
+
if (compareId === '__current__') { setCompareSnapshot(null); return; }
|
|
1927
|
+
fetch(`/api/agents/${agentId}/snapshots/${compareId}`)
|
|
1928
|
+
.then(r => r.json())
|
|
1929
|
+
.then(setCompareSnapshot);
|
|
1930
|
+
}, [agentId, compareId]);
|
|
1931
|
+
|
|
1932
|
+
const handleCreateManual = async () => {
|
|
1933
|
+
const label = window.prompt('Snapshot label (optional):') ?? '';
|
|
1934
|
+
const r = await fetch(`/api/agents/${agentId}/snapshots`, {
|
|
1935
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
1936
|
+
body: JSON.stringify({ label: label || null }),
|
|
1937
|
+
});
|
|
1938
|
+
if (r.ok) {
|
|
1939
|
+
const snap = await r.json();
|
|
1940
|
+
setSnapshots(prev => [snap, ...prev]);
|
|
1941
|
+
setMsg('Snapshot created.');
|
|
1942
|
+
}
|
|
1943
|
+
};
|
|
1944
|
+
|
|
1945
|
+
const handleDelete = async (id: string) => {
|
|
1946
|
+
if (!window.confirm('Delete this snapshot?')) return;
|
|
1947
|
+
await fetch(`/api/agents/${agentId}/snapshots/${id}`, { method: 'DELETE' });
|
|
1948
|
+
setSnapshots(prev => prev.filter(s => s.id !== id));
|
|
1949
|
+
if (selectedId === id) { setSelectedId(null); setFullSnapshot(null); }
|
|
1950
|
+
setMsg('Snapshot deleted.');
|
|
1951
|
+
};
|
|
1952
|
+
|
|
1953
|
+
const handleRestore = async (snap: AgentSnapshot) => {
|
|
1954
|
+
if (!window.confirm(`Restore to snapshot from ${new Date(snap.createdAt).toLocaleString()}?\n\nThis will replace current skills, permissions, and MCPs.`)) return;
|
|
1955
|
+
setRestoring(true);
|
|
1956
|
+
const r = await fetch(`/api/agents/${agentId}/snapshots/${snap.id}/restore`, { method: 'POST' });
|
|
1957
|
+
setRestoring(false);
|
|
1958
|
+
if (r.ok) {
|
|
1959
|
+
setMsg('Restored. Agent is reloading.');
|
|
1960
|
+
} else {
|
|
1961
|
+
const err = await r.json();
|
|
1962
|
+
setMsg(`Restore failed: ${err.error}`);
|
|
1963
|
+
}
|
|
1964
|
+
};
|
|
1965
|
+
|
|
1966
|
+
const fmt = (d: Date | string) => {
|
|
1967
|
+
const dt = new Date(d);
|
|
1968
|
+
return `${dt.toLocaleDateString()} ${dt.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`;
|
|
1969
|
+
};
|
|
1970
|
+
|
|
1971
|
+
// Build comparison target: live state or a selected historical snapshot
|
|
1972
|
+
const currentAsSnapshot: AgentSnapshot | null = compareId === '__current__' ? liveSnapshot : compareSnapshot;
|
|
1973
|
+
|
|
1974
|
+
if (loading) return (
|
|
1975
|
+
<div style={{ display: 'flex', gap: 20, minHeight: 500 }}>
|
|
1976
|
+
<div style={{ width: 280, flexShrink: 0, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
|
1977
|
+
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 }}>
|
|
1978
|
+
<div style={{ width: 70, height: 13, borderRadius: 5, background: 'var(--surface-2)' }} />
|
|
1979
|
+
<div style={{ width: 110, height: 30, borderRadius: 8, background: 'var(--surface-2)' }} />
|
|
1980
|
+
</div>
|
|
1981
|
+
{[1, 2, 3, 4].map(i => (
|
|
1982
|
+
<div key={i} style={{
|
|
1983
|
+
background: '#fff', borderRadius: 'var(--radius)', padding: '14px 16px',
|
|
1984
|
+
boxShadow: 'var(--shadow-card)', opacity: 1 - (i - 1) * 0.2,
|
|
1985
|
+
}}>
|
|
1986
|
+
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
|
|
1987
|
+
<div style={{ width: 70, height: 18, borderRadius: 6, background: 'var(--surface-2)' }} />
|
|
1988
|
+
<div style={{ width: 50, height: 11, borderRadius: 4, background: 'var(--surface-2)' }} />
|
|
1989
|
+
</div>
|
|
1990
|
+
<div style={{ width: '55%', height: 11, borderRadius: 4, background: 'var(--surface-2)' }} />
|
|
1991
|
+
</div>
|
|
1992
|
+
))}
|
|
1993
|
+
</div>
|
|
1994
|
+
<div style={{ flex: 1, background: '#fff', borderRadius: 'var(--radius-lg)', boxShadow: 'var(--shadow-card)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
|
1995
|
+
<div style={{ fontSize: 13, color: 'var(--subtle)' }}>Loading history…</div>
|
|
1996
|
+
</div>
|
|
1997
|
+
</div>
|
|
1998
|
+
);
|
|
1999
|
+
|
|
2000
|
+
return (
|
|
2001
|
+
<div style={{ display: 'flex', gap: 20, minHeight: 500, alignItems: 'flex-start' }}>
|
|
2002
|
+
|
|
2003
|
+
{/* ── Left: snapshot list ────────────────────────────────────────────── */}
|
|
2004
|
+
<div style={{ width: 280, flexShrink: 0 }}>
|
|
2005
|
+
{/* Header */}
|
|
2006
|
+
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
|
|
2007
|
+
<span style={{
|
|
2008
|
+
fontSize: 11, fontWeight: 700, letterSpacing: '0.08em',
|
|
2009
|
+
color: 'var(--subtle)', textTransform: 'uppercase',
|
|
2010
|
+
}}>
|
|
2011
|
+
{snapshots.length} snapshot{snapshots.length !== 1 ? 's' : ''}
|
|
2012
|
+
</span>
|
|
2013
|
+
{canEdit && (
|
|
2014
|
+
<button onClick={handleCreateManual} style={{
|
|
2015
|
+
background: 'var(--accent)', color: '#fff', border: 'none',
|
|
2016
|
+
borderRadius: 'var(--radius-sm)', padding: '7px 13px',
|
|
2017
|
+
fontSize: 12, fontWeight: 600, cursor: 'pointer',
|
|
2018
|
+
fontFamily: 'var(--font-sans)', letterSpacing: '-0.01em',
|
|
2019
|
+
boxShadow: 'var(--shadow-sm)', transition: 'opacity 0.15s',
|
|
2020
|
+
}}
|
|
2021
|
+
onMouseEnter={e => (e.currentTarget.style.opacity = '0.85')}
|
|
2022
|
+
onMouseLeave={e => (e.currentTarget.style.opacity = '1')}
|
|
2023
|
+
>+ Snapshot</button>
|
|
2024
|
+
)}
|
|
2025
|
+
</div>
|
|
2026
|
+
|
|
2027
|
+
{msg && (
|
|
2028
|
+
<div style={{
|
|
2029
|
+
fontSize: 12, color: '#16a34a', background: '#f0fdf4',
|
|
2030
|
+
border: '1px solid #bbf7d0', borderRadius: 8,
|
|
2031
|
+
padding: '8px 12px', marginBottom: 10,
|
|
2032
|
+
}}>{msg}</div>
|
|
2033
|
+
)}
|
|
2034
|
+
|
|
2035
|
+
{snapshots.length === 0 ? (
|
|
2036
|
+
<div style={{
|
|
2037
|
+
background: '#fff', borderRadius: 'var(--radius)',
|
|
2038
|
+
boxShadow: 'var(--shadow-card)', padding: '28px 20px',
|
|
2039
|
+
textAlign: 'center',
|
|
2040
|
+
}}>
|
|
2041
|
+
<Camera size={22} style={{ marginBottom: 10, color: 'var(--border-2)' }} />
|
|
2042
|
+
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text)', marginBottom: 6 }}>No snapshots yet</div>
|
|
2043
|
+
<div style={{ fontSize: 12, color: 'var(--muted)', lineHeight: 1.6 }}>
|
|
2044
|
+
Snapshots are saved automatically when you change skills, MCPs, or permissions.
|
|
2045
|
+
</div>
|
|
2046
|
+
</div>
|
|
2047
|
+
) : (
|
|
2048
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
|
2049
|
+
{snapshots.map(snap => {
|
|
2050
|
+
const isSelected = snap.id === selectedId;
|
|
2051
|
+
return (
|
|
2052
|
+
<div
|
|
2053
|
+
key={snap.id}
|
|
2054
|
+
onClick={() => { setSelectedId(isSelected ? null : snap.id); setCompareId('__current__'); setCompareSnapshot(null); }}
|
|
2055
|
+
style={{
|
|
2056
|
+
background: '#fff',
|
|
2057
|
+
borderRadius: 'var(--radius)',
|
|
2058
|
+
boxShadow: isSelected ? '0 0 0 2px var(--accent), var(--shadow-card)' : 'var(--shadow-card)',
|
|
2059
|
+
padding: '13px 15px', cursor: 'pointer',
|
|
2060
|
+
transition: 'box-shadow 0.15s',
|
|
2061
|
+
}}
|
|
2062
|
+
onMouseEnter={e => { if (!isSelected) (e.currentTarget as HTMLElement).style.boxShadow = 'var(--shadow-hover)'; }}
|
|
2063
|
+
onMouseLeave={e => { if (!isSelected) (e.currentTarget as HTMLElement).style.boxShadow = 'var(--shadow-card)'; }}
|
|
2064
|
+
>
|
|
2065
|
+
{/* Top row: badge + author */}
|
|
2066
|
+
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 7 }}>
|
|
2067
|
+
<TriggerBadge trigger={snap.trigger} />
|
|
2068
|
+
<span style={{ fontSize: 11, color: 'var(--subtle)', fontFamily: 'var(--font-mono)' }}>{snap.createdBy}</span>
|
|
2069
|
+
</div>
|
|
2070
|
+
|
|
2071
|
+
{/* Timestamp */}
|
|
2072
|
+
<div style={{ fontSize: 12.5, fontWeight: 500, color: 'var(--text)', marginBottom: snap.label ? 4 : 0 }}>
|
|
2073
|
+
{fmt(snap.createdAt)}
|
|
2074
|
+
</div>
|
|
2075
|
+
|
|
2076
|
+
{/* Optional label */}
|
|
2077
|
+
{snap.label && (
|
|
2078
|
+
<div style={{
|
|
2079
|
+
fontSize: 11.5, color: 'var(--muted)', fontStyle: 'italic',
|
|
2080
|
+
marginTop: 2, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
|
2081
|
+
}}>{snap.label}</div>
|
|
2082
|
+
)}
|
|
2083
|
+
|
|
2084
|
+
{/* Actions — only when selected */}
|
|
2085
|
+
{isSelected && canEdit && (
|
|
2086
|
+
<div style={{ display: 'flex', gap: 7, marginTop: 11, paddingTop: 11, borderTop: '1px solid var(--border)' }} onClick={e => e.stopPropagation()}>
|
|
2087
|
+
<button
|
|
2088
|
+
onClick={() => handleRestore(snap)}
|
|
2089
|
+
disabled={restoring}
|
|
2090
|
+
style={{
|
|
2091
|
+
flex: 1, fontSize: 12, padding: '6px 0', borderRadius: 6, cursor: restoring ? 'not-allowed' : 'pointer',
|
|
2092
|
+
background: '#16a34a', color: '#fff', border: 'none',
|
|
2093
|
+
fontFamily: 'var(--font-sans)', fontWeight: 600, transition: 'opacity 0.15s',
|
|
2094
|
+
}}
|
|
2095
|
+
onMouseEnter={e => { if (!restoring) (e.currentTarget.style.opacity = '0.85'); }}
|
|
2096
|
+
onMouseLeave={e => (e.currentTarget.style.opacity = '1')}
|
|
2097
|
+
>{restoring ? 'Restoring…' : 'Restore'}</button>
|
|
2098
|
+
<button
|
|
2099
|
+
onClick={() => handleDelete(snap.id)}
|
|
2100
|
+
style={{
|
|
2101
|
+
fontSize: 12, padding: '6px 12px', borderRadius: 6, cursor: 'pointer',
|
|
2102
|
+
background: 'transparent', color: 'var(--red)',
|
|
2103
|
+
border: '1.5px solid rgba(220,38,38,0.25)',
|
|
2104
|
+
fontFamily: 'var(--font-sans)', fontWeight: 500, transition: 'all 0.15s',
|
|
2105
|
+
}}
|
|
2106
|
+
onMouseEnter={e => { (e.currentTarget.style.background = 'var(--red)'); (e.currentTarget.style.color = '#fff'); (e.currentTarget.style.borderColor = 'var(--red)'); }}
|
|
2107
|
+
onMouseLeave={e => { (e.currentTarget.style.background = 'transparent'); (e.currentTarget.style.color = 'var(--red)'); (e.currentTarget.style.borderColor = 'rgba(220,38,38,0.25)'); }}
|
|
2108
|
+
>Delete</button>
|
|
2109
|
+
</div>
|
|
2110
|
+
)}
|
|
2111
|
+
</div>
|
|
2112
|
+
);
|
|
2113
|
+
})}
|
|
2114
|
+
</div>
|
|
2115
|
+
)}
|
|
2116
|
+
</div>
|
|
2117
|
+
|
|
2118
|
+
{/* ── Right: diff panel ─────────────────────────────────────────────── */}
|
|
2119
|
+
{loadingDetail ? (
|
|
2120
|
+
<div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', gap: 16 }}>
|
|
2121
|
+
{/* Compare bar skeleton */}
|
|
2122
|
+
<div style={{ background: '#fff', borderRadius: 'var(--radius)', boxShadow: 'var(--shadow-card)', padding: '14px 18px', display: 'flex', alignItems: 'center', gap: 12 }}>
|
|
2123
|
+
<div style={{ width: 90, height: 14, borderRadius: 4, background: 'var(--surface-2)' }} />
|
|
2124
|
+
<div style={{ flex: 1, height: 34, borderRadius: 8, background: 'var(--surface-2)' }} />
|
|
2125
|
+
</div>
|
|
2126
|
+
{/* Section skeletons */}
|
|
2127
|
+
{[120, 80, 60, 200].map((h, i) => (
|
|
2128
|
+
<div key={i} style={{ background: '#fff', borderRadius: 'var(--radius)', boxShadow: 'var(--shadow-card)', overflow: 'hidden', opacity: 1 - i * 0.15 }}>
|
|
2129
|
+
<div style={{ padding: '12px 18px', borderBottom: '1px solid var(--border)' }}>
|
|
2130
|
+
<div style={{ width: 70, height: 11, borderRadius: 4, background: 'var(--surface-2)' }} />
|
|
2131
|
+
</div>
|
|
2132
|
+
<div style={{ padding: '16px 18px' }}>
|
|
2133
|
+
<div style={{ height: h, borderRadius: 6, background: 'var(--surface-2)' }} />
|
|
2134
|
+
</div>
|
|
2135
|
+
</div>
|
|
2136
|
+
))}
|
|
2137
|
+
</div>
|
|
2138
|
+
) : fullSnapshot ? (
|
|
2139
|
+
<div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', gap: 16 }}>
|
|
2140
|
+
|
|
2141
|
+
{/* Compare bar */}
|
|
2142
|
+
<div style={{
|
|
2143
|
+
background: '#fff', borderRadius: 'var(--radius)',
|
|
2144
|
+
boxShadow: 'var(--shadow-card)', padding: '14px 18px',
|
|
2145
|
+
display: 'flex', alignItems: 'center', gap: 12,
|
|
2146
|
+
}}>
|
|
2147
|
+
<span style={{ fontSize: 12, fontWeight: 600, color: 'var(--muted)', letterSpacing: '0.02em', whiteSpace: 'nowrap' }}>
|
|
2148
|
+
Compare with
|
|
2149
|
+
</span>
|
|
2150
|
+
<select
|
|
2151
|
+
value={compareId}
|
|
2152
|
+
onChange={e => setCompareId(e.target.value)}
|
|
2153
|
+
style={{
|
|
2154
|
+
flex: 1, fontSize: 13, padding: '7px 12px', borderRadius: 8,
|
|
2155
|
+
border: '1.5px solid var(--border)', background: 'var(--surface-2)',
|
|
2156
|
+
color: 'var(--text)', fontFamily: 'var(--font-sans)', outline: 'none', cursor: 'pointer',
|
|
2157
|
+
}}
|
|
2158
|
+
onFocus={e => (e.currentTarget.style.borderColor = 'var(--accent)')}
|
|
2159
|
+
onBlur={e => (e.currentTarget.style.borderColor = 'var(--border)')}
|
|
2160
|
+
>
|
|
2161
|
+
<option value="__current__">Current state</option>
|
|
2162
|
+
{snapshots.filter(s => s.id !== selectedId).map(s => (
|
|
2163
|
+
<option key={s.id} value={s.id}>{fmt(s.createdAt)} — {s.trigger}{s.label ? ` · ${s.label}` : ''}</option>
|
|
2164
|
+
))}
|
|
2165
|
+
</select>
|
|
2166
|
+
</div>
|
|
2167
|
+
|
|
2168
|
+
{/* Diff sections — wait for compare target to load */}
|
|
2169
|
+
{!currentAsSnapshot ? (
|
|
2170
|
+
<div style={{
|
|
2171
|
+
background: '#fff', borderRadius: 'var(--radius)', boxShadow: 'var(--shadow-card)',
|
|
2172
|
+
padding: '24px 18px', textAlign: 'center', color: 'var(--subtle)', fontSize: 13,
|
|
2173
|
+
}}>
|
|
2174
|
+
Loading comparison…
|
|
2175
|
+
</div>
|
|
2176
|
+
) : [
|
|
2177
|
+
{ title: 'Skills', content: <SkillDiff snapshot={fullSnapshot} current={currentAsSnapshot} /> },
|
|
2178
|
+
{ title: 'Tools', content: <PermsDiff snapshot={fullSnapshot} current={currentAsSnapshot} /> },
|
|
2179
|
+
{ title: 'MCPs', content: <McpsDiff snapshot={fullSnapshot} current={currentAsSnapshot} allMcps={allMcps} /> },
|
|
2180
|
+
{ title: 'System Prompt', content: (() => {
|
|
2181
|
+
if (!fullSnapshot.compiledMd || !currentAsSnapshot.compiledMd)
|
|
2182
|
+
return <p style={{ fontSize: 12.5, color: 'var(--subtle)', margin: 0 }}>Not available for this snapshot</p>;
|
|
2183
|
+
const diff = lineDiff(fullSnapshot.compiledMd.trim(), currentAsSnapshot.compiledMd.trim());
|
|
2184
|
+
const changed = diff.some(l => l.type !== 'same');
|
|
2185
|
+
if (!changed) return <p style={{ fontSize: 12.5, color: 'var(--subtle)', margin: 0 }}>No changes</p>;
|
|
2186
|
+
return (
|
|
2187
|
+
<pre style={{
|
|
2188
|
+
margin: 0, padding: '14px 16px', borderRadius: 8, fontSize: 12,
|
|
2189
|
+
fontFamily: 'var(--font-mono)', background: 'var(--surface-2)',
|
|
2190
|
+
border: '1px solid var(--border)', overflow: 'auto', maxHeight: 380,
|
|
2191
|
+
color: 'var(--text)', lineHeight: 1.7,
|
|
2192
|
+
}}>
|
|
2193
|
+
{diff.map((l, i) => (
|
|
2194
|
+
<div key={i} style={{
|
|
2195
|
+
background: l.type === 'add' ? 'rgba(34,197,94,0.12)' : l.type === 'remove' ? 'rgba(239,68,68,0.10)' : 'transparent',
|
|
2196
|
+
color: l.type === 'add' ? '#16a34a' : l.type === 'remove' ? '#dc2626' : 'inherit',
|
|
2197
|
+
padding: '1px 6px', borderRadius: 3, marginBottom: 1,
|
|
2198
|
+
}}>
|
|
2199
|
+
{l.type === 'add' ? '+ ' : l.type === 'remove' ? '- ' : ' '}{l.line}
|
|
2200
|
+
</div>
|
|
2201
|
+
))}
|
|
2202
|
+
</pre>
|
|
2203
|
+
);
|
|
2204
|
+
})(),
|
|
2205
|
+
},
|
|
2206
|
+
].map(({ title, content }) => (
|
|
2207
|
+
<div key={title} style={{
|
|
2208
|
+
background: '#fff', borderRadius: 'var(--radius)',
|
|
2209
|
+
boxShadow: 'var(--shadow-card)', overflow: 'hidden',
|
|
2210
|
+
}}>
|
|
2211
|
+
<div style={{
|
|
2212
|
+
padding: '12px 18px', borderBottom: '1px solid var(--border)',
|
|
2213
|
+
fontSize: 11, fontWeight: 700, letterSpacing: '0.08em',
|
|
2214
|
+
color: 'var(--muted)', textTransform: 'uppercase',
|
|
2215
|
+
}}>{title}</div>
|
|
2216
|
+
<div style={{ padding: '16px 18px' }}>{content}</div>
|
|
2217
|
+
</div>
|
|
2218
|
+
))}
|
|
2219
|
+
</div>
|
|
2220
|
+
) : (
|
|
2221
|
+
<div style={{
|
|
2222
|
+
flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
|
|
2223
|
+
background: '#fff', borderRadius: 'var(--radius-lg)',
|
|
2224
|
+
boxShadow: 'var(--shadow-card)', gap: 10, padding: 40,
|
|
2225
|
+
}}>
|
|
2226
|
+
<History size={32} style={{ color: 'var(--border-2)' }} />
|
|
2227
|
+
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--text)' }}>Select a snapshot</div>
|
|
2228
|
+
<div style={{ fontSize: 13, color: 'var(--muted)', textAlign: 'center', maxWidth: 260, lineHeight: 1.6 }}>
|
|
2229
|
+
Click any snapshot on the left to view what changed at that point in time.
|
|
2230
|
+
</div>
|
|
2231
|
+
</div>
|
|
2232
|
+
)}
|
|
2233
|
+
</div>
|
|
2234
|
+
);
|
|
2235
|
+
}
|