From 3c05db3c499211932e1f03a24a2ac7f27a7ce238 Mon Sep 17 00:00:00 2001 From: Louis Knight-Webb Date: Thu, 4 Sep 2025 20:46:26 +0100 Subject: [PATCH] UI form builder for editing executor config (#619) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ## πŸŽ‰ 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 --- Cargo.toml | 1 + crates/executors/Cargo.toml | 1 + crates/executors/src/command.rs | 13 +- crates/executors/src/executors/amp.rs | 20 +- crates/executors/src/executors/claude.rs | 16 +- crates/executors/src/executors/codex.rs | 18 +- crates/executors/src/executors/cursor.rs | 16 +- crates/executors/src/executors/gemini.rs | 17 +- crates/executors/src/executors/mod.rs | 24 + crates/executors/src/executors/opencode.rs | 18 +- crates/executors/src/executors/qwen.rs | 14 +- crates/executors/src/logs/utils/patch.rs | 1 + crates/server/Cargo.toml | 2 +- crates/server/src/bin/generate_types.rs | 61 +- crates/utils/src/text.rs | 7 - frontend/package-lock.json | 262 +++++- frontend/package.json | 4 + frontend/src/App.tsx | 29 +- .../src/components/ExecutorConfigForm.tsx | 138 +++ frontend/src/components/layout/navbar.tsx | 4 +- frontend/src/components/rjsf/index.ts | 3 + .../rjsf/templates/ArrayFieldTemplate.tsx | 78 ++ .../rjsf/templates/FieldTemplate.tsx | 59 ++ .../rjsf/templates/FormTemplate.tsx | 5 + .../rjsf/templates/ObjectFieldTemplate.tsx | 13 + .../src/components/rjsf/templates/index.ts | 4 + frontend/src/components/rjsf/theme.ts | 33 + .../rjsf/widgets/CheckboxWidget.tsx | 23 + .../components/rjsf/widgets/SelectWidget.tsx | 69 ++ .../components/rjsf/widgets/TextWidget.tsx | 45 + .../rjsf/widgets/TextareaWidget.tsx | 53 ++ frontend/src/components/rjsf/widgets/index.ts | 4 + frontend/src/components/ui/button.tsx | 2 +- frontend/src/components/ui/textarea.tsx | 2 +- frontend/src/hooks/useProfiles.ts | 71 ++ frontend/src/pages/Settings.tsx | 862 ------------------ frontend/src/pages/settings/AgentSettings.tsx | 672 ++++++++++++++ .../src/pages/settings/GeneralSettings.tsx | 580 ++++++++++++ .../McpSettings.tsx} | 286 +++--- .../src/pages/settings/SettingsLayout.tsx | 71 ++ frontend/src/pages/settings/index.ts | 4 + .../src/types/virtual-executor-schemas.d.ts | 8 + frontend/tailwind.config.js | 1 + frontend/vite.config.ts | 81 +- pnpm-lock.yaml | 585 ++++++++++++ shared/old_frozen_types.ts | 12 - shared/schemas/amp.json | 43 + shared/schemas/claude_code.json | 53 ++ shared/schemas/codex.json | 74 ++ shared/schemas/cursor.json | 47 + shared/schemas/gemini.json | 51 ++ shared/schemas/opencode.json | 47 + shared/schemas/qwen_code.json | 41 + shared/types.ts | 16 +- 54 files changed, 3535 insertions(+), 1129 deletions(-) create mode 100644 frontend/src/components/ExecutorConfigForm.tsx create mode 100644 frontend/src/components/rjsf/index.ts create mode 100644 frontend/src/components/rjsf/templates/ArrayFieldTemplate.tsx create mode 100644 frontend/src/components/rjsf/templates/FieldTemplate.tsx create mode 100644 frontend/src/components/rjsf/templates/FormTemplate.tsx create mode 100644 frontend/src/components/rjsf/templates/ObjectFieldTemplate.tsx create mode 100644 frontend/src/components/rjsf/templates/index.ts create mode 100644 frontend/src/components/rjsf/theme.ts create mode 100644 frontend/src/components/rjsf/widgets/CheckboxWidget.tsx create mode 100644 frontend/src/components/rjsf/widgets/SelectWidget.tsx create mode 100644 frontend/src/components/rjsf/widgets/TextWidget.tsx create mode 100644 frontend/src/components/rjsf/widgets/TextareaWidget.tsx create mode 100644 frontend/src/components/rjsf/widgets/index.ts create mode 100644 frontend/src/hooks/useProfiles.ts delete mode 100644 frontend/src/pages/Settings.tsx create mode 100644 frontend/src/pages/settings/AgentSettings.tsx create mode 100644 frontend/src/pages/settings/GeneralSettings.tsx rename frontend/src/pages/{McpServers.tsx => settings/McpSettings.tsx} (52%) create mode 100644 frontend/src/pages/settings/SettingsLayout.tsx create mode 100644 frontend/src/pages/settings/index.ts create mode 100644 frontend/src/types/virtual-executor-schemas.d.ts delete mode 100644 shared/old_frozen_types.ts create mode 100644 shared/schemas/amp.json create mode 100644 shared/schemas/claude_code.json create mode 100644 shared/schemas/codex.json create mode 100644 shared/schemas/cursor.json create mode 100644 shared/schemas/gemini.json create mode 100644 shared/schemas/opencode.json create mode 100644 shared/schemas/qwen_code.json diff --git a/Cargo.toml b/Cargo.toml index 214d9797..1fe536cc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,7 @@ tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } openssl-sys = { version = "0.9", features = ["vendored"] } ts-rs = { git = "https://github.com/xazukx/ts-rs.git", branch = "use-ts-enum", features = ["uuid-impl", "chrono-impl", "no-serde-warnings"] } +schemars = { version = "1.0.4", features = ["derive", "chrono04", "uuid1", "preserve_order"] } [profile.release] debug = true diff --git a/crates/executors/Cargo.toml b/crates/executors/Cargo.toml index 7d1e4ee7..29a4dd3e 100644 --- a/crates/executors/Cargo.toml +++ b/crates/executors/Cargo.toml @@ -16,6 +16,7 @@ tracing-subscriber = { workspace = true } chrono = { version = "0.4", features = ["serde"] } uuid = { version = "1.0", features = ["v4", "serde"] } ts-rs = { workspace = true, features = ["serde-json-impl"]} +schemars = { workspace = true } dirs = "5.0" xdg = "3.0" async-trait = "0.1" diff --git a/crates/executors/src/command.rs b/crates/executors/src/command.rs index 7f3bcdf5..798e3e6e 100644 --- a/crates/executors/src/command.rs +++ b/crates/executors/src/command.rs @@ -1,15 +1,24 @@ +use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use ts_rs::TS; -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS, Default)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS, JsonSchema, Default)] pub struct CmdOverrides { + #[schemars( + title = "Base Command Override", + description = "Override the base command with a custom command" + )] #[serde(default, skip_serializing_if = "Option::is_none")] pub base_command_override: Option, + #[schemars( + title = "Additional Parameters", + description = "Additional parameters to append to the base command" + )] #[serde(default, skip_serializing_if = "Option::is_none")] pub additional_params: Option>, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS, JsonSchema)] pub struct CommandBuilder { /// Base executable command (e.g., "npx -y @anthropic-ai/claude-code@latest") pub base: String, diff --git a/crates/executors/src/executors/amp.rs b/crates/executors/src/executors/amp.rs index 6072cf5c..8332bbcb 100644 --- a/crates/executors/src/executors/amp.rs +++ b/crates/executors/src/executors/amp.rs @@ -2,6 +2,7 @@ use std::{path::Path, process::Stdio, sync::Arc}; use async_trait::async_trait; use command_group::{AsyncCommandGroup, AsyncGroupChild}; +use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use tokio::{io::AsyncWriteExt, process::Command}; use ts_rs::TS; @@ -10,18 +11,21 @@ use utils::{msg_store::MsgStore, shell::get_shell_command}; use crate::{ command::{CmdOverrides, CommandBuilder, apply_overrides}, executors::{ - ExecutorError, StandardCodingAgentExecutor, + AppendPrompt, ExecutorError, StandardCodingAgentExecutor, claude::{ClaudeLogProcessor, HistoryStrategy}, }, logs::{stderr_processor::normalize_stderr_logs, utils::EntryIndexProvider}, }; -/// An executor that uses Amp to process tasks -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS, JsonSchema)] pub struct Amp { + #[serde(default)] + pub append_prompt: AppendPrompt, #[serde(default, skip_serializing_if = "Option::is_none")] - pub append_prompt: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars( + title = "Dangerously Allow All", + description = "Allow all commands to be executed, even if they are not safe." + )] pub dangerously_allow_all: Option, #[serde(flatten)] pub cmd: CmdOverrides, @@ -48,7 +52,7 @@ impl StandardCodingAgentExecutor for Amp { let (shell_cmd, shell_arg) = get_shell_command(); let amp_command = self.build_command_builder().build_initial(); - let combined_prompt = utils::text::combine_prompt(&self.append_prompt, prompt); + let combined_prompt = self.append_prompt.combine_prompt(prompt); let mut command = Command::new(shell_cmd); command @@ -118,7 +122,7 @@ impl StandardCodingAgentExecutor for Amp { new_thread_id.clone(), ]); - let combined_prompt = utils::text::combine_prompt(&self.append_prompt, prompt); + let combined_prompt = self.append_prompt.combine_prompt(prompt); let mut command = Command::new(shell_cmd); command @@ -158,6 +162,6 @@ impl StandardCodingAgentExecutor for Amp { // MCP configuration methods fn default_mcp_config_path(&self) -> Option { - dirs::config_dir().map(|config| config.join("amp").join("settings.json")) + dirs::home_dir().map(|home| home.join(".config").join("amp").join("settings.json")) } } diff --git a/crates/executors/src/executors/claude.rs b/crates/executors/src/executors/claude.rs index a8e253e8..132897cc 100644 --- a/crates/executors/src/executors/claude.rs +++ b/crates/executors/src/executors/claude.rs @@ -3,6 +3,7 @@ use std::{path::Path, process::Stdio, sync::Arc}; use async_trait::async_trait; use command_group::{AsyncCommandGroup, AsyncGroupChild}; use futures::StreamExt; +use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use tokio::{io::AsyncWriteExt, process::Command}; use ts_rs::TS; @@ -16,7 +17,7 @@ use utils::{ use crate::{ command::{CmdOverrides, CommandBuilder, apply_overrides}, - executors::{ExecutorError, StandardCodingAgentExecutor}, + executors::{AppendPrompt, ExecutorError, StandardCodingAgentExecutor}, logs::{ ActionType, FileChange, NormalizedEntry, NormalizedEntryType, TodoItem, stderr_processor::normalize_stderr_logs, @@ -32,14 +33,13 @@ fn base_command(claude_code_router: bool) -> &'static str { } } -/// An executor that uses Claude CLI to process tasks -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS, JsonSchema)] pub struct ClaudeCode { + #[serde(default)] + pub append_prompt: AppendPrompt, #[serde(default, skip_serializing_if = "Option::is_none")] pub claude_code_router: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - pub append_prompt: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] pub plan: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub dangerously_skip_permissions: Option, @@ -88,7 +88,7 @@ impl StandardCodingAgentExecutor for ClaudeCode { base_command }; - let combined_prompt = utils::text::combine_prompt(&self.append_prompt, prompt); + let combined_prompt = self.append_prompt.combine_prompt(prompt); let mut command = Command::new(shell_cmd); command @@ -128,7 +128,7 @@ impl StandardCodingAgentExecutor for ClaudeCode { base_command }; - let combined_prompt = utils::text::combine_prompt(&self.append_prompt, prompt); + let combined_prompt = self.append_prompt.combine_prompt(prompt); let mut command = Command::new(shell_cmd); command @@ -1502,7 +1502,7 @@ mod tests { let executor = ClaudeCode { claude_code_router: Some(false), plan: None, - append_prompt: None, + append_prompt: AppendPrompt::default(), dangerously_skip_permissions: None, cmd: crate::command::CmdOverrides { base_command_override: None, diff --git a/crates/executors/src/executors/codex.rs b/crates/executors/src/executors/codex.rs index 4f33c759..07303628 100644 --- a/crates/executors/src/executors/codex.rs +++ b/crates/executors/src/executors/codex.rs @@ -8,6 +8,7 @@ use async_trait::async_trait; use command_group::{AsyncCommandGroup, AsyncGroupChild}; use futures::StreamExt; use regex::Regex; +use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use strum_macros::AsRefStr; use tokio::{io::AsyncWriteExt, process::Command}; @@ -21,7 +22,7 @@ use utils::{ use crate::{ command::{CmdOverrides, CommandBuilder, apply_overrides}, - executors::{ExecutorError, StandardCodingAgentExecutor}, + executors::{AppendPrompt, ExecutorError, StandardCodingAgentExecutor}, logs::{ ActionType, FileChange, NormalizedEntry, NormalizedEntryType, utils::{EntryIndexProvider, patch::ConversationPatch}, @@ -29,7 +30,7 @@ use crate::{ }; /// Sandbox policy modes for Codex -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS, AsRefStr)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS, JsonSchema, AsRefStr)] #[serde(rename_all = "kebab-case")] #[strum(serialize_all = "kebab-case")] pub enum SandboxMode { @@ -39,7 +40,7 @@ pub enum SandboxMode { } /// Approval policy for Codex -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS, AsRefStr)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS, AsRefStr, JsonSchema)] #[serde(rename_all = "kebab-case")] #[strum(serialize_all = "kebab-case")] pub enum ApprovalPolicy { @@ -201,11 +202,10 @@ impl SessionHandler { } } -/// An executor that uses Codex CLI to process tasks -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS, JsonSchema)] pub struct Codex { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub append_prompt: Option, + #[serde(default)] + pub append_prompt: AppendPrompt, #[serde(default, skip_serializing_if = "Option::is_none")] pub sandbox: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -256,7 +256,7 @@ impl StandardCodingAgentExecutor for Codex { let (shell_cmd, shell_arg) = get_shell_command(); let codex_command = self.build_command_builder().build_initial(); - let combined_prompt = utils::text::combine_prompt(&self.append_prompt, prompt); + let combined_prompt = self.append_prompt.combine_prompt(prompt); let mut command = Command::new(shell_cmd); command @@ -297,7 +297,7 @@ impl StandardCodingAgentExecutor for Codex { format!("experimental_resume={}", rollout_file_path.display()), ]); - let combined_prompt = utils::text::combine_prompt(&self.append_prompt, prompt); + let combined_prompt = self.append_prompt.combine_prompt(prompt); let mut command = Command::new(shell_cmd); command diff --git a/crates/executors/src/executors/cursor.rs b/crates/executors/src/executors/cursor.rs index dc590af4..4de81a93 100644 --- a/crates/executors/src/executors/cursor.rs +++ b/crates/executors/src/executors/cursor.rs @@ -4,6 +4,7 @@ use std::{path::Path, process::Stdio, sync::Arc, time::Duration}; use async_trait::async_trait; use command_group::{AsyncCommandGroup, AsyncGroupChild}; use futures::StreamExt; +use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use tokio::{io::AsyncWriteExt, process::Command}; use ts_rs::TS; @@ -19,7 +20,7 @@ use utils::{ use crate::{ command::{CmdOverrides, CommandBuilder, apply_overrides}, - executors::{ExecutorError, StandardCodingAgentExecutor}, + executors::{AppendPrompt, ExecutorError, StandardCodingAgentExecutor}, logs::{ ActionType, FileChange, NormalizedEntry, NormalizedEntryType, TodoItem, plain_text_processor::PlainTextLogProcessor, @@ -27,11 +28,10 @@ use crate::{ }, }; -/// Executor for running Cursor CLI and normalizing its JSONL stream -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS, JsonSchema)] pub struct Cursor { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub append_prompt: Option, + #[serde(default)] + pub append_prompt: AppendPrompt, #[serde(default, skip_serializing_if = "Option::is_none")] pub force: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -67,7 +67,7 @@ impl StandardCodingAgentExecutor for Cursor { let (shell_cmd, shell_arg) = get_shell_command(); let agent_cmd = self.build_command_builder().build_initial(); - let combined_prompt = utils::text::combine_prompt(&self.append_prompt, prompt); + let combined_prompt = self.append_prompt.combine_prompt(prompt); let mut command = Command::new(shell_cmd); command @@ -100,7 +100,7 @@ impl StandardCodingAgentExecutor for Cursor { .build_command_builder() .build_follow_up(&["--resume".to_string(), session_id.to_string()]); - let combined_prompt = utils::text::combine_prompt(&self.append_prompt, prompt); + let combined_prompt = self.append_prompt.combine_prompt(prompt); let mut command = Command::new(shell_cmd); command @@ -1072,7 +1072,7 @@ mod tests { // Avoid relying on feature flag in tests; construct with a dummy command let executor = Cursor { // No command field needed anymore - append_prompt: None, + append_prompt: AppendPrompt::default(), force: None, model: None, cmd: Default::default(), diff --git a/crates/executors/src/executors/gemini.rs b/crates/executors/src/executors/gemini.rs index e8bed9e9..2250e75a 100644 --- a/crates/executors/src/executors/gemini.rs +++ b/crates/executors/src/executors/gemini.rs @@ -7,6 +7,7 @@ use std::{ use async_trait::async_trait; use command_group::{AsyncCommandGroup, AsyncGroupChild}; use futures::{StreamExt, stream::BoxStream}; +use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use tokio::{ fs::{self, OpenOptions}, @@ -18,7 +19,7 @@ use utils::{msg_store::MsgStore, shell::get_shell_command}; use crate::{ command::{CmdOverrides, CommandBuilder, apply_overrides}, - executors::{ExecutorError, StandardCodingAgentExecutor}, + executors::{AppendPrompt, ExecutorError, StandardCodingAgentExecutor}, logs::{ NormalizedEntry, NormalizedEntryType, plain_text_processor::PlainTextLogProcessor, stderr_processor::normalize_stderr_logs, utils::EntryIndexProvider, @@ -26,8 +27,7 @@ use crate::{ stdout_dup, }; -/// Model variant of Gemini to use -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS, JsonSchema)] #[serde(rename_all = "snake_case")] pub enum GeminiModel { Default, // no --model flag @@ -50,13 +50,12 @@ impl GeminiModel { } } -/// An executor that uses Gemini to process tasks -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS, JsonSchema)] pub struct Gemini { + #[serde(default)] + pub append_prompt: AppendPrompt, pub model: GeminiModel, #[serde(default, skip_serializing_if = "Option::is_none")] - pub append_prompt: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] pub yolo: Option, #[serde(flatten)] pub cmd: CmdOverrides, @@ -84,7 +83,7 @@ impl StandardCodingAgentExecutor for Gemini { let (shell_cmd, shell_arg) = get_shell_command(); let gemini_command = self.build_command_builder().build_initial(); - let combined_prompt = utils::text::combine_prompt(&self.append_prompt, prompt); + let combined_prompt = self.append_prompt.combine_prompt(prompt); let mut command = Command::new(shell_cmd); command @@ -364,7 +363,7 @@ The following is the conversation history from this session: === INSTRUCTIONS === You are continuing work on the above task. The execution history shows the previous conversation in this session. Please continue from where the previous execution left off, taking into account all the context provided above.{} "#, - self.append_prompt.clone().unwrap_or_default(), + self.append_prompt.get().unwrap_or_default(), )) } diff --git a/crates/executors/src/executors/mod.rs b/crates/executors/src/executors/mod.rs index 335337cc..b7d12f8c 100644 --- a/crates/executors/src/executors/mod.rs +++ b/crates/executors/src/executors/mod.rs @@ -4,6 +4,7 @@ use async_trait::async_trait; use command_group::AsyncGroupChild; use enum_dispatch::enum_dispatch; use futures_io::Error as FuturesIoError; +use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use sqlx::Type; use strum_macros::{Display, EnumDiscriminants, EnumString, VariantNames}; @@ -167,3 +168,26 @@ pub trait StandardCodingAgentExecutor { .unwrap_or(false) } } + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS, JsonSchema)] +#[serde(transparent)] +#[schemars( + title = "Append Prompt", + description = "Extra text appended to the prompt", + extend("format" = "textarea") +)] +#[derive(Default)] +pub struct AppendPrompt(pub Option); + +impl AppendPrompt { + pub fn get(&self) -> Option { + self.0.clone() + } + + pub fn combine_prompt(&self, prompt: &str) -> String { + match self { + AppendPrompt(Some(value)) => format!("{prompt}{value}"), + AppendPrompt(None) => prompt.to_string(), + } + } +} diff --git a/crates/executors/src/executors/opencode.rs b/crates/executors/src/executors/opencode.rs index b8829c35..41fc36c2 100644 --- a/crates/executors/src/executors/opencode.rs +++ b/crates/executors/src/executors/opencode.rs @@ -11,6 +11,7 @@ use fork_stream::StreamExt as _; use futures::{StreamExt, future::ready, stream::BoxStream}; use lazy_static::lazy_static; use regex::Regex; +use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use tokio::{io::AsyncWriteExt, process::Command}; use ts_rs::TS; @@ -21,7 +22,7 @@ use utils::{ use crate::{ command::{CmdOverrides, CommandBuilder, apply_overrides}, - executors::{ExecutorError, StandardCodingAgentExecutor}, + executors::{AppendPrompt, ExecutorError, StandardCodingAgentExecutor}, logs::{ ActionType, FileChange, NormalizedEntry, NormalizedEntryType, TodoItem, plain_text_processor::{MessageBoundary, PlainTextLogProcessor}, @@ -29,11 +30,10 @@ use crate::{ }, }; -/// An executor that uses OpenCode to process tasks -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS, JsonSchema)] pub struct Opencode { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub append_prompt: Option, + #[serde(default)] + pub append_prompt: AppendPrompt, #[serde(default, skip_serializing_if = "Option::is_none")] pub model: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -69,7 +69,7 @@ impl StandardCodingAgentExecutor for Opencode { let (shell_cmd, shell_arg) = get_shell_command(); let opencode_command = self.build_command_builder().build_initial(); - let combined_prompt = utils::text::combine_prompt(&self.append_prompt, prompt); + let combined_prompt = self.append_prompt.combine_prompt(prompt); let mut command = Command::new(shell_cmd); command @@ -104,7 +104,7 @@ impl StandardCodingAgentExecutor for Opencode { .build_command_builder() .build_follow_up(&["--session".to_string(), session_id.to_string()]); - let combined_prompt = utils::text::combine_prompt(&self.append_prompt, prompt); + let combined_prompt = self.append_prompt.combine_prompt(prompt); let mut command = Command::new(shell_cmd); command @@ -383,7 +383,7 @@ pub enum Tool { } /// TODO information structure -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS, JsonSchema)] pub struct TodoInfo { pub content: String, pub status: String, @@ -392,7 +392,7 @@ pub struct TodoInfo { } /// Web fetch format options -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS, JsonSchema)] #[serde(rename_all = "lowercase")] pub enum WebFetchFormat { Text, diff --git a/crates/executors/src/executors/qwen.rs b/crates/executors/src/executors/qwen.rs index d12defaf..d644af93 100644 --- a/crates/executors/src/executors/qwen.rs +++ b/crates/executors/src/executors/qwen.rs @@ -2,6 +2,7 @@ use std::{path::Path, process::Stdio, sync::Arc}; use async_trait::async_trait; use command_group::{AsyncCommandGroup, AsyncGroupChild}; +use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use tokio::{io::AsyncWriteExt, process::Command}; use ts_rs::TS; @@ -9,15 +10,14 @@ use utils::{msg_store::MsgStore, shell::get_shell_command}; use crate::{ command::{CmdOverrides, CommandBuilder, apply_overrides}, - executors::{ExecutorError, StandardCodingAgentExecutor, gemini::Gemini}, + executors::{AppendPrompt, ExecutorError, StandardCodingAgentExecutor, gemini::Gemini}, logs::{stderr_processor::normalize_stderr_logs, utils::EntryIndexProvider}, }; -/// An executor that uses QwenCode CLI to process tasks -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS, JsonSchema)] pub struct QwenCode { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub append_prompt: Option, + #[serde(default)] + pub append_prompt: AppendPrompt, #[serde(default, skip_serializing_if = "Option::is_none")] pub yolo: Option, #[serde(flatten)] @@ -46,7 +46,7 @@ impl StandardCodingAgentExecutor for QwenCode { let (shell_cmd, shell_arg) = get_shell_command(); let qwen_command = self.build_command_builder().build_initial(); - let combined_prompt = utils::text::combine_prompt(&self.append_prompt, prompt); + let combined_prompt = self.append_prompt.combine_prompt(prompt); let mut command = Command::new(shell_cmd); command @@ -80,7 +80,7 @@ impl StandardCodingAgentExecutor for QwenCode { .build_command_builder() .build_follow_up(&["--resume".to_string(), session_id.to_string()]); - let combined_prompt = utils::text::combine_prompt(&self.append_prompt, prompt); + let combined_prompt = self.append_prompt.combine_prompt(prompt); let mut command = Command::new(shell_cmd); command diff --git a/crates/executors/src/logs/utils/patch.rs b/crates/executors/src/logs/utils/patch.rs index 737907d5..726110bd 100644 --- a/crates/executors/src/logs/utils/patch.rs +++ b/crates/executors/src/logs/utils/patch.rs @@ -14,6 +14,7 @@ enum PatchOperation { Remove, } +#[allow(clippy::large_enum_variant)] #[derive(Serialize, TS)] #[serde(rename_all = "SCREAMING_SNAKE_CASE", tag = "type", content = "content")] pub enum PatchType { diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml index bd07c02e..2663195b 100644 --- a/crates/server/Cargo.toml +++ b/crates/server/Cargo.toml @@ -31,7 +31,7 @@ command-group = { version = "5.0", features = ["with-tokio"] } nix = { version = "0.29", features = ["signal", "process"] } openssl-sys = { workspace = true } rmcp = { version = "0.5.0", features = ["server", "transport-io"] } -schemars = "0.8" +schemars = { workspace = true } regex = "1.11.1" toml = "0.8" sentry = { version = "0.41.0", features = ["anyhow", "backtrace", "panic", "debug-images"] } diff --git a/crates/server/src/bin/generate_types.rs b/crates/server/src/bin/generate_types.rs index a4878dec..cbe4f2d8 100644 --- a/crates/server/src/bin/generate_types.rs +++ b/crates/server/src/bin/generate_types.rs @@ -1,5 +1,6 @@ use std::{env, fs, path::Path}; +use schemars::{JsonSchema, Schema, SchemaGenerator, generate::SchemaSettings}; use ts_rs::TS; fn generate_types_content() -> String { @@ -74,6 +75,7 @@ fn generate_types_content() -> String { executors::executors::cursor::Cursor::decl(), executors::executors::opencode::Opencode::decl(), executors::executors::qwen::QwenCode::decl(), + executors::executors::AppendPrompt::decl(), executors::actions::coding_agent_initial::CodingAgentInitialRequest::decl(), executors::actions::coding_agent_follow_up::CodingAgentFollowUpRequest::decl(), server::routes::task_attempts::CreateTaskAttemptBody::decl(), @@ -125,13 +127,54 @@ fn generate_types_content() -> String { format!("{HEADER}\n\n{body}") } +fn write_schema( + name: &str, + schemas_dir: &std::path::Path, +) -> Result<(), Box> { + // Draft-07, inline everything (no $defs) + let mut settings = SchemaSettings::draft07(); + settings.inline_subschemas = true; + + let generator: SchemaGenerator = settings.into_generator(); + let schema: Schema = generator.into_root_schema_for::(); + + // Convert to JSON value to manipulate it + let mut schema_value: serde_json::Value = serde_json::to_value(&schema)?; + + // Remove the title from root schema to prevent RJSF from creating an outer field container + if let Some(obj) = schema_value.as_object_mut() { + obj.remove("title"); + } + + let schema_json = serde_json::to_string_pretty(&schema_value)?; + std::fs::write(schemas_dir.join(format!("{name}.json")), schema_json)?; + Ok(()) +} + +fn generate_schemas() -> Result<(), Box> { + // Create schemas directory + let schemas_dir = Path::new("shared/schemas"); + fs::create_dir_all(schemas_dir)?; + + println!("Generating JSON schemas…"); + + // Generate schemas for all executor types + write_schema::("amp", schemas_dir)?; + write_schema::("claude_code", schemas_dir)?; + write_schema::("gemini", schemas_dir)?; + write_schema::("codex", schemas_dir)?; + write_schema::("cursor", schemas_dir)?; + write_schema::("opencode", schemas_dir)?; + write_schema::("qwen_code", schemas_dir)?; + + Ok(()) +} + fn main() { let args: Vec = env::args().collect(); let check_mode = args.iter().any(|arg| arg == "--check"); - // 1. Make sure ../shared exists let shared_path = Path::new("shared"); - fs::create_dir_all(shared_path).expect("cannot create shared"); println!("Generating TypeScript types…"); @@ -151,8 +194,22 @@ fn main() { std::process::exit(1); } } else { + // Wipe existing shared + fs::remove_dir_all(shared_path).ok(); + + // Recreate folder + fs::create_dir_all(shared_path).expect("cannot create shared"); + // Write the file as before fs::write(&types_path, generated).expect("unable to write types.ts"); println!("βœ… TypeScript types generated in shared/"); + + // Generate JSON schemas + if let Err(e) = generate_schemas() { + eprintln!("❌ Failed to generate schemas: {}", e); + std::process::exit(1); + } + + println!("βœ… JSON schemas generated in shared/schemas/"); } } diff --git a/crates/utils/src/text.rs b/crates/utils/src/text.rs index fe5a315b..94831b04 100644 --- a/crates/utils/src/text.rs +++ b/crates/utils/src/text.rs @@ -22,10 +22,3 @@ pub fn short_uuid(u: &Uuid) -> String { let full = u.simple().to_string(); full.chars().take(4).collect() // grab the first 4 chars } - -pub fn combine_prompt(append: &Option, prompt: &str) -> String { - match append { - Some(append) => format!("{prompt}{append}"), - None => prompt.to_string(), - } -} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 43f02b4e..aa251c77 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index 1039539e..5910b56a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c456a924..0e1e3fd4 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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={} /> - } /> - } /> + }> + } /> + } /> + } /> + } /> + + {/* Redirect old MCP route */} + } + /> diff --git a/frontend/src/components/ExecutorConfigForm.tsx b/frontend/src/components/ExecutorConfigForm.tsx new file mode 100644 index 00000000..3d0d8055 --- /dev/null +++ b/frontend/src/components/ExecutorConfigForm.tsx @@ -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; + 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 ( + + + Schema not found for executor type: {executor} + + + ); + } + + return ( +
+ + +
+ {onSave && ( +
+ +
+ )} +
+
+
+ + {validationErrors.length > 0 && ( + + +
    + {validationErrors.map((error, index) => ( +
  • + {error.property}: {error.message} +
  • + ))} +
+
+
+ )} +
+ ); +} diff --git a/frontend/src/components/layout/navbar.tsx b/frontend/src/components/layout/navbar.tsx index 351570f6..cc1cbe36 100644 --- a/frontend/src/components/layout/navbar.tsx +++ b/frontend/src/components/layout/navbar.tsx @@ -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() { {INTERNAL_NAV.map((item) => { - const active = location.pathname === item.to; + const active = location.pathname.startsWith(item.to); const Icon = item.icon; return ( { + const { canAdd, items, onAddClick, disabled, readonly } = props; + + if (!items || (items.length === 0 && !canAdd)) { + return null; + } + + return ( +
+
+ {items.length > 0 && + items.map((element: ArrayFieldTemplateItemType) => ( + + ))} +
+ + {canAdd && ( + + )} +
+ ); +}; + +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 ( +
+
{children}
+ + {/* Remove button */} + {elementAny.buttonsProps?.hasRemove && ( + + )} +
+ ); +}; diff --git a/frontend/src/components/rjsf/templates/FieldTemplate.tsx b/frontend/src/components/rjsf/templates/FieldTemplate.tsx new file mode 100644 index 00000000..832c5063 --- /dev/null +++ b/frontend/src/components/rjsf/templates/FieldTemplate.tsx @@ -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 ( +
+ {/* Left column: Label and description */} +
+ {label && ( +
+ {label} + {required && *} +
+ )} + + {rawDescription && ( +

+ {rawDescription} +

+ )} + + {rawHelp && ( +

+ {rawHelp} +

+ )} +
+ + {/* Right column: Field content */} +
+ {children} + + {rawErrors.length > 0 && ( +
+ {rawErrors.map((error, index) => ( +

+ {error} +

+ ))} +
+ )} +
+
+ ); +}; diff --git a/frontend/src/components/rjsf/templates/FormTemplate.tsx b/frontend/src/components/rjsf/templates/FormTemplate.tsx new file mode 100644 index 00000000..25fea8ae --- /dev/null +++ b/frontend/src/components/rjsf/templates/FormTemplate.tsx @@ -0,0 +1,5 @@ +export const FormTemplate = (props: any) => { + const { children } = props; + + return
{children}
; +}; diff --git a/frontend/src/components/rjsf/templates/ObjectFieldTemplate.tsx b/frontend/src/components/rjsf/templates/ObjectFieldTemplate.tsx new file mode 100644 index 00000000..559f8689 --- /dev/null +++ b/frontend/src/components/rjsf/templates/ObjectFieldTemplate.tsx @@ -0,0 +1,13 @@ +import { ObjectFieldTemplateProps } from '@rjsf/utils'; + +export const ObjectFieldTemplate = (props: ObjectFieldTemplateProps) => { + const { properties } = props; + + return ( +
+ {properties.map((element) => ( +
{element.content}
+ ))} +
+ ); +}; diff --git a/frontend/src/components/rjsf/templates/index.ts b/frontend/src/components/rjsf/templates/index.ts new file mode 100644 index 00000000..722cd093 --- /dev/null +++ b/frontend/src/components/rjsf/templates/index.ts @@ -0,0 +1,4 @@ +export { ArrayFieldTemplate } from './ArrayFieldTemplate'; +export { FieldTemplate } from './FieldTemplate'; +export { ObjectFieldTemplate } from './ObjectFieldTemplate'; +export { FormTemplate } from './FormTemplate'; diff --git a/frontend/src/components/rjsf/theme.ts b/frontend/src/components/rjsf/theme.ts new file mode 100644 index 00000000..d720e66a --- /dev/null +++ b/frontend/src/components/rjsf/theme.ts @@ -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, +}; diff --git a/frontend/src/components/rjsf/widgets/CheckboxWidget.tsx b/frontend/src/components/rjsf/widgets/CheckboxWidget.tsx new file mode 100644 index 00000000..7e1e8920 --- /dev/null +++ b/frontend/src/components/rjsf/widgets/CheckboxWidget.tsx @@ -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 ( +
+ +
+ ); +}; diff --git a/frontend/src/components/rjsf/widgets/SelectWidget.tsx b/frontend/src/components/rjsf/widgets/SelectWidget.tsx new file mode 100644 index 00000000..ff79d484 --- /dev/null +++ b/frontend/src/components/rjsf/widgets/SelectWidget.tsx @@ -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 ( + + ); +}; diff --git a/frontend/src/components/rjsf/widgets/TextWidget.tsx b/frontend/src/components/rjsf/widgets/TextWidget.tsx new file mode 100644 index 00000000..4e60ab39 --- /dev/null +++ b/frontend/src/components/rjsf/widgets/TextWidget.tsx @@ -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) => { + const newValue = event.target.value; + onChange(newValue === '' ? options.emptyValue : newValue); + }; + + const handleBlur = (event: React.FocusEvent) => { + if (onBlur) { + onBlur(id, event.target.value); + } + }; + + const handleFocus = (event: React.FocusEvent) => { + if (onFocus) { + onFocus(id, event.target.value); + } + }; + + return ( + + ); +}; diff --git a/frontend/src/components/rjsf/widgets/TextareaWidget.tsx b/frontend/src/components/rjsf/widgets/TextareaWidget.tsx new file mode 100644 index 00000000..50a72d81 --- /dev/null +++ b/frontend/src/components/rjsf/widgets/TextareaWidget.tsx @@ -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) => { + const newValue = event.target.value; + onChange(newValue === '' ? options.emptyValue : newValue); + }; + + const handleBlur = (event: React.FocusEvent) => { + if (onBlur) { + onBlur(id, event.target.value); + } + }; + + const handleFocus = (event: React.FocusEvent) => { + 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 ( +