Merge remote-tracking branch 'origin/main' into inbox

This commit is contained in:
Thomas Petersen
2026-05-11 19:50:16 -04:00
38 changed files with 2457 additions and 227 deletions

View File

@@ -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());
}

View File

@@ -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` |

View File

@@ -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)?),

View File

@@ -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

View File

@@ -17,6 +17,7 @@ pub struct AppState {
/// init and takes priority over env vars and compile-time defaults.
pub relay_url_override: Mutex<Option<String>>,
pub managed_agents_store_lock: Mutex<()>,
pub channel_templates_store_lock: Mutex<()>,
pub managed_agent_processes: Mutex<HashMap<String, ManagedAgentProcess>>,
pub huddle_state: Mutex<HuddleState>,
/// 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),

View File

@@ -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<String, String> {
let trimmed = value.trim();
if trimmed.is_empty() {
return Err(format!("{label} is required"));
}
Ok(trimmed.to_string())
}
fn trim_optional(value: Option<String>) -> Option<String> {
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<Vec<ChannelTemplateRecord>, 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<ChannelTemplateRecord, String> {
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<ChannelTemplateRecord, String> {
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<ChannelTemplateRecord, String> {
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)
}

View File

@@ -8,11 +8,24 @@ pub async fn save_json_with_dialog(
app: &AppHandle,
suggested_filename: &str,
data: &[u8],
) -> Result<bool, String> {
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<bool, String> {
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);

View File

@@ -109,7 +109,7 @@ const ALLOWED_MIME: &[&str] = &[
"video/mp4",
];
fn detect_and_validate_mime(body: &[u8]) -> Result<String, String> {
pub(crate) fn detect_and_validate_mime(body: &[u8]) -> Result<String, String> {
let mime = infer::get(body)
.map(|t| t.mime_type().to_string())
.unwrap_or_else(|| "application/octet-stream".to_string());

View File

@@ -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<bool, String> {
// 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/"));
}
}

View File

@@ -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::*;

View File

@@ -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,

View File

@@ -0,0 +1,5 @@
mod storage;
mod types;
pub use storage::*;
pub use types::*;

View File

@@ -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<PathBuf, String> {
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<PathBuf, String> {
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<Vec<ChannelTemplateRecord>, 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::<Vec<ChannelTemplateRecord>>(&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<ChannelTemplateRecord> = 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());
}
}

View File

@@ -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<String>,
#[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<String>,
#[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<TemplateAgentEntry>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub teams: Vec<TemplateTeamEntry>,
}
#[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<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub role: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub backend: Option<TemplateBackend>,
}
#[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<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub backend: Option<TemplateBackend>,
}
#[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<String>,
pub channel_type: Option<String>,
pub visibility: Option<String>,
pub canvas_template: Option<String>,
#[serde(default)]
pub agents: TemplateAgentRoster,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateChannelTemplateRequest {
pub id: String,
pub name: String,
pub description: Option<String>,
pub channel_type: Option<String>,
pub visibility: Option<String>,
pub canvas_template: Option<String>,
#[serde(default)]
pub agents: TemplateAgentRoster,
}

View File

@@ -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}

View File

@@ -331,24 +331,30 @@ export async function createChannelManagedAgents(
channelId: string,
inputs: readonly CreateChannelManagedAgentInput[],
): Promise<CreateChannelManagedAgentsResult> {
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 };
}

View File

@@ -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<ChannelTemplate[]>(
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<ChannelTemplate[]>(
channelTemplatesQueryKey,
(current) => {
const next = current ?? [];
return [
created,
...next.filter((template) => template.id !== created.id),
];
},
);
},
onSettled: async () => {
await queryClient.invalidateQueries({
queryKey: channelTemplatesQueryKey,
});
},
});
}

View File

@@ -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<string>();
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 };
}

View File

@@ -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 (
<div className="space-y-3">
@@ -97,29 +100,31 @@ export function AddChannelBotPersonasSection({
<TooltipProvider delayDuration={150}>
<div className="flex flex-wrap gap-2">
<Tooltip>
<TooltipTrigger asChild>
<div>
<SelectionChipButton
disabled={!canToggleSelections}
label="Generic"
onClick={onToggleGeneric}
selected={includeGeneric}
>
<Bot
className={cn(
"h-4 w-4",
includeGeneric ? "text-primary" : "text-current",
)}
/>
Generic
</SelectionChipButton>
</div>
</TooltipTrigger>
<TooltipContent className="max-w-xs text-left">
Add one custom agent with a channel-specific name and prompt.
</TooltipContent>
</Tooltip>
{showGeneric ? (
<Tooltip>
<TooltipTrigger asChild>
<div>
<SelectionChipButton
disabled={!canToggleSelections}
label="Generic"
onClick={onToggleGeneric}
selected={includeGeneric}
>
<Bot
className={cn(
"h-4 w-4",
includeGeneric ? "text-primary" : "text-current",
)}
/>
Generic
</SelectionChipButton>
</div>
</TooltipTrigger>
<TooltipContent className="max-w-xs text-left">
Add one custom agent with a channel-specific name and prompt.
</TooltipContent>
</Tooltip>
) : null}
{personas.map((persona) => {
const isSelected = selectedPersonaIds.includes(persona.id);
const isInChannel = inChannelPersonaIds?.has(persona.id) ?? false;

View File

@@ -64,7 +64,11 @@ export function AgentSessionThreadPanel({
<>
{isOverlay && <OverlayPanelBackdrop onClose={onClose} />}
<aside
className={cn(PANEL_BASE_CLASS, isOverlay && PANEL_OVERLAY_CLASS)}
className={cn(
PANEL_BASE_CLASS,
!isOverlay && "pt-11",
isOverlay && PANEL_OVERLAY_CLASS,
)}
data-testid="agent-session-thread-panel"
style={{ width: `${widthPx}px` }}
>

View File

@@ -14,13 +14,17 @@ import {
} from "@/features/messages/lib/normalizeMentionClipboard";
import { useRichTextEditor } from "@/features/messages/lib/useRichTextEditor";
import { ChannelAutocomplete } from "@/features/messages/ui/ChannelAutocomplete";
import { ComposerAttachments } from "@/features/messages/ui/ComposerAttachments";
import {
ComposerAttachments,
DropZoneOverlay,
} from "@/features/messages/ui/ComposerAttachments";
import {
MentionAutocomplete,
type MentionSuggestion,
} from "@/features/messages/ui/MentionAutocomplete";
import { MessageComposerToolbar } from "@/features/messages/ui/MessageComposerToolbar";
import type { ChannelMember } from "@/shared/api/types";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import { Button } from "@/shared/ui/button";
type ForumComposerProps = {
@@ -38,6 +42,7 @@ type ForumComposerProps = {
) => undefined | Promise<unknown>;
/** When true, autocomplete renders below the input (for top-of-view composers). */
autocompleteBelow?: boolean;
profiles?: UserProfileLookup;
};
export function ForumComposer({
@@ -49,6 +54,7 @@ export function ForumComposer({
onCancel,
onSubmit,
autocompleteBelow = false,
profiles,
}: ForumComposerProps) {
const [content, setContent] = React.useState("");
const contentRef = React.useRef(content);
@@ -62,7 +68,7 @@ export function ForumComposer({
setIsFormattingOpen(pressed);
}, []);
const mentions = useMentions(channelId, members);
const mentions = useMentions(channelId, members, profiles);
const channelLinks = useChannelLinks();
const media = useMediaUpload();
@@ -334,12 +340,15 @@ export function ForumComposer({
return (
<form
className="relative rounded-2xl border border-input bg-card px-3 py-2 sm:px-4"
onDragEnter={media.handleDragEnter}
onDragLeave={media.handleDragLeave}
onDragOver={media.handleDragOver}
onDrop={(e) => {
void media.handleDrop(e);
}}
onSubmit={handleSubmit}
>
{media.isDragOver && <DropZoneOverlay />}
<ChannelAutocomplete
onSelect={applyChannelInsert}
position={autocompletePosition}

View File

@@ -284,6 +284,7 @@ export function ForumThreadPanel({
isSending={isSendingReply}
onSubmit={onReply}
placeholder="Reply to this post..."
profiles={profiles}
/>
</div>
</div>

View File

@@ -161,6 +161,7 @@ export function ForumView({
setIsComposerOpen(false);
}}
placeholder="Write your post..."
profiles={profiles}
/>
) : (
<button

View File

@@ -33,12 +33,23 @@ type UploadState = {
message?: string;
};
/** True when the drag payload contains files (not plain text or URLs). */
function isFileDrag(event: React.DragEvent): boolean {
return event.dataTransfer?.types.includes("Files") ?? false;
}
export function useMediaUpload() {
const [uploadState, setUploadState] = React.useState<UploadState>({
status: "idle",
});
/** Number of files currently in-flight. */
const [uploadingCount, setUploadingCount] = React.useState(0);
// ── Drag-over visual indicator state ───────────────────────────────
const [isDragOver, setIsDragOver] = React.useState(false);
/** Tracks nested dragenter/dragleave pairs so we only flip `isDragOver`
* when the pointer truly enters or leaves the drop target. */
const dragDepthRef = React.useRef(0);
/**
* Internal slots array — may contain `null` for reserved-but-pending uploads.
* Consumers see the filtered `pendingImeta` (nulls stripped) so the public
@@ -117,6 +128,8 @@ export function useMediaUpload() {
const handleDrop = React.useCallback(
async (event: React.DragEvent<HTMLFormElement>) => {
event.preventDefault();
dragDepthRef.current = 0;
setIsDragOver(false);
const files = Array.from(event.dataTransfer.files);
if (files.length === 0) return;
@@ -156,6 +169,31 @@ export function useMediaUpload() {
[reserveSlots, fillSlot, onUploadError],
);
const handleDragEnter = React.useCallback(
(event: React.DragEvent<HTMLFormElement>) => {
if (!isFileDrag(event)) return;
event.preventDefault();
dragDepthRef.current += 1;
if (dragDepthRef.current === 1) {
setIsDragOver(true);
}
},
[],
);
const handleDragLeave = React.useCallback(
(event: React.DragEvent<HTMLFormElement>) => {
if (!isFileDrag(event)) return;
event.preventDefault();
dragDepthRef.current -= 1;
if (dragDepthRef.current <= 0) {
dragDepthRef.current = 0;
setIsDragOver(false);
}
},
[],
);
const handleDragOver = React.useCallback(
(event: React.DragEvent<HTMLFormElement>) => {
event.preventDefault();
@@ -163,6 +201,23 @@ export function useMediaUpload() {
[],
);
// Reset drag state when the drag operation ends outside the form (e.g. user
// drops on another part of the window, presses Escape, or drags out of the
// browser). Without this, `isDragOver` can stick if the browser doesn't fire
// a balanced set of dragenter/dragleave events.
React.useEffect(() => {
function resetDragState() {
dragDepthRef.current = 0;
setIsDragOver(false);
}
window.addEventListener("drop", resetDragState);
window.addEventListener("dragend", resetDragState);
return () => {
window.removeEventListener("drop", resetDragState);
window.removeEventListener("dragend", resetDragState);
};
}, []);
const handlePaste = React.useCallback(
async (event: {
clipboardData: DataTransfer;
@@ -235,10 +290,13 @@ export function useMediaUpload() {
const isUploading = uploadingCount > 0;
return {
handleDragEnter,
handleDragLeave,
handleDragOver,
handleDrop,
handlePaperclip,
handlePaste,
isDragOver,
isUploading,
pendingImeta,
pendingImetaRef,

View File

@@ -8,6 +8,17 @@ import { rewriteRelayUrl } from "@/shared/lib/mediaUrl";
import { shortHash } from "@/features/messages/lib/useMediaUpload";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
/** Dashed-border overlay shown when a file is dragged over the composer form. */
export function DropZoneOverlay() {
return (
<div className="pointer-events-none absolute inset-0 z-10 flex items-center justify-center rounded-2xl border-2 border-dashed border-primary bg-primary/10">
<span className="text-sm font-medium text-primary">
Drop files to upload
</span>
</div>
);
}
type ComposerAttachmentsProps = {
attachments: BlobDescriptor[];
isUploading?: boolean;

View File

@@ -23,7 +23,7 @@ import { useTypingBroadcast } from "@/features/messages/useTypingBroadcast";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import { ChannelAutocomplete } from "./ChannelAutocomplete";
import { ComposerAttachments } from "./ComposerAttachments";
import { ComposerAttachments, DropZoneOverlay } from "./ComposerAttachments";
import { EmojiAutocomplete } from "./EmojiAutocomplete";
import {
MentionAutocomplete,
@@ -559,6 +559,8 @@ export function MessageComposer({
<form
className="relative isolate rounded-2xl border border-border/50 bg-background/70 px-3 pb-2 pt-3 shadow-[0_4px_24px_rgba(0,0,0,0.08)] backdrop-blur-xl supports-[backdrop-filter]:bg-background/55 dark:shadow-[0_4px_24px_rgba(0,0,0,0.35)] sm:px-4"
data-testid="message-composer"
onDragEnter={media.handleDragEnter}
onDragLeave={media.handleDragLeave}
onDragOver={media.handleDragOver}
onDrop={(e) => {
void media.handleDrop(e);
@@ -567,6 +569,7 @@ export function MessageComposer({
handleSubmit(event);
}}
>
{media.isDragOver && <DropZoneOverlay />}
<EmojiAutocomplete
onSelect={applyEmojiInsert}
selectedIndex={emojiAutocomplete.emojiSelectedIndex}

View File

@@ -308,6 +308,7 @@ export function MessageThreadPanel({
onEditSave={onEditSave}
onSend={onSend}
placeholder={`Reply in thread to ${threadHead.author}`}
profiles={profiles}
replyTarget={composerReplyTarget}
toolbarExtraActions={toolbarExtraActions}
typingParentEventId={threadHead.id}

View File

@@ -117,7 +117,11 @@ export function UserProfilePanel({
<>
{isOverlay && <OverlayPanelBackdrop onClose={onClose} />}
<aside
className={cn(PANEL_BASE_CLASS, isOverlay && PANEL_OVERLAY_CLASS)}
className={cn(
PANEL_BASE_CLASS,
!isOverlay && "pt-11",
isOverlay && PANEL_OVERLAY_CLASS,
)}
data-testid="user-profile-panel"
style={{ width: `${widthPx}px` }}
>

View File

@@ -406,6 +406,7 @@ export function PulseView({ currentPubkey }: PulseViewProps) {
onSubmit={(content, mentionPubkeys, mediaTags) =>
publishMutation.mutateAsync({ content, mentionPubkeys, mediaTags })
}
profiles={mentionProfiles}
/>
</div>
</div>

View File

@@ -0,0 +1,782 @@
import {
Copy,
MessageSquare,
MoreHorizontal,
Pencil,
Plus,
Trash2,
Users,
} from "lucide-react";
import * as React from "react";
import { toast } from "sonner";
import {
useAcpProvidersQuery,
usePersonasQuery,
useTeamsQuery,
} from "@/features/agents/hooks";
import {
useChannelTemplatesQuery,
useCreateChannelTemplateMutation,
useDeleteChannelTemplateMutation,
useDuplicateChannelTemplateMutation,
useUpdateChannelTemplateMutation,
} from "@/features/channel-templates/hooks";
import { AddChannelBotPersonasSection } from "@/features/channels/ui/AddChannelBotPersonasSection";
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
import type {
AcpProvider,
AgentPersona,
AgentTeam,
ChannelTemplate,
CreateChannelTemplateInput,
UpdateChannelTemplateInput,
} from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/ui/alert-dialog";
import { Badge } from "@/shared/ui/badge";
import { Button } from "@/shared/ui/button";
import { ChooserDialogContent } from "@/shared/ui/chooser-dialog-content";
import { Dialog } from "@/shared/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/shared/ui/dropdown-menu";
import { Input } from "@/shared/ui/input";
import { Textarea } from "@/shared/ui/textarea";
// ---------------------------------------------------------------------------
// ChannelTemplatesSettingsCard
// ---------------------------------------------------------------------------
export function ChannelTemplatesSettingsCard() {
const templatesQuery = useChannelTemplatesQuery();
const deleteMutation = useDeleteChannelTemplateMutation();
const duplicateMutation = useDuplicateChannelTemplateMutation();
const [editingTemplate, setEditingTemplate] =
React.useState<ChannelTemplate | null>(null);
const [isCreateOpen, setIsCreateOpen] = React.useState(false);
const [deleteTarget, setDeleteTarget] =
React.useState<ChannelTemplate | null>(null);
const templates = templatesQuery.data ?? [];
function handleDuplicate(template: ChannelTemplate) {
duplicateMutation.mutate(template.id, {
onSuccess: (created) => {
toast.success(`Duplicated as "${created.name}"`);
},
onError: (error) => {
toast.error(
error instanceof Error ? error.message : "Failed to duplicate",
);
},
});
}
function handleDelete() {
if (!deleteTarget) return;
deleteMutation.mutate(deleteTarget.id, {
onSuccess: () => {
toast.success(`Deleted "${deleteTarget.name}"`);
setDeleteTarget(null);
},
onError: (error) => {
toast.error(
error instanceof Error ? error.message : "Failed to delete",
);
},
});
}
return (
<section className="min-w-0" data-testid="settings-channel-templates">
<div className="mb-3 flex items-start justify-between gap-4 min-w-0">
<div className="min-w-0">
<h2 className="text-sm font-semibold tracking-tight">
Channel Templates
</h2>
<p className="text-sm text-muted-foreground">
Save reusable channel configurations and apply them when creating
new channels.
</p>
</div>
<Button
className="shrink-0"
onClick={() => setIsCreateOpen(true)}
size="sm"
type="button"
variant="outline"
>
<Plus className="mr-1.5 h-3.5 w-3.5" />
Create
</Button>
</div>
{templatesQuery.isLoading ? (
<p className="py-6 text-center text-sm text-muted-foreground">
Loading templates...
</p>
) : templates.length === 0 ? (
<div className="rounded-xl border border-dashed border-border/70 bg-muted/15 px-4 py-8 text-center text-sm text-muted-foreground">
No templates yet. Create one to save a reusable channel configuration.
</div>
) : (
<div className="space-y-1">
{templates.map((template) => (
<TemplateRow
key={template.id}
onDelete={() => setDeleteTarget(template)}
onDuplicate={() => handleDuplicate(template)}
onEdit={() => setEditingTemplate(template)}
template={template}
/>
))}
</div>
)}
<TemplateFormDialog
onOpenChange={setIsCreateOpen}
open={isCreateOpen}
template={null}
/>
{editingTemplate ? (
<TemplateFormDialog
onOpenChange={(open) => {
if (!open) setEditingTemplate(null);
}}
open
template={editingTemplate}
/>
) : null}
<AlertDialog
onOpenChange={(open) => {
if (!open) setDeleteTarget(null);
}}
open={deleteTarget !== null}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete template</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete &quot;{deleteTarget?.name}&quot;?
This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={handleDelete}
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</section>
);
}
// ---------------------------------------------------------------------------
// 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 (
<div className="group flex items-center gap-3 rounded-lg px-3 py-2.5 hover:bg-muted/50">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate text-sm font-medium">{template.name}</span>
{template.isBuiltin ? (
<Badge className="shrink-0 text-[10px] uppercase" variant="outline">
built-in
</Badge>
) : null}
</div>
{template.description ? (
<p className="mt-0.5 truncate text-xs text-muted-foreground">
{template.description}
</p>
) : null}
<div className="mt-1 flex items-center gap-3 text-xs text-muted-foreground">
{agentCount > 0 ? (
<span className="flex items-center gap-1">
<Users className="h-3 w-3" />
{agentCount} {agentCount === 1 ? "agent" : "agents"}
</span>
) : null}
{template.canvasTemplate ? (
<span className="flex items-center gap-1">
<MessageSquare className="h-3 w-3" />
canvas
</span>
) : null}
</div>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
className="h-7 w-7 shrink-0 opacity-0 group-hover:opacity-100"
size="icon"
type="button"
variant="ghost"
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={onEdit}>
<Pencil className="mr-2 h-3.5 w-3.5" />
Edit
</DropdownMenuItem>
<DropdownMenuItem onClick={onDuplicate}>
<Copy className="mr-2 h-3.5 w-3.5" />
Duplicate
</DropdownMenuItem>
{!template.isBuiltin ? (
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={onDelete}
>
<Trash2 className="mr-2 h-3.5 w-3.5" />
Delete
</DropdownMenuItem>
) : null}
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}
// ---------------------------------------------------------------------------
// 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<string[]>(
[],
);
const [selectedTeamIds, setSelectedTeamIds] = React.useState<string[]>([]);
const [personaProviders, setPersonaProviders] = React.useState<
Record<string, string>
>({});
const [teamProviders, setTeamProviders] = React.useState<
Record<string, string>
>({});
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<string, string> = {};
for (const p of template.agents.personas) {
if (p.provider) pProviders[p.personaId] = p.provider;
}
setPersonaProviders(pProviders);
const tProviders: Record<string, string> = {};
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 (
<Dialog onOpenChange={onOpenChange} open={open}>
<ChooserDialogContent
className="max-w-lg"
title={isEditing ? "Edit template" : "Create template"}
description={
isEditing
? "Update this channel template configuration."
: "Save a reusable channel configuration."
}
footer={
<div className="flex w-full items-center justify-end gap-2">
<Button
disabled={isPending}
onClick={() => onOpenChange(false)}
type="button"
variant="ghost"
>
Cancel
</Button>
<Button
disabled={isPending || name.trim().length === 0}
form="template-form"
type="submit"
>
{isPending
? isEditing
? "Saving..."
: "Creating..."
: isEditing
? "Save"
: "Create"}
</Button>
</div>
}
>
<form className="space-y-5" id="template-form" onSubmit={handleSubmit}>
{/* Name */}
<div className="space-y-1.5">
<label
className="text-sm font-medium text-foreground"
htmlFor="template-name"
>
Name
</label>
<Input
autoComplete="off"
disabled={isPending}
id="template-name"
onChange={(e) => setName(e.target.value)}
placeholder="Sprint Planning"
value={name}
/>
</div>
{/* Description */}
<div className="space-y-1.5">
<label
className="text-sm font-medium text-foreground"
htmlFor="template-description"
>
Description{" "}
<span className="font-normal text-muted-foreground">
(optional)
</span>
</label>
<Textarea
className="min-h-16 resize-none"
disabled={isPending}
id="template-description"
onChange={(e) => setDescription(e.target.value)}
placeholder="What this template is for"
rows={2}
value={description}
/>
</div>
{/* Canvas Template */}
<div className="space-y-1.5">
<label
className="text-sm font-medium text-foreground"
htmlFor="template-canvas"
>
Canvas template{" "}
<span className="font-normal text-muted-foreground">
(optional)
</span>
</label>
<Textarea
className="min-h-20 resize-none font-mono text-xs"
disabled={isPending}
id="template-canvas"
onChange={(e) => setCanvasTemplate(e.target.value)}
placeholder="Canvas content here..."
rows={4}
value={canvasTemplate}
/>
<p className="text-xs text-muted-foreground">
Use {"{channel.name}"} and {"{template.name}"} as placeholders.
</p>
</div>
{/* Agent Personas */}
<AddChannelBotPersonasSection
canToggleSelections={!isPending}
includeGeneric={false}
isLoading={personasQuery.isLoading}
onToggleGeneric={() => {}}
onTogglePersona={handleTogglePersona}
personas={personasQuery.data ?? []}
selectedPersonaIds={selectedPersonaIds}
showGeneric={false}
/>
{/* Agent Teams */}
<TemplateTeamSelector
isPending={isPending}
onToggleTeam={handleToggleTeam}
selectedTeamIds={selectedTeamIds}
teams={teamsQuery.data ?? []}
isLoading={teamsQuery.isLoading}
/>
{/* Provider assignments */}
<ProviderAssignments
isPending={isPending}
personas={personasQuery.data ?? []}
personaProviders={personaProviders}
providers={providers}
providersLoading={providersQuery.isLoading}
selectedPersonaIds={selectedPersonaIds}
selectedTeamIds={selectedTeamIds}
teamProviders={teamProviders}
teams={teamsQuery.data ?? []}
onPersonaProviderChange={(personaId, providerId) =>
setPersonaProviders((prev) => ({
...prev,
[personaId]: providerId,
}))
}
onTeamProviderChange={(teamId, providerId) =>
setTeamProviders((prev) => ({ ...prev, [teamId]: providerId }))
}
/>
</form>
</ChooserDialogContent>
</Dialog>
);
}
// ---------------------------------------------------------------------------
// TemplateTeamSelector — chip-based team toggle for templates
// ---------------------------------------------------------------------------
function TemplateTeamSelector({
isPending,
isLoading,
onToggleTeam,
selectedTeamIds,
teams,
}: {
isPending: boolean;
isLoading: boolean;
onToggleTeam: (teamId: string) => void;
selectedTeamIds: readonly string[];
teams: readonly { id: string; name: string }[];
}) {
if (isLoading || teams.length === 0) {
return null;
}
return (
<div className="space-y-3">
<div>
<div className="text-sm font-medium">Teams</div>
<p className="text-xs text-muted-foreground">
Select teams to include in this template.
</p>
</div>
<div className="flex flex-wrap gap-2">
{teams.map((team) => {
const isSelected = selectedTeamIds.includes(team.id);
return (
<button
key={team.id}
type="button"
aria-pressed={isSelected}
className={cn(
"inline-flex min-h-9 items-center gap-2 rounded-full border px-3 py-1.5 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
isSelected
? "border-primary bg-primary/10 text-foreground"
: "border-border/80 bg-background/60 text-muted-foreground hover:bg-accent hover:text-accent-foreground",
isPending && "cursor-not-allowed opacity-50",
)}
disabled={isPending}
onClick={() => onToggleTeam(team.id)}
>
<Users
className={cn(
"h-4 w-4",
isSelected ? "text-primary" : "text-current",
)}
/>
{team.name}
</button>
);
})}
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// ProviderAssignments — per-entry provider dropdowns for selected agents
// ---------------------------------------------------------------------------
function ProviderAssignments({
isPending,
onPersonaProviderChange,
onTeamProviderChange,
personas,
personaProviders,
providers,
providersLoading,
selectedPersonaIds,
selectedTeamIds,
teamProviders,
teams,
}: {
isPending: boolean;
onPersonaProviderChange: (personaId: string, providerId: string) => void;
onTeamProviderChange: (teamId: string, providerId: string) => void;
personas: AgentPersona[];
personaProviders: Record<string, string>;
providers: AcpProvider[];
providersLoading: boolean;
selectedPersonaIds: readonly string[];
selectedTeamIds: readonly string[];
teamProviders: Record<string, string>;
teams: readonly AgentTeam[];
}) {
const hasSelections =
selectedPersonaIds.length > 0 || selectedTeamIds.length > 0;
if (!hasSelections) return null;
const selectedPersonas = personas.filter((p) =>
selectedPersonaIds.includes(p.id),
);
const selectedTeams = teams.filter((t) => selectedTeamIds.includes(t.id));
return (
<div className="space-y-3">
<div>
<div className="text-sm font-medium">Runtime providers</div>
<p className="text-xs text-muted-foreground">
Choose which runtime to use for each agent.
</p>
</div>
{providersLoading ? (
<p className="text-xs text-muted-foreground">Discovering runtimes...</p>
) : providers.length === 0 ? (
<p className="text-xs text-muted-foreground">
No ACP runtimes detected. Install one to assign providers.
</p>
) : (
<div className="space-y-2">
{selectedPersonas.map((persona) => (
<ProviderRow
key={persona.id}
avatarUrl={persona.avatarUrl}
disabled={isPending}
label={persona.displayName}
onChange={(providerId) =>
onPersonaProviderChange(persona.id, providerId)
}
providers={providers}
value={personaProviders[persona.id] ?? ""}
/>
))}
{selectedTeams.map((team) => (
<ProviderRow
key={team.id}
disabled={isPending}
icon="team"
label={team.name}
onChange={(providerId) =>
onTeamProviderChange(team.id, providerId)
}
providers={providers}
value={teamProviders[team.id] ?? ""}
/>
))}
</div>
)}
</div>
);
}
function ProviderRow({
avatarUrl,
disabled,
icon,
label,
onChange,
providers,
value,
}: {
avatarUrl?: string | null | undefined;
disabled: boolean;
icon?: "team";
label: string;
onChange: (providerId: string) => void;
providers: AcpProvider[];
value: string;
}) {
return (
<div className="flex items-center gap-2">
<div className="flex min-w-0 flex-1 items-center gap-2">
{icon === "team" ? (
<Users className="h-4 w-4 shrink-0 text-muted-foreground" />
) : (
<ProfileAvatar
avatarUrl={avatarUrl ?? null}
className="h-5 w-5 shrink-0 rounded-full text-[8px] bg-muted text-muted-foreground ring-1 ring-border/50"
label={label}
/>
)}
<span className="truncate text-sm">{label}</span>
</div>
<select
className="h-7 rounded-md border border-input bg-background px-2 text-xs shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
disabled={disabled}
onChange={(e) => onChange(e.target.value)}
value={value}
>
<option value="">Default</option>
{providers.map((provider) => (
<option key={provider.id} value={provider.id}>
{provider.label}
</option>
))}
</select>
</div>
);
}

View File

@@ -5,6 +5,7 @@ import {
Check,
Download,
Keyboard,
LayoutTemplate,
LockKeyhole,
MonitorCog,
Moon,
@@ -23,6 +24,7 @@ import { RelayMembersSettingsCard } from "@/features/relay-members/ui/RelayMembe
import { cn } from "@/shared/lib/cn";
import { ACCENT_COLORS, useTheme } from "@/shared/theme/ThemeProvider";
import { SYNTAX_THEMES, isLightTheme } from "@/shared/theme/theme-loader";
import { ChannelTemplatesSettingsCard } from "./ChannelTemplatesSettingsCard";
import { DoctorSettingsPanel } from "./DoctorSettingsPanel";
import { KeyboardShortcutsCard } from "./KeyboardShortcutsCard";
import { MobilePairingCard } from "./MobilePairingCard";
@@ -35,6 +37,7 @@ export type SettingsSection =
| "profile"
| "notifications"
| "agents"
| "channel-templates"
| "appearance"
| "shortcuts"
| "relay-members"
@@ -80,6 +83,11 @@ export const settingsSections: SettingsSectionDescriptor[] = [
label: "Agents",
icon: Bot,
},
{
value: "channel-templates",
label: "Templates",
icon: LayoutTemplate,
},
{
value: "appearance",
label: "Appearance",
@@ -260,6 +268,8 @@ export function renderSettingsSection(
);
case "agents":
return <PreventSleepSettingsCard />;
case "channel-templates":
return <ChannelTemplatesSettingsCard />;
case "appearance":
return <ThemeSettingsCard />;
case "shortcuts":

View File

@@ -89,12 +89,14 @@ type AppSidebarProps = {
description?: string;
visibility: ChannelVisibility;
ttlSeconds?: number;
templateId?: string;
}) => Promise<void>;
onCreateForum: (input: {
name: string;
description?: string;
visibility: ChannelVisibility;
ttlSeconds?: number;
templateId?: string;
}) => Promise<void>;
onOpenAddWorkspace: () => void;
onOpenBrowseChannels: () => void;
@@ -392,6 +394,7 @@ export function AppSidebar({
description?: string;
visibility: ChannelVisibility;
ttlSeconds?: number;
templateId?: string;
}) => {
if (createDialogKind === "stream") {
await onCreateChannel(input);

View File

@@ -1,17 +1,13 @@
import { Lock, Zap } from "lucide-react";
import * as React from "react";
import type { ChannelVisibility } from "@/shared/api/types";
import { useChannelTemplatesQuery } from "@/features/channel-templates/hooks";
import type { ChannelTemplate, ChannelVisibility } from "@/shared/api/types";
import { Button } from "@/shared/ui/button";
import { Checkbox } from "@/shared/ui/checkbox";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/shared/ui/dialog";
import { ChooserDialogContent } from "@/shared/ui/chooser-dialog-content";
import { Dialog } from "@/shared/ui/dialog";
import { Input } from "@/shared/ui/input";
import { Switch } from "@/shared/ui/switch";
import { Textarea } from "@/shared/ui/textarea";
/** Default TTL for ephemeral channels: 1 day of inactivity. */
@@ -29,6 +25,7 @@ type CreateChannelDialogProps = {
description?: string;
visibility: ChannelVisibility;
ttlSeconds?: number;
templateId?: string;
}) => Promise<void>;
};
@@ -48,8 +45,14 @@ export function CreateChannelDialog({
const [visibility, setVisibility] = React.useState<ChannelVisibility>("open");
const [ephemeral, setEphemeral] = React.useState(false);
const [errorMessage, setErrorMessage] = React.useState<string | null>(null);
const [selectedTemplateId, setSelectedTemplateId] = React.useState<
string | null
>(null);
const nameInputRef = React.useRef<HTMLInputElement>(null);
const templatesQuery = useChannelTemplatesQuery();
const templates = templatesQuery.data ?? [];
const kindLabel = channelKind === "forum" ? "forum" : "channel";
// Reset form state when dialog opens/closes or kind changes
@@ -61,6 +64,7 @@ export function CreateChannelDialog({
setVisibility("open");
setEphemeral(false);
setErrorMessage(null);
setSelectedTemplateId(null);
// Small delay to let dialog animation start before focusing
const timerId = globalThis.setTimeout(() => {
@@ -69,6 +73,32 @@ export function CreateChannelDialog({
return () => globalThis.clearTimeout(timerId);
}, [open]);
function handleTemplateChange(templateId: string) {
if (!templateId) {
setSelectedTemplateId(null);
setDescription("");
setVisibility("open");
setErrorMessage(null);
return;
}
const template = templates.find(
(t: ChannelTemplate) => t.id === templateId,
);
if (!template) return;
setSelectedTemplateId(templateId);
// Pre-fill fields from template (always overwrite to avoid stale values)
setDescription(template.description ?? "");
setVisibility(template.visibility);
// If the template's channel type differs from current dialog kind,
// we still apply the visibility but don't change the kind
// (kind is determined by how the dialog was opened)
setErrorMessage(null);
}
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
@@ -83,6 +113,7 @@ export function CreateChannelDialog({
description: description.trim() || undefined,
visibility,
ttlSeconds: ephemeral ? EPHEMERAL_TTL_SECONDS : undefined,
templateId: selectedTemplateId ?? undefined,
});
onOpenChange(false);
@@ -103,18 +134,39 @@ export function CreateChannelDialog({
onOpenChange(nextOpen);
}}
>
<DialogContent className="max-w-lg" data-testid="create-channel-dialog">
<DialogHeader>
<DialogTitle>Create a new {kindLabel}</DialogTitle>
<DialogDescription>
{channelKind === "forum"
? "Forums organize threaded discussions around a topic."
: "Channels are real-time streams for team conversation."}
</DialogDescription>
</DialogHeader>
<ChooserDialogContent
className="max-w-lg"
data-testid="create-channel-dialog"
title={`Create a new ${kindLabel}`}
description={
channelKind === "forum"
? "Forums organize threaded discussions around a topic."
: "Channels are real-time streams for team conversation."
}
footer={
<div className="flex w-full items-center justify-end gap-2">
<Button
disabled={isCreating}
onClick={() => onOpenChange(false)}
type="button"
variant="ghost"
>
Cancel
</Button>
<Button
data-testid="create-channel-submit"
disabled={isCreating || name.trim().length === 0}
form="create-channel-form"
type="submit"
>
{isCreating ? "Creating..." : `Create ${kindLabel}`}
</Button>
</div>
}
>
<form
className="space-y-4"
id="create-channel-form"
onSubmit={(event) => {
void handleSubmit(event);
}}
@@ -175,110 +227,81 @@ export function CreateChannelDialog({
{/* Options */}
<div className="space-y-3">
<PrivateCheckbox
disabled={isCreating}
isPrivate={visibility === "private"}
onChange={(isPrivate) =>
setVisibility(isPrivate ? "private" : "open")
}
/>
<EphemeralCheckbox
disabled={isCreating}
isEphemeral={ephemeral}
onChange={setEphemeral}
/>
<div className="flex items-center justify-between gap-3">
<label
className="flex items-center gap-1.5 text-sm text-muted-foreground"
htmlFor="create-channel-private"
>
<Lock className="h-3.5 w-3.5" />
Private — only visible to invited members
</label>
<Switch
checked={visibility === "private"}
data-testid="create-channel-visibility"
disabled={isCreating}
id="create-channel-private"
onCheckedChange={(checked) =>
setVisibility(checked ? "private" : "open")
}
/>
</div>
<div className="flex items-center justify-between gap-3">
<label
className="flex items-center gap-1.5 text-sm text-muted-foreground"
htmlFor="create-channel-ephemeral"
>
<Zap className="h-3.5 w-3.5" />
Ephemeral — auto-archives after 1 day of inactivity
</label>
<Switch
checked={ephemeral}
disabled={isCreating}
id="create-channel-ephemeral"
onCheckedChange={setEphemeral}
/>
</div>
</div>
{/* Template Selector */}
<div className="space-y-1.5">
<label
className="text-sm font-medium text-foreground"
htmlFor="create-channel-template"
>
Template{" "}
<span className="font-normal text-muted-foreground">
(optional)
</span>
</label>
<select
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
data-testid="create-channel-template"
disabled={isCreating || templates.length === 0}
id="create-channel-template"
onChange={(event) => handleTemplateChange(event.target.value)}
value={selectedTemplateId ?? ""}
>
<option value="">
{templatesQuery.isLoading
? "Loading..."
: templates.length === 0
? "No templates created yet"
: "No template"}
</option>
{templates.map((template: ChannelTemplate) => (
<option key={template.id} value={template.id}>
{template.name}
</option>
))}
</select>
</div>
{/* Error */}
{errorMessage ? (
<p className="text-sm text-destructive">{errorMessage}</p>
) : null}
{/* Footer */}
<div className="flex items-center justify-end gap-2 pt-2">
<Button
disabled={isCreating}
onClick={() => onOpenChange(false)}
type="button"
variant="ghost"
>
Cancel
</Button>
<Button
data-testid="create-channel-submit"
disabled={isCreating || name.trim().length === 0}
type="submit"
>
{isCreating ? "Creating..." : `Create ${kindLabel}`}
</Button>
</div>
</form>
</DialogContent>
</ChooserDialogContent>
</Dialog>
);
}
// ---------------------------------------------------------------------------
// Checkbox helpers (moved from AppSidebar)
// ---------------------------------------------------------------------------
function PrivateCheckbox({
disabled,
isPrivate,
onChange,
}: {
disabled: boolean;
isPrivate: boolean;
onChange: (isPrivate: boolean) => void;
}) {
const id = React.useId();
return (
<div className="flex items-center gap-2">
<Checkbox
checked={isPrivate}
data-testid="create-channel-visibility"
disabled={disabled}
id={id}
onCheckedChange={(checked) => onChange(checked === true)}
/>
<label
className="flex cursor-pointer items-center gap-1.5 text-sm text-muted-foreground select-none peer-disabled:cursor-not-allowed peer-disabled:opacity-50"
htmlFor={id}
>
<Lock className="h-3.5 w-3.5" />
Private — only visible to invited members
</label>
</div>
);
}
function EphemeralCheckbox({
disabled,
isEphemeral,
onChange,
}: {
disabled: boolean;
isEphemeral: boolean;
onChange: (isEphemeral: boolean) => void;
}) {
const id = React.useId();
return (
<div className="flex items-center gap-2">
<Checkbox
checked={isEphemeral}
disabled={disabled}
id={id}
onCheckedChange={(checked) => onChange(checked === true)}
/>
<label
className="flex cursor-pointer items-center gap-1.5 text-sm text-muted-foreground select-none peer-disabled:cursor-not-allowed peer-disabled:opacity-50"
htmlFor={id}
>
<Zap className="h-3.5 w-3.5" />
Ephemeral — auto-archives after 1 day of inactivity
</label>
</div>
);
}

View File

@@ -0,0 +1,117 @@
import { invokeTauri } from "@/shared/api/tauri";
import type {
ChannelTemplate,
CreateChannelTemplateInput,
UpdateChannelTemplateInput,
} from "@/shared/api/types";
type RawChannelTemplate = {
id: string;
name: string;
description?: string | null;
channel_type: string;
visibility: string;
canvas_template?: string | null;
agents?: {
personas?: Array<{
personaId: string;
provider?: string | null;
model?: string | null;
role?: string | null;
backend?: { type: "local" } | { type: "provider"; id: string } | null;
}>;
teams?: Array<{
teamId: string;
provider?: string | null;
model?: string | null;
backend?: { type: "local" } | { type: "provider"; id: string } | null;
}>;
};
is_builtin: boolean;
created_at: string;
updated_at: string;
};
function fromRawChannelTemplate(raw: RawChannelTemplate): ChannelTemplate {
return {
id: raw.id,
name: raw.name,
description: raw.description ?? null,
channelType: raw.channel_type as "stream" | "forum",
visibility: raw.visibility as "open" | "private",
canvasTemplate: raw.canvas_template ?? null,
agents: {
personas: (raw.agents?.personas ?? []).map((p) => ({
personaId: p.personaId,
provider: p.provider ?? null,
model: p.model ?? null,
role: p.role ?? null,
backend: p.backend ?? null,
})),
teams: (raw.agents?.teams ?? []).map((t) => ({
teamId: t.teamId,
provider: t.provider ?? null,
model: t.model ?? null,
backend: t.backend ?? null,
})),
},
isBuiltin: raw.is_builtin,
createdAt: raw.created_at,
updatedAt: raw.updated_at,
};
}
export async function listChannelTemplates(): Promise<ChannelTemplate[]> {
return (
await invokeTauri<RawChannelTemplate[]>("list_channel_templates")
).map(fromRawChannelTemplate);
}
export async function createChannelTemplate(
input: CreateChannelTemplateInput,
): Promise<ChannelTemplate> {
return fromRawChannelTemplate(
await invokeTauri<RawChannelTemplate>("create_channel_template", {
input: {
name: input.name,
description: input.description,
channelType: input.channelType,
visibility: input.visibility,
canvasTemplate: input.canvasTemplate,
agents: input.agents ?? { personas: [], teams: [] },
},
}),
);
}
export async function updateChannelTemplate(
input: UpdateChannelTemplateInput,
): Promise<ChannelTemplate> {
return fromRawChannelTemplate(
await invokeTauri<RawChannelTemplate>("update_channel_template", {
input: {
id: input.id,
name: input.name,
description: input.description,
channelType: input.channelType,
visibility: input.visibility,
canvasTemplate: input.canvasTemplate,
agents: input.agents ?? { personas: [], teams: [] },
},
}),
);
}
export async function deleteChannelTemplate(id: string): Promise<void> {
await invokeTauri("delete_channel_template", { id });
}
export async function duplicateChannelTemplate(
id: string,
): Promise<ChannelTemplate> {
return fromRawChannelTemplate(
await invokeTauri<RawChannelTemplate>("duplicate_channel_template", {
id,
}),
);
}

View File

@@ -450,6 +450,68 @@ export type UpdateTeamInput = {
description?: string;
personaIds: string[];
};
// ── Channel Template types ─────────────────────────────────────────────────────
export type TemplateBackend =
| { type: "local" }
| { type: "provider"; id: string };
export type TemplateAgentEntry = {
personaId: string;
provider: string | null;
model: string | null;
role: string | null;
backend: TemplateBackend | null;
};
export type TemplateTeamEntry = {
teamId: string;
provider: string | null;
model: string | null;
backend: TemplateBackend | null;
};
export type ChannelTemplate = {
id: string;
name: string;
description: string | null;
channelType: "stream" | "forum";
visibility: "open" | "private";
canvasTemplate: string | null;
agents: {
personas: TemplateAgentEntry[];
teams: TemplateTeamEntry[];
};
isBuiltin: boolean;
createdAt: string;
updatedAt: string;
};
export type CreateChannelTemplateInput = {
name: string;
description?: string;
channelType?: string;
visibility?: string;
canvasTemplate?: string;
agents?: {
personas: TemplateAgentEntry[];
teams: TemplateTeamEntry[];
};
};
export type UpdateChannelTemplateInput = {
id: string;
name: string;
description?: string;
channelType?: string;
visibility?: string;
canvasTemplate?: string;
agents?: {
personas: TemplateAgentEntry[];
teams: TemplateTeamEntry[];
};
};
export type {
ApprovalActionResponse,
Workflow,

View File

@@ -46,7 +46,7 @@ export const ChooserDialogContent = React.forwardRef<
) => (
<DialogContent
className={cn(
"flex max-h-[85vh] flex-col overflow-hidden p-0",
"flex max-h-[85vh] flex-col gap-0 overflow-hidden p-0",
className,
)}
ref={ref}
@@ -67,12 +67,12 @@ export const ChooserDialogContent = React.forwardRef<
<div
className={cn(
"min-h-0 flex-1 overflow-y-auto px-6 py-5",
"min-h-0 flex-1 overflow-y-auto px-6",
scrollAreaClassName,
)}
data-testid={scrollAreaTestId}
>
{children}
<div className="py-5">{children}</div>
</div>
{footer ? (

View File

@@ -4,7 +4,10 @@ import * as DialogPrimitive from "@radix-ui/react-dialog";
import remarkBreaks from "remark-breaks";
import remarkGfm from "remark-gfm";
import { toast } from "sonner";
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
import { invokeTauri } from "@/shared/api/tauri";
import type { Channel } from "@/shared/api/types";
import { useChannelNavigation } from "@/shared/context/ChannelNavigationContext";
import { cn } from "@/shared/lib/cn";
@@ -37,6 +40,67 @@ type MarkdownProps = {
type MarkdownVariant = "default" | "compact" | "tight";
function ImageContextMenu({
children,
src,
}: {
children: React.ReactNode;
src: string | undefined;
}) {
const [menu, setMenu] = React.useState<{ x: number; y: number } | null>(null);
React.useEffect(() => {
if (!menu) return;
const close = () => setMenu(null);
window.addEventListener("click", close);
window.addEventListener("contextmenu", close);
window.addEventListener("scroll", close, true);
return () => {
window.removeEventListener("click", close);
window.removeEventListener("contextmenu", close);
window.removeEventListener("scroll", close, true);
};
}, [menu]);
return (
<>
{/* biome-ignore lint/a11y/noStaticElementInteractions: context menu handler on image wrapper */}
<div
onContextMenu={(e) => {
e.preventDefault();
e.stopPropagation();
setMenu({ x: e.clientX, y: e.clientY });
}}
>
{children}
</div>
{menu && src && (
<div
className="fixed z-[100] min-w-[160px] rounded-md border bg-popover p-1 text-popover-foreground shadow-md"
style={{ left: menu.x, top: menu.y }}
>
<button
type="button"
className="flex w-full cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-accent hover:text-accent-foreground"
onClick={() => {
setMenu(null);
invokeTauri("download_image", { url: src }).catch(
(err: unknown) => {
const msg =
err instanceof Error ? err.message : "Download failed";
toast.error(msg);
},
);
}}
>
Download image
</button>
</div>
)}
</>
);
}
function createMarkdownComponents(
variant: MarkdownVariant,
channels: Channel[],
@@ -140,62 +204,68 @@ function createMarkdownComponents(
);
}
return (
<DialogPrimitive.Root>
<DialogPrimitive.Trigger asChild>
<div className="mt-1 max-w-sm cursor-pointer transition-opacity hover:opacity-90">
<img
alt={alt}
className="max-h-64 max-w-full rounded-xl object-contain"
src={resolvedSrc}
/>
</div>
</DialogPrimitive.Trigger>
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay className="fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0" />
<DialogPrimitive.Content
className="fixed inset-0 z-50 flex items-center justify-center p-8"
// Let clicks on the backdrop (the content container itself) close the lightbox
onPointerDownOutside={(e) => e.preventDefault()}
onInteractOutside={(e) => e.preventDefault()}
>
<DialogPrimitive.Title className="sr-only">
{alt || "Image preview"}
</DialogPrimitive.Title>
<DialogPrimitive.Description className="sr-only">
Full-size image preview. Press Escape or click outside the image
to close.
</DialogPrimitive.Description>
{/* Close region: clicking anywhere except the image closes the dialog */}
<DialogPrimitive.Close
className="absolute inset-0 cursor-default"
aria-label="Close lightbox"
/>
<img
alt={alt}
className="relative max-h-[90vh] max-w-[90vw] rounded-lg object-contain"
src={resolvedSrc}
/>
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-full bg-black/50 p-2 text-white/80 transition-colors hover:bg-black/70 hover:text-white focus:outline-none focus:ring-2 focus:ring-white/30">
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
</DialogPrimitive.Root>
<ImageContextMenu src={src}>
<DialogPrimitive.Root>
<DialogPrimitive.Trigger asChild>
<div className="mt-1 max-w-sm cursor-pointer transition-opacity hover:opacity-90">
<img
alt={alt}
className="max-h-64 max-w-full rounded-xl object-contain"
src={resolvedSrc}
onContextMenu={(e) => e.preventDefault()}
/>
</div>
</DialogPrimitive.Trigger>
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay className="fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0" />
<DialogPrimitive.Content
className="fixed inset-0 z-50 flex items-center justify-center p-8"
// Let clicks on the backdrop (the content container itself) close the lightbox
onPointerDownOutside={(e) => e.preventDefault()}
onInteractOutside={(e) => e.preventDefault()}
>
<DialogPrimitive.Title className="sr-only">
{alt || "Image preview"}
</DialogPrimitive.Title>
<DialogPrimitive.Description className="sr-only">
Full-size image preview. Press Escape or click outside the
image to close.
</DialogPrimitive.Description>
{/* Close region: clicking anywhere except the image closes the dialog */}
<DialogPrimitive.Close
className="absolute inset-0 cursor-default"
aria-label="Close lightbox"
/>
<ImageContextMenu src={src}>
<img
alt={alt}
className="relative max-h-[90vh] max-w-[90vw] rounded-lg object-contain"
src={resolvedSrc}
onContextMenu={(e) => e.preventDefault()}
/>
</ImageContextMenu>
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-full bg-black/50 p-2 text-white/80 transition-colors hover:bg-black/70 hover:text-white focus:outline-none focus:ring-2 focus:ring-white/30">
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
</DialogPrimitive.Root>
</ImageContextMenu>
);
},
li: ({ children }) => <li className={listItemClassName}>{children}</li>,

View File

@@ -10,12 +10,6 @@
"groupName": null,
"separateMinorPatch": true,
"separateMultipleMinor": true
},
{
"description": "Hold secp256k1 until sprout-pair-relay is migrated from rand-std to rand/std features.",
"matchManagers": ["cargo"],
"matchPackageNames": ["secp256k1"],
"allowedVersions": "<0.31"
}
]
}