diff --git a/desktop/src-tauri/src/commands/channel_templates.rs b/desktop/src-tauri/src/commands/channel_templates.rs index 5d88e162c9..fdcf69c843 100644 --- a/desktop/src-tauri/src/commands/channel_templates.rs +++ b/desktop/src-tauri/src/commands/channel_templates.rs @@ -5,7 +5,8 @@ use crate::{ app_state::AppState, templates::{ load_channel_templates, save_channel_templates, validate_channel_template_deletion, - ChannelTemplateRecord, CreateChannelTemplateRequest, UpdateChannelTemplateRequest, + ChannelTemplateRecord, CreateChannelTemplateRequest, TemplateWorktreeConfig, + UpdateChannelTemplateRequest, }, util::now_iso, }; @@ -25,6 +26,33 @@ fn trim_optional(value: Option) -> Option { }) } +fn normalize_project_folders( + project_folders: Vec, + project_folder: Option, +) -> Vec { + let mut normalized = Vec::new(); + for candidate in project_folders.into_iter().chain(project_folder) { + let trimmed = candidate.trim(); + if !trimmed.is_empty() && !normalized.iter().any(|folder| folder == trimmed) { + normalized.push(trimmed.to_string()); + } + } + normalized +} + +fn normalize_worktree( + worktree: Option, +) -> Result, String> { + worktree + .map(|worktree| { + Ok(TemplateWorktreeConfig { + location: trim_required(&worktree.location, "Worktree location")?, + base_branch: trim_required(&worktree.base_branch, "Base branch")?, + }) + }) + .transpose() +} + fn validate_channel_type(value: &str) -> Result<(), String> { match value { "stream" | "forum" => Ok(()), @@ -43,6 +71,34 @@ fn validate_visibility(value: &str) -> Result<(), String> { } } +#[tauri::command] +/// Open a native multi-folder picker for channel-template project folders. +/// +/// Returns an empty list when the user cancels. Returns an error if the dialog +/// closes unexpectedly or any selected folder cannot be represented as a local +/// filesystem path. +pub async fn pick_channel_template_project_folder(app: AppHandle) -> Result, String> { + use tauri_plugin_dialog::DialogExt; + + let (tx, rx) = tokio::sync::oneshot::channel(); + app.dialog().file().pick_folders(move |paths| { + let _ = tx.send(paths); + }); + + let Some(folder_paths) = rx.await.map_err(|_| "dialog cancelled".to_string())? else { + return Ok(Vec::new()); + }; + folder_paths + .into_iter() + .map(|folder_path| { + folder_path + .as_path() + .map(|path| path.to_string_lossy().into_owned()) + .ok_or_else(|| "Folder picker returned an invalid path".to_string()) + }) + .collect() +} + #[tauri::command] pub async fn list_channel_templates(app: AppHandle) -> Result, String> { tokio::task::spawn_blocking(move || { @@ -66,6 +122,10 @@ pub async fn create_channel_template( let name = trim_required(&input.name, "Template name")?; let description = trim_optional(input.description); let canvas_template = trim_optional(input.canvas_template); + let project_folders = + normalize_project_folders(input.project_folders, input.project_folder); + let project_folder = project_folders.first().cloned(); + let worktree = normalize_worktree(input.worktree)?; let channel_type = input.channel_type.unwrap_or_else(|| "stream".to_string()); let visibility = input.visibility.unwrap_or_else(|| "open".to_string()); validate_channel_type(&channel_type)?; @@ -86,6 +146,9 @@ pub async fn create_channel_template( channel_type, visibility, canvas_template, + project_folders, + project_folder, + worktree, agents: input.agents, is_builtin: false, created_at: now.clone(), @@ -109,6 +172,10 @@ pub async fn update_channel_template( let name = trim_required(&input.name, "Template name")?; let description = trim_optional(input.description); let canvas_template = trim_optional(input.canvas_template); + let project_folders = + normalize_project_folders(input.project_folders, input.project_folder); + let project_folder = project_folders.first().cloned(); + let worktree = normalize_worktree(input.worktree)?; let channel_type = input.channel_type.unwrap_or_else(|| "stream".to_string()); let visibility = input.visibility.unwrap_or_else(|| "open".to_string()); validate_channel_type(&channel_type)?; @@ -130,6 +197,9 @@ pub async fn update_channel_template( template.channel_type = channel_type; template.visibility = visibility; template.canvas_template = canvas_template; + template.project_folders = project_folders; + template.project_folder = project_folder; + template.worktree = worktree; template.agents = input.agents; template.updated_at = now_iso(); diff --git a/desktop/src-tauri/src/commands/team_snapshot.rs b/desktop/src-tauri/src/commands/team_snapshot.rs index 97cd11933d..41c7f17632 100644 --- a/desktop/src-tauri/src/commands/team_snapshot.rs +++ b/desktop/src-tauri/src/commands/team_snapshot.rs @@ -160,10 +160,8 @@ pub(crate) fn build_import_team( persona_ids: Vec, now: &str, ) -> Result { - let name = snapshot.team.name.trim(); - if name.is_empty() { - return Err("Team snapshot name is empty.".to_string()); - } + let name = crate::managed_agents::validate_team_name(&snapshot.team.name) + .map_err(|error| format!("Invalid team snapshot: {error}"))?; Ok(TeamRecord { id: Uuid::new_v4().to_string(), @@ -184,8 +182,12 @@ pub(crate) fn build_import_team( fn member_preview(member: &AgentSnapshot) -> TeamSnapshotMemberPreview { TeamSnapshotMemberPreview { display_name: member.profile.display_name.clone(), + summary: member.profile.about.clone(), system_prompt: member.definition.system_prompt.clone(), avatar_url: effective_avatar(member), + is_built_in: member.definition.source_is_builtin, + model: member.definition.model.clone(), + runtime: member.definition.runtime.clone(), has_source_allowlist: !member.definition.respond_to_allowlist.is_empty(), source_allowlist_count: member.definition.respond_to_allowlist.len(), } @@ -196,8 +198,12 @@ fn member_preview(member: &AgentSnapshot) -> TeamSnapshotMemberPreview { #[serde(rename_all = "camelCase")] pub struct TeamSnapshotMemberPreview { pub display_name: String, + pub summary: Option, pub system_prompt: Option, pub avatar_url: Option, + pub is_built_in: bool, + pub model: Option, + pub runtime: Option, pub has_source_allowlist: bool, pub source_allowlist_count: usize, } diff --git a/desktop/src-tauri/src/commands/team_snapshot/tests.rs b/desktop/src-tauri/src/commands/team_snapshot/tests.rs index c9a6d8812a..c6ae14427c 100644 --- a/desktop/src-tauri/src/commands/team_snapshot/tests.rs +++ b/desktop/src-tauri/src/commands/team_snapshot/tests.rs @@ -329,6 +329,17 @@ fn team_import_definitions_are_built_for_all_members() { assert_eq!(definitions[0].system_prompt, "Alice prompt"); } +#[test] +fn team_import_rejects_names_that_cannot_be_mentioned() { + for invalid_name in ["x".repeat(201), "Review\nTeam".to_string()] { + let mut source = snapshot(vec![member("Alice")]); + source.team.name = invalid_name; + + let error = build_import_team(&source, vec!["alice".to_string()], "now").unwrap_err(); + assert!(error.starts_with("Invalid team snapshot: Team name")); + } +} + #[test] fn team_import_keeps_or_clears_every_member_allowlist_with_one_toggle() { let source = snapshot(vec![member("Alice"), member("Bob")]); diff --git a/desktop/src-tauri/src/commands/teams.rs b/desktop/src-tauri/src/commands/teams.rs index 4377ddaa43..7fd68f62e8 100644 --- a/desktop/src-tauri/src/commands/teams.rs +++ b/desktop/src-tauri/src/commands/teams.rs @@ -5,19 +5,12 @@ use crate::{ app_state::AppState, managed_agents::{ delete_team_with_cascade, ensure_persona_ids_are_active, load_personas, load_teams, - save_teams, try_regenerate_nest, CreateTeamRequest, TeamRecord, UpdateTeamRequest, + save_teams, try_regenerate_nest, validate_team_name, CreateTeamRequest, TeamRecord, + UpdateTeamRequest, }, util::now_iso, }; -fn trim_required(value: &str, label: &str) -> Result { - let trimmed = value.trim(); - if trimmed.is_empty() { - return Err(format!("{label} is required")); - } - Ok(trimmed.to_string()) -} - fn trim_optional(value: Option) -> Option { value.and_then(|candidate| { let trimmed = candidate.trim(); @@ -145,7 +138,7 @@ pub async fn create_team(input: CreateTeamRequest, app: AppHandle) -> Result(); - let name = trim_required(&input.name, "Team name")?; + let name = validate_team_name(&input.name)?; let description = trim_optional(input.description); let instructions = trim_optional(input.instructions); let now = now_iso(); @@ -186,7 +179,7 @@ pub async fn update_team(input: UpdateTeamRequest, app: AppHandle) -> Result(); - let name = trim_required(&input.name, "Team name")?; + let name = validate_team_name(&input.name)?; let description = trim_optional(input.description); let instructions = trim_optional(input.instructions); diff --git a/desktop/src-tauri/src/events.rs b/desktop/src-tauri/src/events.rs index b7937419bf..d8e3369be5 100644 --- a/desktop/src-tauri/src/events.rs +++ b/desktop/src-tauri/src/events.rs @@ -8,6 +8,8 @@ //! //! Each function validates inputs and returns a nostr::EventBuilder. //! Signing and submission happen in relay::submit_event. +mod mention_reference_tags; + use buzz_core_pkg::kind::{KIND_IA_ARCHIVE_REQUEST, KIND_IA_UNARCHIVE_REQUEST}; use nostr::{EventBuilder, EventId, Kind, Tag}; use uuid::Uuid; @@ -76,17 +78,9 @@ fn mention_tags(mentions: &[&str]) -> Result, String> { fn mention_reference_tags(mentions: &[Vec], tags: &mut Vec) -> Result<(), String> { for mention in mentions { - if mention.first().map(String::as_str) != Some("mention") { - return Err(format!( - "mention reference tags must use 'mention' prefix (got {:?})", - mention.first() - )); - } - let Some(pubkey) = mention.get(1) else { - return Err("mention reference tag missing pubkey".into()); - }; - check_pubkey(pubkey)?; - tags.push(tag(vec!["mention", &pubkey.to_ascii_lowercase()])?); + tags.push(mention_reference_tags::parse_mention_reference_tag( + mention, + )?); } Ok(()) } diff --git a/desktop/src-tauri/src/events/mention_reference_tags.rs b/desktop/src-tauri/src/events/mention_reference_tags.rs new file mode 100644 index 0000000000..a638b108e7 --- /dev/null +++ b/desktop/src-tauri/src/events/mention_reference_tags.rs @@ -0,0 +1,84 @@ +use crate::managed_agents::MAX_TEAM_NAME_CHARS; +use nostr::Tag; + +const MAX_TEAM_MENTION_ID_CHARS: usize = 200; + +pub(super) fn parse_mention_reference_tag(mention: &[String]) -> Result { + match mention.first().map(String::as_str) { + Some("mention") => parse_person_mention(mention), + Some("team_mention") => parse_team_mention(mention), + prefix => Err(format!( + "mention reference tags must use 'mention' or 'team_mention' prefix (got {prefix:?})" + )), + } +} + +fn parse_person_mention(mention: &[String]) -> Result { + if mention.len() != 2 { + return Err("mention reference tag must contain exactly one pubkey".into()); + } + let pubkey = &mention[1]; + if pubkey.len() != 64 + || !pubkey + .chars() + .all(|character| character.is_ascii_hexdigit()) + { + return Err(format!( + "pubkey must be a 64-character hex string (got {} chars)", + pubkey.len() + )); + } + Tag::parse(["mention", &pubkey.to_ascii_lowercase()]) + .map_err(|error| format!("invalid tag: {error}")) +} + +fn parse_team_mention(mention: &[String]) -> Result { + if mention.len() != 3 { + return Err("team mention tag must contain exactly a team id and display name".into()); + } + let team_id = mention[1].trim(); + let display_name = mention[2].trim(); + if team_id.is_empty() || display_name.is_empty() { + return Err("team mention id and display name must not be blank".into()); + } + if team_id.chars().count() > MAX_TEAM_MENTION_ID_CHARS + || display_name.chars().count() > MAX_TEAM_NAME_CHARS + { + return Err("team mention id and display name must be at most 200 characters".into()); + } + if team_id.chars().any(char::is_control) || display_name.chars().any(char::is_control) { + return Err("team mention id and display name must not contain controls".into()); + } + Tag::parse(["team_mention", team_id, display_name]) + .map_err(|error| format!("invalid tag: {error}")) +} + +#[cfg(test)] +mod tests { + use super::parse_mention_reference_tag; + + #[test] + fn preserves_visible_team_chip_metadata() { + let tag = parse_mention_reference_tag(&[ + "team_mention".into(), + "team-launch".into(), + "Launch Team".into(), + ]) + .unwrap(); + assert_eq!( + tag.as_slice(), + &["team_mention", "team-launch", "Launch Team"] + ); + } + + #[test] + fn rejects_extra_team_tag_fields() { + assert!(parse_mention_reference_tag(&[ + "team_mention".into(), + "team-launch".into(), + "Launch Team".into(), + "forged".into(), + ]) + .is_err()); + } +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 7aa954ce8e..50c4dd66cb 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -799,6 +799,7 @@ pub fn run() { set_persona_shared, reconcile_inbound_persona_event, list_channel_templates, + pick_channel_template_project_folder, create_channel_template, update_channel_template, delete_channel_template, diff --git a/desktop/src-tauri/src/managed_agents/team_snapshot.rs b/desktop/src-tauri/src/managed_agents/team_snapshot.rs index 96082acc76..febc1795d6 100644 --- a/desktop/src-tauri/src/managed_agents/team_snapshot.rs +++ b/desktop/src-tauri/src/managed_agents/team_snapshot.rs @@ -206,9 +206,8 @@ pub(crate) fn validate_team_snapshot(snapshot: &TeamSnapshot) -> Result<(), Stri snapshot.version )); } - if snapshot.team.name.trim().is_empty() { - return Err("Team snapshot team.name is empty".to_string()); - } + crate::managed_agents::validate_team_name(&snapshot.team.name) + .map_err(|error| format!("Invalid team snapshot: {error}"))?; if snapshot.members.is_empty() { return Err("Team snapshot must have at least one member".to_string()); } @@ -423,7 +422,18 @@ mod tests { let bytes = serde_json::to_vec(&snapshot).unwrap(); let result = decode_team_snapshot_json(&bytes); assert!(result.is_err()); - assert!(result.unwrap_err().contains("team.name is empty")); + assert!(result.unwrap_err().contains("Team name is required")); + } + + #[test] + fn validate_rejects_team_name_that_cannot_be_mentioned() { + for invalid_name in ["x".repeat(201), "Review\nTeam".to_string()] { + let mut snapshot = two_member_team(); + snapshot.team.name = invalid_name; + let bytes = serde_json::to_vec(&snapshot).unwrap(); + let error = decode_team_snapshot_json(&bytes).unwrap_err(); + assert!(error.starts_with("Invalid team snapshot: Team name")); + } } #[test] diff --git a/desktop/src-tauri/src/managed_agents/teams.rs b/desktop/src-tauri/src/managed_agents/teams.rs index 937893d531..326ef900db 100644 --- a/desktop/src-tauri/src/managed_agents/teams.rs +++ b/desktop/src-tauri/src/managed_agents/teams.rs @@ -9,6 +9,27 @@ use crate::{ use super::team_repair::team_persona_key; +/// Maximum team-name length accepted by the `team_mention` event tag. +pub const MAX_TEAM_NAME_CHARS: usize = 200; + +/// Normalize and validate a team name before it can reach storage or event +/// construction. +pub fn validate_team_name(value: &str) -> Result { + let name = value.trim(); + if name.is_empty() { + return Err("Team name is required".to_string()); + } + if name.chars().count() > MAX_TEAM_NAME_CHARS { + return Err(format!( + "Team name must be at most {MAX_TEAM_NAME_CHARS} characters" + )); + } + if name.chars().any(char::is_control) { + return Err("Team name must not contain control characters".to_string()); + } + Ok(name.to_string()) +} + pub(crate) fn teams_store_path(app: &AppHandle) -> Result { Ok(managed_agents_base_dir(app)?.join("teams.json")) } diff --git a/desktop/src-tauri/src/managed_agents/teams_tests.rs b/desktop/src-tauri/src/managed_agents/teams_tests.rs index 1ffa60eda9..0133f4cbc0 100644 --- a/desktop/src-tauri/src/managed_agents/teams_tests.rs +++ b/desktop/src-tauri/src/managed_agents/teams_tests.rs @@ -5,7 +5,7 @@ use super::{ agents_referencing_team, load_teams_readonly, merge_teams, merge_teams_impl, sort_teams, - validate_team_deletion, BuiltInTeam, + validate_team_deletion, validate_team_name, BuiltInTeam, MAX_TEAM_NAME_CHARS, }; use crate::managed_agents::{ManagedAgentRecord, TeamRecord}; @@ -26,6 +26,31 @@ fn team(id: &str, name: &str) -> TeamRecord { } } +#[test] +fn validate_team_name_normalizes_valid_names() { + assert_eq!( + validate_team_name(" Review Team ").unwrap(), + "Review Team" + ); + assert!(validate_team_name(&"é".repeat(MAX_TEAM_NAME_CHARS)).is_ok()); +} + +#[test] +fn validate_team_name_rejects_unmentionable_names() { + assert_eq!( + validate_team_name(" \t ").unwrap_err(), + "Team name is required" + ); + assert_eq!( + validate_team_name(&"x".repeat(MAX_TEAM_NAME_CHARS + 1)).unwrap_err(), + "Team name must be at most 200 characters" + ); + assert_eq!( + validate_team_name("Review\nTeam").unwrap_err(), + "Team name must not contain control characters" + ); +} + #[test] fn sort_teams_alphabetical_case_insensitive() { let mut teams = vec![team("3", "Zulu"), team("1", "alpha"), team("2", "Bravo")]; diff --git a/desktop/src-tauri/src/templates/storage.rs b/desktop/src-tauri/src/templates/storage.rs index fa122bbc47..407e78245a 100644 --- a/desktop/src-tauri/src/templates/storage.rs +++ b/desktop/src-tauri/src/templates/storage.rs @@ -42,6 +42,15 @@ pub fn load_channel_templates(app: &AppHandle) -> Result, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub project_folders: Vec, + /// Legacy first-folder alias retained for older saved templates. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub project_folder: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub worktree: Option, #[serde(default)] pub agents: TemplateAgentRoster, #[serde(default)] @@ -29,6 +36,18 @@ pub struct TemplateAgentRoster { pub teams: Vec, } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Workspace settings applied when creating a worktree from a channel template. +/// `location` is the parent directory for new worktrees; `base_branch` is the +/// branch fetched from origin and used as each worktree's starting point. +pub struct TemplateWorktreeConfig { + /// Parent directory where new worktrees are created. + pub location: String, + /// Branch used as the base for each new worktree. + pub base_branch: String, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct TemplateAgentEntry { @@ -79,6 +98,10 @@ pub struct CreateChannelTemplateRequest { pub visibility: Option, pub canvas_template: Option, #[serde(default)] + pub project_folders: Vec, + pub project_folder: Option, + pub worktree: Option, + #[serde(default)] pub agents: TemplateAgentRoster, } @@ -92,5 +115,9 @@ pub struct UpdateChannelTemplateRequest { pub visibility: Option, pub canvas_template: Option, #[serde(default)] + pub project_folders: Vec, + pub project_folder: Option, + pub worktree: Option, + #[serde(default)] pub agents: TemplateAgentRoster, } diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index e4233a8fae..0dabfb7a4b 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -489,6 +489,7 @@ export function AppShell() { openBrowseChannels: handleOpenBrowseChannels, onBrowseDialogOpenChange: handleBrowseDialogOpenChange, getCreateSuccess, + initialTemplateId: browseInitialTemplateId, } = useChannelBrowserDialog(() => void refetchChannels()); const handleOpenSearch = React.useCallback(() => { setSearchFocusRequest((request) => request + 1); @@ -564,8 +565,7 @@ export function AppShell() { [applyAgents, applyCanvas, createForumMutation, goChannel], ); - // The channel browser can create either a stream or a forum depending on - // which section opened it. Route to the matching handler. + // Route browser creation to the stream or forum handler that opened it. const handleBrowseChannelCreate = React.useCallback( async (input: { name: string; @@ -966,6 +966,7 @@ export function AppShell() { createChannelMutation.isPending || createForumMutation.isPending } + initialTemplateId={browseInitialTemplateId} onBrowseChannelJoin={handleBrowseChannelJoin} onBrowseChannelCreate={handleBrowseChannelCreate} onBrowseDialogOpenChange={handleBrowseDialogOpenChange} diff --git a/desktop/src/app/AppShellOverlays.tsx b/desktop/src/app/AppShellOverlays.tsx index 9a856ba6b0..ed62ba304a 100644 --- a/desktop/src/app/AppShellOverlays.tsx +++ b/desktop/src/app/AppShellOverlays.tsx @@ -24,6 +24,7 @@ type AppShellOverlaysProps = { currentPubkey?: string; isChannelManagementOpen: boolean; isCreatingBrowseChannel?: boolean; + initialTemplateId?: string; onBrowseChannelJoin: (channelId: string) => Promise; onBrowseChannelCreate?: (input: CreateChannelInput) => Promise; onBrowseDialogOpenChange: (open: boolean) => void; @@ -39,6 +40,7 @@ export function AppShellOverlays({ currentPubkey, isChannelManagementOpen, isCreatingBrowseChannel, + initialTemplateId, onBrowseChannelJoin, onBrowseChannelCreate, onBrowseDialogOpenChange, @@ -74,6 +76,7 @@ export function AppShellOverlays({ channels={channels} channelTypeFilter={renderedBrowseDialogType ?? browseDialogType} isCreatingChannel={isCreatingBrowseChannel} + initialTemplateId={initialTemplateId} onCreateChannel={onBrowseChannelCreate} onJoinChannel={onBrowseChannelJoin} onOpenChange={onBrowseDialogOpenChange} diff --git a/desktop/src/app/useChannelBrowserDialog.ts b/desktop/src/app/useChannelBrowserDialog.ts index a9c08851a8..668bdb85d4 100644 --- a/desktop/src/app/useChannelBrowserDialog.ts +++ b/desktop/src/app/useChannelBrowserDialog.ts @@ -1,17 +1,19 @@ import * as React from "react"; import type { BrowseDialogType } from "@/app/AppShellOverlays"; - type CreatedCallback = (channelId: string) => void; export function useChannelBrowserDialog(onOpen: () => void) { const [browseDialogType, setBrowseDialogType] = React.useState(null); const createSuccessRef = React.useRef(null); - + const [initialTemplateId, setInitialTemplateId] = React.useState< + string | undefined + >(); const openBrowseChannels = React.useCallback( - (onCreated?: CreatedCallback) => { + (onCreated?: CreatedCallback, nextInitialTemplateId?: string) => { createSuccessRef.current = onCreated ?? null; + setInitialTemplateId(nextInitialTemplateId); setBrowseDialogType("stream"); onOpen(); }, @@ -21,6 +23,7 @@ export function useChannelBrowserDialog(onOpen: () => void) { const onBrowseDialogOpenChange = React.useCallback((open: boolean) => { if (!open) { createSuccessRef.current = null; + setInitialTemplateId(undefined); setBrowseDialogType(null); } }, []); @@ -35,5 +38,6 @@ export function useChannelBrowserDialog(onOpen: () => void) { openBrowseChannels, onBrowseDialogOpenChange, getCreateSuccess, + initialTemplateId, }; } diff --git a/desktop/src/features/agents/ui/AgentDefinitionDetails.tsx b/desktop/src/features/agents/ui/AgentDefinitionDetails.tsx new file mode 100644 index 0000000000..fbe68c2992 --- /dev/null +++ b/desktop/src/features/agents/ui/AgentDefinitionDetails.tsx @@ -0,0 +1,117 @@ +import { Markdown } from "@/shared/ui/markdown"; + +import { AgentDefinitionMetadata } from "./AgentDefinitionMetadata"; + +const AGENT_INSTRUCTION_MARKDOWN_CLASS_NAME = [ + "mt-3 w-full min-w-0 max-w-full overflow-x-hidden leading-6 text-muted-foreground [&>*]:min-w-0 [&>*]:max-w-full [&_.code-block-lines]:min-w-0 [&_.code-block-lines]:max-w-full [&_.code-block-lines]:whitespace-pre-wrap [&_.code-block-lines]:[overflow-wrap:anywhere] [&_.inline-code-chip]:max-w-full [&_.inline-code-chip]:whitespace-pre-wrap [&_.inline-code-chip]:[overflow-wrap:anywhere] [&_blockquote]:!text-muted-foreground [&_code]:!text-muted-foreground [&_li]:text-muted-foreground [&_ol]:text-muted-foreground [&_p]:text-muted-foreground [&_strong]:text-muted-foreground [&_td]:text-muted-foreground [&_ul]:text-muted-foreground", + "[&>h1]:!text-sm [&>h1]:!font-semibold [&>h1]:!leading-6 [&>h1]:!tracking-normal [&>h1]:!text-foreground", + "[&>h2]:!text-sm [&>h2]:!font-semibold [&>h2]:!leading-6 [&>h2]:!tracking-normal [&>h2]:!text-foreground", + "[&>h3]:!text-sm [&>h3]:!font-semibold [&>h3]:!leading-6 [&>h3]:!tracking-normal [&>h3]:!text-foreground", + "[&>h4]:!text-sm [&>h4]:!font-semibold [&>h4]:!leading-6 [&>h4]:!tracking-normal [&>h4]:!text-foreground", + "[&>h5]:!text-sm [&>h5]:!font-semibold [&>h5]:!leading-6 [&>h5]:!tracking-normal [&>h5]:!text-foreground", + "[&>h6]:!text-sm [&>h6]:!font-semibold [&>h6]:!leading-6 [&>h6]:!tracking-normal [&>h6]:!text-foreground", +].join(" "); + +const SUMMARY_MAX_LENGTH = 160; + +export function DefinitionMarkdown({ content }: { content: string }) { + return ( + + ); +} + +function markdownToPlainText(value: string) { + return value + .replace(/```(?:[^\n]*)\n?([\s\S]*?)```/g, "$1") + .split("\n") + .map((line) => + line + .trim() + .replace(/^#{1,6}\s+/, "") + .replace(/^>\s?/, "") + .replace(/^[-*+]\s+\[[ xX]\]\s+/, "") + .replace(/^[-*+]\s+/, "") + .replace(/^\d+\.\s+/, "") + .replace(/!\[([^\]]*)\]\([^)]+\)/g, "$1") + .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") + .replace(/`([^`]+)`/g, "$1") + .replace(/(\*\*|__)(.*?)\1/g, "$2") + .replace(/(\*|_)(.*?)\1/g, "$2") + .replace(/~~(.*?)~~/g, "$1") + .replace(/<[^>]*>/g, "") + .trim(), + ) + .filter(Boolean) + .join(" ") + .replace(/\s+/g, " ") + .trim(); +} + +function firstSentence(value: string) { + const sentenceEnd = value.search(/[.!?](?:\s|$)/); + return sentenceEnd >= 0 ? value.slice(0, sentenceEnd + 1) : value; +} + +function truncateSummary(value: string) { + if (value.length <= SUMMARY_MAX_LENGTH) { + return value; + } + + const candidate = value.slice(0, SUMMARY_MAX_LENGTH - 1); + const lastSpace = candidate.lastIndexOf(" "); + const end = lastSpace > SUMMARY_MAX_LENGTH / 2 ? lastSpace : candidate.length; + return `${candidate.slice(0, end).trimEnd()}…`; +} + +/** + * Produces a concise, plain-text row summary from portable agent metadata. + * The profile summary wins; agent instructions are only a fallback. + */ +export function getAgentInstructionSummary( + summary: string | null | undefined, + systemPrompt: string | null | undefined, +) { + const source = + markdownToPlainText(summary ?? "") || + markdownToPlainText(systemPrompt ?? ""); + return source ? truncateSummary(firstSentence(source)) : null; +} + +/** + * Shared agent-definition presentation used by Discover Agents and snapshot + * previews so metadata and instruction markdown follow one visual path. + */ +export function AgentDefinitionDetails({ + isBuiltIn, + model, + runtime, + systemPrompt, +}: { + isBuiltIn: boolean; + model: string | null; + runtime: string | null; + systemPrompt: string; +}) { + return ( + <> + + +
+

+ Agent instruction +

+ +
+ + ); +} diff --git a/desktop/src/features/agents/ui/AgentsView.tsx b/desktop/src/features/agents/ui/AgentsView.tsx index e1e1f37f35..1950d306e8 100644 --- a/desktop/src/features/agents/ui/AgentsView.tsx +++ b/desktop/src/features/agents/ui/AgentsView.tsx @@ -285,9 +285,6 @@ export function AgentsView() { onEdit={teamActions.openEditDialog} onAddToChannel={teamActions.setTeamToAddToChannel} onShare={teamActions.openShare} - onImport={() => { - teamImportInputRef.current?.click(); - }} personas={personas.libraryPersonas} teams={teamActions.teams} /> @@ -539,6 +536,11 @@ export function AgentsView() { teamActions.setTeamDialogState(null); } }} + onImport={ + teamActions.teamDialogState.allowImport + ? () => teamImportInputRef.current?.click() + : undefined + } onDeleteRemovedPersonas={teamActions.handleDeleteRemovedPersonas} onSubmit={teamActions.handleTeamSubmit} open={teamActions.teamDialogState !== null} @@ -649,6 +651,7 @@ export function AgentsView() { reader.onload = () => { const buffer = reader.result as ArrayBuffer; const fileBytes = Array.from(new Uint8Array(buffer)); + teamActions.setTeamDialogState(null); void teamActions.handleImportTeamSnapshotFile(fileBytes, file.name); }; reader.readAsArrayBuffer(file); diff --git a/desktop/src/features/agents/ui/PersonaCatalogDialog.tsx b/desktop/src/features/agents/ui/PersonaCatalogDialog.tsx index 1b8be031cc..e1e1ba0595 100644 --- a/desktop/src/features/agents/ui/PersonaCatalogDialog.tsx +++ b/desktop/src/features/agents/ui/PersonaCatalogDialog.tsx @@ -21,10 +21,9 @@ import { import { Button } from "@/shared/ui/button"; import { Dialog } from "@/shared/ui/dialog"; import { ChooserDialogContent } from "@/shared/ui/chooser-dialog-content"; -import { Markdown } from "@/shared/ui/markdown"; import { Skeleton } from "@/shared/ui/skeleton"; -import { AgentDefinitionMetadata } from "./AgentDefinitionMetadata"; +import { AgentDefinitionDetails } from "./AgentDefinitionDetails"; import { PersonaAddedBy } from "./PersonaAddedBy"; import { personaCatalogCopy } from "./personaLibraryCopy"; @@ -50,16 +49,6 @@ type PendingNavigation = | { type: "close" } | { type: "selection"; selection: string }; -const agentInstructionMarkdownClassName = [ - "mt-3 w-full min-w-0 max-w-full overflow-x-hidden leading-6 text-muted-foreground [&>*]:min-w-0 [&>*]:max-w-full [&_.code-block-lines]:min-w-0 [&_.code-block-lines]:max-w-full [&_.code-block-lines]:whitespace-pre-wrap [&_.code-block-lines]:[overflow-wrap:anywhere] [&_.inline-code-chip]:max-w-full [&_.inline-code-chip]:whitespace-pre-wrap [&_.inline-code-chip]:[overflow-wrap:anywhere] [&_blockquote]:!text-muted-foreground [&_code]:!text-muted-foreground [&_li]:text-muted-foreground [&_ol]:text-muted-foreground [&_p]:text-muted-foreground [&_strong]:text-muted-foreground [&_td]:text-muted-foreground [&_ul]:text-muted-foreground", - "[&>h1]:!text-sm [&>h1]:!font-semibold [&>h1]:!leading-6 [&>h1]:!tracking-normal [&>h1]:!text-foreground", - "[&>h2]:!text-sm [&>h2]:!font-semibold [&>h2]:!leading-6 [&>h2]:!tracking-normal [&>h2]:!text-foreground", - "[&>h3]:!text-sm [&>h3]:!font-semibold [&>h3]:!leading-6 [&>h3]:!tracking-normal [&>h3]:!text-foreground", - "[&>h4]:!text-sm [&>h4]:!font-semibold [&>h4]:!leading-6 [&>h4]:!tracking-normal [&>h4]:!text-foreground", - "[&>h5]:!text-sm [&>h5]:!font-semibold [&>h5]:!leading-6 [&>h5]:!tracking-normal [&>h5]:!text-foreground", - "[&>h6]:!text-sm [&>h6]:!font-semibold [&>h6]:!leading-6 [&>h6]:!tracking-normal [&>h6]:!text-foreground", -].join(" "); - export function PersonaCatalogDialog({ createContent, error, @@ -574,22 +563,12 @@ function PersonaCatalogDetail({ persona }: { persona: AgentPersona }) { - - -
-

- Agent instruction -

- -
); } diff --git a/desktop/src/features/agents/ui/TeamDialog.tsx b/desktop/src/features/agents/ui/TeamDialog.tsx index 695504429d..870ea5ff5f 100644 --- a/desktop/src/features/agents/ui/TeamDialog.tsx +++ b/desktop/src/features/agents/ui/TeamDialog.tsx @@ -1,21 +1,20 @@ import * as React from "react"; +import { Upload } from "lucide-react"; +import { + CHANNEL_FORM_FIELD_CONTROL_CLASS, + CHANNEL_FORM_FIELD_SHELL_CLASS, +} from "@/features/channels/ui/channelFormStyles"; import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; import type { AgentPersona, CreateTeamInput, UpdateTeamInput, } from "@/shared/api/types"; -import { Badge } from "@/shared/ui/badge"; +import { cn } from "@/shared/lib/cn"; import { Button } from "@/shared/ui/button"; -import { Checkbox } from "@/shared/ui/checkbox"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from "@/shared/ui/dialog"; +import { ChooserDialogContent } from "@/shared/ui/chooser-dialog-content"; +import { Dialog } from "@/shared/ui/dialog"; import { Input } from "@/shared/ui/input"; import { Textarea } from "@/shared/ui/textarea"; import { personaCatalogCopy } from "./personaLibraryCopy"; @@ -27,6 +26,10 @@ import { orderPersonasByInitiallySelected, } from "./teamDialogSelection"; +const TEAM_FORM_ID = "team-form"; +const TEAM_ROW_INSET_DIVIDER_CLASS = + "after:pointer-events-none after:absolute after:bottom-0 after:left-[3.75rem] after:right-0 after:h-px after:bg-border/60 after:content-[''] last:after:hidden"; + type TeamDialogProps = { open: boolean; title: string; @@ -37,6 +40,7 @@ type TeamDialogProps = { error: Error | null; isPending: boolean; onOpenChange: (open: boolean) => void; + onImport?: () => void; onSubmit: (input: CreateTeamInput | UpdateTeamInput) => Promise; onDeleteRemovedPersonas?: (personaIds: string[]) => Promise; }; @@ -51,6 +55,7 @@ export function TeamDialog({ error, isPending, onOpenChange, + onImport, onSubmit, onDeleteRemovedPersonas, }: TeamDialogProps) { @@ -134,9 +139,16 @@ export function TeamDialog({ }; if (initialValues && "id" in initialValues) { - return { id: initialValues.id, ...baseInput }; + return { + id: initialValues.id, + ...baseInput, + }; } - return baseInput; + return { + ...baseInput, + description: teamDescription.trim() || undefined, + instructions: instructions.trim() || undefined, + }; } async function handleSubmit() { @@ -174,172 +186,233 @@ export function TeamDialog({ return ( <> - - -
- - {title} - {description.trim().length > 0 ? ( - {description} + { + if (!nextOpen && isPending) return; + handleOpenChange(nextOpen); + }} + open={open} + > + + {onImport ? ( + ) : null} - - -
-
- + +
+ } + footerClassName="border-t-0 pt-0" + headerClassName="pb-2" + title={title} + > +
{ + event.preventDefault(); + void handleSubmit(); + }} + > +
+ +
setName(event.target.value)} - placeholder="Engineering Squad" + placeholder="Enter a team name." + spellCheck={false} value={name} />
+
-
- -