From 2e356a3a450b05c04442860c753b852179ab9f53 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Fri, 7 Aug 2026 17:11:54 +0100 Subject: [PATCH 1/4] feat(desktop): expand team and template workflows Signed-off-by: kenny lopez --- .../src/commands/channel_templates.rs | 72 +- .../src-tauri/src/commands/team_snapshot.rs | 8 + desktop/src-tauri/src/events.rs | 16 +- .../src/events/mention_reference_tags.rs | 83 ++ desktop/src-tauri/src/lib.rs | 1 + desktop/src-tauri/src/templates/storage.rs | 65 +- desktop/src-tauri/src/templates/types.rs | 34 + desktop/src/app/AppShell.tsx | 5 +- desktop/src/app/AppShellOverlays.tsx | 3 + desktop/src/app/useChannelBrowserDialog.ts | 10 +- .../agents/ui/AgentDefinitionDetails.tsx | 117 ++ desktop/src/features/agents/ui/AgentsView.tsx | 9 +- .../agents/ui/PersonaCatalogDialog.tsx | 27 +- desktop/src/features/agents/ui/TeamDialog.tsx | 379 +++--- .../agents/ui/TeamSnapshotImportDialog.tsx | 219 ++-- .../src/features/agents/ui/TeamsSection.tsx | 42 +- .../src/features/agents/ui/useTeamActions.ts | 6 +- .../channel-templates/useApplyTemplate.ts | 55 +- .../channels/ui/ChannelBrowserDialog.tsx | 3 + .../messages/lib/imetaMediaMarkdown.ts | 3 +- .../messages/lib/mentionCandidates.test.mjs | 44 +- .../messages/lib/mentionCandidates.ts | 50 +- .../messages/lib/mentionExtraction.ts | 67 ++ .../src/features/messages/lib/useMentions.ts | 65 +- .../messages/ui/MentionAutocomplete.tsx | 82 +- .../src/features/messages/ui/MessageRow.tsx | 10 +- .../messages/ui/useMentionSendFlow.helpers.ts | 13 + .../messages/ui/useMentionSendFlow.ts | 64 +- .../messages/ui/useMentionSendFlow.types.ts | 57 + .../ui/ChannelTemplateAgentPicker.tsx | 411 +++++++ .../ui/ChannelTemplateSavedIdentities.tsx | 95 ++ .../ui/ChannelTemplateWorkspaceFields.tsx | 235 ++++ .../ui/ChannelTemplatesSettingsCard.tsx | 1057 ++++++++++------- .../features/settings/ui/SettingsPanels.tsx | 2 +- .../features/settings/ui/TemplateTypeStep.tsx | 129 ++ .../lib/channelSectionsStorage.test.mjs | 22 + .../sidebar/lib/channelSectionsStorage.ts | 7 + .../sidebar/lib/channelSectionsSync.ts | 1 + .../sidebar/lib/useChannelSections.ts | 28 +- .../sidebar/lib/useCreateChannelForm.ts | 38 +- .../src/features/sidebar/ui/AppSidebar.tsx | 40 +- .../sidebar/ui/ChannelSectionDialogs.tsx | 145 ++- .../sidebar/ui/CreateChannelFormFields.tsx | 180 +-- .../sidebar/ui/CustomChannelSection.tsx | 4 +- .../src/shared/api/channelTemplateTypes.ts | 72 ++ .../src/shared/api/tauriChannelTemplates.ts | 31 + desktop/src/shared/api/tauriTeams.ts | 4 + desktop/src/shared/api/types.ts | 72 +- .../shared/lib/resolveMentionNames.test.mjs | 20 + desktop/src/shared/lib/resolveMentionNames.ts | 23 +- desktop/src/shared/ui/markdown.tsx | 63 +- .../shared/ui/markdown/MarkdownMention.tsx | 70 ++ desktop/src/shared/ui/markdown/types.ts | 2 + desktop/src/testing/e2eBridge.ts | 43 + desktop/tests/e2e/agents.spec.ts | 235 +++- desktop/tests/e2e/channel-browser.spec.ts | 333 +++++- desktop/tests/e2e/channels.spec.ts | 864 +++++++++++++- desktop/tests/e2e/team-mentions.spec.ts | 89 +- desktop/tests/helpers/bridge.ts | 3 + 59 files changed, 4804 insertions(+), 1123 deletions(-) create mode 100644 desktop/src-tauri/src/events/mention_reference_tags.rs create mode 100644 desktop/src/features/agents/ui/AgentDefinitionDetails.tsx create mode 100644 desktop/src/features/messages/lib/mentionExtraction.ts create mode 100644 desktop/src/features/messages/ui/useMentionSendFlow.types.ts create mode 100644 desktop/src/features/settings/ui/ChannelTemplateAgentPicker.tsx create mode 100644 desktop/src/features/settings/ui/ChannelTemplateSavedIdentities.tsx create mode 100644 desktop/src/features/settings/ui/ChannelTemplateWorkspaceFields.tsx create mode 100644 desktop/src/features/settings/ui/TemplateTypeStep.tsx create mode 100644 desktop/src/shared/api/channelTemplateTypes.ts create mode 100644 desktop/src/shared/ui/markdown/MarkdownMention.tsx diff --git a/desktop/src-tauri/src/commands/channel_templates.rs b/desktop/src-tauri/src/commands/channel_templates.rs index 5d88e162c9d..c261bfa5af1 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, TemplateType, 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,29 @@ fn validate_visibility(value: &str) -> Result<(), String> { } } +#[tauri::command] +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 || { @@ -64,8 +115,13 @@ pub async fn create_channel_template( ) -> Result { tokio::task::spawn_blocking(move || { let name = trim_required(&input.name, "Template name")?; + let template_type = input.template_type.unwrap_or(TemplateType::Channel); 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)?; @@ -82,10 +138,14 @@ pub async fn create_channel_template( let template = ChannelTemplateRecord { id: Uuid::new_v4().to_string(), name, + template_type, description, channel_type, visibility, canvas_template, + project_folders, + project_folder, + worktree, agents: input.agents, is_builtin: false, created_at: now.clone(), @@ -107,8 +167,13 @@ pub async fn update_channel_template( ) -> Result { tokio::task::spawn_blocking(move || { let name = trim_required(&input.name, "Template name")?; + let requested_template_type = input.template_type; 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)?; @@ -124,12 +189,17 @@ pub async fn update_channel_template( .iter_mut() .find(|record| record.id == input.id) .ok_or_else(|| format!("template {} not found", input.id))?; + let template_type = requested_template_type.unwrap_or(template.template_type); template.name = name; + template.template_type = template_type; template.description = description; 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 97cd11933d7..b5bc3a7e05c 100644 --- a/desktop/src-tauri/src/commands/team_snapshot.rs +++ b/desktop/src-tauri/src/commands/team_snapshot.rs @@ -184,8 +184,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 +200,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/events.rs b/desktop/src-tauri/src/events.rs index b7937419bf1..d8e3369be5b 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 00000000000..9d77ffa6a2a --- /dev/null +++ b/desktop/src-tauri/src/events/mention_reference_tags.rs @@ -0,0 +1,83 @@ +use nostr::Tag; + +const MAX_TEAM_MENTION_FIELD_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_FIELD_CHARS + || display_name.chars().count() > MAX_TEAM_MENTION_FIELD_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 7aa954ce8e6..50c4dd66cb6 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/templates/storage.rs b/desktop/src-tauri/src/templates/storage.rs index fa122bbc47e..e0a2715b25f 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 ChannelTemplateRecord { ChannelTemplateRecord { id: id.to_string(), name: name.to_string(), + template_type: TemplateType::Channel, description: None, channel_type: "stream".to_string(), visibility: "open".to_string(), canvas_template: None, + project_folders: Vec::new(), + project_folder: None, + worktree: None, agents: TemplateAgentRoster::default(), is_builtin: false, created_at: "2026-05-11T00:00:00Z".to_string(), @@ -134,15 +148,27 @@ mod tests { #[test] fn serialization_round_trip() { - use crate::templates::{TemplateAgentEntry, TemplateBackend, TemplateTeamEntry}; + use crate::templates::{ + TemplateAgentEntry, TemplateBackend, TemplateTeamEntry, TemplateWorktreeConfig, + }; let original = ChannelTemplateRecord { id: "t1".to_string(), name: "Sprint Planning".to_string(), + template_type: TemplateType::Section, description: Some("Template for sprint channels".to_string()), channel_type: "stream".to_string(), visibility: "private".to_string(), canvas_template: Some("# {channel.name}\n\nSprint goals here".to_string()), + project_folders: vec![ + "/Users/dev/projects/sprint".to_string(), + "/Users/dev/projects/docs".to_string(), + ], + project_folder: Some("/Users/dev/projects/sprint".to_string()), + worktree: Some(TemplateWorktreeConfig { + location: "~/.buzz/worktrees".to_string(), + base_branch: "main".to_string(), + }), agents: TemplateAgentRoster { personas: vec![TemplateAgentEntry { persona_id: "builtin:fizz".to_string(), @@ -169,11 +195,28 @@ mod tests { let parsed: ChannelTemplateRecord = serde_json::from_str(&json).unwrap(); assert_eq!(parsed.id, original.id); + assert_eq!(parsed.template_type, TemplateType::Section); assert_eq!(parsed.name, original.name); assert_eq!(parsed.description, original.description); assert_eq!(parsed.channel_type, original.channel_type); assert_eq!(parsed.visibility, original.visibility); assert_eq!(parsed.canvas_template, original.canvas_template); + assert_eq!(parsed.project_folders, original.project_folders); + assert_eq!(parsed.project_folder, original.project_folder); + assert_eq!( + parsed + .worktree + .as_ref() + .map(|worktree| worktree.location.as_str()), + Some("~/.buzz/worktrees") + ); + assert_eq!( + parsed + .worktree + .as_ref() + .map(|worktree| worktree.base_branch.as_str()), + Some("main") + ); assert_eq!(parsed.agents.personas.len(), 1); assert_eq!(parsed.agents.teams.len(), 1); assert_eq!(parsed.agents.personas[0].persona_id, "builtin:fizz"); @@ -182,6 +225,23 @@ mod tests { assert!(!parsed.is_builtin); } + #[test] + fn legacy_template_defaults_to_channel_type() { + let json = serde_json::json!({ + "id": "legacy", + "name": "Legacy template", + "channel_type": "stream", + "visibility": "open", + "agents": {}, + "is_builtin": false, + "created_at": "2026-05-11T00:00:00Z", + "updated_at": "2026-05-11T00:00:00Z" + }); + + let parsed: ChannelTemplateRecord = serde_json::from_value(json).unwrap(); + assert_eq!(parsed.template_type, TemplateType::Channel); + } + #[test] fn deserialization_defaults() { let json = r#"{"id":"t1","name":"Minimal","created_at":"2026-05-11T00:00:00Z","updated_at":"2026-05-11T00:00:00Z"}"#; @@ -192,6 +252,9 @@ mod tests { assert!(!parsed.is_builtin); assert!(parsed.description.is_none()); assert!(parsed.canvas_template.is_none()); + assert!(parsed.project_folders.is_empty()); + assert!(parsed.project_folder.is_none()); + assert!(parsed.worktree.is_none()); assert!(parsed.agents.personas.is_empty()); assert!(parsed.agents.teams.is_empty()); } diff --git a/desktop/src-tauri/src/templates/types.rs b/desktop/src-tauri/src/templates/types.rs index 06e1f3a353e..cb5f1d283bd 100644 --- a/desktop/src-tauri/src/templates/types.rs +++ b/desktop/src-tauri/src/templates/types.rs @@ -4,6 +4,8 @@ use serde::{Deserialize, Serialize}; pub struct ChannelTemplateRecord { pub id: String, pub name: String, + #[serde(default)] + pub template_type: TemplateType, #[serde(default, skip_serializing_if = "Option::is_none")] pub description: Option, #[serde(default = "default_channel_type")] @@ -12,6 +14,13 @@ pub struct ChannelTemplateRecord { pub visibility: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub canvas_template: Option, + #[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)] @@ -20,6 +29,14 @@ pub struct ChannelTemplateRecord { pub updated_at: String, } +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TemplateType { + #[default] + Channel, + Section, +} + #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct TemplateAgentRoster { @@ -29,6 +46,13 @@ pub struct TemplateAgentRoster { pub teams: Vec, } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TemplateWorktreeConfig { + pub location: String, + pub base_branch: String, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct TemplateAgentEntry { @@ -74,11 +98,16 @@ fn default_visibility() -> String { #[serde(rename_all = "camelCase")] pub struct CreateChannelTemplateRequest { pub name: String, + pub template_type: Option, pub description: Option, pub channel_type: Option, 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, } @@ -87,10 +116,15 @@ pub struct CreateChannelTemplateRequest { pub struct UpdateChannelTemplateRequest { pub id: String, pub name: String, + pub template_type: Option, pub description: Option, pub channel_type: Option, 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 e4233a8fae6..0dabfb7a4ba 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 9a856ba6b07..ed62ba304a4 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 a9c08851a8c..668bdb85d44 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 00000000000..fbe68c2992d --- /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 e1e1f37f35f..1950d306e83 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 1b8be031cc8..e1e1ba0595c 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 695504429dc..4958b3a61c5 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) { @@ -128,13 +133,16 @@ export function TeamDialog({ function buildSubmitInput(): CreateTeamInput | UpdateTeamInput { const baseInput = { name, - description: teamDescription.trim() || undefined, - instructions: instructions.trim() || undefined, personaIds: filterAvailablePersonaIds(selectedPersonaIds, personas), }; if (initialValues && "id" in initialValues) { - return { id: initialValues.id, ...baseInput }; + return { + id: initialValues.id, + ...baseInput, + description: teamDescription.trim() || undefined, + instructions: instructions.trim() || undefined, + }; } return baseInput; } @@ -174,172 +182,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} />
+
-
- -