mirror of
https://github.com/block/buzz.git
synced 2026-11-04 00:12:02 +00:00
Merge remote-tracking branch 'origin/main' into inbox
This commit is contained in:
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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` |
|
||||
|
||||
|
||||
@@ -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)?),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
185
desktop/src-tauri/src/commands/channel_templates.rs
Normal file
185
desktop/src-tauri/src/commands/channel_templates.rs
Normal 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)
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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());
|
||||
|
||||
207
desktop/src-tauri/src/commands/media_download.rs
Normal file
207
desktop/src-tauri/src/commands/media_download.rs
Normal 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/"));
|
||||
}
|
||||
}
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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,
|
||||
|
||||
5
desktop/src-tauri/src/templates/mod.rs
Normal file
5
desktop/src-tauri/src/templates/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
mod storage;
|
||||
mod types;
|
||||
|
||||
pub use storage::*;
|
||||
pub use types::*;
|
||||
248
desktop/src-tauri/src/templates/storage.rs
Normal file
248
desktop/src-tauri/src/templates/storage.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
104
desktop/src-tauri/src/templates/types.rs
Normal file
104
desktop/src-tauri/src/templates/types.rs
Normal 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,
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
103
desktop/src/features/channel-templates/hooks.ts
Normal file
103
desktop/src/features/channel-templates/hooks.ts
Normal 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,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
157
desktop/src/features/channel-templates/useApplyTemplate.ts
Normal file
157
desktop/src/features/channel-templates/useApplyTemplate.ts
Normal 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 };
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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` }}
|
||||
>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -284,6 +284,7 @@ export function ForumThreadPanel({
|
||||
isSending={isSendingReply}
|
||||
onSubmit={onReply}
|
||||
placeholder="Reply to this post..."
|
||||
profiles={profiles}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -161,6 +161,7 @@ export function ForumView({
|
||||
setIsComposerOpen(false);
|
||||
}}
|
||||
placeholder="Write your post..."
|
||||
profiles={profiles}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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` }}
|
||||
>
|
||||
|
||||
@@ -406,6 +406,7 @@ export function PulseView({ currentPubkey }: PulseViewProps) {
|
||||
onSubmit={(content, mentionPubkeys, mediaTags) =>
|
||||
publishMutation.mutateAsync({ content, mentionPubkeys, mediaTags })
|
||||
}
|
||||
profiles={mentionProfiles}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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 "{deleteTarget?.name}"?
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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":
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
117
desktop/src/shared/api/tauriChannelTemplates.ts
Normal file
117
desktop/src/shared/api/tauriChannelTemplates.ts
Normal 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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user