mirror of
https://github.com/block/buzz.git
synced 2026-10-31 22:41:12 +00:00
Simplify template creation and section defaults
Signed-off-by: kenny lopez <klopez4212@gmail.com>
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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) })],
|
||||
),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
|
||||
26
desktop/src/features/settings/ui/TemplateStepIndicator.tsx
Normal file
26
desktop/src/features/settings/ui/TemplateStepIndicator.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -151,7 +151,6 @@ export function useCreateChannelForm({
|
||||
|
||||
const handleTemplateCreated = React.useCallback(
|
||||
(template: ChannelTemplate) => {
|
||||
if (template.templateType !== "channel") return;
|
||||
applyTemplate(template);
|
||||
},
|
||||
[applyTemplate],
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
34
desktop/src/features/sidebar/ui/TemplateInfoTooltip.tsx
Normal file
34
desktop/src/features/sidebar/ui/TemplateInfoTooltip.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -832,7 +832,6 @@ export type {
|
||||
TemplateAgents,
|
||||
TemplateBackend,
|
||||
TemplateTeamEntry,
|
||||
TemplateType,
|
||||
TemplateWorktreeConfig,
|
||||
UpdateChannelTemplateInput,
|
||||
} from "./channelTemplateTypes";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 ({
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user