feat(desktop): persona & team improvements — channel indicators, async imports, team export/import (#156)

This commit is contained in:
Wes
2026-03-22 10:30:16 -07:00
committed by GitHub
parent dfe32bfb2c
commit 0de6487388
31 changed files with 1225 additions and 232 deletions

View File

@@ -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
]);

View File

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

View File

@@ -5,6 +5,7 @@ mod agents;
mod canvas;
mod channels;
mod dms;
mod export_util;
mod identity;
mod media;
mod messages;

View File

@@ -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::<String>()
.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
}

View File

@@ -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<bool, String> {
// 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<u8>,
_file_name: String,
) -> Result<ParsedTeamPreview, String> {
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)
}

View File

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

View File

@@ -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<String>,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct ParsedTeamPreview {
pub name: String,
pub description: Option<String>,
pub personas: Vec<TeamPersonaPreview>,
}
fn teams_store_path(app: &AppHandle) -> Result<PathBuf, String> {
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<Vec<u8>, String> {
let resolved_personas: Vec<serde_json::Value> = 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<ParsedTeamPreview, String> {
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");
}
}

View File

@@ -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::<String>()
.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());

View File

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

View File

@@ -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({
</p>
</div>
{isAlreadyMember ? (
<div className="flex items-center gap-2 rounded-lg border border-border/50 bg-muted/30 px-3 py-2 text-sm text-muted-foreground">
<span>✓</span>
<span>Already a member of this channel</span>
</div>
) : null}
<div className="space-y-1.5">
<label
className="text-sm font-medium"
@@ -202,7 +228,11 @@ export function AddAgentToChannelDialog({
size="sm"
type="button"
>
{attachAgentMutation.isPending ? "Adding..." : "Add to channel"}
{attachAgentMutation.isPending
? "Adding..."
: isAlreadyMember
? "Re-add to channel"
: "Add to channel"}
</Button>
</div>
</div>

View File

@@ -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() {
/>
<PersonaDialog
description={personaDialogState?.description ?? ""}
enableImportDrop={personaDialogState?.enableImportDrop ?? false}
error={
updatePersonaMutation.error instanceof Error
? updatePersonaMutation.error
@@ -625,11 +665,6 @@ export function AgentsView() {
isPending={
createPersonaMutation.isPending || updatePersonaMutation.isPending
}
onBatchImport={(result, fileName) => {
setBatchImportResult(result);
setBatchImportFileName(fileName);
setPersonaDialogState(null);
}}
onOpenChange={(open) => {
if (!open) {
setPersonaDialogState(null);
@@ -717,6 +752,17 @@ export function AgentsView() {
open={batchImportResult !== null}
result={batchImportResult}
/>
<TeamImportDialog
fileName={teamActions.teamImportPreview?.fileName ?? ""}
onComplete={teamActions.handleTeamImportComplete}
onOpenChange={(open) => {
if (!open) {
teamActions.setTeamImportPreview(null);
}
}}
open={teamActions.teamImportPreview !== null}
preview={teamActions.teamImportPreview?.preview ?? null}
/>
</>
);
}

View File

@@ -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<number, ImportItemStatus>
>(new Map());
const [errorMessage, setErrorMessage] = React.useState<string | null>(null);
const [expandedIndex, setExpandedIndex] = React.useState<number | null>(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<number, ImportItemStatus>();
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({
</p>
) : null}
</div>
<ImportStatusIcon status={itemStatuses.get(index)} />
</button>
{isExpanded ? (
<div className="border-t border-border/40 px-3 py-2.5">

View File

@@ -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<void>;
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<string | null>(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 (
<Dialog onOpenChange={handleOpenChange} open={open}>
<DialogContent
className="max-w-2xl overflow-hidden p-0"
onDragLeave={() => setIsDragOver(false)}
onDragOver={(e: React.DragEvent) => {
if (enableImportDrop) {
e.preventDefault();
setIsDragOver(true);
}
}}
onDrop={(e: React.DragEvent) => void handleDrop(e)}
>
<div className="relative flex max-h-[85vh] flex-col">
{isDragOver && enableImportDrop ? (
<div className="pointer-events-none absolute inset-0 z-50 flex items-center justify-center rounded-lg border-2 border-dashed border-primary/50 bg-primary/5">
<p className="text-sm font-medium text-primary">
Drop .persona.json, .persona.png, or .zip
</p>
</div>
) : null}
<DialogContent className="max-w-2xl overflow-hidden p-0">
<div className="flex max-h-[85vh] flex-col">
<DialogHeader className="border-b border-border/60 px-6 py-5 pr-14">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>
{description}
{enableImportDrop ? (
<span className="mt-1 block text-xs text-muted-foreground/70">
Or drag a .persona.json, .persona.png, or .zip onto this
dialog to import.
</span>
) : null}
</DialogDescription>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<div className="space-y-5 px-6 py-5">
@@ -258,12 +156,6 @@ export function PersonaDialog({
{error.message}
</p>
) : null}
{importError ? (
<p className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{importError}
</p>
) : null}
</div>
<div className="flex justify-end gap-2 border-t border-border/60 px-6 py-4">

View File

@@ -6,10 +6,13 @@ import {
Pencil,
Plus,
Trash2,
Upload,
} from "lucide-react";
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
import type { AgentPersona } from "@/shared/api/types";
import { useFileImportZone } from "@/shared/hooks/useFileImportZone";
import { promptPreview } from "@/shared/lib/promptPreview";
import { Button } from "@/shared/ui/button";
import {
DropdownMenu,
@@ -20,18 +23,6 @@ import {
import { Skeleton } from "@/shared/ui/skeleton";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
function promptPreview(systemPrompt: string) {
const trimmed = systemPrompt.trim();
if (!trimmed) {
return null;
}
const [firstLine] = trimmed
.split("\n")
.map((line) => line.trim())
.filter((line) => line.length > 0);
return firstLine ?? trimmed;
}
type PersonasSectionProps = {
personas: AgentPersona[];
error: Error | null;
@@ -42,6 +33,7 @@ type PersonasSectionProps = {
onEdit: (persona: AgentPersona) => void;
onExport: (persona: AgentPersona) => void;
onDelete: (persona: AgentPersona) => void;
onImportFile: (fileBytes: number[], fileName: string) => void;
};
export function PersonasSection({
@@ -54,9 +46,26 @@ export function PersonasSection({
onEdit,
onExport,
onDelete,
onImportFile,
}: PersonasSectionProps) {
const {
fileInputRef,
isDragOver,
dropHandlers,
handleFileChange,
openFilePicker,
} = useFileImportZone({ onImportFile });
return (
<section className="space-y-4">
<section className="relative space-y-4" {...dropHandlers}>
{isDragOver ? (
<div className="pointer-events-none absolute -inset-1 z-10 flex items-center justify-center rounded-2xl border-2 border-dashed border-primary/50 bg-background/80 backdrop-blur-sm">
<p className="text-sm font-medium text-primary">
Drop .persona.json, .persona.png, or .zip to import
</p>
</div>
) : null}
<div className="flex items-center justify-between gap-3">
<div>
<h3 className="text-sm font-semibold tracking-tight">Personas</h3>
@@ -64,6 +73,13 @@ export function PersonasSection({
Reusable agent templates for common roles and prompts.
</p>
</div>
<input
accept=".json,.png,.zip"
className="hidden"
onChange={handleFileChange}
ref={fileInputRef}
type="file"
/>
<Tooltip>
<TooltipTrigger asChild>
<Button
@@ -81,8 +97,8 @@ export function PersonasSection({
</div>
{isLoading ? (
<div className="grid gap-3 md:grid-cols-3 xl:grid-cols-5">
{["first", "second", "third", "fourth", "fifth"].map((key) => (
<div className="grid gap-3 md:grid-cols-3 xl:grid-cols-4">
{["first", "second", "third", "fourth"].map((key) => (
<div
className="rounded-xl border border-border/70 bg-card/80 p-2 shadow-sm"
key={key}
@@ -100,7 +116,7 @@ export function PersonasSection({
) : null}
{!isLoading && personas.length > 0 ? (
<div className="grid gap-3 md:grid-cols-3 xl:grid-cols-5">
<div className="grid gap-3 md:grid-cols-3 xl:grid-cols-4">
{personas.map((persona) => {
const preview = promptPreview(persona.systemPrompt);
@@ -122,7 +138,7 @@ export function PersonasSection({
{persona.displayName}
</p>
{persona.isBuiltIn ? (
<span className="rounded-full bg-muted px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
<span className="whitespace-nowrap rounded-full bg-muted px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
Built-in
</span>
) : null}
@@ -198,18 +214,33 @@ export function PersonasSection({
</div>
);
})}
<button
className="flex cursor-pointer items-center justify-center gap-2 rounded-xl border border-dashed border-primary p-2 text-primary transition-colors hover:bg-primary/5"
onClick={openFilePicker}
type="button"
>
<Upload className="h-4 w-4" />
<span className="text-xs">Import</span>
</button>
</div>
) : null}
{!isLoading && personas.length === 0 ? (
<div className="rounded-xl border border-dashed border-border/80 bg-card/70 px-6 py-10 text-center">
<button
className="w-full cursor-pointer rounded-xl border border-dashed border-primary/40 px-6 py-10 text-center transition-colors hover:border-primary hover:bg-primary/5"
onClick={openFilePicker}
type="button"
>
<p className="text-sm font-semibold tracking-tight">
No personas yet
</p>
<p className="mt-2 text-sm text-muted-foreground">
Create one to save a role, prompt, and optional avatar for reuse.
</p>
</div>
<p className="mt-1 text-xs text-muted-foreground/70">
Or drop a .persona.json, .persona.png, or .zip file here to import.
</p>
</button>
) : null}
{error ? (

View File

@@ -0,0 +1,220 @@
import * as React from "react";
import { Users } from "lucide-react";
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
import type { ParsedTeamPreview } from "@/shared/api/tauriTeams";
import { createPersona } from "@/shared/api/tauriPersonas";
import { promptPreview } from "@/shared/lib/promptPreview";
import {
ImportStatusIcon,
type ImportItemStatus,
} from "@/shared/ui/import-status-icon";
import { Button } from "@/shared/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/shared/ui/dialog";
type TeamImportDialogProps = {
fileName: string;
open: boolean;
preview: ParsedTeamPreview | null;
onOpenChange: (open: boolean) => void;
onComplete: (
teamName: string,
teamDescription: string | null,
personaIds: string[],
) => void;
};
export function TeamImportDialog({
fileName,
open,
preview,
onOpenChange,
onComplete,
}: TeamImportDialogProps) {
const [status, setStatus] = React.useState<
"idle" | "importing" | "done" | "error"
>("idle");
const [importedCount, setImportedCount] = React.useState(0);
const [itemStatuses, setItemStatuses] = React.useState<
Map<number, ImportItemStatus>
>(new Map());
const [errorMessage, setErrorMessage] = React.useState<string | null>(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, ImportItemStatus>();
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 (
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogContent className="flex max-h-[80vh] max-w-2xl flex-col overflow-hidden p-0">
<DialogHeader className="shrink-0 border-b border-border/60 px-6 py-5 pr-14">
<DialogTitle>Import Team</DialogTitle>
<DialogDescription>
Preview the team from {fileName || "file"} before importing.
</DialogDescription>
</DialogHeader>
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-4">
{preview ? (
<div className="space-y-4">
<div className="flex items-center gap-3 rounded-lg border border-border/60 bg-card/80 px-4 py-3">
<Users className="h-5 w-5 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold tracking-tight">
{preview.name}
</p>
{preview.description ? (
<p className="text-xs text-muted-foreground">
{preview.description}
</p>
) : null}
</div>
<span className="text-xs text-muted-foreground">
{personas.length}{" "}
{personas.length === 1 ? "persona" : "personas"}
</span>
</div>
<div className="space-y-1">
<p className="text-sm font-medium">Personas to import</p>
<p className="text-xs text-muted-foreground">
Each persona will be created, then grouped into a new team.
</p>
</div>
<div className="space-y-1">
{personas.map((persona, index) => (
<div
className="flex items-center gap-3 rounded-lg border border-border/60 bg-card/80 px-3 py-2.5"
// biome-ignore lint/suspicious/noArrayIndexKey: static list from imported JSON file, never reordered
key={index}
>
<ProfileAvatar
avatarUrl={persona.avatar_url}
className="h-8 w-8 rounded-lg text-xs"
label={persona.display_name}
/>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold tracking-tight">
{persona.display_name}
</p>
<p className="truncate text-xs text-muted-foreground">
{promptPreview(persona.system_prompt)}
</p>
</div>
<ImportStatusIcon status={itemStatuses.get(index)} />
</div>
))}
</div>
</div>
) : null}
{errorMessage ? (
<p className="mt-4 rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{errorMessage}
</p>
) : null}
</div>
<div className="flex shrink-0 justify-end gap-2 border-t border-border/60 px-6 py-4">
<Button
onClick={() => onOpenChange(false)}
size="sm"
type="button"
variant="outline"
>
Cancel
</Button>
<Button
disabled={
!preview ||
personas.length === 0 ||
status === "importing" ||
status === "done" ||
status === "error"
}
onClick={() => void handleImport()}
size="sm"
type="button"
>
{status === "importing"
? `Importing ${importedCount}/${personas.length}...`
: `Import team (${personas.length} personas)`}
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,16 +1,19 @@
import {
CopyPlus,
Download,
Ellipsis,
Info,
Pencil,
Plus,
Rocket,
Trash2,
Upload,
Users,
} from "lucide-react";
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
import type { AgentPersona, AgentTeam } from "@/shared/api/types";
import { useFileImportZone } from "@/shared/hooks/useFileImportZone";
import { Button } from "@/shared/ui/button";
import {
DropdownMenu,
@@ -33,8 +36,10 @@ type TeamsSectionProps = {
onCreate: () => void;
onDuplicate: (team: AgentTeam) => void;
onEdit: (team: AgentTeam) => void;
onExport: (team: AgentTeam) => void;
onDelete: (team: AgentTeam) => void;
onAddToChannel: (team: AgentTeam) => void;
onImportFile: (fileBytes: number[], fileName: string) => void;
};
function resolvePersonas(
@@ -55,11 +60,29 @@ export function TeamsSection({
onCreate,
onDuplicate,
onEdit,
onExport,
onDelete,
onAddToChannel,
onImportFile,
}: TeamsSectionProps) {
const {
fileInputRef,
isDragOver,
dropHandlers,
handleFileChange,
openFilePicker,
} = useFileImportZone({ onImportFile });
return (
<section className="space-y-4">
<section className="relative space-y-4" {...dropHandlers}>
{isDragOver ? (
<div className="pointer-events-none absolute -inset-1 z-10 flex items-center justify-center rounded-2xl border-2 border-dashed border-primary/50 bg-background/80 backdrop-blur-sm">
<p className="text-sm font-medium text-primary">
Drop .team.json to import
</p>
</div>
) : null}
<div className="flex items-center justify-between gap-3">
<div>
<h3 className="text-sm font-semibold tracking-tight">Teams</h3>
@@ -67,6 +90,13 @@ export function TeamsSection({
Named groups of personas you can deploy to a channel together.
</p>
</div>
<input
accept=".json"
className="hidden"
onChange={handleFileChange}
ref={fileInputRef}
type="file"
/>
<Tooltip>
<TooltipTrigger asChild>
<Button
@@ -197,6 +227,13 @@ export function TeamsSection({
<CopyPlus className="h-4 w-4" />
Duplicate
</DropdownMenuItem>
<DropdownMenuItem
disabled={isPending}
onClick={() => onExport(team)}
>
<Download className="h-4 w-4" />
Export
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive"
@@ -212,16 +249,31 @@ export function TeamsSection({
</div>
);
})}
<button
className="flex cursor-pointer items-center justify-center gap-2 rounded-xl border border-dashed border-primary p-3 text-primary transition-colors hover:bg-primary/5"
onClick={openFilePicker}
type="button"
>
<Upload className="h-4 w-4" />
<span className="text-xs">Import</span>
</button>
</div>
) : null}
{!isLoading && teams.length === 0 ? (
<div className="rounded-xl border border-dashed border-border/80 bg-card/70 px-6 py-10 text-center">
<button
className="w-full cursor-pointer rounded-xl border border-dashed border-primary/40 px-6 py-10 text-center transition-colors hover:border-primary hover:bg-primary/5"
onClick={openFilePicker}
type="button"
>
<p className="text-sm font-semibold tracking-tight">No teams yet</p>
<p className="mt-2 text-sm text-muted-foreground">
Create a team to group personas for quick deployment to channels.
</p>
</div>
<p className="mt-1 text-xs text-muted-foreground/70">
Or drop a .team.json file here to import.
</p>
</button>
) : null}
{error ? (

View File

@@ -1,12 +1,21 @@
import * as React from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import {
personasQueryKey,
teamsQueryKey,
useCreateTeamMutation,
useDeleteTeamMutation,
useTeamsQuery,
useUpdateTeamMutation,
} from "@/features/agents/hooks";
import type { CreateChannelManagedAgentsResult } from "@/features/agents/channelAgents";
import {
type ParsedTeamPreview,
createTeam as createTeamApi,
exportTeamToJson,
parseTeamFile,
} from "@/shared/api/tauriTeams";
import type {
AgentTeam,
Channel,
@@ -35,11 +44,16 @@ export function useTeamActions(
actions: ActionMessages,
refetch: RefetchCallbacks,
) {
const queryClient = useQueryClient();
const teamsQuery = useTeamsQuery();
const createTeamMutation = useCreateTeamMutation();
const updateTeamMutation = useUpdateTeamMutation();
const deleteTeamMutation = useDeleteTeamMutation();
const exportTeamJsonMutation = useMutation({
mutationFn: (id: string) => exportTeamToJson(id),
});
const [teamDialogState, setTeamDialogState] =
React.useState<TeamDialogState>(null);
const [teamToDelete, setTeamToDelete] = React.useState<AgentTeam | null>(
@@ -47,6 +61,10 @@ export function useTeamActions(
);
const [teamToAddToChannel, setTeamToAddToChannel] =
React.useState<AgentTeam | null>(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,

View File

@@ -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 ? (
<AddChannelBotTeamsSection
canToggleSelections={canToggleSelections}
inChannelPersonaIds={inChannelPersonaIds}
isLoading={teamsQuery.isLoading}
onToggleTeam={handleToggleTeam}
personas={personas}
@@ -479,6 +485,7 @@ export function AddChannelBotDialog({
<AddChannelBotPersonasSection
canToggleSelections={canToggleSelections}
inChannelPersonaIds={inChannelPersonaIds}
includeGeneric={includeGeneric}
isLoading={personasQuery.isLoading}
onToggleGeneric={() => {

View File

@@ -1,7 +1,8 @@
import { Bot } from "lucide-react";
import { Bot, Check } from "lucide-react";
import type { AgentPersona } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
import { promptPreview } from "@/shared/lib/promptPreview";
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
import {
Tooltip,
@@ -10,15 +11,6 @@ import {
TooltipTrigger,
} from "@/shared/ui/tooltip";
function promptPreview(prompt: string) {
const [firstLine] = prompt
.split("\n")
.map((line) => line.trim())
.filter((line) => line.length > 0);
return firstLine ?? prompt.trim();
}
type SelectionChipButtonProps = {
avatarUrl?: string | null;
disabled: boolean;
@@ -73,6 +65,7 @@ function SelectionChipButton({
type AddChannelBotPersonasSectionProps = {
canToggleSelections: boolean;
inChannelPersonaIds?: ReadonlySet<string>;
includeGeneric: boolean;
isLoading: boolean;
onToggleGeneric: () => void;
@@ -83,6 +76,7 @@ type AddChannelBotPersonasSectionProps = {
export function AddChannelBotPersonasSection({
canToggleSelections,
inChannelPersonaIds,
includeGeneric,
isLoading,
onToggleGeneric,
@@ -130,18 +124,32 @@ export function AddChannelBotPersonasSection({
</Tooltip>
{personas.map((persona) => {
const isSelected = selectedPersonaIds.includes(persona.id);
const isInChannel = inChannelPersonaIds?.has(persona.id) ?? false;
return (
<Tooltip key={persona.id}>
<TooltipTrigger asChild>
<div>
<SelectionChipButton
avatarUrl={persona.avatarUrl}
disabled={!canToggleSelections}
disabled={!canToggleSelections || isInChannel}
label={persona.displayName}
onClick={() => onTogglePersona(persona.id)}
selected={isSelected}
>
{persona.displayName}
{isInChannel ? (
<span
className={cn(
"inline-flex items-center gap-0.5 rounded-full px-1.5 py-0.5 text-[10px] font-medium leading-none",
isSelected
? "bg-background/20 text-background/80"
: "bg-muted/60 text-muted-foreground",
)}
>
<Check className="h-2.5 w-2.5" />
In channel
</span>
) : null}
</SelectionChipButton>
</div>
</TooltipTrigger>
@@ -156,6 +164,11 @@ export function AddChannelBotPersonasSection({
/>
<p className="font-medium">{persona.displayName}</p>
</div>
{isInChannel ? (
<p className="text-[11px] font-medium text-emerald-300">
✓ Already in this channel
</p>
) : null}
<p className="text-[11px] text-primary-foreground">
{promptPreview(persona.systemPrompt)}
</p>

View File

@@ -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<string>;
isLoading: boolean;
onToggleTeam: (personaIds: string[]) => void;
personas: AgentPersona[];
@@ -65,6 +66,7 @@ type AddChannelBotTeamsSectionProps = {
export function AddChannelBotTeamsSection({
canToggleSelections,
inChannelPersonaIds,
isLoading,
onToggleTeam,
personas,
@@ -92,13 +94,22 @@ export function AddChannelBotTeamsSection({
const allSelected =
validIds.length > 0 &&
validIds.every((id) => selectedPersonaIds.includes(id));
const inChannelCount = inChannelPersonaIds
? validIds.filter((id) => inChannelPersonaIds.has(id)).length
: 0;
const allInChannel =
inChannelCount > 0 && inChannelCount === validIds.length;
return (
<Tooltip key={team.id}>
<TooltipTrigger asChild>
<div>
<SelectionChipButton
disabled={!canToggleSelections || validIds.length === 0}
disabled={
!canToggleSelections ||
validIds.length === 0 ||
allInChannel
}
label={team.name}
onClick={() => onToggleTeam(validIds)}
selected={allSelected}
@@ -122,6 +133,21 @@ export function AddChannelBotTeamsSection({
>
({validIds.length})
</span>
{inChannelCount > 0 ? (
<span
className={cn(
"inline-flex items-center gap-0.5 rounded-full px-1.5 py-0.5 text-[10px] font-medium leading-none",
allSelected
? "bg-background/20 text-background/80"
: "bg-muted/60 text-muted-foreground",
)}
>
<Check className="h-2.5 w-2.5" />
{allInChannel
? "All in channel"
: `${inChannelCount} in channel`}
</span>
) : null}
</SelectionChipButton>
</div>
</TooltipTrigger>
@@ -134,21 +160,28 @@ export function AddChannelBotTeamsSection({
</p>
) : null}
<div className="flex flex-wrap gap-1">
{resolved.map((persona) => (
<div
className="flex items-center gap-1 rounded-full bg-primary-foreground/10 px-1.5 py-0.5"
key={persona.id}
>
<ProfileAvatar
avatarUrl={persona.avatarUrl}
className="h-4 w-4 rounded-full text-[8px] bg-primary-foreground/20 text-primary-foreground"
label={persona.displayName}
/>
<span className="text-[10px] text-primary-foreground">
{persona.displayName}
</span>
</div>
))}
{resolved.map((persona) => {
const personaInChannel =
inChannelPersonaIds?.has(persona.id) ?? false;
return (
<div
className="flex items-center gap-1 rounded-full bg-primary-foreground/10 px-1.5 py-0.5"
key={persona.id}
>
<ProfileAvatar
avatarUrl={persona.avatarUrl}
className="h-4 w-4 rounded-full text-[8px] bg-primary-foreground/20 text-primary-foreground"
label={persona.displayName}
/>
<span className="text-[10px] text-primary-foreground">
{persona.displayName}
</span>
{personaInChannel ? (
<Check className="h-2.5 w-2.5 text-emerald-300" />
) : null}
</div>
);
})}
</div>
</div>
</TooltipContent>

View File

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

View File

@@ -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<string>` 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<string> {
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<string>();
}
const memberPubkeys = new Set(
members.map((m) => normalizePubkey(m.pubkey)),
);
const ids = new Set<string>();
for (const agent of managedAgents) {
if (agent.personaId && memberPubkeys.has(normalizePubkey(agent.pubkey))) {
ids.add(agent.personaId);
}
}
return ids;
}, [membersQuery.data, managedAgentsQuery.data]);
}

View File

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

View File

@@ -1,4 +1,5 @@
import type { Profile, UserProfileSummary } from "@/shared/api/types";
import { normalizePubkey } from "@/shared/lib/pubkey";
export type UserProfileLookup = Record<string, UserProfileSummary>;
@@ -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,

View File

@@ -57,3 +57,27 @@ export async function updateTeam(input: UpdateTeamInput): Promise<AgentTeam> {
export async function deleteTeam(id: string): Promise<void> {
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<boolean> {
return invokeTauri<boolean>("export_team_to_json", { id });
}
export async function parseTeamFile(
fileBytes: number[],
fileName: string,
): Promise<ParsedTeamPreview> {
return invokeTauri<ParsedTeamPreview>("parse_team_file", {
fileBytes,
fileName,
});
}

View File

@@ -0,0 +1,60 @@
import * as React from "react";
type FileImportZoneOptions = {
/** Called with the raw byte array and original file name. */
onImportFile: (fileBytes: number[], fileName: string) => void;
};
/**
* Shared drag-and-drop + file-picker infrastructure for import sections
* (PersonasSection, TeamsSection). Returns state, handlers, and a ref for
* the hidden `<input type="file">`.
*/
export function useFileImportZone({ onImportFile }: FileImportZoneOptions) {
const fileInputRef = React.useRef<HTMLInputElement>(null);
const [isDragOver, setIsDragOver] = React.useState(false);
async function importFile(file: File) {
const buffer = await file.arrayBuffer();
const bytes = Array.from(new Uint8Array(buffer));
onImportFile(bytes, file.name);
}
const dropHandlers = {
onDragLeave: () => setIsDragOver(false),
onDragOver: (e: React.DragEvent) => {
e.preventDefault();
setIsDragOver(true);
},
onDrop: (e: React.DragEvent) => {
e.preventDefault();
setIsDragOver(false);
const file = e.dataTransfer.files[0];
if (file) {
void importFile(file);
}
},
};
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
void importFile(file);
// Reset so the same file can be re-selected
e.target.value = "";
}
function openFilePicker() {
fileInputRef.current?.click();
}
return {
fileInputRef,
isDragOver,
dropHandlers,
handleFileChange,
openFilePicker,
};
}

View File

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

View File

@@ -0,0 +1,20 @@
/** PNG file signature (first 4 bytes). */
const PNG_MAGIC = [0x89, 0x50, 0x4e, 0x47] as const;
/** Opening brace `{` — first byte of a JSON file. */
const JSON_FIRST_BYTE = 0x7b;
function matchesMagic(
bytes: number[] | readonly number[],
magic: readonly number[],
): boolean {
return magic.every((b, i) => bytes[i] === b);
}
/** Return true when `bytes` looks like a single-item file (PNG or JSON). */
export function isSingleItemFile(bytes: number[] | readonly number[]): boolean {
return (
matchesMagic(bytes, PNG_MAGIC) ||
(bytes.length > 0 && bytes[0] === JSON_FIRST_BYTE)
);
}

View File

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

View File

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

View File

@@ -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 (
<Loader2 className="h-4 w-4 shrink-0 animate-spin text-muted-foreground" />
);
case "done":
return <Check className="h-4 w-4 shrink-0 text-green-500" />;
case "error":
return <X className="h-4 w-4 shrink-0 text-destructive" />;
default:
return null;
}
}