From 53ae8d57e3e9f24410be59c7c9f02d07f6ac4e11 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?=
Date: Wed, 18 Feb 2026 18:24:41 +0000
Subject: [PATCH] fix: resolve all web app ESLint linter warnings (#246)
---
.../app/demo/messages/demo-messages-data.ts | 675 +++++++
apps/web/app/demo/messages/page.tsx | 733 +------
apps/web/app/page.tsx | 201 +-
apps/web/app/s/[sessionId]/page.tsx | 469 ++---
.../agents/[agentId]/agent-save-helpers.ts | 245 +++
.../app/settings/agents/[agentId]/page.tsx | 564 ++----
.../[agentId]/profile-mcp-config-card.tsx | 486 +++--
.../[profileId]/command-preview-card.tsx | 152 +-
.../[agentId]/use-profile-mcp-config.ts | 188 +-
apps/web/app/settings/agents/page.tsx | 124 +-
.../app/settings/environment/[id]/page.tsx | 385 ++--
.../web/app/settings/environment/new/page.tsx | 247 ++-
apps/web/app/settings/executor/[id]/page.tsx | 631 +++----
apps/web/app/settings/executor/new/page.tsx | 296 +--
apps/web/app/settings/executors/page.tsx | 240 ++-
.../workspace/workspace-edit-client.tsx | 452 +++--
.../workspace-repositories-client.tsx | 655 +++----
.../workspace/workspace-workflows-client.tsx | 619 +++---
.../workspace/workspaces-page-client.tsx | 124 +-
apps/web/app/stats/loading.tsx | 262 +--
apps/web/app/stats/stats-charts.tsx | 368 ++++
apps/web/app/stats/stats-page-client.tsx | 1217 ++----------
apps/web/app/stats/stats-sections.tsx | 453 +++++
apps/web/app/tasks/columns.tsx | 177 +-
apps/web/app/tasks/tasks-page-client.tsx | 231 +--
apps/web/components/command-panel.tsx | 411 ++--
.../components/diff/diff-viewer-resolver.tsx | 3 +-
apps/web/components/diff/diff-viewer.tsx | 487 +----
.../diff/use-diff-annotation-renderer.tsx | 96 +
apps/web/components/diff/use-diff-metadata.ts | 43 +
apps/web/components/diff/use-diff-options.tsx | 119 ++
.../components/diff/use-diff-viewer-state.ts | 215 +++
apps/web/components/diff/use-hunk-hover.ts | 78 +
.../codemirror/codemirror-code-editor.tsx | 673 +------
.../codemirror/use-codemirror-editor-state.ts | 399 ++++
.../editors/monaco/diff-viewer-helpers.ts | 82 +
.../editors/monaco/monaco-code-editor.tsx | 834 +-------
.../editors/monaco/monaco-diff-viewer.tsx | 430 +----
.../editors/monaco/monaco-editor-toolbar.tsx | 129 ++
.../editors/monaco/monaco-inline-diff.tsx | 67 +-
.../editors/monaco/use-diff-view-zones.ts | 22 +-
.../monaco/use-diff-viewer-comments.ts | 214 +++
.../editors/monaco/use-global-folding.ts | 33 +
.../editors/monaco/use-monaco-editor-lsp.ts | 193 ++
.../editors/monaco/use-monaco-editor-state.ts | 302 +++
.../tiptap/tiptap-comment-highlight.ts | 93 +-
.../editors/tiptap/tiptap-plan-editor.tsx | 246 +--
apps/web/components/global-commands.tsx | 125 +-
apps/web/components/kanban-board-grid.tsx | 369 ++--
apps/web/components/kanban-board.tsx | 391 ++--
apps/web/components/kanban-card.tsx | 236 ++-
.../components/kanban-display-dropdown.tsx | 159 +-
apps/web/components/kanban-with-preview.tsx | 358 ++--
.../components/kanban/graph2-step-node.tsx | 140 +-
.../kanban/graph2-task-pipeline.tsx | 115 +-
apps/web/components/kanban/kanban-header.tsx | 367 ++--
.../components/kanban/mobile-drop-targets.tsx | 10 +-
.../components/kanban/mobile-menu-sheet.tsx | 223 ++-
.../components/kanban/swimlane-container.tsx | 77 +-
.../kanban/swimlane-graph-content.tsx | 266 +--
.../kanban/swimlane-kanban-content.tsx | 144 +-
apps/web/components/onboarding-dialog.tsx | 207 +-
apps/web/components/review/review-dialog.tsx | 279 +--
.../components/review/review-diff-list.tsx | 231 +--
.../components/review/review-file-tree.tsx | 126 +-
apps/web/components/review/review-top-bar.tsx | 206 +-
apps/web/components/session-commands.tsx | 232 +--
apps/web/components/session-tabs.tsx | 164 +-
.../settings/agent-profile-page.tsx | 299 +--
apps/web/components/settings/editor-form.tsx | 223 +++
.../components/settings/editors-settings.tsx | 1224 +++++-------
.../components/settings/general-settings.tsx | 177 +-
.../components/settings/model-combobox.tsx | 381 ++--
.../settings/notifications-settings.tsx | 807 ++++----
.../settings/profile-form-fields.tsx | 180 +-
.../components/settings/prompts-settings.tsx | 519 +++--
.../components/settings/repository-card.tsx | 778 +++++---
.../settings/settings-app-sidebar.tsx | 391 ++--
.../settings/shell-settings-card.tsx | 148 +-
.../web/components/settings/workflow-card.tsx | 797 +++-----
.../workflow-pipeline-editor-helpers.tsx | 22 +
.../workflow-pipeline-editor-panels.tsx | 489 +++++
.../settings/workflow-pipeline-editor.tsx | 712 +------
apps/web/components/step-capability-icons.tsx | 127 +-
.../components/task-create-dialog-footer.tsx | 220 +++
.../task-create-dialog-form-body.tsx | 172 ++
.../components/task-create-dialog-options.tsx | 162 ++
.../task-create-dialog-selectors.tsx | 264 +++
.../components/task-create-dialog-submit.tsx | 389 ++++
apps/web/components/task-create-dialog.tsx | 1681 +++++------------
apps/web/components/task/browser-panel.tsx | 54 +-
.../components/task/changes-panel-dialogs.tsx | 305 +++
.../task/changes-panel-file-row.tsx | 183 ++
.../task/changes-panel-timeline.tsx | 283 +++
apps/web/components/task/changes-panel.tsx | 1254 ++++--------
.../task/chat/chat-input-container.tsx | 723 +++----
.../task/chat/chat-input-toolbar.tsx | 103 +-
.../task/chat/clarification-input-overlay.tsx | 216 +--
.../components/task/chat/context-popover.tsx | 215 +--
.../web/components/task/chat/mention-menu.tsx | 6 +-
.../task/chat/messages/agent-status.tsx | 127 +-
.../task/chat/messages/chat-message.tsx | 465 +++--
.../task/chat/messages/message-actions.tsx | 188 +-
.../messages/permission-request-message.tsx | 60 +-
.../messages/script-execution-message.tsx | 282 +--
.../task/chat/messages/status-message.tsx | 269 +--
.../task/chat/messages/tool-call-message.tsx | 248 ++-
.../task/chat/messages/tool-edit-message.tsx | 200 +-
.../chat/messages/tool-execute-message.tsx | 115 +-
.../task/chat/messages/tool-read-message.tsx | 81 +-
.../chat/messages/tool-search-message.tsx | 118 +-
.../chat/messages/tool-subagent-message.tsx | 124 +-
.../task/chat/messages/turn-group-message.tsx | 200 +-
.../task/chat/messages/use-expand-state.ts | 33 +
.../task/chat/queued-message-indicator.tsx | 187 +-
.../components/task/chat/rich-text-input.tsx | 210 +-
.../task/chat/tiptap-code-block-view.tsx | 46 +
.../components/task/chat/tiptap-helpers.ts | 185 ++
.../web/components/task/chat/tiptap-input.tsx | 793 ++------
.../task/chat/tiptap-suggestion.tsx | 16 +-
.../task/chat/use-chat-input-state.ts | 145 ++
.../components/task/chat/use-tiptap-editor.ts | 340 ++++
.../task/chat/virtualized-message-list.tsx | 408 ++--
.../components/task/comment-view-popover.tsx | 133 +-
.../components/task/commit-detail-panel.tsx | 119 +-
.../task/dockview-desktop-layout.tsx | 648 ++-----
.../task/dockview-header-actions.tsx | 331 ++++
.../task/editor-comment-popover.tsx | 181 +-
.../components/task/file-browser-parts.tsx | 490 +++++
apps/web/components/task/file-browser.tsx | 1148 ++++-------
.../web/components/task/file-editor-panel.tsx | 96 +-
.../task/mobile/session-mobile-layout.tsx | 156 +-
.../task/mobile/session-mobile-top-bar.tsx | 618 +++---
.../task/mobile/session-tablet-layout.tsx | 92 +-
.../mobile/session-task-switcher-sheet.tsx | 307 +--
apps/web/components/task/model-selector.tsx | 121 +-
.../components/task/passthrough-terminal.tsx | 511 ++---
.../task/plan-selection-popover.tsx | 101 +-
.../task/preview/preview-controls.tsx | 203 +-
.../components/task/preview/preview-panel.tsx | 423 ++---
.../web/components/task/sessions-dropdown.tsx | 325 ++--
apps/web/components/task/shell-terminal.tsx | 288 ++-
.../web/components/task/task-center-panel.tsx | 963 ++++------
.../components/task/task-changes-panel.tsx | 608 +++---
apps/web/components/task/task-chat-panel.tsx | 737 ++++----
apps/web/components/task/task-files-panel.tsx | 832 ++++----
apps/web/components/task/task-item.tsx | 91 +-
.../web/components/task/task-page-content.tsx | 519 ++---
apps/web/components/task/task-plan-panel.tsx | 664 +++----
apps/web/components/task/task-right-panel.tsx | 239 +--
.../components/task/task-session-sidebar.tsx | 275 +--
apps/web/components/task/task-top-bar.tsx | 457 ++---
apps/web/components/task/workflow-stepper.tsx | 249 ++-
.../components/ui/data-table-pagination.tsx | 155 +-
apps/web/components/ui/data-table.tsx | 76 +-
apps/web/components/ui/file-icon.tsx | 70 +-
apps/web/components/vcs-split-button.tsx | 269 +--
apps/web/components/vcs/vcs-dialogs.tsx | 321 ++--
.../domains/kanban/use-kanban-actions.ts | 126 +-
.../hooks/domains/kanban/use-swimlane-move.ts | 33 +-
.../domains/session/use-session-messages.ts | 186 +-
.../domains/session/use-session-resumption.ts | 231 +--
.../hooks/domains/session/use-terminals.ts | 350 ++--
.../web/hooks/domains/settings/use-editors.ts | 62 +-
apps/web/hooks/use-capture-keydown.ts | 39 +
apps/web/hooks/use-drag-and-drop.ts | 163 +-
apps/web/hooks/use-file-editors.ts | 337 ++--
apps/web/hooks/use-git-operations.ts | 45 +-
apps/web/hooks/use-inline-mention.ts | 344 ++--
apps/web/hooks/use-inline-slash.ts | 235 +--
apps/web/hooks/use-kanban-display-settings.ts | 50 +-
apps/web/hooks/use-kanban-layout.ts | 9 +-
apps/web/hooks/use-message-handler.ts | 135 +-
apps/web/hooks/use-preview-panel.ts | 317 ++--
apps/web/hooks/use-processed-messages.ts | 361 ++--
apps/web/hooks/use-session-file-reviews.ts | 148 +-
apps/web/hooks/use-session-layout-state.ts | 66 +-
apps/web/hooks/use-task-removal.ts | 81 +-
apps/web/hooks/use-user-display-settings.ts | 206 +-
apps/web/lib/api/domains/plan-api.ts | 10 +-
apps/web/lib/api/domains/queue-api.ts | 10 +-
apps/web/lib/diff/adapter.ts | 85 +-
apps/web/lib/executor-icons.ts | 2 +-
apps/web/lib/keyboard/utils.test.ts | 175 +-
apps/web/lib/keyboard/utils.ts | 87 +-
apps/web/lib/languages.ts | 91 +-
apps/web/lib/lsp/lsp-client-manager.ts | 472 +----
apps/web/lib/lsp/lsp-providers.ts | 373 ++++
apps/web/lib/preview-url-detector.test.ts | 280 ++-
apps/web/lib/preview-url-detector.ts | 71 +-
apps/web/lib/state/dockview-store.ts | 468 ++---
apps/web/lib/state/editor-resolver-store.ts | 21 +-
apps/web/lib/state/hydration/hydrator.ts | 195 +-
apps/web/lib/state/layout-store.ts | 258 +--
.../diff-comments/diff-comments-slice.ts | 123 +-
.../session-runtime/session-runtime-slice.ts | 237 +--
.../lib/state/slices/session/session-slice.ts | 347 ++--
apps/web/lib/types/http-agents.ts | 272 +++
apps/web/lib/types/http.ts | 271 +--
apps/web/lib/ui/state-icons.tsx | 32 +-
apps/web/lib/utils.test.ts | 10 +-
apps/web/lib/utils.ts | 7 +-
apps/web/lib/ws/client.ts | 79 +-
apps/web/lib/ws/handlers/agent-session.ts | 257 ++-
apps/web/lib/ws/handlers/agents.ts | 180 +-
apps/web/lib/ws/handlers/tasks.ts | 171 +-
206 files changed, 28728 insertions(+), 30328 deletions(-)
create mode 100644 apps/web/app/demo/messages/demo-messages-data.ts
create mode 100644 apps/web/app/settings/agents/[agentId]/agent-save-helpers.ts
create mode 100644 apps/web/app/stats/stats-charts.tsx
create mode 100644 apps/web/app/stats/stats-sections.tsx
create mode 100644 apps/web/components/diff/use-diff-annotation-renderer.tsx
create mode 100644 apps/web/components/diff/use-diff-metadata.ts
create mode 100644 apps/web/components/diff/use-diff-options.tsx
create mode 100644 apps/web/components/diff/use-diff-viewer-state.ts
create mode 100644 apps/web/components/diff/use-hunk-hover.ts
create mode 100644 apps/web/components/editors/codemirror/use-codemirror-editor-state.ts
create mode 100644 apps/web/components/editors/monaco/diff-viewer-helpers.ts
create mode 100644 apps/web/components/editors/monaco/monaco-editor-toolbar.tsx
create mode 100644 apps/web/components/editors/monaco/use-diff-viewer-comments.ts
create mode 100644 apps/web/components/editors/monaco/use-global-folding.ts
create mode 100644 apps/web/components/editors/monaco/use-monaco-editor-lsp.ts
create mode 100644 apps/web/components/editors/monaco/use-monaco-editor-state.ts
create mode 100644 apps/web/components/settings/editor-form.tsx
create mode 100644 apps/web/components/settings/workflow-pipeline-editor-helpers.tsx
create mode 100644 apps/web/components/settings/workflow-pipeline-editor-panels.tsx
create mode 100644 apps/web/components/task-create-dialog-footer.tsx
create mode 100644 apps/web/components/task-create-dialog-form-body.tsx
create mode 100644 apps/web/components/task-create-dialog-options.tsx
create mode 100644 apps/web/components/task-create-dialog-selectors.tsx
create mode 100644 apps/web/components/task-create-dialog-submit.tsx
create mode 100644 apps/web/components/task/changes-panel-dialogs.tsx
create mode 100644 apps/web/components/task/changes-panel-file-row.tsx
create mode 100644 apps/web/components/task/changes-panel-timeline.tsx
create mode 100644 apps/web/components/task/chat/messages/use-expand-state.ts
create mode 100644 apps/web/components/task/chat/tiptap-code-block-view.tsx
create mode 100644 apps/web/components/task/chat/tiptap-helpers.ts
create mode 100644 apps/web/components/task/chat/use-chat-input-state.ts
create mode 100644 apps/web/components/task/chat/use-tiptap-editor.ts
create mode 100644 apps/web/components/task/dockview-header-actions.tsx
create mode 100644 apps/web/components/task/file-browser-parts.tsx
create mode 100644 apps/web/hooks/use-capture-keydown.ts
create mode 100644 apps/web/lib/lsp/lsp-providers.ts
create mode 100644 apps/web/lib/types/http-agents.ts
diff --git a/apps/web/app/demo/messages/demo-messages-data.ts b/apps/web/app/demo/messages/demo-messages-data.ts
new file mode 100644
index 0000000000..a1b2132960
--- /dev/null
+++ b/apps/web/app/demo/messages/demo-messages-data.ts
@@ -0,0 +1,675 @@
+import type { Message } from '@/lib/types/http';
+
+const AUTH_CONFIG_PATH = 'src/auth/config.ts';
+const B = { session_id: 'demo' as const, task_id: 'demo-task' };
+
+export const dummyMessages: Message[] = [
+ // Task description
+ { ...B, id: 'task-description', content: '[TASK DESCRIPTION - AMBER BANNER] Implement a new feature to handle user authentication with OAuth 2.0', author_type: 'user', type: 'message', created_at: '2024-01-20T10:00:00Z' },
+
+ // User message - short
+ { ...B, id: 'user-1', content: '[USER MESSAGE - SHORT] Can you help me with this?', author_type: 'user', type: 'message', created_at: '2024-01-20T10:01:00Z' },
+
+ // User message - long
+ { ...B, id: 'user-2', content: '[USER MESSAGE - LONG] I need to implement a complex feature that involves multiple components. The requirements are:\n1. User authentication\n2. Role-based access control\n3. Session management\n4. Token refresh mechanism\n\nCan you help me design this system?', author_type: 'user', type: 'message', created_at: '2024-01-20T10:02:00Z' },
+
+ // Agent message - plain text
+ { ...B, id: 'agent-1', content: '[AGENT MESSAGE - PLAIN TEXT] This is a simple paragraph with no markdown formatting. Just plain text content.', author_type: 'agent', type: 'message', created_at: '2024-01-20T10:03:00Z' },
+
+ // Agent message - with markdown
+ {
+ id: 'agent-2',
+ ...B,
+ content: `**[MARKDOWN DEMO]** Here's a message showing all markdown elements:
+
+## Heading 2 (h2 element)
+
+This is a **paragraph (p element)** with some **bold text (strong element)** to test styling.
+
+### Heading 3 (h3 element)
+
+**Ordered List (ol element):**
+
+1. First item (li element in ol)
+2. Second item with **bold text**
+3. Third item with inline code: \`const example = true\`
+4. Fourth item (li element)
+
+**Unordered List (ul element):**
+
+- Bullet point one (li element in ul)
+- Bullet point two
+- Bullet point with \`inline code\` (code element)
+- Another bullet point
+
+**Inline code examples (code element, inline):**
+- Variable: \`const token = jwt.sign(payload)\`
+- Function call: \`getUserData(userId)\`
+- Path: \`/src/auth/config.ts\`
+
+**Code block example (code element, block):**
+
+\`\`\`typescript
+// This is a code block (pre > code)
+interface AuthConfig {
+ clientId: string;
+ clientSecret: string;
+ redirectUri: string;
+}
+
+function authenticate(config: AuthConfig) {
+ return jwt.sign(config);
+}
+\`\`\`
+
+**Another code block (JavaScript):**
+
+\`\`\`javascript
+// Testing syntax highlighting
+const data = { name: "test", value: 123 };
+console.log(data);
+\`\`\``,
+ author_type: 'agent',
+ type: 'message',
+ created_at: '2024-01-20T10:04:00Z',
+ },
+
+ // Thinking message
+ {
+ id: 'thinking-1',
+ ...B,
+ type: 'thinking',
+ content: '',
+ author_type: 'agent',
+ created_at: '2024-01-20T10:05:00Z',
+ metadata: {
+ thinking: '[THINKING MESSAGE] This shows the agent\'s internal reasoning process. I need to check the current authentication implementation before suggesting changes. Let me search for existing auth files and understand the current architecture.',
+ },
+ },
+
+ // Tool call - Read (complete)
+ {
+ id: 'tool-1',
+ ...B,
+ type: 'tool_call',
+ content: 'Read file',
+ author_type: 'agent',
+ created_at: '2024-01-20T10:06:00Z',
+ metadata: {
+ tool_call_id: 'tool-1',
+ tool_name: 'Read',
+ title: '[TOOL - READ - COMPLETE \u2713] Read src/auth/config.ts',
+ status: 'complete',
+ args: {
+ file_path: '/Users/demo/project/src/auth/config.ts',
+ line_count: 50,
+ },
+ result: 'File content loaded successfully (50 lines)',
+ },
+ },
+
+ // Tool call - Bash (running)
+ {
+ id: 'tool-2',
+ ...B,
+ type: 'tool_call',
+ content: 'Run command',
+ author_type: 'agent',
+ created_at: '2024-01-20T10:07:00Z',
+ metadata: {
+ tool_call_id: 'tool-2',
+ tool_name: 'Bash',
+ title: '[TOOL - BASH - RUNNING \u231B] npm test',
+ status: 'running',
+ args: {
+ command: 'npm test -- --coverage',
+ description: 'Run tests with coverage',
+ timeout: 30000,
+ },
+ },
+ },
+
+ // Tool call - Edit (error)
+ {
+ id: 'tool-3',
+ ...B,
+ type: 'tool_call',
+ content: 'Edit file',
+ author_type: 'agent',
+ created_at: '2024-01-20T10:08:00Z',
+ metadata: {
+ tool_call_id: 'tool-3',
+ tool_name: 'Edit',
+ title: '[TOOL - EDIT - ERROR \u2717] Edit src/components/Login.tsx',
+ status: 'error',
+ args: {
+ file_path: '/Users/demo/project/src/components/Login.tsx',
+ old_string: 'const handleLogin = () => {',
+ new_string: 'const handleLogin = async () => {',
+ },
+ result: 'Error: old_string not found in file. Make sure the old_string exactly matches the content in the file.',
+ },
+ },
+
+ // Tool call - Edit with complex metadata (complete)
+ {
+ id: 'tool-3b',
+ ...B,
+ type: 'tool_call',
+ content: 'Auggie Read `drizzle.config.ts`',
+ author_type: 'agent',
+ created_at: '2024-01-20T10:08:30Z',
+ metadata: {
+ tool_call_id: 'toolu_vrtx_01W75yrLi1WLgLCz8P3D8jjh',
+ tool_name: 'edit',
+ title: 'Edit drizzle.config.ts',
+ status: 'complete',
+ args: {
+ kind: 'edit',
+ locations: [
+ {
+ path: '/Users/cfl/.kandev/worktrees/1_c28de20d/drizzle.config.ts',
+ },
+ ],
+ path: '/Users/cfl/.kandev/worktrees/1_c28de20d/drizzle.config.ts',
+ raw_input: {
+ command: 'str_replace',
+ instruction_reminder: 'ALWAYS BREAK DOWN EDITS INTO SMALLER CHUNKS OF AT MOST 150 LINES EACH.',
+ new_str_1: 'export default defineConfig({\n schema: "./src/main/lib/db/schema/index.ts",\n dialect: "sqlite",\n})',
+ old_str_1: 'export default defineConfig({\n schema: "./src/main/lib/db/schema/index.ts",\n out: "./drizzle",\n dialect: "sqlite",\n})',
+ old_str_end_line_number_1: 7,
+ old_str_start_line_number_1: 3,
+ path: 'drizzle.config.ts',
+ },
+ },
+ },
+ },
+
+ // Tool call - Search (pending, with permission)
+ {
+ id: 'tool-4',
+ ...B,
+ type: 'tool_call',
+ content: 'Search files',
+ author_type: 'agent',
+ created_at: '2024-01-20T10:09:00Z',
+ metadata: {
+ tool_call_id: 'tool-4',
+ tool_name: 'Grep',
+ title: '[TOOL - GREP - PENDING \uD83D\uDD12] Search for "authentication" in codebase',
+ status: 'pending',
+ args: {
+ pattern: 'authentication',
+ path: '/Users/demo/project/src',
+ output_mode: 'files_with_matches',
+ },
+ },
+ },
+
+ // Permission request (for tool-4)
+ {
+ id: 'permission-1',
+ ...B,
+ type: 'permission_request',
+ content: '[PERMISSION REQUEST] Approve/Reject action',
+ author_type: 'user',
+ created_at: '2024-01-20T10:09:01Z',
+ metadata: {
+ pending_id: 'perm-1',
+ tool_call_id: 'tool-4',
+ action_type: 'file_search',
+ action_details: {
+ path: '/Users/demo/project/src',
+ command: 'grep -r "authentication"',
+ },
+ options: [
+ { option_id: 'allow-1', name: 'Allow Once', kind: 'allow_once' },
+ { option_id: 'allow-2', name: 'Allow Always', kind: 'allow_always' },
+ { option_id: 'reject-1', name: 'Reject', kind: 'reject_once' },
+ ],
+ status: 'pending',
+ },
+ },
+
+ // Status message - simple (separator style)
+ {
+ id: 'status-0',
+ ...B,
+ type: 'status',
+ content: 'Agent initialized',
+ author_type: 'agent',
+ created_at: '2024-01-20T10:09:30Z',
+ },
+
+ // Status message - success
+ {
+ id: 'status-1',
+ ...B,
+ type: 'status',
+ content: '[STATUS MESSAGE - SUCCESS \u2713] Tests passed successfully! All 42 tests completed.',
+ author_type: 'agent',
+ created_at: '2024-01-20T10:10:00Z',
+ metadata: {
+ level: 'success',
+ },
+ },
+
+ // Status message - error
+ {
+ id: 'status-2',
+ ...B,
+ type: 'error',
+ content: '[STATUS MESSAGE - ERROR \u2717] Build failed: TypeScript compilation error in auth.ts:45',
+ author_type: 'agent',
+ created_at: '2024-01-20T10:11:00Z',
+ metadata: {
+ level: 'error',
+ },
+ },
+
+ // Todo message
+ {
+ id: 'todo-1',
+ ...B,
+ type: 'todo',
+ content: '[TODO MESSAGE] Task list',
+ author_type: 'agent',
+ created_at: '2024-01-20T10:12:00Z',
+ metadata: {
+ todos: [
+ { id: '1', text: '[TODO ITEM - COMPLETED] Set up OAuth provider', completed: true },
+ { id: '2', text: '[TODO ITEM - PENDING] Implement token validation middleware', completed: false },
+ { id: '3', text: '[TODO ITEM - PENDING] Add refresh token endpoint', completed: false },
+ { id: '4', text: '[TODO ITEM - PENDING] Write integration tests', completed: false },
+ ],
+ },
+ },
+
+ // Script execution message - running
+ {
+ id: 'script-1',
+ ...B,
+ type: 'script_execution',
+ content: '[SCRIPT EXECUTION MESSAGE - RUNNING] Setting up environment...\nInstalling dependencies...',
+ author_type: 'agent',
+ created_at: '2024-01-20T10:13:00Z',
+ metadata: {
+ script_type: 'setup',
+ command: './scripts/setup-env.sh',
+ status: 'running',
+ },
+ },
+
+ // Script execution message - completed
+ {
+ id: 'script-2',
+ ...B,
+ type: 'script_execution',
+ content: '[SCRIPT EXECUTION MESSAGE - COMPLETED] Build successful!\nAll tests passed.\nDeployment ready.',
+ author_type: 'agent',
+ created_at: '2024-01-20T10:14:00Z',
+ metadata: {
+ script_type: 'cleanup',
+ command: 'npm run build',
+ status: 'exited',
+ exit_code: 0,
+ started_at: '2024-01-20T10:13:00Z',
+ completed_at: '2024-01-20T10:14:00Z',
+ },
+ },
+
+ // Agent message - mixed content
+ {
+ id: 'agent-3',
+ ...B,
+ content: `**[MIXED CONTENT DEMO]** Testing nested and mixed elements:
+
+#### Heading 4 (h4 element)
+
+Regular paragraph with inline elements: \`code\`, **bold**, and more text.
+
+**Nested list structure:**
+
+1. **First level item** (ol > li with strong)
+ - Nested bullet (this should indent)
+ - Another nested item
+2. Second level item with \`inline code\`
+3. Third item
+
+**Technical details:**
+
+- File path: \`/src/components/Auth.tsx\`
+- Function: \`authenticateUser(credentials)\`
+- Status: **Active** (strong element)
+
+\`\`\`bash
+# Code block with bash syntax
+npm install
+npm run dev
+\`\`\`
+
+Final paragraph (p element) to wrap up.`,
+ author_type: 'agent',
+ type: 'message',
+ created_at: '2024-01-20T10:14:00Z',
+ },
+
+ // Agent message with rich blocks - thinking
+ {
+ id: 'agent-rich-1',
+ ...B,
+ content: `**[RICH BLOCK - THINKING]** Here's my analysis of the authentication system.`,
+ author_type: 'agent',
+ type: 'message',
+ created_at: '2024-01-20T10:15:00Z',
+ metadata: {
+ thinking: 'I need to carefully consider the security implications here. The current implementation uses JWT tokens, but we should add refresh token rotation to prevent token theft. We also need to implement proper CSRF protection for the authentication endpoints.',
+ },
+ },
+
+ // Agent message with rich blocks - todos
+ {
+ id: 'agent-rich-2',
+ ...B,
+ content: `**[RICH BLOCK - TODOS]** Here are the next steps we need to take.`,
+ author_type: 'agent',
+ type: 'message',
+ created_at: '2024-01-20T10:16:00Z',
+ metadata: {
+ todos: [
+ { text: 'Update the authentication middleware', done: true },
+ { text: 'Add unit tests for token validation', done: false },
+ { text: 'Implement rate limiting on login endpoint', done: false },
+ 'Update documentation for new OAuth flow',
+ ],
+ },
+ },
+
+ // Agent message with rich blocks - diff payload (structured)
+ {
+ id: 'agent-rich-3',
+ ...B,
+ content: `**[RICH BLOCK - DIFF PAYLOAD]** I've made the following changes to the authentication config:`,
+ author_type: 'agent',
+ type: 'message',
+ created_at: '2024-01-20T10:17:00Z',
+ metadata: {
+ diff: {
+ oldFile: { fileName: AUTH_CONFIG_PATH, fileLang: 'typescript' },
+ newFile: { fileName: AUTH_CONFIG_PATH, fileLang: 'typescript' },
+ hunks: [
+ `diff --git a/src/auth/config.ts b/src/auth/config.ts
+index 1234567..abcdefg 100644
+--- a/src/auth/config.ts
++++ b/src/auth/config.ts
+@@ -1,7 +1,10 @@
+ export const authConfig = {
+ clientId: process.env.OAUTH_CLIENT_ID,
+- clientSecret: process.env.OAUTH_CLIENT_SECRET,
++ clientSecret: process.env.OAUTH_CLIENT_SECRET || '',
+ redirectUri: process.env.OAUTH_REDIRECT_URI,
++ tokenExpiry: '1h',
++ refreshTokenExpiry: '7d',
++ enableCsrf: true,
+ };
+
+ export function validateToken(token: string) {`,
+ ],
+ },
+ },
+ },
+
+ // Agent message with rich blocks - diff string (plain text)
+ {
+ id: 'agent-rich-4',
+ ...B,
+ content: `**[RICH BLOCK - DIFF STRING]** Here's the diff for the middleware changes:`,
+ author_type: 'agent',
+ type: 'message',
+ created_at: '2024-01-20T10:18:00Z',
+ metadata: {
+ diff: `--- a/src/middleware/auth.ts
++++ b/src/middleware/auth.ts
+@@ -12,6 +12,11 @@ export async function authMiddleware(req: Request, res: Response, next: NextFun
+ const token = req.headers.authorization?.split(' ')[1];
+
+ if (!token) {
+- return res.status(401).json({ error: 'No token provided' });
++ return res.status(401).json({
++ error: 'No token provided',
++ code: 'AUTH_TOKEN_MISSING',
++ timestamp: new Date().toISOString()
++ });
+ }
+
+ try {`,
+ },
+ },
+
+ // Agent message with multiple rich blocks
+ {
+ id: 'agent-rich-5',
+ ...B,
+ content: `**[RICH BLOCK - ALL TYPES]** Here's my complete analysis with all the changes:`,
+ author_type: 'agent',
+ type: 'message',
+ created_at: '2024-01-20T10:19:00Z',
+ metadata: {
+ thinking: 'After reviewing the code, I found several areas that need improvement. The error handling is inconsistent and we need better type safety.',
+ todos: [
+ { text: 'Add TypeScript strict mode', done: true },
+ { text: 'Implement error boundaries', done: false },
+ { text: 'Add logging middleware', done: false },
+ ],
+ diff: {
+ oldFile: { fileName: 'src/types/auth.ts', fileLang: 'typescript' },
+ newFile: { fileName: 'src/types/auth.ts', fileLang: 'typescript' },
+ hunks: [
+ `diff --git a/src/types/auth.ts b/src/types/auth.ts
+index 9876543..fedcba9 100644
+--- a/src/types/auth.ts
++++ b/src/types/auth.ts
+@@ -1,4 +1,12 @@
+-export type AuthToken = string;
++export interface AuthToken {
++ value: string;
++ expiresAt: Date;
++ type: 'access' | 'refresh';
++}
+
+-export type User = {
++export interface User {
+ id: string;
+ email: string;
++ role: 'admin' | 'user';
++ createdAt: Date;
++}`,
+ ],
+ },
+ },
+ },
+
+ // === Normalized Tool Messages (tool_edit, tool_read, tool_execute) ===
+
+ // Tool Edit - complete with diff
+ {
+ id: 'tool-edit-1',
+ ...B,
+ type: 'tool_edit',
+ content: 'Edit src/auth/config.ts',
+ author_type: 'agent',
+ created_at: '2024-01-20T10:20:00Z',
+ metadata: {
+ tool_call_id: 'tool-edit-1',
+ status: 'complete',
+ file_path: AUTH_CONFIG_PATH,
+ old_content: " clientSecret: process.env.OAUTH_CLIENT_SECRET,",
+ new_content: " clientSecret: process.env.OAUTH_CLIENT_SECRET || '',\n tokenExpiry: '1h',",
+ diff: {
+ hunks: [
+ `diff --git a/src/auth/config.ts b/src/auth/config.ts
+index 1234567..abcdefg 100644
+--- a/src/auth/config.ts
++++ b/src/auth/config.ts
+@@ -2,3 +2,4 @@
+ clientId: process.env.OAUTH_CLIENT_ID,
+- clientSecret: process.env.OAUTH_CLIENT_SECRET,
++ clientSecret: process.env.OAUTH_CLIENT_SECRET || '',
++ tokenExpiry: '1h',
+ redirectUri: process.env.OAUTH_REDIRECT_URI,`,
+ ],
+ oldFile: { fileName: AUTH_CONFIG_PATH, fileLang: 'typescript' },
+ newFile: { fileName: AUTH_CONFIG_PATH, fileLang: 'typescript' },
+ },
+ result: 'File edited successfully.',
+ },
+ },
+
+ // Tool Edit - running (expanded)
+ {
+ id: 'tool-edit-2',
+ ...B,
+ type: 'tool_edit',
+ content: 'Edit src/middleware/auth.ts',
+ author_type: 'agent',
+ created_at: '2024-01-20T10:20:30Z',
+ metadata: {
+ tool_call_id: 'tool-edit-2',
+ status: 'running',
+ file_path: 'src/middleware/auth.ts',
+ old_content: "return res.status(401).json({ error: 'No token' });",
+ new_content: "return res.status(401).json({\n error: 'No token',\n code: 'AUTH_MISSING'\n});",
+ diff: {
+ hunks: [
+ `diff --git a/src/middleware/auth.ts b/src/middleware/auth.ts
+--- a/src/middleware/auth.ts
++++ b/src/middleware/auth.ts
+@@ -12,1 +12,4 @@
+-return res.status(401).json({ error: 'No token' });
++return res.status(401).json({
++ error: 'No token',
++ code: 'AUTH_MISSING'
++});`,
+ ],
+ oldFile: { fileName: 'src/middleware/auth.ts', fileLang: 'typescript' },
+ newFile: { fileName: 'src/middleware/auth.ts', fileLang: 'typescript' },
+ },
+ },
+ },
+
+ // Tool Edit - error
+ {
+ id: 'tool-edit-3',
+ ...B,
+ type: 'tool_edit',
+ content: 'Edit src/utils/format.ts',
+ author_type: 'agent',
+ created_at: '2024-01-20T10:21:00Z',
+ metadata: {
+ tool_call_id: 'tool-edit-3',
+ status: 'error',
+ file_path: 'src/utils/format.ts',
+ result: 'Error: old_string not found in file.',
+ },
+ },
+
+ // Tool Read - complete
+ {
+ id: 'tool-read-1',
+ ...B,
+ type: 'tool_read',
+ content: 'Read src/auth/config.ts',
+ author_type: 'agent',
+ created_at: '2024-01-20T10:22:00Z',
+ metadata: {
+ tool_call_id: 'tool-read-1',
+ status: 'complete',
+ file_path: AUTH_CONFIG_PATH,
+ line_count: 50,
+ },
+ },
+
+ // Tool Read - running
+ {
+ id: 'tool-read-2',
+ ...B,
+ type: 'tool_read',
+ content: 'Read package.json',
+ author_type: 'agent',
+ created_at: '2024-01-20T10:22:30Z',
+ metadata: {
+ tool_call_id: 'tool-read-2',
+ status: 'running',
+ file_path: 'package.json',
+ },
+ },
+
+ // Tool Read - error
+ {
+ id: 'tool-read-3',
+ ...B,
+ type: 'tool_read',
+ content: 'Read /nonexistent/file.ts',
+ author_type: 'agent',
+ created_at: '2024-01-20T10:22:45Z',
+ metadata: {
+ tool_call_id: 'tool-read-3',
+ status: 'error',
+ file_path: '/nonexistent/file.ts',
+ },
+ },
+
+ // Tool Execute - complete (success)
+ {
+ id: 'tool-exec-1',
+ ...B,
+ type: 'tool_execute',
+ content: 'Run `npm test -- --coverage`',
+ author_type: 'agent',
+ created_at: '2024-01-20T10:23:00Z',
+ metadata: {
+ tool_call_id: 'tool-exec-1',
+ status: 'complete',
+ command: 'npm test -- --coverage',
+ cwd: '/workspace',
+ exit_code: 0,
+ stdout: 'PASS src/auth/config.test.ts\nPASS src/middleware/auth.test.ts\n\nTest Suites: 2 passed, 2 total\nTests: 12 passed, 12 total\nCoverage: 87.5%',
+ result: 'Tests passed successfully.',
+ },
+ },
+
+ // Tool Execute - running
+ {
+ id: 'tool-exec-2',
+ ...B,
+ type: 'tool_execute',
+ content: 'Run `go build ./...`',
+ author_type: 'agent',
+ created_at: '2024-01-20T10:23:30Z',
+ metadata: {
+ tool_call_id: 'tool-exec-2',
+ status: 'running',
+ command: 'go build ./...',
+ cwd: '/workspace',
+ },
+ },
+
+ // Tool Execute - error with stderr
+ {
+ id: 'tool-exec-3',
+ ...B,
+ type: 'tool_execute',
+ content: 'Run `npm run lint`',
+ author_type: 'agent',
+ created_at: '2024-01-20T10:24:00Z',
+ metadata: {
+ tool_call_id: 'tool-exec-3',
+ status: 'complete',
+ command: 'npm run lint',
+ exit_code: 1,
+ stdout: '',
+ stderr: 'Error: src/auth/config.ts(5,1): Missing semicolon.\nError: src/middleware/auth.ts(12,3): Unexpected token.',
+ result: 'Lint failed with 2 errors.',
+ },
+ },
+];
diff --git a/apps/web/app/demo/messages/page.tsx b/apps/web/app/demo/messages/page.tsx
index 9c3db752bd..1647269ca5 100644
--- a/apps/web/app/demo/messages/page.tsx
+++ b/apps/web/app/demo/messages/page.tsx
@@ -2,738 +2,7 @@
import type { Message } from '@/lib/types/http';
import { MessageRenderer } from '@/components/task/chat/message-renderer';
-
-const dummyMessages: Message[] = [
- // Task description
- {
- id: 'task-description',
- session_id: 'demo',
- task_id: 'demo-task',
- content: '[TASK DESCRIPTION - AMBER BANNER] Implement a new feature to handle user authentication with OAuth 2.0',
- author_type: 'user',
- type: 'message',
- created_at: '2024-01-20T10:00:00Z',
- },
-
- // User message - short
- {
- id: 'user-1',
- session_id: 'demo',
- task_id: 'demo-task',
- content: '[USER MESSAGE - SHORT] Can you help me with this?',
- author_type: 'user',
- type: 'message',
- created_at: '2024-01-20T10:01:00Z',
- },
-
- // User message - long
- {
- id: 'user-2',
- session_id: 'demo',
- task_id: 'demo-task',
- content: '[USER MESSAGE - LONG] I need to implement a complex feature that involves multiple components. The requirements are:\n1. User authentication\n2. Role-based access control\n3. Session management\n4. Token refresh mechanism\n\nCan you help me design this system?',
- author_type: 'user',
- type: 'message',
- created_at: '2024-01-20T10:02:00Z',
- },
-
- // Agent message - plain text
- {
- id: 'agent-1',
- session_id: 'demo',
- task_id: 'demo-task',
- content: '[AGENT MESSAGE - PLAIN TEXT] This is a simple paragraph with no markdown formatting. Just plain text content.',
- author_type: 'agent',
- type: 'message',
- created_at: '2024-01-20T10:03:00Z',
- },
-
- // Agent message - with markdown
- {
- id: 'agent-2',
- session_id: 'demo',
- task_id: 'demo-task',
- content: `**[MARKDOWN DEMO]** Here's a message showing all markdown elements:
-
-## Heading 2 (h2 element)
-
-This is a **paragraph (p element)** with some **bold text (strong element)** to test styling.
-
-### Heading 3 (h3 element)
-
-**Ordered List (ol element):**
-
-1. First item (li element in ol)
-2. Second item with **bold text**
-3. Third item with inline code: \`const example = true\`
-4. Fourth item (li element)
-
-**Unordered List (ul element):**
-
-- Bullet point one (li element in ul)
-- Bullet point two
-- Bullet point with \`inline code\` (code element)
-- Another bullet point
-
-**Inline code examples (code element, inline):**
-- Variable: \`const token = jwt.sign(payload)\`
-- Function call: \`getUserData(userId)\`
-- Path: \`/src/auth/config.ts\`
-
-**Code block example (code element, block):**
-
-\`\`\`typescript
-// This is a code block (pre > code)
-interface AuthConfig {
- clientId: string;
- clientSecret: string;
- redirectUri: string;
-}
-
-function authenticate(config: AuthConfig) {
- return jwt.sign(config);
-}
-\`\`\`
-
-**Another code block (JavaScript):**
-
-\`\`\`javascript
-// Testing syntax highlighting
-const data = { name: "test", value: 123 };
-console.log(data);
-\`\`\``,
- author_type: 'agent',
- type: 'message',
- created_at: '2024-01-20T10:04:00Z',
- },
-
- // Thinking message
- {
- id: 'thinking-1',
- session_id: 'demo',
- task_id: 'demo-task',
- type: 'thinking',
- content: '',
- author_type: 'agent',
- created_at: '2024-01-20T10:05:00Z',
- metadata: {
- thinking: '[THINKING MESSAGE] This shows the agent\'s internal reasoning process. I need to check the current authentication implementation before suggesting changes. Let me search for existing auth files and understand the current architecture.',
- },
- },
-
- // Tool call - Read (complete)
- {
- id: 'tool-1',
- session_id: 'demo',
- task_id: 'demo-task',
- type: 'tool_call',
- content: 'Read file',
- author_type: 'agent',
- created_at: '2024-01-20T10:06:00Z',
- metadata: {
- tool_call_id: 'tool-1',
- tool_name: 'Read',
- title: '[TOOL - READ - COMPLETE ✓] Read src/auth/config.ts',
- status: 'complete',
- args: {
- file_path: '/Users/demo/project/src/auth/config.ts',
- line_count: 50,
- },
- result: 'File content loaded successfully (50 lines)',
- },
- },
-
- // Tool call - Bash (running)
- {
- id: 'tool-2',
- session_id: 'demo',
- task_id: 'demo-task',
- type: 'tool_call',
- content: 'Run command',
- author_type: 'agent',
- created_at: '2024-01-20T10:07:00Z',
- metadata: {
- tool_call_id: 'tool-2',
- tool_name: 'Bash',
- title: '[TOOL - BASH - RUNNING ⏳] npm test',
- status: 'running',
- args: {
- command: 'npm test -- --coverage',
- description: 'Run tests with coverage',
- timeout: 30000,
- },
- },
- },
-
- // Tool call - Edit (error)
- {
- id: 'tool-3',
- session_id: 'demo',
- task_id: 'demo-task',
- type: 'tool_call',
- content: 'Edit file',
- author_type: 'agent',
- created_at: '2024-01-20T10:08:00Z',
- metadata: {
- tool_call_id: 'tool-3',
- tool_name: 'Edit',
- title: '[TOOL - EDIT - ERROR ✗] Edit src/components/Login.tsx',
- status: 'error',
- args: {
- file_path: '/Users/demo/project/src/components/Login.tsx',
- old_string: 'const handleLogin = () => {',
- new_string: 'const handleLogin = async () => {',
- },
- result: 'Error: old_string not found in file. Make sure the old_string exactly matches the content in the file.',
- },
- },
-
- // Tool call - Edit with complex metadata (complete)
- {
- id: 'tool-3b',
- session_id: 'demo',
- task_id: 'demo-task',
- type: 'tool_call',
- content: 'Auggie Read `drizzle.config.ts`',
- author_type: 'agent',
- created_at: '2024-01-20T10:08:30Z',
- metadata: {
- tool_call_id: 'toolu_vrtx_01W75yrLi1WLgLCz8P3D8jjh',
- tool_name: 'edit',
- title: 'Edit drizzle.config.ts',
- status: 'complete',
- args: {
- kind: 'edit',
- locations: [
- {
- path: '/Users/cfl/.kandev/worktrees/1_c28de20d/drizzle.config.ts',
- },
- ],
- path: '/Users/cfl/.kandev/worktrees/1_c28de20d/drizzle.config.ts',
- raw_input: {
- command: 'str_replace',
- instruction_reminder: 'ALWAYS BREAK DOWN EDITS INTO SMALLER CHUNKS OF AT MOST 150 LINES EACH.',
- new_str_1: 'export default defineConfig({\n schema: "./src/main/lib/db/schema/index.ts",\n dialect: "sqlite",\n})',
- old_str_1: 'export default defineConfig({\n schema: "./src/main/lib/db/schema/index.ts",\n out: "./drizzle",\n dialect: "sqlite",\n})',
- old_str_end_line_number_1: 7,
- old_str_start_line_number_1: 3,
- path: 'drizzle.config.ts',
- },
- },
- },
- },
-
- // Tool call - Search (pending, with permission)
- {
- id: 'tool-4',
- session_id: 'demo',
- task_id: 'demo-task',
- type: 'tool_call',
- content: 'Search files',
- author_type: 'agent',
- created_at: '2024-01-20T10:09:00Z',
- metadata: {
- tool_call_id: 'tool-4',
- tool_name: 'Grep',
- title: '[TOOL - GREP - PENDING 🔒] Search for "authentication" in codebase',
- status: 'pending',
- args: {
- pattern: 'authentication',
- path: '/Users/demo/project/src',
- output_mode: 'files_with_matches',
- },
- },
- },
-
- // Permission request (for tool-4)
- {
- id: 'permission-1',
- session_id: 'demo',
- task_id: 'demo-task',
- type: 'permission_request',
- content: '[PERMISSION REQUEST] Approve/Reject action',
- author_type: 'user',
- created_at: '2024-01-20T10:09:01Z',
- metadata: {
- pending_id: 'perm-1',
- tool_call_id: 'tool-4',
- action_type: 'file_search',
- action_details: {
- path: '/Users/demo/project/src',
- command: 'grep -r "authentication"',
- },
- options: [
- { option_id: 'allow-1', name: 'Allow Once', kind: 'allow_once' },
- { option_id: 'allow-2', name: 'Allow Always', kind: 'allow_always' },
- { option_id: 'reject-1', name: 'Reject', kind: 'reject_once' },
- ],
- status: 'pending',
- },
- },
-
- // Status message - simple (separator style)
- {
- id: 'status-0',
- session_id: 'demo',
- task_id: 'demo-task',
- type: 'status',
- content: 'Agent initialized',
- author_type: 'agent',
- created_at: '2024-01-20T10:09:30Z',
- },
-
- // Status message - success
- {
- id: 'status-1',
- session_id: 'demo',
- task_id: 'demo-task',
- type: 'status',
- content: '[STATUS MESSAGE - SUCCESS ✓] Tests passed successfully! All 42 tests completed.',
- author_type: 'agent',
- created_at: '2024-01-20T10:10:00Z',
- metadata: {
- level: 'success',
- },
- },
-
- // Status message - error
- {
- id: 'status-2',
- session_id: 'demo',
- task_id: 'demo-task',
- type: 'error',
- content: '[STATUS MESSAGE - ERROR ✗] Build failed: TypeScript compilation error in auth.ts:45',
- author_type: 'agent',
- created_at: '2024-01-20T10:11:00Z',
- metadata: {
- level: 'error',
- },
- },
-
- // Todo message
- {
- id: 'todo-1',
- session_id: 'demo',
- task_id: 'demo-task',
- type: 'todo',
- content: '[TODO MESSAGE] Task list',
- author_type: 'agent',
- created_at: '2024-01-20T10:12:00Z',
- metadata: {
- todos: [
- { id: '1', text: '[TODO ITEM - COMPLETED] Set up OAuth provider', completed: true },
- { id: '2', text: '[TODO ITEM - PENDING] Implement token validation middleware', completed: false },
- { id: '3', text: '[TODO ITEM - PENDING] Add refresh token endpoint', completed: false },
- { id: '4', text: '[TODO ITEM - PENDING] Write integration tests', completed: false },
- ],
- },
- },
-
- // Script execution message - running
- {
- id: 'script-1',
- session_id: 'demo',
- task_id: 'demo-task',
- type: 'script_execution',
- content: '[SCRIPT EXECUTION MESSAGE - RUNNING] Setting up environment...\nInstalling dependencies...',
- author_type: 'agent',
- created_at: '2024-01-20T10:13:00Z',
- metadata: {
- script_type: 'setup',
- command: './scripts/setup-env.sh',
- status: 'running',
- },
- },
-
- // Script execution message - completed
- {
- id: 'script-2',
- session_id: 'demo',
- task_id: 'demo-task',
- type: 'script_execution',
- content: '[SCRIPT EXECUTION MESSAGE - COMPLETED] Build successful!\nAll tests passed.\nDeployment ready.',
- author_type: 'agent',
- created_at: '2024-01-20T10:14:00Z',
- metadata: {
- script_type: 'cleanup',
- command: 'npm run build',
- status: 'exited',
- exit_code: 0,
- started_at: '2024-01-20T10:13:00Z',
- completed_at: '2024-01-20T10:14:00Z',
- },
- },
-
- // Agent message - mixed content
- {
- id: 'agent-3',
- session_id: 'demo',
- task_id: 'demo-task',
- content: `**[MIXED CONTENT DEMO]** Testing nested and mixed elements:
-
-#### Heading 4 (h4 element)
-
-Regular paragraph with inline elements: \`code\`, **bold**, and more text.
-
-**Nested list structure:**
-
-1. **First level item** (ol > li with strong)
- - Nested bullet (this should indent)
- - Another nested item
-2. Second level item with \`inline code\`
-3. Third item
-
-**Technical details:**
-
-- File path: \`/src/components/Auth.tsx\`
-- Function: \`authenticateUser(credentials)\`
-- Status: **Active** (strong element)
-
-\`\`\`bash
-# Code block with bash syntax
-npm install
-npm run dev
-\`\`\`
-
-Final paragraph (p element) to wrap up.`,
- author_type: 'agent',
- type: 'message',
- created_at: '2024-01-20T10:14:00Z',
- },
-
- // Agent message with rich blocks - thinking
- {
- id: 'agent-rich-1',
- session_id: 'demo',
- task_id: 'demo-task',
- content: `**[RICH BLOCK - THINKING]** Here's my analysis of the authentication system.`,
- author_type: 'agent',
- type: 'message',
- created_at: '2024-01-20T10:15:00Z',
- metadata: {
- thinking: 'I need to carefully consider the security implications here. The current implementation uses JWT tokens, but we should add refresh token rotation to prevent token theft. We also need to implement proper CSRF protection for the authentication endpoints.',
- },
- },
-
- // Agent message with rich blocks - todos
- {
- id: 'agent-rich-2',
- session_id: 'demo',
- task_id: 'demo-task',
- content: `**[RICH BLOCK - TODOS]** Here are the next steps we need to take.`,
- author_type: 'agent',
- type: 'message',
- created_at: '2024-01-20T10:16:00Z',
- metadata: {
- todos: [
- { text: 'Update the authentication middleware', done: true },
- { text: 'Add unit tests for token validation', done: false },
- { text: 'Implement rate limiting on login endpoint', done: false },
- 'Update documentation for new OAuth flow',
- ],
- },
- },
-
- // Agent message with rich blocks - diff payload (structured)
- {
- id: 'agent-rich-3',
- session_id: 'demo',
- task_id: 'demo-task',
- content: `**[RICH BLOCK - DIFF PAYLOAD]** I've made the following changes to the authentication config:`,
- author_type: 'agent',
- type: 'message',
- created_at: '2024-01-20T10:17:00Z',
- metadata: {
- diff: {
- oldFile: { fileName: 'src/auth/config.ts', fileLang: 'typescript' },
- newFile: { fileName: 'src/auth/config.ts', fileLang: 'typescript' },
- hunks: [
- `diff --git a/src/auth/config.ts b/src/auth/config.ts
-index 1234567..abcdefg 100644
---- a/src/auth/config.ts
-+++ b/src/auth/config.ts
-@@ -1,7 +1,10 @@
- export const authConfig = {
- clientId: process.env.OAUTH_CLIENT_ID,
-- clientSecret: process.env.OAUTH_CLIENT_SECRET,
-+ clientSecret: process.env.OAUTH_CLIENT_SECRET || '',
- redirectUri: process.env.OAUTH_REDIRECT_URI,
-+ tokenExpiry: '1h',
-+ refreshTokenExpiry: '7d',
-+ enableCsrf: true,
- };
-
- export function validateToken(token: string) {`,
- ],
- },
- },
- },
-
- // Agent message with rich blocks - diff string (plain text)
- {
- id: 'agent-rich-4',
- session_id: 'demo',
- task_id: 'demo-task',
- content: `**[RICH BLOCK - DIFF STRING]** Here's the diff for the middleware changes:`,
- author_type: 'agent',
- type: 'message',
- created_at: '2024-01-20T10:18:00Z',
- metadata: {
- diff: `--- a/src/middleware/auth.ts
-+++ b/src/middleware/auth.ts
-@@ -12,6 +12,11 @@ export async function authMiddleware(req: Request, res: Response, next: NextFun
- const token = req.headers.authorization?.split(' ')[1];
-
- if (!token) {
-- return res.status(401).json({ error: 'No token provided' });
-+ return res.status(401).json({
-+ error: 'No token provided',
-+ code: 'AUTH_TOKEN_MISSING',
-+ timestamp: new Date().toISOString()
-+ });
- }
-
- try {`,
- },
- },
-
- // Agent message with multiple rich blocks
- {
- id: 'agent-rich-5',
- session_id: 'demo',
- task_id: 'demo-task',
- content: `**[RICH BLOCK - ALL TYPES]** Here's my complete analysis with all the changes:`,
- author_type: 'agent',
- type: 'message',
- created_at: '2024-01-20T10:19:00Z',
- metadata: {
- thinking: 'After reviewing the code, I found several areas that need improvement. The error handling is inconsistent and we need better type safety.',
- todos: [
- { text: 'Add TypeScript strict mode', done: true },
- { text: 'Implement error boundaries', done: false },
- { text: 'Add logging middleware', done: false },
- ],
- diff: {
- oldFile: { fileName: 'src/types/auth.ts', fileLang: 'typescript' },
- newFile: { fileName: 'src/types/auth.ts', fileLang: 'typescript' },
- hunks: [
- `diff --git a/src/types/auth.ts b/src/types/auth.ts
-index 9876543..fedcba9 100644
---- a/src/types/auth.ts
-+++ b/src/types/auth.ts
-@@ -1,4 +1,12 @@
--export type AuthToken = string;
-+export interface AuthToken {
-+ value: string;
-+ expiresAt: Date;
-+ type: 'access' | 'refresh';
-+}
-
--export type User = {
-+export interface User {
- id: string;
- email: string;
-+ role: 'admin' | 'user';
-+ createdAt: Date;
-+}`,
- ],
- },
- },
- },
-
- // === Normalized Tool Messages (tool_edit, tool_read, tool_execute) ===
-
- // Tool Edit - complete with diff
- {
- id: 'tool-edit-1',
- session_id: 'demo',
- task_id: 'demo-task',
- type: 'tool_edit',
- content: 'Edit src/auth/config.ts',
- author_type: 'agent',
- created_at: '2024-01-20T10:20:00Z',
- metadata: {
- tool_call_id: 'tool-edit-1',
- status: 'complete',
- file_path: 'src/auth/config.ts',
- old_content: " clientSecret: process.env.OAUTH_CLIENT_SECRET,",
- new_content: " clientSecret: process.env.OAUTH_CLIENT_SECRET || '',\n tokenExpiry: '1h',",
- diff: {
- hunks: [
- `diff --git a/src/auth/config.ts b/src/auth/config.ts
-index 1234567..abcdefg 100644
---- a/src/auth/config.ts
-+++ b/src/auth/config.ts
-@@ -2,3 +2,4 @@
- clientId: process.env.OAUTH_CLIENT_ID,
-- clientSecret: process.env.OAUTH_CLIENT_SECRET,
-+ clientSecret: process.env.OAUTH_CLIENT_SECRET || '',
-+ tokenExpiry: '1h',
- redirectUri: process.env.OAUTH_REDIRECT_URI,`,
- ],
- oldFile: { fileName: 'src/auth/config.ts', fileLang: 'typescript' },
- newFile: { fileName: 'src/auth/config.ts', fileLang: 'typescript' },
- },
- result: 'File edited successfully.',
- },
- },
-
- // Tool Edit - running (expanded)
- {
- id: 'tool-edit-2',
- session_id: 'demo',
- task_id: 'demo-task',
- type: 'tool_edit',
- content: 'Edit src/middleware/auth.ts',
- author_type: 'agent',
- created_at: '2024-01-20T10:20:30Z',
- metadata: {
- tool_call_id: 'tool-edit-2',
- status: 'running',
- file_path: 'src/middleware/auth.ts',
- old_content: "return res.status(401).json({ error: 'No token' });",
- new_content: "return res.status(401).json({\n error: 'No token',\n code: 'AUTH_MISSING'\n});",
- diff: {
- hunks: [
- `diff --git a/src/middleware/auth.ts b/src/middleware/auth.ts
---- a/src/middleware/auth.ts
-+++ b/src/middleware/auth.ts
-@@ -12,1 +12,4 @@
--return res.status(401).json({ error: 'No token' });
-+return res.status(401).json({
-+ error: 'No token',
-+ code: 'AUTH_MISSING'
-+});`,
- ],
- oldFile: { fileName: 'src/middleware/auth.ts', fileLang: 'typescript' },
- newFile: { fileName: 'src/middleware/auth.ts', fileLang: 'typescript' },
- },
- },
- },
-
- // Tool Edit - error
- {
- id: 'tool-edit-3',
- session_id: 'demo',
- task_id: 'demo-task',
- type: 'tool_edit',
- content: 'Edit src/utils/format.ts',
- author_type: 'agent',
- created_at: '2024-01-20T10:21:00Z',
- metadata: {
- tool_call_id: 'tool-edit-3',
- status: 'error',
- file_path: 'src/utils/format.ts',
- result: 'Error: old_string not found in file.',
- },
- },
-
- // Tool Read - complete
- {
- id: 'tool-read-1',
- session_id: 'demo',
- task_id: 'demo-task',
- type: 'tool_read',
- content: 'Read src/auth/config.ts',
- author_type: 'agent',
- created_at: '2024-01-20T10:22:00Z',
- metadata: {
- tool_call_id: 'tool-read-1',
- status: 'complete',
- file_path: 'src/auth/config.ts',
- line_count: 50,
- },
- },
-
- // Tool Read - running
- {
- id: 'tool-read-2',
- session_id: 'demo',
- task_id: 'demo-task',
- type: 'tool_read',
- content: 'Read package.json',
- author_type: 'agent',
- created_at: '2024-01-20T10:22:30Z',
- metadata: {
- tool_call_id: 'tool-read-2',
- status: 'running',
- file_path: 'package.json',
- },
- },
-
- // Tool Read - error
- {
- id: 'tool-read-3',
- session_id: 'demo',
- task_id: 'demo-task',
- type: 'tool_read',
- content: 'Read /nonexistent/file.ts',
- author_type: 'agent',
- created_at: '2024-01-20T10:22:45Z',
- metadata: {
- tool_call_id: 'tool-read-3',
- status: 'error',
- file_path: '/nonexistent/file.ts',
- },
- },
-
- // Tool Execute - complete (success)
- {
- id: 'tool-exec-1',
- session_id: 'demo',
- task_id: 'demo-task',
- type: 'tool_execute',
- content: 'Run `npm test -- --coverage`',
- author_type: 'agent',
- created_at: '2024-01-20T10:23:00Z',
- metadata: {
- tool_call_id: 'tool-exec-1',
- status: 'complete',
- command: 'npm test -- --coverage',
- cwd: '/workspace',
- exit_code: 0,
- stdout: 'PASS src/auth/config.test.ts\nPASS src/middleware/auth.test.ts\n\nTest Suites: 2 passed, 2 total\nTests: 12 passed, 12 total\nCoverage: 87.5%',
- result: 'Tests passed successfully.',
- },
- },
-
- // Tool Execute - running
- {
- id: 'tool-exec-2',
- session_id: 'demo',
- task_id: 'demo-task',
- type: 'tool_execute',
- content: 'Run `go build ./...`',
- author_type: 'agent',
- created_at: '2024-01-20T10:23:30Z',
- metadata: {
- tool_call_id: 'tool-exec-2',
- status: 'running',
- command: 'go build ./...',
- cwd: '/workspace',
- },
- },
-
- // Tool Execute - error with stderr
- {
- id: 'tool-exec-3',
- session_id: 'demo',
- task_id: 'demo-task',
- type: 'tool_execute',
- content: 'Run `npm run lint`',
- author_type: 'agent',
- created_at: '2024-01-20T10:24:00Z',
- metadata: {
- tool_call_id: 'tool-exec-3',
- status: 'complete',
- command: 'npm run lint',
- exit_code: 1,
- stdout: '',
- stderr: 'Error: src/auth/config.ts(5,1): Missing semicolon.\nError: src/middleware/auth.ts(12,3): Unexpected token.',
- result: 'Lint failed with 2 errors.',
- },
- },
-];
+import { dummyMessages } from './demo-messages-data';
export default function MessagesDemoPage() {
const permissionsByToolCallId = new Map();
diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx
index 484679da2f..e206d16f20 100644
--- a/apps/web/app/page.tsx
+++ b/apps/web/app/page.tsx
@@ -3,70 +3,127 @@ import { StateHydrator } from '@/components/state-hydrator';
import { fetchWorkflowSnapshot, fetchUserSettings, listWorkflows, listRepositories, listWorkspaces, listTaskSessionMessages } from '@/lib/api';
import { snapshotToState } from '@/lib/ssr/mapper';
import type { AppState } from '@/lib/state/store';
+import type { ListWorkspacesResponse, UserSettings, UserSettingsResponse } from '@/lib/types/http';
// Server Component: runs on the server for SSR and data hydration.
type PageProps = {
searchParams?: Promise>;
};
+function resolveParam(value: string | string[] | undefined): string | undefined {
+ return Array.isArray(value) ? value[0] : value;
+}
+
+type WorkspaceItem = ListWorkspacesResponse['workspaces'][number];
+function mapWorkspaceItem(ws: WorkspaceItem) {
+ return {
+ id: ws.id,
+ name: ws.name,
+ description: ws.description ?? null,
+ owner_id: ws.owner_id,
+ default_executor_id: ws.default_executor_id ?? null,
+ default_environment_id: ws.default_environment_id ?? null,
+ default_agent_profile_id: ws.default_agent_profile_id ?? null,
+ created_at: ws.created_at,
+ updated_at: ws.updated_at,
+ };
+}
+
+function buildLspConfig(s: UserSettings) {
+ return {
+ lspAutoStartLanguages: s.lsp_auto_start_languages ?? [],
+ lspAutoInstallLanguages: s.lsp_auto_install_languages ?? [],
+ lspServerConfigs: s.lsp_server_configs ?? {},
+ };
+}
+
+function buildUserSettingsState(resp: UserSettingsResponse | null, workspaceId: string | null): AppState['userSettings'] {
+ const s = resp?.settings;
+ const shellOptions = resp ? (resp.shell_options ?? []) : [];
+ if (!s) {
+ return {
+ workspaceId, workflowId: null, kanbanViewMode: null, repositoryIds: [], preferredShell: null,
+ shellOptions, defaultEditorId: null, enablePreviewOnClick: false, chatSubmitKey: 'cmd_enter',
+ reviewAutoMarkOnScroll: true, lspAutoStartLanguages: [], lspAutoInstallLanguages: [], lspServerConfigs: {}, loaded: false,
+ };
+ }
+ return {
+ workspaceId,
+ workflowId: s.workflow_filter_id || null,
+ kanbanViewMode: s.kanban_view_mode || null,
+ repositoryIds: Array.from(new Set(s.repository_ids ?? [])).sort(),
+ preferredShell: s.preferred_shell || null,
+ shellOptions,
+ defaultEditorId: s.default_editor_id || null,
+ enablePreviewOnClick: s.enable_preview_on_click ?? false,
+ chatSubmitKey: s.chat_submit_key ?? 'cmd_enter',
+ reviewAutoMarkOnScroll: s.review_auto_mark_on_scroll ?? true,
+ ...buildLspConfig(s),
+ loaded: true,
+ };
+}
+
+function resolveActiveId(items: T[], preferredId?: string, fallbackId?: string | null): string | null {
+ return items.find((i) => i.id === preferredId)?.id ?? items.find((i) => i.id === fallbackId)?.id ?? items[0]?.id ?? null;
+}
+
+function buildBaseState(
+ workspaces: ListWorkspacesResponse,
+ userSettingsResponse: UserSettingsResponse | null,
+ activeWorkspaceId: string | null,
+): Partial {
+ return {
+ workspaces: {
+ items: workspaces.workspaces.map(mapWorkspaceItem),
+ activeId: activeWorkspaceId,
+ },
+ userSettings: buildUserSettingsState(userSettingsResponse, activeWorkspaceId),
+ };
+}
+
+async function loadSessionMessages(
+ sessionId: string,
+ state: Partial,
+): Promise> {
+ try {
+ const messagesResponse = await listTaskSessionMessages(sessionId, { limit: 50, sort: 'desc' }, { cache: 'no-store' });
+ const messages = [...(messagesResponse.messages ?? [])].reverse();
+ return {
+ ...state,
+ messages: {
+ bySession: { [sessionId]: messages },
+ metaBySession: {
+ [sessionId]: {
+ isLoading: false,
+ hasMore: messagesResponse.has_more ?? false,
+ oldestCursor: messages[0]?.id ?? null,
+ },
+ },
+ },
+ };
+ } catch (error) {
+ console.warn('Could not SSR messages (client will load via WebSocket):', error instanceof Error ? error.message : String(error));
+ return state;
+ }
+}
+
export default async function Page({ searchParams }: PageProps) {
try {
const resolvedParams = searchParams ? await searchParams : {};
- const workspaceParam = resolvedParams.workspaceId;
- const workflowParam = resolvedParams.workflowId;
- const taskIdParam = resolvedParams.taskId;
- const sessionIdParam = resolvedParams.sessionId;
- const workspaceId = Array.isArray(workspaceParam) ? workspaceParam[0] : workspaceParam;
- const workflowIdParam = Array.isArray(workflowParam) ? workflowParam[0] : workflowParam;
- const taskId = Array.isArray(taskIdParam) ? taskIdParam[0] : taskIdParam;
- const sessionId = Array.isArray(sessionIdParam) ? sessionIdParam[0] : sessionIdParam;
+ const workspaceId = resolveParam(resolvedParams.workspaceId);
+ const workflowIdParam = resolveParam(resolvedParams.workflowId);
+ const taskId = resolveParam(resolvedParams.taskId);
+ const sessionId = resolveParam(resolvedParams.sessionId);
const [workspaces, userSettingsResponse] = await Promise.all([
listWorkspaces({ cache: 'no-store' }),
fetchUserSettings({ cache: 'no-store' }).catch(() => null),
]);
- const userSettings = userSettingsResponse?.settings;
- const settingsWorkspaceId = userSettings?.workspace_id || null;
- const settingsWorkflowId = userSettings?.workflow_filter_id || null;
- const settingsRepositoryIds = Array.from(new Set(userSettings?.repository_ids ?? [])).sort();
- const settingsEnablePreviewOnClick = userSettings?.enable_preview_on_click ?? false;
- const activeWorkspaceId =
- workspaces.workspaces.find((workspace) => workspace.id === workspaceId)?.id ??
- workspaces.workspaces.find((workspace) => workspace.id === settingsWorkspaceId)?.id ??
- workspaces.workspaces[0]?.id ??
- null;
- let initialState: Partial = {
- workspaces: {
- items: workspaces.workspaces.map((workspace) => ({
- id: workspace.id,
- name: workspace.name,
- description: workspace.description ?? null,
- owner_id: workspace.owner_id,
- default_executor_id: workspace.default_executor_id ?? null,
- default_environment_id: workspace.default_environment_id ?? null,
- default_agent_profile_id: workspace.default_agent_profile_id ?? null,
- created_at: workspace.created_at,
- updated_at: workspace.updated_at,
- })),
- activeId: activeWorkspaceId,
- },
- userSettings: {
- workspaceId: activeWorkspaceId,
- workflowId: settingsWorkflowId,
- kanbanViewMode: userSettings?.kanban_view_mode || null,
- repositoryIds: settingsRepositoryIds,
- preferredShell: userSettings?.preferred_shell || null,
- shellOptions: userSettingsResponse?.shell_options ?? [],
- defaultEditorId: userSettings?.default_editor_id || null,
- enablePreviewOnClick: settingsEnablePreviewOnClick,
- chatSubmitKey: userSettings?.chat_submit_key ?? 'cmd_enter',
- reviewAutoMarkOnScroll: userSettings?.review_auto_mark_on_scroll ?? true,
- lspAutoStartLanguages: userSettings?.lsp_auto_start_languages ?? [],
- lspAutoInstallLanguages: userSettings?.lsp_auto_install_languages ?? [],
- lspServerConfigs: userSettings?.lsp_server_configs ?? {},
- loaded: Boolean(userSettings),
- },
- };
+ const settingsWorkspaceId = userSettingsResponse?.settings?.workspace_id || null;
+ const settingsWorkflowId = userSettingsResponse?.settings?.workflow_filter_id || null;
+ const activeWorkspaceId = resolveActiveId(workspaces.workspaces, workspaceId, settingsWorkspaceId);
+
+ let initialState = buildBaseState(workspaces, userSettingsResponse, activeWorkspaceId);
if (!activeWorkspaceId) {
return (
@@ -82,25 +139,17 @@ export default async function Page({ searchParams }: PageProps) {
listRepositories(activeWorkspaceId, undefined, { cache: 'no-store' }).catch(() => ({ repositories: [] })),
]);
- // Use workflowIdParam if it exists in this workspace, otherwise fall back
- // Client-side code will handle task selection even if workflow doesn't match
- const workflowId =
- workflowList.workflows.find((workflow) => workflow.id === workflowIdParam)?.id ??
- workflowList.workflows.find((workflow) => workflow.id === settingsWorkflowId)?.id ??
- workflowList.workflows[0]?.id;
+ const workflowId = resolveActiveId(workflowList.workflows, workflowIdParam, settingsWorkflowId);
+
initialState = {
...initialState,
userSettings: {
...(initialState.userSettings as AppState['userSettings']),
- workflowId: workflowId ?? null,
+ workflowId,
},
workflows: {
- items: workflowList.workflows.map((workflow) => ({
- id: workflow.id,
- workspaceId: workflow.workspace_id,
- name: workflow.name,
- })),
- activeId: workflowId ?? null,
+ items: workflowList.workflows.map((w) => ({ id: w.id, workspaceId: w.workspace_id, name: w.name })),
+ activeId: workflowId,
},
repositories: {
itemsByWorkspaceId: { [activeWorkspaceId]: repositoriesResponse.repositories },
@@ -121,32 +170,8 @@ export default async function Page({ searchParams }: PageProps) {
const snapshot = await fetchWorkflowSnapshot(workflowId, { cache: 'no-store' });
initialState = { ...initialState, ...snapshotToState(snapshot) };
- // Load messages for selected task session if provided (SSR optimization)
if (taskId && sessionId) {
- try {
- // Load most recent messages in descending order, then reverse to show oldest-to-newest
- const messagesResponse = await listTaskSessionMessages(sessionId, { limit: 50, sort: 'desc' }, { cache: 'no-store' });
- const messages = [...(messagesResponse.messages ?? [])].reverse();
- initialState = {
- ...initialState,
- messages: {
- bySession: {
- [sessionId]: messages,
- },
- metaBySession: {
- [sessionId]: {
- isLoading: false,
- hasMore: messagesResponse.has_more ?? false,
- // oldestCursor should be the first (oldest) message ID for lazy loading older messages
- oldestCursor: messages[0]?.id ?? null,
- },
- },
- },
- };
- } catch (error) {
- // SSR failed - client will load messages via WebSocket
- console.warn('Could not SSR messages (client will load via WebSocket):', error instanceof Error ? error.message : String(error));
- }
+ initialState = await loadSessionMessages(sessionId, initialState);
}
return (
diff --git a/apps/web/app/s/[sessionId]/page.tsx b/apps/web/app/s/[sessionId]/page.tsx
index adda5b20ac..2f8bafdd53 100644
--- a/apps/web/app/s/[sessionId]/page.tsx
+++ b/apps/web/app/s/[sessionId]/page.tsx
@@ -16,240 +16,275 @@ import {
import { toAgentProfileOption } from '@/lib/state/slices/settings/types';
import { listSessionTurns } from '@/lib/api/domains/session-api';
import { fetchTerminals } from '@/lib/api/domains/user-shell-api';
-import type { ListMessagesResponse, Task } from '@/lib/types/http';
+import type { ListMessagesResponse, Task, TaskSession, UserSettingsResponse } from '@/lib/types/http';
import type { Terminal } from '@/hooks/domains/session/use-terminals';
import { snapshotToState, taskToState } from '@/lib/ssr/mapper';
import { TaskPageContent } from '@/components/task/task-page-content';
-export default async function SessionPage({
- params,
-}: {
- params: Promise<{ sessionId: string }>;
-}) {
- let initialState: ReturnType | null = null;
- let task: Task | null = null;
- let sessionId: string | null = null;
- let initialTerminals: Terminal[] = [];
+function buildWorktreeState(allSessions: TaskSession[]) {
+ const sessionsWithWorktrees = allSessions.filter((s) => s.worktree_id);
+ return {
+ worktrees: {
+ items: Object.fromEntries(
+ sessionsWithWorktrees.map((s) => [
+ s.worktree_id,
+ {
+ id: s.worktree_id!,
+ sessionId: s.id,
+ repositoryId: s.repository_id ?? undefined,
+ path: s.worktree_path ?? undefined,
+ branch: s.worktree_branch ?? undefined,
+ },
+ ])
+ ),
+ },
+ sessionWorktreesBySessionId: {
+ itemsBySessionId: Object.fromEntries(
+ sessionsWithWorktrees.map((s) => [s.id, [s.worktree_id!]])
+ ),
+ },
+ };
+}
+
+type UserSettingsSettings = NonNullable;
+
+function buildLspSettings(s: UserSettingsSettings | undefined) {
+ return {
+ lspAutoStartLanguages: s?.lsp_auto_start_languages ?? [],
+ lspAutoInstallLanguages: s?.lsp_auto_install_languages ?? [],
+ lspServerConfigs: s?.lsp_server_configs ?? {},
+ };
+}
+
+function buildUserSettingsCore(s: UserSettingsSettings) {
+ return {
+ workspaceId: s.workspace_id || null,
+ workflowId: s.workflow_filter_id || null,
+ kanbanViewMode: s.kanban_view_mode || null,
+ repositoryIds: Array.from(new Set(s.repository_ids ?? [])).sort(),
+ preferredShell: s.preferred_shell || null,
+ defaultEditorId: s.default_editor_id || null,
+ enablePreviewOnClick: s.enable_preview_on_click ?? false,
+ chatSubmitKey: s.chat_submit_key ?? 'cmd_enter',
+ reviewAutoMarkOnScroll: s.review_auto_mark_on_scroll ?? true,
+ };
+}
+
+function buildUserSettingsFromResponse(userSettingsResponse: UserSettingsResponse | null) {
+ const s = userSettingsResponse?.settings;
+ const shellOptions = userSettingsResponse?.shell_options ?? [];
+ if (!s) return { workspaceId: null, workflowId: null, kanbanViewMode: null, repositoryIds: [], preferredShell: null, shellOptions, defaultEditorId: null, enablePreviewOnClick: false, chatSubmitKey: 'cmd_enter' as const, reviewAutoMarkOnScroll: true, loaded: false, ...buildLspSettings(undefined) };
+ return { ...buildUserSettingsCore(s), shellOptions, loaded: true, ...buildLspSettings(s) };
+}
+
+type BuildSessionPageStateParams = {
+ task: Task;
+ sessionId: string;
+ snapshot: Awaited>;
+ agents: Awaited>;
+ repositories: Awaited>['repositories'];
+ allSessions: TaskSession[];
+ availableAgents: Awaited>['agents'];
+ workspaces: Awaited>['workspaces'];
+ workflows: Awaited>['workflows'];
+ turns: Awaited>['turns'];
+ userSettingsResponse: UserSettingsResponse | null;
+ messagesResponse: ListMessagesResponse | null;
+};
+
+function buildSessionPageState(p: BuildSessionPageStateParams) {
+ const {
+ task, sessionId, snapshot, agents, repositories,
+ allSessions, availableAgents, workspaces, workflows,
+ turns, userSettingsResponse, messagesResponse,
+ } = p;
+
+ const repositoryId = task.repositories?.[0]?.repository_id;
+ const repository = repositories.find((r) => r.id === repositoryId);
+ const scripts = repository?.scripts ?? [];
+ const messages = messagesResponse?.messages ? [...messagesResponse.messages].reverse() : [];
+
+ const taskState = taskToState(task, sessionId, {
+ items: messages,
+ hasMore: messagesResponse?.has_more ?? false,
+ oldestCursor: messages[0]?.id ?? null,
+ });
+
+ const repositoryScripts = repositoryId
+ ? {
+ itemsByRepositoryId: { [repositoryId]: scripts },
+ loadingByRepositoryId: { [repositoryId]: false },
+ loadedByRepositoryId: { [repositoryId]: true },
+ }
+ : {
+ itemsByRepositoryId: {},
+ loadingByRepositoryId: {},
+ loadedByRepositoryId: {},
+ };
+
+ return {
+ ...snapshotToState(snapshot),
+ ...taskState,
+ workspaces: {
+ items: workspaces.map((workspace) => ({
+ id: workspace.id,
+ name: workspace.name,
+ description: workspace.description ?? null,
+ owner_id: workspace.owner_id,
+ default_executor_id: workspace.default_executor_id ?? null,
+ default_environment_id: workspace.default_environment_id ?? null,
+ default_agent_profile_id: workspace.default_agent_profile_id ?? null,
+ created_at: workspace.created_at,
+ updated_at: workspace.updated_at,
+ })),
+ activeId: task.workspace_id,
+ },
+ workflows: {
+ items: workflows.map((workflow) => ({
+ id: workflow.id,
+ workspaceId: workflow.workspace_id,
+ name: workflow.name,
+ })),
+ activeId: task.workflow_id,
+ },
+ repositories: {
+ itemsByWorkspaceId: { [task.workspace_id]: repositories },
+ loadingByWorkspaceId: { [task.workspace_id]: false },
+ loadedByWorkspaceId: { [task.workspace_id]: true },
+ },
+ repositoryScripts,
+ agentProfiles: {
+ items: agents.agents.flatMap((agent) =>
+ agent.profiles.map((profile) => toAgentProfileOption(agent, profile))
+ ),
+ version: 0,
+ },
+ taskSessions: {
+ items: Object.fromEntries(allSessions.map((s) => [s.id, s])),
+ },
+ taskSessionsByTask: {
+ itemsByTaskId: { [task.id]: allSessions },
+ loadingByTaskId: { [task.id]: false },
+ loadedByTaskId: { [task.id]: true },
+ },
+ turns: {
+ bySession: { [sessionId]: turns },
+ activeBySession: {
+ [sessionId]: turns.filter((t) => !t.completed_at).pop()?.id ?? null,
+ },
+ },
+ ...buildWorktreeState(allSessions),
+ settingsAgents: { items: agents.agents },
+ settingsData: {
+ agentsLoaded: true,
+ executorsLoaded: false,
+ environmentsLoaded: false,
+ },
+ availableAgents: { items: availableAgents, loaded: true, loading: false },
+ userSettings: buildUserSettingsFromResponse(userSettingsResponse),
+ };
+}
+
+type FetchedSessionData = {
+ task: Task;
+ sessionId: string;
+ initialState: ReturnType;
+ initialTerminals: Terminal[];
+};
+
+async function fetchSessionData(paramSessionId: string): Promise {
+ const sessionResponse = await fetchTaskSession(paramSessionId, { cache: 'no-store' });
+ const session = sessionResponse.session;
+ if (!session?.task_id) {
+ throw new Error('No task_id found for session');
+ }
+
+ const task = await fetchTask(session.task_id, { cache: 'no-store' });
+ const [
+ snapshot, agents, repositoriesResponse, allSessionsResponse,
+ availableAgentsResponse, workspacesResponse, workflowsResponse,
+ turnsResponse, userSettingsResponse, terminalsResponse,
+ ] = await Promise.all([
+ fetchWorkflowSnapshot(task.workflow_id, { cache: 'no-store' }),
+ listAgents({ cache: 'no-store' }),
+ listRepositories(task.workspace_id, { includeScripts: true }, { cache: 'no-store' }),
+ listTaskSessions(session.task_id, { cache: 'no-store' }),
+ listAvailableAgents({ cache: 'no-store' }).catch(() => ({ agents: [] })),
+ listWorkspaces({ cache: 'no-store' }).catch(() => ({ workspaces: [] })),
+ listWorkflows(task.workspace_id, { cache: 'no-store' }).catch(() => ({ workflows: [] })),
+ listSessionTurns(paramSessionId, { cache: 'no-store' }).catch(() => ({ turns: [], total: 0 })),
+ fetchUserSettings({ cache: 'no-store' }).catch(() => null),
+ fetchTerminals(paramSessionId).catch(() => []),
+ ]);
+
+ const repositories = repositoriesResponse.repositories ?? [];
+ const allSessions = allSessionsResponse.sessions ?? [session];
+ const availableAgents = availableAgentsResponse.agents ?? [];
+ const workspaces = workspacesResponse.workspaces ?? [];
+ const workflows = workflowsResponse.workflows ?? [];
+ const turns = turnsResponse.turns ?? [];
+
+ const initialTerminals: Terminal[] = terminalsResponse.map((t) => ({
+ id: t.terminal_id,
+ type: t.initial_command ? 'script' as const : 'shell' as const,
+ label: t.label,
+ closable: t.closable,
+ }));
+
+ let messagesResponse: ListMessagesResponse | null = null;
+ try {
+ messagesResponse = await listTaskSessionMessages(
+ paramSessionId,
+ { limit: 50, sort: 'desc' },
+ { cache: 'no-store' }
+ );
+ } catch (error) {
+ console.warn(
+ 'Could not load session messages for SSR:',
+ error instanceof Error ? error.message : String(error)
+ );
+ }
+
+ const initialState = buildSessionPageState({
+ task, sessionId: paramSessionId, snapshot, agents,
+ repositories, allSessions, availableAgents,
+ workspaces, workflows, turns,
+ userSettingsResponse, messagesResponse,
+ });
+
+ return { task, sessionId: paramSessionId, initialState, initialTerminals };
+}
+
+function extractInitialRepositories(initialState: FetchedSessionData['initialState'] | null, task: Task | null) {
+ return initialState?.repositories?.itemsByWorkspaceId?.[task?.workspace_id ?? ''] ?? [];
+}
+
+function extractInitialScripts(initialState: FetchedSessionData['initialState'] | null, task: Task | null) {
+ const repoId = task?.repositories?.[0]?.repository_id ?? '';
+ return initialState?.repositoryScripts?.itemsByRepositoryId?.[repoId] ?? [];
+}
+
+export default async function SessionPage({ params }: { params: Promise<{ sessionId: string }> }) {
+ let fetchedData: FetchedSessionData | null = null;
const defaultLayouts = await readLayoutDefaults();
try {
const { sessionId: paramSessionId } = await params;
- sessionId = paramSessionId;
-
- const sessionResponse = await fetchTaskSession(sessionId, { cache: 'no-store' });
- const session = sessionResponse.session;
- if (!session?.task_id) {
- throw new Error('No task_id found for session');
- }
-
- task = await fetchTask(session.task_id, { cache: 'no-store' });
- const [snapshot, agents, repositoriesResponse, allSessionsResponse, availableAgentsResponse, workspacesResponse, workflowsResponse, turnsResponse, userSettingsResponse, terminalsResponse] = await Promise.all([
- fetchWorkflowSnapshot(task.workflow_id, { cache: 'no-store' }),
- listAgents({ cache: 'no-store' }),
- listRepositories(task.workspace_id, { includeScripts: true }, { cache: 'no-store' }),
- listTaskSessions(session.task_id, { cache: 'no-store' }),
- listAvailableAgents({ cache: 'no-store' }).catch(() => ({ agents: [] })),
- listWorkspaces({ cache: 'no-store' }).catch(() => ({ workspaces: [] })),
- listWorkflows(task.workspace_id, { cache: 'no-store' }).catch(() => ({ workflows: [] })),
- listSessionTurns(sessionId, { cache: 'no-store' }).catch(() => ({ turns: [], total: 0 })),
- fetchUserSettings({ cache: 'no-store' }).catch(() => null),
- fetchTerminals(sessionId).catch(() => []),
- ]);
- const repositories = repositoriesResponse.repositories ?? [];
- const allSessions = allSessionsResponse.sessions ?? [session];
- const availableAgents = availableAgentsResponse.agents ?? [];
- const workspaces = workspacesResponse.workspaces ?? [];
- const workflows = workflowsResponse.workflows ?? [];
- const turns = turnsResponse.turns ?? [];
- const userSettings = userSettingsResponse?.settings;
-
- // Transform terminals to frontend format
- initialTerminals = terminalsResponse.map((t) => ({
- id: t.terminal_id,
- type: t.initial_command ? 'script' as const : 'shell' as const,
- label: t.label,
- closable: t.closable,
- }));
-
- // Get repository scripts from the repository (already fetched with includeScripts)
- const repositoryId = task.repositories?.[0]?.repository_id;
- const repository = repositories.find(r => r.id === repositoryId);
- const scripts = repository?.scripts ?? [];
-
- let messagesResponse: ListMessagesResponse | null = null;
- try {
- // Load most recent messages in descending order, then reverse to show oldest-to-newest
- messagesResponse = await listTaskSessionMessages(
- sessionId,
- { limit: 50, sort: 'desc' },
- { cache: 'no-store' }
- );
- } catch (error) {
- console.warn(
- 'Could not load session messages for SSR:',
- error instanceof Error ? error.message : String(error)
- );
- }
-
- const messages = messagesResponse?.messages ? [...messagesResponse.messages].reverse() : [];
- const taskState = taskToState(task, sessionId, {
- items: messages,
- hasMore: messagesResponse?.has_more ?? false,
- // oldestCursor should be the first (oldest) message ID for lazy loading older messages
- oldestCursor: messages[0]?.id ?? null,
- });
-
- initialState = {
- ...snapshotToState(snapshot),
- ...taskState,
- workspaces: {
- items: workspaces.map((workspace) => ({
- id: workspace.id,
- name: workspace.name,
- description: workspace.description ?? null,
- owner_id: workspace.owner_id,
- default_executor_id: workspace.default_executor_id ?? null,
- default_environment_id: workspace.default_environment_id ?? null,
- default_agent_profile_id: workspace.default_agent_profile_id ?? null,
- created_at: workspace.created_at,
- updated_at: workspace.updated_at,
- })),
- activeId: task.workspace_id,
- },
- workflows: {
- items: workflows.map((workflow) => ({
- id: workflow.id,
- workspaceId: workflow.workspace_id,
- name: workflow.name,
- })),
- activeId: task.workflow_id,
- },
- repositories: {
- itemsByWorkspaceId: {
- [task.workspace_id]: repositories,
- },
- loadingByWorkspaceId: {
- [task.workspace_id]: false,
- },
- loadedByWorkspaceId: {
- [task.workspace_id]: true,
- },
- },
- repositoryScripts: repositoryId ? {
- itemsByRepositoryId: {
- [repositoryId]: scripts,
- },
- loadingByRepositoryId: {
- [repositoryId]: false,
- },
- loadedByRepositoryId: {
- [repositoryId]: true,
- },
- } : {
- itemsByRepositoryId: {},
- loadingByRepositoryId: {},
- loadedByRepositoryId: {},
- },
- agentProfiles: {
- items: agents.agents.flatMap((agent) =>
- agent.profiles.map((profile) => toAgentProfileOption(agent, profile))
- ),
- version: 0,
- },
- taskSessions: {
- items: Object.fromEntries(allSessions.map((s) => [s.id, s])),
- },
- taskSessionsByTask: {
- itemsByTaskId: {
- [task.id]: allSessions,
- },
- loadingByTaskId: {
- [task.id]: false,
- },
- loadedByTaskId: {
- [task.id]: true,
- },
- },
- turns: {
- bySession: {
- [sessionId]: turns,
- },
- activeBySession: {
- // Find the most recent (last) incomplete turn
- [sessionId]: turns.filter((t) => !t.completed_at).pop()?.id ?? null,
- },
- },
- worktrees: {
- items: Object.fromEntries(
- allSessions
- .filter((s) => s.worktree_id)
- .map((s) => [
- s.worktree_id,
- {
- id: s.worktree_id!,
- sessionId: s.id,
- repositoryId: s.repository_id ?? undefined,
- path: s.worktree_path ?? undefined,
- branch: s.worktree_branch ?? undefined,
- },
- ])
- ),
- },
- sessionWorktreesBySessionId: {
- itemsBySessionId: Object.fromEntries(
- allSessions
- .filter((s) => s.worktree_id)
- .map((s) => [s.id, [s.worktree_id!]])
- ),
- },
- settingsAgents: {
- items: agents.agents,
- },
- settingsData: {
- agentsLoaded: true,
- executorsLoaded: false,
- environmentsLoaded: false,
- },
- availableAgents: {
- items: availableAgents,
- loaded: true,
- loading: false,
- },
- userSettings: {
- workspaceId: userSettings?.workspace_id || null,
- workflowId: userSettings?.workflow_filter_id || null,
- kanbanViewMode: userSettings?.kanban_view_mode || null,
- repositoryIds: Array.from(new Set(userSettings?.repository_ids ?? [])).sort(),
- preferredShell: userSettings?.preferred_shell || null,
- shellOptions: userSettingsResponse?.shell_options ?? [],
- defaultEditorId: userSettings?.default_editor_id || null,
- enablePreviewOnClick: userSettings?.enable_preview_on_click ?? false,
- chatSubmitKey: userSettings?.chat_submit_key ?? 'cmd_enter',
- reviewAutoMarkOnScroll: userSettings?.review_auto_mark_on_scroll ?? true,
- lspAutoStartLanguages: userSettings?.lsp_auto_start_languages ?? [],
- lspAutoInstallLanguages: userSettings?.lsp_auto_install_languages ?? [],
- lspServerConfigs: userSettings?.lsp_server_configs ?? {},
- loaded: Boolean(userSettings),
- },
- };
+ fetchedData = await fetchSessionData(paramSessionId);
} catch (error) {
- console.warn(
- 'Could not SSR session (client will load via WebSocket):',
- error instanceof Error ? error.message : String(error)
- );
- initialState = null;
- task = null;
- sessionId = null;
+ console.warn('Could not SSR session (client will load via WebSocket):', error instanceof Error ? error.message : String(error));
}
+ const { task, sessionId, initialState, initialTerminals } = fetchedData ?? { task: null, sessionId: null, initialState: null, initialTerminals: [] };
+
return (
<>
{initialState ? : null}
diff --git a/apps/web/app/settings/agents/[agentId]/agent-save-helpers.ts b/apps/web/app/settings/agents/[agentId]/agent-save-helpers.ts
new file mode 100644
index 0000000000..77bb9681a5
--- /dev/null
+++ b/apps/web/app/settings/agents/[agentId]/agent-save-helpers.ts
@@ -0,0 +1,245 @@
+import {
+ createAgentAction,
+ createAgentProfileAction,
+ deleteAgentProfileAction,
+ updateAgentAction,
+ updateAgentProfileAction,
+ updateAgentProfileMcpConfigAction,
+} from '@/app/actions/agents';
+import type { Agent, AgentProfile, McpServerDef, PermissionSetting, ModelConfig } from '@/lib/types/http';
+import { permissionsToProfilePatch } from '@/lib/agent-permissions';
+
+type DraftMcpConfig = {
+ enabled: boolean;
+ servers: string;
+ dirty: boolean;
+ error: string | null;
+};
+
+export type DraftProfile = Omit & {
+ allow_indexing?: boolean;
+ isNew?: boolean;
+ mcp_config?: DraftMcpConfig;
+};
+
+export type DraftAgent = Omit & { profiles: DraftProfile[]; isNew?: boolean };
+
+export const parseProfileMcpServers = (raw: string): Record => {
+ if (!raw.trim()) return {};
+ const parsed = JSON.parse(raw) as unknown;
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
+ throw new Error('MCP servers config must be a JSON object');
+ }
+ if ('mcpServers' in parsed) {
+ const nested = (parsed as { mcpServers?: unknown }).mcpServers;
+ if (!nested || typeof nested !== 'object' || Array.isArray(nested)) {
+ throw new Error('mcpServers must be a JSON object');
+ }
+ return nested as Record;
+ }
+ return parsed as Record;
+};
+
+type SaveMcpForProfileParams = {
+ draftProfile: DraftProfile;
+ targetProfileId: string;
+ onToastError: (error: unknown) => void;
+};
+
+async function saveMcpForProfile({ draftProfile, targetProfileId, onToastError }: SaveMcpForProfileParams) {
+ if (!draftProfile.mcp_config?.dirty || !draftProfile.mcp_config.servers.trim()) return;
+ try {
+ const servers = parseProfileMcpServers(draftProfile.mcp_config.servers);
+ await updateAgentProfileMcpConfigAction(targetProfileId, {
+ enabled: draftProfile.mcp_config.enabled,
+ mcpServers: servers,
+ });
+ } catch (error) {
+ onToastError(error);
+ }
+}
+
+async function saveMcpForCreatedProfiles(
+ draftAgent: DraftAgent,
+ created: Agent,
+ onToastError: (error: unknown) => void
+) {
+ if (created.profiles.length === draftAgent.profiles.length) {
+ for (let index = 0; index < draftAgent.profiles.length; index += 1) {
+ await saveMcpForProfile({
+ draftProfile: draftAgent.profiles[index],
+ targetProfileId: created.profiles[index].id,
+ onToastError,
+ });
+ }
+ return;
+ }
+ for (const draftProfile of draftAgent.profiles) {
+ const createdProfile = created.profiles.find((profile) => profile.name === draftProfile.name);
+ if (!createdProfile) continue;
+ await saveMcpForProfile({
+ draftProfile,
+ targetProfileId: createdProfile.id,
+ onToastError,
+ });
+ }
+}
+
+export type EnsureProfilesFn = (
+ agent: DraftAgent,
+ displayName: string,
+ defaultModel: string,
+ permissions?: Record
+) => DraftAgent;
+
+export type CloneAgentFn = (agent: Agent) => DraftAgent;
+
+export type SaveAgentCallbacks = {
+ onToastError: (error: unknown) => void;
+ currentAgentModelConfig: ModelConfig;
+ permissionSettings: Record;
+ resolveDisplayName: (name: string) => string;
+ upsertAgent: (agent: Agent) => void;
+ setDraftAgent: (agent: DraftAgent) => void;
+ ensureProfiles: EnsureProfilesFn;
+ cloneAgent: CloneAgentFn;
+ replaceRoute: (path: string) => void;
+};
+
+export async function saveNewAgent(
+ draftAgent: DraftAgent,
+ callbacks: SaveAgentCallbacks
+) {
+ let created = await createAgentAction({
+ name: draftAgent.name,
+ workspace_id: draftAgent.workspace_id,
+ profiles: draftAgent.profiles.map((profile) => ({
+ name: profile.name,
+ model: profile.model,
+ ...permissionsToProfilePatch(profile),
+ cli_passthrough: profile.cli_passthrough ?? false,
+ })),
+ });
+
+ await saveMcpForCreatedProfiles(draftAgent, created, callbacks.onToastError);
+
+ if ((draftAgent.mcp_config_path ?? '') !== (created.mcp_config_path ?? '')) {
+ created = await updateAgentAction(created.id, {
+ mcp_config_path: draftAgent.mcp_config_path ?? '',
+ });
+ }
+ callbacks.upsertAgent(created);
+ callbacks.setDraftAgent(
+ callbacks.ensureProfiles(
+ callbacks.cloneAgent(created),
+ callbacks.resolveDisplayName(created.name),
+ callbacks.currentAgentModelConfig.default_model,
+ callbacks.permissionSettings
+ )
+ );
+ callbacks.replaceRoute(`/settings/agents/${encodeURIComponent(created.name)}`);
+}
+
+async function saveExistingAgentPatch(draftAgent: DraftAgent, savedAgent: Agent) {
+ const agentPatch: { workspace_id?: string | null; mcp_config_path?: string | null } = {};
+ if ((draftAgent.workspace_id ?? null) !== (savedAgent.workspace_id ?? null)) {
+ agentPatch.workspace_id = draftAgent.workspace_id ?? null;
+ }
+ if ((draftAgent.mcp_config_path ?? '') !== (savedAgent.mcp_config_path ?? '')) {
+ agentPatch.mcp_config_path = draftAgent.mcp_config_path ?? '';
+ }
+ if (Object.keys(agentPatch).length > 0) {
+ await updateAgentAction(savedAgent.id, agentPatch);
+ }
+}
+
+async function saveExistingProfiles(
+ draftAgent: DraftAgent,
+ savedAgent: Agent,
+ isCreateMode: boolean,
+ onToastError: (error: unknown) => void
+): Promise {
+ const savedProfilesById = new Map(savedAgent.profiles.map((p) => [p.id, p]));
+ const nextProfiles: AgentProfile[] = isCreateMode ? [...savedAgent.profiles] : [];
+
+ for (const profile of draftAgent.profiles) {
+ const savedProfile = savedProfilesById.get(profile.id);
+ if (!savedProfile) {
+ const createdProfile = await createAgentProfileAction(savedAgent.id, {
+ name: profile.name,
+ model: profile.model,
+ ...permissionsToProfilePatch(profile),
+ cli_passthrough: profile.cli_passthrough ?? false,
+ });
+ await saveMcpForProfile({ draftProfile: profile, targetProfileId: createdProfile.id, onToastError });
+ nextProfiles.push(createdProfile);
+ continue;
+ }
+ if (isProfileDirty(profile, savedProfile)) {
+ const updatedProfile = await updateAgentProfileAction(profile.id, {
+ name: profile.name,
+ model: profile.model,
+ ...permissionsToProfilePatch(profile),
+ });
+ nextProfiles.push(updatedProfile);
+ continue;
+ }
+ nextProfiles.push(savedProfile);
+ }
+ return nextProfiles;
+}
+
+async function deleteRemovedProfiles(draftAgent: DraftAgent, savedAgent: Agent) {
+ for (const savedProfile of savedAgent.profiles) {
+ const stillExists = draftAgent.profiles.some((p) => p.id === savedProfile.id);
+ if (!stillExists) {
+ await deleteAgentProfileAction(savedProfile.id);
+ }
+ }
+}
+
+export async function saveExistingAgent(
+ draftAgent: DraftAgent,
+ savedAgent: Agent,
+ isCreateMode: boolean,
+ callbacks: SaveAgentCallbacks
+) {
+ await saveExistingAgentPatch(draftAgent, savedAgent);
+
+ const nextProfiles = await saveExistingProfiles(draftAgent, savedAgent, isCreateMode, callbacks.onToastError);
+
+ if (!isCreateMode) {
+ await deleteRemovedProfiles(draftAgent, savedAgent);
+ }
+
+ const nextAgent = {
+ ...savedAgent,
+ workspace_id: draftAgent.workspace_id ?? null,
+ mcp_config_path: draftAgent.mcp_config_path ?? '',
+ profiles: nextProfiles,
+ };
+ callbacks.upsertAgent(nextAgent);
+ callbacks.setDraftAgent(
+ callbacks.ensureProfiles(
+ callbacks.cloneAgent(nextAgent),
+ callbacks.resolveDisplayName(nextAgent.name),
+ callbacks.currentAgentModelConfig.default_model,
+ callbacks.permissionSettings
+ )
+ );
+ if (isCreateMode) {
+ callbacks.replaceRoute(`/settings/agents/${encodeURIComponent(savedAgent.name)}`);
+ }
+}
+
+import { arePermissionsDirty } from '@/lib/agent-permissions';
+
+export function isProfileDirty(draft: DraftProfile, saved?: AgentProfile): boolean {
+ if (!saved) return true;
+ return (
+ draft.name !== saved.name ||
+ draft.model !== saved.model ||
+ arePermissionsDirty(draft, saved) ||
+ draft.cli_passthrough !== saved.cli_passthrough
+ );
+}
diff --git a/apps/web/app/settings/agents/[agentId]/page.tsx b/apps/web/app/settings/agents/[agentId]/page.tsx
index 7606c7a448..00d38813f2 100644
--- a/apps/web/app/settings/agents/[agentId]/page.tsx
+++ b/apps/web/app/settings/agents/[agentId]/page.tsx
@@ -10,32 +10,14 @@ import { Separator } from '@kandev/ui/separator';
import { useToast } from '@/components/toast-provider';
import { UnsavedChangesBadge, UnsavedSaveButton } from '@/components/settings/unsaved-indicator';
import { ProfileFormFields } from '@/components/settings/profile-form-fields';
-import {
- createAgentAction,
- createAgentProfileAction,
- deleteAgentProfileAction,
- updateAgentAction,
- updateAgentProfileAction,
- updateAgentProfileMcpConfigAction,
-} from '@/app/actions/agents';
-import type { Agent, AgentDiscovery, AgentProfile, McpServerDef, AvailableAgent, PermissionSetting } from '@/lib/types/http';
-import { buildDefaultPermissions, arePermissionsDirty, permissionsToProfilePatch } from '@/lib/agent-permissions';
+import type { Agent, AgentDiscovery, AvailableAgent, PermissionSetting, ModelConfig, PassthroughConfig } from '@/lib/types/http';
+import { buildDefaultPermissions } from '@/lib/agent-permissions';
import { generateUUID } from '@/lib/utils';
import { useAppStore } from '@/components/state-provider';
import { useAvailableAgents } from '@/hooks/domains/settings/use-available-agents';
import { ProfileMcpConfigCard } from './profile-mcp-config-card';
-
-type DraftProfile = Omit & {
- allow_indexing?: boolean;
- isNew?: boolean;
- mcp_config?: {
- enabled: boolean;
- servers: string;
- dirty: boolean;
- error: string | null;
- };
-};
-type DraftAgent = Omit & { profiles: DraftProfile[]; isNew?: boolean };
+import { saveNewAgent, saveExistingAgent, isProfileDirty } from './agent-save-helpers';
+import type { DraftProfile, DraftAgent } from './agent-save-helpers';
const defaultMcpConfig: NonNullable = {
enabled: false,
@@ -84,37 +66,69 @@ const ensureProfiles = (
defaultModel: string,
permissionSettings?: Record
): DraftAgent => {
- if (agent.profiles.length > 0) {
- return agent;
- }
+ if (agent.profiles.length > 0) return agent;
return {
...agent,
profiles: [createDraftProfile(agent.id, agentDisplayName, defaultModel, permissionSettings)],
};
};
-function AgentSetupForm({
- initialAgent,
- savedAgent,
- discoveryAgent,
- onToastError,
- isCreateMode = false,
-}: AgentSetupFormProps) {
- const router = useRouter();
- const settingsAgents = useAppStore((state) => state.settingsAgents.items);
- const setSettingsAgents = useAppStore((state) => state.setSettingsAgents);
- const setAgentProfiles = useAppStore((state) => state.setAgentProfiles);
- const availableAgents = useAvailableAgents().items;
+type ProfileCardItemProps = {
+ profile: DraftProfile;
+ isNew: boolean;
+ draftAgent: DraftAgent;
+ currentAgentModelConfig: ModelConfig;
+ permissionSettings: Record;
+ passthroughConfig: PassthroughConfig | null;
+ onProfileChange: (profileId: string, patch: Partial) => void;
+ onProfileMcpChange: (profileId: string, patch: Partial>) => void;
+ onRemoveProfile: (profileId: string) => void;
+ onToastError: (error: unknown) => void;
+};
+
+function ProfileCardItem({
+ profile, isNew, draftAgent, currentAgentModelConfig, permissionSettings,
+ passthroughConfig, onProfileChange, onProfileMcpChange, onRemoveProfile, onToastError,
+}: ProfileCardItemProps) {
+ return (
+
+
+ onProfileChange(profile.id, patch)}
+ modelConfig={currentAgentModelConfig}
+ permissionSettings={permissionSettings}
+ passthroughConfig={passthroughConfig}
+ agentName={draftAgent.name}
+ onRemove={() => onRemoveProfile(profile.id)}
+ canRemove={draftAgent.profiles.length > 1}
+ />
+ onProfileMcpChange(profile.id, patch)}
+ onToastError={onToastError}
+ />
+
+
+ );
+}
+
+function useAgentFormState(initialAgent: DraftAgent, savedAgent: Agent | null, availableAgents: AvailableAgent[]) {
const [draftAgent, setDraftAgent] = useState(initialAgent);
const [saveStatus, setSaveStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
- const [newProfileId, setNewProfileId] = useState(null);
+
const resolveDisplayName = (name: string) =>
availableAgents.find((item: AvailableAgent) => item.name === name)?.display_name ?? '';
- const hasInvalidMcpConfig = useMemo(() => {
- return draftAgent.profiles.some((profile) => Boolean(profile.mcp_config?.error));
- }, [draftAgent.profiles]);
- // Get model config and permission settings for the current agent
const currentAvailableAgent = useMemo(() => {
return availableAgents.find((item: AvailableAgent) => item.name === draftAgent.name) ?? null;
}, [availableAgents, draftAgent.name]);
@@ -123,48 +137,68 @@ function AgentSetupForm({
return currentAvailableAgent?.model_config ?? { default_model: '', available_models: [], supports_dynamic_models: false };
}, [currentAvailableAgent]);
- const permissionSettings = useMemo(() => {
- return currentAvailableAgent?.permission_settings ?? {};
- }, [currentAvailableAgent]);
+ const permissionSettings = useMemo(() => currentAvailableAgent?.permission_settings ?? {}, [currentAvailableAgent]);
+ const passthroughConfig = useMemo(() => currentAvailableAgent?.passthrough_config ?? null, [currentAvailableAgent]);
- const passthroughConfig = useMemo(() => {
- return currentAvailableAgent?.passthrough_config ?? null;
- }, [currentAvailableAgent]);
-
- const isProfileDirty = (draft: DraftProfile, saved?: AgentProfile) => {
- if (!saved) return true;
- return (
- draft.name !== saved.name ||
- draft.model !== saved.model ||
- arePermissionsDirty(draft, saved) ||
- draft.cli_passthrough !== saved.cli_passthrough
- );
- };
+ const hasInvalidMcpConfig = useMemo(() => {
+ return draftAgent.profiles.some((profile) => Boolean(profile.mcp_config?.error));
+ }, [draftAgent.profiles]);
const isAgentDirty = useMemo(() => {
- if (!draftAgent) return false;
- if (!savedAgent) return true;
+ if (!draftAgent || !savedAgent) return !savedAgent;
if ((draftAgent.workspace_id ?? null) !== (savedAgent.workspace_id ?? null)) return true;
if ((draftAgent.mcp_config_path ?? '') !== (savedAgent.mcp_config_path ?? '')) return true;
if (draftAgent.profiles.length !== savedAgent.profiles.length) return true;
- const savedProfiles = new Map(savedAgent.profiles.map((profile) => [profile.id, profile]));
+ const savedProfiles = new Map(savedAgent.profiles.map((p) => [p.id, p]));
for (const profile of draftAgent.profiles) {
- const savedProfile = savedProfiles.get(profile.id);
- if (!savedProfile || isProfileDirty(profile, savedProfile)) return true;
+ if (!savedProfiles.has(profile.id) || isProfileDirty(profile, savedProfiles.get(profile.id))) return true;
}
return false;
}, [draftAgent, savedAgent]);
+ return {
+ draftAgent, setDraftAgent, saveStatus, setSaveStatus,
+ resolveDisplayName, currentAgentModelConfig, permissionSettings, passthroughConfig,
+ hasInvalidMcpConfig, isAgentDirty,
+ };
+}
+
+type AgentHeaderProps = {
+ displayName: string;
+ matchedPath: string | null | undefined;
+ isCreateMode: boolean;
+};
+
+function AgentHeader({ displayName, matchedPath, isCreateMode }: AgentHeaderProps) {
+ return (
+
+
+
+
{displayName}
+
+ {matchedPath ?? 'Installation not detected'}
+
+
+
+ {isCreateMode ? 'Create a new profile for this agent.' : 'Configure profiles and defaults for this agent.'}
+
+
+
+ );
+}
+
+function useAgentStoreSync() {
+ const settingsAgents = useAppStore((state) => state.settingsAgents.items);
+ const setSettingsAgents = useAppStore((state) => state.setSettingsAgents);
+ const setAgentProfiles = useAppStore((state) => state.setAgentProfiles);
+
const syncAgentsToStore = (nextAgents: Agent[]) => {
setSettingsAgents(nextAgents);
setAgentProfiles(
nextAgents.flatMap((agent) =>
agent.profiles.map((profile) => ({
- id: profile.id,
- label: `${profile.agent_display_name} • ${profile.name}`,
- agent_id: agent.id,
- agent_name: agent.name,
- cli_passthrough: profile.cli_passthrough,
+ id: profile.id, label: `${profile.agent_display_name} • ${profile.name}`,
+ agent_id: agent.id, agent_name: agent.name, cli_passthrough: profile.cli_passthrough,
}))
)
);
@@ -172,79 +206,48 @@ function AgentSetupForm({
const upsertAgent = (agent: Agent) => {
const exists = settingsAgents.some((item: Agent) => item.id === agent.id);
- const nextAgents = exists
- ? settingsAgents.map((item: Agent) => (item.id === agent.id ? agent : item))
- : [...settingsAgents, agent];
- syncAgentsToStore(nextAgents);
+ syncAgentsToStore(exists ? settingsAgents.map((item: Agent) => (item.id === agent.id ? agent : item)) : [...settingsAgents, agent]);
};
+ return { upsertAgent };
+}
+
+function useProfileHandlers(
+ setDraftAgent: (fn: (current: DraftAgent) => DraftAgent) => void,
+ resolveDisplayName: (name: string) => string,
+ defaultModel: string,
+ permissionSettings: Record,
+) {
+ const [newProfileId, setNewProfileId] = useState(null);
+
const handleAddProfile = () => {
const draftId = `draft-${generateUUID()}`;
setDraftAgent((current) => ({
...current,
- profiles: [
- ...current.profiles,
- { ...createDraftProfile(current.id, resolveDisplayName(current.name), currentAgentModelConfig.default_model, permissionSettings), id: draftId },
- ],
+ profiles: [...current.profiles, { ...createDraftProfile(current.id, resolveDisplayName(current.name), defaultModel, permissionSettings), id: draftId }],
}));
setNewProfileId(draftId);
};
const handleRemoveProfile = (profileId: string) => {
setDraftAgent((current) => {
- const remaining = current.profiles.filter((profile) => profile.id !== profileId);
- return {
- ...current,
- profiles:
- remaining.length > 0
- ? remaining
- : [createDraftProfile(current.id, resolveDisplayName(current.name), currentAgentModelConfig.default_model, permissionSettings)],
- };
+ const remaining = current.profiles.filter((p) => p.id !== profileId);
+ return { ...current, profiles: remaining.length > 0 ? remaining : [createDraftProfile(current.id, resolveDisplayName(current.name), defaultModel, permissionSettings)] };
});
- if (newProfileId === profileId) {
- setNewProfileId(null);
- }
+ if (newProfileId === profileId) setNewProfileId(null);
};
const handleProfileChange = (profileId: string, patch: Partial) => {
- setDraftAgent((current) => ({
- ...current,
- profiles: current.profiles.map((profile) =>
- profile.id === profileId ? { ...profile, ...patch } : profile
- ),
- }));
+ setDraftAgent((current) => ({ ...current, profiles: current.profiles.map((p) => p.id === profileId ? { ...p, ...patch } : p) }));
};
- const handleProfileMcpChange = (
- profileId: string,
- patch: Partial>
- ) => {
+ const handleProfileMcpChange = (profileId: string, patch: Partial>) => {
setDraftAgent((current) => ({
...current,
- profiles: current.profiles.map((profile) =>
- profile.id === profileId
- ? { ...profile, mcp_config: { ...(profile.mcp_config ?? defaultMcpConfig), ...patch } }
- : profile
- ),
+ profiles: current.profiles.map((p) => p.id === profileId ? { ...p, mcp_config: { ...(p.mcp_config ?? defaultMcpConfig), ...patch } } : p),
}));
};
- const parseProfileMcpServers = (raw: string) => {
- if (!raw.trim()) return {};
- const parsed = JSON.parse(raw) as unknown;
- if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
- throw new Error('MCP servers config must be a JSON object');
- }
- if ('mcpServers' in parsed) {
- const nested = (parsed as { mcpServers?: unknown }).mcpServers;
- if (!nested || typeof nested !== 'object' || Array.isArray(nested)) {
- throw new Error('mcpServers must be a JSON object');
- }
- return nested as Record;
- }
- return parsed as Record;
- };
-
useEffect(() => {
if (!newProfileId) return;
const target = document.getElementById(`profile-card-${newProfileId}`);
@@ -254,191 +257,46 @@ function AgentSetupForm({
return () => clearTimeout(timeout);
}, [newProfileId]);
+ return { newProfileId, handleAddProfile, handleRemoveProfile, handleProfileChange, handleProfileMcpChange };
+}
+
+function AgentSetupForm({ initialAgent, savedAgent, discoveryAgent, onToastError, isCreateMode = false }: AgentSetupFormProps) {
+ const router = useRouter();
+ const availableAgents = useAvailableAgents().items;
+ const { upsertAgent } = useAgentStoreSync();
+
+ const {
+ draftAgent, setDraftAgent, saveStatus, setSaveStatus,
+ resolveDisplayName, currentAgentModelConfig, permissionSettings, passthroughConfig,
+ hasInvalidMcpConfig, isAgentDirty,
+ } = useAgentFormState(initialAgent, savedAgent, availableAgents);
+
+ const { newProfileId, handleAddProfile, handleRemoveProfile, handleProfileChange, handleProfileMcpChange } =
+ useProfileHandlers(setDraftAgent, resolveDisplayName, currentAgentModelConfig.default_model, permissionSettings);
+
const handleSave = async () => {
- if (draftAgent.profiles.some((profile) => !profile.name.trim())) {
- onToastError(new Error('Profile name is required.'));
- return;
- }
- if (draftAgent.profiles.some((profile) => !profile.model.trim())) {
- onToastError(new Error('Model is required for all profiles.'));
- return;
- }
- if (hasInvalidMcpConfig) {
- onToastError(new Error('Fix invalid MCP JSON before saving.'));
- return;
- }
+ if (draftAgent.profiles.some((p) => !p.name.trim())) { onToastError(new Error('Profile name is required.')); return; }
+ if (draftAgent.profiles.some((p) => !p.model.trim())) { onToastError(new Error('Model is required for all profiles.')); return; }
+ if (hasInvalidMcpConfig) { onToastError(new Error('Fix invalid MCP JSON before saving.')); return; }
setSaveStatus('loading');
+ const callbacks = { onToastError, currentAgentModelConfig, permissionSettings, resolveDisplayName, upsertAgent, setDraftAgent, ensureProfiles, cloneAgent, replaceRoute: (path: string) => router.replace(path) };
try {
- if (!savedAgent) {
- let created = await createAgentAction({
- name: draftAgent.name,
- workspace_id: draftAgent.workspace_id,
- profiles: draftAgent.profiles.map((profile) => ({
- name: profile.name,
- model: profile.model,
- ...permissionsToProfilePatch(profile),
- cli_passthrough: profile.cli_passthrough ?? false,
- })),
- });
- if (created.profiles.length === draftAgent.profiles.length) {
- for (let index = 0; index < draftAgent.profiles.length; index += 1) {
- const draftProfile = draftAgent.profiles[index];
- const createdProfile = created.profiles[index];
- if (
- draftProfile.mcp_config &&
- draftProfile.mcp_config.dirty &&
- draftProfile.mcp_config.servers.trim()
- ) {
- try {
- const servers = parseProfileMcpServers(draftProfile.mcp_config.servers);
- await updateAgentProfileMcpConfigAction(createdProfile.id, {
- enabled: draftProfile.mcp_config.enabled,
- mcpServers: servers,
- });
- } catch (error) {
- onToastError(error);
- }
- }
- }
- } else {
- for (const draftProfile of draftAgent.profiles) {
- if (
- !draftProfile.mcp_config ||
- !draftProfile.mcp_config.dirty ||
- !draftProfile.mcp_config.servers.trim()
- ) {
- continue;
- }
- const createdProfile = created.profiles.find(
- (profile) => profile.name === draftProfile.name
- );
- if (!createdProfile) continue;
- try {
- const servers = parseProfileMcpServers(draftProfile.mcp_config.servers);
- await updateAgentProfileMcpConfigAction(createdProfile.id, {
- enabled: draftProfile.mcp_config.enabled,
- mcpServers: servers,
- });
- } catch (error) {
- onToastError(error);
- }
- }
- }
- if ((draftAgent.mcp_config_path ?? '') !== (created.mcp_config_path ?? '')) {
- created = await updateAgentAction(created.id, {
- mcp_config_path: draftAgent.mcp_config_path ?? '',
- });
- }
- upsertAgent(created);
- setDraftAgent(ensureProfiles(cloneAgent(created), resolveDisplayName(created.name), currentAgentModelConfig.default_model, permissionSettings));
- router.replace(`/settings/agents/${encodeURIComponent(created.name)}`);
- } else {
- const agentPatch: { workspace_id?: string | null; mcp_config_path?: string | null } = {};
- if ((draftAgent.workspace_id ?? null) !== (savedAgent.workspace_id ?? null)) {
- agentPatch.workspace_id = draftAgent.workspace_id ?? null;
- }
- if ((draftAgent.mcp_config_path ?? '') !== (savedAgent.mcp_config_path ?? '')) {
- agentPatch.mcp_config_path = draftAgent.mcp_config_path ?? '';
- }
- if (Object.keys(agentPatch).length > 0) {
- await updateAgentAction(savedAgent.id, agentPatch);
- }
-
- const savedProfilesById = new Map(savedAgent.profiles.map((profile) => [profile.id, profile]));
- // In create mode, preserve existing profiles; otherwise start fresh
- const nextProfiles: AgentProfile[] = isCreateMode ? [...savedAgent.profiles] : [];
- for (const profile of draftAgent.profiles) {
- const savedProfile = savedProfilesById.get(profile.id);
- if (!savedProfile) {
- const createdProfile = await createAgentProfileAction(savedAgent.id, {
- name: profile.name,
- model: profile.model,
- ...permissionsToProfilePatch(profile),
- cli_passthrough: profile.cli_passthrough ?? false,
- });
- if (profile.mcp_config && profile.mcp_config.dirty && profile.mcp_config.servers.trim()) {
- try {
- const servers = parseProfileMcpServers(profile.mcp_config.servers);
- await updateAgentProfileMcpConfigAction(createdProfile.id, {
- enabled: profile.mcp_config.enabled,
- mcpServers: servers,
- });
- } catch (error) {
- onToastError(error);
- }
- }
- nextProfiles.push(createdProfile);
- continue;
- }
- if (isProfileDirty(profile, savedProfile)) {
- const updatedProfile = await updateAgentProfileAction(profile.id, {
- name: profile.name,
- model: profile.model,
- ...permissionsToProfilePatch(profile),
- });
- nextProfiles.push(updatedProfile);
- continue;
- }
- nextProfiles.push(savedProfile);
- }
- // In create mode, don't delete existing profiles - we're only adding new ones
- if (!isCreateMode) {
- for (const savedProfile of savedAgent.profiles) {
- const stillExists = draftAgent.profiles.some((profile) => profile.id === savedProfile.id);
- if (!stillExists) {
- await deleteAgentProfileAction(savedProfile.id);
- }
- }
- }
-
- const nextAgent = {
- ...savedAgent,
- workspace_id: draftAgent.workspace_id ?? null,
- mcp_config_path: draftAgent.mcp_config_path ?? '',
- profiles: nextProfiles,
- };
- upsertAgent(nextAgent);
- setDraftAgent(ensureProfiles(cloneAgent(nextAgent), resolveDisplayName(nextAgent.name), currentAgentModelConfig.default_model, permissionSettings));
- // In create mode, redirect to manage mode after successful save
- if (isCreateMode) {
- router.replace(`/settings/agents/${encodeURIComponent(savedAgent.name)}`);
- }
- }
+ if (!savedAgent) { await saveNewAgent(draftAgent, callbacks); }
+ else { await saveExistingAgent(draftAgent, savedAgent, isCreateMode, callbacks); }
setSaveStatus('success');
- } catch (error) {
- setSaveStatus('error');
- onToastError(error);
- }
+ } catch (error) { setSaveStatus('error'); onToastError(error); }
};
+ const displayName = draftAgent.profiles[0]?.agent_display_name ?? draftAgent.name;
+
return (
-
-
-
-
- {draftAgent.profiles[0]?.agent_display_name ?? draftAgent.name}
-
-
- {discoveryAgent?.matched_path ?? 'Installation not detected'}
-
-
-
- {isCreateMode ? 'Create a new profile for this agent.' : 'Configure profiles and defaults for this agent.'}
-
-
-
-
+
-
-
- {isCreateMode
- ? `Create ${draftAgent.profiles[0]?.agent_display_name ?? draftAgent.name} Profile`
- : `${draftAgent.profiles[0]?.agent_display_name ?? draftAgent.name} Profiles`}
-
+ {isCreateMode ? `Create ${displayName} Profile` : `${displayName} Profiles`}
{isAgentDirty && }
@@ -447,56 +305,20 @@ function AgentSetupForm({
- {draftAgent.profiles.map((profile) => {
- const isNew = profile.id === newProfileId;
- return (
-
-
- handleProfileChange(profile.id, patch)}
- modelConfig={currentAgentModelConfig}
- permissionSettings={permissionSettings}
- passthroughConfig={passthroughConfig}
- agentName={draftAgent.name}
- onRemove={() => handleRemoveProfile(profile.id)}
- canRemove={draftAgent.profiles.length > 1}
- />
-
- handleProfileMcpChange(profile.id, patch)}
- onToastError={onToastError}
- />
-
-
- );
- })}
+ {draftAgent.profiles.map((profile) => (
+
+ ))}
-
+
-
);
}
@@ -512,51 +334,24 @@ export default function AgentSetupPage() {
const savedAgents = useAppStore((state) => state.settingsAgents.items);
const availableAgents = useAvailableAgents().items;
- const discoveryAgent = useMemo(() => {
- return discoveryAgents.find((agent: AgentDiscovery) => agent.name === decodedKey);
- }, [decodedKey, discoveryAgents]);
-
- const savedAgent = useMemo(() => {
- return savedAgents.find((agent: Agent) => agent.id === decodedKey || agent.name === decodedKey) ?? null;
- }, [decodedKey, savedAgents]);
+ const discoveryAgent = useMemo(() => discoveryAgents.find((a: AgentDiscovery) => a.name === decodedKey), [decodedKey, discoveryAgents]);
+ const savedAgent = useMemo(() => savedAgents.find((a: Agent) => a.id === decodedKey || a.name === decodedKey) ?? null, [decodedKey, savedAgents]);
const initialAgent = useMemo(() => {
if (!decodedKey) return null;
- const resolveAvailableAgent = (name: string) =>
- availableAgents.find((item: AvailableAgent) => item.name === name);
- const resolveDisplayName = (name: string) =>
- resolveAvailableAgent(name)?.display_name ?? '';
- const resolveDefaultModel = (name: string) =>
- resolveAvailableAgent(name)?.model_config?.default_model ?? '';
- const resolvePermissionSettings = (name: string) =>
- resolveAvailableAgent(name)?.permission_settings;
+ const resolve = (name: string) => availableAgents.find((item: AvailableAgent) => item.name === name);
+ const dn = (name: string) => resolve(name)?.display_name ?? '';
+ const dm = (name: string) => resolve(name)?.model_config?.default_model ?? '';
+ const ps = (name: string) => resolve(name)?.permission_settings;
if (savedAgent) {
- // In create mode, start with a blank profile instead of loading existing profiles
if (isCreateMode) {
- const draft: DraftAgent = {
- ...savedAgent,
- workspace_id: savedAgent.workspace_id ?? null,
- mcp_config_path: savedAgent.mcp_config_path ?? '',
- profiles: [],
- isNew: false,
- };
- return ensureProfiles(draft, resolveDisplayName(savedAgent.name), resolveDefaultModel(savedAgent.name), resolvePermissionSettings(savedAgent.name));
+ return ensureProfiles({ ...savedAgent, workspace_id: savedAgent.workspace_id ?? null, mcp_config_path: savedAgent.mcp_config_path ?? '', profiles: [], isNew: false }, dn(savedAgent.name), dm(savedAgent.name), ps(savedAgent.name));
}
- return ensureProfiles(cloneAgent(savedAgent), resolveDisplayName(savedAgent.name), resolveDefaultModel(savedAgent.name), resolvePermissionSettings(savedAgent.name));
+ return ensureProfiles(cloneAgent(savedAgent), dn(savedAgent.name), dm(savedAgent.name), ps(savedAgent.name));
}
if (discoveryAgent) {
- const draft: DraftAgent = {
- id: `draft-${generateUUID()}`,
- name: discoveryAgent.name,
- workspace_id: null,
- supports_mcp: discoveryAgent.supports_mcp,
- mcp_config_path: discoveryAgent.mcp_config_path ?? '',
- profiles: [],
- created_at: new Date().toISOString(),
- updated_at: new Date().toISOString(),
- isNew: true,
- };
- return ensureProfiles(draft, resolveDisplayName(draft.name), resolveDefaultModel(draft.name), resolvePermissionSettings(draft.name));
+ const draft: DraftAgent = { id: `draft-${generateUUID()}`, name: discoveryAgent.name, workspace_id: null, supports_mcp: discoveryAgent.supports_mcp, mcp_config_path: discoveryAgent.mcp_config_path ?? '', profiles: [], created_at: new Date().toISOString(), updated_at: new Date().toISOString(), isNew: true };
+ return ensureProfiles(draft, dn(draft.name), dm(draft.name), ps(draft.name));
}
return null;
}, [decodedKey, discoveryAgent, savedAgent, availableAgents, isCreateMode]);
@@ -566,34 +361,23 @@ export default function AgentSetupPage() {
Agent not found.
-
- Back to Agents
-
+ Back to Agents
);
}
- if (!initialAgent) {
- return null;
- }
+ if (!initialAgent) return null;
const handleToastError = (error: unknown) => {
- toast({
- title: 'Failed to save agent',
- description: error instanceof Error ? error.message : 'Request failed',
- variant: 'error',
- });
+ toast({ title: 'Failed to save agent', description: error instanceof Error ? error.message : 'Request failed', variant: 'error' });
};
return (
);
}
diff --git a/apps/web/app/settings/agents/[agentId]/profile-mcp-config-card.tsx b/apps/web/app/settings/agents/[agentId]/profile-mcp-config-card.tsx
index 013bb863a9..16ddc6c381 100644
--- a/apps/web/app/settings/agents/[agentId]/profile-mcp-config-card.tsx
+++ b/apps/web/app/settings/agents/[agentId]/profile-mcp-config-card.tsx
@@ -28,6 +28,259 @@ type ProfileMcpConfigCardProps = {
onToastError: (error: unknown) => void;
};
+const POPULAR_SERVERS: Record> = {
+ playwright: {
+ type: 'stdio',
+ command: 'npx',
+ args: ['-y', '@modelcontextprotocol/server-playwright'],
+ },
+ 'chrome-devtools': {
+ type: 'stdio',
+ command: 'npx',
+ args: ['-y', '@modelcontextprotocol/server-chrome-devtools'],
+ },
+ context7: {
+ type: 'stdio',
+ command: 'npx',
+ args: ['-y', '@context7/mcp'],
+ env: {
+ CONTEXT7_API_KEY: 'your_api_key_here',
+ },
+ },
+ github: {
+ type: 'stdio',
+ command: 'npx',
+ args: ['-y', '@modelcontextprotocol/server-github'],
+ env: {
+ GITHUB_TOKEN: 'your_token_here',
+ },
+ },
+};
+
+const KANDEV_TOOLS_DESCRIPTION = 'Tools: list_workspaces, list_boards, list_workflow_steps, list_tasks, create_task, update_task';
+
+type PopularServerButtonProps = {
+ label: string;
+ displayName: string;
+ onApply: (label: string) => void;
+};
+
+function PopularServerButton({ label, displayName, onApply }: PopularServerButtonProps) {
+ return (
+ onApply(label)}
+ >
+ + {displayName}
+
+ );
+}
+
+function applyPopularServerToJson(
+ currentServers: string,
+ label: string,
+ isDraft: boolean,
+ onDraftStateChange?: (next: { servers?: string; dirty?: boolean; error?: string | null }) => void,
+ handleMcpServersChange?: (value: string) => void,
+) {
+ const base = currentServers.trim() || '{\n "mcpServers": {}\n}';
+ let parsed: Record = {};
+ try {
+ parsed = JSON.parse(base) as Record;
+ } catch {
+ return;
+ }
+ const root =
+ parsed && typeof parsed === 'object' && !Array.isArray(parsed)
+ ? parsed
+ : { mcpServers: {} };
+ const servers = (root.mcpServers &&
+ typeof root.mcpServers === 'object' &&
+ !Array.isArray(root.mcpServers)
+ ? root.mcpServers
+ : {}) as Record;
+
+ if (servers[label]) return;
+ servers[label] = POPULAR_SERVERS[label] ?? { type: 'stdio', command: 'npx', args: ['-y'] };
+ root.mcpServers = servers;
+ const nextValue = JSON.stringify(root, null, 2);
+
+ if (isDraft) {
+ onDraftStateChange?.({ servers: nextValue, dirty: true, error: null });
+ return;
+ }
+ handleMcpServersChange?.(nextValue);
+}
+
+function validateDraftServers(value: string): string | null {
+ if (!value.trim()) return null;
+ try {
+ const parsed = JSON.parse(value);
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
+ return 'MCP servers config must be a JSON object';
+ }
+ if ('mcpServers' in parsed) {
+ const nested = (parsed as { mcpServers?: unknown }).mcpServers;
+ if (!nested || typeof nested !== 'object' || Array.isArray(nested)) {
+ return 'mcpServers must be a JSON object';
+ }
+ }
+ } catch {
+ return 'Invalid JSON';
+ }
+ return null;
+}
+
+type McpServersEditorProps = {
+ profileId: string;
+ currentServers: string;
+ currentError: string | null;
+ isDraft: boolean;
+ isEditableProfile: boolean;
+ onDraftStateChange?: (next: { servers?: string; dirty?: boolean; error?: string | null }) => void;
+ handleMcpServersChange: (value: string) => void;
+};
+
+function McpServersEditor({
+ profileId,
+ currentServers,
+ currentError,
+ isDraft,
+ isEditableProfile,
+ onDraftStateChange,
+ handleMcpServersChange,
+}: McpServersEditorProps) {
+ const handleApplyServer = (label: string) => {
+ applyPopularServerToJson(currentServers, label, isDraft, onDraftStateChange, handleMcpServersChange);
+ };
+
+ const handleDraftChange = (value: string) => {
+ if (!onDraftStateChange) return;
+ const error = validateDraftServers(value);
+ onDraftStateChange({ servers: value, dirty: true, error });
+ };
+
+ return (
+
+ );
+}
+
+type McpConfigState = {
+ isDraft: boolean;
+ isEditableProfile: boolean;
+ currentEnabled: boolean;
+ currentServers: string;
+ currentError: string | null;
+ currentDirty: boolean;
+};
+
+type ResolveMcpConfigInput = {
+ draftState: ProfileMcpConfigCardProps['draftState'];
+ profileId: string;
+ mcpEnabled: boolean;
+ mcpServers: string;
+ mcpError: string | null;
+ mcpDirty: boolean;
+};
+
+function resolveMcpConfigState(input: ResolveMcpConfigInput): McpConfigState {
+ const isDraft = Boolean(input.draftState);
+ const isEditableProfile = !isDraft && Boolean(input.profileId) && !input.profileId.startsWith('draft-');
+ return {
+ isDraft,
+ isEditableProfile,
+ currentEnabled: isDraft ? input.draftState?.enabled ?? false : input.mcpEnabled,
+ currentServers: isDraft ? input.draftState?.servers ?? '' : input.mcpServers,
+ currentError: isDraft ? input.draftState?.error ?? null : input.mcpError,
+ currentDirty: isDraft ? input.draftState?.dirty ?? false : input.mcpDirty,
+ };
+}
+
+type McpEnableToggleProps = {
+ currentEnabled: boolean;
+ isDraft: boolean;
+ isEditableProfile: boolean;
+ onDraftStateChange?: (next: { enabled?: boolean; dirty?: boolean }) => void;
+ setMcpEnabled: (enabled: boolean) => void;
+};
+
+function McpEnableToggle({ currentEnabled, isDraft, isEditableProfile, onDraftStateChange, setMcpEnabled }: McpEnableToggleProps) {
+ return (
+
+
+
Enable MCP
+
+ Allow this profile to use MCP servers during sessions.
+
+
+
{
+ if (isDraft) {
+ onDraftStateChange?.({ enabled: checked, dirty: true });
+ return;
+ }
+ setMcpEnabled(checked);
+ }}
+ disabled={!isEditableProfile && !isDraft}
+ />
+
+ );
+}
+
+function McpProfileHint({ isDraft, isEditableProfile }: { isDraft: boolean; isEditableProfile: boolean }) {
+ if (isEditableProfile) return null;
+ return (
+
+ {isDraft
+ ? 'MCP config will be applied after the profile is saved.'
+ : 'Save this profile to configure MCP servers.'}
+
+ );
+}
+
export function ProfileMcpConfigCard({
profileId,
supportsMcp,
@@ -37,227 +290,44 @@ export function ProfileMcpConfigCard({
onToastError,
}: ProfileMcpConfigCardProps) {
const {
- mcpEnabled,
- mcpServers,
- mcpError,
- mcpDirty,
- mcpStatus,
- setMcpEnabled,
- handleMcpServersChange,
- handleSaveMcp,
- } = useProfileMcpConfig({
- profileId,
- supportsMcp,
- initialConfig,
- onToastError,
- });
+ mcpEnabled, mcpServers, mcpError, mcpDirty, mcpStatus,
+ setMcpEnabled, handleMcpServersChange, handleSaveMcp,
+ } = useProfileMcpConfig({ profileId, supportsMcp, initialConfig, onToastError });
- if (!supportsMcp) {
- return null;
- }
+ if (!supportsMcp) return null;
- const isDraft = Boolean(draftState);
- const isEditableProfile = !isDraft && Boolean(profileId) && !profileId.startsWith('draft-');
-
- const currentEnabled = isDraft ? draftState?.enabled ?? false : mcpEnabled;
- const currentServers = isDraft ? draftState?.servers ?? '' : mcpServers;
- const currentError = isDraft ? draftState?.error ?? null : mcpError;
- const currentDirty = isDraft ? draftState?.dirty ?? false : mcpDirty;
- const kandevTools = 'Tools: list_workspaces, list_boards, list_workflow_steps, list_tasks, create_task, update_task';
-
- // Note: kandev MCP server is automatically injected by agentctl at session creation time.
- // Users only need to configure external MCP servers here.
- const popularServers: Record> = {
- playwright: {
- type: 'stdio',
- command: 'npx',
- args: ['-y', '@modelcontextprotocol/server-playwright'],
- },
- 'chrome-devtools': {
- type: 'stdio',
- command: 'npx',
- args: ['-y', '@modelcontextprotocol/server-chrome-devtools'],
- },
- context7: {
- type: 'stdio',
- command: 'npx',
- args: ['-y', '@context7/mcp'],
- env: {
- CONTEXT7_API_KEY: 'your_api_key_here',
- },
- },
- github: {
- type: 'stdio',
- command: 'npx',
- args: ['-y', '@modelcontextprotocol/server-github'],
- env: {
- GITHUB_TOKEN: 'your_token_here',
- },
- },
- };
-
- const applyPopularServer = (label: string) => {
- const base = currentServers.trim() || '{\n "mcpServers": {}\n}';
- let parsed: Record = {};
- try {
- parsed = JSON.parse(base) as Record;
- } catch {
- return;
- }
- const root =
- parsed && typeof parsed === 'object' && !Array.isArray(parsed)
- ? parsed
- : { mcpServers: {} };
- const servers = (root.mcpServers &&
- typeof root.mcpServers === 'object' &&
- !Array.isArray(root.mcpServers)
- ? root.mcpServers
- : {}) as Record;
-
- if (servers[label]) return;
- servers[label] = popularServers[label] ?? { type: 'stdio', command: 'npx', args: ['-y'] };
- root.mcpServers = servers;
- const nextValue = JSON.stringify(root, null, 2);
-
- if (isDraft) {
- onDraftStateChange?.({ servers: nextValue, dirty: true, error: null });
- return;
- }
- handleMcpServersChange(nextValue);
- };
-
- const handleDraftServersChange = (value: string) => {
- if (!onDraftStateChange) return;
- let error: string | null = null;
- if (value.trim()) {
- try {
- const parsed = JSON.parse(value);
- if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
- error = 'MCP servers config must be a JSON object';
- } else if ('mcpServers' in parsed) {
- const nested = (parsed as { mcpServers?: unknown }).mcpServers;
- if (!nested || typeof nested !== 'object' || Array.isArray(nested)) {
- error = 'mcpServers must be a JSON object';
- }
- }
- } catch {
- error = 'Invalid JSON';
- }
- }
- onDraftStateChange({ servers: value, dirty: true, error });
- };
+ const state = resolveMcpConfigState({ draftState, profileId, mcpEnabled, mcpServers, mcpError, mcpDirty });
return (
MCP Configuration
- {currentDirty && }
+ {state.currentDirty && }
- {!isEditableProfile && (
-
- {isDraft
- ? 'MCP config will be applied after the profile is saved.'
- : 'Save this profile to configure MCP servers.'}
-
- )}
-
-
-
Enable MCP
-
- Allow this profile to use MCP servers during sessions.
-
-
-
{
- if (isDraft) {
- onDraftStateChange?.({ enabled: checked, dirty: true });
- return;
- }
- setMcpEnabled(checked);
- }}
- disabled={!isEditableProfile && !isDraft}
- />
-
-
-
+
+
+
- {isEditableProfile ? (
-
+ {state.isEditableProfile ? (
+
) : null}
diff --git a/apps/web/app/settings/agents/[agentId]/profiles/[profileId]/command-preview-card.tsx b/apps/web/app/settings/agents/[agentId]/profiles/[profileId]/command-preview-card.tsx
index 2c721e05c3..abdb73abd7 100644
--- a/apps/web/app/settings/agents/[agentId]/profiles/[profileId]/command-preview-card.tsx
+++ b/apps/web/app/settings/agents/[agentId]/profiles/[profileId]/command-preview-card.tsx
@@ -14,6 +14,85 @@ type CommandPreviewCardProps = {
cliPassthrough: boolean;
};
+function CommandPreviewLoading() {
+ return (
+
+
+
+
+ );
+}
+
+function CommandPreviewError({ error }: { error: string }) {
+ return (
+
+ );
+}
+
+function CommandPreviewEmpty() {
+ return (
+
+
No command preview available.
+
+ );
+}
+
+type CommandPreviewContentProps = {
+ preview: CommandPreviewResponse;
+};
+
+function CommandPreviewContent({ preview }: CommandPreviewContentProps) {
+ const [copied, setCopied] = useState(false);
+
+ const handleCopy = async () => {
+ if (!preview.command_string) return;
+
+ try {
+ await navigator.clipboard.writeText(preview.command_string);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ } catch {
+ const textArea = document.createElement('textarea');
+ textArea.value = preview.command_string;
+ document.body.appendChild(textArea);
+ textArea.select();
+ document.execCommand('copy');
+ document.body.removeChild(textArea);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ }
+ };
+
+ return (
+ <>
+
+
+ {preview.command_string}
+
+
+ {copied ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ {'{prompt}'} will be replaced with your task description or follow-up message.
+
+ >
+ );
+}
+
export function CommandPreviewCard({
agentName,
model,
@@ -23,9 +102,7 @@ export function CommandPreviewCard({
const [preview, setPreview] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
- const [copied, setCopied] = useState(false);
- // Stable key for debouncing - changes when settings change
const settingsKey = useMemo(() =>
JSON.stringify({ model, permissionSettings, cliPassthrough }),
[model, permissionSettings, cliPassthrough]
@@ -50,32 +127,12 @@ export function CommandPreviewCard({
} finally {
setLoading(false);
}
- }, 300); // 300ms debounce
+ }, 300);
return () => clearTimeout(timeoutId);
// eslint-disable-next-line react-hooks/exhaustive-deps -- settingsKey already includes model, permissionSettings, cliPassthrough
}, [agentName, settingsKey]);
- const handleCopy = async () => {
- if (!preview?.command_string) return;
-
- try {
- await navigator.clipboard.writeText(preview.command_string);
- setCopied(true);
- setTimeout(() => setCopied(false), 2000);
- } catch {
- // Fallback for older browsers
- const textArea = document.createElement('textarea');
- textArea.value = preview.command_string;
- document.body.appendChild(textArea);
- textArea.select();
- document.execCommand('copy');
- document.body.removeChild(textArea);
- setCopied(true);
- setTimeout(() => setCopied(false), 2000);
- }
- };
-
return (
@@ -89,51 +146,10 @@ export function CommandPreviewCard({
The CLI command that will be executed based on the current settings.
- {loading && (
-
-
-
-
- )}
-
- {error && (
-
- )}
-
- {!loading && !error && preview && (
- <>
-
-
- {preview.command_string}
-
-
- {copied ? (
-
- ) : (
-
- )}
-
-
-
-
- {'{prompt}'} will be replaced with your task description or follow-up message.
-
- >
- )}
-
- {!loading && !error && !preview && (
-
-
No command preview available.
-
- )}
+ {loading && }
+ {error && }
+ {!loading && !error && preview && }
+ {!loading && !error && !preview && }
);
diff --git a/apps/web/app/settings/agents/[agentId]/use-profile-mcp-config.ts b/apps/web/app/settings/agents/[agentId]/use-profile-mcp-config.ts
index ab77100a95..2933036436 100644
--- a/apps/web/app/settings/agents/[agentId]/use-profile-mcp-config.ts
+++ b/apps/web/app/settings/agents/[agentId]/use-profile-mcp-config.ts
@@ -24,17 +24,107 @@ type UseProfileMcpConfigResult = {
handleSaveMcp: () => Promise;
};
+const EMPTY_EXAMPLE = '{\n "mcpServers": {}\n}';
+
+function serializeServers(config: AgentProfileMcpConfig | null): string {
+ if (!config?.servers || Object.keys(config.servers).length === 0) {
+ return EMPTY_EXAMPLE;
+ }
+ return JSON.stringify({ mcpServers: config.servers }, null, 2);
+}
+
+function normalizeServers(value: unknown): Record {
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
+ throw new Error('MCP servers config must be a JSON object');
+ }
+ if ('mcpServers' in value) {
+ const nested = (value as { mcpServers?: unknown }).mcpServers;
+ if (!nested || typeof nested !== 'object' || Array.isArray(nested)) {
+ throw new Error('mcpServers must be a JSON object');
+ }
+ return nested as Record;
+ }
+ return value as Record;
+}
+
+function useResetOnUnsupported(
+ supportsMcp: boolean,
+ isEditableProfile: boolean,
+ setters: {
+ setMcpConfig: (v: AgentProfileMcpConfig | null) => void;
+ setMcpEnabledState: (v: boolean) => void;
+ setMcpServers: (v: string) => void;
+ setMcpDirty: (v: boolean) => void;
+ setMcpError: (v: string | null) => void;
+ setMcpStatus: (v: McpStatus) => void;
+ },
+) {
+ useEffect(() => {
+ if (supportsMcp && isEditableProfile) return;
+ let active = true;
+ Promise.resolve().then(() => {
+ if (!active) return;
+ setters.setMcpConfig(null);
+ setters.setMcpEnabledState(false);
+ setters.setMcpServers(EMPTY_EXAMPLE);
+ setters.setMcpDirty(false);
+ setters.setMcpError(null);
+ setters.setMcpStatus('idle');
+ });
+ return () => { active = false; };
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- intentionally only tracking these deps
+ }, [supportsMcp, isEditableProfile]);
+}
+
+function useFetchConfig(
+ profileId: string,
+ supportsMcp: boolean,
+ isEditableProfile: boolean,
+ hasInitialConfig: boolean,
+ setters: {
+ setMcpConfig: (v: AgentProfileMcpConfig) => void;
+ setMcpEnabledState: (v: boolean) => void;
+ setMcpServers: (v: string) => void;
+ setMcpDirty: (v: boolean) => void;
+ setMcpError: (v: string | null) => void;
+ setMcpStatus: (v: McpStatus) => void;
+ },
+) {
+ useEffect(() => {
+ if (!supportsMcp || !isEditableProfile || hasInitialConfig) return;
+ let active = true;
+ Promise.resolve().then(() => {
+ if (!active) return;
+ setters.setMcpStatus('loading');
+ });
+ getAgentProfileMcpConfigAction(profileId)
+ .then((config) => {
+ if (!active) return;
+ setters.setMcpConfig(config);
+ setters.setMcpEnabledState(config.enabled);
+ setters.setMcpServers(serializeServers(config));
+ setters.setMcpDirty(false);
+ setters.setMcpError(null);
+ setters.setMcpStatus('idle');
+ })
+ .catch((error) => {
+ if (!active) return;
+ setters.setMcpError(error instanceof Error ? error.message : 'Failed to load MCP config');
+ setters.setMcpStatus('error');
+ });
+
+ return () => { active = false; };
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- intentionally only tracking these deps
+ }, [profileId, supportsMcp, isEditableProfile, hasInitialConfig]);
+}
+
export function useProfileMcpConfig({
profileId,
supportsMcp,
initialConfig,
onToastError,
}: UseProfileMcpConfigParams): UseProfileMcpConfigResult {
- const emptyExample = '{\n "mcpServers": {}\n}';
- const initialServers =
- initialConfig?.servers && Object.keys(initialConfig.servers).length > 0
- ? JSON.stringify({ mcpServers: initialConfig.servers }, null, 2)
- : emptyExample;
+ const initialServers = serializeServers(initialConfig ?? null);
const [mcpConfig, setMcpConfig] = useState(
initialConfig ?? null
);
@@ -47,81 +137,28 @@ export function useProfileMcpConfig({
const isEditableProfile = Boolean(profileId) && !profileId.startsWith('draft-');
- useEffect(() => {
- let active = true;
- if (!supportsMcp || !isEditableProfile) {
- Promise.resolve().then(() => {
- if (!active) return;
- setMcpConfig(null);
- setMcpEnabledState(false);
- setMcpServers(emptyExample);
- setMcpDirty(false);
- setMcpError(null);
- setMcpStatus('idle');
- });
- return () => {
- active = false;
- };
- }
- if (hasInitialConfig) {
- return () => {
- active = false;
- };
- }
+ const stateSetters = {
+ setMcpConfig,
+ setMcpEnabledState,
+ setMcpServers,
+ setMcpDirty,
+ setMcpError,
+ setMcpStatus,
+ };
- Promise.resolve().then(() => {
- if (!active) return;
- setMcpStatus('loading');
- });
- getAgentProfileMcpConfigAction(profileId)
- .then((config) => {
- if (!active) return;
- setMcpConfig(config);
- setMcpEnabledState(config.enabled);
- const nextServers =
- config.servers && Object.keys(config.servers).length > 0
- ? JSON.stringify({ mcpServers: config.servers }, null, 2)
- : emptyExample;
- setMcpServers(nextServers);
- setMcpDirty(false);
- setMcpError(null);
- setMcpStatus('idle');
- })
- .catch((error) => {
- if (!active) return;
- setMcpError(error instanceof Error ? error.message : 'Failed to load MCP config');
- setMcpStatus('error');
- });
+ useResetOnUnsupported(supportsMcp, isEditableProfile, stateSetters);
+ useFetchConfig(profileId, supportsMcp, isEditableProfile, hasInitialConfig, stateSetters);
- return () => {
- active = false;
- };
- }, [profileId, supportsMcp, isEditableProfile, hasInitialConfig]);
-
- const isEmptyExample = (value: string) => value.trim() === emptyExample.trim();
+ const isEmptyExample = (value: string) => value.trim() === EMPTY_EXAMPLE.trim();
const setMcpEnabled = (enabled: boolean) => {
setMcpEnabledState(enabled);
setMcpDirty(true);
};
- const normalizeServers = (value: unknown) => {
- if (!value || typeof value !== 'object' || Array.isArray(value)) {
- throw new Error('MCP servers config must be a JSON object');
- }
- if ('mcpServers' in value) {
- const nested = (value as { mcpServers?: unknown }).mcpServers;
- if (!nested || typeof nested !== 'object' || Array.isArray(nested)) {
- throw new Error('mcpServers must be a JSON object');
- }
- return nested as Record;
- }
- return value as Record;
- };
-
const handleMcpServersChange = (value: string) => {
if (!value.trim()) {
- setMcpServers(emptyExample);
+ setMcpServers(EMPTY_EXAMPLE);
} else {
setMcpServers(value);
}
@@ -140,10 +177,9 @@ export function useProfileMcpConfig({
};
const handleSaveMcp = async () => {
- if (!isEditableProfile) {
- return;
- }
+ if (!isEditableProfile) return;
setMcpStatus('loading');
+
let servers: Record = {};
try {
const raw = isEmptyExample(mcpServers) ? '{}' : mcpServers;
@@ -163,11 +199,7 @@ export function useProfileMcpConfig({
});
setMcpConfig(updated);
setMcpEnabledState(updated.enabled);
- const nextServers =
- updated.servers && Object.keys(updated.servers).length > 0
- ? JSON.stringify({ mcpServers: updated.servers }, null, 2)
- : emptyExample;
- setMcpServers(nextServers);
+ setMcpServers(serializeServers(updated));
setMcpDirty(false);
setMcpError(null);
setMcpStatus('success');
diff --git a/apps/web/app/settings/agents/page.tsx b/apps/web/app/settings/agents/page.tsx
index 12c4c4f2a9..6a7dd74f5a 100644
--- a/apps/web/app/settings/agents/page.tsx
+++ b/apps/web/app/settings/agents/page.tsx
@@ -13,6 +13,65 @@ import { useAvailableAgents } from '@/hooks/domains/settings/use-available-agent
import { AgentLogo } from '@/components/agent-logo';
import type { AgentDiscovery, Agent, AvailableAgent, AgentProfile } from '@/lib/types/http';
+type AgentCardProps = {
+ agent: AgentDiscovery;
+ savedAgent: Agent | undefined;
+ displayName: string;
+};
+
+function AgentCard({ agent, savedAgent, displayName }: AgentCardProps) {
+ const configured = Boolean(savedAgent && savedAgent.profiles.length > 0);
+ const hasAgentRecord = Boolean(savedAgent);
+ return (
+
+
+
+
+
+
{displayName}
+ {agent.supports_mcp &&
MCP }
+ {configured &&
Configured }
+
+ {agent.matched_path && (
+
Detected at {agent.matched_path}
+ )}
+
+
+
+
+ {hasAgentRecord ? 'Create new profile' : 'Setup Profile'}
+
+
+
+
+ );
+}
+
+type ProfileListItemProps = {
+ agent: Agent;
+ profile: AgentProfile;
+};
+
+function ProfileListItem({ agent, profile }: ProfileListItemProps) {
+ const profilePath = `/settings/agents/${encodeURIComponent(agent.name)}/profiles/${profile.id}`;
+ return (
+
+
+
+
+
+
+ {agent.profiles[0]?.agent_display_name ?? agent.name}
+
+ {agent.supports_mcp &&
MCP }
+
{profile.name}
+
+
+
+
+ );
+}
+
export default function AgentsSettingsPage() {
const discoveryAgents = useAppStore((state) => state.agentDiscovery.items);
const savedAgents = useAppStore((state) => state.settingsAgents.items);
@@ -30,6 +89,9 @@ export default function AgentsSettingsPage() {
[savedAgents]
);
+ const resolveDisplayName = (name: string) =>
+ availableAgents.find((item: AvailableAgent) => item.name === name)?.display_name ?? name;
+
const handleRescan = async () => {
if (rescanning) return;
setRescanning(true);
@@ -61,7 +123,7 @@ export default function AgentsSettingsPage() {
- {rescanning ? 'Rescanning…' : 'Rescan'}
+ {rescanning ? 'Rescanning...' : 'Rescan'}
@@ -77,36 +139,14 @@ export default function AgentsSettingsPage() {
)}
- {installedAgents.map((agent: AgentDiscovery) => {
- const savedAgent = savedAgentsByName.get(agent.name) as Agent | undefined;
- const configured = Boolean(savedAgent && savedAgent.profiles.length > 0);
- const hasAgentRecord = Boolean(savedAgent);
- return (
-
-
-
-
-
-
- {availableAgents.find((item: AvailableAgent) => item.name === agent.name)?.display_name ?? agent.name}
-
- {agent.supports_mcp &&
MCP }
- {configured &&
Configured }
-
- {agent.matched_path && (
-
Detected at {agent.matched_path}
- )}
-
-
-
-
- {hasAgentRecord ? 'Create new profile' : 'Setup Profile'}
-
-
-
-
- );
- })}
+ {installedAgents.map((agent: AgentDiscovery) => (
+
+ ))}
@@ -120,25 +160,9 @@ export default function AgentsSettingsPage() {
{savedAgents.flatMap((agent: Agent) =>
- agent.profiles.map((profile: AgentProfile) => {
- const profilePath = `/settings/agents/${encodeURIComponent(agent.name)}/profiles/${profile.id}`;
- return (
-
-
-
-
-
-
- {agent.profiles[0]?.agent_display_name ?? agent.name}
-
- {agent.supports_mcp &&
MCP }
-
{profile.name}
-
-
-
-
- );
- })
+ agent.profiles.map((profile: AgentProfile) => (
+
+ ))
)}
diff --git a/apps/web/app/settings/environment/[id]/page.tsx b/apps/web/app/settings/environment/[id]/page.tsx
index 4adee6e1d4..6803215427 100644
--- a/apps/web/app/settings/environment/[id]/page.tsx
+++ b/apps/web/app/settings/environment/[id]/page.tsx
@@ -24,6 +24,8 @@ import type { Environment } from '@/lib/types/http';
import type { BaseDocker } from '@/lib/settings/types';
import { useAppStore } from '@/components/state-provider';
+const ENVIRONMENTS_ROUTE = '/settings/environments';
+
const BASE_IMAGE_LABELS: Record = {
universal: 'Universal (Ubuntu)',
golang: 'Golang',
@@ -44,7 +46,7 @@ export default function EnvironmentEditPage({ params }: { params: Promise<{ id:
Environment not found
- router.push('/settings/environments')}>
+ router.push(ENVIRONMENTS_ROUTE)}>
Go to Environments
@@ -60,6 +62,204 @@ type EnvironmentEditFormProps = {
environment: Environment;
};
+type DeleteDialogProps = {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ onDelete: () => void;
+ isDeleting: boolean;
+ confirmText: string;
+ onConfirmTextChange: (value: string) => void;
+};
+
+function DeleteEnvironmentDialog({
+ open,
+ onOpenChange,
+ onDelete,
+ isDeleting,
+ confirmText,
+ onConfirmTextChange,
+}: DeleteDialogProps) {
+ return (
+
+
+
+ Delete Environment
+
+ Type "delete" to confirm deletion. This action cannot be undone.
+
+
+
+ Confirm Delete
+ onConfirmTextChange(event.target.value)}
+ placeholder="delete"
+ />
+
+
+ onOpenChange(false)}>
+ Cancel
+
+
+ {isDeleting ? 'Deleting...' : 'Delete'}
+
+
+
+
+ );
+}
+
+function EnvironmentDetailsCard({
+ draft,
+ isSystem,
+ onDraftChange,
+}: {
+ draft: Environment;
+ isSystem: boolean;
+ onDraftChange: (next: Environment) => void;
+}) {
+ return (
+
+
+ Environment Details
+
+ {isSystem ? 'System environments only allow worktree configuration.' : 'Customize the base image and worktree settings.'}
+
+
+
+
+
Environment name
+
onDraftChange({ ...draft, name: event.target.value })}
+ disabled={isSystem}
+ />
+ {isSystem && (
+
System environment names cannot be edited.
+ )}
+
+ {!isSystem && (
+
+ Base image
+ onDraftChange({ ...draft, kind: value as BaseDocker })}
+ >
+
+
+
+
+ {Object.entries(BASE_IMAGE_LABELS).map(([value, label]) => (
+
+ {label}
+
+ ))}
+
+
+
+ )}
+
+ Worktree root
+
+ onDraftChange({ ...draft, worktree_root: event.target.value || null })
+ }
+ placeholder="/workspace"
+ />
+
+
+
+ );
+}
+
+function DockerfileCard({ dockerfile, onChange }: { dockerfile: string; onChange: (value: string) => void }) {
+ return (
+
+
+ Custom Dockerfile
+
+
+ Dockerfile override
+
+
+ );
+}
+
+function DeleteEnvironmentSection({
+ onDeleteClick,
+ deleteDialogOpen,
+ setDeleteDialogOpen,
+ onDelete,
+ isDeleting,
+ confirmText,
+ onConfirmTextChange,
+}: {
+ onDeleteClick: () => void;
+ deleteDialogOpen: boolean;
+ setDeleteDialogOpen: (open: boolean) => void;
+ onDelete: () => void;
+ isDeleting: boolean;
+ confirmText: string;
+ onConfirmTextChange: (value: string) => void;
+}) {
+ return (
+ <>
+
+
+ Delete Environment
+
+
+
+
Remove this environment
+
This action cannot be undone.
+
+
+
+ Delete
+
+
+
+
+ >
+ );
+}
+
+function buildEnvironmentPayload(draft: Environment, isSystem: boolean): Record {
+ const payload: Record = {
+ worktree_root: draft.worktree_root ?? undefined,
+ };
+ if (!isSystem) {
+ payload.name = draft.name;
+ payload.kind = draft.kind;
+ payload.image_tag = draft.image_tag ?? undefined;
+ payload.dockerfile = draft.dockerfile ?? undefined;
+ payload.build_config = draft.build_config ?? undefined;
+ }
+ return payload;
+}
+
function EnvironmentEditForm({ environment }: EnvironmentEditFormProps) {
const router = useRouter();
const [draft, setDraft] = useState({ ...environment });
@@ -71,9 +271,7 @@ function EnvironmentEditForm({ environment }: EnvironmentEditFormProps) {
const isSystem = draft.is_system ?? false;
const isDirty = useMemo(() => {
- if (isSystem) {
- return draft.worktree_root !== savedEnvironment.worktree_root;
- }
+ if (isSystem) return draft.worktree_root !== savedEnvironment.worktree_root;
return (
draft.name !== savedEnvironment.name ||
draft.kind !== savedEnvironment.kind ||
@@ -89,41 +287,24 @@ function EnvironmentEditForm({ environment }: EnvironmentEditFormProps) {
setIsDeleting(true);
try {
const client = getWebSocketClient();
- if (client) {
- await client.request('environment.delete', { id: draft.id });
- } else {
- await deleteEnvironmentAction(draft.id);
- }
- router.push('/settings/environments');
- } finally {
- setIsDeleting(false);
- setDeleteDialogOpen(false);
- }
+ if (client) { await client.request('environment.delete', { id: draft.id }); }
+ else { await deleteEnvironmentAction(draft.id); }
+ router.push(ENVIRONMENTS_ROUTE);
+ } finally { setIsDeleting(false); setDeleteDialogOpen(false); }
};
const handleSaveEnvironment = async () => {
setIsSaving(true);
try {
- const payload: Record = {
- worktree_root: draft.worktree_root ?? undefined,
- };
- if (!isSystem) {
- payload.name = draft.name;
- payload.kind = draft.kind;
- payload.image_tag = draft.image_tag ?? undefined;
- payload.dockerfile = draft.dockerfile ?? undefined;
- payload.build_config = draft.build_config ?? undefined;
- }
+ const payload = buildEnvironmentPayload(draft, isSystem);
const client = getWebSocketClient();
const updated = client
? await client.request('environment.update', { id: draft.id, ...payload })
: await updateEnvironmentAction(draft.id, payload);
setDraft(updated);
setSavedEnvironment(updated);
- router.push('/settings/environments');
- } finally {
- setIsSaving(false);
- }
+ router.push(ENVIRONMENTS_ROUTE);
+ } finally { setIsSaving(false); }
};
return (
@@ -133,146 +314,26 @@ function EnvironmentEditForm({ environment }: EnvironmentEditFormProps) {
{draft.name}
Configure runtime environment settings.
- router.push('/settings/environments')}>
- Back to Environments
-
+ router.push(ENVIRONMENTS_ROUTE)}>Back to Environments
-
-
-
-
- Environment Details
-
- {isSystem ? 'System environments only allow worktree configuration.' : 'Customize the base image and worktree settings.'}
-
-
-
-
-
Environment name
-
setDraft({ ...draft, name: event.target.value })}
- disabled={isSystem}
- />
- {isSystem && (
-
System environment names cannot be edited.
- )}
-
- {!isSystem && (
-
- Base image
- setDraft({ ...draft, kind: value as BaseDocker })}
- >
-
-
-
-
- {Object.entries(BASE_IMAGE_LABELS).map(([value, label]) => (
-
- {label}
-
- ))}
-
-
-
- )}
-
- Worktree root
-
- setDraft({ ...draft, worktree_root: event.target.value || null })
- }
- placeholder="/workspace"
- />
-
-
-
-
- {!isSystem && (
-
-
- Custom Dockerfile
-
-
- Dockerfile override
-
-
- )}
-
+
+ {!isSystem && setDraft({ ...draft, dockerfile: value })} />}
-
- router.push('/settings/environments')}>
- Cancel
-
-
- {isSaving ? 'Saving...' : 'Save Changes'}
-
+ router.push(ENVIRONMENTS_ROUTE)}>Cancel
+ {isSaving ? 'Saving...' : 'Save Changes'}
-
{!isSystem && (
- <>
-
-
- Delete Environment
-
-
-
-
Remove this environment
-
This action cannot be undone.
-
- setDeleteDialogOpen(true)}>
-
- Delete
-
-
-
-
-
-
-
- Delete Environment
-
- Type "delete" to confirm deletion. This action cannot be undone.
-
-
-
- Confirm Delete
- setDeleteConfirmText(event.target.value)}
- placeholder="delete"
- />
-
-
- setDeleteDialogOpen(false)}>
- Cancel
-
-
- {isDeleting ? 'Deleting...' : 'Delete'}
-
-
-
-
- >
+ setDeleteDialogOpen(true)}
+ deleteDialogOpen={deleteDialogOpen}
+ setDeleteDialogOpen={setDeleteDialogOpen}
+ onDelete={handleDeleteEnvironment}
+ isDeleting={isDeleting}
+ confirmText={deleteConfirmText}
+ onConfirmTextChange={setDeleteConfirmText}
+ />
)}
);
diff --git a/apps/web/app/settings/environment/new/page.tsx b/apps/web/app/settings/environment/new/page.tsx
index 9f8bac7b17..ae07df0897 100644
--- a/apps/web/app/settings/environment/new/page.tsx
+++ b/apps/web/app/settings/environment/new/page.tsx
@@ -51,6 +51,111 @@ function generateDockerfile(baseImage: BaseDocker, agents: AgentType[]) {
].join('\n');
}
+function EnvironmentDetailsSection({
+ name,
+ onNameChange,
+ baseImage,
+ onBaseImageChange,
+ imageTag,
+ onImageTagChange,
+}: {
+ name: string;
+ onNameChange: (value: string) => void;
+ baseImage: BaseDocker;
+ onBaseImageChange: (value: BaseDocker) => void;
+ imageTag: string;
+ onImageTagChange: (value: string) => void;
+}) {
+ return (
+
+
+ Environment Details
+
+
+
+
+ Environment name
+ onNameChange(event.target.value)} />
+
+
+
+
+
+ Base image
+ onBaseImageChange(value as BaseDocker)}>
+
+
+
+
+ {Object.entries(BASE_IMAGE_LABELS).map(([key, label]) => (
+
+ {label}
+
+ ))}
+
+
+
+
+ Image tag
+ onImageTagChange(event.target.value)} />
+
+
+
+
+ );
+}
+
+type ImageBuildCardProps = {
+ installAgents: AgentType[];
+ onToggleAgent: (agentId: AgentType) => void;
+ onBuildImage: () => void;
+ showDockerfileEditor: boolean;
+ dockerfile: string;
+ onDockerfileChange: (value: string) => void;
+};
+
+function ImageBuildCard({
+ installAgents, onToggleAgent, onBuildImage,
+ showDockerfileEditor, dockerfile, onDockerfileChange,
+}: ImageBuildCardProps) {
+ return (
+
+
+
+
+ Image Build
+
+ Select agents to install in the image.
+
+
+
+ {AGENT_OPTIONS.map((agent) => (
+
+ onToggleAgent(agent.id)} />
+
+ {agent.label}
+ {agent.description}
+
+
+ ))}
+
+ Agent folders will be mounted as volumes at runtime.
+
+
Repositories are mounted into the container at runtime.
+
Build image
+
+ {showDockerfileEditor && (
+
+ )}
+
+
+ );
+}
+
export default function EnvironmentCreatePage() {
const router = useRouter();
const [name, setName] = useState('Custom Image');
@@ -59,11 +164,10 @@ export default function EnvironmentCreatePage() {
const [installAgents, setInstallAgents] = useState([]);
const [dockerfile, setDockerfile] = useState('');
const [showDockerfileEditor, setShowDockerfileEditor] = useState(false);
+ const [isCreating, setIsCreating] = useState(false);
const handleToggleAgent = (agentId: AgentType) => {
- setInstallAgents((prev) =>
- prev.includes(agentId) ? prev.filter((id) => id !== agentId) : [...prev, agentId]
- );
+ setInstallAgents((prev) => prev.includes(agentId) ? prev.filter((id) => id !== agentId) : [...prev, agentId]);
};
const handleBuildImage = () => {
@@ -71,145 +175,32 @@ export default function EnvironmentCreatePage() {
setShowDockerfileEditor(true);
};
- const [isCreating, setIsCreating] = useState(false);
-
const handleCreate = async () => {
setIsCreating(true);
try {
- const payload = {
- name,
- kind: 'docker_image',
- image_tag: imageTag,
- dockerfile,
- build_config: {
- base_image: baseImage,
- install_agents: installAgents.join(','),
- },
- };
+ const payload = { name, kind: 'docker_image', image_tag: imageTag, dockerfile, build_config: { base_image: baseImage, install_agents: installAgents.join(',') } };
const client = getWebSocketClient();
- if (client) {
- await client.request('environment.create', payload);
- } else {
- await createEnvironmentAction(payload);
- }
+ if (client) { await client.request('environment.create', payload); }
+ else { await createEnvironmentAction(payload); }
router.push('/settings/environments');
- } finally {
- setIsCreating(false);
- }
+ } finally { setIsCreating(false); }
};
return (
Create Custom Environment
-
- Build a Docker image tailored to your workflow.
-
+
Build a Docker image tailored to your workflow.
-
-
-
-
- Environment Details
-
-
-
-
- Environment name
- setName(event.target.value)} />
-
-
-
-
-
- Base image
- setBaseImage(value as BaseDocker)}>
-
-
-
-
- {Object.entries(BASE_IMAGE_LABELS).map(([key, label]) => (
-
- {label}
-
- ))}
-
-
-
-
- Image tag
- setImageTag(event.target.value)} />
-
-
-
-
-
-
-
-
-
- Image Build
-
- Select agents to install in the image.
-
-
-
- {AGENT_OPTIONS.map((agent) => (
-
- handleToggleAgent(agent.id)}
- />
-
- {agent.label}
- {agent.description}
-
-
- ))}
-
-
- Agent folders will be mounted as volumes at runtime.
-
-
-
- Repositories are mounted into the container at runtime.
-
-
- Build image
-
-
-
- {showDockerfileEditor && (
-
- )}
-
-
-
+
+
- router.push('/settings/environments')}>
- Cancel
-
-
- {isCreating ? 'Creating...' : 'Create Environment'}
-
+ router.push('/settings/environments')}>Cancel
+ {isCreating ? 'Creating...' : 'Create Environment'}
);
diff --git a/apps/web/app/settings/executor/[id]/page.tsx b/apps/web/app/settings/executor/[id]/page.tsx
index 2d2844e9e5..3b055fa787 100644
--- a/apps/web/app/settings/executor/[id]/page.tsx
+++ b/apps/web/app/settings/executor/[id]/page.tsx
@@ -21,7 +21,9 @@ import { updateExecutorAction, deleteExecutorAction } from '@/app/actions/execut
import { getWebSocketClient } from '@/lib/ws/connection';
import { useAppStore } from '@/components/state-provider';
import type { Executor } from '@/lib/types/http';
-import { getExecutorIcon } from '@/lib/executor-icons';
+import { EXECUTOR_ICON_MAP } from '@/lib/executor-icons';
+
+const EXECUTORS_ROUTE = '/settings/executors';
export default function ExecutorEditPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = use(params);
@@ -34,7 +36,7 @@ export default function ExecutorEditPage({ params }: { params: Promise<{ id: str
Executor not found
- router.push('/settings/executors')}>
+ router.push(EXECUTORS_ROUTE)}>
Go to Executors
@@ -50,6 +52,275 @@ type ExecutorEditFormProps = {
executor: Executor;
};
+function getExecutorDescription(type: string): string {
+ if (type === 'local') return 'Runs agents directly in the repository folder.';
+ if (type === 'worktree') return 'Creates git worktrees for isolated agent sessions.';
+ if (type === 'local_docker') return 'Runs Docker containers on this machine.';
+ if (type === 'remote_docker') return 'Connects to a remote Docker host.';
+ return 'Custom executor.';
+}
+
+function parseMcpPolicyJson(currentPolicy: string | undefined): Record {
+ let parsed: Record = {};
+ try {
+ if (currentPolicy?.trim()) {
+ parsed = JSON.parse(currentPolicy) as Record;
+ }
+ } catch {
+ parsed = {};
+ }
+ return parsed;
+}
+
+type McpPresetButtonProps = {
+ label: string;
+ onClick: () => void;
+};
+
+function McpPresetButton({ label, onClick }: McpPresetButtonProps) {
+ return (
+
+ {label}
+
+ );
+}
+
+type McpPolicyCardProps = {
+ mcpPolicy: string;
+ mcpPolicyError: string | null;
+ onPolicyChange: (value: string) => void;
+};
+
+function McpPolicyCard({ mcpPolicy, mcpPolicyError, onPolicyChange }: McpPolicyCardProps) {
+ const applyPreset = (updater: (parsed: Record) => Record) => {
+ const parsed = parseMcpPolicyJson(mcpPolicy);
+ const next = updater(parsed);
+ onPolicyChange(JSON.stringify(next, null, 2));
+ };
+
+ return (
+
+
+
+ MCP Policy
+
+ Advanced
+
+
+
+ JSON policy overrides for MCP servers on this executor. Use it to enforce transport
+ rules, restrict which servers can run, or rewrite URLs for this runtime.
+
+ Examples: block stdio on remote runners, allowlist approved servers, or rewrite
+ localhost URLs for Docker/K8s networking.
+
+
+
+
+ MCP policy JSON
+
+
+ );
+}
+
+type DeleteExecutorDialogProps = {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ onDelete: () => void;
+ isDeleting: boolean;
+ confirmText: string;
+ onConfirmTextChange: (value: string) => void;
+};
+
+function DeleteExecutorDialog({
+ open,
+ onOpenChange,
+ onDelete,
+ isDeleting,
+ confirmText,
+ onConfirmTextChange,
+}: DeleteExecutorDialogProps) {
+ return (
+
+
+
+ Delete Executor
+
+ Type "delete" to confirm deletion. This action cannot be undone.
+
+
+
+ Confirm Delete
+ onConfirmTextChange(event.target.value)}
+ placeholder="delete"
+ />
+
+
+ onOpenChange(false)}>
+ Cancel
+
+
+ {isDeleting ? 'Deleting...' : 'Delete'}
+
+
+
+
+ );
+}
+
+type ExecutorDetailsCardProps = {
+ draft: Executor;
+ isSystem: boolean;
+ onNameChange: (value: string) => void;
+};
+
+function ExecutorDetailsCard({ draft, isSystem, onNameChange }: ExecutorDetailsCardProps) {
+ const ExecutorIcon = EXECUTOR_ICON_MAP[draft.type] ?? EXECUTOR_ICON_MAP.local;
+ return (
+
+
+ Executor Details
+
+
+
+
+ {draft.type}
+
+
+
Executor name
+
onNameChange(event.target.value)} disabled={isSystem} />
+ {isSystem &&
System executor names cannot be edited.
}
+
+
+
+ );
+}
+
+function DockerConfigCard({ draft, onDraftChange }: { draft: Executor; onDraftChange: (next: Executor) => void }) {
+ return (
+
+
+ Docker Configuration
+ {draft.type === 'remote_docker' ? 'Remote Docker host settings.' : 'Local Docker host settings.'}
+
+
+ Docker host env value
+ onDraftChange({ ...draft, config: { ...(draft.config ?? {}), docker_host: event.target.value } })}
+ placeholder="unix:///var/run/docker.sock"
+ />
+ The repository will be mounted as a volume during runtime.
+
+
+ );
+}
+
+function DeleteExecutorSection({
+ onDeleteClick,
+ deleteDialogOpen,
+ setDeleteDialogOpen,
+ onDelete,
+ isDeleting,
+ confirmText,
+ onConfirmTextChange,
+}: {
+ onDeleteClick: () => void;
+ deleteDialogOpen: boolean;
+ setDeleteDialogOpen: (open: boolean) => void;
+ onDelete: () => void;
+ isDeleting: boolean;
+ confirmText: string;
+ onConfirmTextChange: (value: string) => void;
+}) {
+ return (
+ <>
+
+
+ Delete Executor
+
+
+
+
Remove this executor
+
This action cannot be undone.
+
+
+
+ Delete
+
+
+
+
+ >
+ );
+}
+
+function validateMcpPolicy(value: string | undefined): string | null {
+ const raw = value ?? '';
+ if (!raw.trim()) return null;
+ try {
+ const parsed = JSON.parse(raw);
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return 'MCP policy must be a JSON object';
+ } catch { return 'Invalid JSON'; }
+ return null;
+}
+
function ExecutorEditForm({ executor }: ExecutorEditFormProps) {
const router = useRouter();
const executors = useAppStore((state) => state.executors.items);
@@ -63,58 +334,28 @@ function ExecutorEditForm({ executor }: ExecutorEditFormProps) {
const isSystem = draft.is_system ?? false;
const isDockerType = draft.type === 'local_docker' || draft.type === 'remote_docker';
- const mcpPolicyError = useMemo(() => {
- const value = draft.config?.mcp_policy ?? '';
- if (!value.trim()) return null;
- try {
- const parsed = JSON.parse(value);
- if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
- return 'MCP policy must be a JSON object';
- }
- } catch {
- return 'Invalid JSON';
- }
- return null;
- }, [draft.config?.mcp_policy]);
+ const mcpPolicyError = useMemo(() => validateMcpPolicy(draft.config?.mcp_policy), [draft.config?.mcp_policy]);
- const ExecutorIcon = useMemo(() => {
- return getExecutorIcon(draft.type);
- }, [draft.type]);
-
- const isDirty = useMemo(() => {
- return (
- draft.name !== savedExecutor.name ||
- draft.type !== savedExecutor.type ||
- draft.status !== savedExecutor.status ||
- JSON.stringify(draft.config ?? {}) !== JSON.stringify(savedExecutor.config ?? {})
- );
- }, [draft, savedExecutor]);
+ const isDirty = useMemo(() => (
+ draft.name !== savedExecutor.name ||
+ draft.type !== savedExecutor.type ||
+ draft.status !== savedExecutor.status ||
+ JSON.stringify(draft.config ?? {}) !== JSON.stringify(savedExecutor.config ?? {})
+ ), [draft, savedExecutor]);
const handleSaveExecutor = async () => {
if (!draft) return;
setIsSaving(true);
try {
- const payload = isSystem
- ? { config: draft.config ?? {} }
- : {
- name: draft.name,
- type: draft.type,
- status: draft.status,
- config: draft.config ?? {},
- };
+ const payload = isSystem ? { config: draft.config ?? {} } : { name: draft.name, type: draft.type, status: draft.status, config: draft.config ?? {} };
const client = getWebSocketClient();
const updated = client
? await client.request('executor.update', { id: draft.id, ...payload })
: await updateExecutorAction(draft.id, payload);
setDraft(updated);
setSavedExecutor(updated);
- setExecutors(
- executors.map((item: Executor) => (item.id === updated.id ? { ...item, ...updated } : item))
- );
- // Stay on the executor page after saving so the user can continue editing.
- } finally {
- setIsSaving(false);
- }
+ setExecutors(executors.map((item: Executor) => (item.id === updated.id ? { ...item, ...updated } : item)));
+ } finally { setIsSaving(false); }
};
const handleDeleteExecutor = async () => {
@@ -122,17 +363,15 @@ function ExecutorEditForm({ executor }: ExecutorEditFormProps) {
setIsDeleting(true);
try {
const client = getWebSocketClient();
- if (client) {
- await client.request('executor.delete', { id: draft.id });
- } else {
- await deleteExecutorAction(draft.id);
- }
+ if (client) { await client.request('executor.delete', { id: draft.id }); }
+ else { await deleteExecutorAction(draft.id); }
setExecutors(executors.filter((item: Executor) => item.id !== draft.id));
- router.push('/settings/executors');
- } finally {
- setIsDeleting(false);
- setDeleteDialogOpen(false);
- }
+ router.push(EXECUTORS_ROUTE);
+ } finally { setIsDeleting(false); setDeleteDialogOpen(false); }
+ };
+
+ const handleMcpPolicyChange = (value: string) => {
+ setDraft({ ...draft, config: { ...(draft.config ?? {}), mcp_policy: value } });
};
return (
@@ -140,290 +379,24 @@ function ExecutorEditForm({ executor }: ExecutorEditFormProps) {
{draft.name}
-
- {draft.type === 'local'
- ? 'Runs agents directly in the repository folder.'
- : draft.type === 'worktree'
- ? 'Creates git worktrees for isolated agent sessions.'
- : draft.type === 'local_docker'
- ? 'Runs Docker containers on this machine.'
- : draft.type === 'remote_docker'
- ? 'Connects to a remote Docker host.'
- : 'Custom executor.'}
-
+
{getExecutorDescription(draft.type)}
-
router.push('/settings/executors')}>
- Back to Executors
-
+
router.push(EXECUTORS_ROUTE)}>Back to Executors
-
-
-
-
- Executor Details
-
-
-
-
- {draft.type}
-
-
-
Executor name
-
setDraft({ ...draft, name: event.target.value })}
- disabled={isSystem}
- />
- {isSystem && (
-
System executor names cannot be edited.
- )}
-
-
-
-
- {isDockerType && (
-
-
- Docker Configuration
- {draft.type === 'remote_docker' ? 'Remote Docker host settings.' : 'Local Docker host settings.'}
-
-
- Docker host env value
-
- setDraft({
- ...draft,
- config: { ...(draft.config ?? {}), docker_host: event.target.value },
- })
- }
- placeholder="unix:///var/run/docker.sock"
- />
-
- The repository will be mounted as a volume during runtime.
-
-
-
- )}
-
-
-
-
- MCP Policy
-
- Advanced
-
-
-
- JSON policy overrides for MCP servers on this executor. Use it to enforce transport
- rules, restrict which servers can run, or rewrite URLs for this runtime.
-
- Examples: block stdio on remote runners, allowlist approved servers, or rewrite
- localhost URLs for Docker/K8s networking.
-
-
-
-
- MCP policy JSON
-
-
-
+ setDraft({ ...draft, name: value })} />
+ {isDockerType && }
+
-
- router.push('/settings/executors')}>
- Cancel
-
-
- {isSaving ? 'Saving...' : 'Save Changes'}
-
+ router.push(EXECUTORS_ROUTE)}>Cancel
+ {isSaving ? 'Saving...' : 'Save Changes'}
-
{!isSystem && (
- <>
-
-
- Delete Executor
-
-
-
-
Remove this executor
-
This action cannot be undone.
-
- setDeleteDialogOpen(true)}>
-
- Delete
-
-
-
-
-
-
-
- Delete Executor
-
- Type "delete" to confirm deletion. This action cannot be undone.
-
-
-
- Confirm Delete
- setDeleteConfirmText(event.target.value)}
- placeholder="delete"
- />
-
-
- setDeleteDialogOpen(false)}>
- Cancel
-
-
- {isDeleting ? 'Deleting...' : 'Delete'}
-
-
-
-
- >
+ setDeleteDialogOpen(true)} deleteDialogOpen={deleteDialogOpen} setDeleteDialogOpen={setDeleteDialogOpen}
+ onDelete={handleDeleteExecutor} isDeleting={isDeleting} confirmText={deleteConfirmText} onConfirmTextChange={setDeleteConfirmText}
+ />
)}
);
diff --git a/apps/web/app/settings/executor/new/page.tsx b/apps/web/app/settings/executor/new/page.tsx
index 5dbeeb6a1b..e8c415a4f3 100644
--- a/apps/web/app/settings/executor/new/page.tsx
+++ b/apps/web/app/settings/executor/new/page.tsx
@@ -25,24 +25,154 @@ export default function ExecutorCreatePage() {
);
}
+type RemoteDockerFieldsProps = {
+ dockerTlsVerify: string;
+ onDockerTlsVerifyChange: (value: string) => void;
+ dockerCertPath: string;
+ onDockerCertPathChange: (value: string) => void;
+ gitToken: string;
+ onGitTokenChange: (value: string) => void;
+};
+
+function RemoteDockerFields({
+ dockerTlsVerify,
+ onDockerTlsVerifyChange,
+ dockerCertPath,
+ onDockerCertPathChange,
+ gitToken,
+ onGitTokenChange,
+}: RemoteDockerFieldsProps) {
+ return (
+ <>
+
+ TLS verify
+
+
+
+
+
+ Enabled
+ Disabled
+
+
+
+
+
TLS certificate path
+
onDockerCertPathChange(event.target.value)}
+ placeholder="/path/to/certs"
+ />
+
+ Path to TLS certificates for the Docker host.
+
+
+
+
Git token (optional)
+
onGitTokenChange(event.target.value)}
+ placeholder="ghp_..."
+ />
+
+ Personal access token for cloning repositories inside the container. Auto-detected from host environment if not set.
+
+
+ >
+ );
+}
+
+type ExecutorFormCardProps = {
+ type: ExecutorType;
+ name: string;
+ dockerHost: string;
+ dockerTlsVerify: string;
+ dockerCertPath: string;
+ gitToken: string;
+ onTypeChange: (value: ExecutorType) => void;
+ onNameChange: (value: string) => void;
+ onDockerHostChange: (value: string) => void;
+ onDockerTlsVerifyChange: (value: string) => void;
+ onDockerCertPathChange: (value: string) => void;
+ onGitTokenChange: (value: string) => void;
+};
+
+function ExecutorFormCard({
+ type, name, dockerHost, dockerTlsVerify, dockerCertPath, gitToken,
+ onTypeChange, onNameChange, onDockerHostChange,
+ onDockerTlsVerifyChange, onDockerCertPathChange, onGitTokenChange,
+}: ExecutorFormCardProps) {
+ const isRemoteDocker = type === 'remote_docker';
+ return (
+
+
+
+ {isRemoteDocker ? : }
+ {isRemoteDocker ? 'Remote Docker Executor' : 'Local Docker Executor'}
+
+
+ {isRemoteDocker
+ ? 'Connects to a remote Docker host. The repository will be cloned inside the container.'
+ : 'Uses the local Docker daemon on this machine.'}
+
+
+
+
+ Executor type
+ onTypeChange(value as ExecutorType)}>
+
+
+ Local Docker
+ Remote Docker
+
+
+
+
+ Executor name
+ onNameChange(event.target.value)} />
+
+
+
Docker host
+
onDockerHostChange(event.target.value)} placeholder={isRemoteDocker ? 'tcp://remote:2376 or ssh://user@host' : 'unix:///var/run/docker.sock'} />
+
+ {isRemoteDocker ? 'The remote Docker host URL (tcp://, ssh://).' : 'Repositories will be mounted as volumes at runtime.'}
+
+
+ {isRemoteDocker && (
+
+ )}
+
+
+ );
+}
+
+function buildExecutorConfig(type: ExecutorType, dockerHost: string, dockerTlsVerify: string, dockerCertPath: string, gitToken: string): Record {
+ const config: Record = { docker_host: dockerHost };
+ if (type === 'remote_docker') {
+ if (dockerTlsVerify) config.docker_tls_verify = dockerTlsVerify;
+ if (dockerCertPath) config.docker_cert_path = dockerCertPath;
+ if (gitToken) config.git_token = gitToken;
+ }
+ return config;
+}
+
function ExecutorCreatePageContent() {
const router = useRouter();
const searchParams = useSearchParams();
const initialType = searchParams.get('type');
const [type, setType] = useState(() => {
- if (EXECUTOR_TYPES.includes(initialType as ExecutorType)) {
- return initialType as ExecutorType;
- }
+ if (EXECUTOR_TYPES.includes(initialType as ExecutorType)) return initialType as ExecutorType;
return 'local_docker';
});
- const [name, setName] = useState(() => {
- if (initialType === 'remote_docker') return 'Remote Docker';
- return 'Local Docker';
- });
- const [dockerHost, setDockerHost] = useState(() => {
- if (initialType === 'remote_docker') return 'tcp://';
- return 'unix:///var/run/docker.sock';
- });
+ const [name, setName] = useState(() => initialType === 'remote_docker' ? 'Remote Docker' : 'Local Docker');
+ const [dockerHost, setDockerHost] = useState(() => initialType === 'remote_docker' ? 'tcp://' : 'unix:///var/run/docker.sock');
const [dockerTlsVerify, setDockerTlsVerify] = useState('');
const [dockerCertPath, setDockerCertPath] = useState('');
const [gitToken, setGitToken] = useState('');
@@ -52,152 +182,38 @@ function ExecutorCreatePageContent() {
const handleTypeChange = (value: ExecutorType) => {
setType(value);
- if (value === 'local_docker') {
- setName('Local Docker');
- setDockerHost('unix:///var/run/docker.sock');
- } else if (value === 'remote_docker') {
- setName('Remote Docker');
- setDockerHost('tcp://');
- }
+ if (value === 'local_docker') { setName('Local Docker'); setDockerHost('unix:///var/run/docker.sock'); }
+ else if (value === 'remote_docker') { setName('Remote Docker'); setDockerHost('tcp://'); }
};
const handleCreate = async () => {
setIsCreating(true);
try {
- const config: Record = { docker_host: dockerHost };
- if (type === 'remote_docker') {
- if (dockerTlsVerify) config.docker_tls_verify = dockerTlsVerify;
- if (dockerCertPath) config.docker_cert_path = dockerCertPath;
- if (gitToken) config.git_token = gitToken;
- }
- const payload = {
- name,
- type,
- status: 'active',
- config,
- };
+ const config = buildExecutorConfig(type, dockerHost, dockerTlsVerify, dockerCertPath, gitToken);
+ const payload = { name, type, status: 'active', config };
const client = getWebSocketClient();
- const created = client
- ? await client.request('executor.create', payload)
- : await createExecutorAction(payload);
+ const created = client ? await client.request('executor.create', payload) : await createExecutorAction(payload);
setExecutors([...executors.filter((item: Executor) => item.id !== created.id), created]);
router.push('/settings/executors');
- } finally {
- setIsCreating(false);
- }
+ } finally { setIsCreating(false); }
};
- const isRemoteDocker = type === 'remote_docker';
-
return (
Create Executor
-
- Choose an executor type to run environments on your infrastructure.
-
+
Choose an executor type to run environments on your infrastructure.
-
-
-
-
-
- {isRemoteDocker ? : }
- {isRemoteDocker ? 'Remote Docker Executor' : 'Local Docker Executor'}
-
-
- {isRemoteDocker
- ? 'Connects to a remote Docker host. The repository will be cloned inside the container.'
- : 'Uses the local Docker daemon on this machine.'}
-
-
-
-
- Executor type
- handleTypeChange(value as ExecutorType)}>
-
-
-
-
- Local Docker
- Remote Docker
-
-
-
-
- Executor name
- setName(event.target.value)}
- />
-
-
-
Docker host
-
setDockerHost(event.target.value)}
- placeholder={isRemoteDocker ? 'tcp://remote:2376 or ssh://user@host' : 'unix:///var/run/docker.sock'}
- />
-
- {isRemoteDocker
- ? 'The remote Docker host URL (tcp://, ssh://).'
- : 'Repositories will be mounted as volumes at runtime.'}
-
-
- {isRemoteDocker && (
- <>
-
- TLS verify
-
-
-
-
-
- Enabled
- Disabled
-
-
-
-
-
TLS certificate path
-
setDockerCertPath(event.target.value)}
- placeholder="/path/to/certs"
- />
-
- Path to TLS certificates for the Docker host.
-
-
-
-
Git token (optional)
-
setGitToken(event.target.value)}
- placeholder="ghp_..."
- />
-
- Personal access token for cloning repositories inside the container. Auto-detected from host environment if not set.
-
-
- >
- )}
-
-
-
+
- router.push('/settings/executors')}>
- Cancel
-
-
- {isCreating ? 'Creating...' : 'Create Executor'}
-
+ router.push('/settings/executors')}>Cancel
+ {isCreating ? 'Creating...' : 'Create Executor'}
);
diff --git a/apps/web/app/settings/executors/page.tsx b/apps/web/app/settings/executors/page.tsx
index 7c5e853612..00aaaedb8e 100644
--- a/apps/web/app/settings/executors/page.tsx
+++ b/apps/web/app/settings/executors/page.tsx
@@ -7,28 +7,139 @@ import { Card, CardContent } from '@kandev/ui/card';
import { Separator } from '@kandev/ui/separator';
import { useAppStore } from '@/components/state-provider';
import type { Executor } from '@/lib/types/http';
-import { getExecutorIcon, getExecutorLabel } from '@/lib/executor-icons';
+import { EXECUTOR_ICON_MAP, getExecutorLabel } from '@/lib/executor-icons';
+
+const CREATION_OPTIONS = [
+ {
+ id: 'local_docker',
+ label: 'Local Docker',
+ description: 'Run on the local Docker daemon.',
+ href: '/settings/executor/new?type=local_docker',
+ icon: IconBrandDocker,
+ enabled: true,
+ },
+ {
+ id: 'remote_docker',
+ label: 'Remote Docker',
+ description: 'Connect to a remote Docker host.',
+ href: '/settings/executor/new?type=remote_docker',
+ icon: IconCloud,
+ enabled: true,
+ },
+];
+
+type CreationOptionCardProps = {
+ option: (typeof CREATION_OPTIONS)[number];
+};
+
+function CreationOptionCard({ option }: CreationOptionCardProps) {
+ const Icon = option.icon;
+ const cardBody = (
+
+
+
+
+
+
+
+ {option.label}
+ {!option.enabled && (
+
+ Coming soon
+
+ )}
+
+
{option.description}
+
+
+
+ );
+ if (!option.enabled) {
+ return (
+
+ {cardBody}
+
+ );
+ }
+ return (
+
+
+ {cardBody}
+
+
+ );
+}
+
+function ExecutorTypeDescription({ type }: { type: string }) {
+ if (type === 'local') {
+ return (
+
+ Runs agents directly in the repository folder. No worktree isolation.
+
+ );
+ }
+ if (type === 'worktree') {
+ return (
+
+ Creates a git worktree for each session. Agents work in isolated branches.
+
+ );
+ }
+ return null;
+}
+
+const DefaultIcon = EXECUTOR_ICON_MAP.local;
+
+function ExecutorIconBadge({ type }: { type: string }) {
+ const Icon = EXECUTOR_ICON_MAP[type] ?? DefaultIcon;
+ return (
+
+
+
+ );
+}
+
+function ExecutorListItem({ executor }: { executor: Executor }) {
+ const typeLabel = getExecutorLabel(executor.type);
+ const showDockerHost = (executor.type === 'local_docker' || executor.type === 'remote_docker') && executor.config?.docker_host;
+
+ return (
+
+
+
+
+
+
+
+
+
{executor.name}
+
+ {typeLabel}
+
+ {executor.status === 'disabled' && (
+
+ Disabled
+
+ )}
+
+ {showDockerHost && (
+
+ {executor.config?.docker_host}
+
+ )}
+
+
+
+
+
+
+
+
+ );
+}
export default function ExecutorsSettingsPage() {
const executors = useAppStore((state) => state.executors.items);
- const creationOptions = [
- {
- id: 'local_docker',
- label: 'Local Docker',
- description: 'Run on the local Docker daemon.',
- href: '/settings/executor/new?type=local_docker',
- icon: IconBrandDocker,
- enabled: true,
- },
- {
- id: 'remote_docker',
- label: 'Remote Docker',
- description: 'Connect to a remote Docker host.',
- href: '/settings/executor/new?type=remote_docker',
- icon: IconCloud,
- enabled: true,
- },
- ];
return (
@@ -46,95 +157,16 @@ export default function ExecutorsSettingsPage() {
Create an executor
- {creationOptions.map((option) => {
- const Icon = option.icon;
- const cardBody = (
-
-
-
-
-
-
-
- {option.label}
- {!option.enabled && (
-
- Coming soon
-
- )}
-
-
{option.description}
-
-
-
- );
- if (!option.enabled) {
- return (
-
- {cardBody}
-
- );
- }
- return (
-
-
- {cardBody}
-
-
- );
- })}
+ {CREATION_OPTIONS.map((option) => (
+
+ ))}
- {executors.map((executor: Executor) => {
- const Icon = getExecutorIcon(executor.type);
- const typeLabel = getExecutorLabel(executor.type);
- return (
-
-
-
-
-
-
-
-
-
-
-
{executor.name}
-
- {typeLabel}
-
- {executor.status === 'disabled' && (
-
- Disabled
-
- )}
-
- {(executor.type === 'local_docker' || executor.type === 'remote_docker') && executor.config?.docker_host && (
-
- {executor.config.docker_host}
-
- )}
- {executor.type === 'local' && (
-
- Runs agents directly in the repository folder. No worktree isolation.
-
- )}
- {executor.type === 'worktree' && (
-
- Creates a git worktree for each session. Agents work in isolated branches.
-
- )}
-
-
-
-
-
-
-
- );
- })}
+ {executors.map((executor: Executor) => (
+
+ ))}
);
diff --git a/apps/web/app/settings/workspace/workspace-edit-client.tsx b/apps/web/app/settings/workspace/workspace-edit-client.tsx
index dc71dbc0f5..4dd863cf67 100644
--- a/apps/web/app/settings/workspace/workspace-edit-client.tsx
+++ b/apps/web/app/settings/workspace/workspace-edit-client.tsx
@@ -57,247 +57,124 @@ type WorkspaceEditFormProps = {
workspace: Workspace;
};
-function WorkspaceEditForm({ workspace }: WorkspaceEditFormProps) {
- const router = useRouter();
- const { toast } = useToast();
- const [currentWorkspace, setCurrentWorkspace] = useState(workspace);
-
- // Draft state for all fields
- const [workspaceNameDraft, setWorkspaceNameDraft] = useState(workspace.name ?? '');
- const [defaultExecutorId, setDefaultExecutorId] = useState(workspace.default_executor_id ?? '');
- const [defaultEnvironmentId, setDefaultEnvironmentId] = useState(workspace.default_environment_id ?? '');
- const [defaultAgentProfileId, setDefaultAgentProfileId] = useState(workspace.default_agent_profile_id ?? '');
-
- // Saved state to track dirty
- const [savedState, setSavedState] = useState({
- name: workspace.name ?? '',
- executorId: workspace.default_executor_id ?? '',
- environmentId: workspace.default_environment_id ?? '',
- agentProfileId: workspace.default_agent_profile_id ?? '',
- });
-
- const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
- const [deleteConfirmText, setDeleteConfirmText] = useState('');
-
- const executors = useAppStore((state) => state.executors.items);
- const environments = useAppStore((state) => state.environments.items);
- const agentProfiles = useAppStore((state) => state.agentProfiles.items);
- const workspaces = useAppStore((state) => state.workspaces.items);
- const setWorkspaces = useAppStore((state) => state.setWorkspaces);
-
- const saveWorkspaceRequest = useRequest(updateWorkspaceAction);
- const deleteWorkspaceRequest = useRequest(deleteWorkspaceAction);
-
- const activeExecutors = executors.filter((executor: Executor) => executor.status === 'active');
-
- const isDirty =
- workspaceNameDraft.trim() !== savedState.name ||
- defaultExecutorId !== savedState.executorId ||
- defaultEnvironmentId !== savedState.environmentId ||
- defaultAgentProfileId !== savedState.agentProfileId;
-
- const handleSave = async () => {
- if (!isDirty) return;
- try {
- const updates: Record = {};
- if (workspaceNameDraft.trim() !== savedState.name) {
- updates.name = workspaceNameDraft.trim();
- }
- if (defaultExecutorId !== savedState.executorId) {
- updates.default_executor_id = defaultExecutorId;
- }
- if (defaultEnvironmentId !== savedState.environmentId) {
- updates.default_environment_id = defaultEnvironmentId;
- }
- if (defaultAgentProfileId !== savedState.agentProfileId) {
- updates.default_agent_profile_id = defaultAgentProfileId;
- }
-
- const updated = await saveWorkspaceRequest.run(currentWorkspace.id, updates);
- setCurrentWorkspace((prev) => ({ ...prev, ...updated }));
- setSavedState({
- name: updated.name ?? workspaceNameDraft.trim(),
- executorId: updated.default_executor_id ?? '',
- environmentId: updated.default_environment_id ?? '',
- agentProfileId: updated.default_agent_profile_id ?? '',
- });
- setWorkspaces(
- workspaces.map((workspaceItem: Workspace) =>
- workspaceItem.id === updated.id
- ? {
- ...workspaceItem,
- name: updated.name,
- default_executor_id: updated.default_executor_id ?? null,
- default_environment_id: updated.default_environment_id ?? null,
- default_agent_profile_id: updated.default_agent_profile_id ?? null,
- }
- : workspaceItem
- )
- );
- } catch (error) {
- toast({
- title: 'Failed to save workspace',
- description: error instanceof Error ? error.message : 'Request failed',
- variant: 'error',
- });
- }
- };
-
- const handleDeleteWorkspace = async () => {
- if (deleteConfirmText !== 'delete') return;
- try {
- await deleteWorkspaceRequest.run(currentWorkspace.id);
- setWorkspaces(workspaces.filter((workspaceItem: Workspace) => workspaceItem.id !== currentWorkspace.id));
- router.push('/settings/workspace');
- } catch (error) {
- toast({
- title: 'Failed to delete workspace',
- description: error instanceof Error ? error.message : 'Request failed',
- variant: 'error',
- });
- }
- };
+type SelectFieldProps = { label: string; value: string; onChange: (v: string) => void; options: { id: string; name: string }[]; emptyLabel: string; emptyValue: string };
+function SelectField({ label, value, onChange, options, emptyLabel, emptyValue }: SelectFieldProps) {
return (
-
-
-
{currentWorkspace.name}
-
- Manage workspace details and jump into workflows or repositories.
-
-
+
+ {label}
+ onChange(v === 'none' ? '' : v)}>
+
+
+ No default
+ {options.map((opt) => {opt.name} )}
+ {options.length === 0 && {emptyLabel} }
+
+
+
+ );
+}
-
+type WorkspaceSettingsCardProps = {
+ isDirty: boolean;
+ workspaceNameDraft: string;
+ onNameChange: (value: string) => void;
+ defaultExecutorId: string;
+ onExecutorChange: (value: string) => void;
+ activeExecutors: Executor[];
+ executorsEmpty: boolean;
+ defaultEnvironmentId: string;
+ onEnvironmentChange: (value: string) => void;
+ environments: Environment[];
+ defaultAgentProfileId: string;
+ onAgentProfileChange: (value: string) => void;
+ agentProfiles: AgentProfileOption[];
+ isLoading: boolean;
+ saveStatus: 'idle' | 'loading' | 'success' | 'error';
+ onSave: () => void;
+};
-
-
-
- Workspace Settings
- {isDirty && }
-
-
-
-
-
- Name
- setWorkspaceNameDraft(event.target.value)}
- />
-
-
-
- Default Executor
- setDefaultExecutorId(value === 'none' ? '' : value)}
- >
-
-
-
-
- No default
- {activeExecutors.map((executor: Executor) => (
-
- {executor.name}
-
- ))}
- {executors.length === 0 && (
-
- No executors available
-
- )}
-
-
-
-
-
- Default Environment
- setDefaultEnvironmentId(value === 'none' ? '' : value)}
- >
-
-
-
-
- No default
- {environments.map((environment: Environment) => (
-
- {environment.name}
-
- ))}
- {environments.length === 0 && (
-
- No environments available
-
- )}
-
-
-
-
-
- Default Agent Profile
- setDefaultAgentProfileId(value === 'none' ? '' : value)}
- >
-
-
-
-
- No default
- {agentProfiles.map((profile: AgentProfileOption) => (
-
- {profile.label}
-
- ))}
- {agentProfiles.length === 0 && (
-
- No agent profiles available
-
- )}
-
-
-
-
-
-
-
+function WorkspaceSettingsCard({
+ isDirty, workspaceNameDraft, onNameChange, defaultExecutorId, onExecutorChange,
+ activeExecutors, executorsEmpty, defaultEnvironmentId, onEnvironmentChange,
+ environments, defaultAgentProfileId, onAgentProfileChange, agentProfiles, isLoading, saveStatus, onSave,
+}: WorkspaceSettingsCardProps) {
+ const executorOptions = activeExecutors.map((e: Executor) => ({ id: e.id, name: e.name }));
+ const envOptions = environments.map((e: Environment) => ({ id: e.id, name: e.name }));
+ const profileOptions = agentProfiles.map((p: AgentProfileOption) => ({ id: p.id, name: p.label }));
+ return (
+
+
+
+ Workspace Settings
+ {isDirty && }
+
+
+
+
+
+ Name
+ onNameChange(e.target.value)} />
-
-
-
-
-
- Workspace Links
-
-
-
-
-
-
- Repositories
-
-
-
-
-
- Workflows
-
-
+
+
+
+
+
-
-
+
+
+
+ );
+}
-
+type WorkspaceLinksCardProps = {
+ workspaceId: string;
+};
+function WorkspaceLinksCard({ workspaceId }: WorkspaceLinksCardProps) {
+ return (
+
+
+ Workspace Links
+
+
+
+
+
+
+ Repositories
+
+
+
+
+
+ Workflows
+
+
+
+
+
+ );
+}
+
+type DeleteWorkspaceCardProps = {
+ deleteDialogOpen: boolean;
+ setDeleteDialogOpen: (open: boolean) => void;
+ deleteConfirmText: string;
+ setDeleteConfirmText: (text: string) => void;
+ onDelete: () => void;
+};
+
+function DeleteWorkspaceCard({
+ deleteDialogOpen,
+ setDeleteDialogOpen,
+ deleteConfirmText,
+ setDeleteConfirmText,
+ onDelete,
+}: DeleteWorkspaceCardProps) {
+ return (
+ <>
Delete Workspace
@@ -339,7 +216,7 @@ function WorkspaceEditForm({ workspace }: WorkspaceEditFormProps) {
@@ -348,6 +225,113 @@ function WorkspaceEditForm({ workspace }: WorkspaceEditFormProps) {
+ >
+ );
+}
+
+type SavedState = { name: string; executorId: string; environmentId: string; agentProfileId: string };
+
+function buildWorkspaceUpdates(
+ draft: { name: string; executorId: string; environmentId: string; agentProfileId: string },
+ saved: SavedState,
+): Record {
+ const updates: Record = {};
+ if (draft.name.trim() !== saved.name) updates.name = draft.name.trim();
+ if (draft.executorId !== saved.executorId) updates.default_executor_id = draft.executorId;
+ if (draft.environmentId !== saved.environmentId) updates.default_environment_id = draft.environmentId;
+ if (draft.agentProfileId !== saved.agentProfileId) updates.default_agent_profile_id = draft.agentProfileId;
+ return updates;
+}
+
+function WorkspaceEditForm({ workspace }: WorkspaceEditFormProps) {
+ const router = useRouter();
+ const { toast } = useToast();
+ const [currentWorkspace, setCurrentWorkspace] = useState(workspace);
+ const [workspaceNameDraft, setWorkspaceNameDraft] = useState(workspace.name ?? '');
+ const [defaultExecutorId, setDefaultExecutorId] = useState(workspace.default_executor_id ?? '');
+ const [defaultEnvironmentId, setDefaultEnvironmentId] = useState(workspace.default_environment_id ?? '');
+ const [defaultAgentProfileId, setDefaultAgentProfileId] = useState(workspace.default_agent_profile_id ?? '');
+ const [savedState, setSavedState] = useState({ name: workspace.name ?? '', executorId: workspace.default_executor_id ?? '', environmentId: workspace.default_environment_id ?? '', agentProfileId: workspace.default_agent_profile_id ?? '' });
+ const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
+ const [deleteConfirmText, setDeleteConfirmText] = useState('');
+
+ const executors = useAppStore((state) => state.executors.items);
+ const environments = useAppStore((state) => state.environments.items);
+ const agentProfiles = useAppStore((state) => state.agentProfiles.items);
+ const workspaces = useAppStore((state) => state.workspaces.items);
+ const setWorkspaces = useAppStore((state) => state.setWorkspaces);
+
+ const saveWorkspaceRequest = useRequest(updateWorkspaceAction);
+ const deleteWorkspaceRequest = useRequest(deleteWorkspaceAction);
+
+ const activeExecutors = executors.filter((executor: Executor) => executor.status === 'active');
+ const isDirty = workspaceNameDraft.trim() !== savedState.name || defaultExecutorId !== savedState.executorId || defaultEnvironmentId !== savedState.environmentId || defaultAgentProfileId !== savedState.agentProfileId;
+
+ const handleSave = async () => {
+ if (!isDirty) return;
+ try {
+ const updates = buildWorkspaceUpdates({ name: workspaceNameDraft, executorId: defaultExecutorId, environmentId: defaultEnvironmentId, agentProfileId: defaultAgentProfileId }, savedState);
+ const updated = await saveWorkspaceRequest.run(currentWorkspace.id, updates);
+ setCurrentWorkspace((prev) => ({ ...prev, ...updated }));
+ setSavedState({ name: updated.name ?? workspaceNameDraft.trim(), executorId: updated.default_executor_id ?? '', environmentId: updated.default_environment_id ?? '', agentProfileId: updated.default_agent_profile_id ?? '' });
+ setWorkspaces(workspaces.map((ws: Workspace) => ws.id === updated.id ? { ...ws, name: updated.name, default_executor_id: updated.default_executor_id ?? null, default_environment_id: updated.default_environment_id ?? null, default_agent_profile_id: updated.default_agent_profile_id ?? null } : ws));
+ } catch (error) {
+ toast({ title: 'Failed to save workspace', description: error instanceof Error ? error.message : 'Request failed', variant: 'error' });
+ }
+ };
+
+ const handleDeleteWorkspace = async () => {
+ if (deleteConfirmText !== 'delete') return;
+ try {
+ await deleteWorkspaceRequest.run(currentWorkspace.id);
+ setWorkspaces(workspaces.filter((ws: Workspace) => ws.id !== currentWorkspace.id));
+ router.push('/settings/workspace');
+ } catch (error) {
+ toast({ title: 'Failed to delete workspace', description: error instanceof Error ? error.message : 'Request failed', variant: 'error' });
+ }
+ };
+
+ return (
+
+
+
{currentWorkspace.name}
+
+ Manage workspace details and jump into workflows or repositories.
+
+
+
+
+
+
+
+
+
+
+
+
);
}
diff --git a/apps/web/app/settings/workspace/workspace-repositories-client.tsx b/apps/web/app/settings/workspace/workspace-repositories-client.tsx
index e775aaec79..d838984acb 100644
--- a/apps/web/app/settings/workspace/workspace-repositories-client.tsx
+++ b/apps/web/app/settings/workspace/workspace-repositories-client.tsx
@@ -37,344 +37,277 @@ import { useAppStore } from '@/components/state-provider';
type RepositoryWithScripts = Repository & { scripts: RepositoryScript[] };
type RepositoryItem = RepositoryWithScripts & { __autoOpen?: boolean };
+type ManualValidation = { status: 'idle' | 'loading' | 'success' | 'error'; message?: string; isValid?: boolean; path?: string };
+
+type DiscoverRepoDialogProps = {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ isLoading: boolean;
+ filteredRepositories: LocalRepository[];
+ repoSearch: string;
+ onRepoSearchChange: (value: string) => void;
+ selectedRepoPath: string | null;
+ onSelectRepoPath: (path: string) => void;
+ manualRepoPath: string;
+ onManualRepoPathChange: (value: string) => void;
+ manualValidation: ManualValidation;
+ onValidateManualPath: () => void;
+ isValidating: boolean;
+ canSave: boolean;
+ onConfirm: () => void;
+};
+
+function RepoListContent({ isLoading, filteredRepositories, selectedRepoPath, onSelectRepoPath }: { isLoading: boolean; filteredRepositories: LocalRepository[]; selectedRepoPath: string | null; onSelectRepoPath: (path: string) => void }) {
+ if (isLoading) return Scanning repositories...
;
+ if (filteredRepositories.length === 0) return No repositories found.
;
+ return (
+ <>
+ {filteredRepositories.map((repo) => (
+ onSelectRepoPath(repo.path)}>
+ {repo.name}
+ {repo.path}
+
+ ))}
+ >
+ );
+}
+
+function DiscoverRepoDialog({
+ open, onOpenChange, isLoading, filteredRepositories, repoSearch, onRepoSearchChange,
+ selectedRepoPath, onSelectRepoPath, manualRepoPath, onManualRepoPathChange,
+ manualValidation, onValidateManualPath, isValidating, canSave, onConfirm,
+}: DiscoverRepoDialogProps) {
+ return (
+
+
+
+ Add Local Repository
+ Select a discovered repository or provide an absolute path to validate.
+
+
+
+
Discovered repositories
+
onRepoSearchChange(e.target.value)} />
+
+
+
+
+
+
Manual path
+
+ onManualRepoPathChange(e.target.value)} />
+
+ {isValidating ? 'Checking...' : 'Validate'}
+
+
+ {manualValidation.status === 'error' &&
{manualValidation.message}
}
+ {manualValidation.status === 'success' &&
{manualValidation.message}
}
+
+
+
+ onOpenChange(false)}>Cancel
+ Use Repository
+
+
+
+ );
+}
type WorkspaceRepositoriesClientProps = {
workspace: Workspace | null;
repositories: RepositoryWithScripts[];
};
-export function WorkspaceRepositoriesClient({
- workspace,
- repositories,
-}: WorkspaceRepositoriesClientProps) {
- const router = useRouter();
- const { toast } = useToast();
- const clearRepositoryScripts = useAppStore((state) => state.clearRepositoryScripts);
- const [repositoryItems, setRepositoryItems] = useState(repositories);
- const [savedRepositoryItems, setSavedRepositoryItems] = useState(repositories);
- const [discoveredRepositories, setDiscoveredRepositories] = useState([]);
+function cloneRepository(repo: RepositoryWithScripts): RepositoryWithScripts {
+ return { ...repo, scripts: repo.scripts.map((script) => ({ ...script })) };
+}
+
+function isRepositoryDirty(repo: RepositoryItem, saved: RepositoryWithScripts | undefined): boolean {
+ if (!saved) return true;
+ return repo.name !== saved.name || repo.source_type !== saved.source_type || repo.local_path !== saved.local_path || repo.provider !== saved.provider || repo.provider_repo_id !== saved.provider_repo_id || repo.provider_owner !== saved.provider_owner || repo.provider_name !== saved.provider_name || repo.default_branch !== saved.default_branch || repo.worktree_branch_prefix !== saved.worktree_branch_prefix || repo.pull_before_worktree !== saved.pull_before_worktree || repo.setup_script !== saved.setup_script || repo.cleanup_script !== saved.cleanup_script || repo.dev_script !== saved.dev_script;
+}
+
+function areRepositoryScriptsDirty(repo: RepositoryItem, saved: RepositoryWithScripts | undefined): boolean {
+ if (!saved) return repo.scripts.length > 0;
+ if (repo.scripts.length !== saved.scripts.length) return true;
+ const savedScripts = new Map(saved.scripts.map((script) => [script.id, script]));
+ for (const script of repo.scripts) {
+ const savedScript = savedScripts.get(script.id);
+ if (!savedScript || script.name !== savedScript.name || script.command !== savedScript.command || script.position !== savedScript.position) return true;
+ }
+ return false;
+}
+
+function buildDraftRepo(workspace: Workspace, selectedRepo: LocalRepository | undefined, manualValidation: ManualValidation, manualRepoPath: string): RepositoryItem {
+ const path = selectedRepo?.path ?? manualValidation.path ?? manualRepoPath.trim();
+ const name = selectedRepo?.name ?? path.split('/').filter(Boolean).slice(-1)[0] ?? 'New Repository';
+ return {
+ id: `temp-repo-${generateUUID()}`, workspace_id: workspace.id, name, source_type: 'local', local_path: path,
+ provider: '', provider_repo_id: '', provider_owner: '', provider_name: '',
+ default_branch: selectedRepo?.default_branch ?? '', worktree_branch_prefix: 'feature/',
+ pull_before_worktree: true, setup_script: '', cleanup_script: '', dev_script: '',
+ created_at: '', updated_at: '', scripts: [], __autoOpen: true,
+ };
+}
+
+type RepoHandlerArgs = {
+ workspace: Workspace | null;
+ repositoryItems: RepositoryItem[];
+ setRepositoryItems: React.Dispatch>;
+ setSavedRepositoryItems: React.Dispatch>;
+ savedRepositoriesById: Map;
+ clearRepositoryScripts: (id: string) => void;
+};
+
+async function saveNewRepository(repo: RepositoryItem, repoId: string, workspace: Workspace | null, setRepositoryItems: React.Dispatch>, setSavedRepositoryItems: React.Dispatch>) {
+ const created = await createRepositoryAction({
+ workspace_id: workspace?.id ?? repo.workspace_id, name: repo.name.trim() || 'New Repository', source_type: repo.source_type || 'local',
+ local_path: repo.local_path, provider: repo.provider, provider_repo_id: repo.provider_repo_id, provider_owner: repo.provider_owner,
+ provider_name: repo.provider_name, default_branch: repo.default_branch, worktree_branch_prefix: repo.worktree_branch_prefix,
+ pull_before_worktree: repo.pull_before_worktree, setup_script: repo.setup_script, cleanup_script: repo.cleanup_script, dev_script: repo.dev_script,
+ });
+ const scripts = await Promise.all(repo.scripts.map((script, index) => createRepositoryScriptAction({ repository_id: created.id, name: script.name.trim() || 'New Script', command: script.command.trim() || 'echo ""', position: script.position ?? index })));
+ const nextRepo: RepositoryWithScripts = { ...created, scripts };
+ setRepositoryItems((prev) => prev.map((item) => (item.id === repoId ? nextRepo : item)));
+ setSavedRepositoryItems((prev) => [cloneRepository(nextRepo), ...prev]);
+}
+
+type SaveExistingArgs = { repo: RepositoryItem; repoId: string; savedRepositoriesById: Map; clearRepositoryScripts: (id: string) => void; setRepositoryItems: React.Dispatch>; setSavedRepositoryItems: React.Dispatch> };
+
+async function saveExistingRepository({ repo, repoId, savedRepositoriesById, clearRepositoryScripts, setRepositoryItems, setSavedRepositoryItems }: SaveExistingArgs) {
+ const updated = await updateRepositoryAction(repoId, {
+ name: repo.name, source_type: repo.source_type, local_path: repo.local_path, provider: repo.provider,
+ provider_repo_id: repo.provider_repo_id, provider_owner: repo.provider_owner, provider_name: repo.provider_name,
+ default_branch: repo.default_branch, worktree_branch_prefix: repo.worktree_branch_prefix, pull_before_worktree: repo.pull_before_worktree,
+ setup_script: repo.setup_script, cleanup_script: repo.cleanup_script, dev_script: repo.dev_script,
+ });
+ const savedScripts = savedRepositoriesById.get(repoId)?.scripts ?? [];
+ const currentScriptIds = new Set(repo.scripts.map((s) => s.id));
+ await Promise.all(savedScripts.filter((s) => !currentScriptIds.has(s.id)).map((s) => deleteRepositoryScriptAction(s.id)));
+ const nextScripts = await Promise.all(repo.scripts.map((script, index) => {
+ if (script.id.startsWith('temp-script-')) return createRepositoryScriptAction({ repository_id: repoId, name: script.name.trim() || 'New Script', command: script.command.trim() || 'echo ""', position: script.position ?? index });
+ return updateRepositoryScriptAction(script.id, { name: script.name, command: script.command, position: script.position ?? index });
+ }));
+ const nextRepo: RepositoryWithScripts = { ...updated, scripts: nextScripts };
+ setRepositoryItems((prev) => prev.map((item) => (item.id === repoId ? nextRepo : item)));
+ setSavedRepositoryItems((prev) => prev.some((item) => item.id === repoId) ? prev.map((item) => (item.id === repoId ? cloneRepository(nextRepo) : item)) : [...prev, cloneRepository(nextRepo)]);
+ clearRepositoryScripts(repoId);
+}
+
+function useRepositoryHandlers({ workspace, repositoryItems, setRepositoryItems, setSavedRepositoryItems, savedRepositoriesById, clearRepositoryScripts }: RepoHandlerArgs) {
+ const handleUpdateRepository = (repoId: string, updates: Partial) => {
+ setRepositoryItems((prev) => prev.map((repo) => (repo.id === repoId ? { ...repo, ...updates } : repo)));
+ };
+ const handleAddRepositoryScript = (repoId: string) => {
+ const script: RepositoryScript = { id: `temp-script-${generateUUID()}`, repository_id: repoId, name: '', command: '', position: repositoryItems.find((repo) => repo.id === repoId)?.scripts.length ?? 0, created_at: '', updated_at: '' };
+ setRepositoryItems((prev) => prev.map((repo) => repo.id === repoId ? { ...repo, scripts: [...repo.scripts, script] } : repo));
+ };
+ const handleUpdateRepositoryScript = (repoId: string, scriptId: string, updates: Partial) => {
+ setRepositoryItems((prev) => prev.map((repo) => repo.id === repoId ? { ...repo, scripts: repo.scripts.map((script) => script.id === scriptId ? { ...script, ...updates } : script) } : repo));
+ };
+ const handleDeleteRepositoryScript = (repoId: string, scriptId: string) => {
+ setRepositoryItems((prev) => prev.map((repo) => repo.id === repoId ? { ...repo, scripts: repo.scripts.filter((script) => script.id !== scriptId) } : repo));
+ };
+ const handleSaveRepository = async (repoId: string) => {
+ const repo = repositoryItems.find((item) => item.id === repoId);
+ if (!repo) return;
+ if (repoId.startsWith('temp-repo-')) { await saveNewRepository(repo, repoId, workspace, setRepositoryItems, setSavedRepositoryItems); return; }
+ await saveExistingRepository({ repo, repoId, savedRepositoriesById, clearRepositoryScripts, setRepositoryItems, setSavedRepositoryItems });
+ };
+ const handleDeleteRepository = async (repoId: string) => {
+ if (repoId.startsWith('temp-repo-')) { setRepositoryItems((prev) => prev.filter((repo) => repo.id !== repoId)); return; }
+ await deleteRepositoryAction(repoId);
+ setRepositoryItems((prev) => prev.filter((repo) => repo.id !== repoId));
+ setSavedRepositoryItems((prev) => prev.filter((repo) => repo.id !== repoId));
+ };
+ return { handleUpdateRepository, handleAddRepositoryScript, handleUpdateRepositoryScript, handleDeleteRepositoryScript, handleSaveRepository, handleDeleteRepository };
+}
+
+function useDiscoverDialog(workspace: Workspace | null, toast: ReturnType['toast']) {
const [localRepoDialogOpen, setLocalRepoDialogOpen] = useState(false);
+ const [discoveredRepositories, setDiscoveredRepositories] = useState([]);
const [repoSearch, setRepoSearch] = useState('');
const [selectedRepoPath, setSelectedRepoPath] = useState(null);
const [manualRepoPath, setManualRepoPath] = useState('');
- const [manualValidation, setManualValidation] = useState<{
- status: 'idle' | 'loading' | 'success' | 'error';
- message?: string;
- isValid?: boolean;
- path?: string;
- }>({ status: 'idle' });
- const discoverRepositoriesRequest = useRequest(discoverRepositoriesAction);
- const validateRepositoryPathRequest = useRequest(validateRepositoryPathAction);
-
- const cloneRepository = (repo: RepositoryWithScripts): RepositoryWithScripts => ({
- ...repo,
- scripts: repo.scripts.map((script) => ({ ...script })),
- });
-
- const savedRepositoriesById = useMemo(() => {
- return new Map(savedRepositoryItems.map((repo) => [repo.id, repo]));
- }, [savedRepositoryItems]);
+ const [manualValidation, setManualValidation] = useState({ status: 'idle' });
+ const discoverRequest = useRequest(discoverRepositoriesAction);
+ const validateRequest = useRequest(validateRepositoryPathAction);
const filteredRepositories = useMemo(() => {
const query = repoSearch.trim().toLowerCase();
if (!query) return discoveredRepositories;
- return discoveredRepositories.filter(
- (repo) => repo.name.toLowerCase().includes(query) || repo.path.toLowerCase().includes(query)
- );
+ return discoveredRepositories.filter((repo) => repo.name.toLowerCase().includes(query) || repo.path.toLowerCase().includes(query));
}, [discoveredRepositories, repoSearch]);
- const canSaveLocalRepo =
- Boolean(selectedRepoPath) || (manualValidation.status === 'success' && manualValidation.isValid);
-
- const isRepositoryDirty = (repo: RepositoryItem) => {
- const saved = savedRepositoriesById.get(repo.id);
- if (!saved) return true;
- return (
- repo.name !== saved.name ||
- repo.source_type !== saved.source_type ||
- repo.local_path !== saved.local_path ||
- repo.provider !== saved.provider ||
- repo.provider_repo_id !== saved.provider_repo_id ||
- repo.provider_owner !== saved.provider_owner ||
- repo.provider_name !== saved.provider_name ||
- repo.default_branch !== saved.default_branch ||
- repo.worktree_branch_prefix !== saved.worktree_branch_prefix ||
- repo.pull_before_worktree !== saved.pull_before_worktree ||
- repo.setup_script !== saved.setup_script ||
- repo.cleanup_script !== saved.cleanup_script ||
- repo.dev_script !== saved.dev_script
- );
- };
-
- const areRepositoryScriptsDirty = (repo: RepositoryItem) => {
- const saved = savedRepositoriesById.get(repo.id);
- if (!saved) return repo.scripts.length > 0;
- if (repo.scripts.length !== saved.scripts.length) return true;
- const savedScripts = new Map(saved.scripts.map((script) => [script.id, script]));
- for (const script of repo.scripts) {
- const savedScript = savedScripts.get(script.id);
- if (!savedScript) return true;
- if (
- script.name !== savedScript.name ||
- script.command !== savedScript.command ||
- script.position !== savedScript.position
- ) {
- return true;
- }
- }
- return false;
- };
-
- const handleUpdateRepository = (repoId: string, updates: Partial) => {
- setRepositoryItems((prev) =>
- prev.map((repo) => (repo.id === repoId ? { ...repo, ...updates } : repo))
- );
- };
-
- const handleDiscoverRepositories = async () => {
+ const handleDiscover = async () => {
if (!workspace) return;
try {
- const result = await discoverRepositoriesRequest.run(workspace.id);
+ const result = await discoverRequest.run(workspace.id);
setDiscoveredRepositories(result.repositories);
} catch (error) {
- toast({
- title: 'Failed to discover repositories',
- description: error instanceof Error ? error.message : 'Request failed',
- variant: 'error',
- });
+ toast({ title: 'Failed to discover repositories', description: error instanceof Error ? error.message : 'Request failed', variant: 'error' });
}
};
- const openLocalRepositoryDialog = async () => {
- setLocalRepoDialogOpen(true);
- setRepoSearch('');
- setSelectedRepoPath(null);
- setManualRepoPath('');
- setManualValidation({ status: 'idle' });
- await handleDiscoverRepositories();
+ const openDialog = async () => {
+ setLocalRepoDialogOpen(true); setRepoSearch(''); setSelectedRepoPath(null); setManualRepoPath(''); setManualValidation({ status: 'idle' });
+ await handleDiscover();
};
const handleValidateManualPath = async () => {
if (!workspace || !manualRepoPath.trim()) return;
setManualValidation({ status: 'loading' });
try {
- const result = await validateRepositoryPathRequest.run(workspace.id, manualRepoPath.trim());
- if (result.allowed && result.exists && result.is_git) {
- setManualValidation({
- status: 'success',
- isValid: true,
- message: 'Valid git repository',
- path: result.path,
- });
- } else {
- setManualValidation({
- status: 'error',
- isValid: false,
- message: result.message || 'Invalid repository path',
- path: result.path,
- });
- }
+ const result = await validateRequest.run(workspace.id, manualRepoPath.trim());
+ if (result.allowed && result.exists && result.is_git) setManualValidation({ status: 'success', isValid: true, message: 'Valid git repository', path: result.path });
+ else setManualValidation({ status: 'error', isValid: false, message: result.message || 'Invalid repository path', path: result.path });
} catch (error) {
- setManualValidation({
- status: 'error',
- isValid: false,
- message: error instanceof Error ? error.message : 'Request failed',
- });
+ setManualValidation({ status: 'error', isValid: false, message: error instanceof Error ? error.message : 'Request failed' });
}
};
+ const handleSelectRepoPath = (path: string) => { setSelectedRepoPath(path); setManualRepoPath(''); setManualValidation({ status: 'idle' }); };
+ const handleManualRepoPathChange = (value: string) => { setManualRepoPath(value); setSelectedRepoPath(null); setManualValidation({ status: 'idle' }); };
+ const canSave = Boolean(selectedRepoPath) || (manualValidation.status === 'success' && manualValidation.isValid === true);
+
+ return {
+ localRepoDialogOpen, setLocalRepoDialogOpen, filteredRepositories, repoSearch, setRepoSearch,
+ selectedRepoPath, handleSelectRepoPath, manualRepoPath, handleManualRepoPathChange,
+ manualValidation, handleValidateManualPath, isValidating: validateRequest.isLoading,
+ isDiscovering: discoverRequest.isLoading, canSave, openDialog,
+ discoveredRepositories,
+ };
+}
+
+export function WorkspaceRepositoriesClient({ workspace, repositories }: WorkspaceRepositoriesClientProps) {
+ const router = useRouter();
+ const { toast } = useToast();
+ const clearRepositoryScripts = useAppStore((state) => state.clearRepositoryScripts);
+ const [repositoryItems, setRepositoryItems] = useState(repositories);
+ const [savedRepositoryItems, setSavedRepositoryItems] = useState(repositories);
+ const savedRepositoriesById = useMemo(() => new Map(savedRepositoryItems.map((repo) => [repo.id, repo])), [savedRepositoryItems]);
+
+ const handlers = useRepositoryHandlers({ workspace, repositoryItems, setRepositoryItems, setSavedRepositoryItems, savedRepositoriesById, clearRepositoryScripts });
+ const { handleUpdateRepository, handleAddRepositoryScript, handleUpdateRepositoryScript, handleDeleteRepositoryScript, handleSaveRepository, handleDeleteRepository } = handlers;
+
+ const discover = useDiscoverDialog(workspace, toast);
+ const { localRepoDialogOpen, setLocalRepoDialogOpen, filteredRepositories, repoSearch, setRepoSearch, selectedRepoPath, handleSelectRepoPath, manualRepoPath, handleManualRepoPathChange, manualValidation, handleValidateManualPath, isValidating, isDiscovering, canSave, openDialog, discoveredRepositories } = discover;
+
const handleConfirmLocalRepository = () => {
if (!workspace) return;
const selectedRepo = discoveredRepositories.find((repo) => repo.path === selectedRepoPath);
- const path = selectedRepo?.path || manualValidation.path || manualRepoPath.trim();
- if (!path) return;
- const name = selectedRepo?.name || path.split('/').filter(Boolean).slice(-1)[0] || 'New Repository';
- const draftId = `temp-repo-${generateUUID()}`;
- const draftRepo: RepositoryItem = {
- id: draftId,
- workspace_id: workspace.id,
- name,
- source_type: 'local',
- local_path: path,
- provider: '',
- provider_repo_id: '',
- provider_owner: '',
- provider_name: '',
- default_branch: selectedRepo?.default_branch || '',
- worktree_branch_prefix: 'feature/',
- pull_before_worktree: true,
- setup_script: '',
- cleanup_script: '',
- dev_script: '',
- created_at: '',
- updated_at: '',
- scripts: [],
- __autoOpen: true,
- };
+ const draftRepo = buildDraftRepo(workspace, selectedRepo, manualValidation, manualRepoPath);
+ if (!draftRepo.local_path) return;
setRepositoryItems((prev) => [draftRepo, ...prev]);
setLocalRepoDialogOpen(false);
};
- const handleAddRepositoryScript = (repoId: string) => {
- const script: RepositoryScript = {
- id: `temp-script-${generateUUID()}`,
- repository_id: repoId,
- name: '',
- command: '',
- position: repositoryItems.find((repo) => repo.id === repoId)?.scripts.length ?? 0,
- created_at: '',
- updated_at: '',
- };
- setRepositoryItems((prev) =>
- prev.map((repo) =>
- repo.id === repoId ? { ...repo, scripts: [...repo.scripts, script] } : repo
- )
- );
- };
-
- const handleUpdateRepositoryScript = (
- repoId: string,
- scriptId: string,
- updates: Partial
- ) => {
- setRepositoryItems((prev) =>
- prev.map((repo) =>
- repo.id === repoId
- ? {
- ...repo,
- scripts: repo.scripts.map((script) =>
- script.id === scriptId ? { ...script, ...updates } : script
- ),
- }
- : repo
- )
- );
- };
-
- const handleDeleteRepositoryScript = (repoId: string, scriptId: string) => {
- setRepositoryItems((prev) =>
- prev.map((repo) =>
- repo.id === repoId
- ? { ...repo, scripts: repo.scripts.filter((script) => script.id !== scriptId) }
- : repo
- )
- );
- };
-
- const handleSaveRepository = async (repoId: string) => {
- const repo = repositoryItems.find((item) => item.id === repoId);
- if (!repo) return;
- if (repoId.startsWith('temp-repo-')) {
- const created = await createRepositoryAction({
- workspace_id: workspace?.id ?? repo.workspace_id,
- name: repo.name.trim() || 'New Repository',
- source_type: repo.source_type || 'local',
- local_path: repo.local_path,
- provider: repo.provider,
- provider_repo_id: repo.provider_repo_id,
- provider_owner: repo.provider_owner,
- provider_name: repo.provider_name,
- default_branch: repo.default_branch,
- worktree_branch_prefix: repo.worktree_branch_prefix,
- pull_before_worktree: repo.pull_before_worktree,
- setup_script: repo.setup_script,
- cleanup_script: repo.cleanup_script,
- dev_script: repo.dev_script,
- });
- const scripts = await Promise.all(
- repo.scripts.map((script, index) =>
- createRepositoryScriptAction({
- repository_id: created.id,
- name: script.name.trim() || 'New Script',
- command: script.command.trim() || 'echo ""',
- position: script.position ?? index,
- })
- )
- );
- const nextRepo: RepositoryWithScripts = { ...created, scripts };
- setRepositoryItems((prev) => prev.map((item) => (item.id === repoId ? nextRepo : item)));
- setSavedRepositoryItems((prev) => [cloneRepository(nextRepo), ...prev]);
- return;
- }
-
- const updated = await updateRepositoryAction(repoId, {
- name: repo.name,
- source_type: repo.source_type,
- local_path: repo.local_path,
- provider: repo.provider,
- provider_repo_id: repo.provider_repo_id,
- provider_owner: repo.provider_owner,
- provider_name: repo.provider_name,
- default_branch: repo.default_branch,
- worktree_branch_prefix: repo.worktree_branch_prefix,
- pull_before_worktree: repo.pull_before_worktree,
- setup_script: repo.setup_script,
- cleanup_script: repo.cleanup_script,
- dev_script: repo.dev_script,
- });
-
- const saved = savedRepositoriesById.get(repoId);
- const savedScripts = saved ? saved.scripts : [];
- const currentScriptIds = new Set(repo.scripts.map((script) => script.id));
-
- await Promise.all(
- savedScripts
- .filter((script) => !currentScriptIds.has(script.id))
- .map((script) => deleteRepositoryScriptAction(script.id))
- );
-
- const nextScripts = await Promise.all(
- repo.scripts.map((script, index) => {
- if (script.id.startsWith('temp-script-')) {
- return createRepositoryScriptAction({
- repository_id: repoId,
- name: script.name.trim() || 'New Script',
- command: script.command.trim() || 'echo ""',
- position: script.position ?? index,
- });
- }
- return updateRepositoryScriptAction(script.id, {
- name: script.name,
- command: script.command,
- position: script.position ?? index,
- });
- })
- );
-
- const nextRepo: RepositoryWithScripts = { ...updated, scripts: nextScripts };
- setRepositoryItems((prev) => prev.map((item) => (item.id === repoId ? nextRepo : item)));
- setSavedRepositoryItems((prev) =>
- prev.some((item) => item.id === repoId)
- ? prev.map((item) => (item.id === repoId ? cloneRepository(nextRepo) : item))
- : [...prev, cloneRepository(nextRepo)]
- );
-
- // Clear the scripts cache so task pages will refetch the updated scripts
- clearRepositoryScripts(repoId);
- };
-
- const handleDeleteRepository = async (repoId: string) => {
- if (repoId.startsWith('temp-repo-')) {
- setRepositoryItems((prev) => prev.filter((repo) => repo.id !== repoId));
- return;
- }
- await deleteRepositoryAction(repoId);
- setRepositoryItems((prev) => prev.filter((repo) => repo.id !== repoId));
- setSavedRepositoryItems((prev) => prev.filter((repo) => repo.id !== repoId));
- };
-
if (!workspace) {
return (
-
-
-
- Workspace not found
- router.push('/settings/workspace')}>
- Back to Workspaces
-
-
-
-
+
+ Workspace not found
+ router.push('/settings/workspace')}>Back to Workspaces
+
);
}
@@ -383,134 +316,28 @@ export function WorkspaceRepositoriesClient({
{workspace.name}
-
- Manage repositories connected to this workspace.
-
+
Manage repositories connected to this workspace.
-
- Workspace settings
-
+
Workspace settings
-
-
- }
- title="Repositories"
- description="Repositories in this workspace"
- action={
-
-
- Add Local Repository
-
-
- }
+ } title="Repositories" description="Repositories in this workspace"
+ action={Add Local Repository }
>
{repositoryItems.map((repo) => (
-
))}
-
-
-
-
- Add Local Repository
-
- Select a discovered repository or provide an absolute path to validate.
-
-
-
-
-
Discovered repositories
-
setRepoSearch(e.target.value)}
- />
-
- {discoverRepositoriesRequest.isLoading ? (
-
-
- Scanning repositories...
-
- ) : filteredRepositories.length === 0 ? (
-
No repositories found.
- ) : (
- filteredRepositories.map((repo) => (
-
{
- setSelectedRepoPath(repo.path);
- setManualRepoPath('');
- setManualValidation({ status: 'idle' });
- }}
- >
- {repo.name}
- {repo.path}
-
- ))
- )}
-
-
-
-
-
Manual path
-
- {
- setManualRepoPath(e.target.value);
- setSelectedRepoPath(null);
- setManualValidation({ status: 'idle' });
- }}
- />
-
- {validateRepositoryPathRequest.isLoading ? 'Checking...' : 'Validate'}
-
-
- {manualValidation.status === 'error' && (
-
{manualValidation.message}
- )}
- {manualValidation.status === 'success' && (
-
{manualValidation.message}
- )}
-
-
-
- setLocalRepoDialogOpen(false)}>
- Cancel
-
-
- Use Repository
-
-
-
-
-
+
);
}
diff --git a/apps/web/app/settings/workspace/workspace-workflows-client.tsx b/apps/web/app/settings/workspace/workspace-workflows-client.tsx
index 83f2f3e23f..28e249e9df 100644
--- a/apps/web/app/settings/workspace/workspace-workflows-client.tsx
+++ b/apps/web/app/settings/workspace/workspace-workflows-client.tsx
@@ -32,136 +32,179 @@ import {
} from '@/app/actions/workspaces';
import type { Workflow, StepDefinition, WorkflowStep, Workspace, WorkflowTemplate, WorkflowExportData } from '@/lib/types/http';
+type ImportWorkflowsDialogProps = {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ importJson: string;
+ onImportJsonChange: (value: string) => void;
+ onFileUpload: (e: React.ChangeEvent) => void;
+ fileInputRef: React.RefObject;
+ onImport: () => void;
+ importLoading: boolean;
+};
+
+function ImportWorkflowsDialog({
+ open,
+ onOpenChange,
+ importJson,
+ onImportJsonChange,
+ onFileUpload,
+ fileInputRef,
+ onImport,
+ importLoading,
+}: ImportWorkflowsDialogProps) {
+ return (
+
+
+
+ Import Workflows
+
+
+
+ Upload JSON file
+
+
+
+ Or paste JSON
+
+
+
+ onOpenChange(false)} className="cursor-pointer">
+ Cancel
+
+
+ {importLoading ? 'Importing...' : 'Import'}
+
+
+
+
+ );
+}
+
+function TemplateRadioItem({ template, isSelected }: { template: WorkflowTemplate; isSelected: boolean }) {
+ return (
+
+
+
+
{template.name}
+ {template.description &&
{template.description} }
+ {template.default_steps && template.default_steps.length > 0 && (
+
+ {template.default_steps.map((step, i) => (
+
+ ))}
+
+ )}
+
+
+ );
+}
+
+type CreateWorkflowDialogProps = {
+ open: boolean; onOpenChange: (open: boolean) => void;
+ workflowName: string; onWorkflowNameChange: (value: string) => void;
+ selectedTemplateId: string | null; onSelectedTemplateChange: (value: string | null) => void;
+ workflowTemplates: WorkflowTemplate[]; onCreate: () => void;
+};
+
+function CreateWorkflowDialog({ open, onOpenChange, workflowName, onWorkflowNameChange, selectedTemplateId, onSelectedTemplateChange, workflowTemplates, onCreate }: CreateWorkflowDialogProps) {
+ return (
+
+
+ Add Workflow
+
+
+ Name
+ onWorkflowNameChange(e.target.value)} />
+
+ {workflowTemplates.length > 0 && (
+
+
Template
+
onSelectedTemplateChange(v === 'custom' ? null : v)}>
+
+ {workflowTemplates.map((template) =>
)}
+
+
+ Custom Create your own agentic workflow from scratch.
+
+
+
+
+ )}
+
+
+ onOpenChange(false)} className="cursor-pointer">Cancel
+ Add Workflow
+
+
+
+ );
+}
+
type WorkspaceWorkflowsClientProps = {
workspace: Workspace | null;
workflows: Workflow[];
workflowTemplates: WorkflowTemplate[];
};
-export function WorkspaceWorkflowsClient({
- workspace,
- workflows,
- workflowTemplates,
-}: WorkspaceWorkflowsClientProps) {
- const router = useRouter();
- const { toast } = useToast();
- const { workflowItems, setWorkflowItems, setSavedWorkflowItems, isWorkflowDirty } =
- useWorkflowSettings(workflows);
+const DEFAULT_CUSTOM_STEPS: StepDefinition[] = [
+ { name: 'Todo', position: 0, color: 'bg-slate-500' },
+ { name: 'In Progress', position: 1, color: 'bg-blue-500' },
+ { name: 'Review', position: 2, color: 'bg-purple-500' },
+ { name: 'Done', position: 3, color: 'bg-green-500' },
+];
- // Dialog state for creating a new workflow
- const [isAddWorkflowDialogOpen, setIsAddWorkflowDialogOpen] = useState(false);
- const [newWorkflowName, setNewWorkflowName] = useState('');
- const [selectedTemplateId, setSelectedTemplateId] = useState(null);
+type WorkflowActionsArgs = {
+ workspace: Workspace | null;
+ workflowItems: Workflow[];
+ workflowTemplates: WorkflowTemplate[];
+ setWorkflowItems: React.Dispatch>;
+ setSavedWorkflowItems: React.Dispatch>;
+ router: ReturnType;
+};
- // Export dialog state
+function buildWorkflowSteps(workflow: Workflow, definitions: StepDefinition[]): WorkflowStep[] {
+ return definitions.map((step, index) => ({
+ id: `temp-step-${workflow.id}-${index}`,
+ workflow_id: workflow.id,
+ name: step.name,
+ position: step.position ?? index,
+ color: step.color ?? 'bg-slate-500',
+ prompt: step.prompt,
+ events: step.events,
+ is_start_step: step.is_start_step,
+ allow_manual_move: true,
+ created_at: '',
+ updated_at: '',
+ }));
+}
+
+function useWorkflowImportExport(workspace: Workspace | null, router: ReturnType, toast: ReturnType['toast']) {
const [isExportDialogOpen, setIsExportDialogOpen] = useState(false);
const [exportJson, setExportJson] = useState('');
-
- // Import dialog state
const [isImportDialogOpen, setIsImportDialogOpen] = useState(false);
const [importJson, setImportJson] = useState('');
const [importLoading, setImportLoading] = useState(false);
const fileInputRef = useRef(null);
- const templateStepsById = useMemo(() => {
- return new Map(
- workflowTemplates.map((template) => [template.id, template.default_steps ?? []])
- );
- }, [workflowTemplates]);
-
- const defaultCustomSteps = useMemo(
- () => [
- { name: 'Todo', position: 0, color: 'bg-slate-500' },
- { name: 'In Progress', position: 1, color: 'bg-blue-500' },
- { name: 'Review', position: 2, color: 'bg-purple-500' },
- { name: 'Done', position: 3, color: 'bg-green-500' },
- ],
- []
- );
-
- const buildWorkflowSteps = (workflow: Workflow, definitions: StepDefinition[]): WorkflowStep[] =>
- definitions.map((step, index) => ({
- id: `temp-step-${workflow.id}-${index}`,
- workflow_id: workflow.id,
- name: step.name,
- position: step.position ?? index,
- color: step.color ?? 'bg-slate-500',
- prompt: step.prompt,
- events: step.events,
- is_start_step: step.is_start_step,
- allow_manual_move: true,
- created_at: '',
- updated_at: '',
- }));
-
- const handleOpenAddWorkflowDialog = () => {
- setNewWorkflowName('');
- setSelectedTemplateId(workflowTemplates.length > 0 ? workflowTemplates[0].id : null);
- setIsAddWorkflowDialogOpen(true);
- };
-
- const handleCreateWorkflow = () => {
- if (!workspace) return;
-
- const draftWorkflow: Workflow = {
- id: `temp-${generateUUID()}`,
- workspace_id: workspace.id,
- name: newWorkflowName.trim() || (selectedTemplateId ? workflowTemplates.find((t) => t.id === selectedTemplateId)?.name ?? 'New Workflow' : 'New Workflow'),
- description: '',
- workflow_template_id: selectedTemplateId,
- created_at: '',
- updated_at: '',
- };
-
- setWorkflowItems((prev) => [draftWorkflow, ...prev]);
- setIsAddWorkflowDialogOpen(false);
- };
-
- const handleUpdateWorkflow = (workflowId: string, updates: { name?: string; description?: string }) => {
- setWorkflowItems((prev) =>
- prev.map((workflow) => (workflow.id === workflowId ? { ...workflow, ...updates } : workflow))
- );
- };
-
- const handleDeleteWorkflow = async (workflowId: string) => {
- if (workflowId.startsWith('temp-')) {
- setWorkflowItems((prev) => prev.filter((workflow) => workflow.id !== workflowId));
- return;
- }
- await deleteWorkflowAction(workflowId);
- setWorkflowItems((prev) => prev.filter((workflow) => workflow.id !== workflowId));
- setSavedWorkflowItems((prev) => prev.filter((workflow) => workflow.id !== workflowId));
- };
-
- const handleWorkflowCreated = (tempId: string, created: Workflow) => {
- setWorkflowItems((prev) => prev.map((item) => (item.id === tempId ? created : item)));
- setSavedWorkflowItems((prev) => [{ ...created }, ...prev]);
- router.refresh();
- };
-
- const handleSaveWorkflow = async (workflowId: string) => {
- const workflow = workflowItems.find((item) => item.id === workflowId);
- if (!workflow) return;
- // For existing workflows, only update workflow name/description
- const updates: { name?: string; description?: string } = {};
- if (workflow.name.trim()) {
- updates.name = workflow.name.trim();
- }
- if (Object.keys(updates).length) {
- await updateWorkflowAction(workflowId, updates);
- }
- setWorkflowItems((prev) =>
- prev.map((item) => (item.id === workflowId ? { ...item, ...updates } : item))
- );
- setSavedWorkflowItems((prev) =>
- prev.some((item) => item.id === workflowId)
- ? prev.map((item) =>
- item.id === workflowId ? { ...workflow, ...updates } : item
- )
- : [...prev, { ...workflow, ...updates }]
- );
- };
-
const handleExportAll = async () => {
if (!workspace) return;
try {
@@ -169,11 +212,7 @@ export function WorkspaceWorkflowsClient({
setExportJson(JSON.stringify(data, null, 2));
setIsExportDialogOpen(true);
} catch (error) {
- toast({
- title: 'Failed to export workflows',
- description: error instanceof Error ? error.message : 'Request failed',
- variant: 'error',
- });
+ toast({ title: 'Failed to export workflows', description: error instanceof Error ? error.message : 'Request failed', variant: 'error' });
}
};
@@ -181,9 +220,7 @@ export function WorkspaceWorkflowsClient({
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
- reader.onload = (event) => {
- setImportJson(event.target?.result as string);
- };
+ reader.onload = (event) => { setImportJson(event.target?.result as string); };
reader.readAsText(file);
e.target.value = '';
};
@@ -202,32 +239,116 @@ export function WorkspaceWorkflowsClient({
toast({ title: 'Import complete', description: parts.join('. ') });
setIsImportDialogOpen(false);
setImportJson('');
- if (created.length > 0) {
- router.refresh();
- }
+ if (created.length > 0) router.refresh();
} catch (error) {
- toast({
- title: 'Failed to import workflows',
- description: error instanceof Error ? error.message : 'Invalid JSON',
- variant: 'error',
- });
- } finally {
- setImportLoading(false);
- }
+ toast({ title: 'Failed to import workflows', description: error instanceof Error ? error.message : 'Invalid JSON', variant: 'error' });
+ } finally { setImportLoading(false); }
};
+ return { isExportDialogOpen, setIsExportDialogOpen, exportJson, isImportDialogOpen, setIsImportDialogOpen, importJson, setImportJson, importLoading, fileInputRef, handleExportAll, handleFileUpload, handleImport };
+}
+
+function useWorkflowActions({ workspace, workflowItems, workflowTemplates, setWorkflowItems, setSavedWorkflowItems, router }: WorkflowActionsArgs) {
+ const [isAddWorkflowDialogOpen, setIsAddWorkflowDialogOpen] = useState(false);
+ const [newWorkflowName, setNewWorkflowName] = useState('');
+ const [selectedTemplateId, setSelectedTemplateId] = useState(null);
+
+ const handleOpenAddWorkflowDialog = () => {
+ setNewWorkflowName('');
+ setSelectedTemplateId(workflowTemplates.length > 0 ? workflowTemplates[0].id : null);
+ setIsAddWorkflowDialogOpen(true);
+ };
+
+ const handleCreateWorkflow = () => {
+ if (!workspace) return;
+ const templateName = selectedTemplateId ? workflowTemplates.find((t) => t.id === selectedTemplateId)?.name ?? 'New Workflow' : 'New Workflow';
+ const draftWorkflow: Workflow = { id: `temp-${generateUUID()}`, workspace_id: workspace.id, name: newWorkflowName.trim() || templateName, description: '', workflow_template_id: selectedTemplateId, created_at: '', updated_at: '' };
+ setWorkflowItems((prev) => [draftWorkflow, ...prev]);
+ setIsAddWorkflowDialogOpen(false);
+ };
+
+ const handleUpdateWorkflow = (workflowId: string, updates: { name?: string; description?: string }) => {
+ setWorkflowItems((prev) => prev.map((wf) => (wf.id === workflowId ? { ...wf, ...updates } : wf)));
+ };
+
+ const handleDeleteWorkflow = async (workflowId: string) => {
+ if (workflowId.startsWith('temp-')) { setWorkflowItems((prev) => prev.filter((wf) => wf.id !== workflowId)); return; }
+ await deleteWorkflowAction(workflowId);
+ setWorkflowItems((prev) => prev.filter((wf) => wf.id !== workflowId));
+ setSavedWorkflowItems((prev) => prev.filter((wf) => wf.id !== workflowId));
+ };
+
+ const handleWorkflowCreated = (tempId: string, created: Workflow) => {
+ setWorkflowItems((prev) => prev.map((item) => (item.id === tempId ? created : item)));
+ setSavedWorkflowItems((prev) => [{ ...created }, ...prev]);
+ router.refresh();
+ };
+
+ const handleSaveWorkflow = async (workflowId: string) => {
+ const workflow = workflowItems.find((item) => item.id === workflowId);
+ if (!workflow) return;
+ const updates: { name?: string; description?: string } = {};
+ if (workflow.name.trim()) updates.name = workflow.name.trim();
+ if (Object.keys(updates).length) await updateWorkflowAction(workflowId, updates);
+ setWorkflowItems((prev) => prev.map((item) => (item.id === workflowId ? { ...item, ...updates } : item)));
+ setSavedWorkflowItems((prev) => prev.some((item) => item.id === workflowId) ? prev.map((item) => item.id === workflowId ? { ...workflow, ...updates } : item) : [...prev, { ...workflow, ...updates }]);
+ };
+
+ return { isAddWorkflowDialogOpen, setIsAddWorkflowDialogOpen, newWorkflowName, setNewWorkflowName, selectedTemplateId, setSelectedTemplateId, handleOpenAddWorkflowDialog, handleCreateWorkflow, handleUpdateWorkflow, handleDeleteWorkflow, handleWorkflowCreated, handleSaveWorkflow };
+}
+
+type WorkflowSectionActionsProps = { onExport: () => void; onImport: () => void; onAdd: () => void };
+
+function WorkflowSectionActions({ onExport, onImport, onAdd }: WorkflowSectionActionsProps) {
+ return (
+
+ Export All
+ Import
+ Add Workflow
+
+ );
+}
+
+type WorkflowListProps = { workflowItems: Workflow[]; templateStepsById: Map; isWorkflowDirty: (wf: Workflow) => boolean; onUpdate: (id: string, u: { name?: string; description?: string }) => void; onDelete: (id: string) => void; onSave: (id: string) => void; onCreated: (id: string, wf: Workflow) => void };
+
+function WorkflowList({ workflowItems, templateStepsById, isWorkflowDirty, onUpdate, onDelete, onSave, onCreated }: WorkflowListProps) {
+ return (
+
+ {workflowItems.map((workflow) => {
+ const isTempWorkflow = workflow.id.startsWith('temp-');
+ const templateSteps = isTempWorkflow && workflow.workflow_template_id ? templateStepsById.get(workflow.workflow_template_id) ?? [] : DEFAULT_CUSTOM_STEPS;
+ const initialWorkflowSteps = isTempWorkflow && templateSteps.length ? buildWorkflowSteps(workflow, templateSteps) : undefined;
+ return (
+ w.id !== workflow.id && !w.id.startsWith('temp-'))}
+ onUpdateWorkflow={(updates) => onUpdate(workflow.id, updates)} onDeleteWorkflow={async () => { await onDelete(workflow.id); }}
+ onSaveWorkflow={async () => { await onSave(workflow.id); }} onWorkflowCreated={(created) => onCreated(workflow.id, created)}
+ />
+ );
+ })}
+
+ );
+}
+
+export function WorkspaceWorkflowsClient({ workspace, workflows, workflowTemplates }: WorkspaceWorkflowsClientProps) {
+ const router = useRouter();
+ const { toast } = useToast();
+ const { workflowItems, setWorkflowItems, setSavedWorkflowItems, isWorkflowDirty } = useWorkflowSettings(workflows);
+
+ const importExport = useWorkflowImportExport(workspace, router, toast);
+ const { isExportDialogOpen, setIsExportDialogOpen, exportJson, isImportDialogOpen, setIsImportDialogOpen, importJson, setImportJson, importLoading, fileInputRef, handleExportAll, handleFileUpload, handleImport } = importExport;
+
+ const actions = useWorkflowActions({ workspace, workflowItems, workflowTemplates, setWorkflowItems, setSavedWorkflowItems, router });
+ const { isAddWorkflowDialogOpen, setIsAddWorkflowDialogOpen, newWorkflowName, setNewWorkflowName, selectedTemplateId, setSelectedTemplateId, handleOpenAddWorkflowDialog, handleCreateWorkflow, handleUpdateWorkflow, handleDeleteWorkflow, handleWorkflowCreated, handleSaveWorkflow } = actions;
+
+ const templateStepsById = useMemo(() => new Map(workflowTemplates.map((t) => [t.id, t.default_steps ?? []])), [workflowTemplates]);
+
if (!workspace) {
return (
-
-
-
- Workspace not found
- router.push('/settings/workspace')}>
- Back to Workspaces
-
-
-
-
+
+ Workspace not found
+ router.push('/settings/workspace')}>Back to Workspaces
+
);
}
@@ -236,209 +357,27 @@ export function WorkspaceWorkflowsClient({
{workspace.name}
-
- Manage workflows for this workspace.
-
+
Manage workflows for this workspace.
-
- Workspace settings
-
+
Workspace settings
-
-
- }
- title="Workflows"
+ } title="Workflows"
description="Create autonomous pipelines with automated transitions or manual workflows where you move tasks yourself"
- action={
-
-
-
- Export All
-
- setIsImportDialogOpen(true)} className="cursor-pointer">
-
- Import
-
-
-
- Add Workflow
-
-
- }
+ action={ setIsImportDialogOpen(true)} onAdd={handleOpenAddWorkflowDialog} />}
>
-
- {workflowItems.map((workflow) => {
- const isTempWorkflow = workflow.id.startsWith('temp-');
- const templateSteps =
- isTempWorkflow && workflow.workflow_template_id
- ? templateStepsById.get(workflow.workflow_template_id) ?? []
- : defaultCustomSteps;
- const initialWorkflowSteps =
- isTempWorkflow && templateSteps.length
- ? buildWorkflowSteps(workflow, templateSteps)
- : undefined;
- return (
- w.id !== workflow.id && !w.id.startsWith('temp-')
- )}
- onUpdateWorkflow={(updates) => handleUpdateWorkflow(workflow.id, updates)}
- onDeleteWorkflow={() => handleDeleteWorkflow(workflow.id)}
- onSaveWorkflow={() => handleSaveWorkflow(workflow.id)}
- onWorkflowCreated={(created) => handleWorkflowCreated(workflow.id, created)}
- />
- );
- })}
-
+
-
-
+
+
-
- {/* Import Workflows Dialog */}
-
-
-
- Import Workflows
-
-
-
- Upload JSON file
-
-
-
- Or paste JSON
-
-
-
- setIsImportDialogOpen(false)} className="cursor-pointer">
- Cancel
-
-
- {importLoading ? 'Importing...' : 'Import'}
-
-
-
-
-
- {/* Create Workflow Dialog */}
-
-
-
- Add Workflow
-
-
-
-
- Name
- setNewWorkflowName(e.target.value)}
- />
-
-
- {workflowTemplates.length > 0 && (
-
-
Template
-
- setSelectedTemplateId(value === 'custom' ? null : value)
- }
- >
-
- {workflowTemplates.map((template) => (
-
-
-
-
{template.name}
- {template.description && (
-
- {template.description}
-
- )}
- {template.default_steps && template.default_steps.length > 0 && (
-
- {template.default_steps.map((step, i) => (
-
- ))}
-
- )}
-
-
- ))}
-
-
-
- Custom
-
- Create your own agentic workflow from scratch.
-
-
-
-
-
-
- )}
-
- {/* Workflow preview intentionally omitted from the modal */}
-
-
-
- setIsAddWorkflowDialogOpen(false)} className="cursor-pointer">
- Cancel
-
- Add Workflow
-
-
-
);
}
diff --git a/apps/web/app/settings/workspace/workspaces-page-client.tsx b/apps/web/app/settings/workspace/workspaces-page-client.tsx
index e9ece41520..620986631f 100644
--- a/apps/web/app/settings/workspace/workspaces-page-client.tsx
+++ b/apps/web/app/settings/workspace/workspaces-page-client.tsx
@@ -15,6 +15,73 @@ import { RequestIndicator } from '@/components/request-indicator';
import { useAppStore } from '@/components/state-provider';
import type { Workspace } from '@/lib/types/http';
+type AddWorkspaceFormProps = {
+ newWorkspaceName: string;
+ onNameChange: (value: string) => void;
+ onSubmit: (e: React.FormEvent) => void;
+ onCancel: () => void;
+ isLoading: boolean;
+ status: 'idle' | 'loading' | 'success' | 'error';
+};
+
+function AddWorkspaceForm({ newWorkspaceName, onNameChange, onSubmit, onCancel, isLoading, status }: AddWorkspaceFormProps) {
+ return (
+
+
+
+
+
+ );
+}
+
+function WorkspaceListItem({ workspace }: { workspace: Workspace }) {
+ return (
+
+
+
+
+
+
+
+
+
+
{workspace.name}
+
+ Workflows managed in this workspace
+
+
+
+
+
+
+
+
+ );
+}
+
export function WorkspacesPageClient() {
const items = useAppStore((state) => state.workspaces.items);
const setWorkspaces = useAppStore((state) => state.setWorkspaces);
@@ -83,57 +150,18 @@ export function WorkspacesPageClient() {
{isAdding && (
-
-
-
-
-
+
setIsAdding(false)}
+ isLoading={createRequest.isLoading}
+ status={createRequest.status}
+ />
)}
{items.map((workspace: Workspace) => (
-
-
-
-
-
-
-
-
-
-
{workspace.name}
-
- Workflows managed in this workspace
-
-
-
-
-
-
-
-
+
))}
{items.length === 0 && (
diff --git a/apps/web/app/stats/loading.tsx b/apps/web/app/stats/loading.tsx
index 2933447982..ed52bda887 100644
--- a/apps/web/app/stats/loading.tsx
+++ b/apps/web/app/stats/loading.tsx
@@ -1,10 +1,141 @@
import { Card, CardContent, CardHeader } from '@kandev/ui/card';
import { Skeleton } from '@kandev/ui/skeleton';
+function OverviewCardsSkeleton() {
+ return (
+
+ {[...Array(4)].map((_, i) => (
+
+
+
+
+
+
+
+
+
+
+ ))}
+
+ );
+}
+
+function ChartsSkeleton() {
+ return (
+
+
+
+
+
+
+
+
+ {[...Array(3)].map((_, i) => (
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {[...Array(3)].map((_, i) => (
+
+
+
+
+ ))}
+
+
+
+
+ );
+}
+
+function ActivitySkeleton() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ {[...Array(5)].map((_, i) => (
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+ {[...Array(3)].map((_, i) => (
+
+ ))}
+
+
+
+
+ );
+}
+
+function RepositoriesSkeleton() {
+ return (
+
+
+
+
+
+
+ {[...Array(4)].map((_, i) => (
+
+ ))}
+
+
+
+ );
+}
+
export default function StatsLoading() {
return (
- {/* Header */}
-
- {/* Content */}
- {/* Overview Cards */}
-
- {[...Array(4)].map((_, i) => (
-
-
-
-
-
-
-
-
-
-
- ))}
-
-
- {/* Telemetry Section */}
+
-
- {/* Completed Tasks and Productivity */}
-
-
-
-
-
-
-
-
- {[...Array(3)].map((_, i) => (
-
- ))}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {[...Array(3)].map((_, i) => (
-
-
-
-
- ))}
-
-
-
-
-
- {/* Activity and Agents */}
-
-
-
-
-
-
-
-
-
-
-
- {[...Array(5)].map((_, i) => (
-
- ))}
-
-
-
-
-
-
-
-
-
-
-
-
- {[...Array(3)].map((_, i) => (
-
- ))}
-
-
-
-
-
- {/* Repositories */}
-
-
-
-
-
-
- {[...Array(4)].map((_, i) => (
-
- ))}
-
-
-
+
+
+
diff --git a/apps/web/app/stats/stats-charts.tsx b/apps/web/app/stats/stats-charts.tsx
new file mode 100644
index 0000000000..05b2ddb48d
--- /dev/null
+++ b/apps/web/app/stats/stats-charts.tsx
@@ -0,0 +1,368 @@
+'use client';
+
+import { useMemo, useState } from 'react';
+import { Button } from '@kandev/ui/button';
+import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@kandev/ui/tooltip';
+import type {
+ DailyActivityDTO,
+ AgentUsageDTO,
+ CompletedTaskActivityDTO,
+} from '@/lib/types/http';
+
+function formatMonthLabel(date: Date): string {
+ return date.toLocaleDateString('en-US', { month: 'short', year: '2-digit' });
+}
+
+function formatWeekLabel(date: Date): string {
+ return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
+}
+
+function getHeatmapColor(intensity: number): string {
+ if (intensity === 0) return 'bg-muted';
+ if (intensity < 0.25) return 'bg-emerald-500/30';
+ if (intensity < 0.5) return 'bg-emerald-500/50';
+ if (intensity < 0.75) return 'bg-emerald-500/70';
+ return 'bg-emerald-500/90';
+}
+
+function formatDate(dateStr: string): string {
+ const date = new Date(dateStr);
+ return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
+}
+
+function HeatmapGrid({ weeks, maxActivity }: { weeks: DailyActivityDTO[][]; maxActivity: number }) {
+ return (
+
+
+ {weeks.map((week, weekIndex) => (
+
+ {Array.from({ length: 7 }).map((_, dayIndex) => {
+ const day = week[dayIndex] ?? { date: '', turn_count: 0, message_count: 0, task_count: 0 };
+ const activity = day.turn_count + day.message_count;
+ const intensity = activity / maxActivity;
+
+ if (!day.date) {
+ return
;
+ }
+
+ return (
+
+
+
+
+
+ {formatDate(day.date)}
+
+ {day.turn_count} turns, {day.message_count} messages
+
+
+
+ );
+ })}
+
+ ))}
+
+
+ );
+}
+
+export function ActivityHeatmap({ dailyActivity }: { dailyActivity: DailyActivityDTO[] }) {
+ const { weeks, maxActivity, monthLabels } = useMemo(() => {
+ if (!dailyActivity || dailyActivity.length === 0) {
+ return { weeks: [] as DailyActivityDTO[][], maxActivity: 1, monthLabels: [] as { index: number; label: string }[] };
+ }
+
+ const max = Math.max(...dailyActivity.map(d => d.turn_count + d.message_count), 1);
+ const startDate = new Date(`${dailyActivity[0].date}T00:00:00`);
+ const startDay = startDate.getDay();
+ const padded: DailyActivityDTO[] = [];
+
+ for (let i = 0; i < startDay; i++) {
+ padded.push({ date: '', turn_count: 0, message_count: 0, task_count: 0 });
+ }
+ padded.push(...dailyActivity);
+
+ const weeksMap: DailyActivityDTO[][] = [];
+ for (let i = 0; i < padded.length; i += 7) {
+ weeksMap.push(padded.slice(i, i + 7));
+ }
+
+ const monthMarkers: { index: number; label: string }[] = [];
+ let lastMonth = -1;
+ weeksMap.forEach((week, index) => {
+ const firstDay = week.find((d) => d.date)?.date;
+ if (!firstDay) return;
+ const date = new Date(`${firstDay}T00:00:00`);
+ const month = date.getMonth();
+ if (month !== lastMonth) {
+ monthMarkers.push({ index, label: date.toLocaleDateString('en-US', { month: 'short' }) });
+ lastMonth = month;
+ }
+ });
+
+ return { weeks: weeksMap, maxActivity: max, monthLabels: monthMarkers };
+ }, [dailyActivity]);
+
+ const dayLabels = ['', 'Mon', '', 'Wed', '', 'Fri', ''];
+
+ return (
+
+
+
+ {monthLabels.map((label) => (
+
+ {label.label}
+
+ ))}
+
+
+
+
+ {dayLabels.map((label, i) => (
+
+ {label}
+
+ ))}
+
+
+
+
+
+
+
+
+ );
+}
+
+export function AgentUsageList({ agentUsage }: { agentUsage: AgentUsageDTO[] }) {
+ if (!agentUsage || agentUsage.length === 0) {
+ return
No agent usage data yet.
;
+ }
+
+ const maxSessions = Math.max(...agentUsage.map((a) => a.session_count));
+
+ return (
+
+ {agentUsage.map((agent) => (
+
+
+
+
{agent.agent_profile_name}
+ {agent.agent_model && (
+
+ {agent.agent_model}
+
+ )}
+
+
+ {agent.session_count}
+
+
+
+
+ ))}
+
+ );
+}
+
+type CompletionBucket = 'day' | 'week' | 'month';
+
+function BucketBarChart({ series, maxCount }: { series: { label: string; count: number; date: Date }[]; maxCount: number }) {
+ return (
+
+
+ {series.map((item, index) => {
+ const height = Math.max(6, Math.round((item.count / maxCount) * 100));
+ return (
+
+
+
+
+
+ {item.label}
+ {item.count} completed
+
+
+ );
+ })}
+
+
+ );
+}
+
+function useBucketedSeries(safeCompleted: CompletedTaskActivityDTO[], bucket: CompletionBucket) {
+ return useMemo(() => {
+ if (safeCompleted.length === 0) {
+ return [] as { label: string; count: number; date: Date }[];
+ }
+
+ const toDate = (dateStr: string) => {
+ const [year, month, day] = dateStr.split('-').map(Number);
+ return new Date(Date.UTC(year, month - 1, day));
+ };
+
+ const data = safeCompleted
+ .map((item) => ({ date: toDate(item.date), count: item.completed_tasks }))
+ .filter((item) => Number.isFinite(item.date.getTime()));
+
+ const buckets = new Map
();
+
+ data.forEach((item) => {
+ if (bucket === 'day') {
+ const key = item.date.toISOString().slice(0, 10);
+ buckets.set(key, { label: formatDate(key), count: item.count, date: item.date });
+ return;
+ }
+
+ if (bucket === 'week') {
+ const d = new Date(item.date);
+ const day = d.getUTCDay();
+ const diff = (day + 6) % 7;
+ d.setUTCDate(d.getUTCDate() - diff);
+ d.setUTCHours(0, 0, 0, 0);
+ const key = d.toISOString().slice(0, 10);
+ const existing = buckets.get(key);
+ const nextCount = (existing?.count ?? 0) + item.count;
+ buckets.set(key, { label: formatWeekLabel(d), count: nextCount, date: d });
+ return;
+ }
+
+ const d = new Date(Date.UTC(item.date.getUTCFullYear(), item.date.getUTCMonth(), 1));
+ const key = `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}`;
+ const existing = buckets.get(key);
+ const nextCount = (existing?.count ?? 0) + item.count;
+ buckets.set(key, { label: formatMonthLabel(d), count: nextCount, date: d });
+ });
+
+ return Array.from(buckets.values()).sort((a, b) => a.date.getTime() - b.date.getTime());
+ }, [bucket, safeCompleted]);
+}
+
+export function CompletedTasksChart({ completedActivity }: { completedActivity: CompletedTaskActivityDTO[] }) {
+ const [bucket, setBucket] = useState('day');
+ const safeCompleted = useMemo(() => completedActivity ?? [], [completedActivity]);
+ const series = useBucketedSeries(safeCompleted, bucket);
+ const maxCount = Math.max(...series.map((item) => item.count), 1);
+
+ if (safeCompleted.length === 0) {
+ return No completed task data yet.
;
+ }
+
+ return (
+
+
+ Bucket
+ {(['day', 'week', 'month'] as CompletionBucket[]).map((b) => (
+ setBucket(b)}
+ >
+ {b.charAt(0).toUpperCase() + b.slice(1)}
+
+ ))}
+
+
+
+
+
+ {series[0]?.label ?? ''}
+ {series[series.length - 1]?.label ?? ''}
+
+
+ );
+}
+
+export function MostProductiveSummary({ completedActivity }: { completedActivity: CompletedTaskActivityDTO[] }) {
+ const safeCompleted = useMemo(() => completedActivity ?? [], [completedActivity]);
+
+ const stats = useMemo(() => {
+ if (safeCompleted.length === 0) {
+ return {
+ maxWeekday: { idx: 0, value: 0 },
+ maxMonth: { idx: 0, value: 0 },
+ maxYear: { year: 0, value: 0 },
+ };
+ }
+
+ const weekdayTotals = Array(7).fill(0);
+ const monthTotals = Array(12).fill(0);
+ const yearTotals = new Map();
+
+ safeCompleted.forEach((item) => {
+ const [year, month, day] = item.date.split('-').map(Number);
+ const date = new Date(Date.UTC(year, month - 1, day));
+ weekdayTotals[date.getUTCDay()] += item.completed_tasks;
+ monthTotals[date.getUTCMonth()] += item.completed_tasks;
+ yearTotals.set(year, (yearTotals.get(year) ?? 0) + item.completed_tasks);
+ });
+
+ const maxWeekday = weekdayTotals.reduce((best, value, idx) => (
+ value > best.value ? { idx, value } : best
+ ), { idx: 0, value: weekdayTotals[0] ?? 0 });
+
+ const maxMonth = monthTotals.reduce((best, value, idx) => (
+ value > best.value ? { idx, value } : best
+ ), { idx: 0, value: monthTotals[0] ?? 0 });
+
+ let maxYear = { year: 0, value: 0 };
+ yearTotals.forEach((value, year) => {
+ if (value > maxYear.value) {
+ maxYear = { year, value };
+ }
+ });
+
+ return { maxWeekday, maxMonth, maxYear };
+ }, [safeCompleted]);
+
+ if (safeCompleted.length === 0) {
+ return No completed task data yet.
;
+ }
+
+ const weekdayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
+ const monthNames = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
+
+ return (
+
+
+ Best weekday
+
+ {weekdayNames[stats.maxWeekday.idx]} · {stats.maxWeekday.value}
+
+
+
+ Best month
+
+ {monthNames[stats.maxMonth.idx]} · {stats.maxMonth.value}
+
+
+
+ Best year
+
+ {stats.maxYear.year || '\u2014'} · {stats.maxYear.value}
+
+
+
+ );
+}
diff --git a/apps/web/app/stats/stats-page-client.tsx b/apps/web/app/stats/stats-page-client.tsx
index 35168cefba..338e969466 100644
--- a/apps/web/app/stats/stats-page-client.tsx
+++ b/apps/web/app/stats/stats-page-client.tsx
@@ -1,20 +1,15 @@
'use client';
import Link from 'next/link';
-import { IconArrowLeft, IconGitCommit } from '@tabler/icons-react';
+import { IconArrowLeft } from '@tabler/icons-react';
import { Card, CardContent, CardHeader, CardTitle } from '@kandev/ui/card';
import { Button } from '@kandev/ui/button';
import { ToggleGroup, ToggleGroupItem } from '@kandev/ui/toggle-group';
-import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@kandev/ui/tooltip';
-import type {
- StatsResponse,
- DailyActivityDTO,
- AgentUsageDTO,
- RepositoryStatsDTO,
- CompletedTaskActivityDTO,
-} from '@/lib/types/http';
+import type { StatsResponse } from '@/lib/types/http';
import { useMemo, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
+import { OverviewCards, WorkloadSection, RepositoryStatsGrid, TopRepositories, RepoLeaders } from './stats-sections';
+import { ActivityHeatmap, AgentUsageList, CompletedTasksChart, MostProductiveSummary } from './stats-charts';
interface StatsPageClientProps {
stats: StatsResponse | null;
@@ -24,31 +19,9 @@ interface StatsPageClientProps {
}
const EMPTY_STATS: StatsResponse = {
- global: {
- total_tasks: 0,
- completed_tasks: 0,
- in_progress_tasks: 0,
- total_sessions: 0,
- total_turns: 0,
- total_messages: 0,
- total_user_messages: 0,
- total_tool_calls: 0,
- total_duration_ms: 0,
- avg_turns_per_task: 0,
- avg_messages_per_task: 0,
- avg_duration_ms_per_task: 0,
- },
- task_stats: [],
- daily_activity: [],
- completed_activity: [],
- agent_usage: [],
- repository_stats: [],
- git_stats: {
- total_commits: 0,
- total_files_changed: 0,
- total_insertions: 0,
- total_deletions: 0,
- },
+ global: { total_tasks: 0, completed_tasks: 0, in_progress_tasks: 0, total_sessions: 0, total_turns: 0, total_messages: 0, total_user_messages: 0, total_tool_calls: 0, total_duration_ms: 0, avg_turns_per_task: 0, avg_messages_per_task: 0, avg_duration_ms_per_task: 0 },
+ task_stats: [], daily_activity: [], completed_activity: [], agent_usage: [], repository_stats: [],
+ git_stats: { total_commits: 0, total_files_changed: 0, total_insertions: 0, total_deletions: 0 },
};
function formatDuration(ms: number): string {
@@ -56,624 +29,138 @@ function formatDuration(ms: number): string {
const seconds = Math.floor(ms / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
-
- if (hours > 0) {
- return `${hours}h ${minutes % 60}m`;
- }
- if (minutes > 0) {
- return `${minutes}m ${seconds % 60}s`;
- }
+ if (hours > 0) return `${hours}h ${minutes % 60}m`;
+ if (minutes > 0) return `${minutes}m ${seconds % 60}s`;
return `${seconds}s`;
}
-function formatPercent(value: number): string {
- return `${Math.round(value)}%`;
-}
-
type RangeKey = 'week' | 'month' | 'all';
function getRangeLabel(range: RangeKey): string {
switch (range) {
- case 'week':
- return 'Last Week';
- case 'month':
- return 'Last Month';
- case 'all':
- return 'All Time';
- default:
- return 'Last Month';
+ case 'week': return 'Last Week';
+ case 'month': return 'Last Month';
+ case 'all': return 'All Time';
+ default: return 'Last Month';
}
}
-function formatMonthLabel(date: Date): string {
- return date.toLocaleDateString('en-US', { month: 'short', year: '2-digit' });
-}
-
-function formatWeekLabel(date: Date): string {
- return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
-}
-
-function getHeatmapColor(intensity: number): string {
- if (intensity === 0) return 'bg-muted';
- if (intensity < 0.25) return 'bg-emerald-500/30';
- if (intensity < 0.5) return 'bg-emerald-500/50';
- if (intensity < 0.75) return 'bg-emerald-500/70';
- return 'bg-emerald-500/90';
-}
-
-function formatDate(dateStr: string): string {
- const date = new Date(dateStr);
- return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
-}
-
-interface HeatmapProps {
- dailyActivity: DailyActivityDTO[];
-}
-
-function ActivityHeatmap({ dailyActivity }: HeatmapProps) {
- const { weeks, maxActivity, monthLabels } = useMemo(() => {
- if (!dailyActivity || dailyActivity.length === 0) {
- return { weeks: [] as DailyActivityDTO[][], maxActivity: 1, monthLabels: [] as { index: number; label: string }[] };
- }
-
- const max = Math.max(...dailyActivity.map(d => d.turn_count + d.message_count), 1);
- const startDate = new Date(`${dailyActivity[0].date}T00:00:00`);
- const startDay = startDate.getDay();
- const padded: DailyActivityDTO[] = [];
-
- for (let i = 0; i < startDay; i++) {
- padded.push({ date: '', turn_count: 0, message_count: 0, task_count: 0 });
- }
- padded.push(...dailyActivity);
-
- const weeksMap: DailyActivityDTO[][] = [];
- for (let i = 0; i < padded.length; i += 7) {
- weeksMap.push(padded.slice(i, i + 7));
- }
-
- const monthMarkers: { index: number; label: string }[] = [];
- let lastMonth = -1;
- weeksMap.forEach((week, index) => {
- const firstDay = week.find((d) => d.date)?.date;
- if (!firstDay) return;
- const date = new Date(`${firstDay}T00:00:00`);
- const month = date.getMonth();
- if (month !== lastMonth) {
- monthMarkers.push({ index, label: date.toLocaleDateString('en-US', { month: 'short' }) });
- lastMonth = month;
- }
- });
-
- return { weeks: weeksMap, maxActivity: max, monthLabels: monthMarkers };
- }, [dailyActivity]);
-
- const dayLabels = ['', 'Mon', '', 'Wed', '', 'Fri', ''];
-
+function StatsEmptyState({ message }: { message: string }) {
return (
-
-
-
- {monthLabels.map((label) => (
-
- {label.label}
-
- ))}
-
-
-
-
- {dayLabels.map((label, i) => (
-
- {label}
-
- ))}
-
-
-
-
- {weeks.map((week, weekIndex) => (
-
- {Array.from({ length: 7 }).map((_, dayIndex) => {
- const day = week[dayIndex] ?? { date: '', turn_count: 0, message_count: 0, task_count: 0 };
- const activity = day.turn_count + day.message_count;
- const intensity = activity / maxActivity;
-
- if (!day.date) {
- return
;
- }
-
- return (
-
-
-
-
-
- {formatDate(day.date)}
-
- {day.turn_count} turns, {day.message_count} messages
-
-
-
- );
- })}
-
- ))}
-
-
-
-
-
-
-
Less
-
-
More
+
+
+ KanDev
+ /
+ Statistics
+
+
);
}
-function AgentUsageList({ agentUsage }: { agentUsage: AgentUsageDTO[] }) {
- if (!agentUsage || agentUsage.length === 0) {
- return
No agent usage data yet.
;
- }
-
- const maxSessions = Math.max(...agentUsage.map((a) => a.session_count));
+type StatsHeaderProps = { global: StatsResponse['global']; range: RangeKey; copied: boolean; onRangeChange: (r: RangeKey) => void; onCopy: () => void };
+function StatsHeader({ global, range, copied, onRangeChange, onCopy }: StatsHeaderProps) {
return (
-
- {agentUsage.map((agent) => (
-
-
-
-
{agent.agent_profile_name}
- {agent.agent_model && (
-
- {agent.agent_model}
-
- )}
-
-
- {agent.session_count}
-
-
-
-
- ))}
-
- );
-}
-
-function RepositoryStatsGrid({ repositoryStats }: { repositoryStats: RepositoryStatsDTO[] }) {
- if (!repositoryStats || repositoryStats.length === 0) {
- return
No repository stats yet.
;
- }
-
- return (
-
- {repositoryStats.map((repo) => {
- const completionRate = repo.total_tasks > 0
- ? (repo.completed_tasks / repo.total_tasks) * 100
- : 0;
- const hasGit = repo.total_commits > 0 || repo.total_files_changed > 0;
-
- return (
-
-
-
- {repo.repository_name}
-
-
- {formatDuration(repo.total_duration_ms)}
-
-
-
-
- {repo.total_tasks} tasks
- {repo.session_count} sessions
- {repo.turn_count} turns
- {repo.message_count} msgs
-
-
-
-
- Completion
-
- {formatPercent(completionRate)} · {repo.completed_tasks}/{repo.total_tasks}
-
-
-
-
-
- {hasGit ? (
-
- {repo.total_commits} commits
-
-
- +{repo.total_insertions.toLocaleString()}
- {' '}
-
- −{repo.total_deletions.toLocaleString()}
-
-
-
- ) : (
-
No git activity yet.
- )}
-
-
- );
- })}
-
- );
-}
-
-function TopRepositories({ repositoryStats }: { repositoryStats: RepositoryStatsDTO[] }) {
- if (!repositoryStats || repositoryStats.length === 0) {
- return
No repository stats yet.
;
- }
-
- const topByTurns = [...repositoryStats]
- .filter((repo) => repo.turn_count > 0)
- .sort((a, b) => b.turn_count - a.turn_count)
- .slice(0, 3);
-
- const topByMessages = [...repositoryStats]
- .filter((repo) => repo.message_count > 0)
- .sort((a, b) => b.message_count - a.message_count)
- .slice(0, 3);
-
- return (
-
-
-
- Top By Turns
-
-
- {topByTurns.length === 0 && (
-
No turn activity yet.
- )}
- {topByTurns.map((repo, idx) => (
-
-
{idx + 1}.
-
-
- {repo.repository_name}
-
-
-
- {repo.turn_count}
-
-
- ))}
-
+