From 60362872fd9a73e2306ce9188e4faae7fc9350e6 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Mon, 10 Aug 2026 16:21:04 +0100 Subject: [PATCH] Simplify template creation and section defaults Signed-off-by: kenny lopez --- .../src/commands/channel_templates.rs | 7 +- desktop/src-tauri/src/templates/storage.rs | 10 +- desktop/src-tauri/src/templates/types.rs | 12 -- desktop/src/features/agents/ui/TeamDialog.tsx | 4 +- .../src/features/agents/ui/useTeamActions.ts | 2 + .../messages/lib/mentionCandidates.test.mjs | 29 +++ .../messages/lib/mentionCandidates.ts | 55 ++++-- .../ui/ChannelTemplatesSettingsCard.tsx | 135 +++----------- .../settings/ui/TemplateStepIndicator.tsx | 26 +++ .../features/settings/ui/TemplateTypeStep.tsx | 129 ------------- .../sidebar/lib/useCreateChannelForm.ts | 1 - .../src/features/sidebar/ui/AppSidebar.tsx | 4 +- .../sidebar/ui/ChannelSectionDialogs.tsx | 19 +- .../sidebar/ui/CreateChannelFormFields.tsx | 15 +- .../sidebar/ui/CustomChannelSection.tsx | 11 ++ .../sidebar/ui/TemplateInfoTooltip.tsx | 34 ++++ .../src/shared/api/channelTemplateTypes.ts | 4 - .../src/shared/api/tauriChannelTemplates.ts | 4 - desktop/src/shared/api/types.ts | 1 - desktop/src/testing/e2eBridge.ts | 15 +- desktop/tests/e2e/agents.spec.ts | 46 +++++ desktop/tests/e2e/channel-browser.spec.ts | 45 +++-- desktop/tests/e2e/channels.spec.ts | 169 ++++-------------- desktop/tests/e2e/profile.spec.ts | 2 +- 24 files changed, 315 insertions(+), 464 deletions(-) create mode 100644 desktop/src/features/settings/ui/TemplateStepIndicator.tsx delete mode 100644 desktop/src/features/settings/ui/TemplateTypeStep.tsx create mode 100644 desktop/src/features/sidebar/ui/TemplateInfoTooltip.tsx diff --git a/desktop/src-tauri/src/commands/channel_templates.rs b/desktop/src-tauri/src/commands/channel_templates.rs index c261bfa5af..2f00fbad42 100644 --- a/desktop/src-tauri/src/commands/channel_templates.rs +++ b/desktop/src-tauri/src/commands/channel_templates.rs @@ -5,7 +5,7 @@ use crate::{ app_state::AppState, templates::{ load_channel_templates, save_channel_templates, validate_channel_template_deletion, - ChannelTemplateRecord, CreateChannelTemplateRequest, TemplateType, TemplateWorktreeConfig, + ChannelTemplateRecord, CreateChannelTemplateRequest, TemplateWorktreeConfig, UpdateChannelTemplateRequest, }, util::now_iso, @@ -115,7 +115,6 @@ 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 = @@ -138,7 +137,6 @@ pub async fn create_channel_template( let template = ChannelTemplateRecord { id: Uuid::new_v4().to_string(), name, - template_type, description, channel_type, visibility, @@ -167,7 +165,6 @@ 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 = @@ -189,10 +186,8 @@ 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; diff --git a/desktop/src-tauri/src/templates/storage.rs b/desktop/src-tauri/src/templates/storage.rs index e0a2715b25..407e78245a 100644 --- a/desktop/src-tauri/src/templates/storage.rs +++ b/desktop/src-tauri/src/templates/storage.rs @@ -81,14 +81,12 @@ mod tests { use super::sort_channel_templates; use crate::templates::{ validate_channel_template_deletion, ChannelTemplateRecord, TemplateAgentRoster, - TemplateType, }; fn template(id: &str, name: &str) -> 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(), @@ -155,7 +153,6 @@ mod tests { 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(), @@ -195,7 +192,6 @@ 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); @@ -226,10 +222,11 @@ mod tests { } #[test] - fn legacy_template_defaults_to_channel_type() { + fn legacy_template_type_is_ignored() { let json = serde_json::json!({ "id": "legacy", "name": "Legacy template", + "template_type": "section", "channel_type": "stream", "visibility": "open", "agents": {}, @@ -239,7 +236,8 @@ mod tests { }); let parsed: ChannelTemplateRecord = serde_json::from_value(json).unwrap(); - assert_eq!(parsed.template_type, TemplateType::Channel); + let serialized = serde_json::to_value(parsed).unwrap(); + assert!(serialized.get("template_type").is_none()); } #[test] diff --git a/desktop/src-tauri/src/templates/types.rs b/desktop/src-tauri/src/templates/types.rs index cb5f1d283b..af4c233a1f 100644 --- a/desktop/src-tauri/src/templates/types.rs +++ b/desktop/src-tauri/src/templates/types.rs @@ -4,8 +4,6 @@ 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")] @@ -29,14 +27,6 @@ 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 { @@ -98,7 +88,6 @@ 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, @@ -116,7 +105,6 @@ 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, diff --git a/desktop/src/features/agents/ui/TeamDialog.tsx b/desktop/src/features/agents/ui/TeamDialog.tsx index 4958b3a61c..3b9d07a346 100644 --- a/desktop/src/features/agents/ui/TeamDialog.tsx +++ b/desktop/src/features/agents/ui/TeamDialog.tsx @@ -133,6 +133,8 @@ export function TeamDialog({ function buildSubmitInput(): CreateTeamInput | UpdateTeamInput { const baseInput = { name, + description: teamDescription.trim() || undefined, + instructions: instructions.trim() || undefined, personaIds: filterAvailablePersonaIds(selectedPersonaIds, personas), }; @@ -140,8 +142,6 @@ export function TeamDialog({ return { id: initialValues.id, ...baseInput, - description: teamDescription.trim() || undefined, - instructions: instructions.trim() || undefined, }; } return baseInput; diff --git a/desktop/src/features/agents/ui/useTeamActions.ts b/desktop/src/features/agents/ui/useTeamActions.ts index 986ceda23c..45f7b0174b 100644 --- a/desktop/src/features/agents/ui/useTeamActions.ts +++ b/desktop/src/features/agents/ui/useTeamActions.ts @@ -186,6 +186,8 @@ export function useTeamActions( submitLabel: "Create team", initialValues: { name: `${team.name} copy`, + description: team.description ?? undefined, + instructions: team.instructions ?? undefined, personaIds: [...team.personaIds], }, }); diff --git a/desktop/src/features/messages/lib/mentionCandidates.test.mjs b/desktop/src/features/messages/lib/mentionCandidates.test.mjs index 4830f25b77..44edd49137 100644 --- a/desktop/src/features/messages/lib/mentionCandidates.test.mjs +++ b/desktop/src/features/messages/lib/mentionCandidates.test.mjs @@ -212,3 +212,32 @@ test("teams with identity and persona display-name collisions are not suggested" [], ); }); + +test("teams with duplicate names are not suggested", () => { + const builder = persona("builder", "Builder"); + + assert.deepEqual( + buildTeamMentionCandidates( + [ + team("launch-one", ["builder"]), + team("launch-two", ["builder"], { name: " launch team " }), + ], + [builder], + [], + ), + [], + ); +}); + +test("teams sharing a display name with an individual are not suggested", () => { + const builder = persona("builder", "Builder"); + + assert.deepEqual( + buildTeamMentionCandidates( + [team("launch", ["builder"])], + [builder], + [identity("individual", "Launch Team", { pubkey: "1".repeat(64) })], + ), + [], + ); +}); diff --git a/desktop/src/features/messages/lib/mentionCandidates.ts b/desktop/src/features/messages/lib/mentionCandidates.ts index 665e5c4c86..784e1f3c9c 100644 --- a/desktop/src/features/messages/lib/mentionCandidates.ts +++ b/desktop/src/features/messages/lib/mentionCandidates.ts @@ -98,8 +98,18 @@ export function buildTeamMentionCandidates( personas: AgentPersona[], candidates: readonly MentionCandidate[], ): MentionCandidate[] { - return teams.flatMap((team) => { - if (team.isBuiltin || !team.name.trim()) return []; + const individualNames = new Set( + candidates.flatMap((candidate) => { + if (candidate.kind === "team") return []; + const name = candidate.displayName?.trim().toLowerCase(); + return name ? [name] : []; + }), + ); + + const eligibleTeams = teams.flatMap((team) => { + const displayName = team.name.trim(); + const normalizedName = displayName.toLowerCase(); + if (team.isBuiltin || !displayName) return []; const resolution = resolveTeamPersonas(team, personas); if (!resolution.isUsable) return []; @@ -116,17 +126,38 @@ export function buildTeamMentionCandidates( mentionNames.add(mentionName); } - return [ - { - kind: "team" as const, - teamId: team.id, - teamMembers, - displayName: team.name.trim(), - isMember: false, - isAgent: true, - }, - ]; + return [{ displayName, normalizedName, team, teamMembers }]; }); + + const teamNameCounts = new Map(); + for (const team of eligibleTeams) { + teamNameCounts.set( + team.normalizedName, + (teamNameCounts.get(team.normalizedName) ?? 0) + 1, + ); + } + + return eligibleTeams.flatMap( + ({ displayName, normalizedName, team, teamMembers }) => { + if ( + teamNameCounts.get(normalizedName) !== 1 || + individualNames.has(normalizedName) + ) { + return []; + } + + return [ + { + kind: "team" as const, + teamId: team.id, + teamMembers, + displayName, + isMember: false, + isAgent: true, + }, + ]; + }, + ); } export function formatTeamMention( diff --git a/desktop/src/features/settings/ui/ChannelTemplatesSettingsCard.tsx b/desktop/src/features/settings/ui/ChannelTemplatesSettingsCard.tsx index a4efc02aea..1058aaac64 100644 --- a/desktop/src/features/settings/ui/ChannelTemplatesSettingsCard.tsx +++ b/desktop/src/features/settings/ui/ChannelTemplatesSettingsCard.tsx @@ -31,7 +31,6 @@ import { import type { ChannelTemplate, CreateChannelTemplateInput, - TemplateType, UpdateChannelTemplateInput, } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; @@ -67,11 +66,11 @@ import { DEFAULT_WORKTREE_BASE_BRANCH, DEFAULT_WORKTREE_LOCATION, } from "./ChannelTemplateWorkspaceFields"; -import { TemplateStepIndicator, TemplateTypeStep } from "./TemplateTypeStep"; +import { TemplateStepIndicator } from "./TemplateStepIndicator"; import { ChannelTemplateSavedIdentities } from "./ChannelTemplateSavedIdentities"; const TEMPLATE_FORM_ID = "template-form"; -type TemplateFormPage = "type" | "details" | "agents"; +type TemplateFormPage = "details" | "agents"; export function ChannelTemplatesSettingsCard() { const templatesQuery = useChannelTemplatesQuery(); @@ -85,12 +84,6 @@ export function ChannelTemplatesSettingsCard() { React.useState(null); const templates = templatesQuery.data ?? []; - const channelTemplates = templates.filter( - (template) => template.templateType === "channel", - ); - const sectionTemplates = templates.filter( - (template) => template.templateType === "section", - ); function handleDuplicate(template: ChannelTemplate) { duplicateMutation.mutate(template.id, { @@ -124,9 +117,7 @@ export function ChannelTemplatesSettingsCard() {
Save reusable defaults for new channels and channel sections. - } + description={<>Save reusable configurations for new channels.} action={ } /> @@ -145,26 +136,13 @@ export function ChannelTemplatesSettingsCard() { Loading templates...

) : ( -
- - -
+ )} void; @@ -230,8 +204,7 @@ function TemplateList({ onDelete: (template: ChannelTemplate) => void; }) { return ( -
-

{title}

+
{templates.length === 0 ? (
{emptyCopy} @@ -396,15 +369,11 @@ export function TemplateFormDialog({ open, onOpenChange, onCreated, - initialTemplateType = "channel", - fixedTemplateType, }: { template: ChannelTemplate | null; open: boolean; onOpenChange: (open: boolean) => void; onCreated?: (template: ChannelTemplate) => void; - initialTemplateType?: TemplateType; - fixedTemplateType?: TemplateType; }) { const isEditing = template !== null; const createMutation = useCreateChannelTemplateMutation(); @@ -412,14 +381,9 @@ export function TemplateFormDialog({ const personasQuery = usePersonasQuery(); const teamsQuery = useTeamsQuery(); const shouldReduceMotion = useReducedMotion() ?? false; - const dialogContentRef = React.useRef(null); - const [page, setPage] = React.useState( - template || fixedTemplateType ? "details" : "type", - ); + const [page, setPage] = React.useState("details"); const [pageDirection, setPageDirection] = React.useState<1 | -1>(1); - const [templateType, setTemplateType] = - React.useState("channel"); const [name, setName] = React.useState(""); const [canvasTemplate, setCanvasTemplate] = React.useState(""); const [isCanvasOpen, setIsCanvasOpen] = React.useState(false); @@ -443,7 +407,6 @@ export function TemplateFormDialog({ >({}); const isPending = createMutation.isPending || updateMutation.isPending; - const isTypePage = page === "type"; const isAgentPage = page === "agents"; const detailsValid = name.trim().length > 0 && @@ -452,11 +415,10 @@ export function TemplateFormDialog({ React.useEffect(() => { if (!open) return; - setPage(template || fixedTemplateType ? "details" : "type"); + setPage("details"); setPageDirection(1); setIsCanvasOpen(false); if (template) { - setTemplateType(template.templateType); setName(template.name); setCanvasTemplate(template.canvasTemplate ?? ""); setProjectFolders(template.projectFolders); @@ -478,7 +440,6 @@ export function TemplateFormDialog({ } setTeamRuntimes(tRuntimes); } else { - setTemplateType(fixedTemplateType ?? initialTemplateType); setName(""); setCanvasTemplate(""); setProjectFolders([]); @@ -490,30 +451,20 @@ export function TemplateFormDialog({ setPersonaRuntimes({}); setTeamRuntimes({}); } - }, [fixedTemplateType, initialTemplateType, open, template]); + }, [open, template]); function showAgentPage() { setPageDirection(1); setPage("agents"); } - function showTypePage() { - if (fixedTemplateType) return; - setPageDirection(-1); - setPage("type"); - } - function showDetailsPage() { - setPageDirection(page === "type" ? 1 : -1); + setPageDirection(-1); setPage("details"); } function handleSubmit(event: React.FormEvent) { event.preventDefault(); - if (!isEditing && page === "type") { - showDetailsPage(); - return; - } if (!isEditing && page === "details") { if (detailsValid) showAgentPage(); return; @@ -545,7 +496,6 @@ export function TemplateFormDialog({ const input: UpdateChannelTemplateInput = { id: template.id, name: trimmedName, - templateType: template.templateType, description: template.description ?? undefined, canvasTemplate: canvasTemplate.trim() || undefined, projectFolders, @@ -572,7 +522,6 @@ export function TemplateFormDialog({ } else { const input: CreateChannelTemplateInput = { name: trimmedName, - templateType, canvasTemplate: canvasTemplate.trim() || undefined, projectFolders, worktree: worktreeEnabled @@ -648,29 +597,15 @@ export function TemplateFormDialog({ const dialogHeight = isEditing || isAgentPage || isCanvasOpen || worktreeEnabled ? "min(42rem, 85vh)" - : isTypePage - ? "min(27rem, 85vh)" - : "min(31rem, 85vh)"; + : "min(31rem, 85vh)"; return ( { - if (!isTypePage) return; - event.preventDefault(); - dialogContentRef.current?.focus({ preventScroll: true }); - }} - ref={dialogContentRef} - title={ - isEditing - ? `Edit ${template.templateType} template` - : isTypePage - ? "Create template" - : `Create ${templateType} template` - } + title={isEditing ? "Edit template" : "Create new template"} headerSubtitle={ isEditing ? isAgentPage @@ -690,12 +625,10 @@ export function TemplateFormDialog({ Back - ) : !isEditing && - !isTypePage && - (!fixedTemplateType || isAgentPage) ? ( + ) : !isEditing && isAgentPage ? ( - ) : !isEditing && isTypePage ? ( - ) : !isEditing && !isAgentPage ? (
{templates ? (
- + Template + Optional @@ -193,7 +195,7 @@ function SectionNameDialog({ + + +

{TEMPLATE_TOOLTIP_COPY[context]}

+
+ + ); +} diff --git a/desktop/src/shared/api/channelTemplateTypes.ts b/desktop/src/shared/api/channelTemplateTypes.ts index 84069757a6..e78ca04338 100644 --- a/desktop/src/shared/api/channelTemplateTypes.ts +++ b/desktop/src/shared/api/channelTemplateTypes.ts @@ -18,7 +18,6 @@ export type TemplateTeamEntry = { }; export type TemplateWorktreeConfig = { location: string; baseBranch: string }; -export type TemplateType = "channel" | "section"; export type TemplateAgents = { personas: TemplateAgentEntry[]; teams: TemplateTeamEntry[]; @@ -27,7 +26,6 @@ export type TemplateAgents = { export type ChannelTemplate = { id: string; name: string; - templateType: TemplateType; description: string | null; channelType: "stream" | "forum"; visibility: "open" | "private"; @@ -44,7 +42,6 @@ export type ChannelTemplate = { export type CreateChannelTemplateInput = { name: string; - templateType?: TemplateType; description?: string; channelType?: string; visibility?: string; @@ -59,7 +56,6 @@ export type CreateChannelTemplateInput = { export type UpdateChannelTemplateInput = { id: string; name: string; - templateType?: TemplateType; description?: string; channelType?: string; visibility?: string; diff --git a/desktop/src/shared/api/tauriChannelTemplates.ts b/desktop/src/shared/api/tauriChannelTemplates.ts index 2a70ac467d..7d92b4313b 100644 --- a/desktop/src/shared/api/tauriChannelTemplates.ts +++ b/desktop/src/shared/api/tauriChannelTemplates.ts @@ -8,7 +8,6 @@ import type { type RawChannelTemplate = { id: string; name: string; - template_type?: "channel" | "section"; description?: string | null; channel_type: string; visibility: string; @@ -51,7 +50,6 @@ function fromRawChannelTemplate(raw: RawChannelTemplate): ChannelTemplate { return { id: raw.id, name: raw.name, - templateType: raw.template_type ?? "channel", description: raw.description ?? null, channelType: raw.channel_type as "stream" | "forum", visibility: raw.visibility as "open" | "private", @@ -97,7 +95,6 @@ export async function createChannelTemplate( await invokeTauri("create_channel_template", { input: { name: input.name, - templateType: input.templateType, description: input.description, channelType: input.channelType, visibility: input.visibility, @@ -119,7 +116,6 @@ export async function updateChannelTemplate( input: { id: input.id, name: input.name, - templateType: input.templateType, description: input.description, channelType: input.channelType, visibility: input.visibility, diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts index 2fde757436..3b04bde87d 100644 --- a/desktop/src/shared/api/types.ts +++ b/desktop/src/shared/api/types.ts @@ -832,7 +832,6 @@ export type { TemplateAgents, TemplateBackend, TemplateTeamEntry, - TemplateType, TemplateWorktreeConfig, UpdateChannelTemplateInput, } from "./channelTemplateTypes"; diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index a1a948b7de..25ae9c2458 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -142,6 +142,7 @@ type MockTeamSeed = { id?: string; name: string; description?: string | null; + instructions?: string | null; personaIds: string[]; }; @@ -930,6 +931,7 @@ type RawTeam = { id: string; name: string; description: string | null; + instructions: string | null; persona_ids: string[]; is_builtin: boolean; source_dir: string | null; @@ -2413,6 +2415,7 @@ function resetMockTeams(config?: E2eConfig) { id: "team-engineering-001", name: "Engineering", description: "Core engineering personas", + instructions: null, persona_ids: [], is_builtin: false, source_dir: null, @@ -2426,6 +2429,7 @@ function resetMockTeams(config?: E2eConfig) { id: "team-research-002", name: "Research Agents", description: "Directory-backed research team", + instructions: null, persona_ids: [], is_builtin: false, source_dir: "/Users/dev/agents/research", @@ -2439,6 +2443,7 @@ function resetMockTeams(config?: E2eConfig) { id: "team-platform-003", name: "Platform Tools", description: "Symlinked platform team", + instructions: null, persona_ids: [], is_builtin: false, source_dir: "/Users/dev/agents/platform", @@ -2455,6 +2460,7 @@ function resetMockTeams(config?: E2eConfig) { id: team.id ?? crypto.randomUUID(), name: team.name, description: team.description ?? null, + instructions: team.instructions ?? null, persona_ids: [...team.personaIds], is_builtin: false, source_dir: null, @@ -8190,6 +8196,7 @@ async function handleCreateTeam(args: { input: { name: string; description?: string; + instructions?: string; personaIds: string[]; }; }): Promise { @@ -8199,6 +8206,7 @@ async function handleCreateTeam(args: { id: crypto.randomUUID(), name: args.input.name.trim(), description: args.input.description?.trim() || null, + instructions: args.input.instructions?.trim() || null, persona_ids: [...args.input.personaIds], is_builtin: false, source_dir: null, @@ -8217,6 +8225,7 @@ async function handleUpdateTeam(args: { id: string; name: string; description?: string; + instructions?: string; personaIds: string[]; }; }): Promise { @@ -8228,6 +8237,7 @@ async function handleUpdateTeam(args: { ensureMockPersonaIdsAreActive(args.input.personaIds); team.name = args.input.name.trim(); team.description = args.input.description?.trim() || null; + team.instructions = args.input.instructions?.trim() || null; team.persona_ids = [...args.input.personaIds]; team.updated_at = new Date().toISOString(); @@ -8274,6 +8284,7 @@ async function handleInstallTeamFromDirectory(args: { id: crypto.randomUUID(), name: "Installed Team", description: null, + instructions: null, persona_ids: [], is_builtin: false, source_dir: args.path, @@ -11963,7 +11974,6 @@ export function maybeInstallE2eTauriMocks() { return (activeConfig?.mock?.channelTemplates ?? []).map((template) => ({ id: template.id, name: template.name, - template_type: template.templateType, description: template.description, channel_type: template.channelType, visibility: template.visibility, @@ -11980,7 +11990,6 @@ export function maybeInstallE2eTauriMocks() { const { input } = payload as { input: { name: string; - templateType?: "channel" | "section"; description?: string; channelType?: "stream" | "forum"; visibility?: "open" | "private"; @@ -11995,7 +12004,6 @@ export function maybeInstallE2eTauriMocks() { const created: ChannelTemplate = { id: `template-${Date.now()}`, name: input.name, - templateType: input.templateType ?? "channel", description: input.description ?? null, channelType: input.channelType ?? "stream", visibility: input.visibility ?? "open", @@ -12021,7 +12029,6 @@ export function maybeInstallE2eTauriMocks() { return { id: created.id, name: created.name, - template_type: created.templateType, description: created.description, channel_type: created.channelType, visibility: created.visibility, diff --git a/desktop/tests/e2e/agents.spec.ts b/desktop/tests/e2e/agents.spec.ts index 9e72f0e1b7..398be410a6 100644 --- a/desktop/tests/e2e/agents.spec.ts +++ b/desktop/tests/e2e/agents.spec.ts @@ -505,6 +505,7 @@ test("the new team card opens one create and import dialog with add-row agent se id: "existing-release-team", name: "Existing release team", description: "Coordinates releases.", + instructions: "Keep every release coordinated.", personaIds: ["custom:release-agent"], }, ], @@ -726,12 +727,57 @@ test("the new team card opens one create and import dialog with add-row agent se "Coordinates releases.", ); await expect(dialog.getByLabel("Team Instructions")).toBeVisible(); + await expect(dialog.getByLabel("Team Instructions")).toHaveValue( + "Keep every release coordinated.", + ); await expect(dialog.getByTestId("team-import")).toHaveCount(0); await expect( dialog .getByTestId("team-agent-row-custom:release-agent") .getByRole("button", { exact: true, name: "Remove" }), ).toBeVisible(); + + await dialog.getByRole("button", { name: "Close" }).click(); + await page + .getByRole("button", { name: "Existing release team team actions" }) + .click(); + await page.getByRole("menuitem", { exact: true, name: "Duplicate" }).click(); + await expect( + dialog.getByRole("heading", { + exact: true, + name: "Duplicate Existing release team", + }), + ).toBeVisible(); + await expect(dialog.getByLabel("Name")).toHaveValue( + "Existing release team copy", + ); + await dialog.getByTestId("team-submit").click(); + + const duplicatePayload = await page.evaluate(() => + ( + ( + window as Window & { + __BUZZ_E2E_COMMAND_LOG__?: Array<{ + command: string; + payload: unknown; + }>; + } + ).__BUZZ_E2E_COMMAND_LOG__ ?? [] + ) + .filter((entry) => entry.command === "create_team") + .at(-1), + ); + expect(duplicatePayload).toEqual({ + command: "create_team", + payload: { + input: { + name: "Existing release team copy", + description: "Coordinates releases.", + instructions: "Keep every release coordinated.", + personaIds: ["custom:release-agent"], + }, + }, + }); }); test("team cards follow the agents grid alignment at compact widths", async ({ diff --git a/desktop/tests/e2e/channel-browser.spec.ts b/desktop/tests/e2e/channel-browser.spec.ts index c1fbe22610..6c959fc323 100644 --- a/desktop/tests/e2e/channel-browser.spec.ts +++ b/desktop/tests/e2e/channel-browser.spec.ts @@ -42,7 +42,6 @@ test.beforeEach(async ({ page }, testInfo) => { { id: "engineering-defaults", name: "Engineering defaults", - templateType: "section" as const, description: "Inherited section channel", channelType: "stream" as const, visibility: "private" as const, @@ -58,7 +57,6 @@ test.beforeEach(async ({ page }, testInfo) => { { id: "channel-only", name: "Channel only", - templateType: "channel" as const, description: null, channelType: "stream" as const, visibility: "open" as const, @@ -74,7 +72,6 @@ test.beforeEach(async ({ page }, testInfo) => { { id: "product-defaults", name: "Product defaults", - templateType: "section" as const, description: "Inherited product channel", channelType: "stream" as const, visibility: "open" as const, @@ -303,6 +300,19 @@ test("sidebar add-channel button creates without treating the click as a callbac await expect(page.getByTestId("stream-list")).toContainText(channelName); }); +test("Channels actions menu creates a section", async ({ page }) => { + await page.goto("/"); + + await page.getByTestId("section-actions-channels").click(); + await page.getByRole("menuitem", { name: "Create section" }).click(); + + const dialog = page.getByRole("dialog", { name: "Create section" }); + await dialog.getByPlaceholder("Section name").fill("Planning"); + await dialog.getByRole("button", { name: "Create", exact: true }).click(); + + await expect(page.getByText("Planning", { exact: true })).toBeVisible(); +}); + test("custom section add button creates directly into that section", async ({ page, }) => { @@ -339,9 +349,20 @@ test("new-section flow stores a section template selection", async ({ const dialog = page.getByRole("dialog", { name: "Create section" }); await dialog.getByPlaceholder("Section name").fill("Engineering"); + const templateInfo = dialog.getByRole("button", { + name: "About section templates", + }); + await expect(templateInfo).toBeVisible(); + await templateInfo.hover(); + await expect(page.getByRole("tooltip")).toHaveText( + "A template is a reusable channel configuration. New channels in this section automatically use the selected template.", + ); const templateControl = dialog.getByTestId("create-section-template"); await expect(templateControl).toHaveText("None"); await templateControl.click(); + await expect( + page.getByRole("menuitemradio", { name: "Channel only" }), + ).toBeVisible(); await page .getByRole("menuitemradio", { name: "Engineering defaults" }) .click(); @@ -379,14 +400,12 @@ test("create-section flow creates and preselects a new section template", async await sectionDialog.getByPlaceholder("Section name").fill("Product"); const templateControl = sectionDialog.getByTestId("create-section-template"); await templateControl.click(); - await page - .getByRole("menuitem", { name: "Create new section template" }) - .click(); + await page.getByRole("menuitem", { name: "Create new template" }).click(); const templateDialog = page.getByTestId("channel-template-dialog"); await expect( templateDialog.getByRole("heading", { - name: "Create section template", + name: "Create new template", exact: true, }), ).toBeVisible(); @@ -407,9 +426,16 @@ test("create-section flow creates and preselects a new section template", async ); expect(createTemplateCall?.payload).toEqual( expect.objectContaining({ - input: expect.objectContaining({ templateType: "section" }), + input: expect.objectContaining({ name: "Product launch" }), }), ); + expect( + ( + createTemplateCall?.payload as + | { input: { templateType?: string } } + | undefined + )?.input.templateType, + ).toBeUndefined(); await sectionDialog .getByRole("button", { name: "Create", exact: true }) .click(); @@ -466,9 +492,6 @@ test("custom section inherits section template defaults for new channels", async await expect( page.getByRole("menuitem", { name: "Create new template" }), ).toHaveCount(0); - await expect( - page.getByRole("menuitem", { name: "Create new section template" }), - ).toHaveCount(0); await expect(page.getByTestId("create-channel-description")).toHaveValue( "Inherited section channel", ); diff --git a/desktop/tests/e2e/channels.spec.ts b/desktop/tests/e2e/channels.spec.ts index 683cd03525..852f5e7044 100644 --- a/desktop/tests/e2e/channels.spec.ts +++ b/desktop/tests/e2e/channels.spec.ts @@ -1376,7 +1376,6 @@ test("create channel template selector matches the lifecycle controls", async ({ { id: "project-kickoff", name: "Project kickoff", - templateType: "channel", description: "Coordinate a new project from planning through launch.", channelType: "stream", visibility: "private", @@ -1416,7 +1415,6 @@ test("create channel template selector matches the lifecycle controls", async ({ { id: "engineering-section", name: "Engineering section", - templateType: "section", description: null, channelType: "stream", visibility: "open", @@ -1455,6 +1453,15 @@ test("create channel template selector matches the lifecycle controls", async ({ await page.goto("/"); await openCreateChannelDialog(page); + const templateInfo = page.getByRole("button", { + name: "About channel templates", + }); + await expect(templateInfo).toBeVisible(); + await templateInfo.hover(); + await expect(page.getByRole("tooltip")).toHaveText( + "A template is a reusable channel configuration. Choose one to apply its settings to this channel.", + ); + const templateControl = page.getByTestId("create-channel-template"); await expect(templateControl).toHaveRole("button"); await expect(templateControl).toHaveText("None"); @@ -1464,7 +1471,7 @@ test("create channel template selector matches the lifecycle controls", async ({ ).toBeVisible(); await expect( page.getByRole("menuitemradio", { name: "Engineering section" }), - ).toHaveCount(0); + ).toBeVisible(); await page.getByRole("menuitemradio", { name: "Project kickoff" }).click(); await expect(templateControl).toHaveText("Project kickoff"); @@ -1521,7 +1528,6 @@ test("channel template settings use structured cards with visible actions", asyn { id: "project-kickoff", name: "Project kickoff", - templateType: "channel", description: "Legacy description that is no longer presented.", channelType: "stream", visibility: "private", @@ -1558,7 +1564,6 @@ test("channel template settings use structured cards with visible actions", asyn { id: "engineering-section", name: "Engineering section", - templateType: "section", description: null, channelType: "stream", visibility: "open", @@ -1600,12 +1605,9 @@ test("channel template settings use structured cards with visible actions", asyn await expect( page.getByRole("heading", { name: "Templates", exact: true }), ).toBeVisible(); - const channelTemplateList = page.getByTestId("channel-templates-list"); - const sectionTemplateList = page.getByTestId("section-templates-list"); - await expect(channelTemplateList).toContainText("Project kickoff"); - await expect(channelTemplateList).not.toContainText("Engineering section"); - await expect(sectionTemplateList).toContainText("Engineering section"); - await expect(sectionTemplateList).not.toContainText("Project kickoff"); + const templateList = page.getByTestId("templates-list"); + await expect(templateList).toContainText("Project kickoff"); + await expect(templateList).toContainText("Engineering section"); const row = page.getByTestId("channel-template-row-project-kickoff"); await expect(row).toBeVisible(); @@ -1741,7 +1743,7 @@ test("channel template settings use structured cards with visible actions", asyn ); }); -test("create channel template uses the grouped agent team and agent picker", async ({ +test("create template uses the grouped agent team and agent picker", async ({ page, }) => { await installMockBridge(page, { @@ -1807,7 +1809,7 @@ test("create channel template uses the grouped agent team and agent picker", asy await openSettings(page, "channel-templates"); await page .getByTestId("settings-channel-templates") - .getByRole("button", { name: "Create", exact: true }) + .getByRole("button", { name: "New template", exact: true }) .click(); const dialog = page.getByTestId("channel-template-dialog"); @@ -1823,134 +1825,24 @@ test("create channel template uses the grouped agent team and agent picker", asy await expect(dialog).toBeVisible(); await expect( dialog.getByRole("heading", { - name: "Create template", + name: "Create new template", exact: true, }), ).toBeVisible(); const stepIndicator = dialog.getByTestId("template-step-indicator"); await expect(stepIndicator).toHaveAttribute("aria-valuenow", "1"); + await expect(stepIndicator).toHaveAttribute("aria-valuemax", "2"); await expect(stepIndicator.locator('[data-state="active"]')).toHaveCount(1); - await expect(stepIndicator.locator('[data-state="future"]')).toHaveCount(2); - await expect(dialog.getByTestId("template-type-page")).toBeVisible(); + await expect(stepIndicator.locator('[data-state="future"]')).toHaveCount(1); + await expect(dialog.getByTestId("template-type-page")).toHaveCount(0); await expect( dialog.getByRole("button", { name: "Cancel", exact: true }), ).toHaveCount(0); - const channelType = dialog.getByTestId("template-type-channel"); - const sectionType = dialog.getByTestId("template-type-section"); - const channelTypeCard = channelType.locator(".."); - await expect(channelType).toBeChecked(); - await expect(sectionType).not.toBeChecked(); - await expect(dialog).toBeFocused(); - await expect(channelTypeCard).toHaveCSS("box-shadow", "none"); - await page.keyboard.press("Tab"); - await expect(channelType).toBeFocused(); - await expect(channelTypeCard).not.toHaveCSS("box-shadow", "none"); - await expect( - dialog.getByText("Choose what this template configures.", { exact: true }), - ).toHaveCount(0); - await waitForAnimations(page); - const channelTypeBox = await channelTypeCard.boundingBox(); - const sectionTypeBox = await sectionType.locator("..").boundingBox(); - const typeDialogBox = await dialog.boundingBox(); - expect(channelTypeBox).not.toBeNull(); - expect(sectionTypeBox).not.toBeNull(); - expect(typeDialogBox?.width ?? 0).toBeGreaterThanOrEqual(640); - expect(channelTypeBox?.height ?? 0).toBeGreaterThanOrEqual(192); - expect(channelTypeBox?.y).toBe(sectionTypeBox?.y); - expect(channelTypeBox?.x ?? 0).toBeLessThan(sectionTypeBox?.x ?? 0); - await expect( - dialog.getByText("Set defaults for one new channel.", { exact: true }), - ).toHaveClass(/text-muted-foreground/); - await expect(dialog.getByTestId("template-type-copy-channel")).toHaveClass( - /mt-auto/, - ); - const neutralColors = await dialog.evaluate(() => { - const channelInput = document.querySelector( - '[data-testid="template-type-channel"]', - ); - const sectionInput = document.querySelector( - '[data-testid="template-type-section"]', - ); - const channelIcon = document.querySelector( - '[data-testid="template-type-icon-channel"]', - ); - const sectionIcon = document.querySelector( - '[data-testid="template-type-icon-section"]', - ); - return { - selectedCard: channelInput?.parentElement - ? getComputedStyle(channelInput.parentElement).backgroundColor - : null, - unselectedCard: sectionInput?.parentElement - ? getComputedStyle(sectionInput.parentElement).backgroundColor - : null, - selectedIcon: channelIcon - ? getComputedStyle(channelIcon).backgroundColor - : null, - unselectedIcon: sectionIcon - ? getComputedStyle(sectionIcon).backgroundColor - : null, - }; - }); - expect(neutralColors.selectedCard).toBe(neutralColors.unselectedCard); - expect(neutralColors.selectedIcon).toBe(neutralColors.unselectedIcon); - await expect(sectionType.locator("..")).toHaveClass(/hover:bg-input\/30/); await expect(dialog).toHaveCSS("transition-property", "height, max-width"); await expect(dialog).toHaveCSS("transition-duration", "0.22s"); - expect(await dialog.evaluate((element) => element.style.height)).toBe( - "min(27rem, 85vh)", - ); - await sectionType.locator("..").click(); - await expect(sectionType).toBeChecked(); - await dialog.getByRole("button", { name: "Next", exact: true }).click(); - await expect( - dialog.getByRole("heading", { - name: "Create section template", - exact: true, - }), - ).toBeVisible(); - await expect( - dialog.getByRole("button", { name: "Cancel", exact: true }), - ).toHaveCount(0); - await dialog.locator("#template-name").fill("Section defaults"); - await expectFilledTemplateNameForeground(); - await dialog.locator("#template-name").fill(""); - await dialog.getByRole("button", { name: "Back", exact: true }).click(); - await channelType.locator("..").click(); - await dialog.getByRole("button", { name: "Next", exact: true }).click(); - await expect(stepIndicator).toHaveAttribute("aria-valuenow", "2"); - await expect( - dialog.getByRole("heading", { - name: "Create channel template", - exact: true, - }), - ).toBeVisible(); - await expect( - dialog.getByRole("button", { name: "Cancel", exact: true }), - ).toHaveCount(0); expect(await dialog.evaluate((element) => element.style.height)).toBe( "min(31rem, 85vh)", ); - await dialog.getByRole("button", { name: "Back", exact: true }).click(); - await expect(stepIndicator).toHaveAttribute("aria-valuenow", "1"); - await expect(dialog.getByTestId("template-type-page")).toBeVisible(); - expect(await dialog.evaluate((element) => element.style.height)).toBe( - "min(27rem, 85vh)", - ); - await expect( - dialog.getByRole("heading", { name: "Create template", exact: true }), - ).toBeVisible(); - const typePageMetrics = await dialog - .getByTestId("template-type-page") - .evaluate((element) => ({ - clientHeight: element.clientHeight, - scrollHeight: element.scrollHeight, - })); - expect(typePageMetrics.scrollHeight).toBeLessThanOrEqual( - typePageMetrics.clientHeight + 1, - ); - await dialog.getByRole("button", { name: "Next", exact: true }).click(); - await expect(stepIndicator).toHaveAttribute("aria-valuenow", "2"); await expect( dialog.getByRole("button", { name: "Cancel", exact: true }), ).toHaveCount(0); @@ -2113,7 +2005,7 @@ test("create channel template uses the grouped agent team and agent picker", asy await page.locator("#template-name").fill("Weekly planning"); await expectFilledTemplateNameForeground(); await dialog.getByRole("button", { name: "Next", exact: true }).click(); - await expect(stepIndicator).toHaveAttribute("aria-valuenow", "3"); + await expect(stepIndicator).toHaveAttribute("aria-valuenow", "2"); await expect( dialog.getByTestId("channel-template-agents-page"), ).toBeVisible(); @@ -2203,7 +2095,7 @@ test("create channel template uses the grouped agent team and agent picker", asy ).toBeVisible(); await dialog.getByRole("button", { name: "Back", exact: true }).click(); - await expect(stepIndicator).toHaveAttribute("aria-valuenow", "2"); + await expect(stepIndicator).toHaveAttribute("aria-valuenow", "1"); await expect(page.locator("#template-name")).toHaveValue("Weekly planning"); await expect(firstProjectFolder).toHaveText("/Users/dev/projects/sprout"); await expect( @@ -2244,7 +2136,6 @@ test("create channel template uses the grouped agent team and agent picker", asy expect(createTemplateCall?.payload).toEqual( expect.objectContaining({ input: expect.objectContaining({ - templateType: "channel", canvasTemplate: "# Weekly planning", projectFolders: [ "/Users/dev/projects/sprout", @@ -2265,29 +2156,33 @@ test("create channel template uses the grouped agent team and agent picker", asy }), }), ); - await expect(page.getByTestId("channel-templates-list")).toContainText( + expect( + ( + createTemplateCall?.payload as + | { input: { templateType?: string } } + | undefined + )?.input.templateType, + ).toBeUndefined(); + await expect(page.getByTestId("templates-list")).toContainText( "Weekly planning", ); }); -test("create channel template wizard respects reduced motion", async ({ - page, -}) => { +test("create template wizard respects reduced motion", async ({ page }) => { await page.emulateMedia({ reducedMotion: "reduce" }); await page.goto("/"); await openSettings(page, "channel-templates"); await page .getByTestId("settings-channel-templates") - .getByRole("button", { name: "Create", exact: true }) + .getByRole("button", { name: "New template", exact: true }) .click(); const dialog = page.getByTestId("channel-template-dialog"); await expect(dialog).toHaveCSS("transition-property", "height, max-width"); await expect(dialog).toHaveCSS("transition-duration", "0s"); - await dialog.getByRole("button", { name: "Next", exact: true }).click(); await expect( dialog.getByRole("heading", { - name: "Create channel template", + name: "Create new template", exact: true, }), ).toBeVisible(); diff --git a/desktop/tests/e2e/profile.spec.ts b/desktop/tests/e2e/profile.spec.ts index eefdef1fdd..8a145dcb06 100644 --- a/desktop/tests/e2e/profile.spec.ts +++ b/desktop/tests/e2e/profile.spec.ts @@ -1145,7 +1145,7 @@ test("renders settings in the app shell with a back button", async ({ await expect(personalGroup).toContainText("Personal"); await expect( page.getByTestId("settings-nav-channel-templates"), - ).toContainText("Channel templates"); + ).toContainText("Templates"); await expect(page.getByTestId("settings-nav-profile")).toHaveAttribute( "aria-pressed", "true",