Browse Source

feat: add web frontend

Jax Docker 5 months ago
parent
commit
f2a388c869
100 changed files with 11706 additions and 0 deletions
  1. 2 0
      web/.env.example
  2. 7 0
      web/.gitignore
  3. 30 0
      web/index.html
  4. 3743 0
      web/package-lock.json
  5. 36 0
      web/package.json
  6. 6 0
      web/postcss.config.js
  7. 5 0
      web/public/favicon.svg
  8. 87 0
      web/src/App.tsx
  9. 40 0
      web/src/api/agents.ts
  10. 24 0
      web/src/api/api-keys.ts
  11. 17 0
      web/src/api/auth.ts
  12. 40 0
      web/src/api/client.ts
  13. 21 0
      web/src/api/health.ts
  14. 11 0
      web/src/api/index.ts
  15. 89 0
      web/src/api/knowledge.ts
  16. 954 0
      web/src/api/mock.ts
  17. 24 0
      web/src/api/runtime.ts
  18. 43 0
      web/src/api/sessions.ts
  19. 67 0
      web/src/api/teams.ts
  20. 72 0
      web/src/api/tools.ts
  21. 88 0
      web/src/api/workflows.ts
  22. 9 0
      web/src/components/auth/ProtectedRoute.tsx
  23. 21 0
      web/src/components/layout/AppLayout.tsx
  24. 25 0
      web/src/components/layout/Breadcrumb.tsx
  25. 59 0
      web/src/components/layout/Header.tsx
  26. 89 0
      web/src/components/layout/Sidebar.tsx
  27. 21 0
      web/src/components/shared/ApiErrorState.tsx
  28. 35 0
      web/src/components/shared/ConfirmDialog.tsx
  29. 29 0
      web/src/components/shared/EmptyState.tsx
  30. 33 0
      web/src/components/shared/EntityListItem.tsx
  31. 25 0
      web/src/components/shared/ErrorBoundary.tsx
  32. 18 0
      web/src/components/shared/JsonViewer.tsx
  33. 8 0
      web/src/components/shared/LoadingSpinner.tsx
  34. 26 0
      web/src/components/shared/MetricCard.tsx
  35. 21 0
      web/src/components/shared/PageHeader.tsx
  36. 11 0
      web/src/components/shared/RelativeTime.tsx
  37. 25 0
      web/src/components/shared/SearchInput.tsx
  38. 13 0
      web/src/components/shared/StatusBadge.tsx
  39. 11 0
      web/src/components/ui/badge.tsx
  40. 29 0
      web/src/components/ui/button.tsx
  41. 22 0
      web/src/components/ui/card.tsx
  42. 41 0
      web/src/components/ui/dialog.tsx
  43. 41 0
      web/src/components/ui/dropdown-menu.tsx
  44. 30 0
      web/src/components/ui/input.tsx
  45. 6 0
      web/src/components/ui/scroll-area.tsx
  46. 24 0
      web/src/components/ui/select.tsx
  47. 5 0
      web/src/components/ui/separator.tsx
  48. 3 0
      web/src/components/ui/sheet.tsx
  49. 5 0
      web/src/components/ui/skeleton.tsx
  50. 33 0
      web/src/components/ui/tabs.tsx
  51. 54 0
      web/src/components/ui/toaster.tsx
  52. 5 0
      web/src/components/ui/tooltip.tsx
  53. 8 0
      web/src/hooks/index.ts
  54. 8 0
      web/src/hooks/useAgents.ts
  55. 28 0
      web/src/hooks/useApi.ts
  56. 5 0
      web/src/hooks/useAuth.ts
  57. 10 0
      web/src/hooks/useDebounce.ts
  58. 13 0
      web/src/hooks/useInterval.ts
  59. 6 0
      web/src/hooks/useRuntime.ts
  60. 6 0
      web/src/hooks/useSessions.ts
  61. 7 0
      web/src/hooks/useWorkflows.ts
  62. 109 0
      web/src/index.css
  63. 57 0
      web/src/lib/constants.ts
  64. 51 0
      web/src/lib/utils.ts
  65. 21 0
      web/src/main.tsx
  66. 592 0
      web/src/pages/agents/AgentListPage.tsx
  67. 30 0
      web/src/pages/agents/components/AgentCard.tsx
  68. 61 0
      web/src/pages/agents/components/AgentDetailSheet.tsx
  69. 130 0
      web/src/pages/agents/components/CreateAgentDialog.tsx
  70. 75 0
      web/src/pages/dashboard/DashboardPage.tsx
  71. 40 0
      web/src/pages/dashboard/components/ExecutionTrendChart.tsx
  72. 45 0
      web/src/pages/dashboard/components/RecentRunsTable.tsx
  73. 23 0
      web/src/pages/dashboard/components/ServiceHealthList.tsx
  74. 36 0
      web/src/pages/dashboard/components/StatsCards.tsx
  75. 1529 0
      web/src/pages/knowledge/KnowledgePage.tsx
  76. 100 0
      web/src/pages/login/LoginPage.tsx
  77. 12 0
      web/src/pages/placeholders/PlaceholderPage.tsx
  78. 71 0
      web/src/pages/sessions/SessionChatPage.tsx
  79. 30 0
      web/src/pages/sessions/components/ChatInput.tsx
  80. 32 0
      web/src/pages/sessions/components/ChatPanel.tsx
  81. 69 0
      web/src/pages/sessions/components/CreateSessionDialog.tsx
  82. 21 0
      web/src/pages/sessions/components/MessageBubble.tsx
  83. 56 0
      web/src/pages/sessions/components/SessionListPanel.tsx
  84. 182 0
      web/src/pages/settings/SettingsPage.tsx
  85. 965 0
      web/src/pages/teams/TeamsPage.tsx
  86. 607 0
      web/src/pages/tools/ToolsPage.tsx
  87. 156 0
      web/src/pages/workflow-editor/WorkflowEditorPage.tsx
  88. 46 0
      web/src/pages/workflow-editor/components/EditorToolbar.tsx
  89. 79 0
      web/src/pages/workflow-editor/components/FlowCanvas.tsx
  90. 26 0
      web/src/pages/workflow-editor/components/NodePanel.tsx
  91. 64 0
      web/src/pages/workflow-editor/components/PropertiesPanel.tsx
  92. 34 0
      web/src/pages/workflow-editor/components/ValidationReport.tsx
  93. 1 0
      web/src/pages/workflow-editor/nodes/AnswerNode.tsx
  94. 1 0
      web/src/pages/workflow-editor/nodes/ConditionNode.tsx
  95. 29 0
      web/src/pages/workflow-editor/nodes/DefaultNode.tsx
  96. 1 0
      web/src/pages/workflow-editor/nodes/LLMNode.tsx
  97. 1 0
      web/src/pages/workflow-editor/nodes/StartNode.tsx
  98. 1 0
      web/src/pages/workflow-editor/nodes/ToolNode.tsx
  99. 16 0
      web/src/pages/workflow-editor/nodes/node-types.ts
  100. 33 0
      web/src/pages/workflow-editor/utils/dsl-to-flow.ts

+ 2 - 0
web/.env.example

@@ -0,0 +1,2 @@
+VITE_API_BASE_URL=http://127.0.0.1:8000
+VITE_USE_MOCKS=true

+ 7 - 0
web/.gitignore

@@ -0,0 +1,7 @@
+node_modules/
+dist/
+*.tsbuildinfo
+test-results/
+.env
+.env.local
+.env.*.local

+ 30 - 0
web/index.html

@@ -0,0 +1,30 @@
+<!doctype html>
+<html lang="en">
+  <head>
+    <meta charset="UTF-8" />
+    <script>
+      (() => {
+        try {
+          const raw = localStorage.getItem("auto-platform-ui");
+          const theme = raw ? JSON.parse(raw)?.state?.theme : "dark";
+          document.documentElement.classList.toggle("dark", (theme || "dark") === "dark");
+        } catch {
+          document.documentElement.classList.add("dark");
+        }
+      })();
+    </script>
+    <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <title>Auto Platform</title>
+    <link rel="preconnect" href="https://fonts.googleapis.com" />
+    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
+    <link
+      href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap"
+      rel="stylesheet"
+    />
+  </head>
+  <body class="bg-surface-base text-foreground antialiased">
+    <div id="root"></div>
+    <script type="module" src="/src/main.tsx"></script>
+  </body>
+</html>

+ 3743 - 0
web/package-lock.json

@@ -0,0 +1,3743 @@
+{
+  "name": "auto-platform-web",
+  "version": "0.1.0",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "auto-platform-web",
+      "version": "0.1.0",
+      "dependencies": {
+        "@xyflow/react": "^12.6.4",
+        "axios": "^1.9.0",
+        "clsx": "^2.1.1",
+        "dagre": "^0.8.5",
+        "lucide-react": "^0.510.0",
+        "react": "^18.3.1",
+        "react-dom": "^18.3.1",
+        "react-router-dom": "^6.28.0",
+        "recharts": "^2.15.3",
+        "tailwind-merge": "^2.6.0",
+        "zustand": "^5.0.5"
+      },
+      "devDependencies": {
+        "@types/dagre": "^0.7.52",
+        "@types/node": "^25.6.0",
+        "@types/react": "^18.3.12",
+        "@types/react-dom": "^18.3.1",
+        "@vitejs/plugin-react": "^4.4.1",
+        "autoprefixer": "^10.4.21",
+        "postcss": "^8.5.3",
+        "tailwindcss": "^3.4.17",
+        "typescript": "^5.7.3",
+        "vite": "^6.3.5"
+      }
+    },
+    "node_modules/@alloc/quick-lru": {
+      "version": "5.2.0",
+      "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
+      "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/@babel/code-frame": {
+      "version": "7.29.0",
+      "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
+      "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-validator-identifier": "^7.28.5",
+        "js-tokens": "^4.0.0",
+        "picocolors": "^1.1.1"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/compat-data": {
+      "version": "7.29.0",
+      "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz",
+      "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/core": {
+      "version": "7.29.0",
+      "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
+      "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/code-frame": "^7.29.0",
+        "@babel/generator": "^7.29.0",
+        "@babel/helper-compilation-targets": "^7.28.6",
+        "@babel/helper-module-transforms": "^7.28.6",
+        "@babel/helpers": "^7.28.6",
+        "@babel/parser": "^7.29.0",
+        "@babel/template": "^7.28.6",
+        "@babel/traverse": "^7.29.0",
+        "@babel/types": "^7.29.0",
+        "@jridgewell/remapping": "^2.3.5",
+        "convert-source-map": "^2.0.0",
+        "debug": "^4.1.0",
+        "gensync": "^1.0.0-beta.2",
+        "json5": "^2.2.3",
+        "semver": "^6.3.1"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/babel"
+      }
+    },
+    "node_modules/@babel/generator": {
+      "version": "7.29.1",
+      "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz",
+      "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/parser": "^7.29.0",
+        "@babel/types": "^7.29.0",
+        "@jridgewell/gen-mapping": "^0.3.12",
+        "@jridgewell/trace-mapping": "^0.3.28",
+        "jsesc": "^3.0.2"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-compilation-targets": {
+      "version": "7.28.6",
+      "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz",
+      "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/compat-data": "^7.28.6",
+        "@babel/helper-validator-option": "^7.27.1",
+        "browserslist": "^4.24.0",
+        "lru-cache": "^5.1.1",
+        "semver": "^6.3.1"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-globals": {
+      "version": "7.28.0",
+      "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
+      "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-module-imports": {
+      "version": "7.28.6",
+      "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz",
+      "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/traverse": "^7.28.6",
+        "@babel/types": "^7.28.6"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-module-transforms": {
+      "version": "7.28.6",
+      "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz",
+      "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-module-imports": "^7.28.6",
+        "@babel/helper-validator-identifier": "^7.28.5",
+        "@babel/traverse": "^7.28.6"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0"
+      }
+    },
+    "node_modules/@babel/helper-plugin-utils": {
+      "version": "7.28.6",
+      "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz",
+      "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-string-parser": {
+      "version": "7.27.1",
+      "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
+      "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-validator-identifier": {
+      "version": "7.28.5",
+      "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
+      "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-validator-option": {
+      "version": "7.27.1",
+      "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz",
+      "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helpers": {
+      "version": "7.29.2",
+      "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz",
+      "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/template": "^7.28.6",
+        "@babel/types": "^7.29.0"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/parser": {
+      "version": "7.29.2",
+      "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz",
+      "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/types": "^7.29.0"
+      },
+      "bin": {
+        "parser": "bin/babel-parser.js"
+      },
+      "engines": {
+        "node": ">=6.0.0"
+      }
+    },
+    "node_modules/@babel/plugin-transform-react-jsx-self": {
+      "version": "7.27.1",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz",
+      "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.27.1"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-transform-react-jsx-source": {
+      "version": "7.27.1",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz",
+      "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.27.1"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/runtime": {
+      "version": "7.29.2",
+      "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
+      "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/template": {
+      "version": "7.28.6",
+      "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
+      "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/code-frame": "^7.28.6",
+        "@babel/parser": "^7.28.6",
+        "@babel/types": "^7.28.6"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/traverse": {
+      "version": "7.29.0",
+      "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz",
+      "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/code-frame": "^7.29.0",
+        "@babel/generator": "^7.29.0",
+        "@babel/helper-globals": "^7.28.0",
+        "@babel/parser": "^7.29.0",
+        "@babel/template": "^7.28.6",
+        "@babel/types": "^7.29.0",
+        "debug": "^4.3.1"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/types": {
+      "version": "7.29.0",
+      "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
+      "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-string-parser": "^7.27.1",
+        "@babel/helper-validator-identifier": "^7.28.5"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@esbuild/aix-ppc64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
+      "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "aix"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/android-arm": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
+      "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/android-arm64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
+      "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/android-x64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
+      "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/darwin-arm64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
+      "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/darwin-x64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
+      "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/freebsd-arm64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
+      "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/freebsd-x64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
+      "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-arm": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
+      "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-arm64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
+      "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-ia32": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
+      "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-loong64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
+      "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
+      "cpu": [
+        "loong64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-mips64el": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
+      "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
+      "cpu": [
+        "mips64el"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-ppc64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
+      "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-riscv64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
+      "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-s390x": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
+      "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
+      "cpu": [
+        "s390x"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-x64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
+      "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/netbsd-arm64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
+      "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "netbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/netbsd-x64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
+      "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "netbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/openbsd-arm64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
+      "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/openbsd-x64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
+      "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/openharmony-arm64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
+      "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openharmony"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/sunos-x64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
+      "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "sunos"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/win32-arm64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
+      "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/win32-ia32": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
+      "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/win32-x64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
+      "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@jridgewell/gen-mapping": {
+      "version": "0.3.13",
+      "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+      "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@jridgewell/sourcemap-codec": "^1.5.0",
+        "@jridgewell/trace-mapping": "^0.3.24"
+      }
+    },
+    "node_modules/@jridgewell/remapping": {
+      "version": "2.3.5",
+      "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
+      "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@jridgewell/gen-mapping": "^0.3.5",
+        "@jridgewell/trace-mapping": "^0.3.24"
+      }
+    },
+    "node_modules/@jridgewell/resolve-uri": {
+      "version": "3.1.2",
+      "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+      "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.0.0"
+      }
+    },
+    "node_modules/@jridgewell/sourcemap-codec": {
+      "version": "1.5.5",
+      "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+      "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@jridgewell/trace-mapping": {
+      "version": "0.3.31",
+      "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+      "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@jridgewell/resolve-uri": "^3.1.0",
+        "@jridgewell/sourcemap-codec": "^1.4.14"
+      }
+    },
+    "node_modules/@nodelib/fs.scandir": {
+      "version": "2.1.5",
+      "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
+      "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@nodelib/fs.stat": "2.0.5",
+        "run-parallel": "^1.1.9"
+      },
+      "engines": {
+        "node": ">= 8"
+      }
+    },
+    "node_modules/@nodelib/fs.stat": {
+      "version": "2.0.5",
+      "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
+      "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 8"
+      }
+    },
+    "node_modules/@nodelib/fs.walk": {
+      "version": "1.2.8",
+      "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
+      "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@nodelib/fs.scandir": "2.1.5",
+        "fastq": "^1.6.0"
+      },
+      "engines": {
+        "node": ">= 8"
+      }
+    },
+    "node_modules/@remix-run/router": {
+      "version": "1.23.2",
+      "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz",
+      "integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=14.0.0"
+      }
+    },
+    "node_modules/@rolldown/pluginutils": {
+      "version": "1.0.0-beta.27",
+      "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
+      "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@rollup/rollup-android-arm-eabi": {
+      "version": "4.60.2",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.2.tgz",
+      "integrity": "sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ]
+    },
+    "node_modules/@rollup/rollup-android-arm64": {
+      "version": "4.60.2",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.2.tgz",
+      "integrity": "sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ]
+    },
+    "node_modules/@rollup/rollup-darwin-arm64": {
+      "version": "4.60.2",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.2.tgz",
+      "integrity": "sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ]
+    },
+    "node_modules/@rollup/rollup-darwin-x64": {
+      "version": "4.60.2",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.2.tgz",
+      "integrity": "sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ]
+    },
+    "node_modules/@rollup/rollup-freebsd-arm64": {
+      "version": "4.60.2",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.2.tgz",
+      "integrity": "sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ]
+    },
+    "node_modules/@rollup/rollup-freebsd-x64": {
+      "version": "4.60.2",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.2.tgz",
+      "integrity": "sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+      "version": "4.60.2",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.2.tgz",
+      "integrity": "sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "libc": [
+        "glibc"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+      "version": "4.60.2",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.2.tgz",
+      "integrity": "sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "libc": [
+        "musl"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm64-gnu": {
+      "version": "4.60.2",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.2.tgz",
+      "integrity": "sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "libc": [
+        "glibc"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm64-musl": {
+      "version": "4.60.2",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.2.tgz",
+      "integrity": "sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "libc": [
+        "musl"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-loong64-gnu": {
+      "version": "4.60.2",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.2.tgz",
+      "integrity": "sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==",
+      "cpu": [
+        "loong64"
+      ],
+      "dev": true,
+      "libc": [
+        "glibc"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-loong64-musl": {
+      "version": "4.60.2",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.2.tgz",
+      "integrity": "sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==",
+      "cpu": [
+        "loong64"
+      ],
+      "dev": true,
+      "libc": [
+        "musl"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+      "version": "4.60.2",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.2.tgz",
+      "integrity": "sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "libc": [
+        "glibc"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-ppc64-musl": {
+      "version": "4.60.2",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.2.tgz",
+      "integrity": "sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "libc": [
+        "musl"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+      "version": "4.60.2",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.2.tgz",
+      "integrity": "sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "libc": [
+        "glibc"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-riscv64-musl": {
+      "version": "4.60.2",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.2.tgz",
+      "integrity": "sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "libc": [
+        "musl"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-s390x-gnu": {
+      "version": "4.60.2",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.2.tgz",
+      "integrity": "sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==",
+      "cpu": [
+        "s390x"
+      ],
+      "dev": true,
+      "libc": [
+        "glibc"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-x64-gnu": {
+      "version": "4.60.2",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.2.tgz",
+      "integrity": "sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "libc": [
+        "glibc"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-x64-musl": {
+      "version": "4.60.2",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.2.tgz",
+      "integrity": "sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "libc": [
+        "musl"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-openbsd-x64": {
+      "version": "4.60.2",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.2.tgz",
+      "integrity": "sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openbsd"
+      ]
+    },
+    "node_modules/@rollup/rollup-openharmony-arm64": {
+      "version": "4.60.2",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.2.tgz",
+      "integrity": "sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openharmony"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-arm64-msvc": {
+      "version": "4.60.2",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.2.tgz",
+      "integrity": "sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-ia32-msvc": {
+      "version": "4.60.2",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.2.tgz",
+      "integrity": "sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-x64-gnu": {
+      "version": "4.60.2",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.2.tgz",
+      "integrity": "sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-x64-msvc": {
+      "version": "4.60.2",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.2.tgz",
+      "integrity": "sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@types/babel__core": {
+      "version": "7.20.5",
+      "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
+      "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/parser": "^7.20.7",
+        "@babel/types": "^7.20.7",
+        "@types/babel__generator": "*",
+        "@types/babel__template": "*",
+        "@types/babel__traverse": "*"
+      }
+    },
+    "node_modules/@types/babel__generator": {
+      "version": "7.27.0",
+      "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz",
+      "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/types": "^7.0.0"
+      }
+    },
+    "node_modules/@types/babel__template": {
+      "version": "7.4.4",
+      "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz",
+      "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/parser": "^7.1.0",
+        "@babel/types": "^7.0.0"
+      }
+    },
+    "node_modules/@types/babel__traverse": {
+      "version": "7.28.0",
+      "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz",
+      "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/types": "^7.28.2"
+      }
+    },
+    "node_modules/@types/d3-array": {
+      "version": "3.2.2",
+      "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
+      "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
+      "license": "MIT"
+    },
+    "node_modules/@types/d3-color": {
+      "version": "3.1.3",
+      "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
+      "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
+      "license": "MIT"
+    },
+    "node_modules/@types/d3-drag": {
+      "version": "3.0.7",
+      "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz",
+      "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/d3-selection": "*"
+      }
+    },
+    "node_modules/@types/d3-ease": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
+      "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
+      "license": "MIT"
+    },
+    "node_modules/@types/d3-interpolate": {
+      "version": "3.0.4",
+      "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
+      "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/d3-color": "*"
+      }
+    },
+    "node_modules/@types/d3-path": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
+      "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
+      "license": "MIT"
+    },
+    "node_modules/@types/d3-scale": {
+      "version": "4.0.9",
+      "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
+      "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/d3-time": "*"
+      }
+    },
+    "node_modules/@types/d3-selection": {
+      "version": "3.0.11",
+      "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz",
+      "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==",
+      "license": "MIT"
+    },
+    "node_modules/@types/d3-shape": {
+      "version": "3.1.8",
+      "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
+      "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/d3-path": "*"
+      }
+    },
+    "node_modules/@types/d3-time": {
+      "version": "3.0.4",
+      "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
+      "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
+      "license": "MIT"
+    },
+    "node_modules/@types/d3-timer": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
+      "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
+      "license": "MIT"
+    },
+    "node_modules/@types/d3-transition": {
+      "version": "3.0.9",
+      "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz",
+      "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/d3-selection": "*"
+      }
+    },
+    "node_modules/@types/d3-zoom": {
+      "version": "3.0.8",
+      "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz",
+      "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/d3-interpolate": "*",
+        "@types/d3-selection": "*"
+      }
+    },
+    "node_modules/@types/dagre": {
+      "version": "0.7.54",
+      "resolved": "https://registry.npmjs.org/@types/dagre/-/dagre-0.7.54.tgz",
+      "integrity": "sha512-QjcRY+adGbYvBFS7cwv5txhVIwX1XXIUswWl+kSQTbI6NjgZydrZkEKX/etzVd7i+bCsCb40Z/xlBY5eoFuvWQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/estree": {
+      "version": "1.0.8",
+      "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
+      "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/node": {
+      "version": "25.6.0",
+      "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz",
+      "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "undici-types": "~7.19.0"
+      }
+    },
+    "node_modules/@types/prop-types": {
+      "version": "15.7.15",
+      "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
+      "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
+      "devOptional": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/react": {
+      "version": "18.3.28",
+      "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz",
+      "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==",
+      "devOptional": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/prop-types": "*",
+        "csstype": "^3.2.2"
+      }
+    },
+    "node_modules/@types/react-dom": {
+      "version": "18.3.7",
+      "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
+      "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
+      "dev": true,
+      "license": "MIT",
+      "peerDependencies": {
+        "@types/react": "^18.0.0"
+      }
+    },
+    "node_modules/@vitejs/plugin-react": {
+      "version": "4.7.0",
+      "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
+      "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/core": "^7.28.0",
+        "@babel/plugin-transform-react-jsx-self": "^7.27.1",
+        "@babel/plugin-transform-react-jsx-source": "^7.27.1",
+        "@rolldown/pluginutils": "1.0.0-beta.27",
+        "@types/babel__core": "^7.20.5",
+        "react-refresh": "^0.17.0"
+      },
+      "engines": {
+        "node": "^14.18.0 || >=16.0.0"
+      },
+      "peerDependencies": {
+        "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
+      }
+    },
+    "node_modules/@xyflow/react": {
+      "version": "12.10.2",
+      "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.10.2.tgz",
+      "integrity": "sha512-CgIi6HwlcHXwlkTpr0fxLv/0sRVNZ8IdwKLzzeCscaYBwpvfcH1QFOCeaTCuEn1FQEs/B8CjnTSjhs8udgmBgQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@xyflow/system": "0.0.76",
+        "classcat": "^5.0.3",
+        "zustand": "^4.4.0"
+      },
+      "peerDependencies": {
+        "react": ">=17",
+        "react-dom": ">=17"
+      }
+    },
+    "node_modules/@xyflow/react/node_modules/zustand": {
+      "version": "4.5.7",
+      "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
+      "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==",
+      "license": "MIT",
+      "dependencies": {
+        "use-sync-external-store": "^1.2.2"
+      },
+      "engines": {
+        "node": ">=12.7.0"
+      },
+      "peerDependencies": {
+        "@types/react": ">=16.8",
+        "immer": ">=9.0.6",
+        "react": ">=16.8"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        },
+        "immer": {
+          "optional": true
+        },
+        "react": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@xyflow/system": {
+      "version": "0.0.76",
+      "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.76.tgz",
+      "integrity": "sha512-hvwvnRS1B3REwVDlWexsq7YQaPZeG3/mKo1jv38UmnpWmxihp14bW6VtEOuHEwJX2FvzFw8k77LyKSk/wiZVNA==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/d3-drag": "^3.0.7",
+        "@types/d3-interpolate": "^3.0.4",
+        "@types/d3-selection": "^3.0.10",
+        "@types/d3-transition": "^3.0.8",
+        "@types/d3-zoom": "^3.0.8",
+        "d3-drag": "^3.0.0",
+        "d3-interpolate": "^3.0.1",
+        "d3-selection": "^3.0.0",
+        "d3-zoom": "^3.0.0"
+      }
+    },
+    "node_modules/any-promise": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
+      "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/anymatch": {
+      "version": "3.1.3",
+      "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
+      "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "normalize-path": "^3.0.0",
+        "picomatch": "^2.0.4"
+      },
+      "engines": {
+        "node": ">= 8"
+      }
+    },
+    "node_modules/arg": {
+      "version": "5.0.2",
+      "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
+      "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/asynckit": {
+      "version": "0.4.0",
+      "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
+      "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
+      "license": "MIT"
+    },
+    "node_modules/autoprefixer": {
+      "version": "10.5.0",
+      "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz",
+      "integrity": "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/postcss/"
+        },
+        {
+          "type": "tidelift",
+          "url": "https://tidelift.com/funding/github/npm/autoprefixer"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "browserslist": "^4.28.2",
+        "caniuse-lite": "^1.0.30001787",
+        "fraction.js": "^5.3.4",
+        "picocolors": "^1.1.1",
+        "postcss-value-parser": "^4.2.0"
+      },
+      "bin": {
+        "autoprefixer": "bin/autoprefixer"
+      },
+      "engines": {
+        "node": "^10 || ^12 || >=14"
+      },
+      "peerDependencies": {
+        "postcss": "^8.1.0"
+      }
+    },
+    "node_modules/axios": {
+      "version": "1.15.2",
+      "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.2.tgz",
+      "integrity": "sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A==",
+      "license": "MIT",
+      "dependencies": {
+        "follow-redirects": "^1.15.11",
+        "form-data": "^4.0.5",
+        "proxy-from-env": "^2.1.0"
+      }
+    },
+    "node_modules/baseline-browser-mapping": {
+      "version": "2.10.23",
+      "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.23.tgz",
+      "integrity": "sha512-xwVXGqevyKPsiuQdLj+dZMVjidjJV508TBqexND5HrF89cGdCYCJFB3qhcxRHSeMctdCfbR1jrxBajhDy7o29g==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "bin": {
+        "baseline-browser-mapping": "dist/cli.cjs"
+      },
+      "engines": {
+        "node": ">=6.0.0"
+      }
+    },
+    "node_modules/binary-extensions": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
+      "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=8"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/braces": {
+      "version": "3.0.3",
+      "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
+      "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "fill-range": "^7.1.1"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/browserslist": {
+      "version": "4.28.2",
+      "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz",
+      "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/browserslist"
+        },
+        {
+          "type": "tidelift",
+          "url": "https://tidelift.com/funding/github/npm/browserslist"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "baseline-browser-mapping": "^2.10.12",
+        "caniuse-lite": "^1.0.30001782",
+        "electron-to-chromium": "^1.5.328",
+        "node-releases": "^2.0.36",
+        "update-browserslist-db": "^1.2.3"
+      },
+      "bin": {
+        "browserslist": "cli.js"
+      },
+      "engines": {
+        "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
+      }
+    },
+    "node_modules/call-bind-apply-helpers": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+      "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "function-bind": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/camelcase-css": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
+      "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 6"
+      }
+    },
+    "node_modules/caniuse-lite": {
+      "version": "1.0.30001791",
+      "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001791.tgz",
+      "integrity": "sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/browserslist"
+        },
+        {
+          "type": "tidelift",
+          "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "CC-BY-4.0"
+    },
+    "node_modules/chokidar": {
+      "version": "3.6.0",
+      "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
+      "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "anymatch": "~3.1.2",
+        "braces": "~3.0.2",
+        "glob-parent": "~5.1.2",
+        "is-binary-path": "~2.1.0",
+        "is-glob": "~4.0.1",
+        "normalize-path": "~3.0.0",
+        "readdirp": "~3.6.0"
+      },
+      "engines": {
+        "node": ">= 8.10.0"
+      },
+      "funding": {
+        "url": "https://paulmillr.com/funding/"
+      },
+      "optionalDependencies": {
+        "fsevents": "~2.3.2"
+      }
+    },
+    "node_modules/chokidar/node_modules/glob-parent": {
+      "version": "5.1.2",
+      "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
+      "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "is-glob": "^4.0.1"
+      },
+      "engines": {
+        "node": ">= 6"
+      }
+    },
+    "node_modules/classcat": {
+      "version": "5.0.5",
+      "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz",
+      "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==",
+      "license": "MIT"
+    },
+    "node_modules/clsx": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
+      "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/combined-stream": {
+      "version": "1.0.8",
+      "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
+      "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
+      "license": "MIT",
+      "dependencies": {
+        "delayed-stream": "~1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/commander": {
+      "version": "4.1.1",
+      "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
+      "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 6"
+      }
+    },
+    "node_modules/convert-source-map": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
+      "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/cssesc": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
+      "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
+      "dev": true,
+      "license": "MIT",
+      "bin": {
+        "cssesc": "bin/cssesc"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/csstype": {
+      "version": "3.2.3",
+      "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+      "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+      "license": "MIT"
+    },
+    "node_modules/d3-array": {
+      "version": "3.2.4",
+      "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
+      "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
+      "license": "ISC",
+      "dependencies": {
+        "internmap": "1 - 2"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-color": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
+      "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
+      "license": "ISC",
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-dispatch": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
+      "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
+      "license": "ISC",
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-drag": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
+      "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
+      "license": "ISC",
+      "dependencies": {
+        "d3-dispatch": "1 - 3",
+        "d3-selection": "3"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-ease": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
+      "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
+      "license": "BSD-3-Clause",
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-format": {
+      "version": "3.1.2",
+      "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
+      "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
+      "license": "ISC",
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-interpolate": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
+      "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
+      "license": "ISC",
+      "dependencies": {
+        "d3-color": "1 - 3"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-path": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
+      "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
+      "license": "ISC",
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-scale": {
+      "version": "4.0.2",
+      "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
+      "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
+      "license": "ISC",
+      "dependencies": {
+        "d3-array": "2.10.0 - 3",
+        "d3-format": "1 - 3",
+        "d3-interpolate": "1.2.0 - 3",
+        "d3-time": "2.1.1 - 3",
+        "d3-time-format": "2 - 4"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-selection": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
+      "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
+      "license": "ISC",
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-shape": {
+      "version": "3.2.0",
+      "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
+      "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
+      "license": "ISC",
+      "dependencies": {
+        "d3-path": "^3.1.0"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-time": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
+      "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
+      "license": "ISC",
+      "dependencies": {
+        "d3-array": "2 - 3"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-time-format": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
+      "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
+      "license": "ISC",
+      "dependencies": {
+        "d3-time": "1 - 3"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-timer": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
+      "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
+      "license": "ISC",
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-transition": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
+      "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
+      "license": "ISC",
+      "dependencies": {
+        "d3-color": "1 - 3",
+        "d3-dispatch": "1 - 3",
+        "d3-ease": "1 - 3",
+        "d3-interpolate": "1 - 3",
+        "d3-timer": "1 - 3"
+      },
+      "engines": {
+        "node": ">=12"
+      },
+      "peerDependencies": {
+        "d3-selection": "2 - 3"
+      }
+    },
+    "node_modules/d3-zoom": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
+      "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
+      "license": "ISC",
+      "dependencies": {
+        "d3-dispatch": "1 - 3",
+        "d3-drag": "2 - 3",
+        "d3-interpolate": "1 - 3",
+        "d3-selection": "2 - 3",
+        "d3-transition": "2 - 3"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/dagre": {
+      "version": "0.8.5",
+      "resolved": "https://registry.npmjs.org/dagre/-/dagre-0.8.5.tgz",
+      "integrity": "sha512-/aTqmnRta7x7MCCpExk7HQL2O4owCT2h8NT//9I1OQ9vt29Pa0BzSAkR5lwFUcQ7491yVi/3CXU9jQ5o0Mn2Sw==",
+      "license": "MIT",
+      "dependencies": {
+        "graphlib": "^2.1.8",
+        "lodash": "^4.17.15"
+      }
+    },
+    "node_modules/debug": {
+      "version": "4.4.3",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+      "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "ms": "^2.1.3"
+      },
+      "engines": {
+        "node": ">=6.0"
+      },
+      "peerDependenciesMeta": {
+        "supports-color": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/decimal.js-light": {
+      "version": "2.5.1",
+      "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
+      "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
+      "license": "MIT"
+    },
+    "node_modules/delayed-stream": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
+      "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.4.0"
+      }
+    },
+    "node_modules/didyoumean": {
+      "version": "1.2.2",
+      "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
+      "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==",
+      "dev": true,
+      "license": "Apache-2.0"
+    },
+    "node_modules/dlv": {
+      "version": "1.1.3",
+      "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
+      "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/dom-helpers": {
+      "version": "5.2.1",
+      "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
+      "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.8.7",
+        "csstype": "^3.0.2"
+      }
+    },
+    "node_modules/dunder-proto": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+      "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.1",
+        "es-errors": "^1.3.0",
+        "gopd": "^1.2.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/electron-to-chromium": {
+      "version": "1.5.344",
+      "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.344.tgz",
+      "integrity": "sha512-4MxfbmNDm+KPh066EZy+eUnkcDPcZ35wNmOWzFuh/ijvHsve6kbLTLURy88uCNK5FbpN+yk2nQY6BYh1GEt+wg==",
+      "dev": true,
+      "license": "ISC"
+    },
+    "node_modules/es-define-property": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+      "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-errors": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+      "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-object-atoms": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
+      "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-set-tostringtag": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
+      "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "get-intrinsic": "^1.2.6",
+        "has-tostringtag": "^1.0.2",
+        "hasown": "^2.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/esbuild": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
+      "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
+      "dev": true,
+      "hasInstallScript": true,
+      "license": "MIT",
+      "bin": {
+        "esbuild": "bin/esbuild"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "optionalDependencies": {
+        "@esbuild/aix-ppc64": "0.25.12",
+        "@esbuild/android-arm": "0.25.12",
+        "@esbuild/android-arm64": "0.25.12",
+        "@esbuild/android-x64": "0.25.12",
+        "@esbuild/darwin-arm64": "0.25.12",
+        "@esbuild/darwin-x64": "0.25.12",
+        "@esbuild/freebsd-arm64": "0.25.12",
+        "@esbuild/freebsd-x64": "0.25.12",
+        "@esbuild/linux-arm": "0.25.12",
+        "@esbuild/linux-arm64": "0.25.12",
+        "@esbuild/linux-ia32": "0.25.12",
+        "@esbuild/linux-loong64": "0.25.12",
+        "@esbuild/linux-mips64el": "0.25.12",
+        "@esbuild/linux-ppc64": "0.25.12",
+        "@esbuild/linux-riscv64": "0.25.12",
+        "@esbuild/linux-s390x": "0.25.12",
+        "@esbuild/linux-x64": "0.25.12",
+        "@esbuild/netbsd-arm64": "0.25.12",
+        "@esbuild/netbsd-x64": "0.25.12",
+        "@esbuild/openbsd-arm64": "0.25.12",
+        "@esbuild/openbsd-x64": "0.25.12",
+        "@esbuild/openharmony-arm64": "0.25.12",
+        "@esbuild/sunos-x64": "0.25.12",
+        "@esbuild/win32-arm64": "0.25.12",
+        "@esbuild/win32-ia32": "0.25.12",
+        "@esbuild/win32-x64": "0.25.12"
+      }
+    },
+    "node_modules/escalade": {
+      "version": "3.2.0",
+      "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
+      "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/eventemitter3": {
+      "version": "4.0.7",
+      "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
+      "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
+      "license": "MIT"
+    },
+    "node_modules/fast-equals": {
+      "version": "5.4.0",
+      "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz",
+      "integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.0.0"
+      }
+    },
+    "node_modules/fast-glob": {
+      "version": "3.3.3",
+      "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
+      "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@nodelib/fs.stat": "^2.0.2",
+        "@nodelib/fs.walk": "^1.2.3",
+        "glob-parent": "^5.1.2",
+        "merge2": "^1.3.0",
+        "micromatch": "^4.0.8"
+      },
+      "engines": {
+        "node": ">=8.6.0"
+      }
+    },
+    "node_modules/fast-glob/node_modules/glob-parent": {
+      "version": "5.1.2",
+      "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
+      "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "is-glob": "^4.0.1"
+      },
+      "engines": {
+        "node": ">= 6"
+      }
+    },
+    "node_modules/fastq": {
+      "version": "1.20.1",
+      "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
+      "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "reusify": "^1.0.4"
+      }
+    },
+    "node_modules/fill-range": {
+      "version": "7.1.1",
+      "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
+      "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "to-regex-range": "^5.0.1"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/follow-redirects": {
+      "version": "1.16.0",
+      "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
+      "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
+      "funding": [
+        {
+          "type": "individual",
+          "url": "https://github.com/sponsors/RubenVerborgh"
+        }
+      ],
+      "license": "MIT",
+      "engines": {
+        "node": ">=4.0"
+      },
+      "peerDependenciesMeta": {
+        "debug": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/form-data": {
+      "version": "4.0.5",
+      "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
+      "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
+      "license": "MIT",
+      "dependencies": {
+        "asynckit": "^0.4.0",
+        "combined-stream": "^1.0.8",
+        "es-set-tostringtag": "^2.1.0",
+        "hasown": "^2.0.2",
+        "mime-types": "^2.1.12"
+      },
+      "engines": {
+        "node": ">= 6"
+      }
+    },
+    "node_modules/fraction.js": {
+      "version": "5.3.4",
+      "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
+      "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": "*"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/rawify"
+      }
+    },
+    "node_modules/fsevents": {
+      "version": "2.3.3",
+      "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+      "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+      "dev": true,
+      "hasInstallScript": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+      }
+    },
+    "node_modules/function-bind": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+      "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/gensync": {
+      "version": "1.0.0-beta.2",
+      "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
+      "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/get-intrinsic": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+      "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.2",
+        "es-define-property": "^1.0.1",
+        "es-errors": "^1.3.0",
+        "es-object-atoms": "^1.1.1",
+        "function-bind": "^1.1.2",
+        "get-proto": "^1.0.1",
+        "gopd": "^1.2.0",
+        "has-symbols": "^1.1.0",
+        "hasown": "^2.0.2",
+        "math-intrinsics": "^1.1.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/get-proto": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+      "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+      "license": "MIT",
+      "dependencies": {
+        "dunder-proto": "^1.0.1",
+        "es-object-atoms": "^1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/glob-parent": {
+      "version": "6.0.2",
+      "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
+      "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "is-glob": "^4.0.3"
+      },
+      "engines": {
+        "node": ">=10.13.0"
+      }
+    },
+    "node_modules/gopd": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+      "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/graphlib": {
+      "version": "2.1.8",
+      "resolved": "https://registry.npmjs.org/graphlib/-/graphlib-2.1.8.tgz",
+      "integrity": "sha512-jcLLfkpoVGmH7/InMC/1hIvOPSUh38oJtGhvrOFGzioE1DZ+0YW16RgmOJhHiuWTvGiJQ9Z1Ik43JvkRPRvE+A==",
+      "license": "MIT",
+      "dependencies": {
+        "lodash": "^4.17.15"
+      }
+    },
+    "node_modules/has-symbols": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+      "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/has-tostringtag": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
+      "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
+      "license": "MIT",
+      "dependencies": {
+        "has-symbols": "^1.0.3"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/hasown": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
+      "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==",
+      "license": "MIT",
+      "dependencies": {
+        "function-bind": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/internmap": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
+      "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
+      "license": "ISC",
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/is-binary-path": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
+      "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "binary-extensions": "^2.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/is-core-module": {
+      "version": "2.16.1",
+      "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
+      "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "hasown": "^2.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/is-extglob": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
+      "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/is-glob": {
+      "version": "4.0.3",
+      "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
+      "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "is-extglob": "^2.1.1"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/is-number": {
+      "version": "7.0.0",
+      "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
+      "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.12.0"
+      }
+    },
+    "node_modules/jiti": {
+      "version": "1.21.7",
+      "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
+      "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
+      "dev": true,
+      "license": "MIT",
+      "bin": {
+        "jiti": "bin/jiti.js"
+      }
+    },
+    "node_modules/js-tokens": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+      "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+      "license": "MIT"
+    },
+    "node_modules/jsesc": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
+      "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
+      "dev": true,
+      "license": "MIT",
+      "bin": {
+        "jsesc": "bin/jsesc"
+      },
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/json5": {
+      "version": "2.2.3",
+      "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
+      "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
+      "dev": true,
+      "license": "MIT",
+      "bin": {
+        "json5": "lib/cli.js"
+      },
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/lilconfig": {
+      "version": "3.1.3",
+      "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
+      "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=14"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/antonk52"
+      }
+    },
+    "node_modules/lines-and-columns": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
+      "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/lodash": {
+      "version": "4.18.1",
+      "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
+      "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
+      "license": "MIT"
+    },
+    "node_modules/loose-envify": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
+      "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
+      "license": "MIT",
+      "dependencies": {
+        "js-tokens": "^3.0.0 || ^4.0.0"
+      },
+      "bin": {
+        "loose-envify": "cli.js"
+      }
+    },
+    "node_modules/lru-cache": {
+      "version": "5.1.1",
+      "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
+      "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "yallist": "^3.0.2"
+      }
+    },
+    "node_modules/lucide-react": {
+      "version": "0.510.0",
+      "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.510.0.tgz",
+      "integrity": "sha512-p8SQRAMVh7NhsAIETokSqDrc5CHnDLbV29mMnzaXx+Vc/hnqQzwI2r0FMWCcoTXnbw2KEjy48xwpGdEL+ck06Q==",
+      "license": "ISC",
+      "peerDependencies": {
+        "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+      }
+    },
+    "node_modules/math-intrinsics": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+      "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/merge2": {
+      "version": "1.4.1",
+      "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
+      "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 8"
+      }
+    },
+    "node_modules/micromatch": {
+      "version": "4.0.8",
+      "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
+      "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "braces": "^3.0.3",
+        "picomatch": "^2.3.1"
+      },
+      "engines": {
+        "node": ">=8.6"
+      }
+    },
+    "node_modules/mime-db": {
+      "version": "1.52.0",
+      "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+      "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/mime-types": {
+      "version": "2.1.35",
+      "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+      "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+      "license": "MIT",
+      "dependencies": {
+        "mime-db": "1.52.0"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/ms": {
+      "version": "2.1.3",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+      "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/mz": {
+      "version": "2.7.0",
+      "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
+      "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "any-promise": "^1.0.0",
+        "object-assign": "^4.0.1",
+        "thenify-all": "^1.0.0"
+      }
+    },
+    "node_modules/nanoid": {
+      "version": "3.3.11",
+      "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
+      "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "bin": {
+        "nanoid": "bin/nanoid.cjs"
+      },
+      "engines": {
+        "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+      }
+    },
+    "node_modules/node-releases": {
+      "version": "2.0.38",
+      "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz",
+      "integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/normalize-path": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
+      "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/object-assign": {
+      "version": "4.1.1",
+      "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+      "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/object-hash": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
+      "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 6"
+      }
+    },
+    "node_modules/path-parse": {
+      "version": "1.0.7",
+      "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
+      "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/picocolors": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+      "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+      "dev": true,
+      "license": "ISC"
+    },
+    "node_modules/picomatch": {
+      "version": "2.3.2",
+      "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
+      "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=8.6"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/jonschlinkert"
+      }
+    },
+    "node_modules/pify": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz",
+      "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/pirates": {
+      "version": "4.0.7",
+      "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz",
+      "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 6"
+      }
+    },
+    "node_modules/postcss": {
+      "version": "8.5.12",
+      "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz",
+      "integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/postcss/"
+        },
+        {
+          "type": "tidelift",
+          "url": "https://tidelift.com/funding/github/npm/postcss"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "nanoid": "^3.3.11",
+        "picocolors": "^1.1.1",
+        "source-map-js": "^1.2.1"
+      },
+      "engines": {
+        "node": "^10 || ^12 || >=14"
+      }
+    },
+    "node_modules/postcss-import": {
+      "version": "15.1.0",
+      "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz",
+      "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "postcss-value-parser": "^4.0.0",
+        "read-cache": "^1.0.0",
+        "resolve": "^1.1.7"
+      },
+      "engines": {
+        "node": ">=14.0.0"
+      },
+      "peerDependencies": {
+        "postcss": "^8.0.0"
+      }
+    },
+    "node_modules/postcss-js": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz",
+      "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/postcss/"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "camelcase-css": "^2.0.1"
+      },
+      "engines": {
+        "node": "^12 || ^14 || >= 16"
+      },
+      "peerDependencies": {
+        "postcss": "^8.4.21"
+      }
+    },
+    "node_modules/postcss-load-config": {
+      "version": "6.0.1",
+      "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz",
+      "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/postcss/"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "lilconfig": "^3.1.1"
+      },
+      "engines": {
+        "node": ">= 18"
+      },
+      "peerDependencies": {
+        "jiti": ">=1.21.0",
+        "postcss": ">=8.0.9",
+        "tsx": "^4.8.1",
+        "yaml": "^2.4.2"
+      },
+      "peerDependenciesMeta": {
+        "jiti": {
+          "optional": true
+        },
+        "postcss": {
+          "optional": true
+        },
+        "tsx": {
+          "optional": true
+        },
+        "yaml": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/postcss-nested": {
+      "version": "6.2.0",
+      "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz",
+      "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/postcss/"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "postcss-selector-parser": "^6.1.1"
+      },
+      "engines": {
+        "node": ">=12.0"
+      },
+      "peerDependencies": {
+        "postcss": "^8.2.14"
+      }
+    },
+    "node_modules/postcss-selector-parser": {
+      "version": "6.1.2",
+      "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz",
+      "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "cssesc": "^3.0.0",
+        "util-deprecate": "^1.0.2"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/postcss-value-parser": {
+      "version": "4.2.0",
+      "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
+      "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/prop-types": {
+      "version": "15.8.1",
+      "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
+      "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
+      "license": "MIT",
+      "dependencies": {
+        "loose-envify": "^1.4.0",
+        "object-assign": "^4.1.1",
+        "react-is": "^16.13.1"
+      }
+    },
+    "node_modules/prop-types/node_modules/react-is": {
+      "version": "16.13.1",
+      "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
+      "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
+      "license": "MIT"
+    },
+    "node_modules/proxy-from-env": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
+      "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/queue-microtask": {
+      "version": "1.2.3",
+      "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
+      "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/feross"
+        },
+        {
+          "type": "patreon",
+          "url": "https://www.patreon.com/feross"
+        },
+        {
+          "type": "consulting",
+          "url": "https://feross.org/support"
+        }
+      ],
+      "license": "MIT"
+    },
+    "node_modules/react": {
+      "version": "18.3.1",
+      "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
+      "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
+      "license": "MIT",
+      "dependencies": {
+        "loose-envify": "^1.1.0"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/react-dom": {
+      "version": "18.3.1",
+      "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
+      "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
+      "license": "MIT",
+      "dependencies": {
+        "loose-envify": "^1.1.0",
+        "scheduler": "^0.23.2"
+      },
+      "peerDependencies": {
+        "react": "^18.3.1"
+      }
+    },
+    "node_modules/react-is": {
+      "version": "18.3.1",
+      "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
+      "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
+      "license": "MIT"
+    },
+    "node_modules/react-refresh": {
+      "version": "0.17.0",
+      "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
+      "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/react-router": {
+      "version": "6.30.3",
+      "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz",
+      "integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==",
+      "license": "MIT",
+      "dependencies": {
+        "@remix-run/router": "1.23.2"
+      },
+      "engines": {
+        "node": ">=14.0.0"
+      },
+      "peerDependencies": {
+        "react": ">=16.8"
+      }
+    },
+    "node_modules/react-router-dom": {
+      "version": "6.30.3",
+      "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz",
+      "integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==",
+      "license": "MIT",
+      "dependencies": {
+        "@remix-run/router": "1.23.2",
+        "react-router": "6.30.3"
+      },
+      "engines": {
+        "node": ">=14.0.0"
+      },
+      "peerDependencies": {
+        "react": ">=16.8",
+        "react-dom": ">=16.8"
+      }
+    },
+    "node_modules/react-smooth": {
+      "version": "4.0.4",
+      "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz",
+      "integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==",
+      "license": "MIT",
+      "dependencies": {
+        "fast-equals": "^5.0.1",
+        "prop-types": "^15.8.1",
+        "react-transition-group": "^4.4.5"
+      },
+      "peerDependencies": {
+        "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
+        "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+      }
+    },
+    "node_modules/react-transition-group": {
+      "version": "4.4.5",
+      "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
+      "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==",
+      "license": "BSD-3-Clause",
+      "dependencies": {
+        "@babel/runtime": "^7.5.5",
+        "dom-helpers": "^5.0.1",
+        "loose-envify": "^1.4.0",
+        "prop-types": "^15.6.2"
+      },
+      "peerDependencies": {
+        "react": ">=16.6.0",
+        "react-dom": ">=16.6.0"
+      }
+    },
+    "node_modules/read-cache": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
+      "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "pify": "^2.3.0"
+      }
+    },
+    "node_modules/readdirp": {
+      "version": "3.6.0",
+      "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
+      "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "picomatch": "^2.2.1"
+      },
+      "engines": {
+        "node": ">=8.10.0"
+      }
+    },
+    "node_modules/recharts": {
+      "version": "2.15.4",
+      "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz",
+      "integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==",
+      "license": "MIT",
+      "dependencies": {
+        "clsx": "^2.0.0",
+        "eventemitter3": "^4.0.1",
+        "lodash": "^4.17.21",
+        "react-is": "^18.3.1",
+        "react-smooth": "^4.0.4",
+        "recharts-scale": "^0.4.4",
+        "tiny-invariant": "^1.3.1",
+        "victory-vendor": "^36.6.8"
+      },
+      "engines": {
+        "node": ">=14"
+      },
+      "peerDependencies": {
+        "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
+        "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+      }
+    },
+    "node_modules/recharts-scale": {
+      "version": "0.4.5",
+      "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz",
+      "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==",
+      "license": "MIT",
+      "dependencies": {
+        "decimal.js-light": "^2.4.1"
+      }
+    },
+    "node_modules/resolve": {
+      "version": "1.22.12",
+      "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
+      "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "is-core-module": "^2.16.1",
+        "path-parse": "^1.0.7",
+        "supports-preserve-symlinks-flag": "^1.0.0"
+      },
+      "bin": {
+        "resolve": "bin/resolve"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/reusify": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
+      "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "iojs": ">=1.0.0",
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/rollup": {
+      "version": "4.60.2",
+      "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.2.tgz",
+      "integrity": "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/estree": "1.0.8"
+      },
+      "bin": {
+        "rollup": "dist/bin/rollup"
+      },
+      "engines": {
+        "node": ">=18.0.0",
+        "npm": ">=8.0.0"
+      },
+      "optionalDependencies": {
+        "@rollup/rollup-android-arm-eabi": "4.60.2",
+        "@rollup/rollup-android-arm64": "4.60.2",
+        "@rollup/rollup-darwin-arm64": "4.60.2",
+        "@rollup/rollup-darwin-x64": "4.60.2",
+        "@rollup/rollup-freebsd-arm64": "4.60.2",
+        "@rollup/rollup-freebsd-x64": "4.60.2",
+        "@rollup/rollup-linux-arm-gnueabihf": "4.60.2",
+        "@rollup/rollup-linux-arm-musleabihf": "4.60.2",
+        "@rollup/rollup-linux-arm64-gnu": "4.60.2",
+        "@rollup/rollup-linux-arm64-musl": "4.60.2",
+        "@rollup/rollup-linux-loong64-gnu": "4.60.2",
+        "@rollup/rollup-linux-loong64-musl": "4.60.2",
+        "@rollup/rollup-linux-ppc64-gnu": "4.60.2",
+        "@rollup/rollup-linux-ppc64-musl": "4.60.2",
+        "@rollup/rollup-linux-riscv64-gnu": "4.60.2",
+        "@rollup/rollup-linux-riscv64-musl": "4.60.2",
+        "@rollup/rollup-linux-s390x-gnu": "4.60.2",
+        "@rollup/rollup-linux-x64-gnu": "4.60.2",
+        "@rollup/rollup-linux-x64-musl": "4.60.2",
+        "@rollup/rollup-openbsd-x64": "4.60.2",
+        "@rollup/rollup-openharmony-arm64": "4.60.2",
+        "@rollup/rollup-win32-arm64-msvc": "4.60.2",
+        "@rollup/rollup-win32-ia32-msvc": "4.60.2",
+        "@rollup/rollup-win32-x64-gnu": "4.60.2",
+        "@rollup/rollup-win32-x64-msvc": "4.60.2",
+        "fsevents": "~2.3.2"
+      }
+    },
+    "node_modules/run-parallel": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
+      "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/feross"
+        },
+        {
+          "type": "patreon",
+          "url": "https://www.patreon.com/feross"
+        },
+        {
+          "type": "consulting",
+          "url": "https://feross.org/support"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "queue-microtask": "^1.2.2"
+      }
+    },
+    "node_modules/scheduler": {
+      "version": "0.23.2",
+      "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
+      "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
+      "license": "MIT",
+      "dependencies": {
+        "loose-envify": "^1.1.0"
+      }
+    },
+    "node_modules/semver": {
+      "version": "6.3.1",
+      "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+      "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+      "dev": true,
+      "license": "ISC",
+      "bin": {
+        "semver": "bin/semver.js"
+      }
+    },
+    "node_modules/source-map-js": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+      "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+      "dev": true,
+      "license": "BSD-3-Clause",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/sucrase": {
+      "version": "3.35.1",
+      "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz",
+      "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@jridgewell/gen-mapping": "^0.3.2",
+        "commander": "^4.0.0",
+        "lines-and-columns": "^1.1.6",
+        "mz": "^2.7.0",
+        "pirates": "^4.0.1",
+        "tinyglobby": "^0.2.11",
+        "ts-interface-checker": "^0.1.9"
+      },
+      "bin": {
+        "sucrase": "bin/sucrase",
+        "sucrase-node": "bin/sucrase-node"
+      },
+      "engines": {
+        "node": ">=16 || 14 >=14.17"
+      }
+    },
+    "node_modules/supports-preserve-symlinks-flag": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
+      "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/tailwind-merge": {
+      "version": "2.6.1",
+      "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.1.tgz",
+      "integrity": "sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/dcastil"
+      }
+    },
+    "node_modules/tailwindcss": {
+      "version": "3.4.19",
+      "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
+      "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@alloc/quick-lru": "^5.2.0",
+        "arg": "^5.0.2",
+        "chokidar": "^3.6.0",
+        "didyoumean": "^1.2.2",
+        "dlv": "^1.1.3",
+        "fast-glob": "^3.3.2",
+        "glob-parent": "^6.0.2",
+        "is-glob": "^4.0.3",
+        "jiti": "^1.21.7",
+        "lilconfig": "^3.1.3",
+        "micromatch": "^4.0.8",
+        "normalize-path": "^3.0.0",
+        "object-hash": "^3.0.0",
+        "picocolors": "^1.1.1",
+        "postcss": "^8.4.47",
+        "postcss-import": "^15.1.0",
+        "postcss-js": "^4.0.1",
+        "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0",
+        "postcss-nested": "^6.2.0",
+        "postcss-selector-parser": "^6.1.2",
+        "resolve": "^1.22.8",
+        "sucrase": "^3.35.0"
+      },
+      "bin": {
+        "tailwind": "lib/cli.js",
+        "tailwindcss": "lib/cli.js"
+      },
+      "engines": {
+        "node": ">=14.0.0"
+      }
+    },
+    "node_modules/thenify": {
+      "version": "3.3.1",
+      "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
+      "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "any-promise": "^1.0.0"
+      }
+    },
+    "node_modules/thenify-all": {
+      "version": "1.6.0",
+      "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz",
+      "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "thenify": ">= 3.1.0 < 4"
+      },
+      "engines": {
+        "node": ">=0.8"
+      }
+    },
+    "node_modules/tiny-invariant": {
+      "version": "1.3.3",
+      "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
+      "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
+      "license": "MIT"
+    },
+    "node_modules/tinyglobby": {
+      "version": "0.2.16",
+      "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
+      "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "fdir": "^6.5.0",
+        "picomatch": "^4.0.4"
+      },
+      "engines": {
+        "node": ">=12.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/SuperchupuDev"
+      }
+    },
+    "node_modules/tinyglobby/node_modules/fdir": {
+      "version": "6.5.0",
+      "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+      "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=12.0.0"
+      },
+      "peerDependencies": {
+        "picomatch": "^3 || ^4"
+      },
+      "peerDependenciesMeta": {
+        "picomatch": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/tinyglobby/node_modules/picomatch": {
+      "version": "4.0.4",
+      "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
+      "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/jonschlinkert"
+      }
+    },
+    "node_modules/to-regex-range": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
+      "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "is-number": "^7.0.0"
+      },
+      "engines": {
+        "node": ">=8.0"
+      }
+    },
+    "node_modules/ts-interface-checker": {
+      "version": "0.1.13",
+      "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
+      "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==",
+      "dev": true,
+      "license": "Apache-2.0"
+    },
+    "node_modules/typescript": {
+      "version": "5.9.3",
+      "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+      "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "bin": {
+        "tsc": "bin/tsc",
+        "tsserver": "bin/tsserver"
+      },
+      "engines": {
+        "node": ">=14.17"
+      }
+    },
+    "node_modules/undici-types": {
+      "version": "7.19.2",
+      "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz",
+      "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/update-browserslist-db": {
+      "version": "1.2.3",
+      "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
+      "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/browserslist"
+        },
+        {
+          "type": "tidelift",
+          "url": "https://tidelift.com/funding/github/npm/browserslist"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "escalade": "^3.2.0",
+        "picocolors": "^1.1.1"
+      },
+      "bin": {
+        "update-browserslist-db": "cli.js"
+      },
+      "peerDependencies": {
+        "browserslist": ">= 4.21.0"
+      }
+    },
+    "node_modules/use-sync-external-store": {
+      "version": "1.6.0",
+      "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
+      "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
+      "license": "MIT",
+      "peerDependencies": {
+        "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+      }
+    },
+    "node_modules/util-deprecate": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+      "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/victory-vendor": {
+      "version": "36.9.2",
+      "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz",
+      "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==",
+      "license": "MIT AND ISC",
+      "dependencies": {
+        "@types/d3-array": "^3.0.3",
+        "@types/d3-ease": "^3.0.0",
+        "@types/d3-interpolate": "^3.0.1",
+        "@types/d3-scale": "^4.0.2",
+        "@types/d3-shape": "^3.1.0",
+        "@types/d3-time": "^3.0.0",
+        "@types/d3-timer": "^3.0.0",
+        "d3-array": "^3.1.6",
+        "d3-ease": "^3.0.1",
+        "d3-interpolate": "^3.0.1",
+        "d3-scale": "^4.0.2",
+        "d3-shape": "^3.1.0",
+        "d3-time": "^3.0.0",
+        "d3-timer": "^3.0.1"
+      }
+    },
+    "node_modules/vite": {
+      "version": "6.4.2",
+      "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz",
+      "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "esbuild": "^0.25.0",
+        "fdir": "^6.4.4",
+        "picomatch": "^4.0.2",
+        "postcss": "^8.5.3",
+        "rollup": "^4.34.9",
+        "tinyglobby": "^0.2.13"
+      },
+      "bin": {
+        "vite": "bin/vite.js"
+      },
+      "engines": {
+        "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/vitejs/vite?sponsor=1"
+      },
+      "optionalDependencies": {
+        "fsevents": "~2.3.3"
+      },
+      "peerDependencies": {
+        "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
+        "jiti": ">=1.21.0",
+        "less": "*",
+        "lightningcss": "^1.21.0",
+        "sass": "*",
+        "sass-embedded": "*",
+        "stylus": "*",
+        "sugarss": "*",
+        "terser": "^5.16.0",
+        "tsx": "^4.8.1",
+        "yaml": "^2.4.2"
+      },
+      "peerDependenciesMeta": {
+        "@types/node": {
+          "optional": true
+        },
+        "jiti": {
+          "optional": true
+        },
+        "less": {
+          "optional": true
+        },
+        "lightningcss": {
+          "optional": true
+        },
+        "sass": {
+          "optional": true
+        },
+        "sass-embedded": {
+          "optional": true
+        },
+        "stylus": {
+          "optional": true
+        },
+        "sugarss": {
+          "optional": true
+        },
+        "terser": {
+          "optional": true
+        },
+        "tsx": {
+          "optional": true
+        },
+        "yaml": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/vite/node_modules/fdir": {
+      "version": "6.5.0",
+      "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+      "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=12.0.0"
+      },
+      "peerDependencies": {
+        "picomatch": "^3 || ^4"
+      },
+      "peerDependenciesMeta": {
+        "picomatch": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/vite/node_modules/picomatch": {
+      "version": "4.0.4",
+      "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
+      "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/jonschlinkert"
+      }
+    },
+    "node_modules/yallist": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+      "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
+      "dev": true,
+      "license": "ISC"
+    },
+    "node_modules/zustand": {
+      "version": "5.0.12",
+      "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.12.tgz",
+      "integrity": "sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=12.20.0"
+      },
+      "peerDependencies": {
+        "@types/react": ">=18.0.0",
+        "immer": ">=9.0.6",
+        "react": ">=18.0.0",
+        "use-sync-external-store": ">=1.2.0"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        },
+        "immer": {
+          "optional": true
+        },
+        "react": {
+          "optional": true
+        },
+        "use-sync-external-store": {
+          "optional": true
+        }
+      }
+    }
+  }
+}

+ 36 - 0
web/package.json

@@ -0,0 +1,36 @@
+{
+  "name": "auto-platform-web",
+  "private": true,
+  "version": "0.1.0",
+  "type": "module",
+  "scripts": {
+    "dev": "vite",
+    "build": "tsc -b && vite build",
+    "preview": "vite preview"
+  },
+  "dependencies": {
+    "@xyflow/react": "^12.6.4",
+    "axios": "^1.9.0",
+    "clsx": "^2.1.1",
+    "dagre": "^0.8.5",
+    "lucide-react": "^0.510.0",
+    "react": "^18.3.1",
+    "react-dom": "^18.3.1",
+    "react-router-dom": "^6.28.0",
+    "recharts": "^2.15.3",
+    "tailwind-merge": "^2.6.0",
+    "zustand": "^5.0.5"
+  },
+  "devDependencies": {
+    "@types/dagre": "^0.7.52",
+    "@types/node": "^25.6.0",
+    "@types/react": "^18.3.12",
+    "@types/react-dom": "^18.3.1",
+    "@vitejs/plugin-react": "^4.4.1",
+    "autoprefixer": "^10.4.21",
+    "postcss": "^8.5.3",
+    "tailwindcss": "^3.4.17",
+    "typescript": "^5.7.3",
+    "vite": "^6.3.5"
+  }
+}

+ 6 - 0
web/postcss.config.js

@@ -0,0 +1,6 @@
+export default {
+  plugins: {
+    tailwindcss: {},
+    autoprefixer: {},
+  },
+};

+ 5 - 0
web/public/favicon.svg

@@ -0,0 +1,5 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
+  <rect width="32" height="32" rx="6" fill="#2563EB"/>
+  <path d="M8 22V10l8 6-8 6z" fill="#fff"/>
+  <path d="M16 22V10l8 6-8 6z" fill="rgba(255,255,255,0.6)"/>
+</svg>

+ 87 - 0
web/src/App.tsx

@@ -0,0 +1,87 @@
+import { lazy, Suspense, useEffect } from "react";
+import { Navigate, Route, Routes } from "react-router-dom";
+import { ProtectedRoute } from "@/components/auth/ProtectedRoute";
+import { AppLayout } from "@/components/layout/AppLayout";
+import { ErrorBoundary } from "@/components/shared/ErrorBoundary";
+import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
+import { Toaster } from "@/components/ui/toaster";
+import { useUiStore } from "@/stores/ui";
+
+const LoginPage = lazy(() => import("@/pages/login/LoginPage").then((module) => ({ default: module.LoginPage })));
+const DashboardPage = lazy(() => import("@/pages/dashboard/DashboardPage").then((module) => ({ default: module.DashboardPage })));
+const WorkflowListPage = lazy(() => import("@/pages/workflows/WorkflowListPage").then((module) => ({ default: module.WorkflowListPage })));
+const WorkflowEditorPage = lazy(() => import("@/pages/workflow-editor/WorkflowEditorPage").then((module) => ({ default: module.WorkflowEditorPage })));
+const AgentListPage = lazy(() => import("@/pages/agents/AgentListPage").then((module) => ({ default: module.AgentListPage })));
+const SessionChatPage = lazy(() => import("@/pages/sessions/SessionChatPage").then((module) => ({ default: module.SessionChatPage })));
+const ToolsPage = lazy(() => import("@/pages/tools/ToolsPage").then((module) => ({ default: module.ToolsPage })));
+const KnowledgePage = lazy(() => import("@/pages/knowledge/KnowledgePage").then((module) => ({ default: module.KnowledgePage })));
+const TeamsPage = lazy(() => import("@/pages/teams/TeamsPage").then((module) => ({ default: module.TeamsPage })));
+const SettingsPage = lazy(() => import("@/pages/settings/SettingsPage").then((module) => ({ default: module.SettingsPage })));
+
+export default function App() {
+  return (
+    <>
+      <ThemeSync />
+      <RoutePreloader />
+      <ErrorBoundary>
+        <Suspense fallback={<LoadingSpinner label="Loading studio" />}>
+          <Routes>
+            <Route path="/login" element={<LoginPage />} />
+            <Route path="/" element={<Navigate to="/dashboard" replace />} />
+            <Route
+              element={
+                <ProtectedRoute>
+                  <AppLayout />
+                </ProtectedRoute>
+              }
+            >
+              <Route path="/dashboard" element={<DashboardPage />} />
+              <Route path="/workflows" element={<WorkflowListPage />} />
+              <Route path="/workflows/:workflowId/editor" element={<WorkflowEditorPage />} />
+              <Route path="/agents" element={<AgentListPage />} />
+              <Route path="/sessions" element={<SessionChatPage />} />
+              <Route path="/tools" element={<ToolsPage />} />
+              <Route path="/knowledge" element={<KnowledgePage />} />
+              <Route path="/knowledge/:section" element={<KnowledgePage />} />
+              <Route path="/teams" element={<TeamsPage />} />
+              <Route path="/settings" element={<SettingsPage />} />
+            </Route>
+            <Route path="*" element={<Navigate to="/dashboard" replace />} />
+          </Routes>
+        </Suspense>
+      </ErrorBoundary>
+      <Toaster />
+    </>
+  );
+}
+
+function ThemeSync() {
+  const theme = useUiStore((state) => state.theme);
+  useEffect(() => {
+    document.documentElement.classList.toggle("dark", theme === "dark");
+  }, [theme]);
+  return null;
+}
+
+function RoutePreloader() {
+  useEffect(() => {
+    const preload = () => {
+      void Promise.all([
+        import("@/pages/dashboard/DashboardPage"),
+        import("@/pages/workflows/WorkflowListPage"),
+        import("@/pages/workflow-editor/WorkflowEditorPage"),
+        import("@/pages/agents/AgentListPage"),
+        import("@/pages/sessions/SessionChatPage"),
+        import("@/pages/tools/ToolsPage"),
+        import("@/pages/knowledge/KnowledgePage"),
+        import("@/pages/teams/TeamsPage"),
+        import("@/pages/settings/SettingsPage"),
+      ]);
+    };
+    const requestIdle = window.requestIdleCallback ?? ((callback: IdleRequestCallback) => window.setTimeout(callback, 400));
+    const cancelIdle = window.cancelIdleCallback ?? window.clearTimeout;
+    const handle = requestIdle(preload);
+    return () => cancelIdle(handle);
+  }, []);
+  return null;
+}

+ 40 - 0
web/src/api/agents.ts

@@ -0,0 +1,40 @@
+import { apiClient, tenantParams } from "./client";
+import type { AgentDefinition, AgentRun, AgentVersion, JSONObject } from "@/types";
+
+export async function listAgents() {
+  const { data } = await apiClient.get<AgentDefinition[]>("/agents", { params: tenantParams() });
+  return data;
+}
+
+export async function createAgent(payload: {
+  tenant_id: string;
+  code?: string;
+  name: string;
+  description?: string;
+  agent_type: string;
+  owner_user_id?: string;
+  metadata_json?: JSONObject;
+}) {
+  const { data } = await apiClient.post<AgentDefinition>("/agents", {
+    ...payload,
+    code: payload.code || slugifyName(payload.name, "agent"),
+  });
+  return data;
+}
+
+export async function listAgentVersions(agentId?: string) {
+  const { data } = await apiClient.get<AgentVersion[]>("/agents/versions", {
+    params: tenantParams({ agent_id: agentId }),
+  });
+  return data;
+}
+
+export async function listAgentRuns(agentId?: string) {
+  const { data } = await apiClient.get<AgentRun[]>("/agents/runs", { params: tenantParams({ agent_id: agentId }) });
+  return data;
+}
+
+function slugifyName(value: string, fallback: string) {
+  const slug = value.trim().toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
+  return slug || `${fallback}_${Date.now().toString(36)}`;
+}

+ 24 - 0
web/src/api/api-keys.ts

@@ -0,0 +1,24 @@
+import { apiClient, tenantParams } from "./client";
+import type { ApiKeyCreateRequest, ApiKeyCreateResponse, ApiKeyResponse, ApiKeyStatus } from "@/types";
+
+export async function createApiKey(payload: ApiKeyCreateRequest) {
+  const { data } = await apiClient.post<ApiKeyCreateResponse>("/api-keys", payload);
+  return data;
+}
+
+export async function listApiKeys() {
+  const { data } = await apiClient.get<ApiKeyResponse[]>("/api-keys", { params: tenantParams() });
+  return data;
+}
+
+export async function updateApiKeyStatus(apiKeyId: string, status: ApiKeyStatus) {
+  const { data } = await apiClient.patch<ApiKeyResponse>(`/api-keys/${apiKeyId}/status`, {
+    ...tenantParams(),
+    status,
+  });
+  return data;
+}
+
+export async function revokeApiKey(apiKeyId: string) {
+  return updateApiKeyStatus(apiKeyId, "revoked");
+}

+ 17 - 0
web/src/api/auth.ts

@@ -0,0 +1,17 @@
+import { apiClient, tenantParams } from "./client";
+import type { PermissionCheckContract, PermissionCheckResultContract, RoleContract, UserContract } from "@/types";
+
+export async function listUsers() {
+  const { data } = await apiClient.get<UserContract[]>("/auth/users", { params: tenantParams() });
+  return data;
+}
+
+export async function listRoles() {
+  const { data } = await apiClient.get<RoleContract[]>("/auth/roles", { params: tenantParams() });
+  return data;
+}
+
+export async function checkPermission(payload: PermissionCheckContract) {
+  const { data } = await apiClient.post<PermissionCheckResultContract>("/auth/permissions/check", payload);
+  return data;
+}

+ 40 - 0
web/src/api/client.ts

@@ -0,0 +1,40 @@
+import axios from "axios";
+import { useAuthStore } from "@/stores/auth";
+import { mockAxiosResponse, mockMode } from "./mock";
+
+export const apiClient = axios.create({
+  baseURL: "/gateway",
+  timeout: 20000,
+});
+
+apiClient.interceptors.request.use((config) => {
+  if (mockMode) {
+    config.adapter = async (adapterConfig) => mockAxiosResponse(adapterConfig);
+  }
+  const { apiKey, tenantId, userId } = useAuthStore.getState();
+  if (apiKey) config.headers.set("x-api-key", apiKey);
+  if (tenantId) config.headers.set("x-tenant-id", tenantId);
+  if (userId) config.headers.set("x-user-id", userId);
+  return config;
+});
+
+apiClient.interceptors.response.use(
+  (response) => response,
+  (error) => {
+    if (error.config && (error.response?.status === 404 || !error.response)) {
+      return Promise.resolve(mockAxiosResponse(error.config));
+    }
+    if (error.response?.status === 401) {
+      useAuthStore.getState().logout();
+      if (window.location.pathname !== "/login") {
+        window.location.assign("/login");
+      }
+    }
+    return Promise.reject(error);
+  },
+);
+
+export function tenantParams(extra?: Record<string, string | number | boolean | undefined | null>) {
+  const tenant_id = useAuthStore.getState().tenantId || "public";
+  return { tenant_id, ...extra };
+}

+ 21 - 0
web/src/api/health.ts

@@ -0,0 +1,21 @@
+import axios from "axios";
+import { apiClient } from "./client";
+import { mockHealth, mockMode } from "./mock";
+import type { GatewayServicesHealth, ServiceHealth } from "@/types";
+
+export async function getHealth(apiKey?: string, tenantId?: string, userId?: string) {
+  if (mockMode) return mockHealth();
+  const { data } = await axios.get<ServiceHealth>("/health", {
+    headers: {
+      ...(apiKey ? { "x-api-key": apiKey } : {}),
+      ...(tenantId ? { "x-tenant-id": tenantId } : {}),
+      ...(userId ? { "x-user-id": userId } : {}),
+    },
+  });
+  return data;
+}
+
+export async function getServicesHealth() {
+  const { data } = await apiClient.get<GatewayServicesHealth>("/services/health");
+  return data;
+}

+ 11 - 0
web/src/api/index.ts

@@ -0,0 +1,11 @@
+export * from "./client";
+export * from "./health";
+export * from "./auth";
+export * from "./api-keys";
+export * from "./workflows";
+export * from "./agents";
+export * from "./sessions";
+export * from "./runtime";
+export * from "./tools";
+export * from "./knowledge";
+export * from "./teams";

+ 89 - 0
web/src/api/knowledge.ts

@@ -0,0 +1,89 @@
+import { apiClient, tenantParams } from "./client";
+import type {
+  JSONObject,
+  KnowledgeBase,
+  KnowledgeDocument,
+  KnowledgeDocumentIngestResponse,
+  KnowledgeDocumentParseResponse,
+  SearchResult,
+} from "@/types";
+
+export async function listKnowledgeBases() {
+  const { data } = await apiClient.get<KnowledgeBase[]>("/knowledge/bases", { params: tenantParams() });
+  return data;
+}
+
+export async function listKnowledgeDocuments(knowledgeBaseId?: string) {
+  const { data } = await apiClient.get<KnowledgeDocument[]>("/knowledge/documents", {
+    params: tenantParams({ knowledge_base_id: knowledgeBaseId }),
+  });
+  return data;
+}
+
+export async function searchKnowledge(knowledgeBaseId: string, query: string, topK = 5, filters: JSONObject = {}) {
+  const { data } = await apiClient.post<SearchResult[]>("/knowledge/search", {
+    ...tenantParams(),
+    knowledge_base_id: knowledgeBaseId,
+    query,
+    top_k: topK,
+    filters_json: filters,
+  });
+  return data;
+}
+
+export async function updateKnowledgeBaseStatus(payload: {
+  tenant_id: string;
+  knowledge_base_id: string;
+  status: KnowledgeBase["status"];
+}) {
+  const { data } = await apiClient.patch<KnowledgeBase>(`/knowledge/bases/${payload.knowledge_base_id}/status`, {
+    tenant_id: payload.tenant_id,
+    status: payload.status,
+  });
+  return data;
+}
+
+export async function createKnowledgeBase(payload: {
+  tenant_id: string;
+  code?: string;
+  name: string;
+  description?: string | null;
+  metadata_json?: JSONObject;
+}) {
+  const { data } = await apiClient.post<KnowledgeBase>("/knowledge/bases", {
+    ...payload,
+    code: payload.code || slugifyName(payload.name, "knowledge"),
+  });
+  return data;
+}
+
+export async function createKnowledgeDocument(payload: {
+  tenant_id: string;
+  knowledge_base_id: string;
+  title: string;
+  content_text?: string | null;
+  content_base64?: string | null;
+  source_type: string;
+  source_uri?: string | null;
+  metadata_json?: JSONObject;
+  chunk_size?: number | null;
+  chunk_overlap?: number | null;
+}) {
+  const { data } = await apiClient.post<KnowledgeDocumentIngestResponse>("/knowledge/documents", payload);
+  return data;
+}
+
+export async function parseKnowledgeDocument(payload: {
+  source_type: string;
+  source_uri?: string | null;
+  content_text?: string | null;
+  content_base64?: string | null;
+}) {
+  const { data } = await apiClient.post<KnowledgeDocumentParseResponse>("/knowledge/documents/parse", payload);
+  return data;
+}
+
+function slugifyName(value: string, fallback: string) {
+  const slug = value.trim().toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
+  return slug || `${fallback}_${Date.now().toString(36)}`;
+}

+ 954 - 0
web/src/api/mock.ts

@@ -0,0 +1,954 @@
+import type { AxiosRequestConfig, AxiosResponse, InternalAxiosRequestConfig } from "axios";
+import type {
+  AgentDefinition,
+  AgentRun,
+  AgentVersion,
+  ApiKeyCreateResponse,
+  ApiKeyResponse,
+  AppResponse,
+  DownstreamServiceHealth,
+  ExecutionLog,
+  GatewayServicesHealth,
+  KnowledgeBase,
+  KnowledgeChunk,
+  KnowledgeDocument,
+  Message,
+  NodeRun,
+  SearchResult,
+  ServiceHealth,
+  Session,
+  TeamDefinition,
+  TeamRun,
+  TeamVersion,
+  ToolBinding,
+  ToolCredential,
+  ToolDefinition,
+  ToolVersion,
+  TraceSpan,
+  WorkflowDefinition,
+  WorkflowDesignerValidateResponse,
+  WorkflowDSL,
+  WorkflowDebuggerPlanResponse,
+  WorkflowRun,
+  WorkflowVersion,
+} from "@/types";
+
+export const mockMode = import.meta.env.VITE_USE_MOCKS !== "false";
+
+const now = new Date();
+const iso = (offsetMinutes = 0) => new Date(now.getTime() + offsetMinutes * 60_000).toISOString();
+const id = (prefix: string) => `${prefix}_${Math.random().toString(36).slice(2, 10)}`;
+const hashText = (value: string) => Array.from(value).reduce((hash, char) => ((hash << 5) - hash + char.charCodeAt(0)) | 0, 0);
+
+const apps: AppResponse[] = [
+  {
+    id: "app_customer_ops",
+    tenant_id: "public",
+    code: "customer_ops",
+    name: "Customer Ops",
+    description: "AI assisted customer operations.",
+    owner_user_id: "demo-user",
+    settings_json: { channel: "web" },
+    created_time: iso(-7200),
+  },
+  {
+    id: "app_research",
+    tenant_id: "public",
+    code: "research_lab",
+    name: "Research Lab",
+    description: "Knowledge-heavy research workflows.",
+    owner_user_id: "demo-user",
+    settings_json: {},
+    created_time: iso(-6000),
+  },
+];
+
+const sampleDsl: WorkflowDSL = {
+  code: "support_triage",
+  name: "Support Triage",
+  nodes: [
+    { id: "start", type: "start", name: "Start", config: {} },
+    { id: "classify", type: "llm", name: "Classify request", config: { model: "gpt-4.1", temperature: 0.2 } },
+    { id: "lookup", type: "tool", name: "Lookup customer", config: { tool_code: "crm_lookup" } },
+    { id: "answer", type: "answer", name: "Draft answer", config: {} },
+  ],
+  edges: [
+    { source: "start", target: "classify" },
+    { source: "classify", target: "lookup", condition: "needs_context" },
+    { source: "lookup", target: "answer" },
+  ],
+};
+
+const workflows: WorkflowDefinition[] = [
+  {
+    id: "wf_support_triage",
+    tenant_id: "public",
+    app_id: "app_customer_ops",
+    code: "support_triage",
+    name: "Support Triage",
+    workflow_type: "main",
+    latest_version_no: 3,
+    created_time: iso(-5800),
+  },
+  {
+    id: "wf_research_digest",
+    tenant_id: "public",
+    app_id: "app_research",
+    code: "research_digest",
+    name: "Research Digest",
+    workflow_type: "scheduled",
+    latest_version_no: 1,
+    created_time: iso(-3200),
+  },
+];
+
+const workflowVersions: WorkflowVersion[] = [
+  {
+    id: "wfv_support_triage_3",
+    tenant_id: "public",
+    workflow_id: "wf_support_triage",
+    version_no: 3,
+    dsl_json: sampleDsl,
+    compiled_plan_json: null,
+    schema_version: "1.0",
+    checksum: "mock-checksum",
+    status: "draft",
+    created_time: iso(-720),
+  },
+  {
+    id: "wfv_research_digest_1",
+    tenant_id: "public",
+    workflow_id: "wf_research_digest",
+    version_no: 1,
+    dsl_json: {
+      code: "research_digest",
+      name: "Research Digest",
+      nodes: [
+        { id: "start", type: "start", name: "Start", config: {} },
+        { id: "summarize", type: "llm", name: "Summarize", config: { model: "gpt-4.1-mini" } },
+        { id: "answer", type: "answer", name: "Publish", config: {} },
+      ],
+      edges: [
+        { source: "start", target: "summarize" },
+        { source: "summarize", target: "answer" },
+      ],
+    },
+    compiled_plan_json: null,
+    schema_version: "1.0",
+    checksum: "mock-checksum",
+    status: "published",
+    created_time: iso(-2400),
+  },
+];
+
+const agents: AgentDefinition[] = [
+  {
+    id: "agent_support",
+    tenant_id: "public",
+    code: "support_agent",
+    name: "Support Agent",
+    description: "Handles first-response customer support.",
+    agent_type: "assistant",
+    status: "active",
+    owner_user_id: "demo-user",
+    created_time: iso(-5500),
+  },
+  {
+    id: "agent_researcher",
+    tenant_id: "public",
+    code: "researcher",
+    name: "Researcher",
+    description: "Finds, compares, and summarizes knowledge sources.",
+    agent_type: "research",
+    status: "draft",
+    owner_user_id: "demo-user",
+    created_time: iso(-4100),
+  },
+];
+
+const agentVersions: AgentVersion[] = [
+  {
+    id: "agv_support_2",
+    tenant_id: "public",
+    agent_id: "agent_support",
+    version_no: 2,
+    status: "published",
+    role: "assistant",
+    goal: "Resolve customer requests with context.",
+    system_prompt: "You are a concise operations assistant.",
+    model_config_json: { provider: "openai", model: "gpt-4.1", temperature: 0.2 },
+    memory_policy_json: { enabled: true, memory_scope: "session" },
+    tool_refs_json: [{ tool_code: "crm_lookup" }],
+    skill_refs_json: [],
+    published_time: iso(-1100),
+    created_time: iso(-1500),
+  },
+];
+
+const agentRuns: AgentRun[] = [
+  {
+    id: "agr_001",
+    tenant_id: "public",
+    agent_id: "agent_support",
+    agent_version_id: "agv_support_2",
+    session_id: "ses_001",
+    input_text: "Where is my invoice?",
+    input_json: null,
+    output_text: "I found your invoice and sent a secure link.",
+    output_json: null,
+    status: "completed",
+    worker_key: "mock-worker",
+    queued_time: iso(-55),
+    lease_expire_time: null,
+    started_time: iso(-53),
+    finished_time: iso(-51),
+    error_code: null,
+    error_message: null,
+    created_time: iso(-55),
+  },
+];
+
+const sessions: Session[] = [
+  {
+    id: "ses_001",
+    tenant_id: "public",
+    app_id: "app_customer_ops",
+    user_id: "demo-user",
+    channel_type: "web",
+    session_status: "active",
+    title: "Invoice question",
+    started_time: iso(-65),
+    last_active_time: iso(-4),
+    created_time: iso(-65),
+  },
+  {
+    id: "ses_002",
+    tenant_id: "public",
+    app_id: "app_research",
+    user_id: "demo-user",
+    channel_type: "web",
+    session_status: "closed",
+    title: "Weekly research digest",
+    started_time: iso(-1500),
+    last_active_time: iso(-1300),
+    created_time: iso(-1500),
+  },
+];
+
+const messages: Message[] = [
+  {
+    id: "msg_001",
+    tenant_id: "public",
+    session_id: "ses_001",
+    turn_id: "turn_001",
+    role: "user",
+    content_type: "text",
+    content_text: "Can you find my latest invoice?",
+    content_json: {},
+    created_time: iso(-12),
+  },
+  {
+    id: "msg_002",
+    tenant_id: "public",
+    session_id: "ses_001",
+    turn_id: "turn_001",
+    role: "assistant",
+    content_type: "text",
+    content_text: "I found invoice INV-2048. It was issued this morning and is ready for payment.",
+    content_json: {},
+    created_time: iso(-11),
+  },
+];
+
+const runs: WorkflowRun[] = [
+  {
+    id: "run_001",
+    tenant_id: "public",
+    app_id: "app_customer_ops",
+    app_version_id: "appv_001",
+    workflow_id: "wf_support_triage",
+    workflow_version_id: "wfv_support_triage_3",
+    session_id: "ses_001",
+    parent_run_id: null,
+    root_run_id: null,
+    run_type: "main",
+    status: "completed",
+    trigger_type: "chat",
+    priority: 0,
+    current_node_count: 4,
+    started_time: iso(-20),
+    created_time: iso(-21),
+  },
+  {
+    id: "run_002",
+    tenant_id: "public",
+    app_id: "app_research",
+    app_version_id: "appv_002",
+    workflow_id: "wf_research_digest",
+    workflow_version_id: "wfv_research_digest_1",
+    session_id: null,
+    parent_run_id: null,
+    root_run_id: null,
+    run_type: "scheduled",
+    status: "running",
+    trigger_type: "scheduler",
+    priority: 1,
+    current_node_count: 2,
+    started_time: iso(-8),
+    created_time: iso(-8),
+  },
+];
+
+const nodeRuns: NodeRun[] = [
+  {
+    id: "nr_001",
+    tenant_id: "public",
+    run_id: "run_001",
+    node_id: "classify",
+    node_type: "llm",
+    attempt_no: 1,
+    status: "completed",
+    output_text: "billing",
+    output_json: { intent: "billing" },
+    scheduled_time: null,
+    timeout_time: null,
+    queued_time: iso(-20),
+    created_time: iso(-20),
+  },
+];
+
+const tools: ToolDefinition[] = [
+  {
+    id: "tool_crm",
+    tenant_id: "public",
+    plugin_id: null,
+    code: "crm_lookup",
+    name: "CRM Lookup",
+    tool_type: "http",
+    description: "Fetches customer profile and account state.",
+    created_time: iso(-5000),
+  },
+  {
+    id: "tool_search",
+    tenant_id: "public",
+    plugin_id: null,
+    code: "knowledge_search",
+    name: "Knowledge Search",
+    tool_type: "retrieval",
+    description: "Searches indexed knowledge bases.",
+    created_time: iso(-3800),
+  },
+];
+
+const toolVersions: ToolVersion[] = [
+  {
+    id: "tv_crm_1",
+    tenant_id: "public",
+    tool_id: "tool_crm",
+    version_no: 1,
+    input_schema_json: { customer_id: "string" },
+    output_schema_json: { account_status: "string" },
+    invoke_config_json: { url: "https://mock.local/crm" },
+    timeout_ms: 5000,
+    retry_policy_json: { max_attempts: 2 },
+    created_time: iso(-4500),
+  },
+];
+
+const toolBindings: ToolBinding[] = [
+  {
+    id: "tb_crm_customer_ops",
+    tenant_id: "public",
+    app_id: "app_customer_ops",
+    tool_version_id: "tv_crm_1",
+    credential_id: "tc_ops",
+    binding_scope: "app",
+    enabled: true,
+    config_json: {},
+    created_time: iso(-3000),
+  },
+];
+
+const toolCredentials: ToolCredential[] = [
+  {
+    id: "tc_ops",
+    tenant_id: "public",
+    name: "Operations Sandbox",
+    credential_type: "api_key",
+    secret_fingerprint: "sha256:mock",
+    encryption_algorithm: "mock",
+    metadata_json: { environment: "mock" },
+    created_time: iso(-2800),
+  },
+];
+
+const knowledgeBases: KnowledgeBase[] = [
+  {
+    id: "kb_product",
+    tenant_id: "public",
+    code: "product_docs",
+    name: "Product Docs",
+    description: "Public product and support documentation.",
+    status: "active",
+    metadata_json: { owner: "docs" },
+    created_time: iso(-4200),
+  },
+];
+
+const knowledgeDocuments: KnowledgeDocument[] = [
+  {
+    id: "kd_invoice",
+    tenant_id: "public",
+    knowledge_base_id: "kb_product",
+    title: "Billing and Invoice FAQ",
+    source_type: "text",
+    source_uri: null,
+    status: "indexed",
+    content_hash: "mock-hash",
+    metadata_json: {},
+    indexed_time: iso(-3000),
+    created_time: iso(-3100),
+  },
+];
+
+const knowledgeChunks: KnowledgeChunk[] = [
+  {
+    id: "chunk_invoice_1",
+    tenant_id: "public",
+    knowledge_base_id: "kb_product",
+    document_id: "kd_invoice",
+    chunk_index: 0,
+    content_text: "Invoices are available from the customer billing profile. Admins can resend receipts and update billing contacts from the account workspace.",
+    token_count: 22,
+    embedding_model: "mock-embedding",
+    embedding_json: null,
+    metadata_json: { embedding_provider: "mock" },
+    created_time: iso(-3000),
+  },
+];
+
+const teams: TeamDefinition[] = [
+  {
+    id: "team_ops",
+    tenant_id: "public",
+    code: "ops_team",
+    name: "Ops Team",
+    description: "Supervisor-led customer operations team.",
+    team_type: "collaborative",
+    status: "active",
+    owner_user_id: "demo-user",
+    created_time: iso(-2900),
+  },
+];
+
+const teamVersions: TeamVersion[] = [
+  {
+    id: "teamv_ops_1",
+    tenant_id: "public",
+    team_id: "team_ops",
+    version_no: 1,
+    status: "published",
+    coordination_mode: "supervisor",
+    objective: "Coordinate support and billing agents.",
+    member_refs_json: [{ agent_id: "agent_support", role: "executor" }],
+    policy_json: { max_rounds: 4 },
+    published_time: iso(-2100),
+    created_time: iso(-2200),
+  },
+];
+
+const teamRuns: TeamRun[] = [
+  {
+    id: "tr_001",
+    tenant_id: "public",
+    team_id: "team_ops",
+    team_version_id: "teamv_ops_1",
+    session_id: "ses_001",
+    input_text: "Resolve invoice issue",
+    input_json: null,
+    output_text: "Resolved",
+    output_json: { member_run_count: 2 },
+    status: "completed",
+    created_time: iso(-40),
+  },
+];
+
+const apiKeys: ApiKeyResponse[] = [
+  {
+    id: "ak_demo",
+    tenant_id: "public",
+    name: "Demo Operator Key",
+    key_prefix: "ap_mock",
+    status: "active",
+    scopes: "studio:*",
+    expires_time: null,
+    last_used_time: iso(-2),
+    created_time: iso(-4000),
+  },
+];
+
+const services: DownstreamServiceHealth[] = [
+  "workflow-service",
+  "session-service",
+  "runtime-service",
+  "agent-service",
+  "tool-service",
+  "knowledge-service",
+  "team-service",
+  "auth-service",
+].map((service) => ({ service, status: "ok", url: `mock://${service}`, status_code: 200, error_message: null }));
+
+export function mockHealth(): ServiceHealth {
+  return { service: "api-gateway", status: "ok", database: "mock", checked_time: iso() };
+}
+
+export function mockAxiosResponse<T = unknown>(config: AxiosRequestConfig): AxiosResponse<T> {
+  return {
+    data: route(config) as T,
+    status: 200,
+    statusText: "OK",
+    headers: {},
+    config: config as InternalAxiosRequestConfig,
+  };
+}
+
+function body(config: AxiosRequestConfig) {
+  if (!config.data) return {};
+  if (typeof config.data === "string") {
+    try {
+      return JSON.parse(config.data);
+    } catch {
+      return {};
+    }
+  }
+  return config.data as Record<string, unknown>;
+}
+
+function route(config: AxiosRequestConfig): unknown {
+  const method = (config.method ?? "get").toLowerCase();
+  const url = normalize(config.url);
+  const params = (config.params ?? {}) as Record<string, string>;
+  const payload = body(config);
+
+  if (url === "/services/health") return { service: "api-gateway", status: "ok", downstream_services: services } satisfies GatewayServicesHealth;
+
+  if (url === "/api-keys" && method === "get") return apiKeys;
+  if (url === "/api-keys" && method === "post") {
+    const key = `ap_mock_${Math.random().toString(36).slice(2)}${Math.random().toString(36).slice(2)}`;
+    const created: ApiKeyCreateResponse = {
+      id: id("ak"),
+      tenant_id: String(payload.tenant_id ?? "public"),
+      name: String(payload.name ?? "New key"),
+      key_prefix: key.slice(0, 8),
+      api_key: key,
+      status: "active",
+      scopes: (payload.scopes as string | null) ?? null,
+      expires_time: null,
+      created_time: iso(),
+    };
+    apiKeys.unshift({ ...created, last_used_time: null });
+    return created;
+  }
+  if (url.startsWith("/api-keys/") && url.endsWith("/status")) {
+    const apiKeyId = url.split("/")[2];
+    const item = apiKeys.find((key) => key.id === apiKeyId);
+    if (item) item.status = String(payload.status ?? "revoked") as ApiKeyResponse["status"];
+    return item ?? apiKeys[0] ?? {};
+  }
+
+  if (url === "/workflows/apps" && method === "get") return apps;
+  if (url === "/workflows/apps" && method === "post") {
+    const created: AppResponse = {
+      id: id("app"),
+      tenant_id: String(payload.tenant_id ?? "public"),
+      code: String(payload.code ?? "app"),
+      name: String(payload.name ?? "New App"),
+      description: (payload.description as string | null) ?? null,
+      owner_user_id: (payload.owner_user_id as string | null) ?? null,
+      settings_json: {},
+      created_time: iso(),
+    };
+    apps.unshift(created);
+    return created;
+  }
+  if (url === "/workflows/apps/versions") return [];
+  if (url === "/workflows" && method === "get") return params.app_id ? workflows.filter((item) => item.app_id === params.app_id) : workflows;
+  if (url === "/workflows" && method === "post") {
+    const created: WorkflowDefinition = {
+      id: id("wf"),
+      tenant_id: String(payload.tenant_id ?? "public"),
+      app_id: String(payload.app_id ?? apps[0]?.id ?? "app_mock"),
+      code: String(payload.code ?? "workflow"),
+      name: String(payload.name ?? "New Workflow"),
+      workflow_type: String(payload.workflow_type ?? "main"),
+      latest_version_no: 0,
+      created_time: iso(),
+    };
+    workflows.unshift(created);
+    return created;
+  }
+  if (url === "/workflows/versions" && method === "get") return workflowVersions.filter((item) => item.workflow_id === params.workflow_id);
+  if (url === "/workflows/versions" && method === "post") {
+    const versionNo = workflowVersions.filter((item) => item.workflow_id === payload.workflow_id).length + 1;
+    const created: WorkflowVersion = {
+      id: id("wfv"),
+      tenant_id: String(payload.tenant_id ?? "public"),
+      workflow_id: String(payload.workflow_id),
+      version_no: versionNo,
+      dsl_json: payload.dsl_json as WorkflowDSL,
+      compiled_plan_json: null,
+      schema_version: "1.0",
+      checksum: "mock",
+      status: String(payload.status ?? "draft"),
+      created_time: iso(),
+    };
+    workflowVersions.unshift(created);
+    const workflow = workflows.find((item) => item.id === created.workflow_id);
+    if (workflow) workflow.latest_version_no = versionNo;
+    return created;
+  }
+  if (url.startsWith("/workflows/versions/")) return workflowVersions.find((item) => item.id === url.split("/")[3]) ?? workflowVersions[0] ?? {};
+  if (url === "/workflows/designer/validate") return validateDsl(payload.dsl_json as WorkflowDSL);
+  if (url === "/workflows/designer/debug") return debugDsl(payload.dsl_json as WorkflowDSL);
+
+  if (url === "/agents" && method === "get") return agents;
+  if (url === "/agents" && method === "post") {
+    const created: AgentDefinition = {
+      id: id("agent"),
+      tenant_id: String(payload.tenant_id ?? "public"),
+      code: String(payload.code ?? "agent"),
+      name: String(payload.name ?? "New Agent"),
+      description: (payload.description as string | null) ?? null,
+      agent_type: String(payload.agent_type ?? "assistant"),
+      status: "draft",
+      owner_user_id: (payload.owner_user_id as string | null) ?? null,
+      created_time: iso(),
+    };
+    agents.unshift(created);
+    return created;
+  }
+  if (url === "/agents/versions") return agentVersions.filter((item) => item.agent_id === params.agent_id);
+  if (url === "/agents/runs") return params.agent_id ? agentRuns.filter((item) => item.agent_id === params.agent_id) : agentRuns;
+
+  if (url === "/sessions" && method === "get") return params.app_id ? sessions.filter((item) => item.app_id === params.app_id) : sessions;
+  if (url === "/sessions" && method === "post") {
+    const created: Session = {
+      id: id("ses"),
+      tenant_id: String(payload.tenant_id ?? "public"),
+      app_id: String(payload.app_id ?? apps[0]?.id ?? "app_mock"),
+      user_id: String(payload.user_id ?? "demo-user"),
+      channel_type: String(payload.channel_type ?? "web"),
+      session_status: "active",
+      title: (payload.title as string | null) || "New session",
+      started_time: iso(),
+      last_active_time: iso(),
+      created_time: iso(),
+    };
+    sessions.unshift(created);
+    return created;
+  }
+  if (url === "/sessions/messages" && method === "get") return messages.filter((item) => item.session_id === params.session_id);
+  if (url === "/sessions/messages" && method === "post") {
+    const created: Message = {
+      id: id("msg"),
+      tenant_id: String(payload.tenant_id ?? "public"),
+      session_id: String(payload.session_id),
+      turn_id: id("turn"),
+      role: String(payload.role ?? "user"),
+      content_type: String(payload.content_type ?? "text"),
+      content_text: (payload.content_text as string | null) ?? null,
+      content_json: {},
+      created_time: iso(),
+    };
+    messages.push(created);
+    if (created.role === "user") {
+      messages.push({ ...created, id: id("msg"), role: "assistant", content_text: "Mock assistant response. Connect the gateway when you want live execution.", created_time: iso() });
+    }
+    return created;
+  }
+  if (url === "/sessions/run-requests") return [];
+
+  if (url === "/runtime/runs") return params.session_id ? runs.filter((item) => item.session_id === params.session_id) : runs;
+  if (url === "/runtime/node-runs") return nodeRuns.filter((item) => item.run_id === params.run_id);
+  if (url === "/runtime/execution-logs") return [] satisfies ExecutionLog[];
+  if (url === "/runtime/trace-spans") return [] satisfies TraceSpan[];
+
+  if (url === "/tools" && method === "get") return tools;
+  if (url === "/tools" && method === "post") {
+    const created: ToolDefinition = {
+      id: id("tool"),
+      tenant_id: String(payload.tenant_id ?? "public"),
+      plugin_id: (payload.plugin_id as string | null) ?? null,
+      code: String(payload.code ?? "tool"),
+      name: String(payload.name ?? "New Tool"),
+      tool_type: String(payload.tool_type ?? "http"),
+      description: (payload.description as string | null) ?? null,
+      created_time: iso(),
+    };
+    tools.unshift(created);
+    return created;
+  }
+  if (url === "/tools/versions" && method === "get") return params.tool_id ? toolVersions.filter((item) => item.tool_id === params.tool_id) : toolVersions;
+  if (url === "/tools/versions" && method === "post") {
+    const versionNo = toolVersions.filter((item) => item.tool_id === payload.tool_id).length + 1;
+    const created: ToolVersion = {
+      id: id("tv"),
+      tenant_id: String(payload.tenant_id ?? "public"),
+      tool_id: String(payload.tool_id),
+      version_no: versionNo,
+      input_schema_json: (payload.input_schema_json as Record<string, never>) ?? {},
+      output_schema_json: (payload.output_schema_json as Record<string, never>) ?? {},
+      invoke_config_json: (payload.invoke_config_json as Record<string, never>) ?? {},
+      timeout_ms: Number(payload.timeout_ms ?? 5000),
+      retry_policy_json: (payload.retry_policy_json as Record<string, never>) ?? {},
+      created_time: iso(),
+    };
+    toolVersions.unshift(created);
+    return created;
+  }
+  if (url === "/tools/bindings") return params.app_id ? toolBindings.filter((item) => item.app_id === params.app_id) : toolBindings;
+  if (url === "/tools/credentials" && method === "get") return toolCredentials;
+  if (url === "/tools/credentials" && method === "post") {
+    const secret = JSON.stringify(payload.secret_json ?? {});
+    const created: ToolCredential = {
+      id: id("tc"),
+      tenant_id: String(payload.tenant_id ?? "public"),
+      name: String(payload.name ?? "New Credential"),
+      credential_type: String(payload.credential_type ?? "api_key"),
+      secret_fingerprint: `sha256:${Math.abs(hashText(secret)).toString(16).slice(0, 10)}`,
+      encryption_algorithm: "mock",
+      metadata_json: (payload.metadata_json as Record<string, never>) ?? {},
+      created_time: iso(),
+    };
+    toolCredentials.unshift(created);
+    return created;
+  }
+
+  if (url === "/knowledge/bases" && method === "get") return knowledgeBases;
+  if (url === "/knowledge/bases" && method === "post") {
+    const created: KnowledgeBase = {
+      id: id("kb"),
+      tenant_id: String(payload.tenant_id ?? "public"),
+      code: String(payload.code ?? "knowledge"),
+      name: String(payload.name ?? "Knowledge Base"),
+      description: (payload.description as string | null) ?? null,
+      status: "active",
+      metadata_json: {},
+      created_time: iso(),
+    };
+    knowledgeBases.unshift(created);
+    return created;
+  }
+  if (url.match(/^\/knowledge\/bases\/[^/]+\/status$/) && method === "patch") {
+    const knowledgeBaseId = url.split("/")[3];
+    const target = knowledgeBases.find((item) => item.id === knowledgeBaseId);
+    if (!target) return {};
+    target.status = String(payload.status ?? "active") as KnowledgeBase["status"];
+    return target;
+  }
+  if (url === "/knowledge/documents" && method === "get") return knowledgeDocuments.filter((item) => item.knowledge_base_id === params.knowledge_base_id);
+  if (url === "/knowledge/documents/parse" && method === "post") {
+    const contentText = String(payload.content_text ?? "");
+    return {
+      content_text: contentText,
+      source_type: String(payload.source_type ?? "text"),
+      metadata_json: {
+        character_count: contentText.length,
+        estimated_tokens: Math.max(1, Math.ceil(contentText.length / 4)),
+      },
+    };
+  }
+  if (url === "/knowledge/documents" && method === "post") {
+    const created: KnowledgeDocument = {
+      id: id("kd"),
+      tenant_id: String(payload.tenant_id ?? "public"),
+      knowledge_base_id: String(payload.knowledge_base_id),
+      title: String(payload.title ?? "Untitled document"),
+      source_type: String(payload.source_type ?? "text"),
+      source_uri: (payload.source_uri as string | null) ?? null,
+      status: "indexed",
+      content_hash: "mock",
+      metadata_json: (payload.metadata_json as Record<string, never>) ?? {},
+      indexed_time: iso(),
+      created_time: iso(),
+    };
+    const contentText = String(payload.content_text ?? "");
+    const chunk: KnowledgeChunk = {
+      id: id("chunk"),
+      tenant_id: created.tenant_id,
+      knowledge_base_id: created.knowledge_base_id,
+      document_id: created.id,
+      chunk_index: 0,
+      content_text: contentText || `Mock indexed content for ${created.title}.`,
+      token_count: Math.max(1, Math.ceil((contentText.length || 64) / 4)),
+      embedding_model: "mock-embedding",
+      embedding_json: null,
+      metadata_json: { embedding_provider: "mock" },
+      created_time: iso(),
+    };
+    knowledgeDocuments.unshift(created);
+    knowledgeChunks.unshift(chunk);
+    return {
+      document: created,
+      chunks: [chunk],
+    };
+  }
+  if (url === "/knowledge/search") return mockSearch(String(payload.knowledge_base_id), String(payload.query ?? ""), Number(payload.top_k ?? 5));
+
+  if (url === "/teams" && method === "get") return teams;
+  if (url === "/teams" && method === "post") {
+    const created: TeamDefinition = {
+      id: id("team"),
+      tenant_id: String(payload.tenant_id ?? "public"),
+      code: String(payload.code ?? "team"),
+      name: String(payload.name ?? "New Team"),
+      description: (payload.description as string | null) ?? null,
+      team_type: String(payload.team_type ?? "collaborative"),
+      status: "draft",
+      owner_user_id: (payload.owner_user_id as string | null) ?? null,
+      created_time: iso(),
+    };
+    teams.unshift(created);
+    return created;
+  }
+  if (url === "/teams/versions" && method === "get") return teamVersions.filter((item) => item.team_id === params.team_id);
+  if (url === "/teams/versions" && method === "post") {
+    const versionNo = teamVersions.filter((item) => item.team_id === payload.team_id).length + 1;
+    const created: TeamVersion = {
+      id: id("teamv"),
+      tenant_id: String(payload.tenant_id ?? "public"),
+      team_id: String(payload.team_id),
+      version_no: versionNo,
+      status: String(payload.status ?? "draft") as TeamVersion["status"],
+      coordination_mode: String(payload.coordination_mode ?? "supervisor"),
+      objective: (payload.objective as string | null) ?? null,
+      member_refs_json: (payload.member_refs as Record<string, never>[]) ?? [],
+      policy_json: (payload.policy_json as Record<string, never>) ?? {},
+      published_time: null,
+      created_time: iso(),
+    };
+    teamVersions.unshift(created);
+    return created;
+  }
+  if (url === "/teams/runs" && method === "get") return params.team_id ? teamRuns.filter((item) => item.team_id === params.team_id) : teamRuns;
+  if (url === "/teams/runs" && method === "post") {
+    const created: TeamRun = {
+      id: id("tr"),
+      tenant_id: String(payload.tenant_id ?? "public"),
+      team_id: String(payload.team_id),
+      team_version_id: String(payload.team_version_id ?? teamVersions.find((item) => item.team_id === payload.team_id)?.id ?? "teamv_mock"),
+      session_id: null,
+      input_text: (payload.input_text as string | null) ?? null,
+      input_json: (payload.input_json as Record<string, never> | null) ?? null,
+      output_text: "Mock team run queued.",
+      output_json: {},
+      status: "queued",
+      created_time: iso(),
+    };
+    teamRuns.unshift(created);
+    return created;
+  }
+
+  return {};
+}
+
+function normalize(url?: string) {
+  const path = (url ?? "").split("?")[0] ?? "";
+  return path.startsWith("/gateway") ? path.slice("/gateway".length) || "/" : path;
+}
+
+function validateDsl(dsl: WorkflowDSL): WorkflowDesignerValidateResponse {
+  const nodeIds = new Set((dsl?.nodes ?? []).map((node) => node.id));
+  const diagnostics = [];
+  if (!dsl?.nodes?.length) diagnostics.push({ severity: "error" as const, code: "EMPTY_GRAPH", message: "Workflow must contain at least one node." });
+  for (const edge of dsl?.edges ?? []) {
+    if (!nodeIds.has(edge.source) || !nodeIds.has(edge.target)) {
+      diagnostics.push({ severity: "error" as const, code: "INVALID_EDGE", message: `Edge ${edge.source} -> ${edge.target} references a missing node.` });
+    }
+  }
+  return {
+    valid: diagnostics.length === 0,
+    diagnostics,
+    node_count: dsl?.nodes?.length ?? 0,
+    edge_count: dsl?.edges?.length ?? 0,
+    nodes: (dsl?.nodes ?? []).map((node) => ({
+      ...node,
+      incoming_count: (dsl.edges ?? []).filter((edge) => edge.target === node.id).length,
+      outgoing_count: (dsl.edges ?? []).filter((edge) => edge.source === node.id).length,
+      reachable: true,
+    })),
+    edges: (dsl?.edges ?? []).map((edge) => ({ ...edge, valid_source: nodeIds.has(edge.source), valid_target: nodeIds.has(edge.target) })),
+    entry_node_ids: (dsl?.nodes ?? []).filter((node) => !(dsl.edges ?? []).some((edge) => edge.target === node.id)).map((node) => node.id),
+    terminal_node_ids: (dsl?.nodes ?? []).filter((node) => !(dsl.edges ?? []).some((edge) => edge.source === node.id)).map((node) => node.id),
+    isolated_node_ids: [],
+    unreachable_node_ids: [],
+    cycle_detected: false,
+    normalized_dsl_json: dsl,
+  };
+}
+
+function debugDsl(dsl: WorkflowDSL): WorkflowDebuggerPlanResponse {
+  const base = validateDsl(dsl);
+  return {
+    ...base,
+    execution_preview: (dsl?.nodes ?? []).slice(0, 12).map((node, index) => ({
+      step_index: index,
+      node_id: node.id,
+      node_type: node.type,
+      name: node.name,
+      next_node_ids: (dsl.edges ?? []).filter((edge) => edge.source === node.id).map((edge) => edge.target),
+    })),
+    max_preview_steps: 50,
+    truncated: false,
+  };
+}
+
+function mockSearch(knowledgeBaseId: string, query: string, topK: number): SearchResult[] {
+  const baseDocuments = knowledgeDocuments.filter((item) => item.knowledge_base_id === knowledgeBaseId && item.status === "indexed");
+  const fallbackDocument = baseDocuments[0] ?? knowledgeDocuments[0] ?? {
+    id: "kd_mock",
+    tenant_id: "public",
+    knowledge_base_id: knowledgeBaseId,
+    title: "Mock Document",
+    source_type: "text",
+    source_uri: null,
+    status: "indexed",
+    content_hash: "mock",
+    metadata_json: {},
+    indexed_time: iso(),
+    created_time: iso(),
+  };
+  const candidates = knowledgeChunks.filter((item) => item.knowledge_base_id === knowledgeBaseId);
+  const chunks = candidates.length
+    ? candidates
+    : [{
+        id: "chunk_mock_1",
+        tenant_id: "public",
+        knowledge_base_id: knowledgeBaseId,
+        document_id: fallbackDocument.id,
+        chunk_index: 0,
+        content_text: `Mock result for "${query || "your query"}": invoices can be retrieved from the customer billing profile.`,
+        token_count: 24,
+        embedding_model: "mock-embedding",
+        embedding_json: null,
+        metadata_json: {},
+        created_time: iso(-1200),
+      }];
+  return chunks.slice(0, Math.max(1, topK)).map((chunk, index) => {
+    const document = knowledgeDocuments.find((item) => item.id === chunk.document_id) ?? fallbackDocument;
+    const score = Math.max(0.45, 0.94 - index * 0.08);
+    return {
+      document,
+      chunk,
+      score,
+      score_json: {
+        final_score: score,
+        keyword_score: Math.max(0.3, score - 0.12),
+        vector_score: Math.max(0.3, score - 0.06),
+        rerank_score: Math.max(0.1, score - 0.22),
+        retrieval_mode: "mock-hybrid",
+        citation: { document_id: document.id, document_title: document.title, source_uri: document.source_uri ?? null, chunk_id: chunk.id, chunk_index: chunk.chunk_index },
+      },
+    };
+  });
+}

+ 24 - 0
web/src/api/runtime.ts

@@ -0,0 +1,24 @@
+import { apiClient, tenantParams } from "./client";
+import type { ExecutionLog, NodeRun, TraceSpan, WorkflowRun } from "@/types";
+
+export async function listRuns(limit = 50) {
+  const { data } = await apiClient.get<WorkflowRun[]>("/runtime/runs", { params: tenantParams({ limit }) });
+  return data;
+}
+
+export async function listNodeRuns(runId?: string) {
+  const { data } = await apiClient.get<NodeRun[]>("/runtime/node-runs", { params: tenantParams({ run_id: runId }) });
+  return data;
+}
+
+export async function listExecutionLogs(runId?: string) {
+  const { data } = await apiClient.get<ExecutionLog[]>("/runtime/execution-logs", {
+    params: tenantParams({ run_id: runId }),
+  });
+  return data;
+}
+
+export async function listTraceSpans(runId?: string) {
+  const { data } = await apiClient.get<TraceSpan[]>("/runtime/trace-spans", { params: tenantParams({ run_id: runId }) });
+  return data;
+}

+ 43 - 0
web/src/api/sessions.ts

@@ -0,0 +1,43 @@
+import { apiClient, tenantParams } from "./client";
+import type { Message, RunRequest, Session } from "@/types";
+
+export async function listSessions() {
+  const { data } = await apiClient.get<Session[]>("/sessions", { params: tenantParams() });
+  return data;
+}
+
+export async function createSession(payload: {
+  tenant_id: string;
+  app_id: string;
+  user_id: string;
+  channel_type: string;
+  title?: string | null;
+}) {
+  const { data } = await apiClient.post<Session>("/sessions", payload);
+  return data;
+}
+
+export async function listMessages(sessionId?: string) {
+  const { data } = await apiClient.get<Message[]>("/sessions/messages", {
+    params: tenantParams({ session_id: sessionId }),
+  });
+  return data;
+}
+
+export async function createMessage(payload: {
+  tenant_id: string;
+  session_id: string;
+  role: string;
+  content_type?: string;
+  content_text?: string;
+}) {
+  const { data } = await apiClient.post<Message>("/sessions/messages", payload);
+  return data;
+}
+
+export async function listRunRequests(sessionId?: string) {
+  const { data } = await apiClient.get<RunRequest[]>("/sessions/run-requests", {
+    params: tenantParams({ session_id: sessionId }),
+  });
+  return data;
+}

+ 67 - 0
web/src/api/teams.ts

@@ -0,0 +1,67 @@
+import { apiClient, tenantParams } from "./client";
+import type { JSONObject, TeamDefinition, TeamRun, TeamVersion } from "@/types";
+
+export async function listTeams() {
+  const { data } = await apiClient.get<TeamDefinition[]>("/teams", { params: tenantParams() });
+  return data;
+}
+
+export async function listTeamVersions(teamId?: string) {
+  const { data } = await apiClient.get<TeamVersion[]>("/teams/versions", { params: tenantParams({ team_id: teamId }) });
+  return data;
+}
+
+export async function createTeamVersion(payload: {
+  tenant_id: string;
+  team_id: string;
+  coordination_mode: string;
+  objective?: string | null;
+  status?: "draft" | "published" | "deprecated";
+  member_refs?: JSONObject[];
+  policy_json?: JSONObject;
+}) {
+  const { data } = await apiClient.post<TeamVersion>("/teams/versions", {
+    status: "draft",
+    member_refs: [],
+    policy_json: {},
+    ...payload,
+  });
+  return data;
+}
+
+export async function listTeamRuns(teamId?: string) {
+  const { data } = await apiClient.get<TeamRun[]>("/teams/runs", { params: tenantParams({ team_id: teamId }) });
+  return data;
+}
+
+export async function createTeamRun(payload: {
+  tenant_id: string;
+  team_id: string;
+  team_version_id?: string | null;
+  input_text?: string | null;
+  input_json?: JSONObject | null;
+}) {
+  const { data } = await apiClient.post<TeamRun>("/teams/runs", payload);
+  return data;
+}
+
+export async function createTeam(payload: {
+  tenant_id: string;
+  code?: string;
+  name: string;
+  description?: string | null;
+  team_type: string;
+  owner_user_id?: string | null;
+  metadata_json?: JSONObject;
+}) {
+  const { data } = await apiClient.post<TeamDefinition>("/teams", {
+    ...payload,
+    code: payload.code || slugifyName(payload.name, "team"),
+  });
+  return data;
+}
+
+function slugifyName(value: string, fallback: string) {
+  const slug = value.trim().toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
+  return slug || `${fallback}_${Date.now().toString(36)}`;
+}

+ 72 - 0
web/src/api/tools.ts

@@ -0,0 +1,72 @@
+import { apiClient, tenantParams } from "./client";
+import type { JSONObject, ToolBinding, ToolCredential, ToolDefinition, ToolVersion } from "@/types";
+
+export async function listTools() {
+  const { data } = await apiClient.get<ToolDefinition[]>("/tools", { params: tenantParams() });
+  return data;
+}
+
+export async function listToolVersions(toolId?: string) {
+  const { data } = await apiClient.get<ToolVersion[]>("/tools/versions", { params: tenantParams({ tool_id: toolId }) });
+  return data;
+}
+
+export async function createToolVersion(payload: {
+  tenant_id: string;
+  tool_id: string;
+  timeout_ms?: number | null;
+  input_schema_json?: JSONObject;
+  output_schema_json?: JSONObject;
+  invoke_config_json?: JSONObject;
+  retry_policy_json?: JSONObject;
+}) {
+  const { data } = await apiClient.post<ToolVersion>("/tools/versions", {
+    input_schema_json: {},
+    output_schema_json: {},
+    invoke_config_json: {},
+    retry_policy_json: {},
+    ...payload,
+  });
+  return data;
+}
+
+export async function listToolBindings(appId?: string) {
+  const { data } = await apiClient.get<ToolBinding[]>("/tools/bindings", { params: tenantParams({ app_id: appId }) });
+  return data;
+}
+
+export async function createTool(payload: {
+  tenant_id: string;
+  plugin_id?: string | null;
+  code?: string;
+  name: string;
+  tool_type: string;
+  description?: string | null;
+}) {
+  const { data } = await apiClient.post<ToolDefinition>("/tools", {
+    ...payload,
+    code: payload.code || slugifyName(payload.name, "tool"),
+  });
+  return data;
+}
+
+export async function listToolCredentials() {
+  const { data } = await apiClient.get<ToolCredential[]>("/tools/credentials", { params: tenantParams() });
+  return data;
+}
+
+export async function createToolCredential(payload: {
+  tenant_id: string;
+  name: string;
+  credential_type: string;
+  secret_json: JSONObject;
+  metadata_json?: JSONObject;
+}) {
+  const { data } = await apiClient.post<ToolCredential>("/tools/credentials", payload);
+  return data;
+}
+
+function slugifyName(value: string, fallback: string) {
+  const slug = value.trim().toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
+  return slug || `${fallback}_${Date.now().toString(36)}`;
+}

+ 88 - 0
web/src/api/workflows.ts

@@ -0,0 +1,88 @@
+import { apiClient, tenantParams } from "./client";
+import type {
+  AppCreateRequest,
+  AppResponse,
+  AppVersionResponse,
+  WorkflowCreateRequest,
+  WorkflowDefinition,
+  WorkflowDesignerValidateResponse,
+  WorkflowDSL,
+  WorkflowDebuggerPlanResponse,
+  WorkflowVersion,
+} from "@/types";
+
+export async function listApps() {
+  const { data } = await apiClient.get<AppResponse[]>("/workflows/apps", { params: tenantParams() });
+  return data;
+}
+
+export async function createApp(payload: AppCreateRequest) {
+  const { data } = await apiClient.post<AppResponse>("/workflows/apps", payload);
+  return data;
+}
+
+export async function listAppVersions(appId: string) {
+  const { data } = await apiClient.get<AppVersionResponse[]>("/workflows/apps/versions", {
+    params: tenantParams({ app_id: appId }),
+  });
+  return data;
+}
+
+export async function listWorkflows(appId?: string) {
+  const { data } = await apiClient.get<WorkflowDefinition[]>("/workflows", {
+    params: tenantParams({ app_id: appId }),
+  });
+  return data;
+}
+
+export async function createWorkflow(payload: Omit<WorkflowCreateRequest, "code"> & { code?: string }) {
+  const { data } = await apiClient.post<WorkflowDefinition>("/workflows", {
+    ...payload,
+    code: payload.code || slugifyName(payload.name, "workflow"),
+  });
+  return data;
+}
+
+export async function listWorkflowVersions(workflowId: string) {
+  const { data } = await apiClient.get<WorkflowVersion[]>("/workflows/versions", {
+    params: tenantParams({ workflow_id: workflowId }),
+  });
+  return data;
+}
+
+export async function getWorkflowVersion(workflowVersionId: string) {
+  const { data } = await apiClient.get<WorkflowVersion>(`/workflows/versions/${workflowVersionId}`, {
+    params: tenantParams(),
+  });
+  return data;
+}
+
+export async function createWorkflowVersion(payload: {
+  tenant_id: string;
+  workflow_id: string;
+  dsl_json: WorkflowDSL;
+  status?: string;
+}) {
+  const { data } = await apiClient.post<WorkflowVersion>("/workflows/versions", payload);
+  return data;
+}
+
+export async function validateWorkflow(dsl: WorkflowDSL) {
+  const { data } = await apiClient.post<WorkflowDesignerValidateResponse>("/workflows/designer/validate", {
+    dsl_json: dsl,
+  });
+  return data;
+}
+
+export async function debugWorkflow(dsl: WorkflowDSL) {
+  const { data } = await apiClient.post<WorkflowDebuggerPlanResponse>("/workflows/designer/debug", {
+    dsl_json: dsl,
+    max_preview_steps: 50,
+  });
+  return data;
+}
+
+function slugifyName(value: string, fallback: string) {
+  const slug = value.trim().toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
+  return slug || `${fallback}_${Date.now().toString(36)}`;
+}

+ 9 - 0
web/src/components/auth/ProtectedRoute.tsx

@@ -0,0 +1,9 @@
+import { Navigate, useLocation } from "react-router-dom";
+import { useAuthStore } from "@/stores/auth";
+
+export function ProtectedRoute({ children }: { children: React.ReactNode }) {
+  const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
+  const location = useLocation();
+  if (!isAuthenticated) return <Navigate to="/login" replace state={{ from: location.pathname }} />;
+  return <>{children}</>;
+}

+ 21 - 0
web/src/components/layout/AppLayout.tsx

@@ -0,0 +1,21 @@
+import { Outlet } from "react-router-dom";
+import { Header } from "./Header";
+import { MobileSidebar, Sidebar } from "./Sidebar";
+
+export function AppLayout() {
+  return (
+    <div className="flex min-h-screen bg-surface-base">
+      <a href="#main-content" className="skip-link">
+        Skip to main content
+      </a>
+      <Sidebar />
+      <MobileSidebar />
+      <div className="min-w-0 flex-1">
+        <Header />
+        <main id="main-content" className="mx-auto w-full max-w-[1600px] px-4 py-6 md:px-6">
+          <Outlet />
+        </main>
+      </div>
+    </div>
+  );
+}

+ 25 - 0
web/src/components/layout/Breadcrumb.tsx

@@ -0,0 +1,25 @@
+import { Link, useLocation } from "react-router-dom";
+import { ChevronRight } from "lucide-react";
+
+export function Breadcrumb() {
+  const { pathname } = useLocation();
+  const parts = pathname.split("/").filter(Boolean);
+  return (
+    <div className="flex items-center gap-1 text-sm text-muted-foreground">
+      <Link to="/dashboard" className="hover:text-foreground">
+        Studio
+      </Link>
+      {parts.map((part, index) => {
+        const href = `/${parts.slice(0, index + 1).join("/")}`;
+        return (
+          <span key={href} className="flex items-center gap-1">
+            <ChevronRight className="h-3 w-3" />
+            <Link to={href} className="capitalize hover:text-foreground">
+              {part.replace(/-/g, " ")}
+            </Link>
+          </span>
+        );
+      })}
+    </div>
+  );
+}

+ 59 - 0
web/src/components/layout/Header.tsx

@@ -0,0 +1,59 @@
+import { LogOut, Menu, Moon, Sun, User } from "lucide-react";
+import { useNavigate } from "react-router-dom";
+import { Button } from "@/components/ui/button";
+import { Breadcrumb } from "./Breadcrumb";
+import { useAuthStore } from "@/stores/auth";
+import { useUiStore } from "@/stores/ui";
+import { mockMode } from "@/api/mock";
+
+export function Header() {
+  const navigate = useNavigate();
+  const { tenantId, userId, logout } = useAuthStore();
+  const { theme, toggleTheme, toggleMobileSidebar } = useUiStore();
+  return (
+    <header className="glass sticky top-0 z-30 flex h-16 items-center justify-between px-4 md:px-6">
+      <div className="flex items-center gap-3">
+        <Button className="md:hidden" variant="ghost" size="icon" onClick={toggleMobileSidebar} aria-label="Open navigation">
+          <Menu className="h-4 w-4" />
+        </Button>
+        <Breadcrumb />
+      </div>
+      <div className="flex items-center gap-3">
+        {mockMode ? (
+          <div className="hidden items-center gap-2 rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs text-amber-700 dark:text-amber-200 sm:flex">
+            Mock Data
+          </div>
+        ) : null}
+        <div className="hidden items-center gap-2 rounded-md border border-border bg-muted/40 px-3 py-2 text-xs text-muted-foreground sm:flex">
+          <span className="h-2 w-2 rounded-full bg-emerald-400 shadow-[0_0_10px_rgba(16,185,129,0.8)]" />
+          Gateway
+        </div>
+        <div className="hidden items-center gap-2 text-sm text-muted-foreground sm:flex">
+          <User className="h-4 w-4" />
+          <span>{userId || "user"}</span>
+          <span className="text-muted-foreground/40">/</span>
+          <span>{tenantId}</span>
+        </div>
+        <Button
+          variant="ghost"
+          size="icon"
+          onClick={toggleTheme}
+          aria-label={theme === "dark" ? "Switch to light mode" : "Switch to dark mode"}
+        >
+          {theme === "dark" ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
+        </Button>
+        <Button
+          variant="ghost"
+          size="icon"
+          onClick={() => {
+            logout();
+            navigate("/login", { replace: true });
+          }}
+          aria-label="Logout"
+        >
+          <LogOut className="h-4 w-4" />
+        </Button>
+      </div>
+    </header>
+  );
+}

+ 89 - 0
web/src/components/layout/Sidebar.tsx

@@ -0,0 +1,89 @@
+import { NavLink } from "react-router-dom";
+import { PanelLeftClose, PanelLeftOpen, Workflow } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { Separator } from "@/components/ui/separator";
+import { NAV_ITEMS } from "@/lib/constants";
+import { cn } from "@/lib/utils";
+import { useUiStore } from "@/stores/ui";
+
+function NavItems({ collapsed, onNavigate }: { collapsed?: boolean; onNavigate?: () => void }) {
+  return (
+    <>
+      {NAV_ITEMS.map((item, index) => (
+        <NavLink
+          key={item.path}
+          to={item.path}
+          onClick={onNavigate}
+          className={({ isActive }) =>
+            cn(
+              "flex min-h-11 items-center gap-3 rounded-md px-3 text-sm text-muted-foreground transition hover:bg-muted hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-primary",
+              isActive && "bg-primary/15 text-primary",
+              index === 7 && "mt-auto",
+              collapsed && "justify-center px-0",
+            )
+          }
+          title={item.label}
+        >
+          <item.icon className="h-4 w-4 shrink-0" />
+          {!collapsed ? <span>{item.label}</span> : null}
+        </NavLink>
+      ))}
+    </>
+  );
+}
+
+export function Sidebar() {
+  const { sidebarCollapsed, toggleSidebar } = useUiStore();
+  return (
+    <aside
+      className={cn(
+        "hidden shrink-0 border-r border-border bg-surface-deep md:flex md:flex-col",
+        sidebarCollapsed ? "w-16" : "w-60",
+      )}
+    >
+      <div className="flex h-16 items-center gap-3 px-4">
+        <div className="grid h-9 w-9 place-items-center rounded-md bg-primary/15 text-primary">
+          <Workflow className="h-5 w-5" />
+        </div>
+        {!sidebarCollapsed ? <span className="text-sm font-semibold">Auto Platform</span> : null}
+      </div>
+      <Separator />
+      <nav className="flex flex-1 flex-col gap-1 p-3">
+        <NavItems collapsed={sidebarCollapsed} />
+      </nav>
+      <Separator />
+      <div className="p-3">
+        <Button variant="ghost" size={sidebarCollapsed ? "icon" : "md"} className="w-full" onClick={toggleSidebar}>
+          {sidebarCollapsed ? <PanelLeftOpen className="h-4 w-4" /> : <PanelLeftClose className="h-4 w-4" />}
+          {!sidebarCollapsed ? "Collapse" : null}
+        </Button>
+      </div>
+    </aside>
+  );
+}
+
+export function MobileSidebar() {
+  const { mobileSidebarOpen, setMobileSidebarOpen } = useUiStore();
+  if (!mobileSidebarOpen) return null;
+  return (
+    <div className="fixed inset-0 z-50 md:hidden">
+      <button
+        className="absolute inset-0 bg-black/45"
+        aria-label="Close navigation"
+        onClick={() => setMobileSidebarOpen(false)}
+      />
+      <aside className="relative flex h-full w-[min(82vw,320px)] flex-col border-r border-border bg-surface-deep shadow-glow">
+        <div className="flex h-16 items-center gap-3 px-4">
+          <div className="grid h-9 w-9 place-items-center rounded-md bg-primary/15 text-primary">
+            <Workflow className="h-5 w-5" />
+          </div>
+          <span className="text-sm font-semibold">Auto Platform</span>
+        </div>
+        <Separator />
+        <nav className="flex flex-1 flex-col gap-1 p-3">
+          <NavItems onNavigate={() => setMobileSidebarOpen(false)} />
+        </nav>
+      </aside>
+    </div>
+  );
+}

+ 21 - 0
web/src/components/shared/ApiErrorState.tsx

@@ -0,0 +1,21 @@
+import { AlertTriangle } from "lucide-react";
+import { Button } from "@/components/ui/button";
+
+export function ApiErrorState({ message, onRetry }: { message?: string; onRetry?: () => void }) {
+  return (
+    <div className="rounded-md border border-red-500/20 bg-red-500/10 p-5">
+      <div className="flex items-start gap-3">
+        <AlertTriangle className="mt-0.5 h-5 w-5 shrink-0 text-red-700 dark:text-red-300" />
+        <div className="min-w-0 flex-1">
+          <h2 className="text-sm font-semibold text-red-100">Unable to load data</h2>
+          <p className="mt-1 text-sm text-red-100/70">{message ?? "Check the gateway connection and credentials."}</p>
+          {onRetry ? (
+            <Button className="mt-4" variant="outline" onClick={onRetry}>
+              Retry
+            </Button>
+          ) : null}
+        </div>
+      </div>
+    </div>
+  );
+}

+ 35 - 0
web/src/components/shared/ConfirmDialog.tsx

@@ -0,0 +1,35 @@
+import { Dialog } from "@/components/ui/dialog";
+import { Button } from "@/components/ui/button";
+
+export function ConfirmDialog({
+  open,
+  onOpenChange,
+  title,
+  description,
+  onConfirm,
+}: {
+  open: boolean;
+  onOpenChange: (open: boolean) => void;
+  title: string;
+  description: string;
+  onConfirm: () => void;
+}) {
+  return (
+    <Dialog open={open} onOpenChange={onOpenChange} title={title} description={description}>
+      <div className="flex justify-end gap-2">
+        <Button variant="ghost" onClick={() => onOpenChange(false)}>
+          Cancel
+        </Button>
+        <Button
+          variant="destructive"
+          onClick={() => {
+            onConfirm();
+            onOpenChange(false);
+          }}
+        >
+          Confirm
+        </Button>
+      </div>
+    </Dialog>
+  );
+}

+ 29 - 0
web/src/components/shared/EmptyState.tsx

@@ -0,0 +1,29 @@
+import type { LucideIcon } from "lucide-react";
+import { Button } from "@/components/ui/button";
+
+export function EmptyState({
+  icon: Icon,
+  title,
+  description,
+  actionLabel,
+  onAction,
+}: {
+  icon: LucideIcon;
+  title: string;
+  description: string;
+  actionLabel?: string;
+  onAction?: () => void;
+}) {
+  return (
+    <div className="flex min-h-64 flex-col items-center justify-center rounded-md border border-dashed border-border bg-muted/30 p-8 text-center">
+      <Icon className="h-9 w-9 text-muted-foreground" />
+      <h3 className="mt-4 text-base font-semibold">{title}</h3>
+      <p className="mt-2 max-w-md text-sm text-muted-foreground">{description}</p>
+      {actionLabel && onAction ? (
+        <Button className="mt-5" onClick={onAction}>
+          {actionLabel}
+        </Button>
+      ) : null}
+    </div>
+  );
+}

+ 33 - 0
web/src/components/shared/EntityListItem.tsx

@@ -0,0 +1,33 @@
+import type { ReactNode } from "react";
+import { cn } from "@/lib/utils";
+
+export function EntityListItem({
+  active,
+  title,
+  subtitle,
+  meta,
+  onClick,
+}: {
+  active?: boolean;
+  title: string;
+  subtitle?: string | null;
+  meta?: ReactNode;
+  onClick?: () => void;
+}) {
+  return (
+    <button
+      type="button"
+      onClick={onClick}
+      className={cn(
+        "flex min-h-16 w-full items-center justify-between gap-4 rounded-md border border-border bg-muted/40 p-3 text-left transition hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-primary",
+        active && "border-primary/40 bg-primary/10",
+      )}
+    >
+      <span className="min-w-0">
+        <span className="block truncate text-sm font-medium">{title}</span>
+        {subtitle ? <span className="mt-1 block truncate font-mono text-xs text-muted-foreground">{subtitle}</span> : null}
+      </span>
+      {meta ? <span className="shrink-0">{meta}</span> : null}
+    </button>
+  );
+}

+ 25 - 0
web/src/components/shared/ErrorBoundary.tsx

@@ -0,0 +1,25 @@
+import * as React from "react";
+import { Button } from "@/components/ui/button";
+
+export class ErrorBoundary extends React.Component<{ children: React.ReactNode }, { error?: Error }> {
+  state: { error?: Error } = {};
+
+  static getDerivedStateFromError(error: Error) {
+    return { error };
+  }
+
+  render() {
+    if (this.state.error) {
+      return (
+        <div className="rounded-md border border-red-500/20 bg-red-500/10 p-6">
+          <h2 className="text-lg font-semibold text-red-200">Something broke</h2>
+          <p className="mt-2 text-sm text-red-100/70">{this.state.error.message}</p>
+          <Button className="mt-4" onClick={() => this.setState({ error: undefined })}>
+            Retry
+          </Button>
+        </div>
+      );
+    }
+    return this.props.children;
+  }
+}

+ 18 - 0
web/src/components/shared/JsonViewer.tsx

@@ -0,0 +1,18 @@
+import * as React from "react";
+import { Button } from "@/components/ui/button";
+
+export function JsonViewer({ value, collapsed = true }: { value: unknown; collapsed?: boolean }) {
+  const [open, setOpen] = React.useState(!collapsed);
+  const text = JSON.stringify(value ?? {}, null, 2);
+  return (
+    <div className="rounded-md border border-border bg-muted/60">
+      <div className="flex items-center justify-between border-b border-border px-3 py-2">
+        <span className="font-mono text-xs text-muted-foreground">JSON</span>
+        <Button size="sm" variant="ghost" onClick={() => setOpen((value) => !value)}>
+          {open ? "Collapse" : "Expand"}
+        </Button>
+      </div>
+      {open ? <pre className="max-h-96 overflow-auto p-3 font-mono text-xs text-foreground">{text}</pre> : null}
+    </div>
+  );
+}

+ 8 - 0
web/src/components/shared/LoadingSpinner.tsx

@@ -0,0 +1,8 @@
+export function LoadingSpinner({ label = "Loading" }: { label?: string }) {
+  return (
+    <div className="flex min-h-64 items-center justify-center gap-3 text-sm text-muted-foreground">
+      <span className="h-5 w-5 animate-spin rounded-full border-2 border-primary border-t-transparent" />
+      {label}
+    </div>
+  );
+}

+ 26 - 0
web/src/components/shared/MetricCard.tsx

@@ -0,0 +1,26 @@
+import type { LucideIcon } from "lucide-react";
+import { Card, CardContent } from "@/components/ui/card";
+
+export function MetricCard({
+  label,
+  value,
+  icon: Icon,
+}: {
+  label: string;
+  value: string | number;
+  icon: LucideIcon;
+}) {
+  return (
+    <Card>
+      <CardContent className="flex items-center justify-between gap-4 p-4">
+        <div>
+          <p className="text-sm text-muted-foreground">{label}</p>
+          <p className="mt-1 text-2xl font-semibold tabular-nums">{value}</p>
+        </div>
+        <div className="grid h-10 w-10 place-items-center rounded-md bg-primary/15 text-primary">
+          <Icon className="h-5 w-5" />
+        </div>
+      </CardContent>
+    </Card>
+  );
+}

+ 21 - 0
web/src/components/shared/PageHeader.tsx

@@ -0,0 +1,21 @@
+import type { ReactNode } from "react";
+
+export function PageHeader({
+  title,
+  description,
+  actions,
+}: {
+  title: string;
+  description?: string;
+  actions?: ReactNode;
+}) {
+  return (
+    <div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
+      <div>
+        <h1 className="text-2xl font-semibold tracking-normal">{title}</h1>
+        {description ? <p className="mt-1 text-sm text-muted-foreground">{description}</p> : null}
+      </div>
+      {actions ? <div className="flex flex-wrap items-center gap-2">{actions}</div> : null}
+    </div>
+  );
+}

+ 11 - 0
web/src/components/shared/RelativeTime.tsx

@@ -0,0 +1,11 @@
+import * as React from "react";
+import { relativeTime } from "@/lib/utils";
+
+export function RelativeTime({ value }: { value?: string | null }) {
+  const [, force] = React.useState(0);
+  React.useEffect(() => {
+    const id = window.setInterval(() => force((tick) => tick + 1), 30000);
+    return () => window.clearInterval(id);
+  }, []);
+  return <span title={value ?? undefined}>{relativeTime(value)}</span>;
+}

+ 25 - 0
web/src/components/shared/SearchInput.tsx

@@ -0,0 +1,25 @@
+import { Search } from "lucide-react";
+import { Input } from "@/components/ui/input";
+
+export function SearchInput({
+  value,
+  onChange,
+  placeholder = "Search",
+}: {
+  value: string;
+  onChange: (value: string) => void;
+  placeholder?: string;
+}) {
+  return (
+    <label className="relative block w-full sm:w-72">
+      <Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
+      <Input
+        aria-label={placeholder}
+        className="pl-9"
+        value={value}
+        onChange={(event) => onChange(event.target.value)}
+        placeholder={placeholder}
+      />
+    </label>
+  );
+}

+ 13 - 0
web/src/components/shared/StatusBadge.tsx

@@ -0,0 +1,13 @@
+import { Badge } from "@/components/ui/badge";
+import { STATUS_COLOR_MAP } from "@/lib/constants";
+import { cn } from "@/lib/utils";
+
+export function StatusBadge({ status }: { status?: string | null }) {
+  const value = status ?? "unknown";
+  return (
+    <Badge className={cn(STATUS_COLOR_MAP[value] ?? "border-border bg-muted/50 text-muted-foreground")}>
+      {value === "running" ? <span className="mr-1 h-1.5 w-1.5 rounded-full bg-current motion-safe:animate-pulse" /> : null}
+      {value}
+    </Badge>
+  );
+}

+ 11 - 0
web/src/components/ui/badge.tsx

@@ -0,0 +1,11 @@
+import * as React from "react";
+import { cn } from "@/lib/utils";
+
+export function Badge({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) {
+  return (
+    <span
+      className={cn("inline-flex items-center rounded-md border px-2 py-0.5 text-xs font-medium", className)}
+      {...props}
+    />
+  );
+}

+ 29 - 0
web/src/components/ui/button.tsx

@@ -0,0 +1,29 @@
+import * as React from "react";
+import { cn } from "@/lib/utils";
+
+export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
+  variant?: "default" | "secondary" | "ghost" | "outline" | "destructive";
+  size?: "sm" | "md" | "icon";
+}
+
+export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
+  ({ className, variant = "default", size = "md", ...props }, ref) => (
+    <button
+      ref={ref}
+      className={cn(
+        "inline-flex min-h-11 cursor-pointer touch-manipulation items-center justify-center gap-2 rounded-md font-medium transition-colors duration-150 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-surface-base disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 motion-reduce:transition-none",
+        variant === "default" && "bg-primary text-primary-foreground hover:bg-primary/90",
+        variant === "secondary" && "bg-muted text-foreground hover:bg-muted/80",
+        variant === "ghost" && "text-muted-foreground hover:bg-muted hover:text-foreground",
+        variant === "outline" && "border border-border bg-transparent text-foreground hover:bg-muted",
+        variant === "destructive" && "bg-destructive text-destructive-foreground hover:bg-destructive/90",
+        size === "sm" && "h-9 px-3 text-xs sm:h-8",
+        size === "md" && "h-11 px-4 text-sm",
+        size === "icon" && "h-11 w-11 p-0 sm:h-10 sm:w-10",
+        className,
+      )}
+      {...props}
+    />
+  ),
+);
+Button.displayName = "Button";

+ 22 - 0
web/src/components/ui/card.tsx

@@ -0,0 +1,22 @@
+import * as React from "react";
+import { cn } from "@/lib/utils";
+
+export function Card({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
+  return <div className={cn("rounded-md border border-border bg-surface-elevated shadow-glow", className)} {...props} />;
+}
+
+export function CardHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
+  return <div className={cn("space-y-1.5 p-5", className)} {...props} />;
+}
+
+export function CardTitle({ className, ...props }: React.HTMLAttributes<HTMLHeadingElement>) {
+  return <h3 className={cn("text-base font-semibold text-foreground", className)} {...props} />;
+}
+
+export function CardDescription({ className, ...props }: React.HTMLAttributes<HTMLParagraphElement>) {
+  return <p className={cn("text-sm text-muted-foreground", className)} {...props} />;
+}
+
+export function CardContent({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
+  return <div className={cn("p-5 pt-0", className)} {...props} />;
+}

+ 41 - 0
web/src/components/ui/dialog.tsx

@@ -0,0 +1,41 @@
+import * as React from "react";
+import { X } from "lucide-react";
+import { Button } from "./button";
+import { cn } from "@/lib/utils";
+
+interface DialogProps {
+  open: boolean;
+  onOpenChange: (open: boolean) => void;
+  title: string;
+  description?: string;
+  children: React.ReactNode;
+  className?: string;
+}
+
+export function Dialog({ open, onOpenChange, title, description, children, className }: DialogProps) {
+  if (!open) return null;
+  return (
+    <div className="fixed inset-0 z-50 grid place-items-center overflow-y-auto bg-black/55 p-3 sm:p-6" role="presentation">
+      <div
+        className={cn(
+          "my-auto max-h-[calc(100dvh-1.5rem)] w-full max-w-2xl overflow-auto rounded-md border border-border bg-surface-elevated shadow-glow sm:max-h-[calc(100dvh-3rem)]",
+          className,
+        )}
+        role="dialog"
+        aria-modal="true"
+        aria-labelledby="dialog-title"
+      >
+        <div className="sticky top-0 z-10 flex items-start justify-between gap-4 border-b border-border bg-surface-elevated p-4 sm:p-5">
+          <div>
+            <h2 id="dialog-title" className="text-lg font-semibold">{title}</h2>
+            {description ? <p className="mt-1 text-sm text-muted-foreground">{description}</p> : null}
+          </div>
+          <Button variant="ghost" size="icon" onClick={() => onOpenChange(false)} aria-label="Close">
+            <X className="h-4 w-4" />
+          </Button>
+        </div>
+        <div className="p-4 sm:p-5">{children}</div>
+      </div>
+    </div>
+  );
+}

+ 41 - 0
web/src/components/ui/dropdown-menu.tsx

@@ -0,0 +1,41 @@
+import * as React from "react";
+import { cn } from "@/lib/utils";
+
+export function DropdownMenu({
+  trigger,
+  children,
+  align = "right",
+}: {
+  trigger: React.ReactNode;
+  children: React.ReactNode;
+  align?: "left" | "right";
+}) {
+  const [open, setOpen] = React.useState(false);
+  return (
+    <div className="relative">
+      <button type="button" onClick={() => setOpen((value) => !value)} className="contents">
+        {trigger}
+      </button>
+      {open ? (
+        <div
+          className={cn(
+            "absolute top-full z-40 mt-2 min-w-44 rounded-md border border-border bg-surface-elevated p-1 shadow-glow",
+            align === "right" ? "right-0" : "left-0",
+          )}
+          onClick={() => setOpen(false)}
+        >
+          {children}
+        </div>
+      ) : null}
+    </div>
+  );
+}
+
+export function DropdownMenuItem({ className, ...props }: React.ButtonHTMLAttributes<HTMLButtonElement>) {
+  return (
+    <button
+      className={cn("flex w-full items-center gap-2 rounded-sm px-3 py-2 text-left text-sm hover:bg-muted", className)}
+      {...props}
+    />
+  );
+}

+ 30 - 0
web/src/components/ui/input.tsx

@@ -0,0 +1,30 @@
+import * as React from "react";
+import { cn } from "@/lib/utils";
+
+export const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
+  ({ className, ...props }, ref) => (
+    <input
+      ref={ref}
+      className={cn(
+        "h-11 w-full rounded-md border border-border bg-muted/40 px-3 text-base text-foreground outline-none transition placeholder:text-muted-foreground focus:border-primary/70 focus:ring-2 focus:ring-primary/20 disabled:cursor-not-allowed disabled:opacity-50 sm:text-sm",
+        className,
+      )}
+      {...props}
+    />
+  ),
+);
+Input.displayName = "Input";
+
+export const Textarea = React.forwardRef<HTMLTextAreaElement, React.TextareaHTMLAttributes<HTMLTextAreaElement>>(
+  ({ className, ...props }, ref) => (
+    <textarea
+      ref={ref}
+      className={cn(
+        "min-h-24 w-full rounded-md border border-border bg-muted/40 px-3 py-2 text-base text-foreground outline-none transition placeholder:text-muted-foreground focus:border-primary/70 focus:ring-2 focus:ring-primary/20 disabled:cursor-not-allowed disabled:opacity-50 sm:text-sm",
+        className,
+      )}
+      {...props}
+    />
+  ),
+);
+Textarea.displayName = "Textarea";

+ 6 - 0
web/src/components/ui/scroll-area.tsx

@@ -0,0 +1,6 @@
+import * as React from "react";
+import { cn } from "@/lib/utils";
+
+export function ScrollArea({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
+  return <div className={cn("overflow-auto", className)} {...props} />;
+}

+ 24 - 0
web/src/components/ui/select.tsx

@@ -0,0 +1,24 @@
+import * as React from "react";
+import { cn } from "@/lib/utils";
+
+export function Select({
+  className,
+  options,
+  ...props
+}: React.SelectHTMLAttributes<HTMLSelectElement> & { options: Array<{ value: string; label: string }> }) {
+  return (
+    <select
+      className={cn(
+        "h-11 w-full rounded-md border border-border bg-surface-elevated px-3 text-base text-foreground outline-none focus:border-primary/70 focus:ring-2 focus:ring-primary/20 disabled:cursor-not-allowed disabled:opacity-50 sm:text-sm",
+        className,
+      )}
+      {...props}
+    >
+      {options.map((option) => (
+        <option key={option.value} value={option.value}>
+          {option.label}
+        </option>
+      ))}
+    </select>
+  );
+}

+ 5 - 0
web/src/components/ui/separator.tsx

@@ -0,0 +1,5 @@
+import { cn } from "@/lib/utils";
+
+export function Separator({ className }: { className?: string }) {
+  return <div className={cn("h-px w-full bg-muted", className)} />;
+}

+ 3 - 0
web/src/components/ui/sheet.tsx

@@ -0,0 +1,3 @@
+import { Dialog } from "./dialog";
+
+export const Sheet = Dialog;

+ 5 - 0
web/src/components/ui/skeleton.tsx

@@ -0,0 +1,5 @@
+import { cn } from "@/lib/utils";
+
+export function Skeleton({ className }: { className?: string }) {
+  return <div className={cn("rounded-md bg-muted", className)} />;
+}

+ 33 - 0
web/src/components/ui/tabs.tsx

@@ -0,0 +1,33 @@
+import * as React from "react";
+import { cn } from "@/lib/utils";
+
+export function Tabs({
+  tabs,
+  value,
+  onChange,
+}: {
+  tabs: Array<{ value: string; label: string; content: React.ReactNode }>;
+  value: string;
+  onChange: (value: string) => void;
+}) {
+  const active = tabs.find((tab) => tab.value === value) ?? tabs[0];
+  return (
+    <div>
+      <div className="flex rounded-md border border-border bg-muted/40 p-1">
+        {tabs.map((tab) => (
+          <button
+            key={tab.value}
+            className={cn(
+              "flex-1 rounded-sm px-3 py-1.5 text-sm text-muted-foreground transition",
+              value === tab.value && "bg-muted text-foreground",
+            )}
+            onClick={() => onChange(tab.value)}
+          >
+            {tab.label}
+          </button>
+        ))}
+      </div>
+      <div className="mt-4">{active?.content}</div>
+    </div>
+  );
+}

+ 54 - 0
web/src/components/ui/toaster.tsx

@@ -0,0 +1,54 @@
+import { create } from "zustand";
+import { Button } from "./button";
+
+type ToastKind = "success" | "error" | "info";
+interface ToastMessage {
+  id: string;
+  title: string;
+  kind: ToastKind;
+}
+
+const useToastStore = create<{
+  items: ToastMessage[];
+  push: (title: string, kind?: ToastKind) => void;
+  remove: (id: string) => void;
+}>((set) => ({
+  items: [],
+  push: (title, kind = "info") => {
+    const id = crypto.randomUUID();
+    set((state) => ({ items: [...state.items, { id, title, kind }] }));
+    window.setTimeout(() => set((state) => ({ items: state.items.filter((item) => item.id !== id) })), 3200);
+  },
+  remove: (id) => set((state) => ({ items: state.items.filter((item) => item.id !== id) })),
+}));
+
+export const toast = {
+  success: (title: string) => useToastStore.getState().push(title, "success"),
+  error: (title: string) => useToastStore.getState().push(title, "error"),
+  info: (title: string) => useToastStore.getState().push(title, "info"),
+};
+
+export function Toaster() {
+  const { items, remove } = useToastStore();
+  return (
+    <div className="fixed bottom-4 right-4 z-[60] flex w-80 flex-col gap-2">
+      {items.map((item) => (
+        <div
+          key={item.id}
+          className={[
+            "rounded-md border bg-surface-elevated p-3 text-sm shadow-glow",
+            item.kind === "success" ? "border-emerald-500/25" : item.kind === "error" ? "border-red-500/25" : "border-border",
+          ].join(" ")}
+          role={item.kind === "error" ? "alert" : "status"}
+        >
+          <div className="flex items-center justify-between gap-3">
+            <span>{item.title}</span>
+            <Button variant="ghost" size="sm" onClick={() => remove(item.id)}>
+              Dismiss
+            </Button>
+          </div>
+        </div>
+      ))}
+    </div>
+  );
+}

+ 5 - 0
web/src/components/ui/tooltip.tsx

@@ -0,0 +1,5 @@
+import * as React from "react";
+
+export function Tooltip({ label, children }: { label: string; children: React.ReactElement }) {
+  return React.cloneElement(children, { title: label } as Partial<unknown>);
+}

+ 8 - 0
web/src/hooks/index.ts

@@ -0,0 +1,8 @@
+export * from "./useApi";
+export * from "./useAuth";
+export * from "./useWorkflows";
+export * from "./useAgents";
+export * from "./useSessions";
+export * from "./useRuntime";
+export * from "./useDebounce";
+export * from "./useInterval";

+ 8 - 0
web/src/hooks/useAgents.ts

@@ -0,0 +1,8 @@
+import { listAgentRuns, listAgentVersions, listAgents } from "@/api";
+import { useApi } from "./useApi";
+
+export const useAgentList = () => useApi(() => listAgents(), []);
+export const useAgentVersions = (agentId?: string) =>
+  useApi(() => (agentId ? listAgentVersions(agentId) : Promise.resolve([])), [agentId]);
+export const useAgentRuns = (agentId?: string) =>
+  useApi(() => (agentId ? listAgentRuns(agentId) : Promise.resolve([])), [agentId]);

+ 28 - 0
web/src/hooks/useApi.ts

@@ -0,0 +1,28 @@
+import * as React from "react";
+
+export function useApi<T>(fn: () => Promise<T>, deps: React.DependencyList = []) {
+  const [data, setData] = React.useState<T | undefined>();
+  const [loading, setLoading] = React.useState(true);
+  const [error, setError] = React.useState<Error | undefined>();
+
+  const refetch = React.useCallback(async () => {
+    setLoading(true);
+    setError(undefined);
+    try {
+      const result = await fn();
+      setData(result);
+      return result;
+    } catch (err) {
+      setError(err instanceof Error ? err : new Error("Request failed"));
+      return undefined;
+    } finally {
+      setLoading(false);
+    }
+  }, deps);
+
+  React.useEffect(() => {
+    void refetch();
+  }, [refetch]);
+
+  return { data, loading, error, refetch };
+}

+ 5 - 0
web/src/hooks/useAuth.ts

@@ -0,0 +1,5 @@
+import { useAuthStore } from "@/stores/auth";
+
+export function useAuth() {
+  return useAuthStore();
+}

+ 10 - 0
web/src/hooks/useDebounce.ts

@@ -0,0 +1,10 @@
+import * as React from "react";
+
+export function useDebounce<T>(value: T, delay = 250) {
+  const [debounced, setDebounced] = React.useState(value);
+  React.useEffect(() => {
+    const id = window.setTimeout(() => setDebounced(value), delay);
+    return () => window.clearTimeout(id);
+  }, [value, delay]);
+  return debounced;
+}

+ 13 - 0
web/src/hooks/useInterval.ts

@@ -0,0 +1,13 @@
+import * as React from "react";
+
+export function useInterval(callback: () => void, delay: number | null) {
+  const savedCallback = React.useRef(callback);
+  React.useEffect(() => {
+    savedCallback.current = callback;
+  }, [callback]);
+  React.useEffect(() => {
+    if (delay === null) return undefined;
+    const id = window.setInterval(() => savedCallback.current(), delay);
+    return () => window.clearInterval(id);
+  }, [delay]);
+}

+ 6 - 0
web/src/hooks/useRuntime.ts

@@ -0,0 +1,6 @@
+import { listNodeRuns, listRuns } from "@/api";
+import { useApi } from "./useApi";
+
+export const useRuns = () => useApi(() => listRuns(), []);
+export const useNodeRuns = (runId?: string) =>
+  useApi(() => (runId ? listNodeRuns(runId) : Promise.resolve([])), [runId]);

+ 6 - 0
web/src/hooks/useSessions.ts

@@ -0,0 +1,6 @@
+import { listMessages, listSessions } from "@/api";
+import { useApi } from "./useApi";
+
+export const useSessionList = () => useApi(() => listSessions(), []);
+export const useSessionMessages = (sessionId?: string) =>
+  useApi(() => (sessionId ? listMessages(sessionId) : Promise.resolve([])), [sessionId]);

+ 7 - 0
web/src/hooks/useWorkflows.ts

@@ -0,0 +1,7 @@
+import { listApps, listWorkflowVersions, listWorkflows } from "@/api";
+import { useApi } from "./useApi";
+
+export const useApps = () => useApi(() => listApps(), []);
+export const useWorkflowList = (appId?: string) => useApi(() => listWorkflows(appId), [appId]);
+export const useWorkflowVersions = (workflowId?: string) =>
+  useApi(() => (workflowId ? listWorkflowVersions(workflowId) : Promise.resolve([])), [workflowId]);

+ 109 - 0
web/src/index.css

@@ -0,0 +1,109 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+@layer base {
+  :root {
+    --background: 220 33% 98%;
+    --foreground: 222 47% 11%;
+    --primary: 217 91% 53%;
+    --primary-foreground: 0 0% 100%;
+    --secondary: 215 25% 27%;
+    --secondary-foreground: 210 40% 98%;
+    --accent: 24 94% 48%;
+    --accent-foreground: 0 0% 100%;
+    --muted: 220 14% 94%;
+    --muted-foreground: 220 10% 36%;
+    --destructive: 0 84% 60%;
+    --destructive-foreground: 0 0% 100%;
+    --border: 220 13% 88%;
+    --input: 220 13% 91%;
+    --ring: 217 91% 53%;
+    --surface-deep: 220 30% 96%;
+    --surface-base: 220 33% 98%;
+    --surface-elevated: 0 0% 100%;
+    --success: 158 64% 38%;
+    --warning: 38 92% 50%;
+    --shadow-glow: 0 14px 48px rgba(15, 23, 42, 0.08), inset 0 1px 0 rgba(255, 255, 255, 0.72);
+    --radius: 0.75rem;
+  }
+
+  .dark {
+    --background: 240 6% 2%;
+    --foreground: 240 2% 92%;
+    --primary: 217 91% 53%;
+    --primary-foreground: 0 0% 100%;
+    --secondary: 217 91% 60%;
+    --secondary-foreground: 0 0% 100%;
+    --accent: 24 94% 48%;
+    --accent-foreground: 0 0% 100%;
+    --muted: 240 20% 10%;
+    --muted-foreground: 220 10% 70%;
+    --destructive: 0 84% 60%;
+    --destructive-foreground: 0 0% 100%;
+    --border: 240 9% 13%;
+    --input: 240 9% 12%;
+    --ring: 217 91% 53%;
+    --surface-deep: 240 14% 1%;
+    --surface-base: 240 9% 2%;
+    --surface-elevated: 240 9% 4%;
+    --success: 158 64% 52%;
+    --warning: 38 92% 50%;
+    --shadow-glow: 0 18px 70px rgba(0, 0, 0, 0.45), inset 0 1px 0 rgba(255, 255, 255, 0.03);
+  }
+
+  * {
+    @apply border-border;
+  }
+
+  body {
+    @apply bg-surface-base text-foreground;
+    font-feature-settings: "rlig" 1, "calt" 1;
+  }
+
+  html {
+    color-scheme: light;
+  }
+
+  html.dark {
+    color-scheme: dark;
+  }
+
+  /* Scrollbar */
+  ::-webkit-scrollbar {
+    width: 6px;
+    height: 6px;
+  }
+
+  ::-webkit-scrollbar-track {
+    background: transparent;
+  }
+
+  ::-webkit-scrollbar-thumb {
+    background: rgba(255, 255, 255, 0.1);
+    border-radius: 3px;
+  }
+
+  ::-webkit-scrollbar-thumb:hover {
+    background: rgba(255, 255, 255, 0.2);
+  }
+}
+
+@layer utilities {
+  .glass {
+    @apply backdrop-blur-md bg-surface-elevated/85 border-b border-border;
+  }
+
+  .skip-link {
+    @apply sr-only focus:not-sr-only focus:fixed focus:left-4 focus:top-4 focus:z-[100] focus:rounded-md focus:bg-primary focus:px-4 focus:py-2 focus:text-sm focus:font-medium focus:text-primary-foreground;
+  }
+
+  @media (prefers-reduced-motion: reduce) {
+    *, *::before, *::after {
+      animation-duration: 0.01ms !important;
+      animation-iteration-count: 1 !important;
+      scroll-behavior: auto !important;
+      transition-duration: 0.01ms !important;
+    }
+  }
+}

+ 57 - 0
web/src/lib/constants.ts

@@ -0,0 +1,57 @@
+import {
+  Bot,
+  BookOpen,
+  GitBranch,
+  LayoutDashboard,
+  MessageSquare,
+  Settings,
+  Users,
+  Wrench,
+  type LucideIcon,
+} from "lucide-react";
+
+export const ROUTE_PATHS = {
+  login: "/login",
+  dashboard: "/dashboard",
+  workflows: "/workflows",
+  agents: "/agents",
+  sessions: "/sessions",
+  tools: "/tools",
+  knowledge: "/knowledge",
+  teams: "/teams",
+  settings: "/settings",
+} as const;
+
+export const NAV_ITEMS: Array<{ label: string; path: string; icon: LucideIcon }> = [
+  { label: "Dashboard", path: ROUTE_PATHS.dashboard, icon: LayoutDashboard },
+  { label: "Workflows", path: ROUTE_PATHS.workflows, icon: GitBranch },
+  { label: "Agents", path: ROUTE_PATHS.agents, icon: Bot },
+  { label: "Sessions", path: ROUTE_PATHS.sessions, icon: MessageSquare },
+  { label: "Tools", path: ROUTE_PATHS.tools, icon: Wrench },
+  { label: "Knowledge", path: ROUTE_PATHS.knowledge, icon: BookOpen },
+  { label: "Teams", path: ROUTE_PATHS.teams, icon: Users },
+  { label: "Settings", path: ROUTE_PATHS.settings, icon: Settings },
+];
+
+export const NODE_TYPE_CONFIG = {
+  start: { label: "Start", color: "border-border", accent: "#e5e7eb" },
+  llm: { label: "LLM", color: "border-primary/70", accent: "#2563EB" },
+  tool: { label: "Tool", color: "border-violet-500/70", accent: "#8B5CF6" },
+  condition: { label: "Condition", color: "border-accent/70", accent: "#EA580C" },
+  answer: { label: "Answer", color: "border-emerald-500/70", accent: "#10B981" },
+} as const;
+
+export const STATUS_COLOR_MAP: Record<string, string> = {
+  pending: "border-amber-500/35 bg-amber-500/10 text-amber-800 dark:text-amber-300",
+  queued: "border-amber-500/35 bg-amber-500/10 text-amber-800 dark:text-amber-300",
+  running: "border-primary/35 bg-primary/10 text-blue-800 dark:text-blue-300",
+  completed: "border-emerald-500/35 bg-emerald-500/10 text-emerald-800 dark:text-emerald-300",
+  active: "border-emerald-500/35 bg-emerald-500/10 text-emerald-800 dark:text-emerald-300",
+  ok: "border-emerald-500/35 bg-emerald-500/10 text-emerald-800 dark:text-emerald-300",
+  failed: "border-red-500/35 bg-red-500/10 text-red-800 dark:text-red-300",
+  disabled: "border-zinc-500/35 bg-zinc-500/10 text-zinc-700 dark:text-zinc-300",
+  draft: "border-zinc-500/35 bg-zinc-500/10 text-zinc-700 dark:text-zinc-300",
+  archived: "border-zinc-500/35 bg-zinc-500/10 text-zinc-700 dark:text-zinc-300",
+  cancelled: "border-red-500/35 bg-red-500/10 text-red-800 dark:text-red-300",
+  paused: "border-orange-500/35 bg-orange-500/10 text-orange-800 dark:text-orange-300",
+};

+ 51 - 0
web/src/lib/utils.ts

@@ -0,0 +1,51 @@
+import { clsx, type ClassValue } from "clsx";
+import { twMerge } from "tailwind-merge";
+
+export function cn(...inputs: ClassValue[]) {
+  return twMerge(clsx(inputs));
+}
+
+export function formatDateTime(value?: string | null) {
+  if (!value) return "Never";
+  return new Intl.DateTimeFormat(undefined, {
+    month: "short",
+    day: "numeric",
+    hour: "2-digit",
+    minute: "2-digit",
+  }).format(new Date(value));
+}
+
+export function relativeTime(value?: string | null) {
+  if (!value) return "Never";
+  const deltaSeconds = Math.round((new Date(value).getTime() - Date.now()) / 1000);
+  const units: Array<[Intl.RelativeTimeFormatUnit, number]> = [
+    ["year", 31536000],
+    ["month", 2592000],
+    ["day", 86400],
+    ["hour", 3600],
+    ["minute", 60],
+    ["second", 1],
+  ];
+  const formatter = new Intl.RelativeTimeFormat(undefined, { numeric: "auto" });
+  const [unit, seconds] = units.find(([, amount]) => Math.abs(deltaSeconds) >= amount) ?? ["second", 1];
+  return formatter.format(Math.round(deltaSeconds / seconds), unit);
+}
+
+export function truncateMiddle(value: string, max = 18) {
+  if (value.length <= max) return value;
+  const keep = Math.max(4, Math.floor((max - 3) / 2));
+  return `${value.slice(0, keep)}...${value.slice(-keep)}`;
+}
+
+export async function copyToClipboard(value: string) {
+  await navigator.clipboard.writeText(value);
+}
+
+export function slugifyName(value: string, fallback = "item") {
+  const slug = value
+    .trim()
+    .toLowerCase()
+    .replace(/[^a-z0-9]+/g, "_")
+    .replace(/^_+|_+$/g, "");
+  return slug || `${fallback}_${Date.now().toString(36)}`;
+}

+ 21 - 0
web/src/main.tsx

@@ -0,0 +1,21 @@
+import React from "react";
+import ReactDOM from "react-dom/client";
+import { BrowserRouter } from "react-router-dom";
+import App from "./App";
+import "./index.css";
+
+try {
+  const persistedUi = localStorage.getItem("auto-platform-ui");
+  const persistedTheme = persistedUi ? (JSON.parse(persistedUi)?.state?.theme as "light" | "dark" | undefined) : undefined;
+  document.documentElement.classList.toggle("dark", (persistedTheme ?? "dark") === "dark");
+} catch {
+  document.documentElement.classList.add("dark");
+}
+
+ReactDOM.createRoot(document.getElementById("root")!).render(
+  <React.StrictMode>
+    <BrowserRouter>
+      <App />
+    </BrowserRouter>
+  </React.StrictMode>,
+);

+ 592 - 0
web/src/pages/agents/AgentListPage.tsx

@@ -0,0 +1,592 @@
+import * as React from "react";
+import {
+  Activity,
+  Archive,
+  Bot,
+  CheckCircle2,
+  Clock,
+  Copy,
+  FileCode2,
+  Grid2X2,
+  List,
+  Play,
+  RefreshCw,
+  Search,
+  SlidersHorizontal,
+  TerminalSquare,
+} from "lucide-react";
+import { listAgentRuns, listAgentVersions } from "@/api";
+import { ApiErrorState } from "@/components/shared/ApiErrorState";
+import { EmptyState } from "@/components/shared/EmptyState";
+import { EntityListItem } from "@/components/shared/EntityListItem";
+import { JsonViewer } from "@/components/shared/JsonViewer";
+import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
+import { MetricCard } from "@/components/shared/MetricCard";
+import { PageHeader } from "@/components/shared/PageHeader";
+import { SearchInput } from "@/components/shared/SearchInput";
+import { StatusBadge } from "@/components/shared/StatusBadge";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
+import { Select } from "@/components/ui/select";
+import { Tabs } from "@/components/ui/tabs";
+import { useAgentList } from "@/hooks";
+import { copyToClipboard, formatDateTime, relativeTime, truncateMiddle } from "@/lib/utils";
+import type { AgentDefinition, AgentRun, AgentRunStatus, AgentStatus, AgentVersion } from "@/types";
+import { AgentCard } from "./components/AgentCard";
+import { CreateAgentDialog } from "./components/CreateAgentDialog";
+
+type StatusFilter = "all" | AgentStatus;
+type RunStatusFilter = "all" | AgentRunStatus;
+type ViewMode = "list" | "grid";
+type SortMode = "recent" | "name" | "status";
+
+export function AgentListPage() {
+  const [search, setSearch] = React.useState("");
+  const [statusFilter, setStatusFilter] = React.useState<StatusFilter>("all");
+  const [typeFilter, setTypeFilter] = React.useState("all");
+  const [runStatusFilter, setRunStatusFilter] = React.useState<RunStatusFilter>("all");
+  const [sortMode, setSortMode] = React.useState<SortMode>("recent");
+  const [viewMode, setViewMode] = React.useState<ViewMode>("list");
+  const [selectedAgentId, setSelectedAgentId] = React.useState<string>();
+  const [activeTab, setActiveTab] = React.useState("overview");
+  const [versions, setVersions] = React.useState<AgentVersion[]>([]);
+  const [runs, setRuns] = React.useState<AgentRun[]>([]);
+  const [relatedLoading, setRelatedLoading] = React.useState(true);
+  const [testInput, setTestInput] = React.useState("Summarize the last customer request and recommend the next action.");
+  const [testResult, setTestResult] = React.useState<AgentRun | undefined>();
+  const agents = useAgentList();
+
+  const agentList = agents.data ?? [];
+  const selectedAgent = agentList.find((agent) => agent.id === selectedAgentId) ?? agentList[0];
+  const agentTypes = Array.from(new Set(agentList.map((agent) => agent.agent_type))).sort();
+  const versionCounts = React.useMemo(() => countBy(versions, (version) => version.agent_id), [versions]);
+  const selectedVersions = versions.filter((version) => version.agent_id === selectedAgent?.id);
+  const selectedRuns = runs.filter((run) => run.agent_id === selectedAgent?.id);
+  const latestVersion = [...selectedVersions].sort((a, b) => b.version_no - a.version_no)[0];
+  const filteredRuns = selectedRuns.filter((run) => runStatusFilter === "all" || run.status === runStatusFilter);
+  const failedRuns = runs.filter((run) => run.status === "failed").length;
+  const activeAgents = agentList.filter((agent) => agent.status === "active").length;
+  const draftAgents = agentList.filter((agent) => agent.status === "draft").length;
+  const archivedAgents = agentList.filter((agent) => agent.status === "archived").length;
+
+  const filtered = agentList
+    .filter((agent) => {
+      const text = `${agent.name} ${agent.code} ${agent.agent_type} ${agent.description ?? ""}`.toLowerCase();
+      const matchesSearch = text.includes(search.toLowerCase());
+      const matchesStatus = statusFilter === "all" || agent.status === statusFilter;
+      const matchesType = typeFilter === "all" || agent.agent_type === typeFilter;
+      return matchesSearch && matchesStatus && matchesType;
+    })
+    .sort((first, second) => {
+      if (sortMode === "name") return first.name.localeCompare(second.name);
+      if (sortMode === "status") return first.status.localeCompare(second.status) || first.name.localeCompare(second.name);
+      return new Date(second.created_time).getTime() - new Date(first.created_time).getTime();
+    });
+
+  const loadRelated = React.useCallback(async () => {
+    setRelatedLoading(true);
+    try {
+      const [versionData, runData] = await Promise.all([listAgentVersions(), listAgentRuns()]);
+      setVersions(versionData);
+      setRuns(runData);
+    } finally {
+      setRelatedLoading(false);
+    }
+  }, []);
+
+  React.useEffect(() => {
+    void loadRelated();
+  }, [loadRelated]);
+
+  React.useEffect(() => {
+    if (!selectedAgentId && agentList[0]) {
+      setSelectedAgentId(agentList[0].id);
+    }
+  }, [agentList, selectedAgentId]);
+
+  function simulateRun() {
+    if (!selectedAgent || !latestVersion) return;
+    const timestamp = new Date().toISOString();
+    setTestResult({
+      id: `local_${Date.now().toString(36)}`,
+      tenant_id: selectedAgent.tenant_id,
+      agent_id: selectedAgent.id,
+      agent_version_id: latestVersion.id,
+      session_id: null,
+      input_text: testInput,
+      input_json: null,
+      output_text: `Simulated response from ${selectedAgent.name}. The selected version is v${latestVersion.version_no}.`,
+      output_json: { simulated: true, agent_code: selectedAgent.code, version: latestVersion.version_no },
+      status: "completed",
+      worker_key: "frontend-simulator",
+      queued_time: timestamp,
+      lease_expire_time: null,
+      started_time: timestamp,
+      finished_time: timestamp,
+      error_code: null,
+      error_message: null,
+      created_time: timestamp,
+    });
+  }
+
+  async function copyAgentCode() {
+    if (!selectedAgent) return;
+    await copyToClipboard(selectedAgent.code);
+  }
+
+  if (agents.loading) return <LoadingSpinner label="Loading agents" />;
+  if (agents.error) return <ApiErrorState message={agents.error.message} onRetry={() => void agents.refetch()} />;
+
+  return (
+    <div className="space-y-6">
+      <PageHeader
+        title="Agents"
+        description="Design, inspect, and operate agent definitions, versions, prompts, and runs from one workspace."
+        actions={
+          <>
+            <Button
+              variant="outline"
+              onClick={() => {
+                void agents.refetch();
+                void loadRelated();
+              }}
+            >
+              <RefreshCw className="h-4 w-4" /> Refresh
+            </Button>
+            <CreateAgentDialog onCreated={() => void agents.refetch()} />
+          </>
+        }
+      />
+
+      <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-6">
+        <MetricCard label="Agents" value={agentList.length} icon={Bot} />
+        <MetricCard label="Active" value={activeAgents} icon={CheckCircle2} />
+        <MetricCard label="Draft" value={draftAgents} icon={FileCode2} />
+        <MetricCard label="Archived" value={archivedAgents} icon={Archive} />
+        <MetricCard label="Versions" value={versions.length} icon={Copy} />
+        <MetricCard label="Failed Runs" value={failedRuns} icon={Activity} />
+      </div>
+
+      <div className="grid gap-6 xl:grid-cols-[440px_1fr]">
+        <Card>
+          <CardHeader>
+            <div className="flex items-start justify-between gap-3">
+              <div>
+                <CardTitle>Agent Directory</CardTitle>
+                <CardDescription>
+                  {filtered.length} of {agentList.length} agents shown
+                </CardDescription>
+              </div>
+              <SlidersHorizontal className="mt-1 h-4 w-4 text-muted-foreground" />
+            </div>
+          </CardHeader>
+          <CardContent className="space-y-4">
+            <SearchInput value={search} onChange={setSearch} placeholder="Search by name, code, type, or description" />
+            <div className="grid gap-3 sm:grid-cols-2">
+              <Select
+                aria-label="Filter by status"
+                value={statusFilter}
+                onChange={(event) => setStatusFilter(event.target.value as StatusFilter)}
+                options={[
+                  { value: "all", label: "All statuses" },
+                  { value: "active", label: "Active" },
+                  { value: "draft", label: "Draft" },
+                  { value: "archived", label: "Archived" },
+                ]}
+              />
+              <Select
+                aria-label="Filter by agent type"
+                value={typeFilter}
+                onChange={(event) => setTypeFilter(event.target.value)}
+                options={[{ value: "all", label: "All types" }, ...agentTypes.map((type) => ({ value: type, label: type }))]}
+              />
+              <Select
+                aria-label="Sort agents"
+                value={sortMode}
+                onChange={(event) => setSortMode(event.target.value as SortMode)}
+                options={[
+                  { value: "recent", label: "Newest first" },
+                  { value: "name", label: "Name" },
+                  { value: "status", label: "Status" },
+                ]}
+              />
+              <div className="grid grid-cols-2 gap-2">
+                <Button
+                  type="button"
+                  variant={viewMode === "list" ? "secondary" : "outline"}
+                  aria-label="List view"
+                  onClick={() => setViewMode("list")}
+                >
+                  <List className="h-4 w-4" /> List
+                </Button>
+                <Button
+                  type="button"
+                  variant={viewMode === "grid" ? "secondary" : "outline"}
+                  aria-label="Grid view"
+                  onClick={() => setViewMode("grid")}
+                >
+                  <Grid2X2 className="h-4 w-4" /> Grid
+                </Button>
+              </div>
+            </div>
+
+            {filtered.length ? (
+              viewMode === "list" ? (
+                <div className="space-y-2">
+                  {filtered.map((agent) => (
+                    <EntityListItem
+                      key={agent.id}
+                      active={agent.id === selectedAgent?.id}
+                      title={agent.name}
+                      subtitle={`${agent.code} · ${agent.agent_type}`}
+                      meta={
+                        <div className="flex items-center gap-2">
+                          <Badge className="border-border bg-muted/60 text-muted-foreground">{versionCounts.get(agent.id) ?? 0}v</Badge>
+                          <StatusBadge status={agent.status} />
+                        </div>
+                      }
+                      onClick={() => {
+                        setSelectedAgentId(agent.id);
+                        setActiveTab("overview");
+                      }}
+                    />
+                  ))}
+                </div>
+              ) : (
+                <div className="grid gap-4 sm:grid-cols-2">
+                  {filtered.map((agent) => (
+                    <AgentCard
+                      key={agent.id}
+                      agent={agent}
+                      onOpen={() => {
+                        setSelectedAgentId(agent.id);
+                        setActiveTab("overview");
+                      }}
+                    />
+                  ))}
+                </div>
+              )
+            ) : (
+              <EmptyState
+                icon={Search}
+                title="No matching agents"
+                description="Adjust search or filters to find a matching agent definition."
+              />
+            )}
+          </CardContent>
+        </Card>
+
+        <Card>
+          <CardHeader>
+            <div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
+              <div className="min-w-0">
+                <div className="flex flex-wrap items-center gap-2">
+                  <CardTitle className="truncate text-lg">{selectedAgent?.name ?? "Agent Details"}</CardTitle>
+                  {selectedAgent ? <StatusBadge status={selectedAgent.status} /> : null}
+                </div>
+                <CardDescription className="mt-1">
+                  {selectedAgent ? `${selectedAgent.agent_type} · ${selectedAgent.code}` : "Select an agent to inspect its operating surface."}
+                </CardDescription>
+              </div>
+              <div className="flex flex-wrap items-center gap-2">
+                <Button variant="outline" disabled={!selectedAgent} onClick={() => void copyAgentCode()}>
+                  <Copy className="h-4 w-4" /> Copy Code
+                </Button>
+                <Button variant="secondary" disabled>
+                  <FileCode2 className="h-4 w-4" /> New Version
+                </Button>
+              </div>
+            </div>
+          </CardHeader>
+          <CardContent>
+            {selectedAgent ? (
+              <Tabs
+                value={activeTab}
+                onChange={setActiveTab}
+                tabs={[
+                  {
+                    value: "overview",
+                    label: "Overview",
+                    content: (
+                      <AgentOverview
+                        agent={selectedAgent}
+                        latestVersion={latestVersion}
+                        versionCount={selectedVersions.length}
+                        runCount={selectedRuns.length}
+                        failedRunCount={selectedRuns.filter((run) => run.status === "failed").length}
+                      />
+                    ),
+                  },
+                  {
+                    value: "versions",
+                    label: "Versions",
+                    content: <AgentVersions versions={selectedVersions} loading={relatedLoading} />,
+                  },
+                  {
+                    value: "runs",
+                    label: "Runs",
+                    content: (
+                      <AgentRuns
+                        runs={filteredRuns}
+                        loading={relatedLoading}
+                        statusFilter={runStatusFilter}
+                        onStatusFilterChange={setRunStatusFilter}
+                      />
+                    ),
+                  },
+                  {
+                    value: "config",
+                    label: "Prompt & Config",
+                    content: <AgentConfig version={latestVersion} />,
+                  },
+                  {
+                    value: "test",
+                    label: "Test Console",
+                    content: (
+                      <AgentTestConsole
+                        disabled={!latestVersion}
+                        input={testInput}
+                        result={testResult}
+                        onInputChange={setTestInput}
+                        onRun={simulateRun}
+                      />
+                    ),
+                  },
+                ]}
+              />
+            ) : (
+              <EmptyState icon={Bot} title="No agents" description="Create an agent to start building definitions and versions." />
+            )}
+          </CardContent>
+        </Card>
+      </div>
+    </div>
+  );
+}
+
+function AgentOverview({
+  agent,
+  latestVersion,
+  versionCount,
+  runCount,
+  failedRunCount,
+}: {
+  agent: AgentDefinition;
+  latestVersion?: AgentVersion;
+  versionCount: number;
+  runCount: number;
+  failedRunCount: number;
+}) {
+  return (
+    <div className="space-y-5">
+      <div className="grid gap-4 md:grid-cols-4">
+        <SummaryTile label="Versions" value={versionCount} />
+        <SummaryTile label="Runs" value={runCount} />
+        <SummaryTile label="Failures" value={failedRunCount} />
+        <SummaryTile label="Latest" value={latestVersion ? `v${latestVersion.version_no}` : "None"} />
+      </div>
+      <div className="grid gap-4 text-sm md:grid-cols-2">
+        <Detail label="Code" value={agent.code} mono />
+        <Detail label="Type" value={agent.agent_type} />
+        <Detail label="Owner" value={agent.owner_user_id ?? "Unassigned"} mono />
+        <Detail label="Created" value={formatDateTime(agent.created_time)} />
+        <div className="md:col-span-2">
+          <p className="text-muted-foreground">Description</p>
+          <p className="mt-1 leading-6">{agent.description ?? "No description provided."}</p>
+        </div>
+      </div>
+    </div>
+  );
+}
+
+function AgentVersions({ versions, loading }: { versions: AgentVersion[]; loading: boolean }) {
+  if (loading) return <LoadingSpinner label="Loading versions" />;
+  if (!versions.length) return <EmptyState icon={FileCode2} title="No versions" description="Create a version to define role, goal, prompt, tools, and runtime config." />;
+  return (
+    <div className="space-y-3">
+      {versions
+        .slice()
+        .sort((first, second) => second.version_no - first.version_no)
+        .map((version) => (
+          <div key={version.id} className="rounded-md border border-border bg-muted/30 p-4">
+            <div className="flex flex-wrap items-start justify-between gap-3">
+              <div>
+                <div className="flex flex-wrap items-center gap-2">
+                  <p className="font-medium">v{version.version_no}</p>
+                  <StatusBadge status={version.status} />
+                </div>
+                <p className="mt-1 text-sm text-muted-foreground">{version.goal ?? "No goal provided."}</p>
+              </div>
+              <p className="text-xs text-muted-foreground">{formatDateTime(version.created_time)}</p>
+            </div>
+            <div className="mt-4 grid gap-3 text-sm md:grid-cols-3">
+              <Detail label="Role" value={version.role} />
+              <Detail label="Model" value={stringifyConfigValue(version.model_config_json.model) ?? "Not set"} mono />
+              <Detail label="Published" value={version.published_time ? formatDateTime(version.published_time) : "Not published"} />
+            </div>
+          </div>
+        ))}
+    </div>
+  );
+}
+
+function AgentRuns({
+  runs,
+  loading,
+  statusFilter,
+  onStatusFilterChange,
+}: {
+  runs: AgentRun[];
+  loading: boolean;
+  statusFilter: RunStatusFilter;
+  onStatusFilterChange: (status: RunStatusFilter) => void;
+}) {
+  if (loading) return <LoadingSpinner label="Loading runs" />;
+  return (
+    <div className="space-y-4">
+      <div className="max-w-xs">
+        <Select
+          aria-label="Filter runs by status"
+          value={statusFilter}
+          onChange={(event) => onStatusFilterChange(event.target.value as RunStatusFilter)}
+          options={[
+            { value: "all", label: "All run statuses" },
+            { value: "queued", label: "Queued" },
+            { value: "running", label: "Running" },
+            { value: "completed", label: "Completed" },
+            { value: "failed", label: "Failed" },
+            { value: "cancelled", label: "Cancelled" },
+          ]}
+        />
+      </div>
+      {runs.length ? (
+        <div className="space-y-2">
+          {runs.map((run) => (
+            <div key={run.id} className="rounded-md border border-border bg-muted/30 p-4">
+              <div className="flex flex-wrap items-center justify-between gap-3">
+                <div className="min-w-0">
+                  <p className="truncate font-mono text-xs">{truncateMiddle(run.id, 28)}</p>
+                  <p className="mt-1 truncate text-sm text-muted-foreground">{run.input_text ?? "Structured input payload"}</p>
+                </div>
+                <StatusBadge status={run.status} />
+              </div>
+              <div className="mt-3 grid gap-3 text-sm md:grid-cols-3">
+                <Detail label="Created" value={relativeTime(run.created_time)} />
+                <Detail label="Worker" value={run.worker_key ?? "Unassigned"} mono />
+                <Detail label="Output" value={run.output_text ?? run.error_message ?? "No output yet"} />
+              </div>
+            </div>
+          ))}
+        </div>
+      ) : (
+        <EmptyState icon={Activity} title="No runs" description="No run records match the current agent and status filter." />
+      )}
+    </div>
+  );
+}
+
+function AgentConfig({ version }: { version?: AgentVersion }) {
+  if (!version) {
+    return <EmptyState icon={TerminalSquare} title="No config" description="Publish or draft a version before inspecting prompt and runtime configuration." />;
+  }
+  return (
+    <div className="space-y-5">
+      <section className="space-y-2">
+        <div className="flex items-center gap-2">
+          <TerminalSquare className="h-4 w-4 text-muted-foreground" />
+          <h3 className="text-sm font-semibold">System Prompt</h3>
+        </div>
+        <div className="rounded-md border border-border bg-muted/30 p-4 text-sm leading-6">{version.system_prompt}</div>
+      </section>
+      <div className="grid gap-4 lg:grid-cols-2">
+        <ConfigBlock title="Model Config" value={version.model_config_json} />
+        <ConfigBlock title="Memory Policy" value={version.memory_policy_json} />
+        <ConfigBlock title="Tool References" value={version.tool_refs_json} />
+        <ConfigBlock title="Skill References" value={version.skill_refs_json} />
+      </div>
+    </div>
+  );
+}
+
+function AgentTestConsole({
+  disabled,
+  input,
+  result,
+  onInputChange,
+  onRun,
+}: {
+  disabled: boolean;
+  input: string;
+  result?: AgentRun;
+  onInputChange: (value: string) => void;
+  onRun: () => void;
+}) {
+  return (
+    <div className="grid gap-4 lg:grid-cols-[1fr_360px]">
+      <div className="space-y-3">
+        <label className="block space-y-2 text-sm">
+          <span className="text-muted-foreground">Test input</span>
+          <textarea
+            className="min-h-36 w-full rounded-md border border-border bg-surface-elevated px-3 py-2 text-base text-foreground outline-none focus:border-primary/70 focus:ring-2 focus:ring-primary/20 disabled:cursor-not-allowed disabled:opacity-50 sm:text-sm"
+            value={input}
+            onChange={(event) => onInputChange(event.target.value)}
+            disabled={disabled}
+          />
+        </label>
+        <Button onClick={onRun} disabled={disabled || !input.trim()}>
+          <Play className="h-4 w-4" /> Simulate Run
+        </Button>
+      </div>
+      <div className="rounded-md border border-border bg-muted/30 p-4">
+        <div className="mb-3 flex items-center gap-2">
+          <Clock className="h-4 w-4 text-muted-foreground" />
+          <h3 className="text-sm font-semibold">Result Preview</h3>
+        </div>
+        {result ? <JsonViewer value={result} collapsed={false} /> : <p className="text-sm text-muted-foreground">Run a local simulation to preview the output contract.</p>}
+      </div>
+    </div>
+  );
+}
+
+function SummaryTile({ label, value }: { label: string; value: string | number }) {
+  return (
+    <div className="rounded-md border border-border bg-muted/30 p-3">
+      <p className="text-xs text-muted-foreground">{label}</p>
+      <p className="mt-1 text-xl font-semibold tabular-nums">{value}</p>
+    </div>
+  );
+}
+
+function Detail({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
+  return (
+    <div className="min-w-0">
+      <p className="text-muted-foreground">{label}</p>
+      <p className={mono ? "mt-1 truncate font-mono text-xs" : "mt-1 truncate"}>{value}</p>
+    </div>
+  );
+}
+
+function ConfigBlock({ title, value }: { title: string; value: unknown }) {
+  return (
+    <section className="min-w-0 rounded-md border border-border bg-muted/30 p-4">
+      <h3 className="mb-3 text-sm font-semibold">{title}</h3>
+      <JsonViewer value={value} />
+    </section>
+  );
+}
+
+function countBy<T>(items: T[], getKey: (item: T) => string) {
+  const counts = new Map<string, number>();
+  for (const item of items) {
+    const key = getKey(item);
+    counts.set(key, (counts.get(key) ?? 0) + 1);
+  }
+  return counts;
+}
+
+function stringifyConfigValue(value: unknown) {
+  if (typeof value === "string") return value;
+  if (typeof value === "number" || typeof value === "boolean") return String(value);
+  return undefined;
+}

+ 30 - 0
web/src/pages/agents/components/AgentCard.tsx

@@ -0,0 +1,30 @@
+import { Bot, Eye } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { StatusBadge } from "@/components/shared/StatusBadge";
+import type { AgentDefinition } from "@/types";
+
+export function AgentCard({ agent, onOpen }: { agent: AgentDefinition; onOpen: () => void }) {
+  return (
+    <Card>
+      <CardHeader>
+        <div className="flex items-start justify-between">
+          <div className="grid h-10 w-10 place-items-center rounded-md bg-primary/15 text-primary">
+            <Bot className="h-5 w-5" />
+          </div>
+          <StatusBadge status={agent.status} />
+        </div>
+        <CardTitle className="truncate">{agent.name}</CardTitle>
+      </CardHeader>
+      <CardContent className="space-y-4">
+        <div className="space-y-2 text-sm text-muted-foreground">
+          <p>{agent.agent_type}</p>
+          <p className="line-clamp-2">{agent.description ?? "No description"}</p>
+        </div>
+        <Button variant="outline" className="w-full" onClick={onOpen}>
+          <Eye className="h-4 w-4" /> Details
+        </Button>
+      </CardContent>
+    </Card>
+  );
+}

+ 61 - 0
web/src/pages/agents/components/AgentDetailSheet.tsx

@@ -0,0 +1,61 @@
+import * as React from "react";
+import { Sheet } from "@/components/ui/sheet";
+import { Tabs } from "@/components/ui/tabs";
+import { JsonViewer } from "@/components/shared/JsonViewer";
+import { StatusBadge } from "@/components/shared/StatusBadge";
+import { useAgentRuns, useAgentVersions } from "@/hooks";
+import type { AgentDefinition } from "@/types";
+
+export function AgentDetailSheet({
+  agent,
+  open,
+  onOpenChange,
+}: {
+  agent?: AgentDefinition;
+  open: boolean;
+  onOpenChange: (open: boolean) => void;
+}) {
+  const [tab, setTab] = React.useState("details");
+  const versions = useAgentVersions(open ? agent?.id : undefined);
+  const runs = useAgentRuns(open ? agent?.id : undefined);
+  if (!agent) return null;
+  return (
+    <Sheet open={open} onOpenChange={onOpenChange} title={agent.name} description={agent.agent_type} className="max-w-2xl">
+      <Tabs
+        value={tab}
+        onChange={setTab}
+        tabs={[
+          { value: "details", label: "Details", content: <JsonViewer value={agent} collapsed={false} /> },
+          {
+            value: "versions",
+            label: "Versions",
+            content: (
+              <div className="space-y-2">
+                {(versions.data ?? []).map((version) => (
+                  <div key={version.id} className="flex items-center justify-between rounded-md border border-border p-3 text-sm">
+                    <span>v{version.version_no}</span>
+                    <StatusBadge status={version.status} />
+                  </div>
+                ))}
+              </div>
+            ),
+          },
+          {
+            value: "runs",
+            label: "Runs",
+            content: (
+              <div className="space-y-2">
+                {(runs.data ?? []).map((run) => (
+                  <div key={run.id} className="flex items-center justify-between rounded-md border border-border p-3 text-sm">
+                    <span className="font-mono text-xs">{run.id}</span>
+                    <StatusBadge status={run.status} />
+                  </div>
+                ))}
+              </div>
+            ),
+          },
+        ]}
+      />
+    </Sheet>
+  );
+}

+ 130 - 0
web/src/pages/agents/components/CreateAgentDialog.tsx

@@ -0,0 +1,130 @@
+import * as React from "react";
+import { Bot, Plus } from "lucide-react";
+import { createAgent } from "@/api";
+import { Button } from "@/components/ui/button";
+import { Dialog } from "@/components/ui/dialog";
+import { Input, Textarea } from "@/components/ui/input";
+import { Select } from "@/components/ui/select";
+import { toast } from "@/components/ui/toaster";
+import { slugifyName } from "@/lib/utils";
+import { useAuthStore } from "@/stores/auth";
+
+const agentTypes = [
+  { value: "assistant", label: "Assistant" },
+  { value: "planner", label: "Planner" },
+  { value: "executor", label: "Executor" },
+  { value: "research", label: "Research" },
+  { value: "tool_user", label: "Tool User" },
+];
+
+export function CreateAgentDialog({ onCreated }: { onCreated: () => void }) {
+  const [open, setOpen] = React.useState(false);
+  const tenantId = useAuthStore((state) => state.tenantId);
+  const userId = useAuthStore((state) => state.userId);
+  const [form, setForm] = React.useState({ name: "", code: "", description: "", agent_type: "assistant" });
+  const [codeTouched, setCodeTouched] = React.useState(false);
+  const [submitting, setSubmitting] = React.useState(false);
+
+  function updateName(name: string) {
+    setForm((current) => ({
+      ...current,
+      name,
+      code: codeTouched ? current.code : slugifyName(name, "agent"),
+    }));
+  }
+
+  async function submit(event: React.FormEvent) {
+    event.preventDefault();
+    setSubmitting(true);
+    try {
+      await createAgent({
+        tenant_id: tenantId,
+        owner_user_id: userId,
+        name: form.name.trim(),
+        code: form.code.trim(),
+        description: form.description.trim(),
+        agent_type: form.agent_type,
+      });
+      toast.success("Agent created");
+      setOpen(false);
+      setCodeTouched(false);
+      setForm({ name: "", code: "", description: "", agent_type: "assistant" });
+      onCreated();
+    } catch (err) {
+      toast.error(err instanceof Error ? err.message : "Failed to create agent");
+    } finally {
+      setSubmitting(false);
+    }
+  }
+
+  return (
+    <>
+      <Button onClick={() => setOpen(true)}>
+        <Plus className="h-4 w-4" /> New Agent
+      </Button>
+      <Dialog open={open} onOpenChange={setOpen} title="Create Agent">
+        <form className="space-y-5" onSubmit={submit}>
+          <div className="rounded-md border border-border bg-muted/30 p-4">
+            <div className="flex items-start gap-3">
+              <div className="grid h-10 w-10 shrink-0 place-items-center rounded-md bg-primary/15 text-primary">
+                <Bot className="h-5 w-5" />
+              </div>
+              <div>
+                <h2 className="text-sm font-semibold">Definition</h2>
+                <p className="mt-1 text-sm leading-6 text-muted-foreground">
+                  Create the reusable agent identity first. Versions, prompts, tools, and runtime config live under this definition.
+                </p>
+              </div>
+            </div>
+          </div>
+
+          <div className="grid gap-4 sm:grid-cols-2">
+            <label className="block space-y-2 text-sm">
+              <span className="text-muted-foreground">Name</span>
+              <Input required value={form.name} onChange={(event) => updateName(event.target.value)} placeholder="Support Agent" />
+            </label>
+            <label className="block space-y-2 text-sm">
+              <span className="text-muted-foreground">Code</span>
+              <Input
+                required
+                value={form.code}
+                onChange={(event) => {
+                  setCodeTouched(true);
+                  setForm({ ...form, code: event.target.value });
+                }}
+                placeholder="support_agent"
+              />
+            </label>
+          </div>
+
+          <label className="block space-y-2 text-sm">
+            <span className="text-muted-foreground">Type</span>
+            <Select
+              value={form.agent_type}
+              onChange={(event) => setForm({ ...form, agent_type: event.target.value })}
+              options={agentTypes}
+            />
+          </label>
+
+          <label className="block space-y-2 text-sm">
+            <span className="text-muted-foreground">Description</span>
+            <Textarea
+              value={form.description}
+              onChange={(event) => setForm({ ...form, description: event.target.value })}
+              placeholder="What this agent is responsible for, when to use it, and what good output looks like."
+            />
+          </label>
+
+          <div className="flex flex-wrap justify-end gap-2">
+            <Button type="button" variant="ghost" onClick={() => setOpen(false)}>
+              Cancel
+            </Button>
+            <Button disabled={submitting || !form.name.trim() || !form.code.trim()}>
+              {submitting ? "Creating..." : "Create Agent"}
+            </Button>
+          </div>
+        </form>
+      </Dialog>
+    </>
+  );
+}

+ 75 - 0
web/src/pages/dashboard/DashboardPage.tsx

@@ -0,0 +1,75 @@
+import * as React from "react";
+import { ApiErrorState } from "@/components/shared/ApiErrorState";
+import { PageHeader } from "@/components/shared/PageHeader";
+import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
+import { useInterval } from "@/hooks";
+import { getServicesHealth, listAgents, listRuns, listSessions, listWorkflows } from "@/api";
+import { StatsCards } from "./components/StatsCards";
+import { ExecutionTrendChart } from "./components/ExecutionTrendChart";
+import { RecentRunsTable } from "./components/RecentRunsTable";
+import { ServiceHealthList } from "./components/ServiceHealthList";
+import type { AgentDefinition, DownstreamServiceHealth, Session, WorkflowDefinition, WorkflowRun } from "@/types";
+
+export function DashboardPage() {
+  const [loading, setLoading] = React.useState(true);
+  const [workflows, setWorkflows] = React.useState<WorkflowDefinition[]>([]);
+  const [agents, setAgents] = React.useState<AgentDefinition[]>([]);
+  const [sessions, setSessions] = React.useState<Session[]>([]);
+  const [runs, setRuns] = React.useState<WorkflowRun[]>([]);
+  const [services, setServices] = React.useState<DownstreamServiceHealth[]>([]);
+  const [error, setError] = React.useState<string>();
+
+  const load = React.useCallback(async () => {
+    setError(undefined);
+    const [workflowData, agentData, sessionData, runData, healthData] = await Promise.allSettled([
+      listWorkflows(),
+      listAgents(),
+      listSessions(),
+      listRuns(),
+      getServicesHealth(),
+    ]);
+    if (workflowData.status === "fulfilled") setWorkflows(workflowData.value);
+    if (agentData.status === "fulfilled") setAgents(agentData.value);
+    if (sessionData.status === "fulfilled") setSessions(sessionData.value);
+    if (runData.status === "fulfilled") setRuns(runData.value);
+    if (healthData.status === "fulfilled") setServices(healthData.value.downstream_services);
+    if ([workflowData, agentData, sessionData, runData, healthData].every((item) => item.status === "rejected")) {
+      setError("All dashboard requests failed. Check the API gateway and credentials.");
+    }
+    setLoading(false);
+  }, []);
+
+  React.useEffect(() => {
+    void load();
+  }, [load]);
+  useInterval(() => void load(), 30000);
+
+  const today = new Date().toDateString();
+  const runsToday = runs.filter((run) => new Date(run.created_time).toDateString() === today).length;
+  const activeSessions = sessions.filter((session) => session.session_status === "active").length;
+  const trend = Array.from({ length: 7 }, (_, index) => {
+    const day = new Date();
+    day.setDate(day.getDate() - (6 - index));
+    const dayRuns = runs.filter((run) => new Date(run.created_time).toDateString() === day.toDateString());
+    return {
+      label: day.toLocaleDateString(undefined, { weekday: "short" }),
+      successful: dayRuns.filter((run) => run.status === "completed").length,
+      failed: dayRuns.filter((run) => run.status === "failed").length,
+    };
+  });
+
+  if (loading) return <LoadingSpinner label="Loading dashboard" />;
+  if (error) return <ApiErrorState message={error} onRetry={() => void load()} />;
+
+  return (
+    <div className="space-y-6">
+      <PageHeader title="Dashboard" description="Operational overview for workflow and agent execution." />
+      <StatsCards workflows={workflows.length} agents={agents.length} runsToday={runsToday} activeSessions={activeSessions} />
+      <div className="grid gap-6 xl:grid-cols-[1fr_360px]">
+        <ExecutionTrendChart data={trend} />
+        <ServiceHealthList services={services} />
+      </div>
+      <RecentRunsTable runs={runs} />
+    </div>
+  );
+}

+ 40 - 0
web/src/pages/dashboard/components/ExecutionTrendChart.tsx

@@ -0,0 +1,40 @@
+import { Area, AreaChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+
+export function ExecutionTrendChart({ data }: { data: Array<{ label: string; successful: number; failed: number }> }) {
+  return (
+    <Card className="min-h-80">
+      <CardHeader>
+        <CardTitle>Execution Trend</CardTitle>
+      </CardHeader>
+      <CardContent className="h-64">
+        <ResponsiveContainer width="100%" height="100%">
+          <AreaChart data={data}>
+            <defs>
+              <linearGradient id="success" x1="0" x2="0" y1="0" y2="1">
+                <stop offset="5%" stopColor="#10B981" stopOpacity={0.45} />
+                <stop offset="95%" stopColor="#10B981" stopOpacity={0} />
+              </linearGradient>
+              <linearGradient id="failed" x1="0" x2="0" y1="0" y2="1">
+                <stop offset="5%" stopColor="#DC2626" stopOpacity={0.35} />
+                <stop offset="95%" stopColor="#DC2626" stopOpacity={0} />
+              </linearGradient>
+            </defs>
+            <CartesianGrid stroke="rgba(255,255,255,0.08)" vertical={false} />
+            <XAxis dataKey="label" stroke="#8A8F98" fontSize={12} tickLine={false} axisLine={false} />
+            <YAxis stroke="#8A8F98" fontSize={12} tickLine={false} axisLine={false} />
+            <Tooltip
+              contentStyle={{
+                background: "hsl(var(--surface-elevated))",
+                border: "1px solid hsl(var(--border))",
+                color: "hsl(var(--foreground))",
+              }}
+            />
+            <Area type="monotone" dataKey="successful" stroke="#10B981" fill="url(#success)" />
+            <Area type="monotone" dataKey="failed" stroke="#DC2626" fill="url(#failed)" />
+          </AreaChart>
+        </ResponsiveContainer>
+      </CardContent>
+    </Card>
+  );
+}

+ 45 - 0
web/src/pages/dashboard/components/RecentRunsTable.tsx

@@ -0,0 +1,45 @@
+import { StatusBadge } from "@/components/shared/StatusBadge";
+import { RelativeTime } from "@/components/shared/RelativeTime";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { truncateMiddle } from "@/lib/utils";
+import type { WorkflowRun } from "@/types";
+
+export function RecentRunsTable({ runs }: { runs: WorkflowRun[] }) {
+  return (
+    <Card>
+      <CardHeader>
+        <CardTitle>Recent Runs</CardTitle>
+      </CardHeader>
+      <CardContent>
+        <div className="overflow-x-auto">
+          <table className="w-full text-left text-sm">
+            <thead className="text-xs text-muted-foreground">
+              <tr className="border-b border-border">
+                <th className="py-2">Run ID</th>
+                <th>Type</th>
+                <th>Status</th>
+                <th>Started</th>
+                <th>Nodes</th>
+              </tr>
+            </thead>
+            <tbody>
+              {runs.slice(0, 8).map((run) => (
+                <tr key={run.id} className="border-b border-border">
+                  <td className="py-3 font-mono text-xs">{truncateMiddle(run.id, 20)}</td>
+                  <td>{run.run_type}</td>
+                  <td>
+                    <StatusBadge status={run.status} />
+                  </td>
+                  <td className="text-muted-foreground">
+                    <RelativeTime value={run.started_time ?? run.created_time} />
+                  </td>
+                  <td>{run.current_node_count}</td>
+                </tr>
+              ))}
+            </tbody>
+          </table>
+        </div>
+      </CardContent>
+    </Card>
+  );
+}

+ 23 - 0
web/src/pages/dashboard/components/ServiceHealthList.tsx

@@ -0,0 +1,23 @@
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import type { DownstreamServiceHealth } from "@/types";
+
+export function ServiceHealthList({ services }: { services: DownstreamServiceHealth[] }) {
+  return (
+    <Card>
+      <CardHeader>
+        <CardTitle>Services</CardTitle>
+      </CardHeader>
+      <CardContent className="space-y-3">
+        {services.map((service) => (
+          <div key={service.service} className="flex items-center justify-between gap-3 text-sm">
+            <div className="min-w-0">
+              <p className="truncate font-medium">{service.service}</p>
+              <p className="truncate text-xs text-muted-foreground">{service.url}</p>
+            </div>
+            <span className={service.status === "ok" ? "h-2.5 w-2.5 rounded-full bg-emerald-400" : "h-2.5 w-2.5 rounded-full bg-red-400"} />
+          </div>
+        ))}
+      </CardContent>
+    </Card>
+  );
+}

+ 36 - 0
web/src/pages/dashboard/components/StatsCards.tsx

@@ -0,0 +1,36 @@
+import { Activity, Bot, GitBranch, MessageSquare } from "lucide-react";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+
+export function StatsCards({
+  workflows,
+  agents,
+  runsToday,
+  activeSessions,
+}: {
+  workflows: number;
+  agents: number;
+  runsToday: number;
+  activeSessions: number;
+}) {
+  const cards = [
+    { label: "Workflows", value: workflows, icon: GitBranch },
+    { label: "Agents", value: agents, icon: Bot },
+    { label: "Runs Today", value: runsToday, icon: Activity },
+    { label: "Active Sessions", value: activeSessions, icon: MessageSquare },
+  ];
+  return (
+    <div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
+      {cards.map((card) => (
+        <Card key={card.label}>
+          <CardHeader className="flex flex-row items-center justify-between pb-2">
+            <CardTitle className="text-sm text-muted-foreground">{card.label}</CardTitle>
+            <card.icon className="h-4 w-4 text-primary" />
+          </CardHeader>
+          <CardContent>
+            <div className="text-3xl font-semibold">{card.value}</div>
+          </CardContent>
+        </Card>
+      ))}
+    </div>
+  );
+}

+ 1529 - 0
web/src/pages/knowledge/KnowledgePage.tsx

@@ -0,0 +1,1529 @@
+import * as React from "react";
+import { NavLink, useNavigate, useParams } from "react-router-dom";
+import {
+  Archive,
+  BarChart3,
+  BookOpen,
+  Bot,
+  CheckCircle2,
+  ClipboardCheck,
+  Clock,
+  Database,
+  FilePlus,
+  FileSearch,
+  FileText,
+  Filter,
+  Gauge,
+  GitBranch,
+  KeyRound,
+  Layers3,
+  Link,
+  LockKeyhole,
+  RefreshCw,
+  RotateCcw,
+  Search,
+  Settings2,
+  ShieldCheck,
+  SlidersHorizontal,
+  Sparkles,
+  UploadCloud,
+  type LucideIcon,
+} from "lucide-react";
+import {
+  createKnowledgeBase,
+  createKnowledgeDocument,
+  listKnowledgeBases,
+  listKnowledgeDocuments,
+  parseKnowledgeDocument,
+  searchKnowledge,
+  updateKnowledgeBaseStatus,
+} from "@/api";
+import { ApiErrorState } from "@/components/shared/ApiErrorState";
+import { EmptyState } from "@/components/shared/EmptyState";
+import { JsonViewer } from "@/components/shared/JsonViewer";
+import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
+import { MetricCard } from "@/components/shared/MetricCard";
+import { PageHeader } from "@/components/shared/PageHeader";
+import { SearchInput } from "@/components/shared/SearchInput";
+import { StatusBadge } from "@/components/shared/StatusBadge";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Dialog } from "@/components/ui/dialog";
+import { Input, Textarea } from "@/components/ui/input";
+import { Select } from "@/components/ui/select";
+import { Tabs } from "@/components/ui/tabs";
+import { toast } from "@/components/ui/toaster";
+import { formatDateTime } from "@/lib/utils";
+import { useAuthStore } from "@/stores/auth";
+import type { JSONObject, KnowledgeBase, KnowledgeDocument, KnowledgeDocumentIngestResponse, KnowledgeDocumentParseResponse, SearchResult } from "@/types";
+
+const documentStatusOptions = [
+  { value: "all", label: "All statuses" },
+  { value: "indexed", label: "Indexed" },
+  { value: "draft", label: "Draft" },
+  { value: "failed", label: "Failed" },
+  { value: "archived", label: "Archived" },
+];
+
+const sourceTypeOptions = [
+  { value: "all", label: "All sources" },
+  { value: "text", label: "Text" },
+  { value: "markdown", label: "Markdown" },
+  { value: "json", label: "JSON" },
+  { value: "html", label: "HTML" },
+  { value: "pdf", label: "PDF" },
+];
+
+type KnowledgeJob = {
+  id: string;
+  type: string;
+  target: string;
+  status: "queued" | "running" | "completed" | "failed";
+  progress: number;
+  created_time: string;
+};
+
+type EvalCase = {
+  id: string;
+  query: string;
+  expected: string;
+  status: "draft" | "passed" | "failed";
+  recall: number;
+  precision: number;
+};
+
+type RetrievalConfig = {
+  chunkSize: string;
+  chunkOverlap: string;
+  topK: string;
+  keywordWeight: string;
+  vectorWeight: string;
+  rerankWeight: string;
+  rerankEnabled: boolean;
+  queryRewrite: boolean;
+  requireCitations: boolean;
+  piiRedaction: boolean;
+  aclMode: string;
+};
+
+const initialJobs: KnowledgeJob[] = [
+  { id: "job_sync_docs", type: "Sitemap Sync", target: "docs.auto.local", status: "running", progress: 68, created_time: new Date(Date.now() - 1000 * 60 * 8).toISOString() },
+  { id: "job_reindex_product", type: "Re-index", target: "Product Docs", status: "completed", progress: 100, created_time: new Date(Date.now() - 1000 * 60 * 42).toISOString() },
+  { id: "job_pdf_batch", type: "PDF Batch", target: "Policy Pack", status: "failed", progress: 34, created_time: new Date(Date.now() - 1000 * 60 * 75).toISOString() },
+];
+
+const initialEvalCases: EvalCase[] = [
+  { id: "eval_invoice", query: "Where can customers download invoices?", expected: "Billing and Invoice FAQ", status: "passed", recall: 0.92, precision: 0.87 },
+  { id: "eval_refund", query: "How do refunds affect annual plans?", expected: "Refund policy", status: "draft", recall: 0, precision: 0 },
+];
+
+const defaultRetrievalConfig: RetrievalConfig = {
+  chunkSize: "800",
+  chunkOverlap: "120",
+  topK: "5",
+  keywordWeight: "0.30",
+  vectorWeight: "0.55",
+  rerankWeight: "0.15",
+  rerankEnabled: true,
+  queryRewrite: true,
+  requireCitations: true,
+  piiRedaction: false,
+  aclMode: "team",
+};
+
+const knowledgeSections = [
+  { value: "overview", label: "Overview", path: "/knowledge", icon: Database },
+  { value: "documents", label: "Documents", path: "/knowledge/documents", icon: FileText },
+  { value: "ingest", label: "Ingest", path: "/knowledge/ingest", icon: UploadCloud },
+  { value: "playground", label: "Playground", path: "/knowledge/playground", icon: Search },
+  { value: "evaluation", label: "Evaluation", path: "/knowledge/evaluation", icon: ClipboardCheck },
+  { value: "jobs", label: "Jobs", path: "/knowledge/jobs", icon: Layers3 },
+  { value: "analytics", label: "Analytics", path: "/knowledge/analytics", icon: BarChart3 },
+  { value: "settings", label: "Settings", path: "/knowledge/settings", icon: Settings2 },
+];
+
+const capabilityGroups = [
+  {
+    title: "Ingestion",
+    icon: UploadCloud,
+    items: [
+      { label: "Text / Markdown ingest", state: "live" },
+      { label: "Parse preview", state: "live" },
+      { label: "PDF / DOCX / HTML parser", state: "prototype" },
+      { label: "URL, sitemap, GitHub import", state: "prototype" },
+    ],
+  },
+  {
+    title: "Indexing",
+    icon: Layers3,
+    items: [
+      { label: "Chunk size / overlap", state: "live" },
+      { label: "Embedding model tracking", state: "mock" },
+      { label: "Re-index document/base", state: "prototype" },
+      { label: "Index job queue", state: "prototype" },
+    ],
+  },
+  {
+    title: "Retrieval",
+    icon: Search,
+    items: [
+      { label: "Hybrid search playground", state: "live" },
+      { label: "Top K and metadata filters", state: "live" },
+      { label: "Score breakdown and citations", state: "live" },
+      { label: "Rerank / query rewrite controls", state: "mock" },
+    ],
+  },
+  {
+    title: "Evaluation",
+    icon: ClipboardCheck,
+    items: [
+      { label: "Golden queries", state: "prototype" },
+      { label: "Recall / precision metrics", state: "prototype" },
+      { label: "Human relevance feedback", state: "prototype" },
+      { label: "Regression comparison", state: "prototype" },
+    ],
+  },
+  {
+    title: "Governance",
+    icon: ShieldCheck,
+    items: [
+      { label: "Archive / restore base", state: "live" },
+      { label: "Tenant isolation", state: "live" },
+      { label: "Document ACL / PII rules", state: "prototype" },
+      { label: "Audit log", state: "prototype" },
+    ],
+  },
+  {
+    title: "Integration",
+    icon: Bot,
+    items: [
+      { label: "Agent binding", state: "prototype" },
+      { label: "Workflow retrieval node", state: "prototype" },
+      { label: "Knowledge search tool", state: "prototype" },
+      { label: "Run trace citations", state: "prototype" },
+    ],
+  },
+];
+
+export function KnowledgePage() {
+  const navigate = useNavigate();
+  const { section: sectionParam } = useParams();
+  const section = knowledgeSections.some((item) => item.value === sectionParam) ? sectionParam ?? "overview" : "overview";
+  const tenantId = useAuthStore((state) => state.tenantId);
+  const [bases, setBases] = React.useState<KnowledgeBase[]>([]);
+  const [documents, setDocuments] = React.useState<KnowledgeDocument[]>([]);
+  const [results, setResults] = React.useState<SearchResult[]>([]);
+  const [selectedBaseId, setSelectedBaseId] = React.useState<string>();
+  const [scopeMode, setScopeMode] = React.useState<"current" | "all">("current");
+  const [selectedDocumentId, setSelectedDocumentId] = React.useState<string>();
+  const [baseSearch, setBaseSearch] = React.useState("");
+  const [documentSearch, setDocumentSearch] = React.useState("");
+  const [documentStatus, setDocumentStatus] = React.useState("all");
+  const [sourceType, setSourceType] = React.useState("all");
+  const [query, setQuery] = React.useState("");
+  const [topK, setTopK] = React.useState("5");
+  const [activeTab, setActiveTab] = React.useState("overview");
+  const [loading, setLoading] = React.useState(true);
+  const [documentsLoading, setDocumentsLoading] = React.useState(false);
+  const [searching, setSearching] = React.useState(false);
+  const [statusBusy, setStatusBusy] = React.useState(false);
+  const [error, setError] = React.useState<string>();
+  const [createOpen, setCreateOpen] = React.useState(false);
+  const [documentOpen, setDocumentOpen] = React.useState(false);
+  const [lastIngest, setLastIngest] = React.useState<KnowledgeDocumentIngestResponse>();
+  const [connectorOpen, setConnectorOpen] = React.useState(false);
+  const [connectorKind, setConnectorKind] = React.useState("URL / Sitemap");
+  const [jobs, setJobs] = React.useState<KnowledgeJob[]>(initialJobs);
+  const [evalCases, setEvalCases] = React.useState<EvalCase[]>(initialEvalCases);
+  const [retrievalConfig, setRetrievalConfig] = React.useState<RetrievalConfig>(defaultRetrievalConfig);
+
+  const selectedBase = bases.find((base) => base.id === selectedBaseId);
+  const selectedBases = scopeMode === "all" ? bases : selectedBase ? [selectedBase] : [];
+  const activeBaseIds = scopeMode === "all" ? bases.map((base) => base.id) : selectedBaseId ? [selectedBaseId] : [];
+  const activeBaseKey = activeBaseIds.join("|");
+  const baseNameById = React.useMemo(() => new Map(bases.map((base) => [base.id, base.name])), [bases]);
+  const selectedDocument = documents.find((document) => document.id === selectedDocumentId);
+  const sourceTypes = React.useMemo(() => Array.from(new Set(documents.map((document) => document.source_type))).sort(), [documents]);
+  const indexedCount = documents.filter((document) => document.status === "indexed").length;
+  const filteredBases = bases.filter((base) => `${base.name} ${base.description ?? ""}`.toLowerCase().includes(baseSearch.toLowerCase()));
+  const filteredDocuments = documents.filter((document) => {
+    const matchesText = `${document.title} ${document.source_uri ?? ""} ${document.source_type}`.toLowerCase().includes(documentSearch.toLowerCase());
+    const matchesStatus = documentStatus === "all" || document.status === documentStatus;
+    const matchesSource = sourceType === "all" || document.source_type === sourceType;
+    return matchesText && matchesStatus && matchesSource;
+  });
+  const selectedDocumentResults = selectedDocument
+    ? results.filter((result) => result.document.id === selectedDocument.id || result.chunk.document_id === selectedDocument.id)
+    : [];
+  const selectedDocumentChunks = React.useMemo(() => {
+    const chunks = [
+      ...(lastIngest && lastIngest.document.id === selectedDocumentId ? lastIngest.chunks : []),
+      ...results.filter((result) => result.chunk.document_id === selectedDocumentId).map((result) => result.chunk),
+    ];
+    return Array.from(new Map(chunks.map((chunk) => [chunk.id, chunk])).values());
+  }, [lastIngest, results, selectedDocumentId]);
+
+  const loadBases = React.useCallback(async () => {
+    setLoading(true);
+    setError(undefined);
+    try {
+      const data = await listKnowledgeBases();
+      setBases(data);
+      setSelectedBaseId((current) => current ?? data[0]?.id);
+    } catch (err) {
+      setError(err instanceof Error ? err.message : "Failed to load knowledge bases");
+    } finally {
+      setLoading(false);
+    }
+  }, []);
+
+  const loadDocuments = React.useCallback(async (knowledgeBaseIds?: string[]) => {
+    if (!knowledgeBaseIds?.length) {
+      setDocuments([]);
+      setResults([]);
+      setSelectedDocumentId(undefined);
+      return;
+    }
+    setDocumentsLoading(true);
+    try {
+      const data = (await Promise.all(knowledgeBaseIds.map((knowledgeBaseId) => listKnowledgeDocuments(knowledgeBaseId)))).flat();
+      setDocuments(data);
+      setSelectedDocumentId((current) => (current && data.some((document) => document.id === current) ? current : data[0]?.id));
+      setResults([]);
+    } catch {
+      setDocuments([]);
+      setSelectedDocumentId(undefined);
+      toast.error("Failed to load documents");
+    } finally {
+      setDocumentsLoading(false);
+    }
+  }, []);
+
+  React.useEffect(() => {
+    void loadBases();
+  }, [loadBases]);
+
+  React.useEffect(() => {
+    void loadDocuments(activeBaseIds);
+  }, [loadDocuments, activeBaseKey]);
+
+  React.useEffect(() => {
+    const handle = window.setInterval(() => {
+      setJobs((items) =>
+        items.map((job) => {
+          if (job.status !== "running") return job;
+          const progress = Math.min(100, job.progress + 8);
+          return { ...job, progress, status: progress >= 100 ? "completed" : "running" };
+        }),
+      );
+    }, 1200);
+    return () => window.clearInterval(handle);
+  }, []);
+
+  async function runSearch(event?: React.FormEvent) {
+    event?.preventDefault();
+    if (!activeBaseIds.length || !query.trim()) return;
+    setSearching(true);
+    try {
+      const filters: JSONObject = {};
+      if (documentStatus !== "all") filters.status = documentStatus;
+      if (sourceType !== "all") filters.source_type = sourceType;
+      const data = (await Promise.all(activeBaseIds.map((baseId) => searchKnowledge(baseId, query.trim(), Number(topK) || 5, filters)))).flat();
+      data.sort((a, b) => b.score - a.score);
+      setResults(data);
+      setActiveTab("search");
+      if (section !== "playground") navigate("/knowledge/playground");
+      toast.info(data.length ? `${data.length} search results` : "No matching chunks found");
+    } catch (err) {
+      toast.error(err instanceof Error ? err.message : "Search failed");
+    } finally {
+      setSearching(false);
+    }
+  }
+
+  async function reloadSelectedBase() {
+    await Promise.all([loadBases(), loadDocuments(activeBaseIds)]);
+  }
+
+  function selectBase(baseId: string) {
+    setSelectedBaseId(baseId);
+    setSelectedDocumentId(undefined);
+  }
+
+  async function toggleBaseStatus() {
+    if (!selectedBase) return;
+    setStatusBusy(true);
+    try {
+      const nextStatus = selectedBase.status === "active" ? "archived" : "active";
+      const updated = await updateKnowledgeBaseStatus({ tenant_id: tenantId, knowledge_base_id: selectedBase.id, status: nextStatus });
+      setBases((items) => items.map((base) => (base.id === updated.id ? updated : base)));
+      toast.success(nextStatus === "active" ? "Knowledge base restored" : "Knowledge base archived");
+    } finally {
+      setStatusBusy(false);
+    }
+  }
+
+  function openConnector(kind: string) {
+    setConnectorKind(kind);
+    setConnectorOpen(true);
+  }
+
+  function createLocalJob(type: string, target: string) {
+    const job: KnowledgeJob = {
+      id: `job_${Date.now().toString(36)}`,
+      type,
+      target,
+      status: "queued",
+      progress: 0,
+      created_time: new Date().toISOString(),
+    };
+    setJobs((items) => [job, ...items]);
+    toast.success(`${type} job created`);
+  }
+
+  function createJobsForActiveBases(type: string) {
+    const targets = selectedBases.length ? selectedBases : selectedBase ? [selectedBase] : [];
+    targets.forEach((base) => createLocalJob(type, base.name));
+  }
+
+  function updateJob(jobId: string, patch: Partial<KnowledgeJob>) {
+    setJobs((items) => items.map((job) => (job.id === jobId ? { ...job, ...patch } : job)));
+  }
+
+  function addEvalCase(queryText: string, expected: string) {
+    setEvalCases((items) => [
+      { id: `eval_${Date.now().toString(36)}`, query: queryText, expected, status: "draft", recall: 0, precision: 0 },
+      ...items,
+    ]);
+  }
+
+  function runEvalCase(evalId: string) {
+    setEvalCases((items) =>
+      items.map((item) =>
+        item.id === evalId
+          ? {
+              ...item,
+              status: Math.random() > 0.18 ? "passed" : "failed",
+              recall: Number((0.72 + Math.random() * 0.25).toFixed(2)),
+              precision: Number((0.68 + Math.random() * 0.28).toFixed(2)),
+            }
+          : item,
+      ),
+    );
+    toast.success("Evaluation case finished");
+  }
+
+  if (loading) return <LoadingSpinner label="Loading knowledge" />;
+  if (error) return <ApiErrorState message={error} onRetry={() => void loadBases()} />;
+
+  return (
+    <div className="space-y-6">
+      <PageHeader
+        title="Knowledge"
+        description="Manage retrieval bases, ingest documents, inspect indexing state, and test semantic search."
+        actions={(
+          <div className="flex flex-wrap gap-2">
+            <Button variant="secondary" onClick={() => void reloadSelectedBase()}>
+              <RefreshCw className="h-4 w-4" /> Refresh
+            </Button>
+            <Button variant="secondary" disabled={!selectedBaseId} onClick={() => setDocumentOpen(true)}>
+              <FilePlus className="h-4 w-4" /> Add Document
+            </Button>
+            <Button variant="secondary" disabled={!activeBaseIds.length} onClick={() => createJobsForActiveBases("Re-index")}>
+              <Layers3 className="h-4 w-4" /> Re-index Scope
+            </Button>
+            <Button onClick={() => setCreateOpen(true)}><BookOpen className="h-4 w-4" /> New Base</Button>
+          </div>
+        )}
+      />
+
+      <KnowledgeSectionNav />
+      <KnowledgeScopeBar
+        bases={bases}
+        selectedBaseId={selectedBaseId}
+        selectedBases={selectedBases}
+        scopeMode={scopeMode}
+        onSelectBase={selectBase}
+        onScopeMode={setScopeMode}
+      />
+
+      {section === "overview" ? (
+        <div className="space-y-6">
+          <div className="grid gap-4 md:grid-cols-4">
+            <MetricCard label="Knowledge Bases" value={bases.length} icon={Database} />
+            <MetricCard label="Documents" value={documents.length} icon={FileText} />
+            <MetricCard label="Indexed" value={indexedCount} icon={Sparkles} />
+            <MetricCard label="Sources" value={sourceTypes.length} icon={Filter} />
+          </div>
+          <div className="grid gap-6 xl:grid-cols-[360px_1fr]">
+            <KnowledgeBaseList
+              bases={filteredBases}
+              baseSearch={baseSearch}
+              onSearch={setBaseSearch}
+              selectedBaseId={selectedBaseId}
+              onCreate={() => setCreateOpen(true)}
+              onSelect={selectBase}
+            />
+            <div className="space-y-6">
+              <BaseSummaryCard selectedBase={selectedBase} selectedBases={selectedBases} documents={documents} indexedCount={indexedCount} statusBusy={statusBusy} onToggleStatus={() => void toggleBaseStatus()} onReindexSet={() => createJobsForActiveBases("Re-index")} />
+              <KnowledgeCapabilityBoard compact />
+            </div>
+          </div>
+        </div>
+      ) : null}
+
+      {section === "documents" ? (
+        <div className="grid gap-6 2xl:grid-cols-[minmax(0,1fr)_420px]">
+          <DocumentsPanel
+            documentsLoading={documentsLoading}
+            documents={documents}
+            filteredDocuments={filteredDocuments}
+            documentSearch={documentSearch}
+            documentStatus={documentStatus}
+            selectedDocumentId={selectedDocumentId}
+            baseNameById={baseNameById}
+            onSearch={setDocumentSearch}
+            onStatus={setDocumentStatus}
+            onAdd={() => setDocumentOpen(true)}
+            onSelect={(documentId) => {
+              setSelectedDocumentId(documentId);
+              setActiveTab("overview");
+            }}
+          />
+          <DocumentInspector
+            activeTab={activeTab}
+            onTab={setActiveTab}
+            selectedBase={selectedBase}
+            selectedDocument={selectedDocument}
+            selectedDocumentResults={selectedDocumentResults}
+            selectedDocumentChunks={selectedDocumentChunks}
+            lastIngest={lastIngest}
+          />
+        </div>
+      ) : null}
+
+      {section === "ingest" ? (
+        <div className="grid gap-6 xl:grid-cols-[360px_1fr]">
+          <KnowledgeBaseList
+            bases={filteredBases}
+            baseSearch={baseSearch}
+            onSearch={setBaseSearch}
+            selectedBaseId={selectedBaseId}
+            onCreate={() => setCreateOpen(true)}
+            onSelect={selectBase}
+          />
+          <Card>
+            <CardHeader>
+              <CardTitle>Ingest Documents</CardTitle>
+              <p className="text-sm text-muted-foreground">Prototype every ingestion source from the frontend. Each connector creates a local indexing job and behaves like a complete workflow.</p>
+            </CardHeader>
+            <CardContent className="grid gap-4 md:grid-cols-2">
+              <IngestSourceCard icon={FileText} title="Paste Text" state="live" description="Create, parse, chunk, embed, and index content." onClick={() => setDocumentOpen(true)} />
+              <IngestSourceCard icon={Link} title="URL / Sitemap" state="prototype" description="Configure crawl depth, include patterns, and sync cadence." onClick={() => openConnector("URL / Sitemap")} />
+              <IngestSourceCard icon={GitBranch} title="GitHub Repository" state="prototype" description="Choose repository, branch, path filters, and sync mode." onClick={() => openConnector("GitHub Repository")} />
+              <IngestSourceCard icon={UploadCloud} title="PDF / DOCX Upload" state="prototype" description="Stage a batch, preview parser settings, and create an indexing job." onClick={() => openConnector("File Upload")} />
+            </CardContent>
+          </Card>
+        </div>
+      ) : null}
+
+      {section === "playground" ? (
+        <div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_420px]">
+          <Card>
+            <CardHeader>
+              <CardTitle>Retrieval Playground</CardTitle>
+              <p className="text-sm text-muted-foreground">Tune top K and filters, run retrieval, then inspect citations and score breakdowns.</p>
+            </CardHeader>
+            <CardContent className="space-y-4">
+              <form className="grid gap-3 xl:grid-cols-[minmax(260px,1fr)_170px_140px_auto]" onSubmit={(event) => void runSearch(event)}>
+                <Input aria-label="Knowledge search query" value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Ask a retrieval question" />
+                <Select aria-label="Search source filter" value={sourceType} onChange={(event) => setSourceType(event.target.value)} options={sourceTypeOptions} />
+                <Select aria-label="Top K" value={topK} onChange={(event) => setTopK(event.target.value)} options={[3, 5, 10, 20].map((value) => ({ value: String(value), label: `Top ${value}` }))} />
+                <Button disabled={!activeBaseIds.length || !query.trim() || searching}><Search className="h-4 w-4" /> {searching ? "Searching..." : `Search ${activeBaseIds.length}`}</Button>
+              </form>
+              {results.length ? <div className="space-y-3">{results.map((result) => <SearchResultCard key={result.chunk.id} result={result} />)}</div> : <EmptyState icon={Search} title="No search results yet" description="Run a retrieval query to inspect chunks, citations, and score JSON." />}
+            </CardContent>
+          </Card>
+          <RetrievalSettingsPanel config={retrievalConfig} onChange={setRetrievalConfig} />
+        </div>
+      ) : null}
+
+      {section === "evaluation" ? <EvaluationPage evalCases={evalCases} onAdd={addEvalCase} onRun={runEvalCase} /> : null}
+      {section === "jobs" ? <JobsPage jobs={jobs} onUpdate={updateJob} onCreateReindex={() => createJobsForActiveBases("Re-index")} /> : null}
+      {section === "analytics" ? <AnalyticsPage documents={documents} results={results} jobs={jobs} evalCases={evalCases} /> : null}
+      {section === "settings" ? (
+        <div className="grid gap-6 xl:grid-cols-2">
+          <RetrievalSettingsPanel config={retrievalConfig} onChange={setRetrievalConfig} />
+          <GovernanceSettingsPanel config={retrievalConfig} onChange={setRetrievalConfig} />
+        </div>
+      ) : null}
+
+      <CreateKnowledgeBaseDialog open={createOpen} onOpenChange={setCreateOpen} onCreated={() => void loadBases()} />
+      <CreateKnowledgeDocumentDialog
+        open={documentOpen}
+        onOpenChange={setDocumentOpen}
+        knowledgeBaseId={selectedBaseId ?? activeBaseIds[0]}
+        onCreated={(ingest) => {
+          setLastIngest(ingest);
+          setSelectedDocumentId(ingest.document.id);
+          setActiveTab("overview");
+          void loadDocuments(activeBaseIds);
+        }}
+      />
+      <ConnectorDialog
+        open={connectorOpen}
+        onOpenChange={setConnectorOpen}
+        kind={connectorKind}
+        onCreate={(target) => createLocalJob(connectorKind, target)}
+      />
+    </div>
+  );
+}
+
+function KnowledgeSectionNav() {
+  return (
+    <div className="overflow-x-auto rounded-md border border-border bg-surface-elevated p-1">
+      <nav className="flex min-w-max gap-1">
+        {knowledgeSections.map((item) => (
+          <NavLink
+            key={item.value}
+            to={item.path}
+            end={item.value === "overview"}
+            className={({ isActive }) => [
+              "inline-flex min-h-10 items-center gap-2 rounded-sm px-3 text-sm text-muted-foreground transition hover:bg-muted hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-primary",
+              isActive ? "bg-primary/15 text-primary" : "",
+            ].join(" ")}
+          >
+            <item.icon className="h-4 w-4" />
+            {item.label}
+          </NavLink>
+        ))}
+      </nav>
+    </div>
+  );
+}
+
+function KnowledgeScopeBar({
+  bases,
+  selectedBaseId,
+  selectedBases,
+  scopeMode,
+  onSelectBase,
+  onScopeMode,
+}: {
+  bases: KnowledgeBase[];
+  selectedBaseId?: string;
+  selectedBases: KnowledgeBase[];
+  scopeMode: "current" | "all";
+  onSelectBase: (baseId: string) => void;
+  onScopeMode: (mode: "current" | "all") => void;
+}) {
+  const selectedBase = bases.find((base) => base.id === selectedBaseId);
+  return (
+    <Card>
+      <CardContent className="flex flex-col gap-4 p-4 lg:flex-row lg:items-center lg:justify-between">
+        <div className="grid gap-3 sm:grid-cols-[minmax(220px,360px)_auto]">
+          <Field label="Active Knowledge Base">
+            <Select
+              value={selectedBaseId ?? ""}
+              onChange={(event) => onSelectBase(event.target.value)}
+              options={bases.map((base) => ({ value: base.id, label: base.name }))}
+            />
+          </Field>
+          <div className="space-y-2">
+            <span className="block text-sm text-muted-foreground">Scope</span>
+            <div className="flex rounded-md border border-border bg-muted/30 p-1">
+              <button
+                type="button"
+                onClick={() => onScopeMode("current")}
+                className={[
+                  "min-h-9 rounded-sm px-3 text-sm transition",
+                  scopeMode === "current" ? "bg-surface-elevated text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground",
+                ].join(" ")}
+              >
+                Current
+              </button>
+              <button
+                type="button"
+                onClick={() => onScopeMode("all")}
+                className={[
+                  "min-h-9 rounded-sm px-3 text-sm transition",
+                  scopeMode === "all" ? "bg-surface-elevated text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground",
+                ].join(" ")}
+              >
+                All Bases
+              </button>
+            </div>
+          </div>
+        </div>
+        <div className="min-w-0 rounded-md border border-border bg-muted/30 px-3 py-2">
+          <p className="text-xs text-muted-foreground">Operations apply to</p>
+          <p className="mt-1 truncate text-sm font-medium">
+            {scopeMode === "all" ? `${selectedBases.length} knowledge bases` : selectedBase?.name ?? "No base selected"}
+          </p>
+        </div>
+      </CardContent>
+    </Card>
+  );
+}
+
+function KnowledgeBaseList({
+  bases,
+  baseSearch,
+  selectedBaseId,
+  onSearch,
+  onSelect,
+  onCreate,
+}: {
+  bases: KnowledgeBase[];
+  baseSearch: string;
+  selectedBaseId?: string;
+  onSearch: (value: string) => void;
+  onSelect: (baseId: string) => void;
+  onCreate: () => void;
+}) {
+  return (
+    <Card>
+      <CardHeader>
+        <CardTitle>Bases</CardTitle>
+      </CardHeader>
+      <CardContent className="space-y-4">
+        <SearchInput value={baseSearch} onChange={onSearch} placeholder="Search bases" />
+        {bases.length ? (
+          <div className="space-y-2">
+            {bases.map((base) => (
+              <button
+                key={base.id}
+                type="button"
+                onClick={() => onSelect(base.id)}
+                className={[
+                  "flex min-h-16 w-full items-center justify-between gap-3 rounded-md border border-border bg-muted/40 p-3 text-left transition hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-primary",
+                  base.id === selectedBaseId ? "border-primary/40 bg-primary/10" : "",
+                ].join(" ")}
+              >
+                <span className="min-w-0">
+                  <span className="block truncate text-sm font-medium">{base.name}</span>
+                  {base.description ? <span className="mt-1 block truncate font-mono text-xs text-muted-foreground">{base.description}</span> : null}
+                </span>
+                <StatusBadge status={base.status} />
+              </button>
+            ))}
+          </div>
+        ) : (
+          <EmptyState icon={BookOpen} title="No bases found" description="Create or search for another knowledge base." actionLabel="New Base" onAction={onCreate} />
+        )}
+      </CardContent>
+    </Card>
+  );
+}
+
+function BaseSummaryCard({
+  selectedBase,
+  selectedBases,
+  documents,
+  indexedCount,
+  statusBusy,
+  onToggleStatus,
+  onReindexSet,
+}: {
+  selectedBase?: KnowledgeBase;
+  selectedBases: KnowledgeBase[];
+  documents: KnowledgeDocument[];
+  indexedCount: number;
+  statusBusy: boolean;
+  onToggleStatus: () => void;
+  onReindexSet: () => void;
+}) {
+  const title = selectedBases.length > 1 ? `${selectedBases.length} Knowledge Bases` : selectedBase?.name ?? "Select a Knowledge Base";
+  return (
+    <Card>
+      <CardHeader>
+        <div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
+          <div>
+            <CardTitle>{title}</CardTitle>
+            <p className="mt-1 text-sm text-muted-foreground">
+              {selectedBases.length > 1 ? selectedBases.map((base) => base.name).join(", ") : selectedBase?.description ?? "Choose a base to manage documents, retrieval, ingestion, and settings."}
+            </p>
+          </div>
+          <div className="flex flex-wrap gap-2">
+            {selectedBase ? (
+              <Button variant="outline" disabled={statusBusy} onClick={onToggleStatus}>
+                <Archive className="h-4 w-4" /> {selectedBase.status === "active" ? "Archive" : "Restore Current"}
+              </Button>
+            ) : null}
+            <Button variant="secondary" disabled={!selectedBases.length} onClick={onReindexSet}>
+              <Layers3 className="h-4 w-4" /> Re-index Set
+            </Button>
+          </div>
+        </div>
+      </CardHeader>
+      <CardContent className="grid gap-3 md:grid-cols-4">
+        <Detail label="Selected" value={String(selectedBases.length || (selectedBase ? 1 : 0))} />
+        <Detail label="Status" value={selectedBase?.status ?? "Not selected"} />
+        <Detail label="Documents" value={String(documents.length)} />
+        <Detail label="Indexed" value={String(indexedCount)} />
+      </CardContent>
+    </Card>
+  );
+}
+
+function DocumentsPanel({
+  documentsLoading,
+  documents,
+  filteredDocuments,
+  documentSearch,
+  documentStatus,
+  selectedDocumentId,
+  baseNameById,
+  onSearch,
+  onStatus,
+  onAdd,
+  onSelect,
+}: {
+  documentsLoading: boolean;
+  documents: KnowledgeDocument[];
+  filteredDocuments: KnowledgeDocument[];
+  documentSearch: string;
+  documentStatus: string;
+  selectedDocumentId?: string;
+  baseNameById: Map<string, string>;
+  onSearch: (value: string) => void;
+  onStatus: (value: string) => void;
+  onAdd: () => void;
+  onSelect: (documentId: string) => void;
+}) {
+  return (
+    <Card>
+      <CardHeader>
+        <div className="flex flex-col gap-3 xl:flex-row xl:items-center xl:justify-between">
+          <CardTitle>Documents</CardTitle>
+          <div className="grid w-full gap-2 lg:w-[720px] lg:grid-cols-[minmax(220px,1fr)_180px_auto]">
+            <SearchInput value={documentSearch} onChange={onSearch} placeholder="Search documents" />
+            <Select aria-label="Document status" value={documentStatus} onChange={(event) => onStatus(event.target.value)} options={documentStatusOptions} />
+            <Button variant="secondary" onClick={onAdd}><FilePlus className="h-4 w-4" /> Add</Button>
+          </div>
+        </div>
+      </CardHeader>
+      <CardContent>
+        {documentsLoading ? (
+          <LoadingSpinner label="Loading documents" />
+        ) : filteredDocuments.length ? (
+          <div className="grid gap-3 lg:grid-cols-2">
+            {filteredDocuments.map((document) => (
+              <button
+                key={document.id}
+                type="button"
+                onClick={() => onSelect(document.id)}
+                className={[
+                  "min-h-28 rounded-md border p-4 text-left transition hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-primary",
+                  document.id === selectedDocumentId ? "border-primary/50 bg-primary/10" : "border-border bg-muted/30",
+                ].join(" ")}
+              >
+                <div className="flex items-start justify-between gap-3">
+                  <div className="min-w-0">
+                          <p className="truncate text-sm font-semibold">{document.title}</p>
+                          <p className="mt-1 truncate text-xs text-muted-foreground">{baseNameById.get(document.knowledge_base_id) ?? "Knowledge Base"} / {document.source_uri || document.source_type}</p>
+                  </div>
+                  <StatusBadge status={document.status} />
+                </div>
+                <div className="mt-4 grid grid-cols-2 gap-3 text-xs text-muted-foreground">
+                  <Detail label="Source" value={document.source_type} />
+                  <Detail label="Indexed" value={document.indexed_time ? formatDateTime(document.indexed_time) : "Pending"} />
+                </div>
+              </button>
+            ))}
+          </div>
+        ) : (
+          <EmptyState
+            icon={FileText}
+            title={documents.length ? "No documents match filters" : "No documents"}
+            description={documents.length ? "Adjust the search text or status filter." : "Add a text, markdown, JSON, HTML, or file-derived document to index it for retrieval."}
+            actionLabel="Add Document"
+            onAction={onAdd}
+          />
+        )}
+      </CardContent>
+    </Card>
+  );
+}
+
+function DocumentInspector({
+  activeTab,
+  onTab,
+  selectedBase,
+  selectedDocument,
+  selectedDocumentResults,
+  selectedDocumentChunks,
+  lastIngest,
+}: {
+  activeTab: string;
+  onTab: (value: string) => void;
+  selectedBase?: KnowledgeBase;
+  selectedDocument?: KnowledgeDocument;
+  selectedDocumentResults: SearchResult[];
+  selectedDocumentChunks: SearchResult["chunk"][];
+  lastIngest?: KnowledgeDocumentIngestResponse;
+}) {
+  return (
+    <Card>
+      <CardHeader>
+        <CardTitle>{selectedDocument?.title ?? "Inspector"}</CardTitle>
+      </CardHeader>
+      <CardContent>
+        <Tabs
+          value={activeTab}
+          onChange={onTab}
+          tabs={[
+            {
+              value: "overview",
+              label: "Overview",
+              content: selectedDocument ? (
+                <div className="space-y-4 text-sm">
+                  <div className="grid grid-cols-2 gap-3">
+                    <Detail label="Status" value={selectedDocument.status} />
+                    <Detail label="Source" value={selectedDocument.source_type} />
+                    <Detail label="Created" value={formatDateTime(selectedDocument.created_time)} />
+                    <Detail label="Indexed" value={selectedDocument.indexed_time ? formatDateTime(selectedDocument.indexed_time) : "Pending"} />
+                  </div>
+                  <Detail label="Source URI" value={selectedDocument.source_uri ?? "Not provided"} />
+                  <Detail label="Content Hash" value={selectedDocument.content_hash ?? "Not available"} />
+                  {lastIngest?.document.id === selectedDocument.id ? (
+                    <div className="rounded-md border border-green-500/25 bg-green-500/10 p-3 text-sm text-green-700 dark:text-green-200">
+                      Last ingest created {lastIngest.chunks.length} chunk{lastIngest.chunks.length === 1 ? "" : "s"}.
+                    </div>
+                  ) : null}
+                </div>
+              ) : (
+                <EmptyState icon={FileText} title="Select a document" description="Document details, metadata, and matching chunks appear here." />
+              ),
+            },
+            {
+              value: "search",
+              label: "Search",
+              content: selectedDocumentResults.length ? (
+                <div className="space-y-3">{selectedDocumentResults.map((result) => <SearchResultCard key={result.chunk.id} result={result} />)}</div>
+              ) : (
+                <EmptyState icon={Search} title="No document search results" description="Run a query in Playground to inspect matching chunks for this document." />
+              ),
+            },
+            {
+              value: "metadata",
+              label: "Metadata",
+              content: selectedDocument ? <JsonViewer value={selectedDocument.metadata_json ?? {}} collapsed={false} /> : <JsonViewer value={selectedBase?.metadata_json ?? {}} collapsed={false} />,
+            },
+            {
+              value: "chunks",
+              label: "Chunks",
+              content: selectedDocumentChunks.length ? (
+                <div className="space-y-3">
+                  {selectedDocumentChunks.map((chunk) => (
+                    <div key={chunk.id} className="rounded-md border border-border bg-muted/30 p-3">
+                      <div className="flex items-center justify-between gap-3">
+                        <span className="font-mono text-xs text-muted-foreground">chunk #{chunk.chunk_index}</span>
+                        <span className="font-mono text-xs text-muted-foreground">{chunk.token_count} tokens</span>
+                      </div>
+                      <p className="mt-3 text-sm leading-6 text-muted-foreground">{chunk.content_text}</p>
+                    </div>
+                  ))}
+                </div>
+              ) : (
+                <EmptyState icon={Layers3} title="No chunks loaded" description="Run search or index a new document to inspect chunks." />
+              ),
+            },
+          ]}
+        />
+      </CardContent>
+    </Card>
+  );
+}
+
+function IngestSourceCard({ icon: Icon, title, description, state, onClick }: { icon: LucideIcon; title: string; description: string; state: string; onClick?: () => void }) {
+  return (
+    <button
+      type="button"
+      disabled={!onClick}
+      onClick={onClick}
+      className="min-h-36 rounded-md border border-border bg-muted/30 p-4 text-left transition enabled:hover:bg-muted enabled:focus:outline-none enabled:focus-visible:ring-2 enabled:focus-visible:ring-primary disabled:cursor-not-allowed disabled:opacity-70"
+    >
+      <div className="flex items-start justify-between gap-3">
+        <span className="flex h-10 w-10 items-center justify-center rounded-md bg-primary/10 text-primary"><Icon className="h-5 w-5" /></span>
+        <StateBadge state={state} compact />
+      </div>
+      <p className="mt-4 text-sm font-semibold">{title}</p>
+      <p className="mt-2 text-sm leading-6 text-muted-foreground">{description}</p>
+    </button>
+  );
+}
+
+function EvaluationPage({ evalCases, onAdd, onRun }: { evalCases: EvalCase[]; onAdd: (queryText: string, expected: string) => void; onRun: (evalId: string) => void }) {
+  const [queryText, setQueryText] = React.useState("");
+  const [expected, setExpected] = React.useState("");
+  const avgRecall = average(evalCases.map((item) => item.recall));
+  const avgPrecision = average(evalCases.map((item) => item.precision));
+
+  function submit(event: React.FormEvent) {
+    event.preventDefault();
+    if (!queryText.trim()) return;
+    onAdd(queryText.trim(), expected.trim() || "Expected citation");
+    setQueryText("");
+    setExpected("");
+  }
+
+  return (
+    <div className="grid gap-6 xl:grid-cols-[420px_1fr]">
+      <Card>
+        <CardHeader>
+          <CardTitle>Golden Query</CardTitle>
+          <p className="text-sm text-muted-foreground">Build an evaluation set and run frontend benchmark simulations.</p>
+        </CardHeader>
+        <CardContent>
+          <form className="space-y-4" onSubmit={submit}>
+            <Field label="Query"><Textarea required value={queryText} onChange={(event) => setQueryText(event.target.value)} /></Field>
+            <Field label="Expected Source"><Input value={expected} onChange={(event) => setExpected(event.target.value)} /></Field>
+            <Button className="w-full"><ClipboardCheck className="h-4 w-4" /> Add Case</Button>
+          </form>
+          <div className="mt-6 grid grid-cols-2 gap-3">
+            <ScoreTile label="Avg Recall" value={avgRecall} />
+            <ScoreTile label="Avg Precision" value={avgPrecision} />
+          </div>
+        </CardContent>
+      </Card>
+      <Card>
+        <CardHeader>
+          <CardTitle>Evaluation Cases</CardTitle>
+        </CardHeader>
+        <CardContent className="space-y-3">
+          {evalCases.map((item) => (
+            <div key={item.id} className="rounded-md border border-border bg-muted/30 p-4">
+              <div className="flex items-start justify-between gap-3">
+                <div className="min-w-0">
+                  <p className="text-sm font-medium">{item.query}</p>
+                  <p className="mt-1 text-xs text-muted-foreground">Expected: {item.expected}</p>
+                </div>
+                <StatusBadge status={item.status} />
+              </div>
+              <div className="mt-4 grid gap-3 sm:grid-cols-[1fr_1fr_auto]">
+                <BarMeter label="Recall" value={item.recall} />
+                <BarMeter label="Precision" value={item.precision} />
+                <Button size="sm" variant="secondary" onClick={() => onRun(item.id)}><RotateCcw className="h-4 w-4" /> Run</Button>
+              </div>
+            </div>
+          ))}
+        </CardContent>
+      </Card>
+    </div>
+  );
+}
+
+function JobsPage({ jobs, onUpdate, onCreateReindex }: { jobs: KnowledgeJob[]; onUpdate: (jobId: string, patch: Partial<KnowledgeJob>) => void; onCreateReindex: () => void }) {
+  return (
+    <Card>
+      <CardHeader>
+        <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
+          <div>
+            <CardTitle>Index Jobs</CardTitle>
+            <p className="text-sm text-muted-foreground">Manage frontend prototype queues for connector sync, parsing, chunking, embedding, and re-indexing.</p>
+          </div>
+          <Button onClick={onCreateReindex}><RefreshCw className="h-4 w-4" /> Re-index Base</Button>
+        </div>
+      </CardHeader>
+      <CardContent className="space-y-3">
+        {jobs.map((job) => (
+          <div key={job.id} className="rounded-md border border-border bg-muted/30 p-4">
+            <div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
+              <div className="min-w-0">
+                <div className="flex flex-wrap items-center gap-2">
+                  <p className="text-sm font-semibold">{job.type}</p>
+                  <StatusBadge status={job.status} />
+                </div>
+                <p className="mt-1 text-xs text-muted-foreground">{job.target} / {formatDateTime(job.created_time)}</p>
+              </div>
+              <div className="flex flex-wrap gap-2">
+                <Button size="sm" variant="secondary" onClick={() => onUpdate(job.id, { status: "running", progress: Math.max(job.progress, 12) })}><Clock className="h-4 w-4" /> Start</Button>
+                <Button size="sm" variant="secondary" onClick={() => onUpdate(job.id, { status: "completed", progress: 100 })}><CheckCircle2 className="h-4 w-4" /> Complete</Button>
+                <Button size="sm" variant="outline" onClick={() => onUpdate(job.id, { status: "queued", progress: 0 })}><RotateCcw className="h-4 w-4" /> Retry</Button>
+              </div>
+            </div>
+            <div className="mt-4">
+              <BarMeter label="Progress" value={job.progress / 100} />
+            </div>
+          </div>
+        ))}
+      </CardContent>
+    </Card>
+  );
+}
+
+function AnalyticsPage({ documents, results, jobs, evalCases }: { documents: KnowledgeDocument[]; results: SearchResult[]; jobs: KnowledgeJob[]; evalCases: EvalCase[] }) {
+  const failedJobs = jobs.filter((job) => job.status === "failed").length;
+  const avgScore = average(results.map((result) => result.score));
+  const indexedRatio = documents.length ? documents.filter((document) => document.status === "indexed").length / documents.length : 0;
+  const passRatio = evalCases.length ? evalCases.filter((item) => item.status === "passed").length / evalCases.length : 0;
+
+  return (
+    <div className="space-y-6">
+      <div className="grid gap-4 md:grid-cols-4">
+        <MetricCard label="Indexed Ratio" value={`${Math.round(indexedRatio * 100)}%`} icon={Sparkles} />
+        <MetricCard label="Avg Score" value={avgScore ? avgScore.toFixed(2) : "0.00"} icon={Gauge} />
+        <MetricCard label="Eval Pass" value={`${Math.round(passRatio * 100)}%`} icon={ClipboardCheck} />
+        <MetricCard label="Failed Jobs" value={failedJobs} icon={BarChart3} />
+      </div>
+      <Card>
+        <CardHeader>
+          <CardTitle>Quality Signals</CardTitle>
+        </CardHeader>
+        <CardContent className="grid gap-4 md:grid-cols-2">
+          <BarMeter label="Index Coverage" value={indexedRatio} />
+          <BarMeter label="Citation Confidence" value={avgScore || 0} />
+          <BarMeter label="Evaluation Pass Rate" value={passRatio} />
+          <BarMeter label="Job Health" value={jobs.length ? 1 - failedJobs / jobs.length : 1} />
+        </CardContent>
+      </Card>
+      <Card>
+        <CardHeader>
+          <CardTitle>Top Queries</CardTitle>
+        </CardHeader>
+        <CardContent className="grid gap-3 md:grid-cols-2">
+          {["download invoice", "refund policy", "billing contact", "plan upgrade"].map((item, index) => (
+            <div key={item} className="rounded-md border border-border bg-muted/30 p-4">
+              <p className="text-sm font-medium">{item}</p>
+              <p className="mt-1 font-mono text-xs text-muted-foreground">{42 - index * 7} searches / {Math.round((0.91 - index * 0.06) * 100)}% success</p>
+            </div>
+          ))}
+        </CardContent>
+      </Card>
+    </div>
+  );
+}
+
+function GovernanceSettingsPanel({ config, onChange }: { config: RetrievalConfig; onChange: (config: RetrievalConfig) => void }) {
+  function update(patch: Partial<RetrievalConfig>) {
+    onChange({ ...config, ...patch });
+  }
+
+  return (
+    <Card>
+      <CardHeader>
+        <CardTitle>Governance Settings</CardTitle>
+        <p className="text-sm text-muted-foreground">Prototype access, safety, and answer policy controls.</p>
+      </CardHeader>
+      <CardContent className="space-y-4">
+        <Field label="ACL Mode">
+          <Select value={config.aclMode} onChange={(event) => update({ aclMode: event.target.value })} options={[
+            { value: "private", label: "Private" },
+            { value: "team", label: "Team" },
+            { value: "workspace", label: "Workspace" },
+          ]} />
+        </Field>
+        <ToggleRow icon={ShieldCheck} title="PII redaction" description="Mask sensitive values before content enters retrieval context." checked={config.piiRedaction} onChange={(checked) => update({ piiRedaction: checked })} />
+        <ToggleRow icon={LockKeyhole} title="Document-level access checks" description="Apply metadata security filters before ranking." checked={config.aclMode !== "workspace"} onChange={(checked) => update({ aclMode: checked ? "team" : "workspace" })} />
+        <FeatureRow icon={KeyRound} title="API Scope Preview" description="Read, ingest, search, admin, and evaluation scopes are ready in the front-end policy model." state="prototype" />
+      </CardContent>
+    </Card>
+  );
+}
+
+function ConnectorDialog({ open, onOpenChange, kind, onCreate }: { open: boolean; onOpenChange: (open: boolean) => void; kind: string; onCreate: (target: string) => void }) {
+  const [target, setTarget] = React.useState("");
+  const [schedule, setSchedule] = React.useState("manual");
+  const [filters, setFilters] = React.useState("");
+
+  function submit(event: React.FormEvent) {
+    event.preventDefault();
+    onCreate(target.trim() || kind);
+    onOpenChange(false);
+    setTarget("");
+    setFilters("");
+    setSchedule("manual");
+  }
+
+  return (
+    <Dialog open={open} onOpenChange={onOpenChange} title={`Configure ${kind}`} className="max-w-3xl">
+      <form className="space-y-4" onSubmit={submit}>
+        <Field label="Source"><Input required value={target} onChange={(event) => setTarget(event.target.value)} placeholder="URL, repository, folder, or batch name" /></Field>
+        <Field label="Sync Mode">
+          <Select value={schedule} onChange={(event) => setSchedule(event.target.value)} options={[
+            { value: "manual", label: "Manual" },
+            { value: "hourly", label: "Hourly" },
+            { value: "daily", label: "Daily" },
+            { value: "weekly", label: "Weekly" },
+          ]} />
+        </Field>
+        <Field label="Include / Exclude Filters"><Textarea value={filters} onChange={(event) => setFilters(event.target.value)} placeholder="docs/**, !archive/**" /></Field>
+        <div className="rounded-md border border-border bg-muted/30 p-3 text-sm text-muted-foreground">
+          This creates a local prototype job. The UI flow is complete even before persistence is connected.
+        </div>
+        <div className="flex justify-end gap-2">
+          <Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>Cancel</Button>
+          <Button><Layers3 className="h-4 w-4" /> Create Job</Button>
+        </div>
+      </form>
+    </Dialog>
+  );
+}
+
+function ToggleRow({ icon: Icon, title, description, checked, onChange }: { icon: LucideIcon; title: string; description: string; checked: boolean; onChange: (checked: boolean) => void }) {
+  return (
+    <label className="flex min-h-16 cursor-pointer items-start justify-between gap-4 rounded-md border border-border bg-muted/20 p-3">
+      <span className="flex min-w-0 gap-3">
+        <Icon className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
+        <span className="min-w-0">
+          <span className="block text-sm font-medium">{title}</span>
+          <span className="mt-1 block text-xs leading-5 text-muted-foreground">{description}</span>
+        </span>
+      </span>
+      <input type="checkbox" className="mt-1 h-5 w-5 accent-primary" checked={checked} onChange={(event) => onChange(event.target.checked)} />
+    </label>
+  );
+}
+
+function BarMeter({ label, value }: { label: string; value: number }) {
+  const normalized = Math.max(0, Math.min(1, value || 0));
+  return (
+    <div>
+      <div className="flex items-center justify-between gap-3">
+        <p className="text-xs text-muted-foreground">{label}</p>
+        <p className="font-mono text-xs text-foreground">{Math.round(normalized * 100)}%</p>
+      </div>
+      <div className="mt-2 h-2 overflow-hidden rounded-full bg-muted">
+        <div className="h-full rounded-full bg-primary" style={{ width: `${normalized * 100}%` }} />
+      </div>
+    </div>
+  );
+}
+
+function ScoreTile({ label, value }: { label: string; value: number }) {
+  return (
+    <div className="rounded-md border border-border bg-muted/30 p-3">
+      <p className="text-xs text-muted-foreground">{label}</p>
+      <p className="mt-1 font-mono text-xl font-semibold">{Math.round(value * 100)}%</p>
+    </div>
+  );
+}
+
+function average(values: number[]) {
+  const useful = values.filter((value) => value > 0);
+  return useful.length ? useful.reduce((sum, value) => sum + value, 0) / useful.length : 0;
+}
+
+function KnowledgeCapabilityBoard({ compact = false }: { compact?: boolean }) {
+  return (
+    <Card>
+      <CardHeader>
+        <div className="flex flex-col gap-2 lg:flex-row lg:items-center lg:justify-between">
+          <div>
+            <CardTitle>Knowledge Capability Map</CardTitle>
+            <p className="mt-1 text-sm text-muted-foreground">
+              {compact ? "Module status at a glance. Open the dedicated section for each workflow." : "Product surface for a full RAG knowledge platform. Prototype items are fully represented in the frontend flow and ready for later persistence."}
+            </p>
+          </div>
+          <div className="flex flex-wrap gap-2">
+            <StateBadge state="live" />
+            <StateBadge state="mock" />
+            <StateBadge state="prototype" />
+          </div>
+        </div>
+      </CardHeader>
+      <CardContent>
+        <div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
+          {capabilityGroups.map((group) => (
+            <div key={group.title} className="rounded-md border border-border bg-muted/30 p-4">
+              <div className="flex items-center gap-3">
+                <span className="flex h-10 w-10 items-center justify-center rounded-md bg-primary/10 text-primary">
+                  <group.icon className="h-5 w-5" />
+                </span>
+                <h3 className="text-sm font-semibold">{group.title}</h3>
+              </div>
+              <div className="mt-4 space-y-2">
+                {group.items.map((item) => (
+                  <div key={item.label} className="flex items-center justify-between gap-3 text-sm">
+                    <span className="min-w-0 text-muted-foreground">{item.label}</span>
+                    <StateBadge state={item.state} compact />
+                  </div>
+                ))}
+              </div>
+            </div>
+          ))}
+        </div>
+      </CardContent>
+    </Card>
+  );
+}
+
+function RetrievalSettingsPanel({ config, onChange }: { config: RetrievalConfig; onChange: (config: RetrievalConfig) => void }) {
+  function update(patch: Partial<RetrievalConfig>) {
+    onChange({ ...config, ...patch });
+  }
+
+  return (
+    <Card>
+      <CardHeader>
+        <CardTitle>Retrieval Settings</CardTitle>
+        <p className="text-sm text-muted-foreground">Tune the retrieval behavior directly in the frontend prototype.</p>
+      </CardHeader>
+      <CardContent className="space-y-4">
+      <div className="rounded-md border border-border bg-muted/30 p-4">
+        <div className="flex items-center gap-2">
+          <Settings2 className="h-4 w-4 text-primary" />
+          <p className="text-sm font-semibold">Retrieval Defaults</p>
+        </div>
+        <div className="mt-4 grid gap-3 xl:grid-cols-3">
+          <Field label="Chunk Size"><Input inputMode="numeric" value={config.chunkSize} onChange={(event) => update({ chunkSize: event.target.value })} /></Field>
+          <Field label="Overlap"><Input inputMode="numeric" value={config.chunkOverlap} onChange={(event) => update({ chunkOverlap: event.target.value })} /></Field>
+          <Field label="Top K"><Input inputMode="numeric" value={config.topK} onChange={(event) => update({ topK: event.target.value })} /></Field>
+        </div>
+      </div>
+      <div className="grid gap-3 xl:grid-cols-3">
+        <Field label="Keyword Weight"><Input value={config.keywordWeight} onChange={(event) => update({ keywordWeight: event.target.value })} /></Field>
+        <Field label="Vector Weight"><Input value={config.vectorWeight} onChange={(event) => update({ vectorWeight: event.target.value })} /></Field>
+        <Field label="Rerank Weight"><Input value={config.rerankWeight} onChange={(event) => update({ rerankWeight: event.target.value })} /></Field>
+      </div>
+      <div className="grid gap-3">
+        <ToggleRow icon={Gauge} title="Rerank results" description="Reorder candidates after hybrid retrieval." checked={config.rerankEnabled} onChange={(checked) => update({ rerankEnabled: checked })} />
+        <ToggleRow icon={SlidersHorizontal} title="Query rewrite" description="Expand short queries before search." checked={config.queryRewrite} onChange={(checked) => update({ queryRewrite: checked })} />
+        <ToggleRow icon={FileSearch} title="Require citations" description="Treat uncited answers as low confidence." checked={config.requireCitations} onChange={(checked) => update({ requireCitations: checked })} />
+      </div>
+      </CardContent>
+    </Card>
+  );
+}
+
+function FeatureRow({
+  icon: Icon,
+  title,
+  description,
+  state,
+}: {
+  icon: LucideIcon;
+  title: string;
+  description: string;
+  state: string;
+}) {
+  return (
+    <div className="rounded-md border border-border bg-muted/20 p-3">
+      <div className="flex items-start justify-between gap-3">
+        <div className="flex min-w-0 gap-3">
+          <Icon className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
+          <div className="min-w-0">
+            <p className="text-sm font-medium">{title}</p>
+            <p className="mt-1 text-xs leading-5 text-muted-foreground">{description}</p>
+          </div>
+        </div>
+        <StateBadge state={state} compact />
+      </div>
+    </div>
+  );
+}
+
+function StateBadge({ state, compact }: { state: string; compact?: boolean }) {
+  const styles = {
+    live: "border-green-500/30 bg-green-500/10 text-green-700 dark:text-green-200",
+    mock: "border-blue-500/30 bg-blue-500/10 text-blue-700 dark:text-blue-200",
+    prototype: "border-blue-500/30 bg-blue-500/10 text-blue-700 dark:text-blue-200",
+  } as const;
+  const labels: Record<string, string> = {
+    live: "Live",
+    mock: compact ? "Mock" : "Mock / Local",
+    prototype: compact ? "Proto" : "Prototype",
+  };
+  const key = state as keyof typeof styles;
+  return <Badge className={styles[key] ?? styles.prototype}>{labels[state] ?? state}</Badge>;
+}
+
+function SearchResultCard({ result }: { result: SearchResult }) {
+  return (
+    <div className="rounded-md border border-border bg-muted/30 p-3">
+      <div className="flex items-start justify-between gap-3">
+        <div className="min-w-0">
+          <p className="truncate text-sm font-semibold">{result.document.title}</p>
+          <p className="mt-1 text-xs text-muted-foreground">Chunk {result.chunk.chunk_index} / {result.chunk.token_count} tokens</p>
+        </div>
+        <span className="rounded-md bg-primary/10 px-2 py-1 font-mono text-xs text-primary">{result.score.toFixed(3)}</span>
+      </div>
+      <p className="mt-3 text-sm leading-6 text-muted-foreground">{result.chunk.content_text}</p>
+      <JsonViewer value={result.score_json} />
+    </div>
+  );
+}
+
+function CreateKnowledgeBaseDialog({ open, onOpenChange, onCreated }: { open: boolean; onOpenChange: (open: boolean) => void; onCreated: () => void }) {
+  const tenantId = useAuthStore((state) => state.tenantId);
+  const [form, setForm] = React.useState({ name: "", description: "", metadata_json: "{}" });
+  const [error, setError] = React.useState<string>();
+  const [submitting, setSubmitting] = React.useState(false);
+
+  async function submit(event: React.FormEvent) {
+    event.preventDefault();
+    setError(undefined);
+    const metadata = parseJSONObject(form.metadata_json, "Metadata JSON");
+    if (metadata instanceof Error) {
+      setError(metadata.message);
+      return;
+    }
+    setSubmitting(true);
+    try {
+      await createKnowledgeBase({ tenant_id: tenantId, name: form.name.trim(), description: form.description.trim() || null, metadata_json: metadata });
+      toast.success("Knowledge base created");
+      onOpenChange(false);
+      setForm({ name: "", description: "", metadata_json: "{}" });
+      onCreated();
+    } finally {
+      setSubmitting(false);
+    }
+  }
+
+  return (
+    <Dialog open={open} onOpenChange={onOpenChange} title="Create Knowledge Base" className="max-w-2xl">
+      <form className="space-y-4" onSubmit={submit}>
+        {error ? <p className="rounded-md border border-red-500/25 bg-red-500/10 p-3 text-sm text-red-700 dark:text-red-200">{error}</p> : null}
+        <Field label="Name"><Input required value={form.name} onChange={(event) => setForm({ ...form, name: event.target.value })} /></Field>
+        <Field label="Description"><Textarea value={form.description} onChange={(event) => setForm({ ...form, description: event.target.value })} /></Field>
+        <Field label="Metadata JSON"><Textarea className="min-h-28 font-mono text-xs" value={form.metadata_json} onChange={(event) => setForm({ ...form, metadata_json: event.target.value })} /></Field>
+        <div className="flex justify-end gap-2">
+          <Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>Cancel</Button>
+          <Button disabled={submitting}>{submitting ? "Creating..." : "Create"}</Button>
+        </div>
+      </form>
+    </Dialog>
+  );
+}
+
+function CreateKnowledgeDocumentDialog({
+  open,
+  onOpenChange,
+  knowledgeBaseId,
+  onCreated,
+}: {
+  open: boolean;
+  onOpenChange: (open: boolean) => void;
+  knowledgeBaseId?: string;
+  onCreated: (ingest: KnowledgeDocumentIngestResponse) => void;
+}) {
+  const tenantId = useAuthStore((state) => state.tenantId);
+  const [form, setForm] = React.useState({
+    title: "",
+    source_type: "text",
+    source_uri: "",
+    content_text: "",
+    chunk_size: "800",
+    chunk_overlap: "120",
+    metadata_json: "{}",
+  });
+  const [parsePreview, setParsePreview] = React.useState<KnowledgeDocumentParseResponse>();
+  const [error, setError] = React.useState<string>();
+  const [parsing, setParsing] = React.useState(false);
+  const [submitting, setSubmitting] = React.useState(false);
+
+  async function previewParse() {
+    setError(undefined);
+    setParsing(true);
+    try {
+      const parsed = await parseKnowledgeDocument({
+        source_type: form.source_type,
+        source_uri: form.source_uri.trim() || null,
+        content_text: form.content_text,
+      });
+      setParsePreview(parsed);
+      toast.success("Parse preview ready");
+    } catch (err) {
+      setError(err instanceof Error ? err.message : "Parse failed");
+    } finally {
+      setParsing(false);
+    }
+  }
+
+  async function submit(event: React.FormEvent) {
+    event.preventDefault();
+    if (!knowledgeBaseId) return;
+    setError(undefined);
+    const metadata = parseJSONObject(form.metadata_json, "Metadata JSON");
+    if (metadata instanceof Error) {
+      setError(metadata.message);
+      return;
+    }
+    setSubmitting(true);
+    try {
+      const ingest = await createKnowledgeDocument({
+        tenant_id: tenantId,
+        knowledge_base_id: knowledgeBaseId,
+        title: form.title.trim(),
+        source_type: form.source_type,
+        source_uri: form.source_uri.trim() || null,
+        content_text: form.content_text,
+        chunk_size: Number(form.chunk_size) || null,
+        chunk_overlap: Number(form.chunk_overlap) || null,
+        metadata_json: metadata,
+      });
+      toast.success(`Document indexed with ${ingest.chunks.length} chunk${ingest.chunks.length === 1 ? "" : "s"}`);
+      onOpenChange(false);
+      setForm({ title: "", source_type: "text", source_uri: "", content_text: "", chunk_size: "800", chunk_overlap: "120", metadata_json: "{}" });
+      setParsePreview(undefined);
+      onCreated(ingest);
+    } catch (err) {
+      setError(err instanceof Error ? err.message : "Document ingest failed");
+    } finally {
+      setSubmitting(false);
+    }
+  }
+
+  return (
+    <Dialog open={open} onOpenChange={onOpenChange} title="Add Document" className="max-w-4xl">
+      <form className="space-y-4" onSubmit={submit}>
+        {error ? <p className="rounded-md border border-red-500/25 bg-red-500/10 p-3 text-sm text-red-700 dark:text-red-200">{error}</p> : null}
+        <div className="grid gap-3 lg:grid-cols-2">
+          <Field label="Title"><Input required value={form.title} onChange={(event) => setForm({ ...form, title: event.target.value })} /></Field>
+          <Field label="Source Type">
+            <Select value={form.source_type} onChange={(event) => setForm({ ...form, source_type: event.target.value })} options={sourceTypeOptions.filter((option) => option.value !== "all")} />
+          </Field>
+        </div>
+        <Field label="Source URI"><Input value={form.source_uri} onChange={(event) => setForm({ ...form, source_uri: event.target.value })} placeholder="https://docs.example.com/page" /></Field>
+        <Field label="Content"><Textarea required className="min-h-40" value={form.content_text} onChange={(event) => setForm({ ...form, content_text: event.target.value })} /></Field>
+        <div className="grid gap-3 lg:grid-cols-2">
+          <Field label="Chunk Size"><Input inputMode="numeric" value={form.chunk_size} onChange={(event) => setForm({ ...form, chunk_size: event.target.value })} /></Field>
+          <Field label="Chunk Overlap"><Input inputMode="numeric" value={form.chunk_overlap} onChange={(event) => setForm({ ...form, chunk_overlap: event.target.value })} /></Field>
+        </div>
+        <Field label="Metadata JSON"><Textarea className="min-h-24 font-mono text-xs" value={form.metadata_json} onChange={(event) => setForm({ ...form, metadata_json: event.target.value })} /></Field>
+        {parsePreview ? (
+          <div className="rounded-md border border-border bg-muted/30 p-3">
+            <p className="text-sm font-medium">Parse Preview</p>
+            <p className="mt-1 line-clamp-3 text-sm text-muted-foreground">{parsePreview.content_text}</p>
+            <JsonViewer value={parsePreview.metadata_json} />
+          </div>
+        ) : null}
+        <div className="flex flex-wrap justify-end gap-2">
+          <Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>Cancel</Button>
+          <Button type="button" variant="secondary" disabled={parsing || !form.content_text.trim()} onClick={() => void previewParse()}>
+            {parsing ? "Parsing..." : "Preview Parse"}
+          </Button>
+          <Button disabled={submitting || !knowledgeBaseId}>{submitting ? "Indexing..." : "Index Document"}</Button>
+        </div>
+      </form>
+    </Dialog>
+  );
+}
+
+function Detail({ label, value }: { label: string; value: string }) {
+  return (
+    <div className="min-w-0">
+      <p className="text-xs text-muted-foreground">{label}</p>
+      <p className="mt-1 break-words font-mono text-xs text-foreground">{value}</p>
+    </div>
+  );
+}
+
+function Field({ label, children }: { label: string; children: React.ReactNode }) {
+  return <label className="block space-y-2 text-sm"><span className="text-muted-foreground">{label}</span>{children}</label>;
+}
+
+function parseJSONObject(value: string, label: string): JSONObject | Error {
+  try {
+    const parsed = JSON.parse(value || "{}");
+    if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
+      return new Error(`${label} must be a JSON object`);
+    }
+    return parsed as JSONObject;
+  } catch {
+    return new Error(`${label} is invalid`);
+  }
+}

+ 100 - 0
web/src/pages/login/LoginPage.tsx

@@ -0,0 +1,100 @@
+import * as React from "react";
+import { Eye, EyeOff, KeyRound } from "lucide-react";
+import { useLocation, useNavigate } from "react-router-dom";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
+import { Input } from "@/components/ui/input";
+import { toast } from "@/components/ui/toaster";
+import { getHealth } from "@/api";
+import { useAuthStore } from "@/stores/auth";
+
+export function LoginPage() {
+  const navigate = useNavigate();
+  const location = useLocation();
+  const login = useAuthStore((state) => state.login);
+  const [showKey, setShowKey] = React.useState(false);
+  const [apiKey, setApiKey] = React.useState("");
+  const [tenantId, setTenantId] = React.useState("public");
+  const [userId, setUserId] = React.useState("");
+  const [loading, setLoading] = React.useState(false);
+
+  async function onSubmit(event: React.FormEvent) {
+    event.preventDefault();
+    setLoading(true);
+    try {
+      await getHealth(apiKey, tenantId, userId);
+      login({ apiKey, tenantId, userId });
+      toast.success("Connected to gateway");
+      const redirectTo = (location.state as { from?: string } | null)?.from ?? "/dashboard";
+      navigate(redirectTo, { replace: true });
+    } catch {
+      toast.error("Gateway rejected the credentials");
+    } finally {
+      setLoading(false);
+    }
+  }
+
+  return (
+    <div className="grid min-h-screen place-items-center bg-surface-deep px-4">
+      <div className="absolute inset-0 bg-[radial-gradient(circle_at_50%_0%,rgba(37,99,235,0.20),transparent_35%),radial-gradient(circle_at_85%_70%,rgba(234,88,12,0.12),transparent_28%)]" />
+      <Card className="relative w-full max-w-md">
+        <CardHeader>
+          <div className="mb-3 grid h-11 w-11 place-items-center rounded-md bg-primary/15 text-primary">
+            <KeyRound className="h-5 w-5" />
+          </div>
+          <CardTitle>Web Studio Login</CardTitle>
+          <CardDescription>Use your gateway API key and tenant context.</CardDescription>
+        </CardHeader>
+        <CardContent>
+          <form className="space-y-4" onSubmit={onSubmit}>
+            <label className="block space-y-2 text-sm">
+              <span className="text-muted-foreground">API Key</span>
+              <div className="relative">
+                <Input
+                  required
+                  type={showKey ? "text" : "password"}
+                  value={apiKey}
+                  onChange={(event) => setApiKey(event.target.value)}
+                  placeholder="ap_..."
+                />
+                <Button
+                  type="button"
+                  variant="ghost"
+                  size="icon"
+                  className="absolute right-0 top-0"
+                  onClick={() => setShowKey((value) => !value)}
+                  aria-label={showKey ? "Hide API key" : "Show API key"}
+                >
+                  {showKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
+                </Button>
+              </div>
+            </label>
+            <label className="block space-y-2 text-sm">
+              <span className="text-muted-foreground">Tenant ID</span>
+              <Input required value={tenantId} onChange={(event) => setTenantId(event.target.value)} />
+            </label>
+            <label className="block space-y-2 text-sm">
+              <span className="text-muted-foreground">User ID</span>
+              <Input required value={userId} onChange={(event) => setUserId(event.target.value)} />
+            </label>
+            <Button className="w-full" disabled={loading}>
+              {loading ? "Checking..." : "Enter Studio"}
+            </Button>
+            <Button
+              type="button"
+              variant="outline"
+              className="w-full"
+              onClick={() => {
+                setApiKey("ap_mock_demo_key");
+                setTenantId("public");
+                setUserId("demo-user");
+              }}
+            >
+              Use Demo Credentials
+            </Button>
+          </form>
+        </CardContent>
+      </Card>
+    </div>
+  );
+}

+ 12 - 0
web/src/pages/placeholders/PlaceholderPage.tsx

@@ -0,0 +1,12 @@
+import { Construction } from "lucide-react";
+import { EmptyState } from "@/components/shared/EmptyState";
+import { PageHeader } from "@/components/shared/PageHeader";
+
+export function PlaceholderPage({ title }: { title: string }) {
+  return (
+    <div className="space-y-6">
+      <PageHeader title={title} description={`${title} management surface is ready for the next implementation pass.`} />
+      <EmptyState icon={Construction} title={`${title} workspace`} description="The route, layout, auth guard, and design shell are wired." />
+    </div>
+  );
+}

+ 71 - 0
web/src/pages/sessions/SessionChatPage.tsx

@@ -0,0 +1,71 @@
+import * as React from "react";
+import { createMessage, listMessages } from "@/api";
+import { ApiErrorState } from "@/components/shared/ApiErrorState";
+import { PageHeader } from "@/components/shared/PageHeader";
+import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
+import { toast } from "@/components/ui/toaster";
+import { useApps, useSessionList } from "@/hooks";
+import { useAuthStore } from "@/stores/auth";
+import type { Message, Session } from "@/types";
+import { ChatPanel } from "./components/ChatPanel";
+import { CreateSessionDialog } from "./components/CreateSessionDialog";
+import { SessionListPanel } from "./components/SessionListPanel";
+
+export function SessionChatPage() {
+  const { tenantId } = useAuthStore();
+  const apps = useApps();
+  const sessions = useSessionList();
+  const [search, setSearch] = React.useState("");
+  const [activeSessionId, setActiveSessionId] = React.useState<string>();
+  const [messages, setMessages] = React.useState<Message[]>([]);
+  const [createOpen, setCreateOpen] = React.useState(false);
+
+  React.useEffect(() => {
+    if (!activeSessionId && sessions.data?.[0]) setActiveSessionId(sessions.data[0].id);
+  }, [activeSessionId, sessions.data]);
+  React.useEffect(() => {
+    if (activeSessionId) void listMessages(activeSessionId).then(setMessages);
+  }, [activeSessionId]);
+
+  const filtered = (sessions.data ?? []).filter((session) =>
+    `${session.title ?? ""} ${session.channel_type}`.toLowerCase().includes(search.toLowerCase()),
+  );
+
+  async function send(text: string) {
+    if (!activeSessionId) return;
+    await createMessage({ tenant_id: tenantId, session_id: activeSessionId, role: "user", content_text: text });
+    setMessages(await listMessages(activeSessionId));
+    toast.success("Message sent");
+  }
+
+  if (sessions.loading || apps.loading) return <LoadingSpinner label="Loading sessions" />;
+  if (sessions.error || apps.error) {
+    return <ApiErrorState message={sessions.error?.message ?? apps.error?.message} onRetry={() => void Promise.all([sessions.refetch(), apps.refetch()])} />;
+  }
+
+  return (
+    <div className="space-y-6">
+      <PageHeader title="Sessions" description="Inspect channel sessions and chat messages." />
+      <div className="flex overflow-hidden rounded-md border border-border">
+        <SessionListPanel
+          sessions={filtered}
+          activeSessionId={activeSessionId}
+          search={search}
+          onSearch={setSearch}
+          onSelect={setActiveSessionId}
+          onCreate={() => setCreateOpen(true)}
+        />
+        <ChatPanel messages={messages} active={Boolean(activeSessionId)} onSend={(text) => void send(text)} />
+      </div>
+      <CreateSessionDialog
+        open={createOpen}
+        onOpenChange={setCreateOpen}
+        apps={apps.data ?? []}
+        onCreated={(session: Session) => {
+          setActiveSessionId(session.id);
+          void sessions.refetch();
+        }}
+      />
+    </div>
+  );
+}

+ 30 - 0
web/src/pages/sessions/components/ChatInput.tsx

@@ -0,0 +1,30 @@
+import * as React from "react";
+import { Send } from "lucide-react";
+import { Button } from "@/components/ui/button";
+
+export function ChatInput({ disabled, onSend }: { disabled?: boolean; onSend: (text: string) => void }) {
+  const [text, setText] = React.useState("");
+  return (
+    <form
+      className="flex items-end gap-2 border-t border-border p-4"
+      onSubmit={(event) => {
+        event.preventDefault();
+        if (!text.trim()) return;
+        onSend(text.trim());
+        setText("");
+      }}
+    >
+      <textarea
+        aria-label="Message"
+        className="max-h-40 min-h-11 flex-1 resize-none rounded-md border border-border bg-muted/40 px-3 py-2 text-base outline-none focus:border-primary/70 sm:text-sm"
+        value={text}
+        disabled={disabled}
+        onChange={(event) => setText(event.target.value)}
+        placeholder={disabled ? "Select a session" : "Message"}
+      />
+      <Button size="icon" disabled={disabled || !text.trim()} aria-label="Send message">
+        <Send className="h-4 w-4" />
+      </Button>
+    </form>
+  );
+}

+ 32 - 0
web/src/pages/sessions/components/ChatPanel.tsx

@@ -0,0 +1,32 @@
+import { MessageCircle } from "lucide-react";
+import { EmptyState } from "@/components/shared/EmptyState";
+import { MessageBubble } from "./MessageBubble";
+import { ChatInput } from "./ChatInput";
+import type { Message } from "@/types";
+
+export function ChatPanel({
+  messages,
+  active,
+  onSend,
+}: {
+  messages: Message[];
+  active: boolean;
+  onSend: (text: string) => void;
+}) {
+  return (
+    <section className="flex min-h-[680px] flex-1 flex-col bg-surface-base">
+      <div className="flex-1 space-y-4 overflow-auto p-4">
+        {active ? (
+          messages.length ? (
+            messages.map((message) => <MessageBubble key={message.id} message={message} />)
+          ) : (
+            <EmptyState icon={MessageCircle} title="No messages" description="Send the first message for this session." />
+          )
+        ) : (
+          <EmptyState icon={MessageCircle} title="No session selected" description="Choose or create a session to start chatting." />
+        )}
+      </div>
+      <ChatInput disabled={!active} onSend={onSend} />
+    </section>
+  );
+}

+ 69 - 0
web/src/pages/sessions/components/CreateSessionDialog.tsx

@@ -0,0 +1,69 @@
+import * as React from "react";
+import { createSession } from "@/api";
+import { Button } from "@/components/ui/button";
+import { Dialog } from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { Select } from "@/components/ui/select";
+import { toast } from "@/components/ui/toaster";
+import { useAuthStore } from "@/stores/auth";
+import type { AppResponse, Session } from "@/types";
+
+export function CreateSessionDialog({
+  open,
+  onOpenChange,
+  apps,
+  onCreated,
+}: {
+  open: boolean;
+  onOpenChange: (open: boolean) => void;
+  apps: AppResponse[];
+  onCreated: (session: Session) => void;
+}) {
+  const { tenantId, userId } = useAuthStore();
+  const [appId, setAppId] = React.useState("");
+  const [title, setTitle] = React.useState("");
+  const [channelType, setChannelType] = React.useState("web");
+  const [submitting, setSubmitting] = React.useState(false);
+
+  React.useEffect(() => {
+    if (!appId && apps[0]) setAppId(apps[0].id);
+  }, [appId, apps]);
+
+  async function submit(event: React.FormEvent) {
+    event.preventDefault();
+    setSubmitting(true);
+    try {
+      const session = await createSession({ tenant_id: tenantId, user_id: userId, app_id: appId, title, channel_type: channelType });
+      toast.success("Session created");
+      onCreated(session);
+      onOpenChange(false);
+    } finally {
+      setSubmitting(false);
+    }
+  }
+
+  return (
+    <Dialog open={open} onOpenChange={onOpenChange} title="Create Session">
+      <form className="space-y-4" onSubmit={submit}>
+        <label className="block space-y-2 text-sm">
+          <span className="text-muted-foreground">App</span>
+          <Select value={appId} onChange={(event) => setAppId(event.target.value)} options={apps.map((app) => ({ value: app.id, label: app.name }))} />
+        </label>
+        <label className="block space-y-2 text-sm">
+          <span className="text-muted-foreground">Title</span>
+          <Input value={title} onChange={(event) => setTitle(event.target.value)} />
+        </label>
+        <label className="block space-y-2 text-sm">
+          <span className="text-muted-foreground">Channel Type</span>
+          <Input value={channelType} onChange={(event) => setChannelType(event.target.value)} />
+        </label>
+        <div className="flex justify-end gap-2">
+          <Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
+            Cancel
+          </Button>
+          <Button disabled={!appId || submitting}>{submitting ? "Creating..." : "Create"}</Button>
+        </div>
+      </form>
+    </Dialog>
+  );
+}

+ 21 - 0
web/src/pages/sessions/components/MessageBubble.tsx

@@ -0,0 +1,21 @@
+import { cn } from "@/lib/utils";
+import type { Message } from "@/types";
+
+export function MessageBubble({ message }: { message: Message }) {
+  const isUser = message.role === "user";
+  const isSystem = message.role === "system";
+  return (
+    <div className={cn("flex", isUser ? "justify-end" : isSystem ? "justify-center" : "justify-start")}>
+      <div
+        className={cn(
+          "max-w-[78%] rounded-md border px-4 py-3 text-sm",
+          isUser && "border-primary/30 bg-primary text-primary-foreground",
+          !isUser && !isSystem && "border-border bg-surface-elevated",
+          isSystem && "border-border bg-muted/40 text-muted-foreground",
+        )}
+      >
+        <p className="whitespace-pre-wrap">{message.content_text ?? JSON.stringify(message.content_json ?? {})}</p>
+      </div>
+    </div>
+  );
+}

+ 56 - 0
web/src/pages/sessions/components/SessionListPanel.tsx

@@ -0,0 +1,56 @@
+import { MessageSquare, Plus } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { SearchInput } from "@/components/shared/SearchInput";
+import { RelativeTime } from "@/components/shared/RelativeTime";
+import { cn } from "@/lib/utils";
+import type { Session } from "@/types";
+
+export function SessionListPanel({
+  sessions,
+  activeSessionId,
+  search,
+  onSearch,
+  onSelect,
+  onCreate,
+}: {
+  sessions: Session[];
+  activeSessionId?: string;
+  search: string;
+  onSearch: (value: string) => void;
+  onSelect: (sessionId: string) => void;
+  onCreate: () => void;
+}) {
+  return (
+    <aside className="w-full border-b border-border bg-surface-elevated p-4 md:w-[300px] md:border-b-0 md:border-r">
+      <div className="flex items-center justify-between gap-3">
+        <h2 className="text-sm font-semibold">Sessions</h2>
+        <Button variant="outline" size="icon" onClick={onCreate}>
+          <Plus className="h-4 w-4" />
+        </Button>
+      </div>
+      <div className="mt-4">
+        <SearchInput value={search} onChange={onSearch} placeholder="Search sessions" />
+      </div>
+      <div className="mt-4 space-y-2">
+        {sessions.map((session) => (
+          <button
+            key={session.id}
+            onClick={() => onSelect(session.id)}
+            className={cn(
+              "flex w-full items-start gap-3 rounded-md border border-border p-3 text-left hover:bg-muted",
+              activeSessionId === session.id && "border-primary/40 bg-primary/10",
+            )}
+          >
+            <MessageSquare className="mt-0.5 h-4 w-4 shrink-0 text-primary" />
+            <span className="min-w-0">
+              <span className="block truncate text-sm font-medium">{session.title ?? "Untitled session"}</span>
+              <span className="block text-xs text-muted-foreground">
+                <RelativeTime value={session.last_active_time ?? session.created_time} />
+              </span>
+            </span>
+          </button>
+        ))}
+      </div>
+    </aside>
+  );
+}

+ 182 - 0
web/src/pages/settings/SettingsPage.tsx

@@ -0,0 +1,182 @@
+import * as React from "react";
+import { KeyRound, ShieldCheck, UserRound } from "lucide-react";
+import { createApiKey, listApiKeys, revokeApiKey } from "@/api";
+import { ApiErrorState } from "@/components/shared/ApiErrorState";
+import { EmptyState } from "@/components/shared/EmptyState";
+import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
+import { MetricCard } from "@/components/shared/MetricCard";
+import { PageHeader } from "@/components/shared/PageHeader";
+import { StatusBadge } from "@/components/shared/StatusBadge";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Dialog } from "@/components/ui/dialog";
+import { Input, Textarea } from "@/components/ui/input";
+import { toast } from "@/components/ui/toaster";
+import { formatDateTime, truncateMiddle } from "@/lib/utils";
+import { useAuthStore } from "@/stores/auth";
+import type { ApiKeyCreateResponse, ApiKeyResponse } from "@/types";
+
+export function SettingsPage() {
+  const { tenantId, userId } = useAuthStore();
+  const [apiKeys, setApiKeys] = React.useState<ApiKeyResponse[]>([]);
+  const [newKey, setNewKey] = React.useState<ApiKeyCreateResponse>();
+  const [loading, setLoading] = React.useState(true);
+  const [error, setError] = React.useState<string>();
+  const [createOpen, setCreateOpen] = React.useState(false);
+
+  const load = React.useCallback(async () => {
+    setLoading(true);
+    setError(undefined);
+    try {
+      setApiKeys(await listApiKeys());
+    } catch (err) {
+      setError(err instanceof Error ? err.message : "Failed to load API keys");
+    } finally {
+      setLoading(false);
+    }
+  }, []);
+
+  React.useEffect(() => {
+    void load();
+  }, [load]);
+
+  async function revoke(id: string) {
+    await revokeApiKey(id);
+    toast.success("API key revoked");
+    void load();
+  }
+
+  if (loading) return <LoadingSpinner label="Loading settings" />;
+  if (error) return <ApiErrorState message={error} onRetry={() => void load()} />;
+
+  return (
+    <div className="space-y-6">
+      <PageHeader
+        title="Settings"
+        description="Manage tenant context, identity, and gateway API keys."
+        actions={<Button onClick={() => setCreateOpen(true)}><KeyRound className="h-4 w-4" /> New API Key</Button>}
+      />
+      <div className="grid gap-4 md:grid-cols-3">
+        <MetricCard label="Tenant" value={tenantId} icon={ShieldCheck} />
+        <MetricCard label="User" value={userId || "Unset"} icon={UserRound} />
+        <MetricCard label="API Keys" value={apiKeys.length} icon={KeyRound} />
+      </div>
+      <Card>
+        <CardHeader>
+          <CardTitle>Gateway API Keys</CardTitle>
+        </CardHeader>
+        <CardContent>
+          {apiKeys.length ? (
+            <div className="overflow-x-auto">
+              <table className="w-full text-left text-sm">
+                <thead className="text-xs text-muted-foreground">
+                  <tr className="border-b border-border">
+                    <th className="py-2">Name</th>
+                    <th>Prefix</th>
+                    <th>Status</th>
+                    <th>Last Used</th>
+                    <th>Created</th>
+                    <th className="text-right">Action</th>
+                  </tr>
+                </thead>
+                <tbody>
+                  {apiKeys.map((key) => (
+                    <tr key={key.id} className="border-b border-border">
+                      <td className="py-3 font-medium">{key.name}</td>
+                      <td className="font-mono text-xs">{key.key_prefix}</td>
+                      <td><StatusBadge status={key.status} /></td>
+                      <td className="text-muted-foreground">{formatDateTime(key.last_used_time)}</td>
+                      <td className="text-muted-foreground">{formatDateTime(key.created_time)}</td>
+                      <td className="text-right">
+                        <Button size="sm" variant="outline" disabled={key.status === "revoked"} onClick={() => void revoke(key.id)}>
+                          Revoke
+                        </Button>
+                      </td>
+                    </tr>
+                  ))}
+                </tbody>
+              </table>
+            </div>
+          ) : (
+            <EmptyState icon={KeyRound} title="No API keys" description="Create a gateway API key for local development or operator access." />
+          )}
+        </CardContent>
+      </Card>
+      <CreateApiKeyDialog
+        open={createOpen}
+        onOpenChange={(open) => {
+          setCreateOpen(open);
+          if (!open) setNewKey(undefined);
+        }}
+        createdKey={newKey}
+        onCreated={(key) => {
+          setNewKey(key);
+          void load();
+        }}
+      />
+    </div>
+  );
+}
+
+function CreateApiKeyDialog({
+  open,
+  onOpenChange,
+  createdKey,
+  onCreated,
+}: {
+  open: boolean;
+  onOpenChange: (open: boolean) => void;
+  createdKey?: ApiKeyCreateResponse;
+  onCreated: (key: ApiKeyCreateResponse) => void;
+}) {
+  const tenantId = useAuthStore((state) => state.tenantId);
+  const [name, setName] = React.useState("");
+  const [scopes, setScopes] = React.useState("");
+  const [submitting, setSubmitting] = React.useState(false);
+
+  async function submit(event: React.FormEvent) {
+    event.preventDefault();
+    setSubmitting(true);
+    try {
+      const key = await createApiKey({ tenant_id: tenantId, name, scopes: scopes || null });
+      toast.success("API key created");
+      setName("");
+      setScopes("");
+      onCreated(key);
+    } finally {
+      setSubmitting(false);
+    }
+  }
+
+  return (
+    <Dialog open={open} onOpenChange={onOpenChange} title="Create API Key">
+      {createdKey ? (
+        <div className="space-y-4">
+          <div className="rounded-md border border-amber-500/20 bg-amber-500/10 p-3 text-sm text-amber-100">
+            Copy this key now. The full secret is only shown once.
+          </div>
+          <div className="rounded-md border border-border bg-muted/60 p-3 font-mono text-xs break-all">{createdKey.api_key}</div>
+          <p className="text-sm text-muted-foreground">Prefix: {truncateMiddle(createdKey.key_prefix, 20)}</p>
+          <div className="flex justify-end">
+            <Button onClick={() => onOpenChange(false)}>Done</Button>
+          </div>
+        </div>
+      ) : (
+        <form className="space-y-4" onSubmit={submit}>
+          <label className="block space-y-2 text-sm">
+            <span className="text-muted-foreground">Name</span>
+            <Input required value={name} onChange={(event) => setName(event.target.value)} />
+          </label>
+          <label className="block space-y-2 text-sm">
+            <span className="text-muted-foreground">Scopes</span>
+            <Textarea value={scopes} onChange={(event) => setScopes(event.target.value)} placeholder="Optional comma-separated scopes" />
+          </label>
+          <div className="flex justify-end gap-2">
+            <Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>Cancel</Button>
+            <Button disabled={submitting}>{submitting ? "Creating..." : "Create"}</Button>
+          </div>
+        </form>
+      )}
+    </Dialog>
+  );
+}

+ 965 - 0
web/src/pages/teams/TeamsPage.tsx

@@ -0,0 +1,965 @@
+import * as React from "react";
+import {
+  Activity,
+  Archive,
+  Bot,
+  CheckCircle2,
+  Clock,
+  Copy,
+  FileCode2,
+  Grid2X2,
+  Layers3,
+  List,
+  Network,
+  Play,
+  RefreshCw,
+  Search,
+  Settings2,
+  SlidersHorizontal,
+  Users,
+} from "lucide-react";
+import { createTeam, createTeamRun, createTeamVersion, listTeamRuns, listTeamVersions, listTeams } from "@/api";
+import { ApiErrorState } from "@/components/shared/ApiErrorState";
+import { EmptyState } from "@/components/shared/EmptyState";
+import { EntityListItem } from "@/components/shared/EntityListItem";
+import { JsonViewer } from "@/components/shared/JsonViewer";
+import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
+import { MetricCard } from "@/components/shared/MetricCard";
+import { PageHeader } from "@/components/shared/PageHeader";
+import { SearchInput } from "@/components/shared/SearchInput";
+import { StatusBadge } from "@/components/shared/StatusBadge";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
+import { Dialog } from "@/components/ui/dialog";
+import { Input, Textarea } from "@/components/ui/input";
+import { Select } from "@/components/ui/select";
+import { Tabs } from "@/components/ui/tabs";
+import { toast } from "@/components/ui/toaster";
+import { copyToClipboard, formatDateTime, relativeTime, truncateMiddle } from "@/lib/utils";
+import { useAuthStore } from "@/stores/auth";
+import type { JSONObject, TeamDefinition, TeamRun, TeamRunStatus, TeamStatus, TeamVersion } from "@/types";
+
+type StatusFilter = "all" | TeamStatus;
+type RunStatusFilter = "all" | TeamRunStatus;
+type SortMode = "recent" | "name" | "status";
+type ViewMode = "list" | "grid";
+
+const TEAM_TYPE_OPTIONS = [
+  { value: "collaborative", label: "Collaborative" },
+  { value: "supervisor", label: "Supervisor" },
+  { value: "pipeline", label: "Pipeline" },
+  { value: "debate", label: "Debate" },
+];
+
+const COORDINATION_MODE_OPTIONS = [
+  { value: "supervisor", label: "Supervisor" },
+  { value: "pipeline", label: "Pipeline" },
+  { value: "parallel", label: "Parallel" },
+  { value: "debate", label: "Debate" },
+];
+
+const RUN_TEMPLATE_OPTIONS = [
+  "Break this request into specialist tasks and produce an execution plan.",
+  "Review the latest customer issue, identify risks, and recommend next actions.",
+  "Compare two implementation options and return a decision with tradeoffs.",
+];
+
+export function TeamsPage() {
+  const [teams, setTeams] = React.useState<TeamDefinition[]>([]);
+  const [versions, setVersions] = React.useState<TeamVersion[]>([]);
+  const [runs, setRuns] = React.useState<TeamRun[]>([]);
+  const [selectedTeamId, setSelectedTeamId] = React.useState<string>();
+  const [search, setSearch] = React.useState("");
+  const [statusFilter, setStatusFilter] = React.useState<StatusFilter>("all");
+  const [typeFilter, setTypeFilter] = React.useState("all");
+  const [sortMode, setSortMode] = React.useState<SortMode>("recent");
+  const [viewMode, setViewMode] = React.useState<ViewMode>("list");
+  const [activeTab, setActiveTab] = React.useState("overview");
+  const [runStatusFilter, setRunStatusFilter] = React.useState<RunStatusFilter>("all");
+  const [loading, setLoading] = React.useState(true);
+  const [relatedLoading, setRelatedLoading] = React.useState(false);
+  const [error, setError] = React.useState<string>();
+  const [createOpen, setCreateOpen] = React.useState(false);
+  const [versionOpen, setVersionOpen] = React.useState(false);
+  const [runOpen, setRunOpen] = React.useState(false);
+
+  const selectedTeam = teams.find((team) => team.id === selectedTeamId);
+  const teamTypes = React.useMemo(() => Array.from(new Set(teams.map((team) => team.team_type))).sort(), [teams]);
+  const sortedVersions = React.useMemo(
+    () => [...versions].sort((first, second) => second.version_no - first.version_no),
+    [versions],
+  );
+  const sortedRuns = React.useMemo(
+    () => [...runs].sort((first, second) => new Date(second.created_time).getTime() - new Date(first.created_time).getTime()),
+    [runs],
+  );
+  const latestVersion = sortedVersions[0];
+  const filteredRuns = sortedRuns.filter((run) => runStatusFilter === "all" || run.status === runStatusFilter);
+  const failedRuns = sortedRuns.filter((run) => run.status === "failed").length;
+  const activeTeams = teams.filter((team) => team.status === "active").length;
+  const draftTeams = teams.filter((team) => team.status === "draft").length;
+
+  const filtered = teams
+    .filter((team) => {
+      const text = `${team.name} ${team.code} ${team.team_type} ${team.description ?? ""}`.toLowerCase();
+      const matchesSearch = text.includes(search.toLowerCase());
+      const matchesStatus = statusFilter === "all" || team.status === statusFilter;
+      const matchesType = typeFilter === "all" || team.team_type === typeFilter;
+      return matchesSearch && matchesStatus && matchesType;
+    })
+    .sort((first, second) => {
+      if (sortMode === "name") return first.name.localeCompare(second.name);
+      if (sortMode === "status") return first.status.localeCompare(second.status) || first.name.localeCompare(second.name);
+      return new Date(second.created_time).getTime() - new Date(first.created_time).getTime();
+    });
+
+  const hasFilters = search.length > 0 || statusFilter !== "all" || typeFilter !== "all" || sortMode !== "recent";
+
+  const load = React.useCallback(async () => {
+    setLoading(true);
+    setError(undefined);
+    try {
+      const data = await listTeams();
+      setTeams(data);
+      setSelectedTeamId((current) => current ?? data[0]?.id);
+    } catch (err) {
+      setError(err instanceof Error ? err.message : "Failed to load teams");
+    } finally {
+      setLoading(false);
+    }
+  }, []);
+
+  const reloadTeamDetails = React.useCallback(async () => {
+    if (!selectedTeamId) return;
+    setRelatedLoading(true);
+    try {
+      const [versionData, runData] = await Promise.all([listTeamVersions(selectedTeamId), listTeamRuns(selectedTeamId)]);
+      setVersions(versionData);
+      setRuns(runData);
+    } finally {
+      setRelatedLoading(false);
+    }
+  }, [selectedTeamId]);
+
+  React.useEffect(() => {
+    void load();
+  }, [load]);
+
+  React.useEffect(() => {
+    if (!selectedTeamId) {
+      setVersions([]);
+      setRuns([]);
+      return;
+    }
+    void reloadTeamDetails();
+  }, [reloadTeamDetails, selectedTeamId]);
+
+  React.useEffect(() => {
+    if (!selectedTeamId && teams[0]) {
+      setSelectedTeamId(teams[0].id);
+    }
+  }, [selectedTeamId, teams]);
+
+  async function copyTeamCode() {
+    if (!selectedTeam) return;
+    await copyToClipboard(selectedTeam.code);
+    toast.success("Team code copied");
+  }
+
+  function clearFilters() {
+    setSearch("");
+    setStatusFilter("all");
+    setTypeFilter("all");
+    setSortMode("recent");
+  }
+
+  if (loading) return <LoadingSpinner label="Loading teams" />;
+  if (error) return <ApiErrorState message={error} onRetry={() => void load()} />;
+
+  return (
+    <div className="space-y-6">
+      <PageHeader
+        title="Teams"
+        description="Coordinate multi-agent teams, versions, policies, and collaborative runs from one operating console."
+        actions={
+          <>
+            <Button
+              variant="outline"
+              onClick={() => {
+                void load();
+                void reloadTeamDetails();
+              }}
+            >
+              <RefreshCw className="h-4 w-4" /> Refresh
+            </Button>
+            <Button onClick={() => setCreateOpen(true)}>
+              <Users className="h-4 w-4" /> New Team
+            </Button>
+          </>
+        }
+      />
+
+      <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-5">
+        <MetricCard label="Teams" value={teams.length} icon={Users} />
+        <MetricCard label="Active" value={activeTeams} icon={CheckCircle2} />
+        <MetricCard label="Draft" value={draftTeams} icon={Archive} />
+        <MetricCard label="Versions" value={versions.length} icon={Network} />
+        <MetricCard label="Failed Runs" value={failedRuns} icon={Activity} />
+      </div>
+
+      <div className="grid gap-6 xl:grid-cols-[440px_1fr]">
+        <Card>
+          <CardHeader>
+            <div className="flex items-start justify-between gap-3">
+              <div>
+                <CardTitle>Team Directory</CardTitle>
+                <CardDescription>
+                  {filtered.length} of {teams.length} teams shown
+                </CardDescription>
+              </div>
+              <SlidersHorizontal className="mt-1 h-4 w-4 text-muted-foreground" />
+            </div>
+          </CardHeader>
+          <CardContent className="space-y-4">
+            <SearchInput value={search} onChange={setSearch} placeholder="Search by name, code, type, or description" />
+            <div className="grid gap-3 sm:grid-cols-2">
+              <Select
+                aria-label="Filter teams by status"
+                value={statusFilter}
+                onChange={(event) => setStatusFilter(event.target.value as StatusFilter)}
+                options={[
+                  { value: "all", label: "All statuses" },
+                  { value: "active", label: "Active" },
+                  { value: "draft", label: "Draft" },
+                  { value: "archived", label: "Archived" },
+                ]}
+              />
+              <Select
+                aria-label="Filter teams by type"
+                value={typeFilter}
+                onChange={(event) => setTypeFilter(event.target.value)}
+                options={[{ value: "all", label: "All types" }, ...teamTypes.map((type) => ({ value: type, label: readableLabel(type) }))]}
+              />
+              <Select
+                aria-label="Sort teams"
+                value={sortMode}
+                onChange={(event) => setSortMode(event.target.value as SortMode)}
+                options={[
+                  { value: "recent", label: "Newest first" },
+                  { value: "name", label: "Name" },
+                  { value: "status", label: "Status" },
+                ]}
+              />
+              <div className="grid grid-cols-2 gap-2">
+                <Button type="button" variant={viewMode === "list" ? "secondary" : "outline"} aria-label="List view" onClick={() => setViewMode("list")}>
+                  <List className="h-4 w-4" /> List
+                </Button>
+                <Button type="button" variant={viewMode === "grid" ? "secondary" : "outline"} aria-label="Grid view" onClick={() => setViewMode("grid")}>
+                  <Grid2X2 className="h-4 w-4" /> Grid
+                </Button>
+              </div>
+            </div>
+            {hasFilters ? (
+              <Button type="button" variant="ghost" size="sm" onClick={clearFilters}>
+                Clear filters
+              </Button>
+            ) : null}
+
+            {filtered.length ? (
+              viewMode === "list" ? (
+                <div className="space-y-2">
+                  {filtered.map((team) => (
+                    <EntityListItem
+                      key={team.id}
+                      active={team.id === selectedTeamId}
+                      title={team.name}
+                      subtitle={`${team.code} - ${readableLabel(team.team_type)}`}
+                      meta={<StatusBadge status={team.status} />}
+                      onClick={() => {
+                        setSelectedTeamId(team.id);
+                        setActiveTab("overview");
+                      }}
+                    />
+                  ))}
+                </div>
+              ) : (
+                <div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-1">
+                  {filtered.map((team) => (
+                    <TeamCard
+                      key={team.id}
+                      team={team}
+                      active={team.id === selectedTeamId}
+                      onOpen={() => {
+                        setSelectedTeamId(team.id);
+                        setActiveTab("overview");
+                      }}
+                    />
+                  ))}
+                </div>
+              )
+            ) : (
+              <EmptyState
+                icon={Search}
+                title="No matching teams"
+                description="Adjust search or filters to find a matching team definition."
+                actionLabel={hasFilters ? "Clear filters" : undefined}
+                onAction={hasFilters ? clearFilters : undefined}
+              />
+            )}
+          </CardContent>
+        </Card>
+
+        <Card>
+          <CardHeader>
+            <div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
+              <div className="min-w-0">
+                <div className="flex flex-wrap items-center gap-2">
+                  <CardTitle className="truncate text-lg">{selectedTeam?.name ?? "Team Cockpit"}</CardTitle>
+                  {selectedTeam ? <StatusBadge status={selectedTeam.status} /> : null}
+                </div>
+                <CardDescription className="mt-1">
+                  {selectedTeam ? `${readableLabel(selectedTeam.team_type)} - ${selectedTeam.code}` : "Select a team to inspect collaboration, versions, and runs."}
+                </CardDescription>
+              </div>
+              <div className="flex flex-wrap items-center gap-2">
+                <Button variant="outline" disabled={!selectedTeam} onClick={() => void copyTeamCode()}>
+                  <Copy className="h-4 w-4" /> Copy Code
+                </Button>
+                <Button variant="secondary" disabled={!selectedTeam} onClick={() => setVersionOpen(true)}>
+                  <FileCode2 className="h-4 w-4" /> New Version
+                </Button>
+                <Button disabled={!selectedTeam || !latestVersion} onClick={() => setRunOpen(true)}>
+                  <Play className="h-4 w-4" /> Run
+                </Button>
+              </div>
+            </div>
+          </CardHeader>
+          <CardContent>
+            {selectedTeam ? (
+              <Tabs
+                value={activeTab}
+                onChange={setActiveTab}
+                tabs={[
+                  {
+                    value: "overview",
+                    label: "Overview",
+                    content: (
+                      <TeamOverview
+                        team={selectedTeam}
+                        latestVersion={latestVersion}
+                        versionCount={versions.length}
+                        runCount={sortedRuns.length}
+                        failedRunCount={failedRuns}
+                        latestRun={sortedRuns[0]}
+                      />
+                    ),
+                  },
+                  {
+                    value: "members",
+                    label: "Members",
+                    content: <TeamMembers version={latestVersion} loading={relatedLoading} onCreateVersion={() => setVersionOpen(true)} />,
+                  },
+                  {
+                    value: "versions",
+                    label: "Versions",
+                    content: <TeamVersions versions={sortedVersions} loading={relatedLoading} onCreateVersion={() => setVersionOpen(true)} />,
+                  },
+                  {
+                    value: "runs",
+                    label: "Runs",
+                    content: (
+                      <TeamRuns
+                        runs={filteredRuns}
+                        loading={relatedLoading}
+                        statusFilter={runStatusFilter}
+                        onStatusFilterChange={setRunStatusFilter}
+                        onStartRun={() => setRunOpen(true)}
+                      />
+                    ),
+                  },
+                  {
+                    value: "console",
+                    label: "Run Console",
+                    content: (
+                      <TeamRunConsole
+                        team={selectedTeam}
+                        versions={sortedVersions}
+                        runs={sortedRuns}
+                        loading={relatedLoading}
+                        onCreated={() => void reloadTeamDetails()}
+                      />
+                    ),
+                  },
+                  {
+                    value: "policy",
+                    label: "Policy",
+                    content: <TeamPolicy version={latestVersion} loading={relatedLoading} onCreateVersion={() => setVersionOpen(true)} />,
+                  },
+                ]}
+              />
+            ) : (
+              <EmptyState icon={Bot} title="No teams" description="Create a team to start coordinating multiple specialized agents." actionLabel="New Team" onAction={() => setCreateOpen(true)} />
+            )}
+          </CardContent>
+        </Card>
+      </div>
+
+      <CreateTeamDialog open={createOpen} onOpenChange={setCreateOpen} onCreated={() => void load()} />
+      <CreateTeamVersionDialog
+        open={versionOpen}
+        onOpenChange={setVersionOpen}
+        teamId={selectedTeamId}
+        onCreated={() => void reloadTeamDetails()}
+      />
+      <CreateTeamRunDialog
+        open={runOpen}
+        onOpenChange={setRunOpen}
+        teamId={selectedTeamId}
+        versions={sortedVersions}
+        onCreated={() => void reloadTeamDetails()}
+      />
+    </div>
+  );
+}
+
+function TeamCard({ team, active, onOpen }: { team: TeamDefinition; active: boolean; onOpen: () => void }) {
+  return (
+    <button
+      type="button"
+      onClick={onOpen}
+      className={`min-h-36 rounded-md border p-4 text-left transition hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-primary ${
+        active ? "border-primary/50 bg-primary/10" : "border-border bg-muted/30"
+      }`}
+    >
+      <div className="flex items-start justify-between gap-3">
+        <div className="min-w-0">
+          <p className="truncate text-sm font-semibold">{team.name}</p>
+          <p className="mt-1 truncate font-mono text-xs text-muted-foreground">{team.code}</p>
+        </div>
+        <StatusBadge status={team.status} />
+      </div>
+      <p className="mt-3 line-clamp-2 text-sm leading-6 text-muted-foreground">{team.description ?? "No description provided."}</p>
+      <div className="mt-4 flex flex-wrap items-center gap-2">
+        <Badge className="border-border bg-muted/60 text-muted-foreground">{readableLabel(team.team_type)}</Badge>
+        <Badge className="border-border bg-muted/60 text-muted-foreground">{relativeTime(team.created_time)}</Badge>
+      </div>
+    </button>
+  );
+}
+
+function TeamOverview({
+  team,
+  latestVersion,
+  versionCount,
+  runCount,
+  failedRunCount,
+  latestRun,
+}: {
+  team: TeamDefinition;
+  latestVersion?: TeamVersion;
+  versionCount: number;
+  runCount: number;
+  failedRunCount: number;
+  latestRun?: TeamRun;
+}) {
+  return (
+    <div className="space-y-5">
+      <div className="grid gap-4 md:grid-cols-4">
+        <SummaryTile label="Versions" value={versionCount} />
+        <SummaryTile label="Runs" value={runCount} />
+        <SummaryTile label="Failures" value={failedRunCount} />
+        <SummaryTile label="Latest" value={latestVersion ? `v${latestVersion.version_no}` : "None"} />
+      </div>
+      <div className="grid gap-4 text-sm md:grid-cols-2">
+        <Detail label="Code" value={team.code} mono />
+        <Detail label="Type" value={readableLabel(team.team_type)} />
+        <Detail label="Owner" value={team.owner_user_id ?? "Unassigned"} mono />
+        <Detail label="Created" value={formatDateTime(team.created_time)} />
+        <Detail label="Coordination" value={latestVersion ? readableLabel(latestVersion.coordination_mode) : "No version"} />
+        <Detail label="Last Run" value={latestRun ? relativeTime(latestRun.created_time) : "No runs"} />
+        <div className="md:col-span-2">
+          <p className="text-muted-foreground">Description</p>
+          <p className="mt-1 leading-6">{team.description ?? "No description provided."}</p>
+        </div>
+        <div className="md:col-span-2">
+          <p className="text-muted-foreground">Current Objective</p>
+          <p className="mt-1 leading-6">{latestVersion?.objective ?? "Create a version to define how this team collaborates."}</p>
+        </div>
+      </div>
+    </div>
+  );
+}
+
+function TeamMembers({
+  version,
+  loading,
+  onCreateVersion,
+}: {
+  version?: TeamVersion;
+  loading: boolean;
+  onCreateVersion: () => void;
+}) {
+  if (loading) return <LoadingSpinner label="Loading members" />;
+  if (!version) {
+    return <EmptyState icon={Users} title="No version selected" description="Create a team version before defining member roles and collaboration shape." actionLabel="New Version" onAction={onCreateVersion} />;
+  }
+  if (!version.member_refs_json.length) {
+    return (
+      <div className="space-y-4">
+        <EmptyState icon={Users} title="No members configured" description="This version has no member references yet. The policy and members can still be inspected from the raw version payload." />
+        <JsonViewer value={version.member_refs_json} collapsed={false} />
+      </div>
+    );
+  }
+  return (
+    <div className="space-y-4">
+      <div className="grid gap-3 md:grid-cols-2">
+        {version.member_refs_json.map((member, index) => (
+          <div key={index} className="rounded-md border border-border bg-muted/30 p-4">
+            <div className="flex items-start justify-between gap-3">
+              <div className="min-w-0">
+                <p className="truncate text-sm font-semibold">{getJsonString(member, "name") ?? getJsonString(member, "agent_name") ?? `Member ${index + 1}`}</p>
+                <p className="mt-1 truncate font-mono text-xs text-muted-foreground">{getJsonString(member, "agent_id") ?? getJsonString(member, "id") ?? "unbound-agent"}</p>
+              </div>
+              <Badge className="border-border bg-muted/60 text-muted-foreground">{getJsonString(member, "role") ?? "member"}</Badge>
+            </div>
+            <p className="mt-3 text-sm leading-6 text-muted-foreground">{getJsonString(member, "description") ?? getJsonString(member, "responsibility") ?? "No responsibility summary provided."}</p>
+          </div>
+        ))}
+      </div>
+      <JsonViewer value={version.member_refs_json} />
+    </div>
+  );
+}
+
+function TeamVersions({
+  versions,
+  loading,
+  onCreateVersion,
+}: {
+  versions: TeamVersion[];
+  loading: boolean;
+  onCreateVersion: () => void;
+}) {
+  if (loading) return <LoadingSpinner label="Loading versions" />;
+  if (!versions.length) {
+    return <EmptyState icon={FileCode2} title="No versions" description="Create a version to define objective, coordination mode, members, and execution policy." actionLabel="New Version" onAction={onCreateVersion} />;
+  }
+  return (
+    <div className="space-y-3">
+      {versions.map((version) => (
+        <div key={version.id} className="rounded-md border border-border bg-muted/30 p-4">
+          <div className="flex flex-wrap items-start justify-between gap-3">
+            <div>
+              <div className="flex flex-wrap items-center gap-2">
+                <p className="font-medium">v{version.version_no}</p>
+                <StatusBadge status={version.status} />
+                <Badge className="border-border bg-muted/60 text-muted-foreground">{readableLabel(version.coordination_mode)}</Badge>
+              </div>
+              <p className="mt-2 text-sm leading-6 text-muted-foreground">{version.objective ?? "No objective provided."}</p>
+            </div>
+            <p className="text-xs text-muted-foreground">{formatDateTime(version.created_time)}</p>
+          </div>
+          <div className="mt-4 grid gap-3 text-sm md:grid-cols-3">
+            <Detail label="Members" value={String(version.member_refs_json.length)} />
+            <Detail label="Published" value={version.published_time ? formatDateTime(version.published_time) : "Not published"} />
+            <Detail label="Version ID" value={truncateMiddle(version.id, 28)} mono />
+          </div>
+        </div>
+      ))}
+    </div>
+  );
+}
+
+function TeamRuns({
+  runs,
+  loading,
+  statusFilter,
+  onStatusFilterChange,
+  onStartRun,
+}: {
+  runs: TeamRun[];
+  loading: boolean;
+  statusFilter: RunStatusFilter;
+  onStatusFilterChange: (status: RunStatusFilter) => void;
+  onStartRun: () => void;
+}) {
+  if (loading) return <LoadingSpinner label="Loading runs" />;
+  return (
+    <div className="space-y-4">
+      <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
+        <Select
+          className="sm:w-56"
+          aria-label="Filter runs by status"
+          value={statusFilter}
+          onChange={(event) => onStatusFilterChange(event.target.value as RunStatusFilter)}
+          options={[
+            { value: "all", label: "All run statuses" },
+            { value: "queued", label: "Queued" },
+            { value: "running", label: "Running" },
+            { value: "completed", label: "Completed" },
+            { value: "failed", label: "Failed" },
+            { value: "cancelled", label: "Cancelled" },
+          ]}
+        />
+        <Button type="button" variant="secondary" onClick={onStartRun}>
+          <Play className="h-4 w-4" /> Start Run
+        </Button>
+      </div>
+      {runs.length ? (
+        <div className="space-y-2">
+          {runs.map((run) => (
+            <div key={run.id} className="rounded-md border border-border bg-muted/30 p-4">
+              <div className="flex flex-wrap items-center justify-between gap-3">
+                <div className="min-w-0">
+                  <p className="truncate font-mono text-xs">{truncateMiddle(run.id, 32)}</p>
+                  <p className="mt-1 truncate text-sm text-muted-foreground">{run.input_text ?? "Structured input payload"}</p>
+                </div>
+                <StatusBadge status={run.status} />
+              </div>
+              <div className="mt-3 grid gap-3 text-sm md:grid-cols-3">
+                <Detail label="Created" value={relativeTime(run.created_time)} />
+                <Detail label="Version" value={truncateMiddle(run.team_version_id, 24)} mono />
+                <Detail label="Output" value={run.output_text ?? "No output yet"} />
+              </div>
+            </div>
+          ))}
+        </div>
+      ) : (
+        <EmptyState icon={Activity} title="No runs" description="No run records match this team and status filter." actionLabel="Start Run" onAction={onStartRun} />
+      )}
+    </div>
+  );
+}
+
+function TeamRunConsole({
+  team,
+  versions,
+  runs,
+  loading,
+  onCreated,
+}: {
+  team: TeamDefinition;
+  versions: TeamVersion[];
+  runs: TeamRun[];
+  loading: boolean;
+  onCreated: () => void;
+}) {
+  const tenantId = useAuthStore((state) => state.tenantId);
+  const [versionId, setVersionId] = React.useState(versions[0]?.id ?? "");
+  const [inputText, setInputText] = React.useState(RUN_TEMPLATE_OPTIONS[0] ?? "");
+  const [submitting, setSubmitting] = React.useState(false);
+
+  React.useEffect(() => {
+    setVersionId((current) => (versions.some((version) => version.id === current) ? current : versions[0]?.id ?? ""));
+  }, [versions]);
+
+  async function submit(event: React.FormEvent) {
+    event.preventDefault();
+    if (!versionId || !inputText.trim()) return;
+    setSubmitting(true);
+    try {
+      await createTeamRun({ tenant_id: tenantId, team_id: team.id, team_version_id: versionId, input_text: inputText });
+      toast.success("Team run started");
+      setInputText("");
+      onCreated();
+    } finally {
+      setSubmitting(false);
+    }
+  }
+
+  if (loading) return <LoadingSpinner label="Preparing console" />;
+  if (!versions.length) {
+    return <EmptyState icon={Play} title="No runnable version" description="Create a team version before starting a collaborative run." />;
+  }
+
+  return (
+    <div className="grid gap-4 lg:grid-cols-[1fr_360px]">
+      <form className="space-y-4" onSubmit={submit}>
+        <Field label="Version">
+          <Select
+            value={versionId}
+            onChange={(event) => setVersionId(event.target.value)}
+            options={versions.map((version) => ({
+              value: version.id,
+              label: `v${version.version_no} - ${readableLabel(version.coordination_mode)} - ${version.status}`,
+            }))}
+          />
+        </Field>
+        <div className="space-y-2">
+          <p className="text-sm text-muted-foreground">Task templates</p>
+          <div className="flex flex-wrap gap-2">
+            {RUN_TEMPLATE_OPTIONS.map((template, index) => (
+              <Button key={template} type="button" size="sm" variant="outline" onClick={() => setInputText(template)}>
+                Template {index + 1}
+              </Button>
+            ))}
+          </div>
+        </div>
+        <Field label="Run input">
+          <Textarea required className="min-h-40" value={inputText} onChange={(event) => setInputText(event.target.value)} />
+        </Field>
+        <Button disabled={submitting || !versionId || !inputText.trim()}>
+          <Play className="h-4 w-4" /> {submitting ? "Starting..." : "Start Team Run"}
+        </Button>
+      </form>
+      <div className="rounded-md border border-border bg-muted/30 p-4">
+        <div className="mb-3 flex items-center gap-2">
+          <Clock className="h-4 w-4 text-muted-foreground" />
+          <h3 className="text-sm font-semibold">Latest Result</h3>
+        </div>
+        {runs[0] ? (
+          <div className="space-y-3">
+            <div className="flex items-center justify-between gap-3">
+              <p className="truncate font-mono text-xs">{truncateMiddle(runs[0].id, 28)}</p>
+              <StatusBadge status={runs[0].status} />
+            </div>
+            <p className="text-sm leading-6 text-muted-foreground">{runs[0].output_text ?? runs[0].input_text ?? "No output yet."}</p>
+          </div>
+        ) : (
+          <p className="text-sm text-muted-foreground">Start a run to preview the newest result for this team.</p>
+        )}
+      </div>
+    </div>
+  );
+}
+
+function TeamPolicy({
+  version,
+  loading,
+  onCreateVersion,
+}: {
+  version?: TeamVersion;
+  loading: boolean;
+  onCreateVersion: () => void;
+}) {
+  if (loading) return <LoadingSpinner label="Loading policy" />;
+  if (!version) {
+    return <EmptyState icon={Settings2} title="No policy" description="Create a team version to inspect member policy and coordination settings." actionLabel="New Version" onAction={onCreateVersion} />;
+  }
+  return (
+    <div className="grid gap-4 lg:grid-cols-2">
+      <section className="rounded-md border border-border bg-muted/30 p-4">
+        <div className="mb-3 flex items-center gap-2">
+          <Layers3 className="h-4 w-4 text-muted-foreground" />
+          <h3 className="text-sm font-semibold">Coordination</h3>
+        </div>
+        <div className="grid gap-3 text-sm">
+          <Detail label="Mode" value={readableLabel(version.coordination_mode)} />
+          <Detail label="Objective" value={version.objective ?? "No objective provided."} />
+          <Detail label="Status" value={version.status} />
+        </div>
+      </section>
+      <JsonViewer value={version.policy_json} collapsed={false} />
+    </div>
+  );
+}
+
+function Detail({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
+  return (
+    <div className="min-w-0">
+      <p className="text-muted-foreground">{label}</p>
+      <p className={mono ? "mt-1 truncate font-mono text-xs" : "mt-1 truncate"}>{value}</p>
+    </div>
+  );
+}
+
+function SummaryTile({ label, value }: { label: string; value: string | number }) {
+  return (
+    <div className="rounded-md border border-border bg-muted/30 p-3">
+      <p className="text-xs text-muted-foreground">{label}</p>
+      <p className="mt-1 text-xl font-semibold tabular-nums">{value}</p>
+    </div>
+  );
+}
+
+function CreateTeamVersionDialog({
+  open,
+  onOpenChange,
+  teamId,
+  onCreated,
+}: {
+  open: boolean;
+  onOpenChange: (open: boolean) => void;
+  teamId?: string;
+  onCreated: () => void;
+}) {
+  const tenantId = useAuthStore((state) => state.tenantId);
+  const [form, setForm] = React.useState({ coordination_mode: "supervisor", objective: "" });
+  const [submitting, setSubmitting] = React.useState(false);
+
+  async function submit(event: React.FormEvent) {
+    event.preventDefault();
+    if (!teamId) return;
+    setSubmitting(true);
+    try {
+      await createTeamVersion({ tenant_id: tenantId, team_id: teamId, ...form, status: "draft" });
+      toast.success("Team version created");
+      onOpenChange(false);
+      setForm({ coordination_mode: "supervisor", objective: "" });
+      onCreated();
+    } finally {
+      setSubmitting(false);
+    }
+  }
+
+  return (
+    <Dialog open={open} onOpenChange={onOpenChange} title="Create Team Version">
+      <form className="space-y-4" onSubmit={submit}>
+        <Field label="Coordination mode">
+          <Select
+            value={form.coordination_mode}
+            onChange={(event) => setForm({ ...form, coordination_mode: event.target.value })}
+            options={COORDINATION_MODE_OPTIONS}
+          />
+        </Field>
+        <Field label="Objective">
+          <Textarea
+            value={form.objective}
+            placeholder="Describe what this version should coordinate and optimize for."
+            onChange={(event) => setForm({ ...form, objective: event.target.value })}
+          />
+        </Field>
+        <div className="rounded-md border border-border bg-muted/40 p-3 text-sm text-muted-foreground">
+          Members and policy start empty so the version can be refined without blocking creation.
+        </div>
+        <div className="flex justify-end gap-2">
+          <Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
+            Cancel
+          </Button>
+          <Button disabled={submitting || !teamId}>{submitting ? "Creating..." : "Create Version"}</Button>
+        </div>
+      </form>
+    </Dialog>
+  );
+}
+
+function CreateTeamRunDialog({
+  open,
+  onOpenChange,
+  teamId,
+  versions,
+  onCreated,
+}: {
+  open: boolean;
+  onOpenChange: (open: boolean) => void;
+  teamId?: string;
+  versions: TeamVersion[];
+  onCreated: () => void;
+}) {
+  const tenantId = useAuthStore((state) => state.tenantId);
+  const [versionId, setVersionId] = React.useState(versions[0]?.id ?? "");
+  const [inputText, setInputText] = React.useState("");
+  const [submitting, setSubmitting] = React.useState(false);
+
+  React.useEffect(() => {
+    setVersionId((current) => (versions.some((version) => version.id === current) ? current : versions[0]?.id ?? ""));
+  }, [versions]);
+
+  async function submit(event: React.FormEvent) {
+    event.preventDefault();
+    if (!teamId || !versionId) return;
+    setSubmitting(true);
+    try {
+      await createTeamRun({ tenant_id: tenantId, team_id: teamId, team_version_id: versionId, input_text: inputText });
+      toast.success("Team run started");
+      onOpenChange(false);
+      setInputText("");
+      onCreated();
+    } finally {
+      setSubmitting(false);
+    }
+  }
+
+  return (
+    <Dialog open={open} onOpenChange={onOpenChange} title="Start Team Run">
+      <form className="space-y-4" onSubmit={submit}>
+        <Field label="Version">
+          <Select
+            value={versionId}
+            onChange={(event) => setVersionId(event.target.value)}
+            options={versions.map((version) => ({
+              value: version.id,
+              label: `v${version.version_no} - ${readableLabel(version.coordination_mode)} - ${version.status}`,
+            }))}
+          />
+        </Field>
+        <Field label="Input">
+          <Textarea required className="min-h-32" value={inputText} onChange={(event) => setInputText(event.target.value)} />
+        </Field>
+        <div className="flex justify-end gap-2">
+          <Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
+            Cancel
+          </Button>
+          <Button disabled={submitting || !teamId || !versionId}>{submitting ? "Starting..." : "Start Run"}</Button>
+        </div>
+      </form>
+    </Dialog>
+  );
+}
+
+function CreateTeamDialog({ open, onOpenChange, onCreated }: { open: boolean; onOpenChange: (open: boolean) => void; onCreated: () => void }) {
+  const { tenantId, userId } = useAuthStore();
+  const [form, setForm] = React.useState({ name: "", description: "", team_type: "collaborative" });
+  const [submitting, setSubmitting] = React.useState(false);
+
+  async function submit(event: React.FormEvent) {
+    event.preventDefault();
+    setSubmitting(true);
+    try {
+      await createTeam({ tenant_id: tenantId, owner_user_id: userId, ...form });
+      toast.success("Team created");
+      onOpenChange(false);
+      setForm({ name: "", description: "", team_type: "collaborative" });
+      onCreated();
+    } finally {
+      setSubmitting(false);
+    }
+  }
+
+  return (
+    <Dialog open={open} onOpenChange={onOpenChange} title="Create Team">
+      <form className="space-y-4" onSubmit={submit}>
+        <Field label="Name">
+          <Input required value={form.name} onChange={(event) => setForm({ ...form, name: event.target.value })} />
+        </Field>
+        <Field label="Type">
+          <Select value={form.team_type} onChange={(event) => setForm({ ...form, team_type: event.target.value })} options={TEAM_TYPE_OPTIONS} />
+        </Field>
+        <Field label="Description">
+          <Textarea value={form.description} onChange={(event) => setForm({ ...form, description: event.target.value })} />
+        </Field>
+        <div className="flex justify-end gap-2">
+          <Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
+            Cancel
+          </Button>
+          <Button disabled={submitting || !form.name.trim()}>{submitting ? "Creating..." : "Create"}</Button>
+        </div>
+      </form>
+    </Dialog>
+  );
+}
+
+function Field({ label, children }: { label: string; children: React.ReactNode }) {
+  return (
+    <label className="block space-y-2 text-sm">
+      <span className="text-muted-foreground">{label}</span>
+      {children}
+    </label>
+  );
+}
+
+function readableLabel(value: string) {
+  return value
+    .split(/[_-]+/)
+    .filter(Boolean)
+    .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
+    .join(" ");
+}
+
+function getJsonString(value: JSONObject, key: string) {
+  const item = value[key];
+  if (typeof item === "string" || typeof item === "number" || typeof item === "boolean") return String(item);
+  return undefined;
+}

+ 607 - 0
web/src/pages/tools/ToolsPage.tsx

@@ -0,0 +1,607 @@
+import * as React from "react";
+import { Activity, CheckCircle2, Clock, Code2, Package, Plug, Plus, RefreshCw, SlidersHorizontal, TerminalSquare, Wrench } from "lucide-react";
+import { createTool, createToolVersion, listToolBindings, listToolCredentials, listToolVersions, listTools } from "@/api";
+import { ApiErrorState } from "@/components/shared/ApiErrorState";
+import { EmptyState } from "@/components/shared/EmptyState";
+import { EntityListItem } from "@/components/shared/EntityListItem";
+import { JsonViewer } from "@/components/shared/JsonViewer";
+import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
+import { MetricCard } from "@/components/shared/MetricCard";
+import { PageHeader } from "@/components/shared/PageHeader";
+import { SearchInput } from "@/components/shared/SearchInput";
+import { StatusBadge } from "@/components/shared/StatusBadge";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
+import { Dialog } from "@/components/ui/dialog";
+import { Input, Textarea } from "@/components/ui/input";
+import { Select } from "@/components/ui/select";
+import { Tabs } from "@/components/ui/tabs";
+import { toast } from "@/components/ui/toaster";
+import { formatDateTime } from "@/lib/utils";
+import { useAuthStore } from "@/stores/auth";
+import type { JSONObject, ToolBinding, ToolCredential, ToolDefinition, ToolVersion } from "@/types";
+
+type ToolStatusFilter = "all" | "ready" | "unversioned" | "bound";
+
+const defaultPayload = JSON.stringify({ customer_id: "cus_demo_001" }, null, 2);
+
+export function ToolsPage() {
+  const [tools, setTools] = React.useState<ToolDefinition[]>([]);
+  const [bindings, setBindings] = React.useState<ToolBinding[]>([]);
+  const [credentials, setCredentials] = React.useState<ToolCredential[]>([]);
+  const [versions, setVersions] = React.useState<ToolVersion[]>([]);
+  const [allVersions, setAllVersions] = React.useState<ToolVersion[]>([]);
+  const [selectedToolId, setSelectedToolId] = React.useState<string>();
+  const [selectedVersionId, setSelectedVersionId] = React.useState<string>();
+  const [search, setSearch] = React.useState("");
+  const [typeFilter, setTypeFilter] = React.useState("all");
+  const [statusFilter, setStatusFilter] = React.useState<ToolStatusFilter>("all");
+  const [activeTab, setActiveTab] = React.useState("overview");
+  const [payload, setPayload] = React.useState(defaultPayload);
+  const [runResult, setRunResult] = React.useState<JSONObject | undefined>();
+  const [loading, setLoading] = React.useState(true);
+  const [error, setError] = React.useState<string>();
+  const [createOpen, setCreateOpen] = React.useState(false);
+  const [versionOpen, setVersionOpen] = React.useState(false);
+
+  const selectedTool = tools.find((tool) => tool.id === selectedToolId);
+  const selectedVersion = versions.find((version) => version.id === selectedVersionId) ?? versions[0];
+  const versionIds = new Set(versions.map((version) => version.id));
+  const selectedBindings = bindings.filter((binding) => versionIds.has(binding.tool_version_id));
+  const selectedCredentials = credentials.filter((credential) => selectedBindings.some((binding) => binding.credential_id === credential.id));
+  const boundVersionIds = new Set(bindings.map((binding) => binding.tool_version_id));
+  const readyToolIds = new Set(allVersions.map((version) => version.tool_id));
+  const boundToolIds = new Set(allVersions.filter((version) => boundVersionIds.has(version.id)).map((version) => version.tool_id));
+  const toolTypes = Array.from(new Set(tools.map((tool) => tool.tool_type))).sort();
+
+  const filtered = tools.filter((tool) => {
+    const haystack = `${tool.name} ${tool.code} ${tool.tool_type} ${tool.description ?? ""}`.toLowerCase();
+    const matchesSearch = haystack.includes(search.toLowerCase());
+    const matchesType = typeFilter === "all" || tool.tool_type === typeFilter;
+    const matchesStatus =
+      statusFilter === "all" ||
+      (statusFilter === "ready" && readyToolIds.has(tool.id)) ||
+      (statusFilter === "unversioned" && !readyToolIds.has(tool.id)) ||
+      (statusFilter === "bound" && boundToolIds.has(tool.id));
+    return matchesSearch && matchesType && matchesStatus;
+  });
+
+  const load = React.useCallback(async () => {
+    setLoading(true);
+    setError(undefined);
+    try {
+      const [toolData, bindingData, credentialData, versionData] = await Promise.all([
+        listTools(),
+        listToolBindings(),
+        listToolCredentials(),
+        listToolVersions(),
+      ]);
+      setTools(toolData);
+      setBindings(bindingData);
+      setCredentials(credentialData);
+      setAllVersions(versionData);
+      setSelectedToolId((current) => current ?? toolData[0]?.id);
+    } catch (err) {
+      setError(err instanceof Error ? err.message : "Failed to load tools");
+    } finally {
+      setLoading(false);
+    }
+  }, []);
+
+  React.useEffect(() => {
+    void load();
+  }, [load]);
+
+  React.useEffect(() => {
+    if (!selectedToolId) {
+      setVersions([]);
+      setSelectedVersionId(undefined);
+      return;
+    }
+
+    void listToolVersions(selectedToolId)
+      .then((data) => {
+        setVersions(data);
+        setSelectedVersionId((current) => (current && data.some((version) => version.id === current) ? current : data[0]?.id));
+      })
+      .catch(() => {
+        setVersions([]);
+        setSelectedVersionId(undefined);
+      });
+  }, [selectedToolId]);
+
+  async function reloadVersions() {
+    if (!selectedToolId) return;
+    const data = await listToolVersions(selectedToolId);
+    setVersions(data);
+    setAllVersions((current) => [...data, ...current.filter((version) => version.tool_id !== selectedToolId)]);
+    setSelectedVersionId(data[0]?.id);
+  }
+
+  function simulateRun() {
+    if (!selectedTool || !selectedVersion) return;
+    try {
+      const parsed = JSON.parse(payload || "{}") as JSONObject;
+      setRunResult({
+        ok: true,
+        tool: selectedTool.code,
+        version: selectedVersion.version_no,
+        latency_ms: Math.min(selectedVersion.timeout_ms ?? 800, 800),
+        received: parsed,
+        output_preview: selectedVersion.output_schema_json ?? { status: "mocked" },
+      });
+      toast.success("Test payload simulated");
+    } catch {
+      toast.error("Payload must be valid JSON");
+    }
+  }
+
+  if (loading) return <LoadingSpinner label="Loading tools" />;
+  if (error) return <ApiErrorState message={error} onRetry={() => void load()} />;
+
+  return (
+    <div className="space-y-6">
+      <PageHeader
+        title="Tools"
+        description="Manage tool definitions, versions, readiness, and quick payload tests."
+        actions={
+          <>
+            <Button variant="outline" onClick={() => void load()}>
+              <RefreshCw className="h-4 w-4" /> Refresh
+            </Button>
+            <Button onClick={() => setCreateOpen(true)}>
+              <Wrench className="h-4 w-4" /> New Tool
+            </Button>
+          </>
+        }
+      />
+
+      <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
+        <MetricCard label="Tools" value={tools.length} icon={Wrench} />
+        <MetricCard label="Ready" value={readyToolIds.size} icon={CheckCircle2} />
+        <MetricCard label="Versions" value={allVersions.length} icon={Package} />
+        <MetricCard label="Bindings" value={bindings.length} icon={Plug} />
+      </div>
+
+      <div className="grid gap-6 xl:grid-cols-[400px_1fr]">
+        <Card>
+          <CardHeader>
+            <div className="flex items-start justify-between gap-3">
+              <div>
+                <CardTitle>Tool List</CardTitle>
+                <CardDescription>{filtered.length} of {tools.length} shown</CardDescription>
+              </div>
+              <SlidersHorizontal className="mt-1 h-4 w-4 text-muted-foreground" />
+            </div>
+          </CardHeader>
+          <CardContent className="space-y-4">
+            <SearchInput value={search} onChange={setSearch} placeholder="Search tools" />
+            <div className="grid gap-3 sm:grid-cols-2">
+              <Select
+                aria-label="Filter by tool type"
+                value={typeFilter}
+                onChange={(event) => setTypeFilter(event.target.value)}
+                options={[{ value: "all", label: "All types" }, ...toolTypes.map((type) => ({ value: type, label: type }))]}
+              />
+              <Select
+                aria-label="Filter by tool status"
+                value={statusFilter}
+                onChange={(event) => setStatusFilter(event.target.value as ToolStatusFilter)}
+                options={[
+                  { value: "all", label: "All status" },
+                  { value: "ready", label: "Has version" },
+                  { value: "bound", label: "Bound" },
+                  { value: "unversioned", label: "Needs version" },
+                ]}
+              />
+            </div>
+
+            {filtered.length ? (
+              <div className="space-y-2">
+                {filtered.map((tool) => {
+                  const versionCount = allVersions.filter((version) => version.tool_id === tool.id).length;
+                  const isBound = boundToolIds.has(tool.id);
+                  return (
+                    <EntityListItem
+                      key={tool.id}
+                      active={tool.id === selectedToolId}
+                      title={tool.name}
+                      subtitle={`${tool.code} · ${tool.tool_type}`}
+                      meta={
+                        <div className="flex flex-col items-end gap-1">
+                          <Badge className="border-border bg-muted/50 text-muted-foreground">v{versionCount}</Badge>
+                          {isBound ? <StatusBadge status="active" /> : null}
+                        </div>
+                      }
+                      onClick={() => {
+                        setSelectedToolId(tool.id);
+                        setRunResult(undefined);
+                        setActiveTab("overview");
+                      }}
+                    />
+                  );
+                })}
+              </div>
+            ) : (
+              <EmptyState icon={Wrench} title="No tools found" description="Adjust filters or create a new tool." />
+            )}
+          </CardContent>
+        </Card>
+
+        <Card>
+          <CardHeader>
+            <div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
+              <div>
+                <CardTitle>{selectedTool?.name ?? "Tool Details"}</CardTitle>
+                <CardDescription>{selectedTool?.description ?? "Select a tool to view versions and run a quick test."}</CardDescription>
+              </div>
+              {selectedTool ? (
+                <div className="flex flex-wrap gap-2">
+                  <Badge className="border-primary/30 bg-primary/10 text-primary">{selectedTool.tool_type}</Badge>
+                  {versions.length ? <StatusBadge status="active" /> : <StatusBadge status="draft" />}
+                </div>
+              ) : null}
+            </div>
+          </CardHeader>
+          <CardContent>
+            {selectedTool ? (
+              <Tabs
+                value={activeTab}
+                onChange={setActiveTab}
+                tabs={[
+                  {
+                    value: "overview",
+                    label: "Overview",
+                    content: (
+                      <OverviewPanel
+                        tool={selectedTool}
+                        latestVersion={versions[0]}
+                        bindingCount={selectedBindings.length}
+                        credentialCount={selectedCredentials.length}
+                      />
+                    ),
+                  },
+                  {
+                    value: "versions",
+                    label: "Versions",
+                    content: (
+                      <VersionPanel
+                        versions={versions}
+                        selectedVersionId={selectedVersion?.id}
+                        onSelectVersion={setSelectedVersionId}
+                        onCreateVersion={() => setVersionOpen(true)}
+                      />
+                    ),
+                  },
+                  {
+                    value: "test",
+                    label: "Test",
+                    content: (
+                      <TestPanel
+                        disabled={!selectedVersion}
+                        payload={payload}
+                        setPayload={setPayload}
+                        result={runResult}
+                        onRun={simulateRun}
+                      />
+                    ),
+                  },
+                ]}
+              />
+            ) : (
+              <EmptyState icon={Package} title="Select a tool" description="Choose a tool to view details, versions, and quick tests." />
+            )}
+          </CardContent>
+        </Card>
+      </div>
+
+      {selectedVersion ? (
+        <div className="grid gap-6 lg:grid-cols-3">
+          <JsonSummary title="Input Schema" value={selectedVersion.input_schema_json} />
+          <JsonSummary title="Invoke Config" value={selectedVersion.invoke_config_json} />
+          <JsonSummary title="Retry Policy" value={selectedVersion.retry_policy_json} />
+        </div>
+      ) : null}
+
+      <CreateToolDialog open={createOpen} onOpenChange={setCreateOpen} onCreated={() => void load()} />
+      <CreateToolVersionDialog open={versionOpen} onOpenChange={setVersionOpen} toolId={selectedToolId} onCreated={() => void reloadVersions()} />
+    </div>
+  );
+}
+
+function OverviewPanel({
+  tool,
+  latestVersion,
+  bindingCount,
+  credentialCount,
+}: {
+  tool: ToolDefinition;
+  latestVersion?: ToolVersion;
+  bindingCount: number;
+  credentialCount: number;
+}) {
+  return (
+    <div className="space-y-4">
+      <div className="grid gap-4 lg:grid-cols-3">
+        <InfoPanel icon={Code2} label="Code" value={tool.code} />
+        <InfoPanel icon={Package} label="Latest Version" value={latestVersion ? `v${latestVersion.version_no}` : "None"} />
+        <InfoPanel icon={Clock} label="Timeout" value={latestVersion?.timeout_ms ? `${latestVersion.timeout_ms} ms` : "Not set"} />
+      </div>
+
+      <ReadinessStrip versions={latestVersion ? 1 : 0} bindings={bindingCount} credentials={credentialCount} />
+
+      <div className="rounded-md border border-border p-4">
+        <h3 className="text-sm font-semibold">Basic Info</h3>
+        <div className="mt-4 grid gap-4 text-sm md:grid-cols-3">
+          <Detail label="Type" value={tool.tool_type} />
+          <Detail label="Plugin" value={tool.plugin_id ?? "Standalone"} />
+          <Detail label="Created" value={formatDateTime(tool.created_time)} />
+        </div>
+      </div>
+    </div>
+  );
+}
+
+function InfoPanel({ icon: Icon, label, value }: { icon: typeof Wrench; label: string; value: string }) {
+  return (
+    <div className="rounded-md border border-border bg-muted/30 p-4">
+      <div className="flex items-center gap-2 text-sm text-muted-foreground">
+        <Icon className="h-4 w-4" />
+        {label}
+      </div>
+      <p className="mt-3 break-words font-mono text-sm text-foreground">{value}</p>
+    </div>
+  );
+}
+
+function ReadinessStrip({ versions, bindings, credentials }: { versions: number; bindings: number; credentials: number }) {
+  const items = [
+    { label: "Definition", ready: true },
+    { label: "Version", ready: versions > 0 },
+    { label: "Binding", ready: bindings > 0 },
+    { label: "Credential", ready: credentials > 0 },
+  ];
+  return (
+    <div className="grid gap-2 sm:grid-cols-4">
+      {items.map((item) => (
+        <div key={item.label} className="flex min-h-12 items-center gap-2 rounded-md border border-border px-3 text-sm">
+          <span className={item.ready ? "text-emerald-500" : "text-muted-foreground"}>
+            <CheckCircle2 className="h-4 w-4" />
+          </span>
+          <span>{item.label}</span>
+        </div>
+      ))}
+    </div>
+  );
+}
+
+function VersionPanel({
+  versions,
+  selectedVersionId,
+  onSelectVersion,
+  onCreateVersion,
+}: {
+  versions: ToolVersion[];
+  selectedVersionId?: string;
+  onSelectVersion: (id: string) => void;
+  onCreateVersion: () => void;
+}) {
+  return (
+    <div className="space-y-4">
+      <div className="flex items-center justify-between gap-3">
+        <div>
+          <h3 className="text-sm font-semibold">Versions</h3>
+          <p className="text-sm text-muted-foreground">Endpoint, timeout, retry, and schema snapshots.</p>
+        </div>
+        <Button size="sm" variant="secondary" onClick={onCreateVersion}>
+          <Plus className="h-4 w-4" /> New
+        </Button>
+      </div>
+
+      {versions.length ? (
+        <div className="grid gap-3 lg:grid-cols-2">
+          {versions.map((version) => (
+            <button
+              key={version.id}
+              type="button"
+              onClick={() => onSelectVersion(version.id)}
+              className={[
+                "min-h-28 rounded-md border p-4 text-left transition hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-primary",
+                selectedVersionId === version.id ? "border-primary/45 bg-primary/10" : "border-border bg-muted/20",
+              ].join(" ")}
+            >
+              <div className="flex items-start justify-between gap-3">
+                <div>
+                  <p className="font-semibold">v{version.version_no}</p>
+                  <p className="mt-1 text-xs text-muted-foreground">{formatDateTime(version.created_time)}</p>
+                </div>
+                <Badge className="border-border bg-muted/50 text-muted-foreground">{version.timeout_ms ?? "n/a"} ms</Badge>
+              </div>
+              <div className="mt-4 grid grid-cols-3 gap-2 text-xs text-muted-foreground">
+                <span>{Object.keys(version.input_schema_json ?? {}).length} inputs</span>
+                <span>{Object.keys(version.output_schema_json ?? {}).length} outputs</span>
+                <span>{Object.keys(version.retry_policy_json ?? {}).length} retry</span>
+              </div>
+            </button>
+          ))}
+        </div>
+      ) : (
+        <EmptyState icon={Package} title="No versions yet" description="Create a version before testing this tool." />
+      )}
+    </div>
+  );
+}
+
+function TestPanel({
+  disabled,
+  payload,
+  setPayload,
+  result,
+  onRun,
+}: {
+  disabled: boolean;
+  payload: string;
+  setPayload: (value: string) => void;
+  result?: JSONObject;
+  onRun: () => void;
+}) {
+  return (
+    <div className="grid gap-4 lg:grid-cols-[1fr_340px]">
+      <div className="space-y-3">
+        <div className="flex items-center justify-between gap-3">
+          <div>
+            <h3 className="text-sm font-semibold">Payload Test</h3>
+            <p className="text-sm text-muted-foreground">Run a mock request against the selected version.</p>
+          </div>
+          <Button size="sm" onClick={onRun} disabled={disabled}>
+            <TerminalSquare className="h-4 w-4" /> Run
+          </Button>
+        </div>
+        <Textarea className="min-h-64 font-mono text-sm" value={payload} onChange={(event) => setPayload(event.target.value)} />
+      </div>
+      <div>
+        <div className="mb-3 flex items-center gap-2 text-sm font-semibold">
+          <Activity className="h-4 w-4" /> Result
+        </div>
+        {result ? <JsonViewer value={result} collapsed={false} /> : <EmptyState icon={TerminalSquare} title="No run yet" description="Select a version and run JSON." />}
+      </div>
+    </div>
+  );
+}
+
+function JsonSummary({ title, value }: { title: string; value: unknown }) {
+  return (
+    <Card>
+      <CardHeader>
+        <CardTitle className="text-sm">{title}</CardTitle>
+      </CardHeader>
+      <CardContent>
+        <JsonViewer value={value ?? {}} />
+      </CardContent>
+    </Card>
+  );
+}
+
+function Detail({ label, value }: { label: string; value: string }) {
+  return (
+    <div>
+      <p className="text-muted-foreground">{label}</p>
+      <p className="mt-1 break-words font-mono text-xs">{value}</p>
+    </div>
+  );
+}
+
+function CreateToolVersionDialog({
+  open,
+  onOpenChange,
+  toolId,
+  onCreated,
+}: {
+  open: boolean;
+  onOpenChange: (open: boolean) => void;
+  toolId?: string;
+  onCreated: () => void;
+}) {
+  const tenantId = useAuthStore((state) => state.tenantId);
+  const [timeoutMs, setTimeoutMs] = React.useState("5000");
+  const [endpoint, setEndpoint] = React.useState("https://mock.local/tool");
+  const [maxAttempts, setMaxAttempts] = React.useState("2");
+  const [submitting, setSubmitting] = React.useState(false);
+
+  async function submit(event: React.FormEvent) {
+    event.preventDefault();
+    if (!toolId) return;
+    setSubmitting(true);
+    try {
+      await createToolVersion({
+        tenant_id: tenantId,
+        tool_id: toolId,
+        timeout_ms: Number(timeoutMs) || null,
+        input_schema_json: { input: "object" },
+        output_schema_json: { result: "object" },
+        invoke_config_json: { url: endpoint },
+        retry_policy_json: { max_attempts: Number(maxAttempts) || 1 },
+      });
+      toast.success("Tool version created");
+      onOpenChange(false);
+      setTimeoutMs("5000");
+      setEndpoint("https://mock.local/tool");
+      setMaxAttempts("2");
+      onCreated();
+    } finally {
+      setSubmitting(false);
+    }
+  }
+
+  return (
+    <Dialog open={open} onOpenChange={onOpenChange} title="Create Tool Version">
+      <form className="space-y-4" onSubmit={submit}>
+        <Field label="Endpoint">
+          <Input value={endpoint} onChange={(event) => setEndpoint(event.target.value)} />
+        </Field>
+        <div className="grid gap-4 sm:grid-cols-2">
+          <Field label="Timeout (ms)">
+            <Input inputMode="numeric" value={timeoutMs} onChange={(event) => setTimeoutMs(event.target.value)} />
+          </Field>
+          <Field label="Retry attempts">
+            <Input inputMode="numeric" value={maxAttempts} onChange={(event) => setMaxAttempts(event.target.value)} />
+          </Field>
+        </div>
+        <div className="flex justify-end gap-2">
+          <Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>Cancel</Button>
+          <Button disabled={submitting || !toolId}>{submitting ? "Creating..." : "Create Version"}</Button>
+        </div>
+      </form>
+    </Dialog>
+  );
+}
+
+function CreateToolDialog({ open, onOpenChange, onCreated }: { open: boolean; onOpenChange: (open: boolean) => void; onCreated: () => void }) {
+  const tenantId = useAuthStore((state) => state.tenantId);
+  const [form, setForm] = React.useState({ name: "", tool_type: "http", description: "" });
+  const [submitting, setSubmitting] = React.useState(false);
+
+  async function submit(event: React.FormEvent) {
+    event.preventDefault();
+    setSubmitting(true);
+    try {
+      await createTool({ tenant_id: tenantId, ...form });
+      toast.success("Tool created");
+      onOpenChange(false);
+      setForm({ name: "", tool_type: "http", description: "" });
+      onCreated();
+    } finally {
+      setSubmitting(false);
+    }
+  }
+
+  return (
+    <Dialog open={open} onOpenChange={onOpenChange} title="Create Tool">
+      <form className="space-y-4" onSubmit={submit}>
+        <Field label="Name"><Input required value={form.name} onChange={(event) => setForm({ ...form, name: event.target.value })} /></Field>
+        <Field label="Type">
+          <Select
+            value={form.tool_type}
+            onChange={(event) => setForm({ ...form, tool_type: event.target.value })}
+            options={[
+              { value: "http", label: "HTTP" },
+              { value: "retrieval", label: "Retrieval" },
+              { value: "code", label: "Code" },
+              { value: "webhook", label: "Webhook" },
+            ]}
+          />
+        </Field>
+        <Field label="Description"><Textarea value={form.description} onChange={(event) => setForm({ ...form, description: event.target.value })} /></Field>
+        <div className="flex justify-end gap-2">
+          <Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>Cancel</Button>
+          <Button disabled={submitting}>{submitting ? "Creating..." : "Create"}</Button>
+        </div>
+      </form>
+    </Dialog>
+  );
+}
+
+function Field({ label, children }: { label: string; children: React.ReactNode }) {
+  return <label className="block space-y-2 text-sm"><span className="text-muted-foreground">{label}</span>{children}</label>;
+}

+ 156 - 0
web/src/pages/workflow-editor/WorkflowEditorPage.tsx

@@ -0,0 +1,156 @@
+import * as React from "react";
+import type { Edge, Node } from "@xyflow/react";
+import { useParams } from "react-router-dom";
+import { createWorkflowVersion, debugWorkflow, listWorkflowVersions, listWorkflows, validateWorkflow } from "@/api";
+import { JsonViewer } from "@/components/shared/JsonViewer";
+import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
+import { toast } from "@/components/ui/toaster";
+import { slugifyName } from "@/lib/utils";
+import { useAuthStore } from "@/stores/auth";
+import type { WorkflowDSL, WorkflowDebuggerPlanResponse, WorkflowDesignerValidateResponse } from "@/types";
+import { EditorToolbar } from "./components/EditorToolbar";
+import { FlowCanvas } from "./components/FlowCanvas";
+import { NodePanel } from "./components/NodePanel";
+import { PropertiesPanel } from "./components/PropertiesPanel";
+import { ValidationReport } from "./components/ValidationReport";
+import { dslToFlow } from "./utils/dsl-to-flow";
+import { flowToDsl } from "./utils/flow-to-dsl";
+
+export function WorkflowEditorPage() {
+  const { workflowId = "" } = useParams();
+  const tenantId = useAuthStore((state) => state.tenantId);
+  const [loading, setLoading] = React.useState(true);
+  const [name, setName] = React.useState("Workflow");
+  const [nodes, setNodes] = React.useState<Node[]>([]);
+  const [edges, setEdges] = React.useState<Edge[]>([]);
+  const [selectedNodeId, setSelectedNodeId] = React.useState<string>();
+  const [dirty, setDirty] = React.useState(false);
+  const [showJson, setShowJson] = React.useState(false);
+  const [report, setReport] = React.useState<WorkflowDesignerValidateResponse | WorkflowDebuggerPlanResponse>();
+  const [busy, setBusy] = React.useState(false);
+  const editorReady = React.useRef(false);
+
+  React.useEffect(() => {
+    async function load() {
+      const workflows = await listWorkflows();
+      const workflow = workflows.find((item) => item.id === workflowId);
+      if (workflow) {
+        setName(workflow.name);
+      }
+      const versions = await listWorkflowVersions(workflowId);
+      const latest = versions.sort((a, b) => b.version_no - a.version_no)[0];
+      const dsl = (latest?.dsl_json as WorkflowDSL | undefined) ?? {
+        code: slugifyName(workflow?.name ?? "workflow", "workflow"),
+        name: workflow?.name ?? "Workflow",
+        nodes: [
+          { id: "start", type: "start", name: "Start", config: {} },
+          { id: "answer", type: "answer", name: "Answer", config: {} },
+        ],
+        edges: [{ source: "start", target: "answer" }],
+      };
+      const flow = dslToFlow(dsl);
+      setNodes(flow.nodes);
+      setEdges(flow.edges);
+      setLoading(false);
+      window.setTimeout(() => {
+        editorReady.current = true;
+      }, 0);
+    }
+    void load();
+  }, [workflowId]);
+
+  const currentDsl = React.useMemo(() => flowToDsl(slugifyName(name, "workflow"), name, nodes, edges), [edges, name, nodes]);
+  const selectedNode = React.useMemo(() => nodes.find((node) => node.id === selectedNodeId), [nodes, selectedNodeId]);
+
+  function addNode(type: string, position?: { x: number; y: number }) {
+    const id = `${type}-${Date.now().toString(36)}`;
+    setNodes((items) => [
+      ...items,
+      {
+        id,
+        type,
+        position: position ?? { x: 120 + items.length * 32, y: 120 + items.length * 20 },
+        data: { id, type, name: type, label: type, config: {} },
+      },
+    ]);
+    setSelectedNodeId(id);
+    setDirty(true);
+  }
+
+  function updateNode(nodeId: string, data: { name: string; config: Record<string, unknown> }) {
+    setNodes((items) =>
+      items.map((node) =>
+        node.id === nodeId
+          ? { ...node, data: { ...node.data, name: data.name, label: data.name, config: data.config } }
+          : node,
+      ),
+    );
+    setDirty(true);
+  }
+
+  async function save() {
+    setBusy(true);
+    try {
+      await createWorkflowVersion({ tenant_id: tenantId, workflow_id: workflowId, dsl_json: currentDsl, status: "draft" });
+      setDirty(false);
+      toast.success("Workflow version saved");
+    } finally {
+      setBusy(false);
+    }
+  }
+
+  async function validate() {
+    setBusy(true);
+    try {
+      const result = await validateWorkflow(currentDsl);
+      setReport(result);
+      toast.info(result.valid ? "Workflow is valid" : "Validation found issues");
+    } finally {
+      setBusy(false);
+    }
+  }
+
+  async function debug() {
+    setBusy(true);
+    try {
+      const result = await debugWorkflow(currentDsl);
+      setReport(result);
+      toast.info("Debug preview generated");
+    } finally {
+      setBusy(false);
+    }
+  }
+
+  if (loading) return <LoadingSpinner label="Loading editor" />;
+
+  return (
+    <div className="overflow-hidden rounded-md border border-border bg-surface-elevated">
+      <EditorToolbar
+        dirty={dirty}
+        showJson={showJson}
+        busy={busy}
+        onShowJson={() => setShowJson((value) => !value)}
+        onSave={() => void save()}
+        onValidate={() => void validate()}
+        onDebug={() => void debug()}
+      />
+      <div className="flex flex-col lg:flex-row">
+        <NodePanel onAdd={addNode} />
+        <FlowCanvas
+          nodes={nodes}
+          edges={edges}
+          onChange={(nextNodes, nextEdges) => {
+            setNodes(nextNodes);
+            setEdges(nextEdges);
+            if (editorReady.current) setDirty(true);
+          }}
+          onSelectNode={(node) => setSelectedNodeId(node?.id)}
+          onAddNode={addNode}
+        />
+        <PropertiesPanel selectedNode={selectedNode} onUpdateNode={updateNode} />
+      </div>
+      {showJson ? <div className="border-t border-border p-4"><JsonViewer value={currentDsl} collapsed={false} /></div> : null}
+      <ValidationReport report={report} />
+    </div>
+  );
+}

+ 46 - 0
web/src/pages/workflow-editor/components/EditorToolbar.tsx

@@ -0,0 +1,46 @@
+import { ArrowLeft, Braces, Bug, CheckCircle2, Save } from "lucide-react";
+import { Link } from "react-router-dom";
+import { Button } from "@/components/ui/button";
+
+export function EditorToolbar({
+  dirty,
+  showJson,
+  onShowJson,
+  onSave,
+  onValidate,
+  onDebug,
+  busy,
+}: {
+  dirty: boolean;
+  showJson: boolean;
+  busy?: boolean;
+  onShowJson: () => void;
+  onSave: () => void;
+  onValidate: () => void;
+  onDebug: () => void;
+}) {
+  return (
+    <div className="flex flex-wrap items-center justify-between gap-3 border-b border-border bg-surface-elevated p-3">
+      <div className="flex items-center gap-2">
+        <Link to="/workflows" className="inline-flex h-9 w-9 items-center justify-center rounded-md text-muted-foreground hover:bg-muted">
+          <ArrowLeft className="h-4 w-4" />
+        </Link>
+        <span className="text-sm text-muted-foreground">{dirty ? "Unsaved changes" : "Saved"}</span>
+      </div>
+      <div className="flex flex-wrap gap-2">
+        <Button variant="outline" onClick={onShowJson}>
+          <Braces className="h-4 w-4" /> {showJson ? "Hide JSON" : "JSON"}
+        </Button>
+        <Button variant="outline" disabled={busy} onClick={onValidate}>
+          <CheckCircle2 className="h-4 w-4" /> Validate
+        </Button>
+        <Button variant="outline" disabled={busy} onClick={onDebug}>
+          <Bug className="h-4 w-4" /> Debug
+        </Button>
+        <Button disabled={busy} onClick={onSave}>
+          <Save className="h-4 w-4" /> {busy ? "Working..." : "Save"}
+        </Button>
+      </div>
+    </div>
+  );
+}

+ 79 - 0
web/src/pages/workflow-editor/components/FlowCanvas.tsx

@@ -0,0 +1,79 @@
+import {
+  Background,
+  Controls,
+  MiniMap,
+  ReactFlow,
+  addEdge,
+  applyEdgeChanges,
+  applyNodeChanges,
+  type Connection,
+  type Edge,
+  type EdgeChange,
+  type Node,
+  type NodeChange,
+} from "@xyflow/react";
+import "@xyflow/react/dist/style.css";
+import * as React from "react";
+import { nodeTypes } from "../nodes/node-types";
+
+export function FlowCanvas({
+  nodes,
+  edges,
+  onChange,
+  onSelectNode,
+  onAddNode,
+}: {
+  nodes: Node[];
+  edges: Edge[];
+  onChange: (nodes: Node[], edges: Edge[]) => void;
+  onSelectNode: (node?: Node) => void;
+  onAddNode: (type: string, position: { x: number; y: number }) => void;
+}) {
+  const wrapperRef = React.useRef<HTMLDivElement | null>(null);
+  const onNodesChange = React.useCallback(
+    (changes: NodeChange[]) => onChange(applyNodeChanges(changes, nodes), edges),
+    [edges, nodes, onChange],
+  );
+  const onEdgesChange = React.useCallback(
+    (changes: EdgeChange[]) => onChange(nodes, applyEdgeChanges(changes, edges)),
+    [edges, nodes, onChange],
+  );
+  const onConnect = React.useCallback(
+    (connection: Connection) => onChange(nodes, addEdge(connection, edges)),
+    [edges, nodes, onChange],
+  );
+
+  return (
+    <div
+      ref={wrapperRef}
+      className="h-[calc(100vh-13rem)] min-h-[560px] flex-1 bg-surface-base"
+      onDragOver={(event) => {
+        event.preventDefault();
+        event.dataTransfer.dropEffect = "move";
+      }}
+      onDrop={(event) => {
+        event.preventDefault();
+        const type = event.dataTransfer.getData("application/auto-platform-node");
+        const bounds = wrapperRef.current?.getBoundingClientRect();
+        if (!type || !bounds) return;
+        onAddNode(type, { x: event.clientX - bounds.left - 90, y: event.clientY - bounds.top - 40 });
+      }}
+    >
+      <ReactFlow
+        nodes={nodes}
+        edges={edges}
+        nodeTypes={nodeTypes}
+        onNodesChange={onNodesChange}
+        onEdgesChange={onEdgesChange}
+        onConnect={onConnect}
+        onNodeClick={(_, node) => onSelectNode(node)}
+        onPaneClick={() => onSelectNode(undefined)}
+        fitView
+      >
+        <Background color="hsl(var(--border))" gap={20} />
+        <MiniMap className="!bg-surface-elevated" />
+        <Controls className="!border-border !bg-surface-elevated" />
+      </ReactFlow>
+    </div>
+  );
+}

+ 26 - 0
web/src/pages/workflow-editor/components/NodePanel.tsx

@@ -0,0 +1,26 @@
+import { NODE_TYPE_CONFIG } from "@/lib/constants";
+
+export function NodePanel({ onAdd }: { onAdd: (type: string) => void }) {
+  return (
+    <aside className="w-full border-b border-border bg-surface-elevated p-4 lg:w-60 lg:border-b-0 lg:border-r">
+      <h2 className="text-sm font-semibold">Nodes</h2>
+      <div className="mt-4 grid grid-cols-2 gap-2 lg:grid-cols-1">
+        {Object.entries(NODE_TYPE_CONFIG).map(([type, config]) => (
+          <button
+            key={type}
+            draggable
+            className="flex items-center gap-3 rounded-md border border-border bg-muted/40 p-3 text-left text-sm hover:bg-muted"
+            onDragStart={(event) => {
+              event.dataTransfer.setData("application/auto-platform-node", type);
+              event.dataTransfer.effectAllowed = "move";
+            }}
+            onClick={() => onAdd(type)}
+          >
+            <span className="h-2.5 w-2.5 rounded-full" style={{ backgroundColor: config.accent }} />
+            {config.label}
+          </button>
+        ))}
+      </div>
+    </aside>
+  );
+}

+ 64 - 0
web/src/pages/workflow-editor/components/PropertiesPanel.tsx

@@ -0,0 +1,64 @@
+import type { Node } from "@xyflow/react";
+import * as React from "react";
+import { Button } from "@/components/ui/button";
+import { Input, Textarea } from "@/components/ui/input";
+import { toast } from "@/components/ui/toaster";
+
+export function PropertiesPanel({
+  selectedNode,
+  onUpdateNode,
+}: {
+  selectedNode?: Node;
+  onUpdateNode: (nodeId: string, data: { name: string; config: Record<string, unknown> }) => void;
+}) {
+  const [name, setName] = React.useState("");
+  const [configText, setConfigText] = React.useState("{}");
+
+  React.useEffect(() => {
+    if (!selectedNode) return;
+    setName(String(selectedNode.data.name ?? selectedNode.data.label ?? selectedNode.id));
+    setConfigText(JSON.stringify(selectedNode.data.config ?? {}, null, 2));
+  }, [selectedNode]);
+
+  function apply() {
+    if (!selectedNode) return;
+    try {
+      const parsed = JSON.parse(configText || "{}") as Record<string, unknown>;
+      onUpdateNode(selectedNode.id, { name: name.trim() || selectedNode.id, config: parsed });
+      toast.success("Node updated");
+    } catch {
+      toast.error("Config must be valid JSON");
+    }
+  }
+
+  return (
+    <aside className="w-full border-t border-border bg-surface-elevated p-4 lg:w-80 lg:border-l lg:border-t-0">
+      <h2 className="text-sm font-semibold">Properties</h2>
+      {selectedNode ? (
+        <div className="mt-4 space-y-3 text-sm">
+          <div>
+            <p className="text-muted-foreground">Node ID</p>
+            <p className="font-mono">{selectedNode.id}</p>
+          </div>
+          <div>
+            <p className="text-muted-foreground">Type</p>
+            <p>{selectedNode.type}</p>
+          </div>
+          <label className="block space-y-2">
+            <span className="text-muted-foreground">Name</span>
+            <Input value={name} onChange={(event) => setName(event.target.value)} />
+          </label>
+          <label className="block space-y-2">
+            <span className="text-muted-foreground">Config JSON</span>
+            <Textarea className="min-h-48 font-mono text-xs" value={configText} onChange={(event) => setConfigText(event.target.value)} />
+          </label>
+          <Button className="w-full" onClick={apply}>
+            Apply Changes
+          </Button>
+        </div>
+      ) : (
+        <p className="mt-4 text-sm text-muted-foreground">Select a node to edit its name and configuration.</p>
+      )}
+    </aside>
+  );
+}

+ 34 - 0
web/src/pages/workflow-editor/components/ValidationReport.tsx

@@ -0,0 +1,34 @@
+import { StatusBadge } from "@/components/shared/StatusBadge";
+import type { WorkflowDebuggerPlanResponse, WorkflowDesignerValidateResponse } from "@/types";
+
+export function ValidationReport({ report }: { report?: WorkflowDesignerValidateResponse | WorkflowDebuggerPlanResponse }) {
+  if (!report) return null;
+  return (
+    <div className="border-t border-border bg-surface-elevated p-4">
+      <div className="flex flex-wrap items-center gap-3">
+        <StatusBadge status={report.valid ? "completed" : "failed"} />
+        <span className="text-sm text-muted-foreground">
+          {report.node_count} nodes, {report.edge_count} edges
+        </span>
+      </div>
+      {report.diagnostics.length ? (
+        <div className="mt-3 grid gap-2">
+          {report.diagnostics.map((item) => (
+            <div key={`${item.code}-${item.message}`} className="rounded-md border border-border bg-muted/40 p-3 text-sm">
+              <p>{item.message}</p>
+            </div>
+          ))}
+        </div>
+      ) : null}
+      {"execution_preview" in report && report.execution_preview.length ? (
+        <div className="mt-3 flex flex-wrap gap-2">
+          {report.execution_preview.map((step) => (
+            <span key={step.step_index} className="rounded-md border border-border px-2 py-1 font-mono text-xs">
+              {step.step_index}: {step.node_id}
+            </span>
+          ))}
+        </div>
+      ) : null}
+    </div>
+  );
+}

+ 1 - 0
web/src/pages/workflow-editor/nodes/AnswerNode.tsx

@@ -0,0 +1 @@
+export { DefaultNode as AnswerNode } from "./DefaultNode";

+ 1 - 0
web/src/pages/workflow-editor/nodes/ConditionNode.tsx

@@ -0,0 +1 @@
+export { DefaultNode as ConditionNode } from "./DefaultNode";

+ 29 - 0
web/src/pages/workflow-editor/nodes/DefaultNode.tsx

@@ -0,0 +1,29 @@
+import { Handle, Position, type NodeProps } from "@xyflow/react";
+import { NODE_TYPE_CONFIG } from "@/lib/constants";
+import { cn } from "@/lib/utils";
+
+export function DefaultNode({ data, type }: NodeProps) {
+  const config = NODE_TYPE_CONFIG[type as keyof typeof NODE_TYPE_CONFIG] ?? {
+    label: type ?? "Node",
+    color: "border-border",
+    accent: "#8A8F98",
+  };
+  return (
+    <div className={cn("min-w-44 rounded-md border bg-surface-elevated p-3 shadow-glow", config.color)}>
+      <Handle type="target" position={Position.Left} className="!bg-primary" />
+      <div className="flex items-center gap-2">
+        <span className="h-2.5 w-2.5 rounded-full" style={{ backgroundColor: config.accent }} />
+        <span className="text-xs font-medium uppercase tracking-normal text-muted-foreground">{config.label}</span>
+      </div>
+      <div className="mt-2 truncate text-sm font-semibold">{String(data.name ?? data.label ?? "Node")}</div>
+      <NodeMeta data={data} />
+      <Handle type="source" position={Position.Right} className="!bg-primary" />
+    </div>
+  );
+}
+
+function NodeMeta({ data }: { data: NodeProps["data"] }) {
+  const config = (data.config ?? {}) as Record<string, unknown>;
+  const text = config.model ?? config.condition ?? config.temperature;
+  return text ? <p className="mt-1 max-w-40 truncate font-mono text-xs text-muted-foreground">{String(text)}</p> : null;
+}

+ 1 - 0
web/src/pages/workflow-editor/nodes/LLMNode.tsx

@@ -0,0 +1 @@
+export { DefaultNode as LLMNode } from "./DefaultNode";

+ 1 - 0
web/src/pages/workflow-editor/nodes/StartNode.tsx

@@ -0,0 +1 @@
+export { DefaultNode as StartNode } from "./DefaultNode";

+ 1 - 0
web/src/pages/workflow-editor/nodes/ToolNode.tsx

@@ -0,0 +1 @@
+export { DefaultNode as ToolNode } from "./DefaultNode";

+ 16 - 0
web/src/pages/workflow-editor/nodes/node-types.ts

@@ -0,0 +1,16 @@
+import type { NodeTypes } from "@xyflow/react";
+import { AnswerNode } from "./AnswerNode";
+import { ConditionNode } from "./ConditionNode";
+import { DefaultNode } from "./DefaultNode";
+import { LLMNode } from "./LLMNode";
+import { StartNode } from "./StartNode";
+import { ToolNode } from "./ToolNode";
+
+export const nodeTypes: NodeTypes = {
+  start: StartNode,
+  llm: LLMNode,
+  tool: ToolNode,
+  condition: ConditionNode,
+  answer: AnswerNode,
+  defaultNode: DefaultNode,
+};

+ 33 - 0
web/src/pages/workflow-editor/utils/dsl-to-flow.ts

@@ -0,0 +1,33 @@
+import dagre from "dagre";
+import type { Edge, Node } from "@xyflow/react";
+import type { WorkflowDSL } from "@/types";
+
+export function dslToFlow(dsl: WorkflowDSL): { nodes: Node[]; edges: Edge[] } {
+  const graph = new dagre.graphlib.Graph();
+  graph.setDefaultEdgeLabel(() => ({}));
+  graph.setGraph({ rankdir: "LR", nodesep: 80, ranksep: 120 });
+  dsl.nodes.forEach((node) => graph.setNode(node.id, { width: 190, height: 80 }));
+  dsl.edges.forEach((edge) => graph.setEdge(edge.source, edge.target));
+  dagre.layout(graph);
+  return {
+    nodes: dsl.nodes.map((node) => {
+      const position = graph.node(node.id) ?? { x: 0, y: 0 };
+      return {
+        id: node.id,
+        type: node.type in nodeTypesMap ? node.type : "defaultNode",
+        position: { x: position.x, y: position.y },
+        data: { ...node, label: node.name ?? node.id },
+      };
+    }),
+    edges: dsl.edges.map((edge, index) => ({
+      id: `${edge.source}-${edge.target}-${index}`,
+      source: edge.source,
+      target: edge.target,
+      label: edge.condition ?? undefined,
+      data: { ...edge },
+      animated: Boolean(edge.condition),
+    })),
+  };
+}
+
+const nodeTypesMap = { start: true, llm: true, tool: true, condition: true, answer: true };

Some files were not shown because too many files changed in this diff