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:
committed by
GitHub
parent
71fda5eb90
commit
3c05db3c49
262
frontend/package-lock.json
generated
262
frontend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
138
frontend/src/components/ExecutorConfigForm.tsx
Normal file
138
frontend/src/components/ExecutorConfigForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
3
frontend/src/components/rjsf/index.ts
Normal file
3
frontend/src/components/rjsf/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { shadcnTheme, customWidgets, customTemplates } from './theme';
|
||||
export * from './widgets';
|
||||
export * from './templates';
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
59
frontend/src/components/rjsf/templates/FieldTemplate.tsx
Normal file
59
frontend/src/components/rjsf/templates/FieldTemplate.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
5
frontend/src/components/rjsf/templates/FormTemplate.tsx
Normal file
5
frontend/src/components/rjsf/templates/FormTemplate.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
export const FormTemplate = (props: any) => {
|
||||
const { children } = props;
|
||||
|
||||
return <div className="w-full">{children}</div>;
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
4
frontend/src/components/rjsf/templates/index.ts
Normal file
4
frontend/src/components/rjsf/templates/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { ArrayFieldTemplate } from './ArrayFieldTemplate';
|
||||
export { FieldTemplate } from './FieldTemplate';
|
||||
export { ObjectFieldTemplate } from './ObjectFieldTemplate';
|
||||
export { FormTemplate } from './FormTemplate';
|
||||
33
frontend/src/components/rjsf/theme.ts
Normal file
33
frontend/src/components/rjsf/theme.ts
Normal 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,
|
||||
};
|
||||
23
frontend/src/components/rjsf/widgets/CheckboxWidget.tsx
Normal file
23
frontend/src/components/rjsf/widgets/CheckboxWidget.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
69
frontend/src/components/rjsf/widgets/SelectWidget.tsx
Normal file
69
frontend/src/components/rjsf/widgets/SelectWidget.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
45
frontend/src/components/rjsf/widgets/TextWidget.tsx
Normal file
45
frontend/src/components/rjsf/widgets/TextWidget.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
53
frontend/src/components/rjsf/widgets/TextareaWidget.tsx
Normal file
53
frontend/src/components/rjsf/widgets/TextareaWidget.tsx
Normal 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"
|
||||
/>
|
||||
);
|
||||
};
|
||||
4
frontend/src/components/rjsf/widgets/index.ts
Normal file
4
frontend/src/components/rjsf/widgets/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { TextWidget } from './TextWidget';
|
||||
export { SelectWidget } from './SelectWidget';
|
||||
export { CheckboxWidget } from './CheckboxWidget';
|
||||
export { TextareaWidget } from './TextareaWidget';
|
||||
@@ -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',
|
||||
|
||||
@@ -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}
|
||||
|
||||
71
frontend/src/hooks/useProfiles.ts
Normal file
71
frontend/src/hooks/useProfiles.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
672
frontend/src/pages/settings/AgentSettings.tsx
Normal file
672
frontend/src/pages/settings/AgentSettings.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
580
frontend/src/pages/settings/GeneralSettings.tsx
Normal file
580
frontend/src/pages/settings/GeneralSettings.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
71
frontend/src/pages/settings/SettingsLayout.tsx
Normal file
71
frontend/src/pages/settings/SettingsLayout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
4
frontend/src/pages/settings/index.ts
Normal file
4
frontend/src/pages/settings/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { SettingsLayout } from './SettingsLayout';
|
||||
export { GeneralSettings } from './GeneralSettings';
|
||||
export { AgentSettings } from './AgentSettings';
|
||||
export { McpSettings } from './McpSettings';
|
||||
8
frontend/src/types/virtual-executor-schemas.d.ts
vendored
Normal file
8
frontend/src/types/virtual-executor-schemas.d.ts
vendored
Normal 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;
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user