diff --git a/crates/sprout-acp/src/acp.rs b/crates/sprout-acp/src/acp.rs index bc81a9e296..5b4dcd49b7 100644 --- a/crates/sprout-acp/src/acp.rs +++ b/crates/sprout-acp/src/acp.rs @@ -101,6 +101,9 @@ pub enum AcpError { #[error("Protocol error: {0}")] Protocol(String), + + #[error("Agent reported error: {0}")] + AgentError(String), } // ─── AcpClient ─────────────────────────────────────────────────────────────── @@ -709,7 +712,7 @@ impl AcpClient { if let Some(id) = msg.get("id") { if *id == serde_json::json!(expected_id) && msg.get("method").is_none() { if let Some(error) = msg.get("error") { - return Err(AcpError::Protocol(error.to_string())); + return Err(AcpError::AgentError(error.to_string())); } return Ok(msg["result"].clone()); } @@ -823,7 +826,7 @@ impl AcpClient { if let Some(id) = msg.get("id") { if *id == serde_json::json!(expected_id) && msg.get("method").is_none() { if let Some(error) = msg.get("error") { - return Err(AcpError::Protocol(error.to_string())); + return Err(AcpError::AgentError(error.to_string())); } return Ok(msg["result"].clone()); } diff --git a/crates/sprout-agent/README.md b/crates/sprout-agent/README.md index ba29dace75..2c20ffd728 100644 --- a/crates/sprout-agent/README.md +++ b/crates/sprout-agent/README.md @@ -133,7 +133,7 @@ Everything is environment variables. No flags, no config files. (We are a subpro | `OPENAI_COMPAT_BASE_URL` | `https://api.openai.com/v1` | Point at vLLM, llama.cpp, OpenRouter, Ollama, etc. | | `SPROUT_AGENT_SYSTEM_PROMPT` | built-in | Inline system prompt. | | `SPROUT_AGENT_SYSTEM_PROMPT_FILE` | — | File path. Mutually exclusive with the above. | -| `SPROUT_AGENT_MAX_ROUNDS` | `16` | Tool-loop iteration cap. | +| `SPROUT_AGENT_MAX_ROUNDS` | `0` | Tool-loop iteration cap. 0 = unlimited. | | `SPROUT_AGENT_MAX_OUTPUT_TOKENS` | `4096` | Per LLM call. | | `SPROUT_AGENT_LLM_TIMEOUT_SECS` | `120` | | | `SPROUT_AGENT_TOOL_TIMEOUT_SECS` | `660` | Per-tool call timeout in seconds | @@ -223,7 +223,7 @@ The trust boundary is **the operator who launched the agent**. The harness, MCP | Tool description bytes | 1 KiB | `MAX_DESCRIPTION_BYTES` | | Tool schema bytes | 4 KiB | `MAX_SCHEMA_BYTES` (oversize → replaced with `{}`) | | Tool calls per turn | 64 | `MAX_TOOL_CALLS_PER_TURN` | -| Loop rounds | 16 | `SPROUT_AGENT_MAX_ROUNDS` | +| Loop rounds | 0 (unlimited) | `SPROUT_AGENT_MAX_ROUNDS` | | LLM call timeout | 120 s | `SPROUT_AGENT_LLM_TIMEOUT_SECS` | | Tool call timeout | 660 s | `SPROUT_AGENT_TOOL_TIMEOUT_SECS` | diff --git a/crates/sprout-agent/src/config.rs b/crates/sprout-agent/src/config.rs index 91bbf169c8..d60834374b 100644 --- a/crates/sprout-agent/src/config.rs +++ b/crates/sprout-agent/src/config.rs @@ -92,7 +92,7 @@ impl Config { model, base_url, anthropic_api_version: env_or("ANTHROPIC_API_VERSION", "2023-06-01"), - max_rounds: parse_env("SPROUT_AGENT_MAX_ROUNDS", 16)?, + max_rounds: parse_env("SPROUT_AGENT_MAX_ROUNDS", 0)?, max_output_tokens: parse_env("SPROUT_AGENT_MAX_OUTPUT_TOKENS", 4096)?, llm_timeout: Duration::from_secs(parse_env("SPROUT_AGENT_LLM_TIMEOUT_SECS", 120)?), tool_timeout: Duration::from_secs(parse_env("SPROUT_AGENT_TOOL_TIMEOUT_SECS", 660)?), diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index c5c9400551..3df8d02d12 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -33,7 +33,7 @@ const overrides = new Map([ ["src-tauri/src/managed_agents/personas.rs", 900], // built-in persona system prompts (Solo + Kit + Scout) + persona pack import/uninstall/list + uninstall safety check ["src-tauri/src/managed_agents/teams.rs", 580], // built-in team registry (Kit & Scout) + merge_teams + validate_team_deletion + JSON export/import + tests ["src-tauri/src/managed_agents/persona_card.rs", 970], // PNG/ZIP/MD persona card codec + pack-zip detection + nested root finder + provider/model/namePool fields + 27 unit tests - ["src/app/AppShell.tsx", 860], // message edit state + handlers + ChannelPane edit prop threading + scrollback pagination + workflows view + memory-leak safeguards + ["src/app/AppShell.tsx", 770], // message edit state + handlers + ChannelPane edit prop threading + scrollback pagination + workflows view + memory-leak safeguards ["src/features/channels/hooks.ts", 550], // canvas query + mutation hooks + DM hide mutation ["src/features/channels/ui/ChannelManagementSheet.tsx", 800], ["src/features/channels/ui/ChannelPane.tsx", 520], // composer/timeline/sidebar orchestration + anchored agent activity footers @@ -61,7 +61,8 @@ const overrides = new Map([ ["src/features/agents/ui/useTeamActions.ts", 510], // team CRUD + export + import + import-update orchestration with query invalidation ["src/features/agents/ui/CreateAgentDialog.tsx", 685], // provider selector + config form + schema-typed config coercion + required field validation + locked scopes ["src/features/channels/ui/AddChannelBotDialog.tsx", 640], // provider mode: Run on selector, trust warning, probe effect, single-agent enforcement, provider warnings display - ["src/shared/api/types.ts", 570], // persona provider/model fields + forum types + workflow type re-exports + ephemeral channel TTL fields + mcpToolsets + sourcePack + UpdateManagedAgentInput edit fields + UserStatus/UserStatusLookup (NIP-38) + RelayMember/RelayMemberRole types + ["src/features/settings/ui/ChannelTemplatesSettingsCard.tsx", 850], // template CRUD card + TemplateFormDialog (persona/team chip selectors + provider assignments + canvas template) + TemplateTeamSelector + ProviderAssignments + ProviderRow + ["src/shared/api/types.ts", 580], // persona provider/model fields + forum types + workflow type re-exports + ephemeral channel TTL fields + mcpToolsets + sourcePack + UpdateManagedAgentInput edit fields + UserStatus/UserStatusLookup (NIP-38) + RelayMember/RelayMemberRole types + ChannelTemplate/TemplateAgentEntry/TemplateTeamEntry ["src-tauri/src/events.rs", 610], // event builders + build_huddle_guidelines (kind:48106) + post_event_raw transport helper + participant p-tag on join/leave + NIP-43 relay admin builders (add/remove/change-role) + check_relay_role + DM/presence/workflow command builders ["src-tauri/src/huddle/kokoro.rs", 980], // Kokoro ONNX TTS engine + three-tier G2P + ARPAbet→IPA + CoreML + synth_chunk() public API + style validation + hyphenated compound splitting + 23 unit tests ["src-tauri/src/huddle/mod.rs", 1020], // huddle state machine + Tauri commands + sync protocol doc; state/relay/pipeline extracted + emit_huddle_state_changed wiring diff --git a/desktop/src-tauri/src/app_state.rs b/desktop/src-tauri/src/app_state.rs index c070c1f44b..54e33553ba 100644 --- a/desktop/src-tauri/src/app_state.rs +++ b/desktop/src-tauri/src/app_state.rs @@ -17,6 +17,7 @@ pub struct AppState { /// init and takes priority over env vars and compile-time defaults. pub relay_url_override: Mutex>, pub managed_agents_store_lock: Mutex<()>, + pub channel_templates_store_lock: Mutex<()>, pub managed_agent_processes: Mutex>, pub huddle_state: Mutex, /// Tauri app handle — stored after setup so huddle commands can emit @@ -68,6 +69,7 @@ pub fn build_app_state() -> AppState { .unwrap_or_else(|_| reqwest::Client::new()), relay_url_override: Mutex::new(None), managed_agents_store_lock: Mutex::new(()), + channel_templates_store_lock: Mutex::new(()), managed_agent_processes: Mutex::new(HashMap::new()), huddle_state: Mutex::new(HuddleState::default()), app_handle: Mutex::new(None), diff --git a/desktop/src-tauri/src/commands/channel_templates.rs b/desktop/src-tauri/src/commands/channel_templates.rs new file mode 100644 index 0000000000..c4e8dfca1a --- /dev/null +++ b/desktop/src-tauri/src/commands/channel_templates.rs @@ -0,0 +1,185 @@ +use tauri::{AppHandle, State}; +use uuid::Uuid; + +use crate::{ + app_state::AppState, + templates::{ + load_channel_templates, save_channel_templates, validate_channel_template_deletion, + ChannelTemplateRecord, CreateChannelTemplateRequest, UpdateChannelTemplateRequest, + }, + util::now_iso, +}; + +fn trim_required(value: &str, label: &str) -> Result { + let trimmed = value.trim(); + if trimmed.is_empty() { + return Err(format!("{label} is required")); + } + Ok(trimmed.to_string()) +} + +fn trim_optional(value: Option) -> Option { + value.and_then(|candidate| { + let trimmed = candidate.trim(); + (!trimmed.is_empty()).then(|| trimmed.to_string()) + }) +} + +fn validate_channel_type(value: &str) -> Result<(), String> { + match value { + "stream" | "forum" => Ok(()), + _ => Err(format!( + "invalid channel type: {value:?} (expected \"stream\" or \"forum\")" + )), + } +} + +fn validate_visibility(value: &str) -> Result<(), String> { + match value { + "open" | "private" => Ok(()), + _ => Err(format!( + "invalid visibility: {value:?} (expected \"open\" or \"private\")" + )), + } +} + +#[tauri::command] +pub fn list_channel_templates( + app: AppHandle, + state: State<'_, AppState>, +) -> Result, String> { + let _store_guard = state + .channel_templates_store_lock + .lock() + .map_err(|error| error.to_string())?; + load_channel_templates(&app) +} + +#[tauri::command] +pub fn create_channel_template( + input: CreateChannelTemplateRequest, + app: AppHandle, + state: State<'_, AppState>, +) -> Result { + let name = trim_required(&input.name, "Template name")?; + let description = trim_optional(input.description); + let canvas_template = trim_optional(input.canvas_template); + let channel_type = input.channel_type.unwrap_or_else(|| "stream".to_string()); + let visibility = input.visibility.unwrap_or_else(|| "open".to_string()); + validate_channel_type(&channel_type)?; + validate_visibility(&visibility)?; + let now = now_iso(); + + let _store_guard = state + .channel_templates_store_lock + .lock() + .map_err(|error| error.to_string())?; + let mut templates = load_channel_templates(&app)?; + + let template = ChannelTemplateRecord { + id: Uuid::new_v4().to_string(), + name, + description, + channel_type, + visibility, + canvas_template, + agents: input.agents, + is_builtin: false, + created_at: now.clone(), + updated_at: now, + }; + + templates.push(template.clone()); + save_channel_templates(&app, &templates)?; + Ok(template) +} + +#[tauri::command] +pub fn update_channel_template( + input: UpdateChannelTemplateRequest, + app: AppHandle, + state: State<'_, AppState>, +) -> Result { + let name = trim_required(&input.name, "Template name")?; + let description = trim_optional(input.description); + let canvas_template = trim_optional(input.canvas_template); + let channel_type = input.channel_type.unwrap_or_else(|| "stream".to_string()); + let visibility = input.visibility.unwrap_or_else(|| "open".to_string()); + validate_channel_type(&channel_type)?; + validate_visibility(&visibility)?; + + let _store_guard = state + .channel_templates_store_lock + .lock() + .map_err(|error| error.to_string())?; + let mut templates = load_channel_templates(&app)?; + let template = templates + .iter_mut() + .find(|record| record.id == input.id) + .ok_or_else(|| format!("template {} not found", input.id))?; + + template.name = name; + template.description = description; + template.channel_type = channel_type; + template.visibility = visibility; + template.canvas_template = canvas_template; + template.agents = input.agents; + template.updated_at = now_iso(); + + let updated = template.clone(); + save_channel_templates(&app, &templates)?; + Ok(updated) +} + +#[tauri::command] +pub fn delete_channel_template( + id: String, + app: AppHandle, + state: State<'_, AppState>, +) -> Result<(), String> { + let _store_guard = state + .channel_templates_store_lock + .lock() + .map_err(|error| error.to_string())?; + let mut templates = load_channel_templates(&app)?; + let template = templates + .iter() + .find(|record| record.id == id) + .ok_or_else(|| format!("template {id} not found"))?; + validate_channel_template_deletion(template)?; + templates.retain(|record| record.id != id); + save_channel_templates(&app, &templates) +} + +#[tauri::command] +pub fn duplicate_channel_template( + id: String, + app: AppHandle, + state: State<'_, AppState>, +) -> Result { + let now = now_iso(); + + let _store_guard = state + .channel_templates_store_lock + .lock() + .map_err(|error| error.to_string())?; + let mut templates = load_channel_templates(&app)?; + let source = templates + .iter() + .find(|record| record.id == id) + .ok_or_else(|| format!("template {id} not found"))? + .clone(); + + let duplicate = ChannelTemplateRecord { + id: Uuid::new_v4().to_string(), + name: format!("{} (Copy)", source.name), + is_builtin: false, + created_at: now.clone(), + updated_at: now, + ..source + }; + + templates.push(duplicate.clone()); + save_channel_templates(&app, &templates)?; + Ok(duplicate) +} diff --git a/desktop/src-tauri/src/commands/export_util.rs b/desktop/src-tauri/src/commands/export_util.rs index 8263216c35..8ebe4333bc 100644 --- a/desktop/src-tauri/src/commands/export_util.rs +++ b/desktop/src-tauri/src/commands/export_util.rs @@ -8,11 +8,24 @@ pub async fn save_json_with_dialog( app: &AppHandle, suggested_filename: &str, data: &[u8], +) -> Result { + save_bytes_with_dialog(app, suggested_filename, "JSON", &["json"], data).await +} + +/// Show a save-file dialog with a custom filter and write `data` to the chosen +/// path. Returns `Ok(true)` when the file was written, `Ok(false)` when the +/// user cancelled the dialog. +pub async fn save_bytes_with_dialog( + app: &AppHandle, + suggested_filename: &str, + filter_name: &str, + extensions: &[&str], + data: &[u8], ) -> Result { let (tx, rx) = tokio::sync::oneshot::channel(); app.dialog() .file() - .add_filter("JSON", &["json"]) + .add_filter(filter_name, extensions) .set_file_name(suggested_filename) .save_file(move |path| { let _ = tx.send(path); diff --git a/desktop/src-tauri/src/commands/media.rs b/desktop/src-tauri/src/commands/media.rs index dcb1a94d95..6d9f561ccc 100644 --- a/desktop/src-tauri/src/commands/media.rs +++ b/desktop/src-tauri/src/commands/media.rs @@ -109,7 +109,7 @@ const ALLOWED_MIME: &[&str] = &[ "video/mp4", ]; -fn detect_and_validate_mime(body: &[u8]) -> Result { +pub(crate) fn detect_and_validate_mime(body: &[u8]) -> Result { let mime = infer::get(body) .map(|t| t.mime_type().to_string()) .unwrap_or_else(|| "application/octet-stream".to_string()); diff --git a/desktop/src-tauri/src/commands/media_download.rs b/desktop/src-tauri/src/commands/media_download.rs new file mode 100644 index 0000000000..aed720f952 --- /dev/null +++ b/desktop/src-tauri/src/commands/media_download.rs @@ -0,0 +1,207 @@ +use futures_util::StreamExt; +use tauri::State; + +use crate::app_state::AppState; +use crate::commands::export_util::save_bytes_with_dialog; +use crate::relay::relay_api_base_url_with_override; + +use super::media::detect_and_validate_mime; + +/// Maximum download size: 50 MiB. Prevents OOM from oversized responses. +const MAX_DOWNLOAD_BYTES: u64 = 50 * 1024 * 1024; + +/// Download request timeout. +const DOWNLOAD_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60); + +/// Validate that a URL is a legitimate relay media URL. +/// +/// Ensures: +/// - URL scheme is `https` (or `http` for localhost dev) +/// - URL origin matches the relay base URL +/// - URL path matches `/media/{hash}.{ext}` +fn validate_download_url(url: &str, relay_base: &str) -> Result<(), String> { + let parsed = url::Url::parse(url).map_err(|_| "invalid URL".to_string())?; + let base = url::Url::parse(relay_base).map_err(|_| "invalid relay base URL".to_string())?; + + // Scheme must be https (allow http for localhost dev servers). + match parsed.scheme() { + "https" => {} + "http" => { + let host = parsed.host_str().unwrap_or(""); + if host != "localhost" && host != "127.0.0.1" && host != "[::1]" { + return Err("download URL must use HTTPS".to_string()); + } + } + _ => return Err("download URL must use HTTPS".to_string()), + } + + // Origin must match relay. + if parsed.origin() != base.origin() { + return Err("download URL must match the relay origin".to_string()); + } + + // Path must be /media/{filename}. + let path = parsed.path(); + if !path.starts_with("/media/") { + return Err("download URL must be a /media/ path".to_string()); + } + + Ok(()) +} + +/// Download an image from a URL and save it via a native save-file dialog. +#[tauri::command] +pub async fn download_image( + url: String, + app: tauri::AppHandle, + state: State<'_, AppState>, +) -> Result { + // SSRF protection: only allow downloads from the relay's /media/ path. + let relay_base = relay_api_base_url_with_override(&state); + validate_download_url(&url, &relay_base)?; + + // Infer filename from the URL path (e.g. "abcdef123.jpg" from a Blossom URL). + let filename = url::Url::parse(&url) + .ok() + .and_then(|u| { + u.path_segments()? + .last() + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()) + }) + .unwrap_or_else(|| "image.png".to_string()); + + // Derive extension for the save dialog filter. + let ext = std::path::Path::new(&filename) + .extension() + .and_then(|e| e.to_str()) + .unwrap_or("png") + .to_string(); + + // Fetch image bytes via the app's HTTP client (goes through WARP tunnel). + let resp = state + .http_client + .get(&url) + .timeout(DOWNLOAD_TIMEOUT) + .send() + .await + .map_err(|e| format!("download failed: {e}"))?; + + if !resp.status().is_success() { + let status = resp.status(); + let text = resp.text().await.unwrap_or_default(); + return Err(format!("download failed ({status}): {text}")); + } + + // Check Content-Length header upfront if present. + if let Some(content_length) = resp.content_length() { + if content_length > MAX_DOWNLOAD_BYTES { + return Err(format!( + "file too large ({} MiB, max {} MiB)", + content_length / (1024 * 1024), + MAX_DOWNLOAD_BYTES / (1024 * 1024) + )); + } + } + + // Stream the response with a running byte count to enforce the size cap + // even when Content-Length is missing or dishonest. + let mut bytes = Vec::new(); + let mut stream = resp.bytes_stream(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|e| format!("download stream error: {e}"))?; + if bytes.len() as u64 + chunk.len() as u64 > MAX_DOWNLOAD_BYTES { + return Err(format!( + "file too large (max {} MiB)", + MAX_DOWNLOAD_BYTES / (1024 * 1024) + )); + } + bytes.extend_from_slice(&chunk); + } + + // Validate the downloaded content is actually a supported media type. + detect_and_validate_mime(&bytes)?; + + save_bytes_with_dialog(&app, &filename, "Images", &[&ext], &bytes).await +} + +#[cfg(test)] +mod tests { + use super::*; + + const RELAY_BASE: &str = "https://relay.example.com"; + + #[test] + fn test_validate_download_url_valid_relay_url() { + assert!(validate_download_url( + "https://relay.example.com/media/abcdef1234567890.jpg", + RELAY_BASE, + ) + .is_ok()); + } + + #[test] + fn test_validate_download_url_valid_relay_url_png() { + assert!( + validate_download_url("https://relay.example.com/media/abc123.png", RELAY_BASE,) + .is_ok() + ); + } + + #[test] + fn test_validate_download_url_non_relay_origin_rejected() { + let result = validate_download_url("https://evil.example.com/media/abc123.jpg", RELAY_BASE); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("relay origin")); + } + + #[test] + fn test_validate_download_url_private_ip_rejected() { + let result = validate_download_url("http://169.254.169.254/latest/meta-data/", RELAY_BASE); + assert!(result.is_err()); + } + + #[test] + fn test_validate_download_url_loopback_rejected() { + let result = validate_download_url("http://127.0.0.1/media/abc.jpg", RELAY_BASE); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("relay origin")); + } + + #[test] + fn test_validate_download_url_localhost_allowed_for_localhost_relay() { + assert!(validate_download_url( + "http://localhost:3000/media/abc.jpg", + "http://localhost:3000", + ) + .is_ok()); + } + + #[test] + fn test_validate_download_url_missing_media_path_rejected() { + let result = validate_download_url("https://relay.example.com/other/abc.jpg", RELAY_BASE); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("/media/")); + } + + #[test] + fn test_validate_download_url_non_https_scheme_rejected() { + let result = validate_download_url("ftp://relay.example.com/media/abc.jpg", RELAY_BASE); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("HTTPS")); + } + + #[test] + fn test_validate_download_url_http_non_localhost_rejected() { + let result = validate_download_url("http://relay.example.com/media/abc.jpg", RELAY_BASE); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("HTTPS")); + } + + #[test] + fn test_validate_download_url_root_path_rejected() { + let result = validate_download_url("https://relay.example.com/", RELAY_BASE); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("/media/")); + } +} diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index 511b61d5d1..2bc5062f5e 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -3,11 +3,13 @@ mod agent_models; mod agent_settings; mod agents; mod canvas; +mod channel_templates; mod channels; mod dms; mod export_util; mod identity; mod media; +mod media_download; mod messages; pub mod pairing; mod personas; @@ -24,10 +26,12 @@ pub use agent_models::*; pub use agent_settings::*; pub use agents::*; pub use canvas::*; +pub use channel_templates::*; pub use channels::*; pub use dms::*; pub use identity::*; pub use media::*; +pub use media_download::*; pub use messages::*; pub use pairing::*; pub use personas::*; diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 34aa644366..5e71c2579c 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -9,6 +9,7 @@ mod models; pub mod nostr_convert; mod prevent_sleep; mod relay; +mod templates; mod util; use app_state::{build_app_state, resolve_persisted_identity, AppState}; @@ -493,6 +494,7 @@ pub fn run() { upload_media, pick_and_upload_media, upload_media_bytes, + download_image, list_relay_members, get_my_relay_membership, add_relay_member, @@ -515,6 +517,11 @@ pub fn run() { update_persona, delete_persona, set_persona_active, + list_channel_templates, + create_channel_template, + update_channel_template, + delete_channel_template, + duplicate_channel_template, list_teams, create_team, update_team, diff --git a/desktop/src-tauri/src/templates/mod.rs b/desktop/src-tauri/src/templates/mod.rs new file mode 100644 index 0000000000..2ff5aa9462 --- /dev/null +++ b/desktop/src-tauri/src/templates/mod.rs @@ -0,0 +1,5 @@ +mod storage; +mod types; + +pub use storage::*; +pub use types::*; diff --git a/desktop/src-tauri/src/templates/storage.rs b/desktop/src-tauri/src/templates/storage.rs new file mode 100644 index 0000000000..8a2151e6ee --- /dev/null +++ b/desktop/src-tauri/src/templates/storage.rs @@ -0,0 +1,248 @@ +use std::{fs, path::PathBuf}; + +use tauri::AppHandle; +use tauri::Manager; + +use crate::templates::ChannelTemplateRecord; + +// --------------------------------------------------------------------------- +// Paths +// --------------------------------------------------------------------------- + +fn channel_templates_base_dir(app: &AppHandle) -> Result { + let dir = app + .path() + .app_data_dir() + .map_err(|error| format!("app data dir: {error}"))? + .join("templates"); + fs::create_dir_all(&dir).map_err(|error| format!("failed to create templates dir: {error}"))?; + Ok(dir) +} + +fn channel_templates_store_path(app: &AppHandle) -> Result { + Ok(channel_templates_base_dir(app)?.join("channel-templates.json")) +} + +// --------------------------------------------------------------------------- +// Sort +// --------------------------------------------------------------------------- + +pub fn sort_channel_templates(records: &mut [ChannelTemplateRecord]) { + records.sort_by(|left, right| { + let left_builtin = if left.is_builtin { 0 } else { 1 }; + let right_builtin = if right.is_builtin { 0 } else { 1 }; + left_builtin + .cmp(&right_builtin) + .then_with(|| left.name.to_lowercase().cmp(&right.name.to_lowercase())) + .then_with(|| left.id.cmp(&right.id)) + }); +} + +// --------------------------------------------------------------------------- +// Load / Save +// --------------------------------------------------------------------------- + +pub fn load_channel_templates(app: &AppHandle) -> Result, String> { + let path = channel_templates_store_path(app)?; + + let mut records = if path.exists() { + let content = fs::read_to_string(&path) + .map_err(|error| format!("failed to read channel templates store: {error}"))?; + serde_json::from_str::>(&content) + .map_err(|error| format!("failed to parse channel templates store: {error}"))? + } else { + Vec::new() + }; + + sort_channel_templates(&mut records); + Ok(records) +} + +pub fn save_channel_templates( + app: &AppHandle, + records: &[ChannelTemplateRecord], +) -> Result<(), String> { + let mut sorted = records.to_vec(); + sort_channel_templates(&mut sorted); + + let path = channel_templates_store_path(app)?; + let payload = serde_json::to_vec_pretty(&sorted) + .map_err(|error| format!("failed to serialize channel templates store: {error}"))?; + fs::write(&path, payload) + .map_err(|error| format!("failed to write channel templates store: {error}")) +} + +// --------------------------------------------------------------------------- +// Validation +// --------------------------------------------------------------------------- + +pub fn validate_channel_template_deletion(template: &ChannelTemplateRecord) -> Result<(), String> { + if template.is_builtin { + return Err("Built-in templates cannot be deleted.".to_string()); + } + Ok(()) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::sort_channel_templates; + use crate::templates::{ + validate_channel_template_deletion, ChannelTemplateRecord, TemplateAgentRoster, + }; + + fn template(id: &str, name: &str) -> ChannelTemplateRecord { + ChannelTemplateRecord { + id: id.to_string(), + name: name.to_string(), + description: None, + channel_type: "stream".to_string(), + visibility: "open".to_string(), + canvas_template: None, + agents: TemplateAgentRoster::default(), + is_builtin: false, + created_at: "2026-05-11T00:00:00Z".to_string(), + updated_at: "2026-05-11T00:00:00Z".to_string(), + } + } + + // ----------------------------------------------------------------------- + // sort_channel_templates + // ----------------------------------------------------------------------- + + #[test] + fn sort_alphabetical_case_insensitive() { + let mut templates = vec![ + template("3", "Zulu"), + template("1", "alpha"), + template("2", "Bravo"), + ]; + sort_channel_templates(&mut templates); + + let names: Vec<&str> = templates.iter().map(|t| t.name.as_str()).collect(); + assert_eq!(names, vec!["alpha", "Bravo", "Zulu"]); + } + + #[test] + fn sort_builtin_first() { + let mut builtin = template("b", "Zebra"); + builtin.is_builtin = true; + let custom = template("a", "Apple"); + + let mut templates = vec![custom, builtin]; + sort_channel_templates(&mut templates); + + assert!(templates[0].is_builtin); + assert_eq!(templates[0].name, "Zebra"); + assert_eq!(templates[1].name, "Apple"); + } + + #[test] + fn sort_breaks_ties_by_id() { + let mut templates = vec![template("b", "same"), template("a", "same")]; + sort_channel_templates(&mut templates); + + let ids: Vec<&str> = templates.iter().map(|t| t.id.as_str()).collect(); + assert_eq!(ids, vec!["a", "b"]); + } + + #[test] + fn sort_empty_is_noop() { + let mut templates: Vec = Vec::new(); + sort_channel_templates(&mut templates); + assert!(templates.is_empty()); + } + + // ----------------------------------------------------------------------- + // serialization round-trip + // ----------------------------------------------------------------------- + + #[test] + fn serialization_round_trip() { + use crate::templates::{TemplateAgentEntry, TemplateBackend, TemplateTeamEntry}; + + let original = ChannelTemplateRecord { + id: "t1".to_string(), + name: "Sprint Planning".to_string(), + description: Some("Template for sprint channels".to_string()), + channel_type: "stream".to_string(), + visibility: "private".to_string(), + canvas_template: Some("# {channel.name}\n\nSprint goals here".to_string()), + agents: TemplateAgentRoster { + personas: vec![TemplateAgentEntry { + persona_id: "builtin:kit".to_string(), + provider: Some("claude".to_string()), + model: Some("opus".to_string()), + role: Some("bot".to_string()), + backend: Some(TemplateBackend::Local), + }], + teams: vec![TemplateTeamEntry { + team_id: "team-1".to_string(), + provider: None, + model: None, + backend: Some(TemplateBackend::Provider { + id: "provider-1".to_string(), + }), + }], + }, + is_builtin: false, + created_at: "2026-05-11T00:00:00Z".to_string(), + updated_at: "2026-05-11T00:00:00Z".to_string(), + }; + + let json = serde_json::to_string_pretty(&original).unwrap(); + let parsed: ChannelTemplateRecord = serde_json::from_str(&json).unwrap(); + + assert_eq!(parsed.id, original.id); + assert_eq!(parsed.name, original.name); + assert_eq!(parsed.description, original.description); + assert_eq!(parsed.channel_type, original.channel_type); + assert_eq!(parsed.visibility, original.visibility); + assert_eq!(parsed.canvas_template, original.canvas_template); + assert_eq!(parsed.agents.personas.len(), 1); + assert_eq!(parsed.agents.teams.len(), 1); + assert_eq!(parsed.agents.personas[0].persona_id, "builtin:kit"); + assert_eq!( + parsed.agents.personas[0].provider.as_deref(), + Some("claude") + ); + assert_eq!(parsed.agents.teams[0].team_id, "team-1"); + assert!(!parsed.is_builtin); + } + + #[test] + fn deserialization_defaults() { + let json = r#"{"id":"t1","name":"Minimal","created_at":"2026-05-11T00:00:00Z","updated_at":"2026-05-11T00:00:00Z"}"#; + let parsed: ChannelTemplateRecord = serde_json::from_str(json).unwrap(); + + assert_eq!(parsed.channel_type, "stream"); + assert_eq!(parsed.visibility, "open"); + assert!(!parsed.is_builtin); + assert!(parsed.description.is_none()); + assert!(parsed.canvas_template.is_none()); + assert!(parsed.agents.personas.is_empty()); + assert!(parsed.agents.teams.is_empty()); + } + + // ----------------------------------------------------------------------- + // validate_channel_template_deletion + // ----------------------------------------------------------------------- + + #[test] + fn validate_deletion_rejects_builtin() { + let mut t = template("builtin-1", "Builtin"); + t.is_builtin = true; + + let err = validate_channel_template_deletion(&t).unwrap_err(); + assert_eq!(err, "Built-in templates cannot be deleted."); + } + + #[test] + fn validate_deletion_allows_custom() { + let t = template("custom-1", "Custom"); + assert!(validate_channel_template_deletion(&t).is_ok()); + } +} diff --git a/desktop/src-tauri/src/templates/types.rs b/desktop/src-tauri/src/templates/types.rs new file mode 100644 index 0000000000..c03a369205 --- /dev/null +++ b/desktop/src-tauri/src/templates/types.rs @@ -0,0 +1,104 @@ +use serde::{Deserialize, Serialize}; + +// --------------------------------------------------------------------------- +// Records (stored on disk + returned to frontend) +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChannelTemplateRecord { + pub id: String, + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(default = "default_channel_type")] + pub channel_type: String, + #[serde(default = "default_visibility")] + pub visibility: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub canvas_template: Option, + #[serde(default)] + pub agents: TemplateAgentRoster, + #[serde(default)] + pub is_builtin: bool, + pub created_at: String, + pub updated_at: String, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TemplateAgentRoster { + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub personas: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub teams: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TemplateAgentEntry { + pub persona_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub role: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub backend: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TemplateTeamEntry { + pub team_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub backend: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum TemplateBackend { + Local, + Provider { id: String }, +} + +fn default_channel_type() -> String { + "stream".to_string() +} + +fn default_visibility() -> String { + "open".to_string() +} + +// --------------------------------------------------------------------------- +// Request types (received from frontend) +// --------------------------------------------------------------------------- + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateChannelTemplateRequest { + pub name: String, + pub description: Option, + pub channel_type: Option, + pub visibility: Option, + pub canvas_template: Option, + #[serde(default)] + pub agents: TemplateAgentRoster, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateChannelTemplateRequest { + pub id: String, + pub name: String, + pub description: Option, + pub channel_type: Option, + pub visibility: Option, + pub canvas_template: Option, + #[serde(default)] + pub agents: TemplateAgentRoster, +} diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 953982bfd7..3526bec9a4 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -47,6 +47,7 @@ import { import { HuddleBar, HuddleProvider } from "@/features/huddle"; import { AppSidebar } from "@/features/sidebar/ui/AppSidebar"; import { useWorkspaces } from "@/features/workspaces/useWorkspaces"; +import { useApplyTemplate } from "@/features/channel-templates/useApplyTemplate"; import { relayClient } from "@/shared/api/relayClient"; import { useIdentityQuery } from "@/shared/api/hooks"; import { useDeferredStartup } from "@/shared/hooks/useDeferredStartup"; @@ -261,6 +262,8 @@ export function AppShell() { const createChannelMutation = useCreateChannelMutation(); const createForumMutation = useCreateChannelMutation(); + const { applyCanvas, applyAgents } = useApplyTemplate(); + const openDmMutation = useOpenDmMutation(); const hideDmMutation = useHideDmMutation(); const handleOpenBrowseChannels = React.useCallback(() => { @@ -345,6 +348,23 @@ export function AppShell() { }, ); + // Prevent webview file:/// navigation on file drop outside the composer. + // Scoped to file drags only (text drag-and-drop into inputs still works). + // Composer's onDrop fires first (React synthetic before window bubble). + React.useEffect(() => { + function preventNavigation(e: DragEvent) { + if (e.dataTransfer?.types.includes("Files")) { + e.preventDefault(); + } + } + window.addEventListener("dragover", preventNavigation); + window.addEventListener("drop", preventNavigation); + return () => { + window.removeEventListener("dragover", preventNavigation); + window.removeEventListener("drop", preventNavigation); + }; + }, []); + React.useEffect(() => { let isCancelled = false; @@ -599,6 +619,7 @@ export function AppShell() { name, visibility, ttlSeconds, + templateId, }) => { const createdChannel = await createChannelMutation.mutateAsync({ @@ -609,13 +630,16 @@ export function AppShell() { ttlSeconds, }); + await applyCanvas(templateId, createdChannel.id, name); await goChannel(createdChannel.id); + void applyAgents(templateId, createdChannel.id); }} onCreateForum={async ({ description, name, visibility, ttlSeconds, + templateId, }) => { const createdForum = await createForumMutation.mutateAsync({ name, @@ -625,7 +649,9 @@ export function AppShell() { ttlSeconds, }); + await applyCanvas(templateId, createdForum.id, name); await goChannel(createdForum.id); + void applyAgents(templateId, createdForum.id); }} onHideDm={handleHideDm} onOpenBrowseChannels={handleOpenBrowseChannels} diff --git a/desktop/src/features/agents/channelAgents.ts b/desktop/src/features/agents/channelAgents.ts index cf39a0506f..f50ff0788a 100644 --- a/desktop/src/features/agents/channelAgents.ts +++ b/desktop/src/features/agents/channelAgents.ts @@ -331,24 +331,30 @@ export async function createChannelManagedAgents( channelId: string, inputs: readonly CreateChannelManagedAgentInput[], ): Promise { + const results = await Promise.allSettled( + inputs.map((input) => createChannelManagedAgent(channelId, input)), + ); + const successes: CreateChannelManagedAgentResult[] = []; const failures: CreateChannelManagedAgentBatchFailure[] = []; - for (const input of inputs) { - try { - successes.push(await createChannelManagedAgent(channelId, input)); - } catch (error) { + for (let i = 0; i < results.length; i++) { + const result = results[i]; + const input = inputs[i]; + if (result.status === "fulfilled") { + successes.push(result.value); + } else { failures.push({ kind: input.personaId ? "persona" : "generic", name: input.name.trim() || "agent", personaId: input.personaId ?? null, - error: error instanceof Error ? error.message : "Failed to add agent.", + error: + result.reason instanceof Error + ? result.reason.message + : "Failed to add agent.", }); } } - return { - successes, - failures, - }; + return { successes, failures }; } diff --git a/desktop/src/features/channel-templates/hooks.ts b/desktop/src/features/channel-templates/hooks.ts new file mode 100644 index 0000000000..0f99f253d8 --- /dev/null +++ b/desktop/src/features/channel-templates/hooks.ts @@ -0,0 +1,103 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { + createChannelTemplate, + deleteChannelTemplate, + duplicateChannelTemplate, + listChannelTemplates, + updateChannelTemplate, +} from "@/shared/api/tauriChannelTemplates"; +import type { + ChannelTemplate, + CreateChannelTemplateInput, + UpdateChannelTemplateInput, +} from "@/shared/api/types"; + +export const channelTemplatesQueryKey = ["channel-templates"] as const; + +export function useChannelTemplatesQuery() { + return useQuery({ + queryKey: channelTemplatesQueryKey, + queryFn: listChannelTemplates, + staleTime: 30_000, + refetchInterval: 30_000, + }); +} + +export function useCreateChannelTemplateMutation() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (input: CreateChannelTemplateInput) => + createChannelTemplate(input), + onSuccess: (created) => { + queryClient.setQueryData( + channelTemplatesQueryKey, + (current) => { + const next = current ?? []; + return [ + created, + ...next.filter((template) => template.id !== created.id), + ]; + }, + ); + }, + onSettled: async () => { + await queryClient.invalidateQueries({ + queryKey: channelTemplatesQueryKey, + }); + }, + }); +} + +export function useUpdateChannelTemplateMutation() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (input: UpdateChannelTemplateInput) => + updateChannelTemplate(input), + onSettled: async () => { + await queryClient.invalidateQueries({ + queryKey: channelTemplatesQueryKey, + }); + }, + }); +} + +export function useDeleteChannelTemplateMutation() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (id: string) => deleteChannelTemplate(id), + onSettled: async () => { + await queryClient.invalidateQueries({ + queryKey: channelTemplatesQueryKey, + }); + }, + }); +} + +export function useDuplicateChannelTemplateMutation() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (id: string) => duplicateChannelTemplate(id), + onSuccess: (created) => { + queryClient.setQueryData( + channelTemplatesQueryKey, + (current) => { + const next = current ?? []; + return [ + created, + ...next.filter((template) => template.id !== created.id), + ]; + }, + ); + }, + onSettled: async () => { + await queryClient.invalidateQueries({ + queryKey: channelTemplatesQueryKey, + }); + }, + }); +} diff --git a/desktop/src/features/channel-templates/useApplyTemplate.ts b/desktop/src/features/channel-templates/useApplyTemplate.ts new file mode 100644 index 0000000000..a6598011e3 --- /dev/null +++ b/desktop/src/features/channel-templates/useApplyTemplate.ts @@ -0,0 +1,157 @@ +import { useQueryClient } from "@tanstack/react-query"; + +import { + createChannelManagedAgents, + type CreateChannelManagedAgentInput, +} from "@/features/agents/channelAgents"; +import { + useAcpProvidersQuery, + usePersonasQuery, + useTeamsQuery, +} from "@/features/agents/hooks"; +import { resolvePersonaProvider } from "@/features/agents/lib/resolvePersonaProvider"; +import { resolveTeamPersonas } from "@/features/agents/lib/teamPersonas"; +import { useLastRuntimeProvider } from "@/features/agents/lib/useLastRuntimeProvider"; +import { useChannelTemplatesQuery } from "@/features/channel-templates/hooks"; +import { setCanvas } from "@/shared/api/tauri"; +import type { ChannelTemplate } from "@/shared/api/types"; + +/** + * TemplateBackend omits `config` — supply an empty object for provider backends. + */ +function toManagedBackend( + backend: ChannelTemplate["agents"]["personas"][number]["backend"], +): CreateChannelManagedAgentInput["backend"] { + if (!backend || backend.type === "local") return { type: "local" }; + return { type: "provider", id: backend.id, config: {} }; +} + +export function useApplyTemplate() { + const queryClient = useQueryClient(); + const channelTemplatesQuery = useChannelTemplatesQuery(); + const acpProvidersQuery = useAcpProvidersQuery(); + const personasQuery = usePersonasQuery(); + const teamsQuery = useTeamsQuery(); + const { lastProviderId } = useLastRuntimeProvider(); + + async function applyCanvas( + templateId: string | undefined, + channelId: string, + channelName: string, + ) { + if (!templateId) return; + const template = channelTemplatesQuery.data?.find( + (t) => t.id === templateId, + ); + if (!template?.canvasTemplate) return; + const content = template.canvasTemplate + .replace(/\{channel\.name\}/g, channelName) + .replace(/\{template\.name\}/g, template.name); + try { + await setCanvas({ channelId, content }); + } catch { + // Canvas is best-effort — don't block navigation + } + } + + async function applyAgents( + templateId: string | undefined, + channelId: string, + ) { + if (!templateId) return; + const template = channelTemplatesQuery.data?.find( + (t) => t.id === templateId, + ); + if (!template) return; + const { personas: templatePersonas, teams: templateTeams } = + template.agents; + if (templatePersonas.length === 0 && templateTeams.length === 0) return; + + const allPersonas = personasQuery.data ?? []; + const allTeams = teamsQuery.data ?? []; + const providers = acpProvidersQuery.data ?? []; + if (providers.length === 0) return; // No runtimes — skip silently + + // Resolve default provider: user's last-used preference, or first available + const defaultProvider = + providers.find((p) => p.id === lastProviderId) ?? providers[0] ?? null; + if (!defaultProvider) return; + + const seenPersonaIds = new Set(); + const inputs: CreateChannelManagedAgentInput[] = []; + + // Direct personas from template + for (const entry of templatePersonas) { + const persona = allPersonas.find((p) => p.id === entry.personaId); + if (!persona) continue; + if (seenPersonaIds.has(persona.id)) continue; + seenPersonaIds.add(persona.id); + const resolved = resolvePersonaProvider( + entry.provider ?? persona.provider, + providers, + defaultProvider, + ); + inputs.push({ + provider: resolved.provider ?? defaultProvider, + name: persona.displayName, + personaId: persona.id, + systemPrompt: persona.systemPrompt, + avatarUrl: persona.avatarUrl ?? undefined, + model: entry.model ?? persona.model ?? undefined, + role: "bot", + backend: toManagedBackend(entry.backend), + }); + } + + // Team-expanded personas (skip dupes) + for (const teamEntry of templateTeams) { + const team = allTeams.find((t) => t.id === teamEntry.teamId); + if (!team) continue; + const { resolvedPersonas } = resolveTeamPersonas(team, allPersonas); + for (const persona of resolvedPersonas) { + if (seenPersonaIds.has(persona.id)) continue; + seenPersonaIds.add(persona.id); + const resolved = resolvePersonaProvider( + teamEntry.provider ?? persona.provider, + providers, + defaultProvider, + ); + inputs.push({ + provider: resolved.provider ?? defaultProvider, + name: persona.displayName, + personaId: persona.id, + systemPrompt: persona.systemPrompt, + avatarUrl: persona.avatarUrl ?? undefined, + model: teamEntry.model ?? persona.model ?? undefined, + role: "bot", + backend: toManagedBackend(teamEntry.backend), + }); + } + } + + if (inputs.length === 0) return; + + try { + const result = await createChannelManagedAgents(channelId, inputs); + if (result.failures.length > 0) { + const { toast } = await import("sonner"); + toast.warning( + result.failures.length === 1 + ? "1 agent from the template could not be created" + : `${result.failures.length} agents from the template could not be created`, + ); + } + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: ["channels", channelId, "members"], + }), + queryClient.invalidateQueries({ queryKey: ["managed-agents"] }), + queryClient.invalidateQueries({ queryKey: ["relay-agents"] }), + ]); + } catch { + // Agent creation is best-effort — don't block navigation + } + } + + return { applyCanvas, applyAgents }; +} diff --git a/desktop/src/features/channels/ui/AddChannelBotPersonasSection.tsx b/desktop/src/features/channels/ui/AddChannelBotPersonasSection.tsx index 7cf78d54a9..2a8015811d 100644 --- a/desktop/src/features/channels/ui/AddChannelBotPersonasSection.tsx +++ b/desktop/src/features/channels/ui/AddChannelBotPersonasSection.tsx @@ -72,6 +72,8 @@ type AddChannelBotPersonasSectionProps = { onTogglePersona: (personaId: string) => void; personas: AgentPersona[]; selectedPersonaIds: readonly string[]; + /** Whether to show the "Generic" chip. Defaults to true. */ + showGeneric?: boolean; }; export function AddChannelBotPersonasSection({ @@ -83,6 +85,7 @@ export function AddChannelBotPersonasSection({ onTogglePersona, personas, selectedPersonaIds, + showGeneric = true, }: AddChannelBotPersonasSectionProps) { return (
@@ -97,29 +100,31 @@ export function AddChannelBotPersonasSection({
- - -
- - - Generic - -
-
- - Add one custom agent with a channel-specific name and prompt. - -
+ {showGeneric ? ( + + +
+ + + Generic + +
+
+ + Add one custom agent with a channel-specific name and prompt. + +
+ ) : null} {personas.map((persona) => { const isSelected = selectedPersonaIds.includes(persona.id); const isInChannel = inChannelPersonaIds?.has(persona.id) ?? false; diff --git a/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx b/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx index 249bc35a1d..84abf7f7a4 100644 --- a/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx +++ b/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx @@ -64,7 +64,11 @@ export function AgentSessionThreadPanel({ <> {isOverlay && }
diff --git a/desktop/src/features/forum/ui/ForumView.tsx b/desktop/src/features/forum/ui/ForumView.tsx index e9aa44417a..d9901ec64b 100644 --- a/desktop/src/features/forum/ui/ForumView.tsx +++ b/desktop/src/features/forum/ui/ForumView.tsx @@ -161,6 +161,7 @@ export function ForumView({ setIsComposerOpen(false); }} placeholder="Write your post..." + profiles={profiles} /> ) : ( + + + {templatesQuery.isLoading ? ( +

+ Loading templates... +

+ ) : templates.length === 0 ? ( +
+ No templates yet. Create one to save a reusable channel configuration. +
+ ) : ( +
+ {templates.map((template) => ( + setDeleteTarget(template)} + onDuplicate={() => handleDuplicate(template)} + onEdit={() => setEditingTemplate(template)} + template={template} + /> + ))} +
+ )} + + + + {editingTemplate ? ( + { + if (!open) setEditingTemplate(null); + }} + open + template={editingTemplate} + /> + ) : null} + + { + if (!open) setDeleteTarget(null); + }} + open={deleteTarget !== null} + > + + + Delete template + + Are you sure you want to delete "{deleteTarget?.name}"? + This action cannot be undone. + + + + Cancel + + Delete + + + + + + ); +} + +// --------------------------------------------------------------------------- +// TemplateRow +// --------------------------------------------------------------------------- + +function TemplateRow({ + template, + onEdit, + onDuplicate, + onDelete, +}: { + template: ChannelTemplate; + onEdit: () => void; + onDuplicate: () => void; + onDelete: () => void; +}) { + const agentCount = + template.agents.personas.length + template.agents.teams.length; + + return ( +
+
+
+ {template.name} + {template.isBuiltin ? ( + + built-in + + ) : null} +
+ {template.description ? ( +

+ {template.description} +

+ ) : null} +
+ {agentCount > 0 ? ( + + + {agentCount} {agentCount === 1 ? "agent" : "agents"} + + ) : null} + {template.canvasTemplate ? ( + + + canvas + + ) : null} +
+
+ + + + + + + + + Edit + + + + Duplicate + + {!template.isBuiltin ? ( + + + Delete + + ) : null} + + +
+ ); +} + +// --------------------------------------------------------------------------- +// TemplateFormDialog (create + edit) +// --------------------------------------------------------------------------- + +function TemplateFormDialog({ + template, + open, + onOpenChange, +}: { + template: ChannelTemplate | null; + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const isEditing = template !== null; + const createMutation = useCreateChannelTemplateMutation(); + const updateMutation = useUpdateChannelTemplateMutation(); + const personasQuery = usePersonasQuery(); + const teamsQuery = useTeamsQuery(); + const providersQuery = useAcpProvidersQuery(); + const providers = providersQuery.data ?? []; + + const [name, setName] = React.useState(""); + const [description, setDescription] = React.useState(""); + const [canvasTemplate, setCanvasTemplate] = React.useState(""); + const [selectedPersonaIds, setSelectedPersonaIds] = React.useState( + [], + ); + const [selectedTeamIds, setSelectedTeamIds] = React.useState([]); + const [personaProviders, setPersonaProviders] = React.useState< + Record + >({}); + const [teamProviders, setTeamProviders] = React.useState< + Record + >({}); + + const isPending = createMutation.isPending || updateMutation.isPending; + + React.useEffect(() => { + if (!open) return; + if (template) { + setName(template.name); + setDescription(template.description ?? ""); + setCanvasTemplate(template.canvasTemplate ?? ""); + setSelectedPersonaIds(template.agents.personas.map((p) => p.personaId)); + setSelectedTeamIds(template.agents.teams.map((t) => t.teamId)); + const pProviders: Record = {}; + for (const p of template.agents.personas) { + if (p.provider) pProviders[p.personaId] = p.provider; + } + setPersonaProviders(pProviders); + const tProviders: Record = {}; + for (const t of template.agents.teams) { + if (t.provider) tProviders[t.teamId] = t.provider; + } + setTeamProviders(tProviders); + } else { + setName(""); + setDescription(""); + setCanvasTemplate(""); + setSelectedPersonaIds([]); + setSelectedTeamIds([]); + setPersonaProviders({}); + setTeamProviders({}); + } + }, [open, template]); + + function handleSubmit(event: React.FormEvent) { + event.preventDefault(); + const trimmedName = name.trim(); + if (!trimmedName) return; + + const agents = { + personas: selectedPersonaIds.map((personaId) => ({ + personaId, + provider: personaProviders[personaId] || null, + model: null, + role: null, + backend: null, + })), + teams: selectedTeamIds.map((teamId) => ({ + teamId, + provider: teamProviders[teamId] || null, + model: null, + backend: null, + })), + }; + + if (isEditing) { + const input: UpdateChannelTemplateInput = { + id: template.id, + name: trimmedName, + description: description.trim() || undefined, + canvasTemplate: canvasTemplate.trim() || undefined, + agents, + }; + + updateMutation.mutate(input, { + onSuccess: () => { + toast.success(`Updated "${trimmedName}"`); + onOpenChange(false); + }, + onError: (error) => { + toast.error( + error instanceof Error ? error.message : "Failed to update", + ); + }, + }); + } else { + const input: CreateChannelTemplateInput = { + name: trimmedName, + description: description.trim() || undefined, + canvasTemplate: canvasTemplate.trim() || undefined, + agents, + }; + + createMutation.mutate(input, { + onSuccess: () => { + toast.success(`Created "${trimmedName}"`); + onOpenChange(false); + }, + onError: (error) => { + toast.error( + error instanceof Error ? error.message : "Failed to create", + ); + }, + }); + } + } + + function handleTogglePersona(personaId: string) { + setSelectedPersonaIds((prev) => { + if (prev.includes(personaId)) { + setPersonaProviders((pp) => { + const next = { ...pp }; + delete next[personaId]; + return next; + }); + return prev.filter((id) => id !== personaId); + } + return [...prev, personaId]; + }); + } + + function handleToggleTeam(teamId: string) { + setSelectedTeamIds((prev) => { + if (prev.includes(teamId)) { + setTeamProviders((tp) => { + const next = { ...tp }; + delete next[teamId]; + return next; + }); + return prev.filter((id) => id !== teamId); + } + return [...prev, teamId]; + }); + } + + return ( + + + + + + } + > + + {/* Name */} +
+ + setName(e.target.value)} + placeholder="Sprint Planning" + value={name} + /> +
+ + {/* Description */} +
+ +