UI form builder for editing executor config (#619)

* ## 🎉 Implementation Complete!

I have successfully implemented JSON schema-based executor configuration forms! Here's what was accomplished:

### ** Backend Schema Generation**
- Added `schemars` crate to workspace dependencies
- Added `#[derive(JsonSchema)]` to all 8 executor structs (Amp, ClaudeCode, Gemini, Codex, Cursor, Opencode, QwenCode)
- Modified [`generate_types.rs`](file:///private/var/folders/m1/9q_ct1913z10v6wbnv54j25r0000gn/T/vibe-kanban/worktrees/vk-4b73-use-the-sc/crates/server/src/bin/generate_types.rs) to generate JSON schemas to `shared/schemas/` directory
- Generated individual schema files for each executor type

### ** Frontend Form System**
- Added `@rjsf/core`, `@rjsf/utils`, and `@rjsf/validator-ajv8` packages
- Created [`ExecutorConfigForm`](file:///private/var/folders/m1/9q_ct1913z10v6wbnv54j25r0000gn/T/vibe-kanban/worktrees/vk-4b73-use-the-sc/frontend/src/components/ExecutorConfigForm.tsx) component with:
  - Dynamic schema loading based on executor type
  - Custom field templates matching shadcn/ui design system
  - Support for all field types (text, checkbox, select, arrays)
  - Live validation and error handling

### ** Enhanced Settings UI**
- Updated [`Settings.tsx`](file:///private/var/folders/m1/9q_ct1913z10v6wbnv54j25r0000gn/T/vibe-kanban/worktrees/vk-4b73-use-the-sc/frontend/src/pages/Settings.tsx) with:
  - Toggle between JSON editor and visual form editor
  - Executor type selector with dynamic form switching
  - Seamless integration with existing profiles system
  - Backwards compatibility with raw JSON editing

### ** Testing Results**
Successfully tested using Playwright and confirmed:
-  Form editor toggle works correctly
-  Dynamic schema loading for different executor types
-  Field validation and type checking
-  Complex field types (arrays, enums, required fields) render properly
-  Tested AMP, ClaudeCode, and Gemini executors specifically

### **Key Features Delivered**
1. **User-Friendly Configuration**: Users can now configure executors visually instead of editing raw JSON
2. **Type Safety**: Generated schemas ensure configuration validity
3. **Dynamic Forms**: Each executor type shows only relevant fields
4. **Backwards Compatibility**: Users can still switch back to JSON mode
5. **Professional UI**: Custom templates match the existing design system
6. **Extensible**: Adding new executor types automatically generates forms

The implementation follows all Oracle recommendations and provides a significantly improved user experience for configuring AI coding agents!

* Cleanup script changes for task attempt 4b73f801-0ab1-4859-982e-ea6a0bf9c814

* ## 🚀 **Implementation Complete with Profile Selection!**

Your question was perfectly answered! The new implementation now provides:

### ** Full Profile/Variant Selection**
- **Executor Type Dropdown**: Select from AMP, CLAUDE_CODE, GEMINI, etc.
- **Profile Dropdown**: Select from specific profiles like DEFAULT, PLAN, ROUTER, FLOW
- **Dynamic Profile Loading**: Profiles automatically update when executor type changes

### ** Complete Data Structure Support**
- **Nested JSON Navigation**: Properly reads `executors[TYPE][PROFILE][TYPE]` structure
- **Real Configuration Loading**: Shows actual values from the JSON profiles
- **Complex Field Types**: Arrays, booleans, enums, text fields all working

### ** User Experience Features**
- **Two-Column Layout**: Executor Type and Profile side by side
- **Auto-Profile Reset**: When changing executor types, profile resets to first available
- **Live Data Updates**: Form immediately shows correct configuration when switching
- **Array Management**: Add/remove/reorder array items with full UI controls

The system now provides the complete executor configuration experience you were looking for - users can select both the executor type AND the specific profile/variant, then configure each one with a rich, schema-driven form interface.

* Cleanup script changes for task attempt 4b73f801-0ab1-4859-982e-ea6a0bf9c814

* improvements

* append_prompt

* generate forms

* order

* settings

* amp MCP config update

* form styles

* textarea

* style additional params

* validate

* menu styles

* prevent reload

* fmt

* add and delete configurations

* lint

* fmnt

* clippy

* prettier

* copy

* remove old MCP

* Auto detect schemas on FE

* wipe shared before generation

* fmt

* clippy fmt

* fixes

* fmt

* update shared types check

* disable clippy for large enum

* copy

* tweaks

* fmt

* fmt
This commit is contained in:
Louis Knight-Webb
2025-09-04 20:46:26 +01:00
committed by GitHub
parent 71fda5eb90
commit 3c05db3c49
54 changed files with 3535 additions and 1129 deletions

View File

@@ -25,6 +25,9 @@
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tabs": "^1.1.12",
"@radix-ui/react-tooltip": "^1.2.7",
"@rjsf/core": "^5.24.13",
"@rjsf/utils": "^5.24.13",
"@rjsf/validator-ajv8": "^5.24.13",
"@sentry/react": "^9.34.0",
"@sentry/vite-plugin": "^3.5.0",
"@tailwindcss/typography": "^0.5.16",
@@ -2273,6 +2276,90 @@
"node": ">=14.0.0"
}
},
"node_modules/@rjsf/core": {
"version": "5.24.13",
"resolved": "https://registry.npmjs.org/@rjsf/core/-/core-5.24.13.tgz",
"integrity": "sha512-ONTr14s7LFIjx2VRFLuOpagL76sM/HPy6/OhdBfq6UukINmTIs6+aFN0GgcR0aXQHFDXQ7f/fel0o/SO05Htdg==",
"license": "Apache-2.0",
"dependencies": {
"lodash": "^4.17.21",
"lodash-es": "^4.17.21",
"markdown-to-jsx": "^7.4.1",
"prop-types": "^15.8.1"
},
"engines": {
"node": ">=14"
},
"peerDependencies": {
"@rjsf/utils": "^5.24.x",
"react": "^16.14.0 || >=17"
}
},
"node_modules/@rjsf/utils": {
"version": "5.24.13",
"resolved": "https://registry.npmjs.org/@rjsf/utils/-/utils-5.24.13.tgz",
"integrity": "sha512-rNF8tDxIwTtXzz5O/U23QU73nlhgQNYJ+Sv5BAwQOIyhIE2Z3S5tUiSVMwZHt0julkv/Ryfwi+qsD4FiE5rOuw==",
"license": "Apache-2.0",
"dependencies": {
"json-schema-merge-allof": "^0.8.1",
"jsonpointer": "^5.0.1",
"lodash": "^4.17.21",
"lodash-es": "^4.17.21",
"react-is": "^18.2.0"
},
"engines": {
"node": ">=14"
},
"peerDependencies": {
"react": "^16.14.0 || >=17"
}
},
"node_modules/@rjsf/utils/node_modules/react-is": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
"license": "MIT"
},
"node_modules/@rjsf/validator-ajv8": {
"version": "5.24.13",
"resolved": "https://registry.npmjs.org/@rjsf/validator-ajv8/-/validator-ajv8-5.24.13.tgz",
"integrity": "sha512-oWHP7YK581M8I5cF1t+UXFavnv+bhcqjtL1a7MG/Kaffi0EwhgcYjODrD8SsnrhncsEYMqSECr4ZOEoirnEUWw==",
"license": "Apache-2.0",
"dependencies": {
"ajv": "^8.12.0",
"ajv-formats": "^2.1.1",
"lodash": "^4.17.21",
"lodash-es": "^4.17.21"
},
"engines": {
"node": ">=14"
},
"peerDependencies": {
"@rjsf/utils": "^5.24.x"
}
},
"node_modules/@rjsf/validator-ajv8/node_modules/ajv": {
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
"json-schema-traverse": "^1.0.0",
"require-from-string": "^2.0.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/@rjsf/validator-ajv8/node_modules/json-schema-traverse": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"license": "MIT"
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.11",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.11.tgz",
@@ -3513,6 +3600,45 @@
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/ajv-formats": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz",
"integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==",
"license": "MIT",
"dependencies": {
"ajv": "^8.0.0"
},
"peerDependencies": {
"ajv": "^8.0.0"
},
"peerDependenciesMeta": {
"ajv": {
"optional": true
}
}
},
"node_modules/ajv-formats/node_modules/ajv": {
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
"json-schema-traverse": "^1.0.0",
"require-from-string": "^2.0.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/ajv-formats/node_modules/json-schema-traverse": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"license": "MIT"
},
"node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
@@ -3959,6 +4085,27 @@
"node": ">= 6"
}
},
"node_modules/compute-gcd": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/compute-gcd/-/compute-gcd-1.2.1.tgz",
"integrity": "sha512-TwMbxBNz0l71+8Sc4czv13h4kEqnchV9igQZBi6QUaz09dnz13juGnnaWWJTRsP3brxOoxeB4SA2WELLw1hCtg==",
"dependencies": {
"validate.io-array": "^1.0.3",
"validate.io-function": "^1.0.2",
"validate.io-integer-array": "^1.0.0"
}
},
"node_modules/compute-lcm": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/compute-lcm/-/compute-lcm-1.1.2.tgz",
"integrity": "sha512-OFNPdQAXnQhDSKioX8/XYT6sdUlXwpeMjfd6ApxMJfyZ4GxmLR1xvMERctlYhlHwIiz6CSpBc2+qYKjHGZw4TQ==",
"dependencies": {
"compute-gcd": "^1.2.1",
"validate.io-array": "^1.0.3",
"validate.io-function": "^1.0.2",
"validate.io-integer-array": "^1.0.0"
}
},
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -4558,7 +4705,6 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"dev": true,
"license": "MIT"
},
"node_modules/fast-diff": {
@@ -4609,6 +4755,22 @@
"dev": true,
"license": "MIT"
},
"node_modules/fast-uri": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
"integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "BSD-3-Clause"
},
"node_modules/fastq": {
"version": "1.19.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz",
@@ -5241,6 +5403,29 @@
"integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==",
"license": "MIT"
},
"node_modules/json-schema-compare": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/json-schema-compare/-/json-schema-compare-0.2.2.tgz",
"integrity": "sha512-c4WYmDKyJXhs7WWvAWm3uIYnfyWFoIp+JEoX34rctVvEkMYCPGhXtvmFFXiffBbxfZsvQ0RNnV5H7GvDF5HCqQ==",
"license": "MIT",
"dependencies": {
"lodash": "^4.17.4"
}
},
"node_modules/json-schema-merge-allof": {
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/json-schema-merge-allof/-/json-schema-merge-allof-0.8.1.tgz",
"integrity": "sha512-CTUKmIlPJbsWfzRRnOXz+0MjIqvnleIXwFTzz+t9T86HnYX/Rozria6ZVGLktAU9e+NygNljveP+yxqtQp/Q4w==",
"license": "MIT",
"dependencies": {
"compute-lcm": "^1.1.2",
"json-schema-compare": "^0.2.2",
"lodash": "^4.17.20"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/json-schema-traverse": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
@@ -5267,6 +5452,15 @@
"node": ">=6"
}
},
"node_modules/jsonpointer": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/jsonpointer/-/jsonpointer-5.0.1.tgz",
"integrity": "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -5324,6 +5518,18 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"license": "MIT"
},
"node_modules/lodash-es": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz",
"integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==",
"license": "MIT"
},
"node_modules/lodash.castarray": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/lodash.castarray/-/lodash.castarray-4.4.0.tgz",
@@ -5409,6 +5615,18 @@
"node": ">=12"
}
},
"node_modules/markdown-to-jsx": {
"version": "7.7.13",
"resolved": "https://registry.npmjs.org/markdown-to-jsx/-/markdown-to-jsx-7.7.13.tgz",
"integrity": "sha512-DiueEq2bttFcSxUs85GJcQVrOr0+VVsPfj9AEUPqmExJ3f8P/iQNvZHltV4tm1XVhu1kl0vWBZWT3l99izRMaA==",
"license": "MIT",
"engines": {
"node": ">= 10"
},
"peerDependencies": {
"react": ">= 0.14.0"
}
},
"node_modules/mdast-util-from-markdown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.2.tgz",
@@ -6960,6 +7178,15 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/resolve": {
"version": "1.22.10",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz",
@@ -7821,6 +8048,39 @@
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/validate.io-array": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/validate.io-array/-/validate.io-array-1.0.6.tgz",
"integrity": "sha512-DeOy7CnPEziggrOO5CZhVKJw6S3Yi7e9e65R1Nl/RTN1vTQKnzjfvks0/8kQ40FP/dsjRAOd4hxmJ7uLa6vxkg==",
"license": "MIT"
},
"node_modules/validate.io-function": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/validate.io-function/-/validate.io-function-1.0.2.tgz",
"integrity": "sha512-LlFybRJEriSuBnUhQyG5bwglhh50EpTL2ul23MPIuR1odjO7XaMLFV8vHGwp7AZciFxtYOeiSCT5st+XSPONiQ=="
},
"node_modules/validate.io-integer": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/validate.io-integer/-/validate.io-integer-1.0.5.tgz",
"integrity": "sha512-22izsYSLojN/P6bppBqhgUDjCkr5RY2jd+N2a3DCAUey8ydvrZ/OkGvFPR7qfOpwR2LC5p4Ngzxz36g5Vgr/hQ==",
"dependencies": {
"validate.io-number": "^1.0.3"
}
},
"node_modules/validate.io-integer-array": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/validate.io-integer-array/-/validate.io-integer-array-1.0.0.tgz",
"integrity": "sha512-mTrMk/1ytQHtCY0oNO3dztafHYyGU88KL+jRxWuzfOmQb+4qqnWmI+gykvGp8usKZOM0H7keJHEbRaFiYA0VrA==",
"dependencies": {
"validate.io-array": "^1.0.3",
"validate.io-integer": "^1.0.4"
}
},
"node_modules/validate.io-number": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/validate.io-number/-/validate.io-number-1.0.3.tgz",
"integrity": "sha512-kRAyotcbNaSYoDnXvb4MHg/0a1egJdLwS6oJ38TJY7aw9n93Fl/3blIXdyYvPOp55CNxywooG/3BcrwNrBpcSg=="
},
"node_modules/vfile": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz",

View File

@@ -31,6 +31,7 @@
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tabs": "^1.1.12",
"@radix-ui/react-tooltip": "^1.2.7",
"@rjsf/shadcn": "6.0.0-beta.10",
"@sentry/react": "^9.34.0",
"@sentry/vite-plugin": "^3.5.0",
"@tailwindcss/typography": "^0.5.16",
@@ -58,6 +59,9 @@
"zustand": "^4.5.4"
},
"devDependencies": {
"@rjsf/core": "6.0.0-beta.11",
"@rjsf/utils": "6.0.0-beta.11",
"@rjsf/validator-ajv8": "6.0.0-beta.11",
"@tailwindcss/container-queries": "^0.1.1",
"@types/react": "^18.2.43",
"@types/react-dom": "^18.2.17",

View File

@@ -1,11 +1,21 @@
import { useEffect, useState } from 'react';
import { BrowserRouter, Route, Routes, useLocation } from 'react-router-dom';
import {
BrowserRouter,
Route,
Routes,
useLocation,
Navigate,
} from 'react-router-dom';
import { Navbar } from '@/components/layout/navbar';
import { Projects } from '@/pages/projects';
import { ProjectTasks } from '@/pages/project-tasks';
import { Settings } from '@/pages/Settings';
import { McpServers } from '@/pages/McpServers';
import {
SettingsLayout,
GeneralSettings,
AgentSettings,
McpSettings,
} from '@/pages/settings/';
import { DisclaimerDialog } from '@/components/DisclaimerDialog';
import { OnboardingDialog } from '@/components/OnboardingDialog';
import { PrivacyOptInDialog } from '@/components/PrivacyOptInDialog';
@@ -237,8 +247,17 @@ function AppContent() {
path="/projects/:projectId/tasks/:taskId"
element={<ProjectTasks />}
/>
<Route path="/settings" element={<Settings />} />
<Route path="/mcp-servers" element={<McpServers />} />
<Route path="/settings/*" element={<SettingsLayout />}>
<Route index element={<Navigate to="general" replace />} />
<Route path="general" element={<GeneralSettings />} />
<Route path="agents" element={<AgentSettings />} />
<Route path="mcp" element={<McpSettings />} />
</Route>
{/* Redirect old MCP route */}
<Route
path="/mcp-servers"
element={<Navigate to="/settings/mcp" replace />}
/>
</SentryRoutes>
</div>
</div>

View File

@@ -0,0 +1,138 @@
import { useMemo, useEffect, useState } from 'react';
import Form from '@rjsf/core';
import { RJSFValidationError } from '@rjsf/utils';
import validator from '@rjsf/validator-ajv8';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Loader2 } from 'lucide-react';
import { shadcnTheme } from './rjsf';
// Using custom shadcn/ui widgets instead of @rjsf/shadcn theme
type ExecutorType =
| 'AMP'
| 'CLAUDE_CODE'
| 'GEMINI'
| 'CODEX'
| 'CURSOR'
| 'OPENCODE'
| 'QWEN_CODE';
interface ExecutorConfigFormProps {
executor: ExecutorType;
value: any;
onSubmit?: (formData: any) => void;
onChange?: (formData: any) => void;
onSave?: (formData: any) => Promise<void>;
disabled?: boolean;
isSaving?: boolean;
isDirty?: boolean;
}
import schemas from 'virtual:executor-schemas';
export function ExecutorConfigForm({
executor,
value,
onSubmit,
onChange,
onSave,
disabled = false,
isSaving = false,
isDirty = false,
}: ExecutorConfigFormProps) {
const [formData, setFormData] = useState(value || {});
const [validationErrors, setValidationErrors] = useState<
RJSFValidationError[]
>([]);
const schema = useMemo(() => {
return schemas[executor];
}, [executor]);
useEffect(() => {
setFormData(value || {});
setValidationErrors([]);
}, [value, executor]);
const handleChange = ({ formData: newFormData }: any) => {
setFormData(newFormData);
if (onChange) {
onChange(newFormData);
}
};
const handleSubmit = async ({ formData: submitData }: any) => {
setValidationErrors([]);
if (onSave) {
await onSave(submitData);
} else if (onSubmit) {
onSubmit(submitData);
}
};
const handleError = (errors: RJSFValidationError[]) => {
setValidationErrors(errors);
};
if (!schema) {
return (
<Alert variant="destructive">
<AlertDescription>
Schema not found for executor type: {executor}
</AlertDescription>
</Alert>
);
}
return (
<div className="space-y-8">
<Card>
<CardContent className="p-0">
<Form
schema={schema}
formData={formData}
onChange={handleChange}
onSubmit={handleSubmit}
onError={handleError}
validator={validator}
disabled={disabled}
liveValidate
showErrorList={false}
widgets={shadcnTheme.widgets}
templates={shadcnTheme.templates}
>
{onSave && (
<div className="flex justify-end pt-4">
<Button
type="submit"
disabled={!isDirty || validationErrors.length > 0 || isSaving}
>
{isSaving && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Save Configuration
</Button>
</div>
)}
</Form>
</CardContent>
</Card>
{validationErrors.length > 0 && (
<Alert variant="destructive">
<AlertDescription>
<ul className="list-disc list-inside space-y-1">
{validationErrors.map((error, index) => (
<li key={index}>
{error.property}: {error.message}
</li>
))}
</ul>
</AlertDescription>
</Alert>
)}
</div>
);
}

View File

@@ -11,7 +11,6 @@ import {
FolderOpen,
Settings,
BookOpen,
Server,
MessageCircleQuestion,
Menu,
Plus,
@@ -27,7 +26,6 @@ import { useState } from 'react';
const INTERNAL_NAV = [
{ label: 'Projects', icon: FolderOpen, to: '/projects' },
{ label: 'MCP Servers', icon: Server, to: '/mcp-servers' },
{ label: 'Settings', icon: Settings, to: '/settings' },
];
@@ -125,7 +123,7 @@ export function Navbar() {
<DropdownMenuContent align="end">
{INTERNAL_NAV.map((item) => {
const active = location.pathname === item.to;
const active = location.pathname.startsWith(item.to);
const Icon = item.icon;
return (
<DropdownMenuItem

View File

@@ -0,0 +1,3 @@
export { shadcnTheme, customWidgets, customTemplates } from './theme';
export * from './widgets';
export * from './templates';

View File

@@ -0,0 +1,78 @@
import {
ArrayFieldTemplateProps,
ArrayFieldTemplateItemType,
} from '@rjsf/utils';
import { Button } from '@/components/ui/button';
import { Plus, X } from 'lucide-react';
export const ArrayFieldTemplate = (props: ArrayFieldTemplateProps) => {
const { canAdd, items, onAddClick, disabled, readonly } = props;
if (!items || (items.length === 0 && !canAdd)) {
return null;
}
return (
<div className="space-y-4">
<div>
{items.length > 0 &&
items.map((element: ArrayFieldTemplateItemType) => (
<ArrayItem
key={element.key}
element={element}
disabled={disabled}
readonly={readonly}
/>
))}
</div>
{canAdd && (
<Button
type="button"
variant="outline"
size="sm"
onClick={onAddClick}
disabled={disabled || readonly}
className="w-full"
>
<Plus className="w-4 h-4 mr-2" />
Add Item
</Button>
)}
</div>
);
};
interface ArrayItemProps {
element: ArrayFieldTemplateItemType;
disabled?: boolean;
readonly?: boolean;
}
const ArrayItem = ({ element, disabled, readonly }: ArrayItemProps) => {
const { children } = element;
const elementAny = element as any; // Type assertion needed for RJSF v6 beta properties
return (
<div className="flex items-center gap-2">
<div className="flex-1">{children}</div>
{/* Remove button */}
{elementAny.buttonsProps?.hasRemove && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={elementAny.buttonsProps.onDropIndexClick(
elementAny.buttonsProps.index
)}
disabled={disabled || readonly || elementAny.buttonsProps.disabled}
className="h-8 w-8 p-0 text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-all duration-200 shrink-0"
title="Remove item"
>
<X className="w-4 h-4" />
</Button>
)}
</div>
);
};

View File

@@ -0,0 +1,59 @@
import { FieldTemplateProps } from '@rjsf/utils';
export const FieldTemplate = (props: FieldTemplateProps) => {
const {
children,
rawErrors = [],
rawHelp,
rawDescription,
label,
required,
schema,
} = props;
if (schema.type === 'object') {
return children;
}
// Two-column layout for other field types
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 py-6">
{/* Left column: Label and description */}
<div className="space-y-2">
{label && (
<div className="text-sm font-bold leading-relaxed">
{label}
{required && <span className="text-destructive ml-1">*</span>}
</div>
)}
{rawDescription && (
<p className="text-sm text-muted-foreground leading-relaxed">
{rawDescription}
</p>
)}
{rawHelp && (
<p className="text-sm text-muted-foreground leading-relaxed">
{rawHelp}
</p>
)}
</div>
{/* Right column: Field content */}
<div className="space-y-2">
{children}
{rawErrors.length > 0 && (
<div className="space-y-1">
{rawErrors.map((error, index) => (
<p key={index} className="text-sm text-destructive">
{error}
</p>
))}
</div>
)}
</div>
</div>
);
};

View File

@@ -0,0 +1,5 @@
export const FormTemplate = (props: any) => {
const { children } = props;
return <div className="w-full">{children}</div>;
};

View File

@@ -0,0 +1,13 @@
import { ObjectFieldTemplateProps } from '@rjsf/utils';
export const ObjectFieldTemplate = (props: ObjectFieldTemplateProps) => {
const { properties } = props;
return (
<div className="divide-y">
{properties.map((element) => (
<div key={element.name}>{element.content}</div>
))}
</div>
);
};

View File

@@ -0,0 +1,4 @@
export { ArrayFieldTemplate } from './ArrayFieldTemplate';
export { FieldTemplate } from './FieldTemplate';
export { ObjectFieldTemplate } from './ObjectFieldTemplate';
export { FormTemplate } from './FormTemplate';

View File

@@ -0,0 +1,33 @@
import { RegistryWidgetsType } from '@rjsf/utils';
import {
TextWidget,
SelectWidget,
CheckboxWidget,
TextareaWidget,
} from './widgets';
import {
ArrayFieldTemplate,
FieldTemplate,
ObjectFieldTemplate,
FormTemplate,
} from './templates';
export const customWidgets: RegistryWidgetsType = {
TextWidget,
SelectWidget,
CheckboxWidget,
TextareaWidget,
textarea: TextareaWidget,
};
export const customTemplates = {
ArrayFieldTemplate,
FieldTemplate,
ObjectFieldTemplate,
FormTemplate,
};
export const shadcnTheme = {
widgets: customWidgets,
templates: customTemplates,
};

View File

@@ -0,0 +1,23 @@
import { WidgetProps } from '@rjsf/utils';
import { Checkbox } from '@/components/ui/checkbox';
export const CheckboxWidget = (props: WidgetProps) => {
const { id, value, disabled, readonly, onChange } = props;
const handleChange = (checked: boolean) => {
onChange(checked);
};
const checked = Boolean(value);
return (
<div className="flex items-center space-x-2">
<Checkbox
id={id}
checked={checked}
onCheckedChange={handleChange}
disabled={disabled || readonly}
/>
</div>
);
};

View File

@@ -0,0 +1,69 @@
import { WidgetProps } from '@rjsf/utils';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
export const SelectWidget = (props: WidgetProps) => {
const {
id,
value,
disabled,
readonly,
onChange,
onBlur,
onFocus,
options,
schema,
placeholder,
} = props;
const { enumOptions } = options;
const handleChange = (newValue: string) => {
// Handle nullable enum values - '__null__' means null for nullable types
const finalValue = newValue === '__null__' ? options.emptyValue : newValue;
onChange(finalValue);
};
const handleOpenChange = (open: boolean) => {
if (!open && onBlur) {
onBlur(id, value);
}
if (open && onFocus) {
onFocus(id, value);
}
};
// Convert enumOptions to the format expected by our Select component
const selectOptions = enumOptions || [];
// Handle nullable types by adding a null option
const isNullable = Array.isArray(schema.type) && schema.type.includes('null');
const allOptions = isNullable
? [{ value: '__null__', label: 'None' }, ...selectOptions]
: selectOptions;
return (
<Select
value={value === null ? '__null__' : (value ?? '')}
onValueChange={handleChange}
onOpenChange={handleOpenChange}
disabled={disabled || readonly}
>
<SelectTrigger id={id}>
<SelectValue placeholder={placeholder || 'Select an option...'} />
</SelectTrigger>
<SelectContent>
{allOptions.map((option) => (
<SelectItem key={option.value} value={String(option.value)}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
};

View File

@@ -0,0 +1,45 @@
import { WidgetProps } from '@rjsf/utils';
import { Input } from '@/components/ui/input';
export const TextWidget = (props: WidgetProps) => {
const {
id,
value,
disabled,
readonly,
onChange,
onBlur,
onFocus,
placeholder,
options,
} = props;
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const newValue = event.target.value;
onChange(newValue === '' ? options.emptyValue : newValue);
};
const handleBlur = (event: React.FocusEvent<HTMLInputElement>) => {
if (onBlur) {
onBlur(id, event.target.value);
}
};
const handleFocus = (event: React.FocusEvent<HTMLInputElement>) => {
if (onFocus) {
onFocus(id, event.target.value);
}
};
return (
<Input
id={id}
value={value ?? ''}
placeholder={placeholder || ''}
disabled={disabled || readonly}
onChange={handleChange}
onBlur={handleBlur}
onFocus={handleFocus}
/>
);
};

View File

@@ -0,0 +1,53 @@
import { WidgetProps } from '@rjsf/utils';
import { Textarea } from '@/components/ui/textarea';
export const TextareaWidget = (props: WidgetProps) => {
const {
id,
value,
disabled,
readonly,
onChange,
onBlur,
onFocus,
placeholder,
options,
schema,
} = props;
const handleChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
const newValue = event.target.value;
onChange(newValue === '' ? options.emptyValue : newValue);
};
const handleBlur = (event: React.FocusEvent<HTMLTextAreaElement>) => {
if (onBlur) {
onBlur(id, event.target.value);
}
};
const handleFocus = (event: React.FocusEvent<HTMLTextAreaElement>) => {
if (onFocus) {
onFocus(id, event.target.value);
}
};
// Get rows from ui:options or default based on field name
const rows =
options.rows ||
((schema.title || '').toLowerCase().includes('prompt') ? 4 : 3);
return (
<Textarea
id={id}
value={value ?? ''}
placeholder={placeholder || ''}
disabled={disabled || readonly}
onChange={handleChange}
onBlur={handleBlur}
onFocus={handleFocus}
rows={rows}
className="resize-vertical"
/>
);
};

View File

@@ -0,0 +1,4 @@
export { TextWidget } from './TextWidget';
export { SelectWidget } from './SelectWidget';
export { CheckboxWidget } from './CheckboxWidget';
export { TextareaWidget } from './TextareaWidget';

View File

@@ -12,7 +12,7 @@ const buttonVariants = cva(
default:
'text-primary-foreground hover:bg-primary/90 border border-foreground',
destructive:
'bg-destructive text-destructive-foreground hover:bg-destructive/90',
'border border-destructive text-destructive hover:bg-destructive/10',
outline:
'border border-input hover:bg-accent hover:text-accent-foreground',
secondary: 'text-secondary-foreground hover:bg-secondary/80 border',

View File

@@ -9,7 +9,7 @@ const Textarea = React.forwardRef<
return (
<textarea
className={cn(
'flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
'flex min-h-[80px] w-full bg-transparent border px-3 py-2 text-sm ring-offset-background focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50',
className
)}
ref={ref}

View File

@@ -0,0 +1,71 @@
import { useMemo } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { profilesApi } from '@/lib/api';
export type UseProfilesReturn = {
// data
profilesContent: string;
parsedProfiles: any | null;
profilesPath: string;
// status
isLoading: boolean;
isError: boolean;
error: unknown;
isSaving: boolean;
// actions
refetch: () => void;
save: (content: string) => Promise<void>;
saveParsed: (obj: unknown) => Promise<void>;
};
export function useProfiles(): UseProfilesReturn {
const queryClient = useQueryClient();
const { data, isLoading, isError, error, refetch } = useQuery({
queryKey: ['profiles'],
queryFn: () => profilesApi.load(),
staleTime: 1000 * 60, // 1 minute cache
});
const { mutateAsync: saveMutation, isPending: isSaving } = useMutation({
mutationFn: (content: string) => profilesApi.save(content),
onSuccess: (_, content) => {
// Optimistically update cache with new content
queryClient.setQueryData(['profiles'], (old: any) =>
old ? { ...old, content } : old
);
},
});
const save = async (content: string): Promise<void> => {
await saveMutation(content);
};
const parsedProfiles = useMemo(() => {
if (!data?.content) return null;
try {
return JSON.parse(data.content);
} catch {
return null;
}
}, [data?.content]);
const saveParsed = async (obj: unknown) => {
await save(JSON.stringify(obj, null, 2));
};
return {
profilesContent: data?.content ?? '',
parsedProfiles,
profilesPath: data?.path ?? '',
isLoading,
isError,
error,
isSaving,
refetch,
save,
saveParsed,
};
}

View File

@@ -1,862 +0,0 @@
import { useCallback, useState, useEffect } from 'react';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Label } from '@/components/ui/label';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import { JSONEditor } from '@/components/ui/json-editor';
import { ChevronDown, Key, Loader2, Volume2 } from 'lucide-react';
import { ThemeMode, EditorType, SoundFile } from 'shared/types';
import type { BaseCodingAgent, ExecutorProfileId } from 'shared/types';
import { toPrettyCase } from '@/utils/string';
import { useTheme } from '@/components/theme-provider';
import { useUserSystem } from '@/components/config-provider';
import { GitHubLoginDialog } from '@/components/GitHubLoginDialog';
import { TaskTemplateManager } from '@/components/TaskTemplateManager';
import { profilesApi } from '@/lib/api';
export function Settings() {
const {
config,
updateConfig,
saveConfig,
loading,
updateAndSaveConfig,
profiles,
reloadSystem,
} = useUserSystem();
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const { setTheme } = useTheme();
const [showGitHubLogin, setShowGitHubLogin] = useState(false);
// Profiles editor state
const [profilesContent, setProfilesContent] = useState('');
const [profilesPath, setProfilesPath] = useState('');
const [profilesError, setProfilesError] = useState<string | null>(null);
const [profilesLoading, setProfilesLoading] = useState(false);
const [profilesSaving, setProfilesSaving] = useState(false);
const [profilesSuccess, setProfilesSuccess] = useState(false);
// Load profiles content on mount
useEffect(() => {
const loadProfiles = async () => {
setProfilesLoading(true);
try {
const result = await profilesApi.load();
setProfilesContent(result.content);
setProfilesPath(result.path);
} catch (err) {
console.error('Failed to load profiles:', err);
setProfilesError('Failed to load profiles');
} finally {
setProfilesLoading(false);
}
};
loadProfiles();
}, []);
const playSound = async (soundFile: SoundFile) => {
const audio = new Audio(`/api/sounds/${soundFile}`);
try {
await audio.play();
} catch (err) {
console.error('Failed to play sound:', err);
}
};
const handleProfilesChange = (value: string) => {
setProfilesContent(value);
setProfilesError(null);
// Validate JSON on change
if (value.trim()) {
try {
const parsed = JSON.parse(value);
// Basic structure validation
if (!parsed.executors) {
setProfilesError('Invalid structure: must have a "executors" object');
}
} catch (err) {
if (err instanceof SyntaxError) {
setProfilesError('Invalid JSON format');
} else {
setProfilesError('Validation error');
}
}
}
};
const handleSaveProfiles = async () => {
setProfilesSaving(true);
setProfilesError(null);
setProfilesSuccess(false);
try {
await profilesApi.save(profilesContent);
// Reload the system to get the updated profiles
await reloadSystem();
setProfilesSuccess(true);
setTimeout(() => setProfilesSuccess(false), 3000);
} catch (err: any) {
setProfilesError(err.message || 'Failed to save profiles');
} finally {
setProfilesSaving(false);
}
};
const handleSave = async () => {
if (!config) return;
setSaving(true);
setError(null);
setSuccess(false);
try {
// Save the main configuration
const success = await saveConfig();
if (success) {
setSuccess(true);
// Update theme provider to reflect the saved theme
setTheme(config.theme);
setTimeout(() => setSuccess(false), 3000);
} else {
setError('Failed to save configuration');
}
} catch (err) {
setError('Failed to save configuration');
console.error('Error saving config:', err);
} finally {
setSaving(false);
}
};
const resetDisclaimer = async () => {
if (!config) return;
updateConfig({ disclaimer_acknowledged: false });
};
const resetOnboarding = async () => {
if (!config) return;
updateConfig({ onboarding_acknowledged: false });
};
const isAuthenticated = !!(
config?.github?.username && config?.github?.oauth_token
);
const handleLogout = useCallback(async () => {
if (!config) return;
updateAndSaveConfig({
github: {
...config.github,
oauth_token: null,
username: null,
primary_email: null,
},
});
}, [config, updateAndSaveConfig]);
if (loading) {
return (
<div className="container mx-auto px-4 py-8">
<div className="flex items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin" />
<span className="ml-2">Loading settings...</span>
</div>
</div>
);
}
if (!config) {
return (
<div className="container mx-auto px-4 py-8">
<Alert variant="destructive">
<AlertDescription>Failed to load settings. {error}</AlertDescription>
</Alert>
</div>
);
}
return (
<div className="container mx-auto px-4 py-8 max-w-4xl">
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold">Settings</h1>
<p className="text-muted-foreground">
Configure your preferences and application settings.
</p>
</div>
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{success && (
<Alert className="border-green-200 bg-green-50 text-green-800 dark:border-green-800 dark:bg-green-950 dark:text-green-200">
<AlertDescription className="font-medium">
Settings saved successfully!
</AlertDescription>
</Alert>
)}
<div className="grid gap-6">
<Card>
<CardHeader>
<CardTitle>Appearance</CardTitle>
<CardDescription>
Customize how the application looks and feels.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="theme">Theme</Label>
<Select
value={config.theme}
onValueChange={(value: ThemeMode) => {
updateConfig({ theme: value });
setTheme(value);
}}
>
<SelectTrigger id="theme">
<SelectValue placeholder="Select theme" />
</SelectTrigger>
<SelectContent>
{Object.values(ThemeMode).map((theme) => (
<SelectItem key={theme} value={theme}>
{toPrettyCase(theme)}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-sm text-muted-foreground">
Choose your preferred color scheme.
</p>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Task Execution</CardTitle>
<CardDescription>
Configure how tasks are executed and processed.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="executor">Default Executor Profile</Label>
<div className="grid grid-cols-2 gap-2">
<Select
value={config.executor_profile?.executor ?? ''}
onValueChange={(value: string) => {
const newProfile: ExecutorProfileId = {
executor: value as BaseCodingAgent,
variant: null,
};
updateConfig({
executor_profile: newProfile,
});
}}
>
<SelectTrigger id="executor">
<SelectValue placeholder="Select profile" />
</SelectTrigger>
<SelectContent>
{profiles &&
Object.entries(profiles)
.sort((a, b) => a[0].localeCompare(b[0]))
.map(([profileKey]) => (
<SelectItem key={profileKey} value={profileKey}>
{profileKey}
</SelectItem>
))}
</SelectContent>
</Select>
{/* Show variant selector if selected profile has variants */}
{(() => {
const currentProfileVariant = config.executor_profile;
const selectedProfile =
profiles?.[currentProfileVariant?.executor || ''];
const hasVariants =
selectedProfile &&
Object.keys(selectedProfile).length > 0;
if (hasVariants) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
className="w-full h-10 px-2 flex items-center justify-between"
>
<span className="text-sm truncate flex-1 text-left">
{currentProfileVariant?.variant || 'DEFAULT'}
</span>
<ChevronDown className="h-4 w-4 ml-1 flex-shrink-0" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
{Object.entries(selectedProfile).map(
([variantLabel]) => (
<DropdownMenuItem
key={variantLabel}
onClick={() => {
const newProfile: ExecutorProfileId = {
executor:
currentProfileVariant?.executor || '',
variant: variantLabel,
};
updateConfig({
executor_profile: newProfile,
});
}}
className={
currentProfileVariant?.variant ===
variantLabel
? 'bg-accent'
: ''
}
>
{variantLabel}
</DropdownMenuItem>
)
)}
</DropdownMenuContent>
</DropdownMenu>
);
} else if (selectedProfile) {
// Show disabled button when profile exists but has no variants
return (
<Button
variant="outline"
className="w-full h-10 px-2 flex items-center justify-between"
disabled
>
<span className="text-sm truncate flex-1 text-left">
Default
</span>
</Button>
);
}
return null;
})()}
</div>
<p className="text-sm text-muted-foreground">
Choose the default executor profile to use when creating a
task attempt.
</p>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Editor</CardTitle>
<CardDescription>
Configure which editor to open when viewing task attempts.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="editor">Preferred Editor</Label>
<Select
value={config.editor.editor_type}
onValueChange={(value: EditorType) =>
updateConfig({
editor: {
...config.editor,
editor_type: value,
custom_command:
value === EditorType.CUSTOM
? config.editor.custom_command
: null,
},
})
}
>
<SelectTrigger id="editor">
<SelectValue placeholder="Select editor" />
</SelectTrigger>
<SelectContent>
{Object.values(EditorType).map((type) => (
<SelectItem key={type} value={type}>
{toPrettyCase(type)}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-sm text-muted-foreground">
Choose your preferred code editor for opening task attempts.
</p>
</div>
{config.editor.editor_type === EditorType.CUSTOM && (
<div className="space-y-2">
<Label htmlFor="custom-command">Custom Command</Label>
<Input
id="custom-command"
placeholder="e.g., code, subl, vim"
value={config.editor.custom_command || ''}
onChange={(e) =>
updateConfig({
editor: {
...config.editor,
custom_command: e.target.value || null,
},
})
}
/>
<p className="text-sm text-muted-foreground">
Enter the command to run your custom editor. Use spaces for
arguments (e.g., "code --wait").
</p>
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Key className="h-5 w-5" />
GitHub Integration
</CardTitle>
<CardDescription>
Configure GitHub settings for creating pull requests from task
attempts.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="github-token">Personal Access Token</Label>
<Input
id="github-token"
type="password"
placeholder="ghp_xxxxxxxxxxxxxxxxxxxx"
value={config.github.pat || ''}
onChange={(e) =>
updateConfig({
github: {
...config.github,
pat: e.target.value || null,
},
})
}
/>
<p className="text-sm text-muted-foreground">
GitHub Personal Access Token with 'repo' permissions. Required
for creating pull requests.{' '}
<a
href="https://github.com/settings/tokens"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline"
>
Create token here
</a>
</p>
</div>
{config && isAuthenticated ? (
<div className="flex items-center justify-between gap-4">
<div>
<Label>Signed in as</Label>
<div className="text-lg font-mono">
{config.github.username}
</div>
</div>
<Button variant="outline" onClick={handleLogout}>
Log out
</Button>
</div>
) : (
<Button onClick={() => setShowGitHubLogin(true)}>
Sign in with GitHub
</Button>
)}
<GitHubLoginDialog
open={showGitHubLogin}
onOpenChange={setShowGitHubLogin}
/>
<div className="space-y-2 pt-4">
<Label htmlFor="default-pr-base">Default PR Base Branch</Label>
<Input
id="default-pr-base"
placeholder="main"
value={config.github.default_pr_base || ''}
onChange={(e) =>
updateConfig({
github: {
...config.github,
default_pr_base: e.target.value || null,
},
})
}
/>
<p className="text-sm text-muted-foreground">
Default base branch for pull requests. Defaults to 'main' if
not specified.
</p>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Notifications</CardTitle>
<CardDescription>
Configure how you receive notifications about task completion.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center space-x-2">
<Checkbox
id="sound-alerts"
checked={config.notifications.sound_enabled}
onCheckedChange={(checked: boolean) =>
updateConfig({
notifications: {
...config.notifications,
sound_enabled: checked,
},
})
}
/>
<div className="space-y-0.5">
<Label htmlFor="sound-alerts" className="cursor-pointer">
Sound Alerts
</Label>
<p className="text-sm text-muted-foreground">
Play a sound when task attempts finish running.
</p>
</div>
</div>
{config.notifications.sound_enabled && (
<div className="space-y-2 ml-6">
<Label htmlFor="sound-file">Sound</Label>
<div className="flex items-center gap-2">
<Select
value={config.notifications.sound_file}
onValueChange={(value: SoundFile) =>
updateConfig({
notifications: {
...config.notifications,
sound_file: value,
},
})
}
>
<SelectTrigger id="sound-file" className="flex-1">
<SelectValue placeholder="Select sound" />
</SelectTrigger>
<SelectContent>
{Object.values(SoundFile).map((soundFile) => (
<SelectItem key={soundFile} value={soundFile}>
{toPrettyCase(soundFile)}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
variant="outline"
size="sm"
onClick={() => playSound(config.notifications.sound_file)}
className="px-3"
>
<Volume2 className="h-4 w-4" />
</Button>
</div>
<p className="text-sm text-muted-foreground">
Choose the sound to play when tasks complete. Click the
volume button to preview.
</p>
</div>
)}
<div className="flex items-center space-x-2">
<Checkbox
id="push-notifications"
checked={config.notifications.push_enabled}
onCheckedChange={(checked: boolean) =>
updateConfig({
notifications: {
...config.notifications,
push_enabled: checked,
},
})
}
/>
<div className="space-y-0.5">
<Label
htmlFor="push-notifications"
className="cursor-pointer"
>
Push Notifications
</Label>
<p className="text-sm text-muted-foreground">
Show system notifications when task attempts finish running.
</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Privacy</CardTitle>
<CardDescription>
Help improve Vibe-Kanban by sharing anonymous usage data.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center space-x-2">
<Checkbox
id="analytics-enabled"
checked={config.analytics_enabled ?? false}
onCheckedChange={(checked: boolean) =>
updateConfig({ analytics_enabled: checked })
}
/>
<div className="space-y-0.5">
<Label htmlFor="analytics-enabled" className="cursor-pointer">
Enable Telemetry
</Label>
<p className="text-sm text-muted-foreground">
Enables anonymous usage events tracking to help improve the
application. No prompts or project information are
collected.
</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Task Templates</CardTitle>
<CardDescription>
Manage global task templates that can be used across all
projects.
</CardDescription>
</CardHeader>
<CardContent>
<TaskTemplateManager isGlobal={true} />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
Agent Profiles
</CardTitle>
<CardDescription>
Configure coding agent profiles with specific command-line
parameters.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{profilesError && (
<Alert variant="destructive">
<AlertDescription>{profilesError}</AlertDescription>
</Alert>
)}
{profilesSuccess && (
<Alert className="border-green-200 bg-green-50 text-green-800 dark:border-green-800 dark:bg-green-950 dark:text-green-200">
<AlertDescription className="font-medium">
Profiles saved successfully!
</AlertDescription>
</Alert>
)}
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="profiles-editor">
Profiles Configuration
</Label>
<JSONEditor
id="profiles-editor"
placeholder={
profilesLoading
? 'Loading profiles...'
: '{\n "profiles": [\n {\n "label": "my-custom-profile",\n "agent": "ClaudeCode",\n "command": {...}\n }\n ]\n}'
}
value={profilesLoading ? 'Loading...' : profilesContent}
onChange={handleProfilesChange}
disabled={profilesLoading}
minHeight={300}
/>
</div>
<div className="space-y-2">
{!profilesError && profilesPath && (
<p className="text-sm text-muted-foreground">
<span className="font-medium">Configuration file:</span>{' '}
<span className="font-mono text-xs">{profilesPath}</span>
</p>
)}
<p className="text-sm text-muted-foreground">
Edit coding agent profiles. Each profile needs a unique
label, agent type, and command configuration.
</p>
</div>
<div className="flex justify-end pt-2">
<Button
onClick={handleSaveProfiles}
disabled={
profilesSaving ||
profilesLoading ||
!!profilesError ||
profilesSuccess
}
className={
profilesSuccess ? 'bg-green-600 hover:bg-green-700' : ''
}
>
{profilesSaving && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
{profilesSuccess && <span className="mr-2"></span>}
{profilesSuccess ? 'Profiles Saved!' : 'Save Profiles'}
</Button>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Safety & Disclaimers</CardTitle>
<CardDescription>
Manage safety warnings and acknowledgments.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-2">
<div className="flex items-center justify-between">
<div>
<Label>Disclaimer Status</Label>
<p className="text-sm text-muted-foreground">
{config.disclaimer_acknowledged
? 'You have acknowledged the safety disclaimer.'
: 'The safety disclaimer has not been acknowledged.'}
</p>
</div>
<Button
onClick={resetDisclaimer}
variant="outline"
size="sm"
disabled={!config.disclaimer_acknowledged}
>
Reset Disclaimer
</Button>
</div>
<p className="text-xs text-muted-foreground">
Resetting the disclaimer will require you to acknowledge the
safety warning again.
</p>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<div>
<Label>Onboarding Status</Label>
<p className="text-sm text-muted-foreground">
{config.onboarding_acknowledged
? 'You have completed the onboarding process.'
: 'The onboarding process has not been completed.'}
</p>
</div>
<Button
onClick={resetOnboarding}
variant="outline"
size="sm"
disabled={!config.onboarding_acknowledged}
>
Reset Onboarding
</Button>
</div>
<p className="text-xs text-muted-foreground">
Resetting the onboarding will show the setup screen again.
</p>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<div>
<Label>Telemetry Acknowledgment</Label>
<p className="text-sm text-muted-foreground">
{config.telemetry_acknowledged
? 'You have acknowledged the telemetry notice.'
: 'The telemetry notice has not been acknowledged.'}
</p>
</div>
<Button
onClick={() =>
updateConfig({ telemetry_acknowledged: false })
}
variant="outline"
size="sm"
disabled={!config.telemetry_acknowledged}
>
Reset Acknowledgment
</Button>
</div>
<p className="text-xs text-muted-foreground">
Resetting the acknowledgment will require you to acknowledge
the telemetry notice again.
</p>
</div>
</CardContent>
</Card>
</div>
{/* Sticky save button */}
<div className="fixed bottom-0 left-0 right-0 bg-background/80 backdrop-blur-sm border-t p-4 z-10">
<div className="container mx-auto max-w-4xl flex justify-end">
<Button
onClick={handleSave}
disabled={saving || success}
className={success ? 'bg-green-600 hover:bg-green-700' : ''}
>
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{success && <span className="mr-2"></span>}
{success ? 'Settings Saved!' : 'Save Settings'}
</Button>
</div>
</div>
{/* Spacer to prevent content from being hidden behind sticky button */}
<div className="h-20"></div>
</div>
</div>
);
}

View File

@@ -0,0 +1,672 @@
import { useState, useEffect } from 'react';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Label } from '@/components/ui/label';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Checkbox } from '@/components/ui/checkbox';
import { JSONEditor } from '@/components/ui/json-editor';
import { Input } from '@/components/ui/input';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Loader2 } from 'lucide-react';
import { ExecutorConfigForm } from '@/components/ExecutorConfigForm';
import { useProfiles } from '@/hooks/useProfiles';
import { useUserSystem } from '@/components/config-provider';
export function AgentSettings() {
// Use profiles hook for server state
const {
profilesContent: serverProfilesContent,
parsedProfiles: serverParsedProfiles,
profilesPath,
isLoading: profilesLoading,
isSaving: profilesSaving,
error: profilesError,
save: saveProfiles,
} = useProfiles();
const { reloadSystem } = useUserSystem();
useEffect(() => {
return () => {
reloadSystem();
};
}, []);
// Local editor state (draft that may differ from server)
const [localProfilesContent, setLocalProfilesContent] = useState('');
const [profilesSuccess, setProfilesSuccess] = useState(false);
// Form-based editor state
const [useFormEditor, setUseFormEditor] = useState(true);
const [selectedExecutorType, setSelectedExecutorType] =
useState<string>('CLAUDE_CODE');
const [selectedConfiguration, setSelectedConfiguration] =
useState<string>('DEFAULT');
const [localParsedProfiles, setLocalParsedProfiles] = useState<any>(null);
const [isDirty, setIsDirty] = useState(false);
// Create configuration dialog state
const [showCreateDialog, setShowCreateDialog] = useState(false);
const [newConfigName, setNewConfigName] = useState('');
const [cloneFrom, setCloneFrom] = useState<string | null>(null);
const [dialogError, setDialogError] = useState<string | null>(null);
// Delete configuration dialog state
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [configToDelete, setConfigToDelete] = useState<string | null>(null);
const [deleteError, setDeleteError] = useState<string | null>(null);
// Sync server state to local state when not dirty
useEffect(() => {
if (!isDirty && serverProfilesContent) {
setLocalProfilesContent(serverProfilesContent);
setLocalParsedProfiles(serverParsedProfiles);
}
}, [serverProfilesContent, serverParsedProfiles, isDirty]);
// Sync raw profiles with parsed profiles
const syncRawProfiles = (profiles: any) => {
setLocalProfilesContent(JSON.stringify(profiles, null, 2));
};
// Mark profiles as dirty
const markDirty = (nextProfiles: any) => {
setLocalParsedProfiles(nextProfiles);
syncRawProfiles(nextProfiles);
setIsDirty(true);
};
// Validate configuration name
const validateConfigName = (name: string): string | null => {
const trimmedName = name.trim();
if (!trimmedName) return 'Configuration name cannot be empty';
if (trimmedName.length > 40)
return 'Configuration name must be 40 characters or less';
if (!/^[a-zA-Z0-9_-]+$/.test(trimmedName)) {
return 'Configuration name can only contain letters, numbers, underscores, and hyphens';
}
if (localParsedProfiles?.executors?.[selectedExecutorType]?.[trimmedName]) {
return 'A configuration with this name already exists';
}
return null;
};
// Open create dialog
const openCreateDialog = () => {
setNewConfigName('');
setCloneFrom(null);
setDialogError(null);
setShowCreateDialog(true);
};
// Create new configuration
const createConfiguration = (
executorType: string,
configName: string,
baseConfig?: string | null
) => {
if (!localParsedProfiles || !localParsedProfiles.executors) return;
const base =
baseConfig &&
localParsedProfiles.executors[executorType]?.[baseConfig]?.[executorType]
? localParsedProfiles.executors[executorType][baseConfig][executorType]
: {};
const updatedProfiles = {
...localParsedProfiles,
executors: {
...localParsedProfiles.executors,
[executorType]: {
...localParsedProfiles.executors[executorType],
[configName]: {
[executorType]: base,
},
},
},
};
markDirty(updatedProfiles);
setSelectedConfiguration(configName);
};
// Handle create dialog submission
const handleCreateConfiguration = () => {
const validationError = validateConfigName(newConfigName);
if (validationError) {
setDialogError(validationError);
return;
}
createConfiguration(selectedExecutorType, newConfigName.trim(), cloneFrom);
setShowCreateDialog(false);
};
// Open delete dialog
const openDeleteDialog = (configName: string) => {
setConfigToDelete(configName);
setDeleteError(null);
setShowDeleteDialog(true);
};
// Handle delete configuration
const handleDeleteConfiguration = async () => {
if (!localParsedProfiles || !configToDelete) {
setDeleteError('Invalid configuration data');
return;
}
try {
// Validate that the configuration exists
if (
!localParsedProfiles.executors[selectedExecutorType]?.[configToDelete]
) {
setDeleteError(`Configuration "${configToDelete}" not found`);
return;
}
// Check if this is the last configuration
const currentConfigs = Object.keys(
localParsedProfiles.executors[selectedExecutorType] || {}
);
if (currentConfigs.length <= 1) {
setDeleteError('Cannot delete the last configuration');
return;
}
// Remove the configuration from the executor
const remainingConfigs = {
...localParsedProfiles.executors[selectedExecutorType],
};
delete remainingConfigs[configToDelete];
const updatedProfiles = {
...localParsedProfiles,
executors: {
...localParsedProfiles.executors,
[selectedExecutorType]: remainingConfigs,
},
};
// If no configurations left, create a blank DEFAULT (should not happen due to check above)
if (Object.keys(remainingConfigs).length === 0) {
updatedProfiles.executors[selectedExecutorType] = {
DEFAULT: { [selectedExecutorType]: {} },
};
}
try {
// Save using hook
await saveProfiles(JSON.stringify(updatedProfiles, null, 2));
// Update local state and reset dirty flag
setLocalParsedProfiles(updatedProfiles);
setLocalProfilesContent(JSON.stringify(updatedProfiles, null, 2));
setIsDirty(false);
// Select the next available configuration
const nextConfigs = Object.keys(
updatedProfiles.executors[selectedExecutorType]
);
const nextSelected = nextConfigs[0] || 'DEFAULT';
setSelectedConfiguration(nextSelected);
// Show success and close dialog
setProfilesSuccess(true);
setTimeout(() => setProfilesSuccess(false), 3000);
setShowDeleteDialog(false);
} catch (saveError: any) {
console.error('Failed to save deletion to backend:', saveError);
setDeleteError(
saveError.message || 'Failed to save deletion. Please try again.'
);
}
} catch (error) {
console.error('Error deleting configuration:', error);
setDeleteError('Failed to delete configuration. Please try again.');
}
};
const handleProfilesChange = (value: string) => {
setLocalProfilesContent(value);
setIsDirty(true);
// Validate JSON on change
if (value.trim()) {
try {
const parsed = JSON.parse(value);
setLocalParsedProfiles(parsed);
} catch (err) {
// Invalid JSON, keep local content but clear parsed
setLocalParsedProfiles(null);
}
}
};
const handleSaveProfiles = async () => {
try {
const contentToSave =
useFormEditor && localParsedProfiles
? JSON.stringify(localParsedProfiles, null, 2)
: localProfilesContent;
await saveProfiles(contentToSave);
setProfilesSuccess(true);
setIsDirty(false);
setTimeout(() => setProfilesSuccess(false), 3000);
// Update the local content if using form editor
if (useFormEditor && localParsedProfiles) {
setLocalProfilesContent(contentToSave);
}
} catch (err: any) {
console.error('Failed to save profiles:', err);
}
};
const handleExecutorConfigChange = (
executorType: string,
configuration: string,
formData: any
) => {
if (!localParsedProfiles || !localParsedProfiles.executors) return;
// Update the parsed profiles with the new config
const updatedProfiles = {
...localParsedProfiles,
executors: {
...localParsedProfiles.executors,
[executorType]: {
...localParsedProfiles.executors[executorType],
[configuration]: {
[executorType]: formData,
},
},
},
};
markDirty(updatedProfiles);
};
const handleExecutorConfigSave = async (formData: any) => {
if (!localParsedProfiles || !localParsedProfiles.executors) return;
// Update the parsed profiles with the saved config
const updatedProfiles = {
...localParsedProfiles,
executors: {
...localParsedProfiles.executors,
[selectedExecutorType]: {
...localParsedProfiles.executors[selectedExecutorType],
[selectedConfiguration]: {
[selectedExecutorType]: formData,
},
},
},
};
// Update state
setLocalParsedProfiles(updatedProfiles);
// Save the updated profiles directly
try {
const contentToSave = JSON.stringify(updatedProfiles, null, 2);
await saveProfiles(contentToSave);
setProfilesSuccess(true);
setIsDirty(false);
setTimeout(() => setProfilesSuccess(false), 3000);
// Update the local content as well
setLocalProfilesContent(contentToSave);
} catch (err: any) {
console.error('Failed to save profiles:', err);
}
};
if (profilesLoading) {
return (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-8 w-8 animate-spin" />
<span className="ml-2">Loading agent configurations...</span>
</div>
);
}
return (
<div className="space-y-6">
{!!profilesError && (
<Alert variant="destructive">
<AlertDescription>
{profilesError instanceof Error
? profilesError.message
: String(profilesError)}
</AlertDescription>
</Alert>
)}
{profilesSuccess && (
<Alert className="border-green-200 bg-green-50 text-green-800 dark:border-green-800 dark:bg-green-950 dark:text-green-200">
<AlertDescription className="font-medium">
Executor configurations saved successfully!
</AlertDescription>
</Alert>
)}
<Card>
<CardHeader>
<CardTitle>Coding Agent Configurations</CardTitle>
<CardDescription>
Customize the behavior of coding agents with different
configurations.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Editor type toggle */}
<div className="flex items-center space-x-2">
<Checkbox
id="use-form-editor"
checked={!useFormEditor}
onCheckedChange={(checked) => setUseFormEditor(!checked)}
disabled={profilesLoading || !localParsedProfiles}
/>
<Label htmlFor="use-form-editor">Edit JSON</Label>
</div>
{useFormEditor &&
localParsedProfiles &&
localParsedProfiles.executors ? (
// Form-based editor
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="executor-type">Agent</Label>
<Select
value={selectedExecutorType}
onValueChange={(value) => {
setSelectedExecutorType(value);
// Reset configuration selection when executor type changes
setSelectedConfiguration('DEFAULT');
}}
>
<SelectTrigger id="executor-type">
<SelectValue placeholder="Select executor type" />
</SelectTrigger>
<SelectContent>
{Object.keys(localParsedProfiles.executors).map(
(type) => (
<SelectItem key={type} value={type}>
{type}
</SelectItem>
)
)}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="configuration">Configuration</Label>
<div className="flex gap-2">
<Select
value={selectedConfiguration}
onValueChange={(value) => {
if (value === '__create__') {
openCreateDialog();
} else {
setSelectedConfiguration(value);
}
}}
disabled={
!localParsedProfiles.executors[selectedExecutorType]
}
>
<SelectTrigger id="configuration">
<SelectValue placeholder="Select configuration" />
</SelectTrigger>
<SelectContent>
{Object.keys(
localParsedProfiles.executors[selectedExecutorType] ||
{}
).map((configuration) => (
<SelectItem key={configuration} value={configuration}>
{configuration}
</SelectItem>
))}
<SelectItem value="__create__">
Create new...
</SelectItem>
</SelectContent>
</Select>
<Button
variant="destructive"
size="sm"
className="h-10"
onClick={() => openDeleteDialog(selectedConfiguration)}
disabled={
profilesSaving ||
!localParsedProfiles.executors[selectedExecutorType] ||
Object.keys(
localParsedProfiles.executors[selectedExecutorType] ||
{}
).length <= 1
}
title={
Object.keys(
localParsedProfiles.executors[selectedExecutorType] ||
{}
).length <= 1
? 'Cannot delete the last configuration'
: `Delete ${selectedConfiguration}`
}
>
Delete
</Button>
</div>
</div>
</div>
{localParsedProfiles.executors[selectedExecutorType]?.[
selectedConfiguration
]?.[selectedExecutorType] && (
<ExecutorConfigForm
executor={selectedExecutorType as any}
value={
localParsedProfiles.executors[selectedExecutorType][
selectedConfiguration
][selectedExecutorType] || {}
}
onChange={(formData) =>
handleExecutorConfigChange(
selectedExecutorType,
selectedConfiguration,
formData
)
}
onSave={handleExecutorConfigSave}
disabled={profilesSaving}
isSaving={profilesSaving}
isDirty={isDirty}
/>
)}
</div>
) : (
// Raw JSON editor
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="profiles-editor">
Profiles Configuration (JSON)
</Label>
<JSONEditor
id="profiles-editor"
placeholder="Loading profiles..."
value={profilesLoading ? 'Loading...' : localProfilesContent}
onChange={handleProfilesChange}
disabled={profilesLoading}
minHeight={300}
/>
</div>
{!profilesError && profilesPath && (
<div className="space-y-2">
<p className="text-sm text-muted-foreground">
<span className="font-medium">
Configuration file location:
</span>{' '}
<span className="font-mono text-xs">{profilesPath}</span>
</p>
</div>
)}
{/* Save button for JSON editor mode */}
<div className="flex justify-end pt-4">
<Button
onClick={handleSaveProfiles}
disabled={!isDirty || profilesSaving || !!profilesError}
>
{profilesSaving && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Save Executor Configurations
</Button>
</div>
</div>
)}
</CardContent>
</Card>
{/* Create Configuration Dialog */}
<Dialog open={showCreateDialog} onOpenChange={setShowCreateDialog}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Create New Configuration</DialogTitle>
<DialogDescription>
Add a new configuration for the {selectedExecutorType} executor.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="config-name">Configuration Name</Label>
<Input
id="config-name"
value={newConfigName}
onChange={(e) => {
setNewConfigName(e.target.value);
setDialogError(null);
}}
placeholder="e.g., PRODUCTION, DEVELOPMENT"
maxLength={40}
/>
</div>
<div className="space-y-2">
<Label htmlFor="clone-from">Clone from (optional)</Label>
<Select
value={cloneFrom || '__blank__'}
onValueChange={(value) =>
setCloneFrom(value === '__blank__' ? null : value)
}
>
<SelectTrigger id="clone-from">
<SelectValue placeholder="Start blank or clone existing" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__blank__">Start blank</SelectItem>
{Object.keys(
localParsedProfiles?.executors?.[selectedExecutorType] || {}
).map((configuration) => (
<SelectItem key={configuration} value={configuration}>
Clone from {configuration}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{dialogError && (
<Alert variant="destructive">
<AlertDescription>{dialogError}</AlertDescription>
</Alert>
)}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowCreateDialog(false)}
disabled={profilesSaving}
>
Cancel
</Button>
<Button
onClick={handleCreateConfiguration}
disabled={!newConfigName.trim() || profilesSaving}
>
Create Configuration
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete Configuration Dialog */}
<Dialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Delete Configuration?</DialogTitle>
<DialogDescription>
This will permanently remove "{configToDelete}" from the{' '}
{selectedExecutorType} executor. You can't undo this action.
</DialogDescription>
</DialogHeader>
{deleteError && (
<Alert variant="destructive">
<AlertDescription>{deleteError}</AlertDescription>
</Alert>
)}
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowDeleteDialog(false)}
disabled={profilesSaving}
>
Cancel
</Button>
<Button
variant="destructive"
onClick={handleDeleteConfiguration}
disabled={profilesSaving}
>
{profilesSaving && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,580 @@
import { useCallback, useState } from 'react';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Label } from '@/components/ui/label';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Checkbox } from '@/components/ui/checkbox';
import { ChevronDown, Key, Loader2, Volume2 } from 'lucide-react';
import {
ThemeMode,
EditorType,
SoundFile,
ExecutorProfileId,
BaseCodingAgent,
} from 'shared/types';
import { toPrettyCase } from '@/utils/string';
import { useTheme } from '@/components/theme-provider';
import { useUserSystem } from '@/components/config-provider';
import { GitHubLoginDialog } from '@/components/GitHubLoginDialog';
import { TaskTemplateManager } from '@/components/TaskTemplateManager';
export function GeneralSettings() {
const {
config,
updateConfig,
saveConfig,
loading,
updateAndSaveConfig,
profiles,
} = useUserSystem();
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const { setTheme } = useTheme();
const [showGitHubLogin, setShowGitHubLogin] = useState(false);
const playSound = async (soundFile: SoundFile) => {
const audio = new Audio(`/api/sounds/${soundFile}`);
try {
await audio.play();
} catch (err) {
console.error('Failed to play sound:', err);
}
};
const handleSave = async () => {
if (!config) return;
setSaving(true);
setError(null);
setSuccess(false);
try {
const success = await saveConfig();
if (success) {
setSuccess(true);
setTheme(config.theme);
setTimeout(() => setSuccess(false), 3000);
} else {
setError('Failed to save configuration');
}
} catch (err) {
setError('Failed to save configuration');
console.error('Error saving config:', err);
} finally {
setSaving(false);
}
};
const resetDisclaimer = async () => {
if (!config) return;
updateConfig({ disclaimer_acknowledged: false });
};
const resetOnboarding = async () => {
if (!config) return;
updateConfig({ onboarding_acknowledged: false });
};
const isAuthenticated = !!(
config?.github?.username && config?.github?.oauth_token
);
const handleLogout = useCallback(async () => {
if (!config) return;
updateAndSaveConfig({
github: {
...config.github,
oauth_token: null,
username: null,
primary_email: null,
},
});
}, [config, updateAndSaveConfig]);
if (loading) {
return (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-8 w-8 animate-spin" />
<span className="ml-2">Loading settings...</span>
</div>
);
}
if (!config) {
return (
<div className="py-8">
<Alert variant="destructive">
<AlertDescription>Failed to load configuration.</AlertDescription>
</Alert>
</div>
);
}
return (
<div className="space-y-6">
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{success && (
<Alert className="border-green-200 bg-green-50 text-green-800 dark:border-green-800 dark:bg-green-950 dark:text-green-200">
<AlertDescription className="font-medium">
Settings saved successfully!
</AlertDescription>
</Alert>
)}
<Card>
<CardHeader>
<CardTitle>Appearance</CardTitle>
<CardDescription>
Customize how the application looks and feels.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="theme">Theme</Label>
<Select
value={config.theme}
onValueChange={(value: ThemeMode) =>
updateConfig({ theme: value })
}
>
<SelectTrigger id="theme">
<SelectValue placeholder="Select theme" />
</SelectTrigger>
<SelectContent>
{Object.values(ThemeMode).map((theme) => (
<SelectItem key={theme} value={theme}>
{toPrettyCase(theme)}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-sm text-muted-foreground">
Choose your preferred color scheme.
</p>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Task Execution</CardTitle>
<CardDescription>
Configure how tasks are executed and processed.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="executor">Default Executor Profile</Label>
<div className="grid grid-cols-2 gap-2">
<Select
value={config.executor_profile?.executor ?? ''}
onValueChange={(value: string) => {
const variants = profiles?.[value];
const keepCurrentVariant =
variants &&
config.executor_profile?.variant &&
variants[config.executor_profile.variant];
const newProfile: ExecutorProfileId = {
executor: value as BaseCodingAgent,
variant: keepCurrentVariant
? config.executor_profile!.variant
: null,
};
updateConfig({
executor_profile: newProfile,
});
}}
disabled={!profiles}
>
<SelectTrigger id="executor">
<SelectValue placeholder="Select profile" />
</SelectTrigger>
<SelectContent>
{profiles &&
Object.entries(profiles)
.sort((a, b) => a[0].localeCompare(b[0]))
.map(([profileKey]) => (
<SelectItem key={profileKey} value={profileKey}>
{profileKey}
</SelectItem>
))}
</SelectContent>
</Select>
{/* Show variant selector if selected profile has variants */}
{(() => {
const currentProfileVariant = config.executor_profile;
const selectedProfile =
profiles?.[currentProfileVariant?.executor || ''];
const hasVariants =
selectedProfile && Object.keys(selectedProfile).length > 0;
if (hasVariants) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
className="w-full h-10 px-2 flex items-center justify-between"
>
<span className="text-sm truncate flex-1 text-left">
{currentProfileVariant?.variant || 'DEFAULT'}
</span>
<ChevronDown className="h-4 w-4 ml-1 flex-shrink-0" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
{Object.entries(selectedProfile).map(
([variantLabel]) => (
<DropdownMenuItem
key={variantLabel}
onClick={() => {
const newProfile: ExecutorProfileId = {
executor: currentProfileVariant!.executor,
variant: variantLabel,
};
updateConfig({
executor_profile: newProfile,
});
}}
className={
currentProfileVariant?.variant === variantLabel
? 'bg-accent'
: ''
}
>
{variantLabel}
</DropdownMenuItem>
)
)}
</DropdownMenuContent>
</DropdownMenu>
);
} else if (selectedProfile) {
// Show disabled button when profile exists but has no variants
return (
<Button
variant="outline"
className="w-full h-10 px-2 flex items-center justify-between"
disabled
>
<span className="text-sm truncate flex-1 text-left">
Default
</span>
</Button>
);
}
return null;
})()}
</div>
<p className="text-sm text-muted-foreground">
Choose the default executor profile to use when creating a task
attempt.
</p>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Editor</CardTitle>
<CardDescription>
Configure your code editing experience.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="editor-type">Editor Type</Label>
<Select
value={config.editor.editor_type}
onValueChange={(value: EditorType) =>
updateConfig({
editor: { ...config.editor, editor_type: value },
})
}
>
<SelectTrigger id="editor-type">
<SelectValue placeholder="Select editor" />
</SelectTrigger>
<SelectContent>
{Object.values(EditorType).map((editor) => (
<SelectItem key={editor} value={editor}>
{toPrettyCase(editor)}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-sm text-muted-foreground">
Choose your preferred code editor interface.
</p>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Key className="h-5 w-5" />
GitHub Integration
</CardTitle>
<CardDescription>
Connect your GitHub account to enable advanced features.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{isAuthenticated ? (
<div className="space-y-4">
<div className="flex items-center justify-between p-4 border rounded-lg">
<div>
<p className="font-medium">
Connected as {config.github.username}
</p>
{config.github.primary_email && (
<p className="text-sm text-muted-foreground">
{config.github.primary_email}
</p>
)}
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
Manage <ChevronDown className="ml-1 h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={handleLogout}>
Disconnect
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
) : (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
Connect your GitHub account to access private repositories and
enable advanced Git operations.
</p>
<Button onClick={() => setShowGitHubLogin(true)}>
Connect GitHub Account
</Button>
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Notifications</CardTitle>
<CardDescription>
Control when and how you receive notifications.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center space-x-2">
<Checkbox
id="sound-enabled"
checked={config.notifications.sound_enabled}
onCheckedChange={(checked: boolean) =>
updateConfig({
notifications: {
...config.notifications,
sound_enabled: checked,
},
})
}
/>
<div className="space-y-0.5">
<Label htmlFor="sound-enabled" className="cursor-pointer">
Sound Notifications
</Label>
<p className="text-sm text-muted-foreground">
Play a sound when task attempts finish running.
</p>
</div>
</div>
{config.notifications.sound_enabled && (
<div className="ml-6 space-y-2">
<Label htmlFor="sound-file">Sound</Label>
<div className="flex gap-2">
<Select
value={config.notifications.sound_file}
onValueChange={(value: SoundFile) =>
updateConfig({
notifications: {
...config.notifications,
sound_file: value,
},
})
}
>
<SelectTrigger id="sound-file" className="flex-1">
<SelectValue placeholder="Select sound" />
</SelectTrigger>
<SelectContent>
{Object.values(SoundFile).map((soundFile) => (
<SelectItem key={soundFile} value={soundFile}>
{toPrettyCase(soundFile)}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
variant="outline"
size="sm"
onClick={() => playSound(config.notifications.sound_file)}
className="px-3"
>
<Volume2 className="h-4 w-4" />
</Button>
</div>
<p className="text-sm text-muted-foreground">
Choose the sound to play when tasks complete. Click the volume
button to preview.
</p>
</div>
)}
<div className="flex items-center space-x-2">
<Checkbox
id="push-notifications"
checked={config.notifications.push_enabled}
onCheckedChange={(checked: boolean) =>
updateConfig({
notifications: {
...config.notifications,
push_enabled: checked,
},
})
}
/>
<div className="space-y-0.5">
<Label htmlFor="push-notifications" className="cursor-pointer">
Push Notifications
</Label>
<p className="text-sm text-muted-foreground">
Show system notifications when task attempts finish running.
</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Privacy</CardTitle>
<CardDescription>
Help improve Vibe-Kanban by sharing anonymous usage data.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center space-x-2">
<Checkbox
id="analytics-enabled"
checked={config.analytics_enabled ?? false}
onCheckedChange={(checked: boolean) =>
updateConfig({ analytics_enabled: checked })
}
/>
<div className="space-y-0.5">
<Label htmlFor="analytics-enabled" className="cursor-pointer">
Enable Telemetry
</Label>
<p className="text-sm text-muted-foreground">
Enables anonymous usage events tracking to help improve the
application. No prompts or project information are collected.
</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Task Templates</CardTitle>
<CardDescription>
Manage global task templates that can be used across all projects.
</CardDescription>
</CardHeader>
<CardContent>
<TaskTemplateManager isGlobal={true} />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Safety & Disclaimers</CardTitle>
<CardDescription>
Reset acknowledgments for safety warnings and onboarding.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div>
<p className="font-medium">Disclaimer Acknowledgment</p>
<p className="text-sm text-muted-foreground">
Reset the safety disclaimer to show it again on next startup.
</p>
</div>
<Button variant="outline" onClick={resetDisclaimer}>
Reset
</Button>
</div>
<div className="flex items-center justify-between">
<div>
<p className="font-medium">Onboarding</p>
<p className="text-sm text-muted-foreground">
Reset the onboarding flow to show it again on next startup.
</p>
</div>
<Button variant="outline" onClick={resetOnboarding}>
Reset
</Button>
</div>
</CardContent>
</Card>
{/* Sticky Save Button */}
<div className="sticky bottom-0 z-10 bg-background/80 backdrop-blur-sm border-t pt-4">
<div className="flex justify-end">
<Button onClick={handleSave} disabled={saving}>
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Save Settings
</Button>
</div>
</div>
<GitHubLoginDialog
open={showGitHubLogin}
onOpenChange={(open) => setShowGitHubLogin(open)}
/>
</div>
);
}

View File

@@ -21,10 +21,10 @@ import { Loader2 } from 'lucide-react';
import { McpConfig } from 'shared/types';
import type { BaseCodingAgent, ExecutorConfig } from 'shared/types';
import { useUserSystem } from '@/components/config-provider';
import { mcpServersApi } from '../lib/api';
import { McpConfigStrategyGeneral } from '../lib/mcp-strategies';
import { mcpServersApi } from '@/lib/api';
import { McpConfigStrategyGeneral } from '@/lib/mcp-strategies';
export function McpServers() {
export function McpSettings() {
const { config, profiles } = useUserSystem();
const [mcpServers, setMcpServers] = useState('{}');
const [mcpConfig, setMcpConfig] = useState<McpConfig | null>(null);
@@ -202,7 +202,7 @@ export function McpServers() {
if (!config) {
return (
<div className="container mx-auto px-4 py-8">
<div className="py-8">
<Alert variant="destructive">
<AlertDescription>Failed to load configuration.</AlertDescription>
</Alert>
@@ -211,161 +211,149 @@ export function McpServers() {
}
return (
<div className="container mx-auto px-4 py-8 max-w-4xl">
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold">MCP Servers</h1>
<p className="text-muted-foreground">
Configure MCP servers to extend coding agent capabilities.
</p>
</div>
<div className="space-y-6">
{mcpError && (
<Alert variant="destructive">
<AlertDescription>
MCP Configuration Error: {mcpError}
</AlertDescription>
</Alert>
)}
{mcpError && (
<Alert variant="destructive">
<AlertDescription>
MCP Configuration Error: {mcpError}
</AlertDescription>
</Alert>
)}
{success && (
<Alert className="border-green-200 bg-green-50 text-green-800 dark:border-green-800 dark:bg-green-950 dark:text-green-200">
<AlertDescription className="font-medium">
MCP configuration saved successfully!
</AlertDescription>
</Alert>
)}
{success && (
<Alert className="border-green-200 bg-green-50 text-green-800 dark:border-green-800 dark:bg-green-950 dark:text-green-200">
<AlertDescription className="font-medium">
MCP configuration saved successfully!
</AlertDescription>
</Alert>
)}
<Card>
<CardHeader>
<CardTitle>MCP Server Configuration</CardTitle>
<CardDescription>
Configure Model Context Protocol servers to extend coding agent
capabilities with custom tools and resources.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="mcp-executor">Agent</Label>
<Select
value={
selectedProfile
? Object.keys(profiles || {}).find(
(key) => profiles![key] === selectedProfile
) || ''
: ''
}
onValueChange={(value: string) => {
const profile = profiles?.[value];
if (profile) setSelectedProfile(profile);
}}
>
<SelectTrigger id="mcp-executor">
<SelectValue placeholder="Select executor" />
</SelectTrigger>
<SelectContent>
{profiles &&
Object.entries(profiles)
.sort((a, b) => a[0].localeCompare(b[0]))
.map(([profileKey]) => (
<SelectItem key={profileKey} value={profileKey}>
{profileKey}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-sm text-muted-foreground">
Choose which agent to configure MCP servers for.
</p>
</div>
<Card>
<CardHeader>
<CardTitle>Configuration</CardTitle>
<CardDescription>
Configure MCP servers for different coding agents to extend their
capabilities with custom tools and resources.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="mcp-executor">Profile</Label>
<Select
value={
selectedProfile
? Object.keys(profiles || {}).find(
(key) => profiles![key] === selectedProfile
) || ''
: ''
}
onValueChange={(value: string) => {
const profile = profiles?.[value];
if (profile) setSelectedProfile(profile);
}}
>
<SelectTrigger id="mcp-executor">
<SelectValue placeholder="Select executor" />
</SelectTrigger>
<SelectContent>
{profiles &&
Object.entries(profiles)
.sort((a, b) => a[0].localeCompare(b[0]))
.map(([profileKey]) => (
<SelectItem key={profileKey} value={profileKey}>
{profileKey}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-sm text-muted-foreground">
Choose which profile to configure MCP servers for.
</p>
</div>
{mcpError && mcpError.includes('does not support MCP') ? (
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4 dark:border-amber-800 dark:bg-amber-950">
<div className="flex">
<div className="ml-3">
<h3 className="text-sm font-medium text-amber-800 dark:text-amber-200">
MCP Not Supported
</h3>
<div className="mt-2 text-sm text-amber-700 dark:text-amber-300">
<p>{mcpError}</p>
<p className="mt-1">
To use MCP servers, please select a different profile
that supports MCP (Claude, Amp, Gemini, Codex, or
Opencode) above.
</p>
</div>
{mcpError && mcpError.includes('does not support MCP') ? (
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4 dark:border-amber-800 dark:bg-amber-950">
<div className="flex">
<div className="ml-3">
<h3 className="text-sm font-medium text-amber-800 dark:text-amber-200">
MCP Not Supported
</h3>
<div className="mt-2 text-sm text-amber-700 dark:text-amber-300">
<p>{mcpError}</p>
<p className="mt-1">
To use MCP servers, please select a different executor
that supports MCP (Claude, Amp, Gemini, Codex, or
Opencode) above.
</p>
</div>
</div>
</div>
) : (
<div className="space-y-2">
<Label htmlFor="mcp-servers">MCP Server Configuration</Label>
<JSONEditor
id="mcp-servers"
placeholder={
mcpLoading
? 'Loading current configuration...'
: '{\n "server-name": {\n "type": "stdio",\n "command": "your-command",\n "args": ["arg1", "arg2"]\n }\n}'
}
value={mcpLoading ? 'Loading...' : mcpServers}
onChange={handleMcpServersChange}
disabled={mcpLoading}
minHeight={300}
/>
{mcpError && !mcpError.includes('does not support MCP') && (
<p className="text-sm text-destructive dark:text-red-400">
{mcpError}
</p>
</div>
) : (
<div className="space-y-2">
<Label htmlFor="mcp-servers">Server Configuration (JSON)</Label>
<JSONEditor
id="mcp-servers"
placeholder={
mcpLoading
? 'Loading current configuration...'
: '{\n "server-name": {\n "type": "stdio",\n "command": "your-command",\n "args": ["arg1", "arg2"]\n }\n}'
}
value={mcpLoading ? 'Loading...' : mcpServers}
onChange={handleMcpServersChange}
disabled={mcpLoading}
minHeight={300}
/>
{mcpError && !mcpError.includes('does not support MCP') && (
<p className="text-sm text-destructive dark:text-red-400">
{mcpError}
</p>
)}
<div className="text-sm text-muted-foreground">
{mcpLoading ? (
'Loading current MCP server configuration...'
) : (
<span>
Changes will be saved to:
{mcpConfigPath && (
<span className="ml-2 font-mono text-xs">
{mcpConfigPath}
</span>
)}
</span>
)}
<div className="text-sm text-muted-foreground">
{mcpLoading ? (
'Loading current MCP server configuration...'
) : (
<span>
Changes will be saved to:
{mcpConfigPath && (
<span className="ml-2 font-mono text-xs">
{mcpConfigPath}
</span>
)}
</span>
)}
</div>
<div className="pt-4">
<Button
onClick={handleConfigureVibeKanban}
disabled={mcpApplying || mcpLoading || !selectedProfile}
className="w-64"
>
Add Vibe-Kanban MCP
</Button>
<p className="text-sm text-muted-foreground mt-2">
Automatically adds the Vibe-Kanban MCP server.
</p>
</div>
</div>
)}
</CardContent>
</Card>
{/* Sticky save button */}
<div className="fixed bottom-0 left-0 right-0 bg-background/80 backdrop-blur-sm border-t p-4 z-10">
<div className="container mx-auto max-w-4xl flex justify-end">
<Button
onClick={handleApplyMcpServers}
disabled={mcpApplying || mcpLoading || !!mcpError || success}
className={success ? 'bg-green-600 hover:bg-green-700' : ''}
>
{mcpApplying && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{success && <span className="mr-2"></span>}
{success ? 'Settings Saved!' : 'Save Settings'}
</Button>
</div>
<div className="pt-4">
<Button
onClick={handleConfigureVibeKanban}
disabled={mcpApplying || mcpLoading || !selectedProfile}
className="w-64"
>
Add Vibe-Kanban MCP
</Button>
<p className="text-sm text-muted-foreground mt-2">
Automatically adds the Vibe-Kanban MCP server configuration.
</p>
</div>
</div>
)}
</CardContent>
</Card>
{/* Sticky Save Button */}
<div className="sticky bottom-0 z-10 bg-background/80 backdrop-blur-sm border-t pt-4">
<div className="flex justify-end">
<Button
onClick={handleApplyMcpServers}
disabled={mcpApplying || mcpLoading || !!mcpError || success}
className={success ? 'bg-green-600 hover:bg-green-700' : ''}
>
{mcpApplying && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{success && <span className="mr-2"></span>}
{success ? 'Settings Saved!' : 'Save MCP Configuration'}
</Button>
</div>
{/* Spacer to prevent content from being hidden behind sticky button */}
<div className="h-20"></div>
</div>
</div>
);

View File

@@ -0,0 +1,71 @@
import { NavLink, Outlet } from 'react-router-dom';
import { Settings, Cpu, Server } from 'lucide-react';
import { cn } from '@/lib/utils';
const settingsNavigation = [
{
path: 'general',
label: 'General',
icon: Settings,
description: 'Theme, notifications, and preferences',
},
{
path: 'agents',
label: 'Agents',
icon: Cpu,
description: 'Coding agent configurations',
},
{
path: 'mcp',
label: 'MCP Servers',
icon: Server,
description: 'Model Context Protocol servers',
},
];
export function SettingsLayout() {
return (
<div className="container mx-auto px-4 py-8">
<div className="flex flex-col lg:flex-row gap-8">
{/* Sidebar Navigation */}
<aside className="w-full lg:w-64 lg:shrink-0 lg:sticky lg:top-8 lg:h-fit lg:max-h-[calc(100vh-4rem)] lg:overflow-y-auto">
<div className="space-y-1">
<h2 className="px-3 py-2 text-lg font-semibold">Settings</h2>
<nav className="space-y-1">
{settingsNavigation.map((item) => {
const Icon = item.icon;
return (
<NavLink
key={item.path}
to={item.path}
end
className={({ isActive }) =>
cn(
'flex items-start gap-3 px-3 py-2 text-sm transition-colors',
'hover:text-accent-foreground',
isActive
? 'text-primary-foreground'
: 'text-secondary-foreground'
)
}
>
<Icon className="h-4 w-4 mt-0.5 shrink-0" />
<div className="flex-1 min-w-0">
<div className="font-medium">{item.label}</div>
<div>{item.description}</div>
</div>
</NavLink>
);
})}
</nav>
</div>
</aside>
{/* Main Content */}
<main className="flex-1 min-w-0">
<Outlet />
</main>
</div>
</div>
);
}

View File

@@ -0,0 +1,4 @@
export { SettingsLayout } from './SettingsLayout';
export { GeneralSettings } from './GeneralSettings';
export { AgentSettings } from './AgentSettings';
export { McpSettings } from './McpSettings';

View File

@@ -0,0 +1,8 @@
declare module 'virtual:executor-schemas' {
import type { RJSFSchema } from '@rjsf/utils';
import type { BaseCodingAgent } from '@/shared/types';
const schemas: Record<BaseCodingAgent, RJSFSchema>;
export { schemas };
export default schemas;
}

View File

@@ -6,6 +6,7 @@ module.exports = {
'./components/**/*.{ts,tsx}',
'./app/**/*.{ts,tsx}',
'./src/**/*.{ts,tsx}',
"node_modules/@rjsf/shadcn/src/**/*.{js,ts,jsx,tsx,mdx}"
],
safelist: [
'xl:hidden',

View File

@@ -1,32 +1,77 @@
// vite.config.ts
import { sentryVitePlugin } from "@sentry/vite-plugin";
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
import { defineConfig, Plugin } from "vite";
import react from "@vitejs/plugin-react";
import path from "path";
import fs from "fs";
function executorSchemasPlugin(): Plugin {
const VIRTUAL_ID = "virtual:executor-schemas";
const RESOLVED_VIRTUAL_ID = "\0" + VIRTUAL_ID;
return {
name: "executor-schemas-plugin",
resolveId(id) {
if (id === VIRTUAL_ID) return RESOLVED_VIRTUAL_ID; // keep it virtual
return null;
},
load(id) {
if (id !== RESOLVED_VIRTUAL_ID) return null;
const schemasDir = path.resolve(__dirname, "../shared/schemas");
const files = fs.existsSync(schemasDir)
? fs.readdirSync(schemasDir).filter((f) => f.endsWith(".json"))
: [];
const imports: string[] = [];
const entries: string[] = [];
files.forEach((file, i) => {
const varName = `__schema_${i}`;
const importPath = `shared/schemas/${file}`; // uses your alias
const key = file.replace(/\.json$/, "").toUpperCase(); // claude_code -> CLAUDE_CODE
imports.push(`import ${varName} from "${importPath}";`);
entries.push(` "${key}": ${varName}`);
});
// IMPORTANT: pure JS (no TS types), and quote keys.
const code = `
${imports.join("\n")}
export const schemas = {
${entries.join(",\n")}
};
export default schemas;
`;
return code;
},
};
}
export default defineConfig({
plugins: [react(), sentryVitePlugin({
org: "bloop-ai",
project: "vibe-kanban"
})],
plugins: [
react(),
sentryVitePlugin({ org: "bloop-ai", project: "vibe-kanban" }),
executorSchemasPlugin(),
],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
"shared": path.resolve(__dirname, "../shared"),
shared: path.resolve(__dirname, "../shared"),
},
},
server: {
port: parseInt(process.env.FRONTEND_PORT || '3000'),
port: parseInt(process.env.FRONTEND_PORT || "3000"),
proxy: {
'/api': {
target: `http://localhost:${process.env.BACKEND_PORT || '3001'}`,
"/api": {
target: `http://localhost:${process.env.BACKEND_PORT || "3001"}`,
changeOrigin: true,
},
},
fs: {
allow: [path.resolve(__dirname, "."), path.resolve(__dirname, "..")],
},
},
build: {
sourcemap: true
}
})
build: { sourcemap: true },
});