Simplify template creation and section defaults

Signed-off-by: kenny lopez <klopez4212@gmail.com>
This commit is contained in:
kenny lopez
2026-08-10 16:21:04 +01:00
parent 2e356a3a45
commit 60362872fd
24 changed files with 315 additions and 464 deletions

View File

@@ -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<ChannelTemplateRecord, String> {
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<ChannelTemplateRecord, String> {
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;

View File

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

View File

@@ -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<String>,
#[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<TemplateType>,
pub description: Option<String>,
pub channel_type: Option<String>,
pub visibility: Option<String>,
@@ -116,7 +105,6 @@ pub struct CreateChannelTemplateRequest {
pub struct UpdateChannelTemplateRequest {
pub id: String,
pub name: String,
pub template_type: Option<TemplateType>,
pub description: Option<String>,
pub channel_type: Option<String>,
pub visibility: Option<String>,

View File

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

View File

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

View File

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

View File

@@ -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<string, number>();
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(

View File

@@ -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<ChannelTemplate | null>(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() {
<section className="min-w-0" data-testid="settings-channel-templates">
<SettingsSectionHeader
title="Templates"
description={
<>Save reusable defaults for new channels and channel sections.</>
}
description={<>Save reusable configurations for new channels.</>}
action={
<Button
onClick={() => setIsCreateOpen(true)}
@@ -135,7 +126,7 @@ export function ChannelTemplatesSettingsCard() {
variant="outline"
>
<Plus className="mr-1.5 h-4 w-4" />
Create
New template
</Button>
}
/>
@@ -145,26 +136,13 @@ export function ChannelTemplatesSettingsCard() {
Loading templates...
</p>
) : (
<div className="space-y-7">
<TemplateList
emptyCopy="No channel templates yet."
onDelete={setDeleteTarget}
onDuplicate={handleDuplicate}
onEdit={setEditingTemplate}
templates={channelTemplates}
title="Channel templates"
type="channel"
/>
<TemplateList
emptyCopy="No section templates yet."
onDelete={setDeleteTarget}
onDuplicate={handleDuplicate}
onEdit={setEditingTemplate}
templates={sectionTemplates}
title="Section templates"
type="section"
/>
</div>
<TemplateList
emptyCopy="No templates yet."
onDelete={setDeleteTarget}
onDuplicate={handleDuplicate}
onEdit={setEditingTemplate}
templates={templates}
/>
)}
<TemplateFormDialog
@@ -213,16 +191,12 @@ export function ChannelTemplatesSettingsCard() {
}
function TemplateList({
title,
type,
templates,
emptyCopy,
onEdit,
onDuplicate,
onDelete,
}: {
title: string;
type: TemplateType;
templates: ChannelTemplate[];
emptyCopy: string;
onEdit: (template: ChannelTemplate) => void;
@@ -230,8 +204,7 @@ function TemplateList({
onDelete: (template: ChannelTemplate) => void;
}) {
return (
<div className="space-y-3" data-testid={`${type}-templates-list`}>
<h3 className="text-sm font-semibold text-foreground">{title}</h3>
<div className="space-y-3" data-testid="templates-list">
{templates.length === 0 ? (
<div className="rounded-xl border border-dashed border-border/70 bg-muted/15 px-4 py-6 text-center text-sm text-muted-foreground">
{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<HTMLDivElement>(null);
const [page, setPage] = React.useState<TemplateFormPage>(
template || fixedTemplateType ? "details" : "type",
);
const [page, setPage] = React.useState<TemplateFormPage>("details");
const [pageDirection, setPageDirection] = React.useState<1 | -1>(1);
const [templateType, setTemplateType] =
React.useState<TemplateType>("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 (
<Dialog onOpenChange={onOpenChange} open={open}>
<ChooserDialogContent
className={isTypePage ? "max-w-2xl" : "max-w-xl"}
className="max-w-xl"
contentClassName="h-full pt-3"
data-testid="channel-template-dialog"
onOpenAutoFocus={(event) => {
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({
<ArrowLeft className="mr-1.5 h-4 w-4" />
Back
</Button>
) : !isEditing &&
!isTypePage &&
(!fixedTemplateType || isAgentPage) ? (
) : !isEditing && isAgentPage ? (
<Button
disabled={isPending}
onClick={isAgentPage ? showDetailsPage : showTypePage}
onClick={showDetailsPage}
type="button"
variant="ghost"
>
@@ -725,15 +658,6 @@ export function TemplateFormDialog({
>
Done
</Button>
) : !isEditing && isTypePage ? (
<Button
disabled={isPending}
key="show-template-details"
onClick={showDetailsPage}
type="button"
>
Next
</Button>
) : !isEditing && !isAgentPage ? (
<Button
disabled={
@@ -785,9 +709,7 @@ export function TemplateFormDialog({
>
{!isEditing ? (
<div className="mb-5 shrink-0">
<TemplateStepIndicator
step={isTypePage ? 1 : isAgentPage ? 3 : 2}
/>
<TemplateStepIndicator step={isAgentPage ? 2 : 1} />
</div>
) : null}
<AnimatePresence initial={false} mode="popLayout">
@@ -816,18 +738,7 @@ export function TemplateFormDialog({
ease: [0.23, 1, 0.32, 1],
}}
>
{isTypePage ? (
<div
className="min-h-0 flex-1 overflow-y-auto"
data-testid="template-type-page"
>
<TemplateTypeStep
disabled={isPending}
onChange={setTemplateType}
value={templateType}
/>
</div>
) : !isAgentPage ? (
{!isAgentPage ? (
<div
className="min-h-0 flex-1 overflow-y-auto pr-1"
data-testid="channel-template-details-page"

View File

@@ -0,0 +1,26 @@
import { cn } from "@/shared/lib/cn";
export function TemplateStepIndicator({ step }: { step: 1 | 2 }) {
return (
<div
aria-label={`Step ${step} of 2`}
aria-valuemax={2}
aria-valuemin={1}
aria-valuenow={step}
className="flex w-full gap-2"
data-testid="template-step-indicator"
role="progressbar"
>
{[1, 2].map((segment) => (
<span
className={cn(
"h-1.5 flex-1 rounded-full",
segment <= step ? "bg-primary" : "bg-input",
)}
data-state={segment <= step ? "active" : "future"}
key={segment}
/>
))}
</div>
);
}

View File

@@ -1,129 +0,0 @@
import { LayoutList, MessageSquare } from "lucide-react";
import type { TemplateType } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
const TEMPLATE_TYPE_OPTIONS: Array<{
type: TemplateType;
title: string;
description: string;
icon: typeof MessageSquare;
}> = [
{
type: "channel",
title: "Channel",
description: "Set defaults for one new channel.",
icon: MessageSquare,
},
{
type: "section",
title: "Section",
description:
"Set defaults inherited by every channel created in a section using this template.",
icon: LayoutList,
},
];
export function TemplateTypeStep({
value,
onChange,
disabled,
}: {
value: TemplateType;
onChange: (value: TemplateType) => void;
disabled: boolean;
}) {
return (
<div
aria-label="Template type"
className="grid grid-cols-2 gap-3"
data-testid="template-type-options"
role="radiogroup"
>
{TEMPLATE_TYPE_OPTIONS.map((option) => {
const Icon = option.icon;
const selected = option.type === value;
return (
<label
className={cn(
"relative flex min-h-48 flex-col items-start rounded-2xl border p-5 text-left transition-colors",
selected
? "border-primary bg-background"
: "border-border/70 bg-background",
"hover:bg-input/30",
"has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-ring has-[:focus-visible]:ring-offset-2",
disabled && "pointer-events-none opacity-50",
)}
key={option.type}
>
<input
checked={selected}
className="sr-only"
data-testid={`template-type-${option.type}`}
disabled={disabled}
name="template-type"
onChange={() => onChange(option.type)}
type="radio"
value={option.type}
/>
<span
aria-hidden
className={cn(
"absolute right-4 top-4 flex size-4 items-center justify-center rounded-full border",
selected
? "border-primary"
: "border-muted-foreground/50 bg-background",
)}
>
{selected ? (
<span className="size-2 rounded-full bg-primary" />
) : null}
</span>
<span
className="flex size-10 shrink-0 items-center justify-center rounded-xl border border-border/60 bg-muted/20 text-muted-foreground"
data-testid={`template-type-icon-${option.type}`}
>
<Icon aria-hidden className="size-5" />
</span>
<span
className="mt-auto block pt-6"
data-testid={`template-type-copy-${option.type}`}
>
<span className="block text-sm font-medium text-foreground">
{option.title}
</span>
<span className="mt-1 block text-sm leading-5 text-muted-foreground">
{option.description}
</span>
</span>
</label>
);
})}
</div>
);
}
export function TemplateStepIndicator({ step }: { step: 1 | 2 | 3 }) {
return (
<div
aria-label={`Step ${step} of 3`}
className="flex w-full gap-2"
data-testid="template-step-indicator"
role="progressbar"
aria-valuemax={3}
aria-valuemin={1}
aria-valuenow={step}
>
{[1, 2, 3].map((segment) => (
<span
className={cn(
"h-1.5 flex-1 rounded-full",
segment <= step ? "bg-primary" : "bg-input",
)}
data-state={segment <= step ? "active" : "future"}
key={segment}
/>
))}
</div>
);
}

View File

@@ -151,7 +151,6 @@ export function useCreateChannelForm({
const handleTemplateCreated = React.useCallback(
(template: ChannelTemplate) => {
if (template.templateType !== "channel") return;
applyTemplate(template);
},
[applyTemplate],

View File

@@ -431,12 +431,11 @@ export function AppSidebar({
}, [streamChannels, starredChannelIds, sortModeFor]);
const handleCreateSectionForChannel = React.useCallback(
(channelId: string) => {
(channelId: string | null = null) => {
setCreateSectionState({ open: true, pendingChannelId: channelId });
},
[],
);
const handleCreateSectionConfirm = React.useCallback(
(value: SectionDialogValue) => {
const section = createSection(value.name, value.icon, value.templateId);
@@ -756,6 +755,7 @@ export function AppSidebar({
onAssignChannel={assignChannel}
onUnassignChannel={unassignChannel}
onCreateSectionForChannel={handleCreateSectionForChannel}
onCreateSection={handleCreateSectionForChannel}
mutedChannelIds={mutedChannelIds}
onMuteChannel={onMuteChannel}
onUnmuteChannel={onUnmuteChannel}

View File

@@ -42,6 +42,7 @@ import {
useLeaveChannelMutation,
} from "@/features/channels/hooks";
import { ChannelDeleteConfirmationDialog } from "@/features/channels/ui/ChannelManagementModerationActions";
import { TemplateInfoTooltip } from "@/features/sidebar/ui/TemplateInfoTooltip";
export type SectionDialogValue = {
name: string;
@@ -184,8 +185,9 @@ function SectionNameDialog({
</div>
{templates ? (
<div className="mt-3 flex min-h-11 items-center justify-between gap-4 rounded-xl border border-input bg-background px-3">
<span className="text-sm font-medium text-foreground">
<span className="inline-flex items-center gap-1 text-sm font-medium text-foreground">
Template
<TemplateInfoTooltip context="section" />
<span className="ml-1 text-xs font-normal text-muted-foreground/50">
Optional
</span>
@@ -193,7 +195,7 @@ function SectionNameDialog({
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button
aria-label={`Section template: ${
aria-label={`Template: ${
templates.find(
(template) => template.id === selectedTemplateId,
)?.name ?? "None"
@@ -239,7 +241,7 @@ function SectionNameDialog({
onSelect={() => setIsCreateTemplateOpen(true)}
>
<Plus className="size-4" />
Create new section template
Create new template
</DropdownMenuItem>
</>
) : null}
@@ -268,7 +270,6 @@ function SectionNameDialog({
</DialogContent>
{allowTemplateCreation ? (
<TemplateFormDialog
fixedTemplateType="section"
onCreated={(template) => setSelectedTemplateId(template.id)}
onOpenChange={setIsCreateTemplateOpen}
open={isCreateTemplateOpen}
@@ -291,9 +292,6 @@ export function CreateSectionDialog({
onConfirm,
}: CreateSectionDialogProps) {
const templatesQuery = useChannelTemplatesQuery();
const sectionTemplates = (templatesQuery.data ?? []).filter(
(template) => template.templateType === "section",
);
return (
<SectionNameDialog
@@ -302,7 +300,7 @@ export function CreateSectionDialog({
title="Create section"
description="Sections let you group related channels in the sidebar."
initialValue=""
templates={sectionTemplates}
templates={templatesQuery.data ?? []}
allowTemplateCreation
confirmLabel="Create"
isConfirmDisabled={(trimmed) => trimmed.length === 0}
@@ -329,9 +327,6 @@ export function RenameSectionDialog({
onConfirm,
}: RenameSectionDialogProps) {
const templatesQuery = useChannelTemplatesQuery();
const sectionTemplates = (templatesQuery.data ?? []).filter(
(template) => template.templateType === "section",
);
return (
<SectionNameDialog
@@ -342,7 +337,7 @@ export function RenameSectionDialog({
initialValue={sectionName}
initialIcon={sectionIcon}
initialTemplateId={sectionTemplateId}
templates={sectionTemplates}
templates={templatesQuery.data ?? []}
confirmLabel="Save"
isConfirmDisabled={(trimmed, icon, templateId) =>
trimmed.length === 0 ||

View File

@@ -23,6 +23,7 @@ import {
import { ChannelPermissionsSettings } from "@/features/channels/ui/ChannelPermissionsSettings";
import { ChannelTypeSettings } from "@/features/channels/ui/ChannelTypeSettings";
import type { CreateChannelFormState } from "@/features/sidebar/lib/useCreateChannelForm";
import { TemplateInfoTooltip } from "@/features/sidebar/ui/TemplateInfoTooltip";
const CREATE_LABEL_OPTIONAL_CLASS =
"ml-1 text-xs font-normal text-muted-foreground/50";
@@ -46,9 +47,6 @@ export function CreateChannelFormFields({
const selectedTemplate = form.templates.find(
(template) => template.id === form.selectedTemplateId,
);
const channelTemplates = form.templates.filter(
(template) => template.templateType === "channel",
);
return (
<div className="space-y-5">
@@ -140,8 +138,9 @@ export function CreateChannelFormFields({
)}
data-testid="create-channel-template-container"
>
<span className="text-sm font-medium text-foreground">
Section template
<span className="inline-flex items-center gap-1 text-sm font-medium text-foreground">
Template
<TemplateInfoTooltip context="channel" />
</span>
<span
className="-mr-2.5 flex h-9 min-w-0 max-w-[60%] items-center px-2.5 text-right text-sm font-medium text-foreground"
@@ -161,8 +160,9 @@ export function CreateChannelFormFields({
)}
data-testid="create-channel-template-container"
>
<span className="text-sm font-medium text-foreground">
<span className="inline-flex items-center gap-1 text-sm font-medium text-foreground">
Template
<TemplateInfoTooltip context="channel" />
<span className={CREATE_LABEL_OPTIONAL_CLASS}>Optional</span>
</span>
<DropdownMenu modal={false}>
@@ -200,7 +200,7 @@ export function CreateChannelFormFields({
<DropdownMenuRadioItem value={NO_TEMPLATE_VALUE}>
None
</DropdownMenuRadioItem>
{channelTemplates.map((template) => (
{form.templates.map((template) => (
<DropdownMenuRadioItem key={template.id} value={template.id}>
{template.name}
</DropdownMenuRadioItem>
@@ -214,7 +214,6 @@ export function CreateChannelFormFields({
</DropdownMenuContent>
</DropdownMenu>
<TemplateFormDialog
fixedTemplateType="channel"
onCreated={form.handleTemplateCreated}
onOpenChange={setIsCreateTemplateOpen}
open={isCreateTemplateOpen}

View File

@@ -128,6 +128,7 @@ export function SectionActionsMenu({
browseLabel,
onCreate,
createLabel,
onCreateSection,
onNewMessage,
newMessageLabel,
onRenameSection,
@@ -149,6 +150,7 @@ export function SectionActionsMenu({
browseLabel?: string;
onCreate?: () => void;
createLabel?: string;
onCreateSection?: () => void;
onNewMessage?: () => void;
newMessageLabel?: string;
onRenameSection?: () => void;
@@ -213,6 +215,12 @@ export function SectionActionsMenu({
<span>{createLabel ?? "Create channel"}</span>
</DropdownMenuItem>
) : null}
{onCreateSection ? (
<DropdownMenuItem onSelect={() => deferMenuAction(onCreateSection)}>
<Plus className="h-4 w-4" />
<span>Create section</span>
</DropdownMenuItem>
) : null}
{showSectionManagement ? (
<>
{onRenameSection ? (
@@ -365,6 +373,7 @@ export function ChannelGroupSection({
onAssignChannel,
onUnassignChannel,
onCreateSectionForChannel,
onCreateSection,
mutedChannelIds,
onMuteChannel,
onUnmuteChannel,
@@ -415,6 +424,7 @@ export function ChannelGroupSection({
onAssignChannel?: (channelId: string, sectionId: string) => void;
onUnassignChannel?: (channelId: string) => void;
onCreateSectionForChannel?: (channelId: string) => void;
onCreateSection?: () => void;
mutedChannelIds?: ReadonlySet<string>;
onMuteChannel?: (channelId: string) => void;
onUnmuteChannel?: (channelId: string) => void;
@@ -521,6 +531,7 @@ export function ChannelGroupSection({
browseLabel={browseLabel}
onCreate={onCreateClick}
createLabel={createLabel}
onCreateSection={onCreateSection}
sortMode={sortMode}
onSortModeChange={onSortModeChange}
/>

View File

@@ -0,0 +1,34 @@
import { Info } from "lucide-react";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
const TEMPLATE_TOOLTIP_COPY = {
channel:
"A template is a reusable channel configuration. Choose one to apply its settings to this channel.",
section:
"A template is a reusable channel configuration. New channels in this section automatically use the selected template.",
} as const;
export function TemplateInfoTooltip({
context,
}: {
context: keyof typeof TEMPLATE_TOOLTIP_COPY;
}) {
return (
<Tooltip>
<TooltipTrigger asChild>
<button
aria-label={`About ${context} templates`}
className="inline-flex size-6 shrink-0 items-center justify-center rounded-full text-muted-foreground/70 transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
data-testid={`${context}-template-info`}
type="button"
>
<Info aria-hidden className="size-4" />
</button>
</TooltipTrigger>
<TooltipContent className="max-w-72 text-left" side="top">
<p>{TEMPLATE_TOOLTIP_COPY[context]}</p>
</TooltipContent>
</Tooltip>
);
}

View File

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

View File

@@ -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<RawChannelTemplate>("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,

View File

@@ -832,7 +832,6 @@ export type {
TemplateAgents,
TemplateBackend,
TemplateTeamEntry,
TemplateType,
TemplateWorktreeConfig,
UpdateChannelTemplateInput,
} from "./channelTemplateTypes";

View File

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

View File

@@ -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 ({

View File

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

View File

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

View File

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