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 (