From 00e869c723e86bfbc1f3c031258fd9b9eed8795d Mon Sep 17 00:00:00 2001 From: Wes Date: Sat, 21 Mar 2026 10:42:52 -0700 Subject: [PATCH 01/11] feat(desktop): show 'already in channel' indicator + team export/import backend Feature 1: Add agent-already-in-channel indicators - AddAgentToChannelDialog: fetch channel members, show 'Already a member' badge, contextual 'Re-add to channel' button text - AddChannelBotDialog: use new useInChannelPersonaIds hook to compute in-channel persona IDs, pass to PersonasSection and TeamsSection - AddChannelBotPersonasSection: show 'In channel' badge on persona chips - AddChannelBotTeamsSection: show per-team in-channel count and per-persona check marks in tooltip - New useInChannelPersonaIds hook extracts shared logic Refactor: extract shared normalizePubkey utility - New shared/lib/pubkey.ts with canonical normalizePubkey function - Deduplicate from channelAgents.ts, ChannelMembersBar.tsx, presence/hooks.ts, profile/lib/identity.ts, shared/lib/authors.ts Feature 3 (backend): team export/import Tauri commands - managed_agents/teams.rs: add encode_team_json, parse_team_json, types (TeamPersonaPreview, ParsedTeamPreview) with 10 unit tests - commands/teams.rs: add export_team_to_json (async, save dialog) and parse_team_file commands - Register new commands in lib.rs --- desktop/src-tauri/src/commands/teams.rs | 84 +++++- desktop/src-tauri/src/lib.rs | 2 + desktop/src-tauri/src/managed_agents/teams.rs | 255 +++++++++++++++++- desktop/src/features/agents/channelAgents.ts | 5 +- .../agents/ui/AddAgentToChannelDialog.tsx | 34 ++- .../channels/ui/AddChannelBotDialog.tsx | 7 + .../ui/AddChannelBotPersonasSection.tsx | 23 +- .../channels/ui/AddChannelBotTeamsSection.tsx | 61 +++-- .../channels/ui/ChannelMembersBar.tsx | 5 +- .../channels/ui/useInChannelPersonaIds.ts | 38 +++ desktop/src/features/presence/hooks.ts | 3 +- desktop/src/features/profile/lib/identity.ts | 5 +- desktop/src/shared/lib/authors.ts | 6 +- desktop/src/shared/lib/pubkey.ts | 9 + 14 files changed, 497 insertions(+), 40 deletions(-) create mode 100644 desktop/src/features/channels/ui/useInChannelPersonaIds.ts create mode 100644 desktop/src/shared/lib/pubkey.ts diff --git a/desktop/src-tauri/src/commands/teams.rs b/desktop/src-tauri/src/commands/teams.rs index 2a87e78b..6e237cb5 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 tauri_plugin_dialog::DialogExt; use uuid::Uuid; 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,81 @@ pub fn delete_team( } save_teams(&app, &teams) } + +// --------------------------------------------------------------------------- +// Import / Export +// --------------------------------------------------------------------------- + +#[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)?; + + // Slugify team name for filename. + let slug: String = team + .name + .to_lowercase() + .chars() + .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }) + .collect::() + .trim_matches('-') + .to_string(); + let slug = if slug.is_empty() { "team" } 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}.team.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) +} + +#[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() > 5 * 1024 * 1024 { + 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/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/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..da44074b 100644 --- a/desktop/src/features/channels/ui/AddChannelBotPersonasSection.tsx +++ b/desktop/src/features/channels/ui/AddChannelBotPersonasSection.tsx @@ -1,4 +1,4 @@ -import { Bot } from "lucide-react"; +import { Bot, Check } from "lucide-react"; import type { AgentPersona } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; @@ -73,6 +73,7 @@ function SelectionChipButton({ type AddChannelBotPersonasSectionProps = { canToggleSelections: boolean; + inChannelPersonaIds?: ReadonlySet; includeGeneric: boolean; isLoading: boolean; onToggleGeneric: () => void; @@ -83,6 +84,7 @@ type AddChannelBotPersonasSectionProps = { export function AddChannelBotPersonasSection({ canToggleSelections, + inChannelPersonaIds, includeGeneric, isLoading, onToggleGeneric, @@ -130,6 +132,7 @@ export function AddChannelBotPersonasSection({ {personas.map((persona) => { const isSelected = selectedPersonaIds.includes(persona.id); + const isInChannel = inChannelPersonaIds?.has(persona.id) ?? false; return ( @@ -142,6 +145,19 @@ export function AddChannelBotPersonasSection({ selected={isSelected} > {persona.displayName} + {isInChannel ? ( + + + In channel + + ) : null} @@ -156,6 +172,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..1e07520d 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,6 +94,11 @@ 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 ( @@ -122,6 +129,21 @@ export function AddChannelBotTeamsSection({ > ({validIds.length}) + {inChannelCount > 0 ? ( + + + {allInChannel + ? "All in channel" + : `${inChannelCount} in channel`} + + ) : null} @@ -134,21 +156,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/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/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(); +} From f37e1df4f821b2960c96bcffaf192b95564a25cd Mon Sep 17 00:00:00 2001 From: Wes Date: Sat, 21 Mar 2026 13:52:56 -0700 Subject: [PATCH 02/11] feat(desktop): async import loaders + team export/import UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Feature 2: Async import flow with loaders - PersonaDialog: parsing overlay with spinner, submit disabled during parse - BatchImportDialog: per-item status indicators (spinner/✓/✗) during import Feature 3 Frontend: Team export/import UI - TeamsSection: Export menu item + Import button with file picker - TeamImportDialog: full import dialog with per-persona progress tracking - tauriTeams.ts: exportTeamToJson + parseTeamFile API functions - useTeamActions: export/import/complete handlers extracted from AgentsView Rust refactors: - Extracted slugify() into shared util with 5 unit tests - Extracted save_json_with_dialog() into commands/export_util.rs - Deduplicated ~60 lines across persona and team export commands Fixes from review: - TeamImportDialog: disable Import button on error (prevents duplicate personas) - TeamImportDialog: use stable index-based React keys - Team creation auto-retries once on failure with clear error message --- desktop/scripts/check-file-sizes.mjs | 2 +- desktop/src-tauri/src/commands/export_util.rs | 33 +++ desktop/src-tauri/src/commands/mod.rs | 1 + desktop/src-tauri/src/commands/personas.rs | 39 +-- desktop/src-tauri/src/commands/teams.rs | 44 +--- desktop/src-tauri/src/util.rs | 58 +++++ desktop/src/features/agents/ui/AgentsView.tsx | 16 ++ .../features/agents/ui/BatchImportDialog.tsx | 46 +++- .../src/features/agents/ui/PersonaDialog.tsx | 20 +- .../features/agents/ui/TeamImportDialog.tsx | 234 ++++++++++++++++++ .../src/features/agents/ui/TeamsSection.tsx | 81 ++++-- .../src/features/agents/ui/useTeamActions.ts | 90 +++++++ desktop/src/shared/api/tauriTeams.ts | 24 ++ 13 files changed, 598 insertions(+), 90 deletions(-) create mode 100644 desktop/src-tauri/src/commands/export_util.rs create mode 100644 desktop/src/features/agents/ui/TeamImportDialog.tsx diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index aeebb714..719aa370 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -43,7 +43,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", 740], // 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 6e237cb5..4645747d 100644 --- a/desktop/src-tauri/src/commands/teams.rs +++ b/desktop/src-tauri/src/commands/teams.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::{ @@ -115,6 +115,8 @@ pub fn delete_team( // Import / Export // --------------------------------------------------------------------------- +const MAX_TEAM_JSON_BYTES: usize = 5 * 1024 * 1024; + #[tauri::command] pub async fn export_team_to_json( id: String, @@ -138,41 +140,9 @@ pub async fn export_team_to_json( let json_bytes = encode_team_json(&team, &personas)?; - // Slugify team name for filename. - let slug: String = team - .name - .to_lowercase() - .chars() - .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }) - .collect::() - .trim_matches('-') - .to_string(); - let slug = if slug.is_empty() { "team" } 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}.team.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(&team.name, "team", 50); + let filename = format!("{slug}.team.json"); + save_json_with_dialog(&app, &filename, &json_bytes).await } #[tauri::command] @@ -183,7 +153,7 @@ pub fn parse_team_file( if file_bytes.is_empty() { return Err("File is empty.".to_string()); } - if file_bytes.len() > 5 * 1024 * 1024 { + 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/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/ui/AgentsView.tsx b/desktop/src/features/agents/ui/AgentsView.tsx index 426783d5..bab45742 100644 --- a/desktop/src/features/agents/ui/AgentsView.tsx +++ b/desktop/src/features/agents/ui/AgentsView.tsx @@ -22,6 +22,7 @@ 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 type { AgentPersona, Channel, @@ -43,6 +44,7 @@ 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"; @@ -399,6 +401,7 @@ export function AgentsView() { updatePersonaMutation.isPending || deletePersonaMutation.isPending || exportPersonaJsonMutation.isPending || + teamActions.exportTeamJsonMutation.isPending || teamActions.createTeamMutation.isPending || teamActions.updateTeamMutation.isPending || teamActions.deleteTeamMutation.isPending; @@ -507,6 +510,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} @@ -717,6 +722,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..44a9da91 100644 --- a/desktop/src/features/agents/ui/BatchImportDialog.tsx +++ b/desktop/src/features/agents/ui/BatchImportDialog.tsx @@ -1,5 +1,12 @@ import * as React from "react"; -import { AlertTriangle, ChevronDown, ChevronRight } from "lucide-react"; +import { + AlertTriangle, + Check, + ChevronDown, + ChevronRight, + Loader2, + X, +} from "lucide-react"; import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; import type { ParsePersonaFilesResult } from "@/shared/api/tauriPersonas"; @@ -34,6 +41,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 +57,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 +71,16 @@ export function BatchImportDialog({ setStatus("importing"); setErrorMessage(null); + // Initialize all selected items as pending + const initialStatuses = new Map< + number, + "pending" | "importing" | "done" | "error" + >(); + for (const index of selected) { + initialStatuses.set(index, "pending"); + } + setItemStatuses(new Map(initialStatuses)); + let completed = 0; for (const index of selected) { @@ -68,6 +89,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 +103,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 +195,13 @@ export function BatchImportDialog({

) : null} + {itemStatuses.get(index) === "importing" ? ( + + ) : itemStatuses.get(index) === "done" ? ( + + ) : itemStatuses.get(index) === "error" ? ( + + ) : null} {isExpanded ? (
diff --git a/desktop/src/features/agents/ui/PersonaDialog.tsx b/desktop/src/features/agents/ui/PersonaDialog.tsx index a866ef3a..e67d7f36 100644 --- a/desktop/src/features/agents/ui/PersonaDialog.tsx +++ b/desktop/src/features/agents/ui/PersonaDialog.tsx @@ -1,4 +1,5 @@ import * as React from "react"; +import { Loader2 } from "lucide-react"; import type { ParsePersonaFilesResult } from "@/shared/api/tauriPersonas"; import { parsePersonaFiles } from "@/shared/api/tauriPersonas"; @@ -57,6 +58,7 @@ export function PersonaDialog({ const [avatarUrl, setAvatarUrl] = React.useState(""); const [systemPrompt, setSystemPrompt] = React.useState(""); const [isDragOver, setIsDragOver] = React.useState(false); + const [isParsing, setIsParsing] = React.useState(false); const [importError, setImportError] = React.useState(null); React.useEffect(() => { @@ -75,6 +77,7 @@ export function PersonaDialog({ setAvatarUrl(""); setSystemPrompt(""); setIsDragOver(false); + setIsParsing(false); setImportError(null); } @@ -100,6 +103,7 @@ export function PersonaDialog({ } setImportError(null); + setIsParsing(true); try { const buffer = await file.arrayBuffer(); @@ -135,6 +139,8 @@ export function PersonaDialog({ setImportError( err instanceof Error ? err.message : "Failed to parse file.", ); + } finally { + setIsParsing(false); } } @@ -174,7 +180,16 @@ export function PersonaDialog({ onDrop={(e: React.DragEvent) => void handleDrop(e)} >
- {isDragOver && enableImportDrop ? ( + {isParsing ? ( +
+
+ +

+ Parsing file... +

+
+
+ ) : isDragOver && enableImportDrop ? (

Drop .persona.json, .persona.png, or .zip @@ -279,7 +294,8 @@ export function PersonaDialog({ disabled={ displayName.trim().length === 0 || systemPrompt.trim().length === 0 || - isPending + isPending || + isParsing } onClick={() => void handleSubmit()} size="sm" diff --git a/desktop/src/features/agents/ui/TeamImportDialog.tsx b/desktop/src/features/agents/ui/TeamImportDialog.tsx new file mode 100644 index 00000000..fe26ba7b --- /dev/null +++ b/desktop/src/features/agents/ui/TeamImportDialog.tsx @@ -0,0 +1,234 @@ +import * as React from "react"; +import { Check, Loader2, Users, X } from "lucide-react"; + +import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; +import type { ParsedTeamPreview } from "@/shared/api/tauriTeams"; +import { createPersona } from "@/shared/api/tauriPersonas"; +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; +}; + +function promptPreview(prompt: string) { + const [firstLine] = prompt + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0); + return firstLine ?? prompt.trim(); +} + +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< + number, + "pending" | "importing" | "done" | "error" + >(); + 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)} +

+
+ {itemStatuses.get(index) === "importing" ? ( + + ) : itemStatuses.get(index) === "done" ? ( + + ) : itemStatuses.get(index) === "error" ? ( + + ) : null} +
+ ))} +
+
+ ) : null} + + {errorMessage ? ( +

+ {errorMessage} +

+ ) : null} +
+ +
+ + +
+
+
+
+ ); +} diff --git a/desktop/src/features/agents/ui/TeamsSection.tsx b/desktop/src/features/agents/ui/TeamsSection.tsx index 0173488a..b85595d2 100644 --- a/desktop/src/features/agents/ui/TeamsSection.tsx +++ b/desktop/src/features/agents/ui/TeamsSection.tsx @@ -1,11 +1,14 @@ +import * as React from "react"; import { CopyPlus, + Download, Ellipsis, Info, Pencil, Plus, Rocket, Trash2, + Upload, Users, } from "lucide-react"; @@ -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,9 +60,27 @@ export function TeamsSection({ onCreate, onDuplicate, onEdit, + onExport, onDelete, onAddToChannel, + onImportFile, }: TeamsSectionProps) { + const fileInputRef = React.useRef(null); + + function handleFileChange(e: React.ChangeEvent) { + const file = e.target.files?.[0]; + if (!file) return; + + void (async () => { + const buffer = await file.arrayBuffer(); + const bytes = Array.from(new Uint8Array(buffer)); + onImportFile(bytes, file.name); + })(); + + // Reset so the same file can be re-selected + e.target.value = ""; + } + return (
@@ -67,20 +90,43 @@ export function TeamsSection({ Named groups of personas you can deploy to a channel together.

- - - - - Create team - +
+ + + + + + Import team + + + + + + Create team + +
{isLoading ? ( @@ -197,6 +243,13 @@ export function TeamsSection({ Duplicate + onExport(team)} + > + + Export + 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/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, + }); +} From fd800fd2777dc492aa469b70c2ea10af2cf95b67 Mon Sep 17 00:00:00 2001 From: Wes Date: Sat, 21 Mar 2026 14:02:42 -0700 Subject: [PATCH 03/11] refactor(desktop): extract shared promptPreview and ImportStatusIcon Deduplicate promptPreview() from PersonasSection, TeamImportDialog, and AddChannelBotPersonasSection into shared/lib/promptPreview.ts. Extract the Loader2/Check/X status icon pattern from BatchImportDialog and TeamImportDialog into shared/ui/import-status-icon.tsx with a reusable ImportStatusIcon component and ImportItemStatus type. --- .../features/agents/ui/BatchImportDialog.tsx | 27 +++++------------ .../features/agents/ui/PersonasSection.tsx | 13 +------- .../features/agents/ui/TeamImportDialog.tsx | 30 ++++++------------- .../ui/AddChannelBotPersonasSection.tsx | 10 +------ desktop/src/shared/lib/promptPreview.ts | 15 ++++++++++ desktop/src/shared/ui/import-status-icon.tsx | 27 +++++++++++++++++ 6 files changed, 61 insertions(+), 61 deletions(-) create mode 100644 desktop/src/shared/lib/promptPreview.ts create mode 100644 desktop/src/shared/ui/import-status-icon.tsx diff --git a/desktop/src/features/agents/ui/BatchImportDialog.tsx b/desktop/src/features/agents/ui/BatchImportDialog.tsx index 44a9da91..43bd7239 100644 --- a/desktop/src/features/agents/ui/BatchImportDialog.tsx +++ b/desktop/src/features/agents/ui/BatchImportDialog.tsx @@ -1,12 +1,10 @@ import * as React from "react"; +import { AlertTriangle, ChevronDown, ChevronRight } from "lucide-react"; + import { - AlertTriangle, - Check, - ChevronDown, - ChevronRight, - Loader2, - X, -} from "lucide-react"; + ImportStatusIcon, + type ImportItemStatus, +} from "@/shared/ui/import-status-icon"; import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; import type { ParsePersonaFilesResult } from "@/shared/api/tauriPersonas"; @@ -42,7 +40,7 @@ export function BatchImportDialog({ >("idle"); const [importedCount, setImportedCount] = React.useState(0); const [itemStatuses, setItemStatuses] = React.useState< - Map + Map >(new Map()); const [errorMessage, setErrorMessage] = React.useState(null); const [expandedIndex, setExpandedIndex] = React.useState(null); @@ -72,10 +70,7 @@ export function BatchImportDialog({ setErrorMessage(null); // Initialize all selected items as pending - const initialStatuses = new Map< - number, - "pending" | "importing" | "done" | "error" - >(); + const initialStatuses = new Map(); for (const index of selected) { initialStatuses.set(index, "pending"); } @@ -195,13 +190,7 @@ export function BatchImportDialog({

) : null}
- {itemStatuses.get(index) === "importing" ? ( - - ) : itemStatuses.get(index) === "done" ? ( - - ) : itemStatuses.get(index) === "error" ? ( - - ) : null} + {isExpanded ? (
diff --git a/desktop/src/features/agents/ui/PersonasSection.tsx b/desktop/src/features/agents/ui/PersonasSection.tsx index 6b4ec166..e9ce44f3 100644 --- a/desktop/src/features/agents/ui/PersonasSection.tsx +++ b/desktop/src/features/agents/ui/PersonasSection.tsx @@ -10,6 +10,7 @@ import { import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; import type { AgentPersona } from "@/shared/api/types"; +import { promptPreview } from "@/shared/lib/promptPreview"; import { Button } from "@/shared/ui/button"; import { DropdownMenu, @@ -20,18 +21,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; diff --git a/desktop/src/features/agents/ui/TeamImportDialog.tsx b/desktop/src/features/agents/ui/TeamImportDialog.tsx index fe26ba7b..2336a655 100644 --- a/desktop/src/features/agents/ui/TeamImportDialog.tsx +++ b/desktop/src/features/agents/ui/TeamImportDialog.tsx @@ -1,9 +1,14 @@ import * as React from "react"; -import { Check, Loader2, Users, X } from "lucide-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, @@ -25,14 +30,6 @@ type TeamImportDialogProps = { ) => void; }; -function promptPreview(prompt: string) { - const [firstLine] = prompt - .split("\n") - .map((line) => line.trim()) - .filter((line) => line.length > 0); - return firstLine ?? prompt.trim(); -} - export function TeamImportDialog({ fileName, open, @@ -45,7 +42,7 @@ export function TeamImportDialog({ >("idle"); const [importedCount, setImportedCount] = React.useState(0); const [itemStatuses, setItemStatuses] = React.useState< - Map + Map >(new Map()); const [errorMessage, setErrorMessage] = React.useState(null); @@ -69,10 +66,7 @@ export function TeamImportDialog({ setStatus("importing"); setErrorMessage(null); - const initialStatuses = new Map< - number, - "pending" | "importing" | "done" | "error" - >(); + const initialStatuses = new Map(); for (let i = 0; i < personas.length; i++) { initialStatuses.set(i, "pending"); } @@ -181,13 +175,7 @@ export function TeamImportDialog({ {promptPreview(persona.system_prompt)}

- {itemStatuses.get(index) === "importing" ? ( - - ) : itemStatuses.get(index) === "done" ? ( - - ) : itemStatuses.get(index) === "error" ? ( - - ) : null} +
))} diff --git a/desktop/src/features/channels/ui/AddChannelBotPersonasSection.tsx b/desktop/src/features/channels/ui/AddChannelBotPersonasSection.tsx index da44074b..91a6cda5 100644 --- a/desktop/src/features/channels/ui/AddChannelBotPersonasSection.tsx +++ b/desktop/src/features/channels/ui/AddChannelBotPersonasSection.tsx @@ -2,6 +2,7 @@ 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; 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/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; + } +} From d851a32e9824467de5410ec6b1070b7aeb42bb63 Mon Sep 17 00:00:00 2001 From: Wes Date: Sat, 21 Mar 2026 14:13:41 -0700 Subject: [PATCH 04/11] =?UTF-8?q?fix(desktop):=20fix=20TeamImportDialog=20?= =?UTF-8?q?layout=20overflow=20=E2=80=94=20buttons=20unreachable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dialog content could overflow its container when importing teams with many personas, pushing the footer buttons (Cancel / Import) below the visible area and making them unclickable. Fixes: - Add min-h-0 to flex container for proper CSS flex shrinking - Add shrink-0 to header and footer to prevent compression - Reduce max-h from 85vh to 80vh for more breathing room - Add min-h-0 to scrollable content area for flex overflow --- .../features/agents/ui/TeamImportDialog.tsx | 180 +++++++++--------- 1 file changed, 89 insertions(+), 91 deletions(-) diff --git a/desktop/src/features/agents/ui/TeamImportDialog.tsx b/desktop/src/features/agents/ui/TeamImportDialog.tsx index 2336a655..e29d8e48 100644 --- a/desktop/src/features/agents/ui/TeamImportDialog.tsx +++ b/desktop/src/features/agents/ui/TeamImportDialog.tsx @@ -118,103 +118,101 @@ export function TeamImportDialog({ return ( - -
- - Import Team - - Preview the team from {fileName || "file"} before importing. - - - -
- {preview ? ( -
-
- -
-

- {preview.name} + + + Import Team + + Preview the team from {fileName || "file"} before importing. + + + +

+ {preview ? ( +
+
+ +
+

+ {preview.name} +

+ {preview.description ? ( +

+ {preview.description}

- {preview.description ? ( -

- {preview.description} -

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

Personas to import

-

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

-
+
+

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

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

+ {persona.display_name} +

+

+ {promptPreview(persona.system_prompt)} +

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

- {errorMessage} -

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

+ {errorMessage} +

+ ) : null} +
+ +
+ +
From c9d5ac99da1e2e53b2b03f78e2b794c26768a077 Mon Sep 17 00:00:00 2001 From: Wes Date: Sun, 22 Mar 2026 09:25:43 -0700 Subject: [PATCH 05/11] fix(desktop): disable in-channel personas, add team drag/drop import + drop zone hints --- .../src/features/agents/ui/PersonaDialog.tsx | 12 ++++- .../src/features/agents/ui/TeamsSection.tsx | 48 ++++++++++++++++--- .../ui/AddChannelBotPersonasSection.tsx | 2 +- .../channels/ui/AddChannelBotTeamsSection.tsx | 6 ++- 4 files changed, 58 insertions(+), 10 deletions(-) diff --git a/desktop/src/features/agents/ui/PersonaDialog.tsx b/desktop/src/features/agents/ui/PersonaDialog.tsx index e67d7f36..d2c37fbb 100644 --- a/desktop/src/features/agents/ui/PersonaDialog.tsx +++ b/desktop/src/features/agents/ui/PersonaDialog.tsx @@ -1,5 +1,5 @@ import * as React from "react"; -import { Loader2 } from "lucide-react"; +import { Loader2, Upload } from "lucide-react"; import type { ParsePersonaFilesResult } from "@/shared/api/tauriPersonas"; import { parsePersonaFiles } from "@/shared/api/tauriPersonas"; @@ -268,6 +268,16 @@ export function PersonaDialog({ /> + {enableImportDrop ? ( +
+ +

+ Drag a .persona.json, .persona.png, or .zip onto this dialog + to import. +

+
+ ) : null} + {error ? (

{error.message} diff --git a/desktop/src/features/agents/ui/TeamsSection.tsx b/desktop/src/features/agents/ui/TeamsSection.tsx index b85595d2..b1c6791f 100644 --- a/desktop/src/features/agents/ui/TeamsSection.tsx +++ b/desktop/src/features/agents/ui/TeamsSection.tsx @@ -66,28 +66,59 @@ export function TeamsSection({ onImportFile, }: TeamsSectionProps) { 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); + } + + async function handleDrop(e: React.DragEvent) { + e.preventDefault(); + setIsDragOver(false); + + const file = e.dataTransfer.files[0]; + if (!file) return; + + await importFile(file); + } function handleFileChange(e: React.ChangeEvent) { const file = e.target.files?.[0]; if (!file) return; - void (async () => { - const buffer = await file.arrayBuffer(); - const bytes = Array.from(new Uint8Array(buffer)); - onImportFile(bytes, file.name); - })(); + void importFile(file); // Reset so the same file can be re-selected e.target.value = ""; } return ( -

+ // biome-ignore lint/a11y/noStaticElementInteractions: drop zone for .team.json import +
setIsDragOver(false)} + onDragOver={(e: React.DragEvent) => { + e.preventDefault(); + setIsDragOver(true); + }} + onDrop={(e: React.DragEvent) => void handleDrop(e)} + > + {isDragOver ? ( +
+

+ Drop .team.json to import +

+
+ ) : null} +

Teams

- Named groups of personas you can deploy to a channel together. + Named groups of personas you can deploy to a channel together. Drop + a .team.json file to import.

@@ -274,6 +305,9 @@ export function TeamsSection({

Create a team to group personas for quick deployment to channels.

+

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

) : null} diff --git a/desktop/src/features/channels/ui/AddChannelBotPersonasSection.tsx b/desktop/src/features/channels/ui/AddChannelBotPersonasSection.tsx index 91a6cda5..da2d67f8 100644 --- a/desktop/src/features/channels/ui/AddChannelBotPersonasSection.tsx +++ b/desktop/src/features/channels/ui/AddChannelBotPersonasSection.tsx @@ -131,7 +131,7 @@ export function AddChannelBotPersonasSection({
onTogglePersona(persona.id)} selected={isSelected} diff --git a/desktop/src/features/channels/ui/AddChannelBotTeamsSection.tsx b/desktop/src/features/channels/ui/AddChannelBotTeamsSection.tsx index 1e07520d..f7a5857f 100644 --- a/desktop/src/features/channels/ui/AddChannelBotTeamsSection.tsx +++ b/desktop/src/features/channels/ui/AddChannelBotTeamsSection.tsx @@ -105,7 +105,11 @@ export function AddChannelBotTeamsSection({
onToggleTeam(validIds)} selected={allSelected} From c2b661cad97d6ed000755c124caa90b060535fac Mon Sep 17 00:00:00 2001 From: Wes Date: Sun, 22 Mar 2026 09:35:36 -0700 Subject: [PATCH 06/11] fix(desktop): cap persona grid at 4 columns and prevent Built-in badge wrapping --- desktop/src/features/agents/ui/PersonasSection.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/desktop/src/features/agents/ui/PersonasSection.tsx b/desktop/src/features/agents/ui/PersonasSection.tsx index e9ce44f3..307e5af1 100644 --- a/desktop/src/features/agents/ui/PersonasSection.tsx +++ b/desktop/src/features/agents/ui/PersonasSection.tsx @@ -70,8 +70,8 @@ export function PersonasSection({
{isLoading ? ( -
- {["first", "second", "third", "fourth", "fifth"].map((key) => ( +
+ {["first", "second", "third", "fourth"].map((key) => (
0 ? ( -
+
{personas.map((persona) => { const preview = promptPreview(persona.systemPrompt); @@ -111,7 +111,7 @@ export function PersonasSection({ {persona.displayName}

{persona.isBuiltIn ? ( - + Built-in ) : null} From fda37083759c0a9cb05d59a6518a8d2db0c71e9d Mon Sep 17 00:00:00 2001 From: Wes Date: Sun, 22 Mar 2026 09:44:11 -0700 Subject: [PATCH 07/11] feat(desktop): add drag/drop and file picker import to PersonasSection Mirror the TeamsSection import pattern on PersonasSection: - Drag/drop overlay for .persona.json, .persona.png, or .zip files - Upload button (file picker) next to the Create button - Drop zone hint text in subtitle and empty state - handlePersonaImportFile in AgentsView routes single files to PersonaDialog and multi-file archives to BatchImportDialog Bump AgentsView.tsx file size limit from 740 to 790 to accommodate the new import handler wiring. --- desktop/scripts/check-file-sizes.mjs | 2 +- desktop/src/features/agents/ui/AgentsView.tsx | 45 +++++++- .../features/agents/ui/PersonasSection.tsx | 109 +++++++++++++++--- 3 files changed, 138 insertions(+), 18 deletions(-) diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 719aa370..ed100dcc 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -43,7 +43,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", 740], // remote agent stop/delete + channel UUID resolution + presence-aware delete guard + persona/team import + ["src/features/agents/ui/AgentsView.tsx", 790], // remote agent stop/delete + channel UUID resolution + presence-aware delete guard + persona/team import + persona drag/drop handler ["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/features/agents/ui/AgentsView.tsx b/desktop/src/features/agents/ui/AgentsView.tsx index bab45742..c5e86f06 100644 --- a/desktop/src/features/agents/ui/AgentsView.tsx +++ b/desktop/src/features/agents/ui/AgentsView.tsx @@ -21,7 +21,10 @@ 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 type { AgentPersona, @@ -391,6 +394,43 @@ export function AgentsView() { void relayAgentsQuery.refetch(); } + async function handlePersonaImportFile( + fileBytes: number[], + fileName: string, + ) { + setActionNoticeMessage(null); + setActionErrorMessage(null); + try { + const result = await parsePersonaFiles(fileBytes, fileName); + const isPng = + fileBytes.length >= 4 && fileBytes[0] === 0x89 && fileBytes[1] === 0x50; + const isJson = fileBytes.length > 0 && fileBytes[0] === 0x7b; + if ((isPng || isJson) && result.personas.length === 1) { + const p = result.personas[0]; + setPersonaDialogState({ + title: `Import ${p.displayName}`, + description: "Review and save this imported persona.", + enableImportDrop: false, + 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 || @@ -473,6 +513,9 @@ export function AgentsView() { }, }); }} + onImportFile={(fileBytes, fileName) => { + void handlePersonaImportFile(fileBytes, fileName); + }} onExport={(persona) => { exportPersonaJsonMutation.mutate(persona.id, { onSuccess: (saved) => { diff --git a/desktop/src/features/agents/ui/PersonasSection.tsx b/desktop/src/features/agents/ui/PersonasSection.tsx index 307e5af1..32edc458 100644 --- a/desktop/src/features/agents/ui/PersonasSection.tsx +++ b/desktop/src/features/agents/ui/PersonasSection.tsx @@ -1,3 +1,4 @@ +import * as React from "react"; import { CopyPlus, Download, @@ -6,6 +7,7 @@ import { Pencil, Plus, Trash2, + Upload, } from "lucide-react"; import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; @@ -31,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({ @@ -43,30 +46,101 @@ export function PersonasSection({ onEdit, onExport, onDelete, + onImportFile, }: PersonasSectionProps) { + 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); + } + + async function handleDrop(e: React.DragEvent) { + e.preventDefault(); + setIsDragOver(false); + + const file = e.dataTransfer.files[0]; + if (!file) return; + + await 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 = ""; + } + return ( -
+ // biome-ignore lint/a11y/noStaticElementInteractions: drop zone for persona file import +
setIsDragOver(false)} + onDragOver={(e: React.DragEvent) => { + e.preventDefault(); + setIsDragOver(true); + }} + onDrop={(e: React.DragEvent) => void handleDrop(e)} + > + {isDragOver ? ( +
+

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

+
+ ) : null} +

Personas

- Reusable agent templates for common roles and prompts. + Reusable agent templates for common roles and prompts. Drop a file + to import.

- - - - - Create persona - +
+ + + + + + Import persona + + + + + + Create persona + +
{isLoading ? ( @@ -198,6 +272,9 @@ export function PersonasSection({

Create one to save a role, prompt, and optional avatar for reuse.

+

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

) : null} From 37d70c56948bd2dd03295d6b47c247dd9d0cb462 Mon Sep 17 00:00:00 2001 From: Wes Date: Sun, 22 Mar 2026 09:49:33 -0700 Subject: [PATCH 08/11] refactor(desktop): extract shared fileMagic constants and useFileImportZone hook --- desktop/src/features/agents/ui/AgentsView.tsx | 6 +- .../src/features/agents/ui/PersonaDialog.tsx | 13 ++-- .../features/agents/ui/PersonasSection.tsx | 50 ++++------------ .../src/features/agents/ui/TeamsSection.tsx | 50 ++++------------ desktop/src/shared/hooks/useFileImportZone.ts | 60 +++++++++++++++++++ desktop/src/shared/lib/fileMagic.ts | 24 ++++++++ 6 files changed, 112 insertions(+), 91 deletions(-) create mode 100644 desktop/src/shared/hooks/useFileImportZone.ts create mode 100644 desktop/src/shared/lib/fileMagic.ts diff --git a/desktop/src/features/agents/ui/AgentsView.tsx b/desktop/src/features/agents/ui/AgentsView.tsx index c5e86f06..b58175eb 100644 --- a/desktop/src/features/agents/ui/AgentsView.tsx +++ b/desktop/src/features/agents/ui/AgentsView.tsx @@ -25,6 +25,7 @@ import { parsePersonaFiles, type ParsePersonaFilesResult, } from "@/shared/api/tauriPersonas"; +import { isSingleItemFile } from "@/shared/lib/fileMagic"; import type { AgentPersona, @@ -402,10 +403,7 @@ export function AgentsView() { setActionErrorMessage(null); try { const result = await parsePersonaFiles(fileBytes, fileName); - const isPng = - fileBytes.length >= 4 && fileBytes[0] === 0x89 && fileBytes[1] === 0x50; - const isJson = fileBytes.length > 0 && fileBytes[0] === 0x7b; - if ((isPng || isJson) && result.personas.length === 1) { + if (isSingleItemFile(fileBytes) && result.personas.length === 1) { const p = result.personas[0]; setPersonaDialogState({ title: `Import ${p.displayName}`, diff --git a/desktop/src/features/agents/ui/PersonaDialog.tsx b/desktop/src/features/agents/ui/PersonaDialog.tsx index d2c37fbb..c6654286 100644 --- a/desktop/src/features/agents/ui/PersonaDialog.tsx +++ b/desktop/src/features/agents/ui/PersonaDialog.tsx @@ -15,17 +15,16 @@ import { DialogHeader, DialogTitle, } from "@/shared/ui/dialog"; +import { + JSON_FIRST_BYTE, + PNG_MAGIC, + ZIP_MAGIC, + matchesMagic, +} from "@/shared/lib/fileMagic"; 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; diff --git a/desktop/src/features/agents/ui/PersonasSection.tsx b/desktop/src/features/agents/ui/PersonasSection.tsx index 32edc458..7f2d6196 100644 --- a/desktop/src/features/agents/ui/PersonasSection.tsx +++ b/desktop/src/features/agents/ui/PersonasSection.tsx @@ -1,4 +1,3 @@ -import * as React from "react"; import { CopyPlus, Download, @@ -12,6 +11,7 @@ import { 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 { @@ -48,46 +48,16 @@ export function PersonasSection({ onDelete, onImportFile, }: PersonasSectionProps) { - 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); - } - - async function handleDrop(e: React.DragEvent) { - e.preventDefault(); - setIsDragOver(false); - - const file = e.dataTransfer.files[0]; - if (!file) return; - - await 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 = ""; - } + const { + fileInputRef, + isDragOver, + dropHandlers, + handleFileChange, + openFilePicker, + } = useFileImportZone({ onImportFile }); return ( - // biome-ignore lint/a11y/noStaticElementInteractions: drop zone for persona file import -
setIsDragOver(false)} - onDragOver={(e: React.DragEvent) => { - e.preventDefault(); - setIsDragOver(true); - }} - onDrop={(e: React.DragEvent) => void handleDrop(e)} - > +
{isDragOver ? (

@@ -116,7 +86,7 @@ export function PersonasSection({ - - Import persona - - - - - - Create persona - -

+ + + + + + Create persona +
{isLoading ? ( @@ -231,11 +214,23 @@ export function PersonasSection({
); })} +
) : null} {!isLoading && personas.length === 0 ? ( -
+
+ ) : null} {error ? ( diff --git a/desktop/src/features/agents/ui/TeamsSection.tsx b/desktop/src/features/agents/ui/TeamsSection.tsx index a201e1ed..8a4f7cb5 100644 --- a/desktop/src/features/agents/ui/TeamsSection.tsx +++ b/desktop/src/features/agents/ui/TeamsSection.tsx @@ -87,47 +87,30 @@ export function TeamsSection({

Teams

- Named groups of personas you can deploy to a channel together. Drop - a .team.json file to import. + Named groups of personas you can deploy to a channel together.

-
- - - - - - Import team - - - - - - Create team - -
+ + + + + + Create team +
{isLoading ? ( @@ -266,11 +249,23 @@ export function TeamsSection({
); })} +
) : null} {!isLoading && teams.length === 0 ? ( -
+
+ ) : null} {error ? ( From bd490274cc69e028f58ac43ba2aad8424957589c Mon Sep 17 00:00:00 2001 From: Wes Date: Sun, 22 Mar 2026 10:08:25 -0700 Subject: [PATCH 10/11] style(desktop): accent color on import cards + breathing room on drop overlay border --- desktop/src/features/agents/ui/PersonasSection.tsx | 6 +++--- desktop/src/features/agents/ui/TeamsSection.tsx | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/desktop/src/features/agents/ui/PersonasSection.tsx b/desktop/src/features/agents/ui/PersonasSection.tsx index 7044a8ca..40c978ae 100644 --- a/desktop/src/features/agents/ui/PersonasSection.tsx +++ b/desktop/src/features/agents/ui/PersonasSection.tsx @@ -59,7 +59,7 @@ export function PersonasSection({ return (
{isDragOver ? ( -
+

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

@@ -215,7 +215,7 @@ export function PersonasSection({ ); })}