mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
feat(desktop): persona & team improvements — channel indicators, async imports, team export/import (#156)
This commit is contained in:
@@ -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
|
||||
]);
|
||||
|
||||
33
desktop/src-tauri/src/commands/export_util.rs
Normal file
33
desktop/src-tauri/src/commands/export_util.rs
Normal 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)
|
||||
}
|
||||
@@ -5,6 +5,7 @@ mod agents;
|
||||
mod canvas;
|
||||
mod channels;
|
||||
mod dms;
|
||||
mod export_util;
|
||||
mod identity;
|
||||
mod media;
|
||||
mod messages;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
])
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
|
||||
@@ -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("/");
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
220
desktop/src/features/agents/ui/TeamImportDialog.tsx
Normal file
220
desktop/src/features/agents/ui/TeamImportDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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={() => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
38
desktop/src/features/channels/ui/useInChannelPersonaIds.ts
Normal file
38
desktop/src/features/channels/ui/useInChannelPersonaIds.ts
Normal 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]);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
60
desktop/src/shared/hooks/useFileImportZone.ts
Normal file
60
desktop/src/shared/hooks/useFileImportZone.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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[][],
|
||||
|
||||
20
desktop/src/shared/lib/fileMagic.ts
Normal file
20
desktop/src/shared/lib/fileMagic.ts
Normal 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)
|
||||
);
|
||||
}
|
||||
15
desktop/src/shared/lib/promptPreview.ts
Normal file
15
desktop/src/shared/lib/promptPreview.ts
Normal 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;
|
||||
}
|
||||
9
desktop/src/shared/lib/pubkey.ts
Normal file
9
desktop/src/shared/lib/pubkey.ts
Normal 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();
|
||||
}
|
||||
27
desktop/src/shared/ui/import-status-icon.tsx
Normal file
27
desktop/src/shared/ui/import-status-icon.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user