diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index aeebb714..baffe0ce 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -32,7 +32,6 @@ const rules = [ const overrides = new Map([ ["src-tauri/src/managed_agents/persona_card.rs", 700], // PNG/ZIP persona card codec + 21 unit tests (~300 lines of tests) ["src/app/AppShell.tsx", 775], - ["src/features/agents/ui/AgentsView.tsx", 625], // persona/team orchestration plus import/export wiring ["src/features/channels/hooks.ts", 550], // canvas query + mutation hooks + DM hide mutation ["src/features/channels/ui/ChannelManagementSheet.tsx", 800], ["src/features/messages/ui/MessageComposer.tsx", 665], // media upload handlers (paste, drop, dialog) + channelId reset effect @@ -43,7 +42,7 @@ const overrides = new Map([ ["src/shared/api/tauri.ts", 1100], // remote agent provider API bindings + canvas API functions ["src-tauri/src/commands/agents.rs", 820], // remote agent lifecycle routing (local + provider branches) + scope enforcement ["src-tauri/src/managed_agents/backend.rs", 530], // provider IPC, validation, discovery, binary resolution + tests - ["src/features/agents/ui/AgentsView.tsx", 730], // remote agent stop/delete + channel UUID resolution + presence-aware delete guard + persona import + ["src/features/agents/ui/AgentsView.tsx", 775], // remote agent stop/delete + channel UUID resolution + presence-aware delete guard + persona/team import ["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", 600], // provider mode: Run on selector, trust warning, probe effect, single-agent enforcement ]); diff --git a/desktop/src-tauri/src/commands/export_util.rs b/desktop/src-tauri/src/commands/export_util.rs new file mode 100644 index 00000000..8263216c --- /dev/null +++ b/desktop/src-tauri/src/commands/export_util.rs @@ -0,0 +1,33 @@ +use tauri::AppHandle; +use tauri_plugin_dialog::DialogExt; + +/// Show a save-file dialog for a JSON export 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_json_with_dialog( + app: &AppHandle, + suggested_filename: &str, + data: &[u8], +) -> Result { + let (tx, rx) = tokio::sync::oneshot::channel(); + app.dialog() + .file() + .add_filter("JSON", &["json"]) + .set_file_name(suggested_filename) + .save_file(move |path| { + let _ = tx.send(path); + }); + + let selected = rx.await.map_err(|_| "dialog cancelled".to_string())?; + let file_path = match selected { + Some(p) => p, + None => return Ok(false), + }; + + let dest = file_path + .as_path() + .ok_or_else(|| "Save dialog returned an invalid path".to_string())?; + std::fs::write(dest, data).map_err(|e| format!("Failed to write file: {e}"))?; + + Ok(true) +} diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index 4449594e..c8d40cc5 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -5,6 +5,7 @@ mod agents; mod canvas; mod channels; mod dms; +mod export_util; mod identity; mod media; mod messages; diff --git a/desktop/src-tauri/src/commands/personas.rs b/desktop/src-tauri/src/commands/personas.rs index 2231afaf..05465b15 100644 --- a/desktop/src-tauri/src/commands/personas.rs +++ b/desktop/src-tauri/src/commands/personas.rs @@ -1,7 +1,7 @@ use tauri::{AppHandle, State}; -use tauri_plugin_dialog::DialogExt; use uuid::Uuid; +use super::export_util::save_json_with_dialog; use crate::{ app_state::AppState, managed_agents::{ @@ -235,38 +235,7 @@ pub async fn export_persona_to_json( let json_bytes = encode_persona_json(&display_name, &system_prompt, avatar_url.as_deref())?; - // Slugify display name for filename. - let slug: String = display_name - .to_lowercase() - .chars() - .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }) - .collect::() - .trim_matches('-') - .to_string(); - let slug = if slug.is_empty() { "persona" } else { &slug }; - let slug = if slug.len() > 50 { &slug[..50] } else { slug }; - let slug = slug.trim_end_matches('-'); - - let (tx, rx) = tokio::sync::oneshot::channel(); - app.dialog() - .file() - .add_filter("JSON", &["json"]) - .set_file_name(&format!("{slug}.persona.json")) - .save_file(move |path| { - let _ = tx.send(path); - }); - - let selected = rx.await.map_err(|_| "dialog cancelled".to_string())?; - let file_path = match selected { - Some(p) => p, - None => return Ok(false), - }; - - let dest = file_path - .as_path() - .ok_or_else(|| "Save dialog returned an invalid path".to_string())?; - std::fs::write(dest, &json_bytes) - .map_err(|e| format!("Failed to write file: {e}"))?; - - Ok(true) + let slug = crate::util::slugify(&display_name, "persona", 50); + let filename = format!("{slug}.persona.json"); + save_json_with_dialog(&app, &filename, &json_bytes).await } diff --git a/desktop/src-tauri/src/commands/teams.rs b/desktop/src-tauri/src/commands/teams.rs index 2a87e78b..4645747d 100644 --- a/desktop/src-tauri/src/commands/teams.rs +++ b/desktop/src-tauri/src/commands/teams.rs @@ -1,9 +1,13 @@ use tauri::{AppHandle, State}; use uuid::Uuid; +use super::export_util::save_json_with_dialog; use crate::{ app_state::AppState, - managed_agents::{load_teams, save_teams, CreateTeamRequest, TeamRecord, UpdateTeamRequest}, + managed_agents::{ + encode_team_json, load_personas, load_teams, parse_team_json, save_teams, + CreateTeamRequest, ParsedTeamPreview, TeamRecord, UpdateTeamRequest, + }, util::now_iso, }; @@ -106,3 +110,51 @@ pub fn delete_team( } save_teams(&app, &teams) } + +// --------------------------------------------------------------------------- +// Import / Export +// --------------------------------------------------------------------------- + +const MAX_TEAM_JSON_BYTES: usize = 5 * 1024 * 1024; + +#[tauri::command] +pub async fn export_team_to_json( + id: String, + app: AppHandle, + state: State<'_, AppState>, +) -> Result { + // Load team and personas under lock, then drop lock before dialog. + let (team, personas) = { + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|e| e.to_string())?; + let teams = load_teams(&app)?; + let team = teams + .into_iter() + .find(|t| t.id == id) + .ok_or_else(|| format!("team {id} not found"))?; + let personas = load_personas(&app)?; + (team, personas) + }; + + let json_bytes = encode_team_json(&team, &personas)?; + + let slug = crate::util::slugify(&team.name, "team", 50); + let filename = format!("{slug}.team.json"); + save_json_with_dialog(&app, &filename, &json_bytes).await +} + +#[tauri::command] +pub fn parse_team_file( + file_bytes: Vec, + _file_name: String, +) -> Result { + if file_bytes.is_empty() { + return Err("File is empty.".to_string()); + } + if file_bytes.len() > MAX_TEAM_JSON_BYTES { + return Err("File is too large (max 5 MB).".to_string()); + } + parse_team_json(&file_bytes) +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index d4954997..6ef0b860 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -262,6 +262,8 @@ pub fn run() { create_team, update_team, delete_team, + export_team_to_json, + parse_team_file, parse_persona_files, export_persona_to_json, ]) diff --git a/desktop/src-tauri/src/managed_agents/teams.rs b/desktop/src-tauri/src/managed_agents/teams.rs index 44816e10..cc2844e2 100644 --- a/desktop/src-tauri/src/managed_agents/teams.rs +++ b/desktop/src-tauri/src/managed_agents/teams.rs @@ -2,7 +2,25 @@ use std::{fs, path::PathBuf}; use tauri::AppHandle; -use crate::managed_agents::{managed_agents_base_dir, TeamRecord}; +use crate::managed_agents::{managed_agents_base_dir, PersonaRecord, TeamRecord}; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, serde::Serialize)] +pub struct TeamPersonaPreview { + pub display_name: String, + pub system_prompt: String, + pub avatar_url: Option, +} + +#[derive(Debug, Clone, serde::Serialize)] +pub struct ParsedTeamPreview { + pub name: String, + pub description: Option, + pub personas: Vec, +} fn teams_store_path(app: &AppHandle) -> Result { Ok(managed_agents_base_dir(app)?.join("teams.json")) @@ -41,10 +59,118 @@ pub fn save_teams(app: &AppHandle, records: &[TeamRecord]) -> Result<(), String> fs::write(&path, payload).map_err(|error| format!("failed to write teams store: {error}")) } +// --------------------------------------------------------------------------- +// Team JSON export / import +// --------------------------------------------------------------------------- + +/// Encode a team as a JSON blob for export. The format includes the team's +/// name, description, and the full persona data for each member (so the +/// import side can recreate personas that don't exist locally). +pub fn encode_team_json( + team: &TeamRecord, + personas: &[PersonaRecord], +) -> Result, String> { + let resolved_personas: Vec = team + .persona_ids + .iter() + .filter_map(|id| { + personas.iter().find(|p| p.id == *id).map(|p| { + serde_json::json!({ + "displayName": p.display_name, + "systemPrompt": p.system_prompt, + "avatarUrl": p.avatar_url, + }) + }) + }) + .collect(); + + let map = serde_json::json!({ + "version": 1, + "type": "team", + "name": team.name, + "description": team.description, + "personas": resolved_personas, + }); + + serde_json::to_vec_pretty(&map).map_err(|e| format!("Failed to serialize team JSON: {e}")) +} + +/// Parse a team JSON file. Returns the team name, description, and embedded persona previews. +pub fn parse_team_json(json_bytes: &[u8]) -> Result { + let v: serde_json::Value = + serde_json::from_slice(json_bytes).map_err(|e| format!("Invalid JSON: {e}"))?; + + let version = v.get("version").and_then(|v| v.as_u64()).unwrap_or(0); + if version != 1 { + return Err(format!("Unsupported team version: {version}")); + } + + let file_type = v.get("type").and_then(|v| v.as_str()).unwrap_or(""); + if file_type != "team" { + return Err("Not a team export file".to_string()); + } + + let name = v + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or("") + .trim() + .to_string(); + if name.is_empty() { + return Err("Team name is empty".to_string()); + } + + let description = v + .get("description") + .and_then(|v| v.as_str()) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()); + + let personas = v + .get("personas") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|p| { + let display_name = p + .get("displayName") + .and_then(|v| v.as_str())? + .trim() + .to_string(); + let system_prompt = p + .get("systemPrompt") + .and_then(|v| v.as_str())? + .trim() + .to_string(); + let avatar_url = p + .get("avatarUrl") + .and_then(|v| v.as_str()) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()); + if display_name.is_empty() || system_prompt.is_empty() { + return None; + } + Some(TeamPersonaPreview { + display_name, + system_prompt, + avatar_url, + }) + }) + .collect() + }) + .unwrap_or_default(); + + Ok(ParsedTeamPreview { + name, + description, + personas, + }) +} + #[cfg(test)] mod tests { - use super::sort_teams; - use crate::managed_agents::TeamRecord; + use super::{encode_team_json, parse_team_json, sort_teams}; + use crate::managed_agents::{PersonaRecord, TeamRecord}; fn team(id: &str, name: &str) -> TeamRecord { TeamRecord { @@ -88,4 +214,127 @@ mod tests { sort_teams(&mut teams); assert!(teams.is_empty()); } + + // ----------------------------------------------------------------------- + // encode / parse round-trip tests + // ----------------------------------------------------------------------- + + fn persona(id: &str, name: &str, prompt: &str) -> PersonaRecord { + PersonaRecord { + id: id.to_string(), + display_name: name.to_string(), + avatar_url: None, + system_prompt: prompt.to_string(), + is_builtin: false, + created_at: "2026-03-20T00:00:00Z".to_string(), + updated_at: "2026-03-20T00:00:00Z".to_string(), + } + } + + #[test] + fn encode_parse_round_trip() { + let t = team("t1", "My Team"); + let t = TeamRecord { + description: Some("A great team".to_string()), + persona_ids: vec!["p1".to_string(), "p2".to_string()], + ..t + }; + let personas = vec![ + persona("p1", "Alice", "You are Alice"), + persona("p2", "Bob", "You are Bob"), + ]; + + let bytes = encode_team_json(&t, &personas).unwrap(); + let parsed = parse_team_json(&bytes).unwrap(); + + assert_eq!(parsed.name, "My Team"); + assert_eq!(parsed.description.as_deref(), Some("A great team")); + assert_eq!(parsed.personas.len(), 2); + assert_eq!(parsed.personas[0].display_name, "Alice"); + assert_eq!(parsed.personas[0].system_prompt, "You are Alice"); + assert_eq!(parsed.personas[1].display_name, "Bob"); + assert_eq!(parsed.personas[1].system_prompt, "You are Bob"); + } + + #[test] + fn encode_skips_missing_personas() { + let t = TeamRecord { + persona_ids: vec!["p1".to_string(), "missing".to_string()], + ..team("t1", "Team") + }; + let personas = vec![persona("p1", "Alice", "prompt")]; + + let bytes = encode_team_json(&t, &personas).unwrap(); + let parsed = parse_team_json(&bytes).unwrap(); + + assert_eq!(parsed.personas.len(), 1); + assert_eq!(parsed.personas[0].display_name, "Alice"); + } + + #[test] + fn parse_team_json_invalid_version() { + let json = serde_json::json!({ + "version": 99, + "type": "team", + "name": "X", + }); + let bytes = serde_json::to_vec(&json).unwrap(); + let err = parse_team_json(&bytes).unwrap_err(); + assert!(err.contains("Unsupported team version"), "{err}"); + } + + #[test] + fn parse_team_json_wrong_type() { + let json = serde_json::json!({ + "version": 1, + "type": "persona", + "name": "X", + }); + let bytes = serde_json::to_vec(&json).unwrap(); + let err = parse_team_json(&bytes).unwrap_err(); + assert!(err.contains("Not a team export file"), "{err}"); + } + + #[test] + fn parse_team_json_empty_name() { + let json = serde_json::json!({ + "version": 1, + "type": "team", + "name": " ", + }); + let bytes = serde_json::to_vec(&json).unwrap(); + let err = parse_team_json(&bytes).unwrap_err(); + assert!(err.contains("Team name is empty"), "{err}"); + } + + #[test] + fn parse_team_json_skips_invalid_personas() { + let json = serde_json::json!({ + "version": 1, + "type": "team", + "name": "Team", + "personas": [ + { "displayName": "Good", "systemPrompt": "prompt" }, + { "displayName": "", "systemPrompt": "prompt" }, + { "displayName": "NoPrompt" }, + ], + }); + let bytes = serde_json::to_vec(&json).unwrap(); + let parsed = parse_team_json(&bytes).unwrap(); + assert_eq!(parsed.personas.len(), 1); + assert_eq!(parsed.personas[0].display_name, "Good"); + } + + #[test] + fn parse_team_json_no_personas_key() { + let json = serde_json::json!({ + "version": 1, + "type": "team", + "name": "Solo", + }); + let bytes = serde_json::to_vec(&json).unwrap(); + let parsed = parse_team_json(&bytes).unwrap(); + assert!(parsed.personas.is_empty()); + assert_eq!(parsed.name, "Solo"); + } } diff --git a/desktop/src-tauri/src/util.rs b/desktop/src-tauri/src/util.rs index ae6708bc..d439248c 100644 --- a/desktop/src-tauri/src/util.rs +++ b/desktop/src-tauri/src/util.rs @@ -4,6 +4,64 @@ pub fn now_iso() -> String { Utc::now().to_rfc3339() } +/// Turn a human-readable name into a filesystem-safe slug. +/// +/// Non-alphanumeric characters become hyphens, leading/trailing hyphens are +/// stripped, and the result is capped at `max_len` characters (on a hyphen +/// boundary when possible). Returns `fallback` when the input produces an +/// empty slug. +pub fn slugify(name: &str, fallback: &str, max_len: usize) -> String { + let raw: String = name + .to_lowercase() + .chars() + .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }) + .collect::() + .trim_matches('-') + .to_string(); + let raw = if raw.is_empty() { fallback } else { &raw }; + let raw = if raw.len() > max_len { + &raw[..max_len] + } else { + raw + }; + raw.trim_end_matches('-').to_string() +} + +#[cfg(test)] +mod tests { + use super::slugify; + + #[test] + fn slugify_basic() { + assert_eq!(slugify("My Cool Team", "team", 50), "my-cool-team"); + } + + #[test] + fn slugify_special_chars() { + assert_eq!(slugify("héllo wörld!", "fallback", 50), "h-llo-w-rld"); + } + + #[test] + fn slugify_empty_uses_fallback() { + assert_eq!(slugify(" ", "persona", 50), "persona"); + assert_eq!(slugify("", "team", 50), "team"); + } + + #[test] + fn slugify_truncates_at_max_len() { + let long_name = "a]".repeat(60); + let result = slugify(&long_name, "fallback", 10); + assert!(result.len() <= 10); + assert!(!result.ends_with('-')); + } + + #[test] + fn slugify_trims_trailing_hyphens_after_truncation() { + // "abcde-----fghij" truncated at 10 → "abcde-----" → trimmed → "abcde" + assert_eq!(slugify("abcde fghij", "x", 10), "abcde"); + } +} + pub fn percent_encode(input: &str) -> String { let mut encoded = String::with_capacity(input.len()); diff --git a/desktop/src/features/agents/channelAgents.ts b/desktop/src/features/agents/channelAgents.ts index aaa91abc..02a3603f 100644 --- a/desktop/src/features/agents/channelAgents.ts +++ b/desktop/src/features/agents/channelAgents.ts @@ -1,4 +1,5 @@ import { DEFAULT_MANAGED_AGENT_SCOPES } from "@/features/tokens/lib/scopeOptions"; +import { normalizePubkey } from "@/shared/lib/pubkey"; import { addChannelMembers, createManagedAgent, @@ -74,10 +75,6 @@ export type CreateChannelManagedAgentsResult = { failures: CreateChannelManagedAgentBatchFailure[]; }; -function normalizePubkey(pubkey: string) { - return pubkey.trim().toLowerCase(); -} - function commandBasename(command: string) { const normalized = command.trim().replace(/\\/g, "/"); const parts = normalized.split("/"); diff --git a/desktop/src/features/agents/ui/AddAgentToChannelDialog.tsx b/desktop/src/features/agents/ui/AddAgentToChannelDialog.tsx index cd60b696..9644ffd8 100644 --- a/desktop/src/features/agents/ui/AddAgentToChannelDialog.tsx +++ b/desktop/src/features/agents/ui/AddAgentToChannelDialog.tsx @@ -4,8 +4,12 @@ import { type AttachManagedAgentToChannelResult, useAttachManagedAgentToChannelMutation, } from "@/features/agents/hooks"; -import { useChannelsQuery } from "@/features/channels/hooks"; +import { + useChannelMembersQuery, + useChannelsQuery, +} from "@/features/channels/hooks"; import type { Channel, ChannelRole, ManagedAgent } from "@/shared/api/types"; +import { normalizePubkey } from "@/shared/lib/pubkey"; import { Button } from "@/shared/ui/button"; import { Dialog, @@ -68,6 +72,21 @@ export function AddAgentToChannelDialog({ } }, [channelId, channels, open]); + const membersQuery = useChannelMembersQuery( + channelId || null, + open && !!channelId, + ); + + const isAlreadyMember = React.useMemo(() => { + if (!agent?.pubkey || !membersQuery.data) { + return false; + } + const normalized = normalizePubkey(agent.pubkey); + return membersQuery.data.some( + (member) => normalizePubkey(member.pubkey) === normalized, + ); + }, [agent?.pubkey, membersQuery.data]); + const selectedChannel = channels.find((channel) => channel.id === channelId) ?? null; @@ -132,6 +151,13 @@ export function AddAgentToChannelDialog({

+ {isAlreadyMember ? ( +
+ + Already a member of this channel +
+ ) : null} +
diff --git a/desktop/src/features/agents/ui/AgentsView.tsx b/desktop/src/features/agents/ui/AgentsView.tsx index 426783d5..1404b0d0 100644 --- a/desktop/src/features/agents/ui/AgentsView.tsx +++ b/desktop/src/features/agents/ui/AgentsView.tsx @@ -21,7 +21,12 @@ import { import { useChannelsQuery } from "@/features/channels/hooks"; import { usePresenceQuery } from "@/features/presence/hooks"; import { sendChannelMessage } from "@/shared/api/tauri"; -import type { ParsePersonaFilesResult } from "@/shared/api/tauriPersonas"; +import { + parsePersonaFiles, + type ParsePersonaFilesResult, +} from "@/shared/api/tauriPersonas"; +import { isSingleItemFile } from "@/shared/lib/fileMagic"; + import type { AgentPersona, Channel, @@ -43,13 +48,13 @@ import { RelayDirectorySection } from "./RelayDirectorySection"; import { SecretRevealDialog } from "./SecretRevealDialog"; import { TeamDeleteDialog } from "./TeamDeleteDialog"; import { TeamDialog } from "./TeamDialog"; +import { TeamImportDialog } from "./TeamImportDialog"; import { TeamsSection } from "./TeamsSection"; import { TokenRevealDialog } from "./TokenRevealDialog"; import { useTeamActions } from "./useTeamActions"; type PersonaDialogState = { description: string; - enableImportDrop: boolean; initialValues: CreatePersonaInput | UpdatePersonaInput; submitLabel: string; title: string; @@ -389,6 +394,39 @@ export function AgentsView() { void relayAgentsQuery.refetch(); } + async function handlePersonaImportFile( + fileBytes: number[], + fileName: string, + ) { + setActionNoticeMessage(null); + setActionErrorMessage(null); + try { + const result = await parsePersonaFiles(fileBytes, fileName); + if (isSingleItemFile(fileBytes) && result.personas.length === 1) { + const p = result.personas[0]; + setPersonaDialogState({ + title: `Import ${p.displayName}`, + description: "Review and save this imported persona.", + submitLabel: "Create persona", + initialValues: { + displayName: p.displayName, + avatarUrl: p.avatarDataUrl ?? "", + systemPrompt: p.systemPrompt, + }, + }); + } else if (result.personas.length > 0) { + setBatchImportResult(result); + setBatchImportFileName(fileName); + } else { + setActionErrorMessage("No valid personas found in file."); + } + } catch (err) { + setActionErrorMessage( + err instanceof Error ? err.message : "Failed to parse persona file.", + ); + } + } + const isActionPending = startMutation.isPending || stopMutation.isPending || @@ -399,6 +437,7 @@ export function AgentsView() { updatePersonaMutation.isPending || deletePersonaMutation.isPending || exportPersonaJsonMutation.isPending || + teamActions.exportTeamJsonMutation.isPending || teamActions.createTeamMutation.isPending || teamActions.updateTeamMutation.isPending || teamActions.deleteTeamMutation.isPending; @@ -427,7 +466,6 @@ export function AgentsView() { title: "Create persona", description: "Save a reusable role, prompt, and optional avatar for future agent deployments.", - enableImportDrop: true, submitLabel: "Create persona", initialValues: { displayName: "", @@ -444,7 +482,6 @@ export function AgentsView() { title: `Duplicate ${persona.displayName}`, description: "Create a new persona by copying this template and adjusting it as needed.", - enableImportDrop: false, submitLabel: "Create persona", initialValues: { displayName: `${persona.displayName} copy`, @@ -460,7 +497,6 @@ export function AgentsView() { title: `Edit ${persona.displayName}`, description: "Update this saved persona. New deployments will use the updated values.", - enableImportDrop: false, submitLabel: "Save changes", initialValues: { id: persona.id, @@ -470,6 +506,9 @@ export function AgentsView() { }, }); }} + onImportFile={(fileBytes, fileName) => { + void handlePersonaImportFile(fileBytes, fileName); + }} onExport={(persona) => { exportPersonaJsonMutation.mutate(persona.id, { onSuccess: (saved) => { @@ -507,6 +546,8 @@ export function AgentsView() { onDelete={teamActions.setTeamToDelete} onDuplicate={teamActions.openDuplicateDialog} onEdit={teamActions.openEditDialog} + onExport={teamActions.handleExportTeam} + onImportFile={teamActions.handleImportFile} onAddToChannel={teamActions.setTeamToAddToChannel} personas={personas} teams={teamActions.teams} @@ -613,7 +654,6 @@ export function AgentsView() { /> { - setBatchImportResult(result); - setBatchImportFileName(fileName); - setPersonaDialogState(null); - }} onOpenChange={(open) => { if (!open) { setPersonaDialogState(null); @@ -717,6 +752,17 @@ export function AgentsView() { open={batchImportResult !== null} result={batchImportResult} /> + { + if (!open) { + teamActions.setTeamImportPreview(null); + } + }} + open={teamActions.teamImportPreview !== null} + preview={teamActions.teamImportPreview?.preview ?? null} + /> ); } diff --git a/desktop/src/features/agents/ui/BatchImportDialog.tsx b/desktop/src/features/agents/ui/BatchImportDialog.tsx index 5f0036f3..43bd7239 100644 --- a/desktop/src/features/agents/ui/BatchImportDialog.tsx +++ b/desktop/src/features/agents/ui/BatchImportDialog.tsx @@ -1,6 +1,11 @@ import * as React from "react"; import { AlertTriangle, ChevronDown, ChevronRight } from "lucide-react"; +import { + ImportStatusIcon, + type ImportItemStatus, +} from "@/shared/ui/import-status-icon"; + import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; import type { ParsePersonaFilesResult } from "@/shared/api/tauriPersonas"; import { createPersona } from "@/shared/api/tauriPersonas"; @@ -34,6 +39,9 @@ export function BatchImportDialog({ "idle" | "importing" | "done" | "error" >("idle"); const [importedCount, setImportedCount] = React.useState(0); + const [itemStatuses, setItemStatuses] = React.useState< + Map + >(new Map()); const [errorMessage, setErrorMessage] = React.useState(null); const [expandedIndex, setExpandedIndex] = React.useState(null); const [skippedExpanded, setSkippedExpanded] = React.useState(false); @@ -47,6 +55,7 @@ export function BatchImportDialog({ setSelected(new Set(Array.from({ length: count }, (_, i) => i))); setStatus("idle"); setImportedCount(0); + setItemStatuses(new Map()); setErrorMessage(null); setExpandedIndex(null); setSkippedExpanded(false); @@ -60,6 +69,13 @@ export function BatchImportDialog({ setStatus("importing"); setErrorMessage(null); + // Initialize all selected items as pending + const initialStatuses = new Map(); + for (const index of selected) { + initialStatuses.set(index, "pending"); + } + setItemStatuses(new Map(initialStatuses)); + let completed = 0; for (const index of selected) { @@ -68,6 +84,12 @@ export function BatchImportDialog({ continue; } + setItemStatuses((prev) => { + const next = new Map(prev); + next.set(index, "importing"); + return next; + }); + try { await createPersona({ displayName: persona.displayName, @@ -76,7 +98,17 @@ export function BatchImportDialog({ }); completed += 1; setImportedCount(completed); + setItemStatuses((prev) => { + const next = new Map(prev); + next.set(index, "done"); + return next; + }); } catch (error) { + setItemStatuses((prev) => { + const next = new Map(prev); + next.set(index, "error"); + return next; + }); setStatus("error"); setErrorMessage( `Imported ${completed} of ${selected.size}. Failed on '${persona.displayName}': ${error instanceof Error ? error.message : String(error)}. Already-imported personas are saved.`, @@ -158,6 +190,7 @@ export function BatchImportDialog({

) : null} + {isExpanded ? (
diff --git a/desktop/src/features/agents/ui/PersonaDialog.tsx b/desktop/src/features/agents/ui/PersonaDialog.tsx index a866ef3a..cc55d32e 100644 --- a/desktop/src/features/agents/ui/PersonaDialog.tsx +++ b/desktop/src/features/agents/ui/PersonaDialog.tsx @@ -1,7 +1,5 @@ import * as React from "react"; -import type { ParsePersonaFilesResult } from "@/shared/api/tauriPersonas"; -import { parsePersonaFiles } from "@/shared/api/tauriPersonas"; import type { CreatePersonaInput, UpdatePersonaInput, @@ -17,15 +15,6 @@ import { import { Input } from "@/shared/ui/input"; import { Textarea } from "@/shared/ui/textarea"; -const MAX_FILE_SIZE = 100 * 1024 * 1024; // 100 MB (ZIP ceiling) -const PNG_MAGIC = [0x89, 0x50, 0x4e, 0x47]; -const ZIP_MAGIC = [0x50, 0x4b, 0x03, 0x04]; -const JSON_FIRST_BYTE = 0x7b; // '{' - -function matchesMagic(bytes: number[], magic: number[]) { - return magic.every((b, i) => bytes[i] === b); -} - type PersonaDialogProps = { open: boolean; title: string; @@ -34,10 +23,8 @@ type PersonaDialogProps = { initialValues: CreatePersonaInput | UpdatePersonaInput | null; error: Error | null; isPending: boolean; - enableImportDrop?: boolean; onOpenChange: (open: boolean) => void; onSubmit: (input: CreatePersonaInput | UpdatePersonaInput) => Promise; - onBatchImport?: (result: ParsePersonaFilesResult, fileName: string) => void; }; export function PersonaDialog({ @@ -48,16 +35,12 @@ export function PersonaDialog({ initialValues, error, isPending, - enableImportDrop, onOpenChange, onSubmit, - onBatchImport, }: PersonaDialogProps) { const [displayName, setDisplayName] = React.useState(""); const [avatarUrl, setAvatarUrl] = React.useState(""); const [systemPrompt, setSystemPrompt] = React.useState(""); - const [isDragOver, setIsDragOver] = React.useState(false); - const [importError, setImportError] = React.useState(null); React.useEffect(() => { if (!open || !initialValues) { @@ -74,70 +57,11 @@ export function PersonaDialog({ setDisplayName(""); setAvatarUrl(""); setSystemPrompt(""); - setIsDragOver(false); - setImportError(null); } onOpenChange(next); } - async function handleDrop(e: React.DragEvent) { - e.preventDefault(); - setIsDragOver(false); - - if (!enableImportDrop) { - return; - } - - const file = e.dataTransfer.files[0]; - if (!file) { - return; - } - - if (file.size > MAX_FILE_SIZE) { - setImportError("File is too large (max 100 MB)."); - return; - } - - setImportError(null); - - try { - const buffer = await file.arrayBuffer(); - const bytes = Array.from(new Uint8Array(buffer)); - const result = await parsePersonaFiles(bytes, file.name); - - const isPng = matchesMagic(bytes, PNG_MAGIC); - const isZip = matchesMagic(bytes, ZIP_MAGIC); - const isJson = bytes.length > 0 && bytes[0] === JSON_FIRST_BYTE; - - if ((isPng || isJson) && result.personas.length === 1) { - const persona = result.personas[0]; - setDisplayName(persona.displayName); - setSystemPrompt(persona.systemPrompt); - setAvatarUrl(persona.avatarDataUrl ?? ""); - return; - } - - if (isZip && result.personas.length > 0 && onBatchImport) { - onBatchImport(result, file.name); - return; - } - - if (result.personas.length === 0) { - setImportError("No valid personas found in file."); - return; - } - - setImportError( - "Unsupported file format. Drop a .persona.json, .persona.png, or .zip.", - ); - } catch (err) { - setImportError( - err instanceof Error ? err.message : "Failed to parse file.", - ); - } - } - async function handleSubmit() { if (!initialValues) { return; @@ -162,37 +86,11 @@ export function PersonaDialog({ return ( - setIsDragOver(false)} - onDragOver={(e: React.DragEvent) => { - if (enableImportDrop) { - e.preventDefault(); - setIsDragOver(true); - } - }} - onDrop={(e: React.DragEvent) => void handleDrop(e)} - > -
- {isDragOver && enableImportDrop ? ( -
-

- Drop .persona.json, .persona.png, or .zip -

-
- ) : null} - + +
{title} - - {description} - {enableImportDrop ? ( - - Or drag a .persona.json, .persona.png, or .zip onto this - dialog to import. - - ) : null} - + {description}
@@ -258,12 +156,6 @@ export function PersonaDialog({ {error.message}

) : null} - - {importError ? ( -

- {importError} -

- ) : null}
diff --git a/desktop/src/features/agents/ui/PersonasSection.tsx b/desktop/src/features/agents/ui/PersonasSection.tsx index 6b4ec166..24a5e688 100644 --- a/desktop/src/features/agents/ui/PersonasSection.tsx +++ b/desktop/src/features/agents/ui/PersonasSection.tsx @@ -6,10 +6,13 @@ import { Pencil, Plus, Trash2, + Upload, } from "lucide-react"; import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; import type { AgentPersona } from "@/shared/api/types"; +import { useFileImportZone } from "@/shared/hooks/useFileImportZone"; +import { promptPreview } from "@/shared/lib/promptPreview"; import { Button } from "@/shared/ui/button"; import { DropdownMenu, @@ -20,18 +23,6 @@ import { import { Skeleton } from "@/shared/ui/skeleton"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; -function promptPreview(systemPrompt: string) { - const trimmed = systemPrompt.trim(); - if (!trimmed) { - return null; - } - const [firstLine] = trimmed - .split("\n") - .map((line) => line.trim()) - .filter((line) => line.length > 0); - return firstLine ?? trimmed; -} - type PersonasSectionProps = { personas: AgentPersona[]; error: Error | null; @@ -42,6 +33,7 @@ type PersonasSectionProps = { onEdit: (persona: AgentPersona) => void; onExport: (persona: AgentPersona) => void; onDelete: (persona: AgentPersona) => void; + onImportFile: (fileBytes: number[], fileName: string) => void; }; export function PersonasSection({ @@ -54,9 +46,26 @@ export function PersonasSection({ onEdit, onExport, onDelete, + onImportFile, }: PersonasSectionProps) { + const { + fileInputRef, + isDragOver, + dropHandlers, + handleFileChange, + openFilePicker, + } = useFileImportZone({ onImportFile }); + return ( -
+
+ {isDragOver ? ( +
+

+ Drop .persona.json, .persona.png, or .zip to import +

+
+ ) : null} +

Personas

@@ -64,6 +73,13 @@ export function PersonasSection({ Reusable agent templates for common roles and prompts.

+
) : null} {!isLoading && personas.length === 0 ? ( -
+
+

+ Or drop a .persona.json, .persona.png, or .zip file here to import. +

+ ) : null} {error ? ( diff --git a/desktop/src/features/agents/ui/TeamImportDialog.tsx b/desktop/src/features/agents/ui/TeamImportDialog.tsx new file mode 100644 index 00000000..e29d8e48 --- /dev/null +++ b/desktop/src/features/agents/ui/TeamImportDialog.tsx @@ -0,0 +1,220 @@ +import * as React from "react"; +import { Users } from "lucide-react"; + +import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; +import type { ParsedTeamPreview } from "@/shared/api/tauriTeams"; +import { createPersona } from "@/shared/api/tauriPersonas"; +import { promptPreview } from "@/shared/lib/promptPreview"; +import { + ImportStatusIcon, + type ImportItemStatus, +} from "@/shared/ui/import-status-icon"; +import { Button } from "@/shared/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/shared/ui/dialog"; + +type TeamImportDialogProps = { + fileName: string; + open: boolean; + preview: ParsedTeamPreview | null; + onOpenChange: (open: boolean) => void; + onComplete: ( + teamName: string, + teamDescription: string | null, + personaIds: string[], + ) => void; +}; + +export function TeamImportDialog({ + fileName, + open, + preview, + onOpenChange, + onComplete, +}: TeamImportDialogProps) { + const [status, setStatus] = React.useState< + "idle" | "importing" | "done" | "error" + >("idle"); + const [importedCount, setImportedCount] = React.useState(0); + const [itemStatuses, setItemStatuses] = React.useState< + Map + >(new Map()); + const [errorMessage, setErrorMessage] = React.useState(null); + + const personas = preview?.personas ?? []; + + React.useEffect(() => { + if (!open) { + return; + } + setStatus("idle"); + setImportedCount(0); + setItemStatuses(new Map()); + setErrorMessage(null); + }, [open]); + + async function handleImport() { + if (!preview || personas.length === 0) { + return; + } + + setStatus("importing"); + setErrorMessage(null); + + const initialStatuses = new Map(); + for (let i = 0; i < personas.length; i++) { + initialStatuses.set(i, "pending"); + } + setItemStatuses(new Map(initialStatuses)); + + const personaIds: string[] = []; + let completed = 0; + + for (let i = 0; i < personas.length; i++) { + const persona = personas[i]; + + setItemStatuses((prev) => { + const next = new Map(prev); + next.set(i, "importing"); + return next; + }); + + try { + const created = await createPersona({ + displayName: persona.display_name, + systemPrompt: persona.system_prompt, + avatarUrl: persona.avatar_url ?? undefined, + }); + personaIds.push(created.id); + completed += 1; + setImportedCount(completed); + setItemStatuses((prev) => { + const next = new Map(prev); + next.set(i, "done"); + return next; + }); + } catch (error) { + setItemStatuses((prev) => { + const next = new Map(prev); + next.set(i, "error"); + return next; + }); + setStatus("error"); + setErrorMessage( + `Imported ${completed} of ${personas.length} personas. Failed on '${persona.display_name}': ${error instanceof Error ? error.message : String(error)}. Already-imported personas are saved.`, + ); + return; + } + } + + setStatus("done"); + onComplete(preview.name, preview.description, personaIds); + } + + return ( + + + + Import Team + + Preview the team from {fileName || "file"} before importing. + + + +
+ {preview ? ( +
+
+ +
+

+ {preview.name} +

+ {preview.description ? ( +

+ {preview.description} +

+ ) : null} +
+ + {personas.length}{" "} + {personas.length === 1 ? "persona" : "personas"} + +
+ +
+

Personas to import

+

+ Each persona will be created, then grouped into a new team. +

+
+ +
+ {personas.map((persona, index) => ( +
+ +
+

+ {persona.display_name} +

+

+ {promptPreview(persona.system_prompt)} +

+
+ +
+ ))} +
+
+ ) : null} + + {errorMessage ? ( +

+ {errorMessage} +

+ ) : null} +
+ +
+ + +
+
+
+ ); +} diff --git a/desktop/src/features/agents/ui/TeamsSection.tsx b/desktop/src/features/agents/ui/TeamsSection.tsx index 0173488a..672b9b03 100644 --- a/desktop/src/features/agents/ui/TeamsSection.tsx +++ b/desktop/src/features/agents/ui/TeamsSection.tsx @@ -1,16 +1,19 @@ import { CopyPlus, + Download, Ellipsis, Info, Pencil, Plus, Rocket, Trash2, + Upload, Users, } from "lucide-react"; import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; import type { AgentPersona, AgentTeam } from "@/shared/api/types"; +import { useFileImportZone } from "@/shared/hooks/useFileImportZone"; import { Button } from "@/shared/ui/button"; import { DropdownMenu, @@ -33,8 +36,10 @@ type TeamsSectionProps = { onCreate: () => void; onDuplicate: (team: AgentTeam) => void; onEdit: (team: AgentTeam) => void; + onExport: (team: AgentTeam) => void; onDelete: (team: AgentTeam) => void; onAddToChannel: (team: AgentTeam) => void; + onImportFile: (fileBytes: number[], fileName: string) => void; }; function resolvePersonas( @@ -55,11 +60,29 @@ export function TeamsSection({ onCreate, onDuplicate, onEdit, + onExport, onDelete, onAddToChannel, + onImportFile, }: TeamsSectionProps) { + const { + fileInputRef, + isDragOver, + dropHandlers, + handleFileChange, + openFilePicker, + } = useFileImportZone({ onImportFile }); + return ( -
+
+ {isDragOver ? ( +
+

+ Drop .team.json to import +

+
+ ) : null} +

Teams

@@ -67,6 +90,13 @@ export function TeamsSection({ Named groups of personas you can deploy to a channel together.

+
) : null} {!isLoading && teams.length === 0 ? ( -
+
+

+ Or drop a .team.json file here to import. +

+ ) : null} {error ? ( diff --git a/desktop/src/features/agents/ui/useTeamActions.ts b/desktop/src/features/agents/ui/useTeamActions.ts index 67bfc714..d9743b10 100644 --- a/desktop/src/features/agents/ui/useTeamActions.ts +++ b/desktop/src/features/agents/ui/useTeamActions.ts @@ -1,12 +1,21 @@ import * as React from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; import { + personasQueryKey, + teamsQueryKey, useCreateTeamMutation, useDeleteTeamMutation, useTeamsQuery, useUpdateTeamMutation, } from "@/features/agents/hooks"; import type { CreateChannelManagedAgentsResult } from "@/features/agents/channelAgents"; +import { + type ParsedTeamPreview, + createTeam as createTeamApi, + exportTeamToJson, + parseTeamFile, +} from "@/shared/api/tauriTeams"; import type { AgentTeam, Channel, @@ -35,11 +44,16 @@ export function useTeamActions( actions: ActionMessages, refetch: RefetchCallbacks, ) { + const queryClient = useQueryClient(); const teamsQuery = useTeamsQuery(); const createTeamMutation = useCreateTeamMutation(); const updateTeamMutation = useUpdateTeamMutation(); const deleteTeamMutation = useDeleteTeamMutation(); + const exportTeamJsonMutation = useMutation({ + mutationFn: (id: string) => exportTeamToJson(id), + }); + const [teamDialogState, setTeamDialogState] = React.useState(null); const [teamToDelete, setTeamToDelete] = React.useState( @@ -47,6 +61,10 @@ export function useTeamActions( ); const [teamToAddToChannel, setTeamToAddToChannel] = React.useState(null); + const [teamImportPreview, setTeamImportPreview] = React.useState<{ + preview: ParsedTeamPreview; + fileName: string; + } | null>(null); const teams = teamsQuery.data ?? []; @@ -136,6 +154,72 @@ export function useTeamActions( }); } + function handleExportTeam(team: AgentTeam) { + exportTeamJsonMutation.mutate(team.id, { + onSuccess: (saved) => { + if (saved) { + actions.setActionNoticeMessage(`Exported team "${team.name}".`); + } + }, + onError: (err) => { + actions.setActionErrorMessage( + err instanceof Error ? err.message : "Failed to export team.", + ); + }, + }); + } + + function handleImportFile(fileBytes: number[], fileName: string) { + actions.setActionNoticeMessage(null); + actions.setActionErrorMessage(null); + void (async () => { + try { + const preview = await parseTeamFile(fileBytes, fileName); + setTeamImportPreview({ preview, fileName }); + } catch (err) { + actions.setActionErrorMessage( + err instanceof Error ? err.message : "Failed to parse team file.", + ); + } + })(); + } + + function handleTeamImportComplete( + teamName: string, + teamDescription: string | null, + personaIds: string[], + ) { + setTeamImportPreview(null); + void (async () => { + const teamInput = { + name: teamName, + description: teamDescription ?? undefined, + personaIds, + }; + + // Try creating the team, retry once on failure. + for (let attempt = 0; attempt < 2; attempt++) { + try { + await createTeamApi(teamInput); + actions.setActionNoticeMessage( + `Imported team "${teamName}" with ${personaIds.length} persona${personaIds.length !== 1 ? "s" : ""}.`, + ); + void queryClient.invalidateQueries({ queryKey: personasQueryKey }); + void queryClient.invalidateQueries({ queryKey: teamsQueryKey }); + return; + } catch { + if (attempt === 0) continue; + } + } + + // Both attempts failed — personas exist but team doesn't. + actions.setActionErrorMessage( + `Imported ${personaIds.length} persona${personaIds.length !== 1 ? "s" : ""} but failed to create team "${teamName}". The personas are saved — create a team manually to group them.`, + ); + void queryClient.invalidateQueries({ queryKey: personasQueryKey }); + })(); + } + function openEditDialog(team: AgentTeam) { actions.setActionNoticeMessage(null); actions.setActionErrorMessage(null); @@ -158,15 +242,21 @@ export function useTeamActions( createTeamMutation, updateTeamMutation, deleteTeamMutation, + exportTeamJsonMutation, teamDialogState, setTeamDialogState, teamToDelete, setTeamToDelete, teamToAddToChannel, setTeamToAddToChannel, + teamImportPreview, + setTeamImportPreview, handleTeamSubmit, handleDeleteTeam, handleTeamDeployed, + handleExportTeam, + handleImportFile, + handleTeamImportComplete, openCreateDialog, openDuplicateDialog, openEditDialog, diff --git a/desktop/src/features/channels/ui/AddChannelBotDialog.tsx b/desktop/src/features/channels/ui/AddChannelBotDialog.tsx index 93e74d33..91c9e23e 100644 --- a/desktop/src/features/channels/ui/AddChannelBotDialog.tsx +++ b/desktop/src/features/channels/ui/AddChannelBotDialog.tsx @@ -7,6 +7,7 @@ import { useTeamsQuery, type CreateChannelManagedAgentResult, } from "@/features/agents/hooks"; +import { useInChannelPersonaIds } from "@/features/channels/ui/useInChannelPersonaIds"; import { AddChannelBotGenericSection } from "@/features/channels/ui/AddChannelBotGenericSection"; import { AddChannelBotPersonasSection } from "@/features/channels/ui/AddChannelBotPersonasSection"; import { AddChannelBotTeamsSection } from "@/features/channels/ui/AddChannelBotTeamsSection"; @@ -98,6 +99,10 @@ export function AddChannelBotDialog({ }: AddChannelBotDialogProps) { const personasQuery = usePersonasQuery(); const teamsQuery = useTeamsQuery(); + const inChannelPersonaIds = useInChannelPersonaIds( + channelId, + open && channelId !== null, + ); const createBotsMutation = useCreateChannelManagedAgentsMutation(channelId); const personas = personasQuery.data ?? []; const teams = teamsQuery.data ?? []; @@ -469,6 +474,7 @@ export function AddChannelBotDialog({ {teams.length > 0 ? ( { diff --git a/desktop/src/features/channels/ui/AddChannelBotPersonasSection.tsx b/desktop/src/features/channels/ui/AddChannelBotPersonasSection.tsx index 29e81a8e..da2d67f8 100644 --- a/desktop/src/features/channels/ui/AddChannelBotPersonasSection.tsx +++ b/desktop/src/features/channels/ui/AddChannelBotPersonasSection.tsx @@ -1,7 +1,8 @@ -import { Bot } from "lucide-react"; +import { Bot, Check } from "lucide-react"; import type { AgentPersona } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; +import { promptPreview } from "@/shared/lib/promptPreview"; import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; import { Tooltip, @@ -10,15 +11,6 @@ import { TooltipTrigger, } from "@/shared/ui/tooltip"; -function promptPreview(prompt: string) { - const [firstLine] = prompt - .split("\n") - .map((line) => line.trim()) - .filter((line) => line.length > 0); - - return firstLine ?? prompt.trim(); -} - type SelectionChipButtonProps = { avatarUrl?: string | null; disabled: boolean; @@ -73,6 +65,7 @@ function SelectionChipButton({ type AddChannelBotPersonasSectionProps = { canToggleSelections: boolean; + inChannelPersonaIds?: ReadonlySet; includeGeneric: boolean; isLoading: boolean; onToggleGeneric: () => void; @@ -83,6 +76,7 @@ type AddChannelBotPersonasSectionProps = { export function AddChannelBotPersonasSection({ canToggleSelections, + inChannelPersonaIds, includeGeneric, isLoading, onToggleGeneric, @@ -130,18 +124,32 @@ export function AddChannelBotPersonasSection({ {personas.map((persona) => { const isSelected = selectedPersonaIds.includes(persona.id); + const isInChannel = inChannelPersonaIds?.has(persona.id) ?? false; return (
onTogglePersona(persona.id)} selected={isSelected} > {persona.displayName} + {isInChannel ? ( + + + In channel + + ) : null}
@@ -156,6 +164,11 @@ export function AddChannelBotPersonasSection({ />

{persona.displayName}

+ {isInChannel ? ( +

+ ✓ Already in this channel +

+ ) : null}

{promptPreview(persona.systemPrompt)}

diff --git a/desktop/src/features/channels/ui/AddChannelBotTeamsSection.tsx b/desktop/src/features/channels/ui/AddChannelBotTeamsSection.tsx index 14917540..f7a5857f 100644 --- a/desktop/src/features/channels/ui/AddChannelBotTeamsSection.tsx +++ b/desktop/src/features/channels/ui/AddChannelBotTeamsSection.tsx @@ -1,4 +1,4 @@ -import { Users } from "lucide-react"; +import { Check, Users } from "lucide-react"; import type * as React from "react"; import type { AgentPersona, AgentTeam } from "@/shared/api/types"; @@ -56,6 +56,7 @@ function resolveTeamPersonas( type AddChannelBotTeamsSectionProps = { canToggleSelections: boolean; + inChannelPersonaIds?: ReadonlySet; isLoading: boolean; onToggleTeam: (personaIds: string[]) => void; personas: AgentPersona[]; @@ -65,6 +66,7 @@ type AddChannelBotTeamsSectionProps = { export function AddChannelBotTeamsSection({ canToggleSelections, + inChannelPersonaIds, isLoading, onToggleTeam, personas, @@ -92,13 +94,22 @@ export function AddChannelBotTeamsSection({ const allSelected = validIds.length > 0 && validIds.every((id) => selectedPersonaIds.includes(id)); + const inChannelCount = inChannelPersonaIds + ? validIds.filter((id) => inChannelPersonaIds.has(id)).length + : 0; + const allInChannel = + inChannelCount > 0 && inChannelCount === validIds.length; return (
onToggleTeam(validIds)} selected={allSelected} @@ -122,6 +133,21 @@ export function AddChannelBotTeamsSection({ > ({validIds.length}) + {inChannelCount > 0 ? ( + + + {allInChannel + ? "All in channel" + : `${inChannelCount} in channel`} + + ) : null}
@@ -134,21 +160,28 @@ export function AddChannelBotTeamsSection({

) : null}
- {resolved.map((persona) => ( -
- - - {persona.displayName} - -
- ))} + {resolved.map((persona) => { + const personaInChannel = + inChannelPersonaIds?.has(persona.id) ?? false; + return ( +
+ + + {persona.displayName} + + {personaInChannel ? ( + + ) : null} +
+ ); + })}
diff --git a/desktop/src/features/channels/ui/ChannelMembersBar.tsx b/desktop/src/features/channels/ui/ChannelMembersBar.tsx index f4bf99f2..e9fedc03 100644 --- a/desktop/src/features/channels/ui/ChannelMembersBar.tsx +++ b/desktop/src/features/channels/ui/ChannelMembersBar.tsx @@ -9,6 +9,7 @@ import { } from "@/features/agents/hooks"; import { useChannelMembersQuery } from "@/features/channels/hooks"; import type { Channel } from "@/shared/api/types"; +import { normalizePubkey } from "@/shared/lib/pubkey"; import { Button } from "@/shared/ui/button"; import { AddChannelBotDialog } from "./AddChannelBotDialog"; @@ -18,10 +19,6 @@ type ChannelMembersBarProps = { onManageChannel: () => void; }; -function normalizePubkey(pubkey: string) { - return pubkey.trim().toLowerCase(); -} - function CountStat({ count, icon: Icon, diff --git a/desktop/src/features/channels/ui/useInChannelPersonaIds.ts b/desktop/src/features/channels/ui/useInChannelPersonaIds.ts new file mode 100644 index 00000000..4f5a68a2 --- /dev/null +++ b/desktop/src/features/channels/ui/useInChannelPersonaIds.ts @@ -0,0 +1,38 @@ +import * as React from "react"; + +import { useManagedAgentsQuery } from "@/features/agents/hooks"; +import { useChannelMembersQuery } from "@/features/channels/hooks"; +import { normalizePubkey } from "@/shared/lib/pubkey"; + +/** + * Returns a `Set` of persona IDs whose managed agents are already + * members of the given channel. The query is only enabled when `enabled` is + * true (e.g. when the dialog is open). + */ +export function useInChannelPersonaIds( + channelId: string | null, + enabled: boolean, +): ReadonlySet { + const membersQuery = useChannelMembersQuery(channelId, enabled); + const managedAgentsQuery = useManagedAgentsQuery(); + + return React.useMemo(() => { + const members = membersQuery.data; + const managedAgents = managedAgentsQuery.data; + if (!members || !managedAgents) { + return new Set(); + } + + const memberPubkeys = new Set( + members.map((m) => normalizePubkey(m.pubkey)), + ); + + const ids = new Set(); + for (const agent of managedAgents) { + if (agent.personaId && memberPubkeys.has(normalizePubkey(agent.pubkey))) { + ids.add(agent.personaId); + } + } + return ids; + }, [membersQuery.data, managedAgentsQuery.data]); +} diff --git a/desktop/src/features/presence/hooks.ts b/desktop/src/features/presence/hooks.ts index 2b5b3944..680c2c14 100644 --- a/desktop/src/features/presence/hooks.ts +++ b/desktop/src/features/presence/hooks.ts @@ -3,6 +3,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { relayClient } from "@/shared/api/relayClient"; import { getPresence, setPresence } from "@/shared/api/tauri"; +import { normalizePubkey } from "@/shared/lib/pubkey"; import type { PresenceLookup, PresenceStatus } from "@/shared/api/types"; const PRESENCE_HEARTBEAT_INTERVAL_MS = 60_000; @@ -14,7 +15,7 @@ const PRESENCE_PREFERENCE_STORAGE_KEY = "sprout-presence-preference"; type PresencePreference = "auto" | "away" | "offline" | null; function normalizePubkeys(pubkeys: string[]) { - return [...new Set(pubkeys.map((pubkey) => pubkey.trim().toLowerCase()))] + return [...new Set(pubkeys.map((pubkey) => normalizePubkey(pubkey)))] .filter((pubkey) => pubkey.length > 0) .sort(); } diff --git a/desktop/src/features/profile/lib/identity.ts b/desktop/src/features/profile/lib/identity.ts index c40ae2ec..adee22e3 100644 --- a/desktop/src/features/profile/lib/identity.ts +++ b/desktop/src/features/profile/lib/identity.ts @@ -1,4 +1,5 @@ import type { Profile, UserProfileSummary } from "@/shared/api/types"; +import { normalizePubkey } from "@/shared/lib/pubkey"; export type UserProfileLookup = Record; @@ -6,10 +7,6 @@ export function truncatePubkey(pubkey: string) { return `${pubkey.slice(0, 8)}…${pubkey.slice(-4)}`; } -function normalizePubkey(pubkey: string) { - return pubkey.toLowerCase(); -} - function getResolvedProfile( pubkey: string, profiles: UserProfileLookup | undefined, diff --git a/desktop/src/shared/api/tauriTeams.ts b/desktop/src/shared/api/tauriTeams.ts index 17f92b30..2586d3a9 100644 --- a/desktop/src/shared/api/tauriTeams.ts +++ b/desktop/src/shared/api/tauriTeams.ts @@ -57,3 +57,27 @@ export async function updateTeam(input: UpdateTeamInput): Promise { export async function deleteTeam(id: string): Promise { await invokeTauri("delete_team", { id }); } + +export type ParsedTeamPreview = { + name: string; + description: string | null; + personas: Array<{ + display_name: string; + system_prompt: string; + avatar_url: string | null; + }>; +}; + +export async function exportTeamToJson(id: string): Promise { + return invokeTauri("export_team_to_json", { id }); +} + +export async function parseTeamFile( + fileBytes: number[], + fileName: string, +): Promise { + return invokeTauri("parse_team_file", { + fileBytes, + fileName, + }); +} diff --git a/desktop/src/shared/hooks/useFileImportZone.ts b/desktop/src/shared/hooks/useFileImportZone.ts new file mode 100644 index 00000000..2e99a144 --- /dev/null +++ b/desktop/src/shared/hooks/useFileImportZone.ts @@ -0,0 +1,60 @@ +import * as React from "react"; + +type FileImportZoneOptions = { + /** Called with the raw byte array and original file name. */ + onImportFile: (fileBytes: number[], fileName: string) => void; +}; + +/** + * Shared drag-and-drop + file-picker infrastructure for import sections + * (PersonasSection, TeamsSection). Returns state, handlers, and a ref for + * the hidden ``. + */ +export function useFileImportZone({ onImportFile }: FileImportZoneOptions) { + const fileInputRef = React.useRef(null); + const [isDragOver, setIsDragOver] = React.useState(false); + + async function importFile(file: File) { + const buffer = await file.arrayBuffer(); + const bytes = Array.from(new Uint8Array(buffer)); + onImportFile(bytes, file.name); + } + + const dropHandlers = { + onDragLeave: () => setIsDragOver(false), + onDragOver: (e: React.DragEvent) => { + e.preventDefault(); + setIsDragOver(true); + }, + onDrop: (e: React.DragEvent) => { + e.preventDefault(); + setIsDragOver(false); + const file = e.dataTransfer.files[0]; + if (file) { + void importFile(file); + } + }, + }; + + function handleFileChange(e: React.ChangeEvent) { + const file = e.target.files?.[0]; + if (!file) return; + + void importFile(file); + + // Reset so the same file can be re-selected + e.target.value = ""; + } + + function openFilePicker() { + fileInputRef.current?.click(); + } + + return { + fileInputRef, + isDragOver, + dropHandlers, + handleFileChange, + openFilePicker, + }; +} diff --git a/desktop/src/shared/lib/authors.ts b/desktop/src/shared/lib/authors.ts index 87658244..b49f3a9a 100644 --- a/desktop/src/shared/lib/authors.ts +++ b/desktop/src/shared/lib/authors.ts @@ -1,8 +1,6 @@ -const PUBKEY_HEX_RE = /^[0-9a-f]{64}$/i; +import { normalizePubkey } from "@/shared/lib/pubkey"; -function normalizePubkey(pubkey: string) { - return pubkey.toLowerCase(); -} +const PUBKEY_HEX_RE = /^[0-9a-f]{64}$/i; function getTaggedPubkey( tags: string[][], diff --git a/desktop/src/shared/lib/fileMagic.ts b/desktop/src/shared/lib/fileMagic.ts new file mode 100644 index 00000000..edbc1ad1 --- /dev/null +++ b/desktop/src/shared/lib/fileMagic.ts @@ -0,0 +1,20 @@ +/** PNG file signature (first 4 bytes). */ +const PNG_MAGIC = [0x89, 0x50, 0x4e, 0x47] as const; + +/** Opening brace `{` — first byte of a JSON file. */ +const JSON_FIRST_BYTE = 0x7b; + +function matchesMagic( + bytes: number[] | readonly number[], + magic: readonly number[], +): boolean { + return magic.every((b, i) => bytes[i] === b); +} + +/** Return true when `bytes` looks like a single-item file (PNG or JSON). */ +export function isSingleItemFile(bytes: number[] | readonly number[]): boolean { + return ( + matchesMagic(bytes, PNG_MAGIC) || + (bytes.length > 0 && bytes[0] === JSON_FIRST_BYTE) + ); +} diff --git a/desktop/src/shared/lib/promptPreview.ts b/desktop/src/shared/lib/promptPreview.ts new file mode 100644 index 00000000..c19ad3b6 --- /dev/null +++ b/desktop/src/shared/lib/promptPreview.ts @@ -0,0 +1,15 @@ +/** + * Extract the first non-empty line from a system prompt for use as a + * short preview string. Returns the trimmed input when every line is empty. + */ +export function promptPreview(prompt: string): string { + const trimmed = prompt.trim(); + if (!trimmed) { + return ""; + } + const [firstLine] = trimmed + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0); + return firstLine ?? trimmed; +} diff --git a/desktop/src/shared/lib/pubkey.ts b/desktop/src/shared/lib/pubkey.ts new file mode 100644 index 00000000..07214037 --- /dev/null +++ b/desktop/src/shared/lib/pubkey.ts @@ -0,0 +1,9 @@ +/** + * Canonical pubkey normalisation. + * + * Hex pubkeys are case-insensitive, but callers compare them with `===`. + * Trimming guards against stray whitespace from user input or tag parsing. + */ +export function normalizePubkey(pubkey: string): string { + return pubkey.trim().toLowerCase(); +} diff --git a/desktop/src/shared/ui/import-status-icon.tsx b/desktop/src/shared/ui/import-status-icon.tsx new file mode 100644 index 00000000..6b1f011f --- /dev/null +++ b/desktop/src/shared/ui/import-status-icon.tsx @@ -0,0 +1,27 @@ +import { Check, Loader2, X } from "lucide-react"; + +export type ImportItemStatus = "pending" | "importing" | "done" | "error"; + +/** + * Tiny status indicator used in sequential-import dialogs (persona batch + * import, team import). Renders a spinner while importing, a check on + * success, an X on failure, and nothing while pending. + */ +export function ImportStatusIcon({ + status, +}: { + status: ImportItemStatus | undefined; +}) { + switch (status) { + case "importing": + return ( + + ); + case "done": + return ; + case "error": + return ; + default: + return null; + } +}