From 60362872fd9a73e2306ce9188e4faae7fc9350e6 Mon Sep 17 00:00:00 2001
From: kenny lopez
Date: Mon, 10 Aug 2026 16:21:04 +0100
Subject: [PATCH] Simplify template creation and section defaults
Signed-off-by: kenny lopez
---
.../src/commands/channel_templates.rs | 7 +-
desktop/src-tauri/src/templates/storage.rs | 10 +-
desktop/src-tauri/src/templates/types.rs | 12 --
desktop/src/features/agents/ui/TeamDialog.tsx | 4 +-
.../src/features/agents/ui/useTeamActions.ts | 2 +
.../messages/lib/mentionCandidates.test.mjs | 29 +++
.../messages/lib/mentionCandidates.ts | 55 ++++--
.../ui/ChannelTemplatesSettingsCard.tsx | 135 +++-----------
.../settings/ui/TemplateStepIndicator.tsx | 26 +++
.../features/settings/ui/TemplateTypeStep.tsx | 129 -------------
.../sidebar/lib/useCreateChannelForm.ts | 1 -
.../src/features/sidebar/ui/AppSidebar.tsx | 4 +-
.../sidebar/ui/ChannelSectionDialogs.tsx | 19 +-
.../sidebar/ui/CreateChannelFormFields.tsx | 15 +-
.../sidebar/ui/CustomChannelSection.tsx | 11 ++
.../sidebar/ui/TemplateInfoTooltip.tsx | 34 ++++
.../src/shared/api/channelTemplateTypes.ts | 4 -
.../src/shared/api/tauriChannelTemplates.ts | 4 -
desktop/src/shared/api/types.ts | 1 -
desktop/src/testing/e2eBridge.ts | 15 +-
desktop/tests/e2e/agents.spec.ts | 46 +++++
desktop/tests/e2e/channel-browser.spec.ts | 45 +++--
desktop/tests/e2e/channels.spec.ts | 169 ++++--------------
desktop/tests/e2e/profile.spec.ts | 2 +-
24 files changed, 315 insertions(+), 464 deletions(-)
create mode 100644 desktop/src/features/settings/ui/TemplateStepIndicator.tsx
delete mode 100644 desktop/src/features/settings/ui/TemplateTypeStep.tsx
create mode 100644 desktop/src/features/sidebar/ui/TemplateInfoTooltip.tsx
diff --git a/desktop/src-tauri/src/commands/channel_templates.rs b/desktop/src-tauri/src/commands/channel_templates.rs
index c261bfa5af..2f00fbad42 100644
--- a/desktop/src-tauri/src/commands/channel_templates.rs
+++ b/desktop/src-tauri/src/commands/channel_templates.rs
@@ -5,7 +5,7 @@ use crate::{
app_state::AppState,
templates::{
load_channel_templates, save_channel_templates, validate_channel_template_deletion,
- ChannelTemplateRecord, CreateChannelTemplateRequest, TemplateType, TemplateWorktreeConfig,
+ ChannelTemplateRecord, CreateChannelTemplateRequest, TemplateWorktreeConfig,
UpdateChannelTemplateRequest,
},
util::now_iso,
@@ -115,7 +115,6 @@ pub async fn create_channel_template(
) -> Result {
tokio::task::spawn_blocking(move || {
let name = trim_required(&input.name, "Template name")?;
- let template_type = input.template_type.unwrap_or(TemplateType::Channel);
let description = trim_optional(input.description);
let canvas_template = trim_optional(input.canvas_template);
let project_folders =
@@ -138,7 +137,6 @@ pub async fn create_channel_template(
let template = ChannelTemplateRecord {
id: Uuid::new_v4().to_string(),
name,
- template_type,
description,
channel_type,
visibility,
@@ -167,7 +165,6 @@ pub async fn update_channel_template(
) -> Result {
tokio::task::spawn_blocking(move || {
let name = trim_required(&input.name, "Template name")?;
- let requested_template_type = input.template_type;
let description = trim_optional(input.description);
let canvas_template = trim_optional(input.canvas_template);
let project_folders =
@@ -189,10 +186,8 @@ pub async fn update_channel_template(
.iter_mut()
.find(|record| record.id == input.id)
.ok_or_else(|| format!("template {} not found", input.id))?;
- let template_type = requested_template_type.unwrap_or(template.template_type);
template.name = name;
- template.template_type = template_type;
template.description = description;
template.channel_type = channel_type;
template.visibility = visibility;
diff --git a/desktop/src-tauri/src/templates/storage.rs b/desktop/src-tauri/src/templates/storage.rs
index e0a2715b25..407e78245a 100644
--- a/desktop/src-tauri/src/templates/storage.rs
+++ b/desktop/src-tauri/src/templates/storage.rs
@@ -81,14 +81,12 @@ mod tests {
use super::sort_channel_templates;
use crate::templates::{
validate_channel_template_deletion, ChannelTemplateRecord, TemplateAgentRoster,
- TemplateType,
};
fn template(id: &str, name: &str) -> ChannelTemplateRecord {
ChannelTemplateRecord {
id: id.to_string(),
name: name.to_string(),
- template_type: TemplateType::Channel,
description: None,
channel_type: "stream".to_string(),
visibility: "open".to_string(),
@@ -155,7 +153,6 @@ mod tests {
let original = ChannelTemplateRecord {
id: "t1".to_string(),
name: "Sprint Planning".to_string(),
- template_type: TemplateType::Section,
description: Some("Template for sprint channels".to_string()),
channel_type: "stream".to_string(),
visibility: "private".to_string(),
@@ -195,7 +192,6 @@ mod tests {
let parsed: ChannelTemplateRecord = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.id, original.id);
- assert_eq!(parsed.template_type, TemplateType::Section);
assert_eq!(parsed.name, original.name);
assert_eq!(parsed.description, original.description);
assert_eq!(parsed.channel_type, original.channel_type);
@@ -226,10 +222,11 @@ mod tests {
}
#[test]
- fn legacy_template_defaults_to_channel_type() {
+ fn legacy_template_type_is_ignored() {
let json = serde_json::json!({
"id": "legacy",
"name": "Legacy template",
+ "template_type": "section",
"channel_type": "stream",
"visibility": "open",
"agents": {},
@@ -239,7 +236,8 @@ mod tests {
});
let parsed: ChannelTemplateRecord = serde_json::from_value(json).unwrap();
- assert_eq!(parsed.template_type, TemplateType::Channel);
+ let serialized = serde_json::to_value(parsed).unwrap();
+ assert!(serialized.get("template_type").is_none());
}
#[test]
diff --git a/desktop/src-tauri/src/templates/types.rs b/desktop/src-tauri/src/templates/types.rs
index cb5f1d283b..af4c233a1f 100644
--- a/desktop/src-tauri/src/templates/types.rs
+++ b/desktop/src-tauri/src/templates/types.rs
@@ -4,8 +4,6 @@ use serde::{Deserialize, Serialize};
pub struct ChannelTemplateRecord {
pub id: String,
pub name: String,
- #[serde(default)]
- pub template_type: TemplateType,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option,
#[serde(default = "default_channel_type")]
@@ -29,14 +27,6 @@ pub struct ChannelTemplateRecord {
pub updated_at: String,
}
-#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
-#[serde(rename_all = "snake_case")]
-pub enum TemplateType {
- #[default]
- Channel,
- Section,
-}
-
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TemplateAgentRoster {
@@ -98,7 +88,6 @@ fn default_visibility() -> String {
#[serde(rename_all = "camelCase")]
pub struct CreateChannelTemplateRequest {
pub name: String,
- pub template_type: Option,
pub description: Option,
pub channel_type: Option,
pub visibility: Option,
@@ -116,7 +105,6 @@ pub struct CreateChannelTemplateRequest {
pub struct UpdateChannelTemplateRequest {
pub id: String,
pub name: String,
- pub template_type: Option,
pub description: Option,
pub channel_type: Option,
pub visibility: Option,
diff --git a/desktop/src/features/agents/ui/TeamDialog.tsx b/desktop/src/features/agents/ui/TeamDialog.tsx
index 4958b3a61c..3b9d07a346 100644
--- a/desktop/src/features/agents/ui/TeamDialog.tsx
+++ b/desktop/src/features/agents/ui/TeamDialog.tsx
@@ -133,6 +133,8 @@ export function TeamDialog({
function buildSubmitInput(): CreateTeamInput | UpdateTeamInput {
const baseInput = {
name,
+ description: teamDescription.trim() || undefined,
+ instructions: instructions.trim() || undefined,
personaIds: filterAvailablePersonaIds(selectedPersonaIds, personas),
};
@@ -140,8 +142,6 @@ export function TeamDialog({
return {
id: initialValues.id,
...baseInput,
- description: teamDescription.trim() || undefined,
- instructions: instructions.trim() || undefined,
};
}
return baseInput;
diff --git a/desktop/src/features/agents/ui/useTeamActions.ts b/desktop/src/features/agents/ui/useTeamActions.ts
index 986ceda23c..45f7b0174b 100644
--- a/desktop/src/features/agents/ui/useTeamActions.ts
+++ b/desktop/src/features/agents/ui/useTeamActions.ts
@@ -186,6 +186,8 @@ export function useTeamActions(
submitLabel: "Create team",
initialValues: {
name: `${team.name} copy`,
+ description: team.description ?? undefined,
+ instructions: team.instructions ?? undefined,
personaIds: [...team.personaIds],
},
});
diff --git a/desktop/src/features/messages/lib/mentionCandidates.test.mjs b/desktop/src/features/messages/lib/mentionCandidates.test.mjs
index 4830f25b77..44edd49137 100644
--- a/desktop/src/features/messages/lib/mentionCandidates.test.mjs
+++ b/desktop/src/features/messages/lib/mentionCandidates.test.mjs
@@ -212,3 +212,32 @@ test("teams with identity and persona display-name collisions are not suggested"
[],
);
});
+
+test("teams with duplicate names are not suggested", () => {
+ const builder = persona("builder", "Builder");
+
+ assert.deepEqual(
+ buildTeamMentionCandidates(
+ [
+ team("launch-one", ["builder"]),
+ team("launch-two", ["builder"], { name: " launch team " }),
+ ],
+ [builder],
+ [],
+ ),
+ [],
+ );
+});
+
+test("teams sharing a display name with an individual are not suggested", () => {
+ const builder = persona("builder", "Builder");
+
+ assert.deepEqual(
+ buildTeamMentionCandidates(
+ [team("launch", ["builder"])],
+ [builder],
+ [identity("individual", "Launch Team", { pubkey: "1".repeat(64) })],
+ ),
+ [],
+ );
+});
diff --git a/desktop/src/features/messages/lib/mentionCandidates.ts b/desktop/src/features/messages/lib/mentionCandidates.ts
index 665e5c4c86..784e1f3c9c 100644
--- a/desktop/src/features/messages/lib/mentionCandidates.ts
+++ b/desktop/src/features/messages/lib/mentionCandidates.ts
@@ -98,8 +98,18 @@ export function buildTeamMentionCandidates(
personas: AgentPersona[],
candidates: readonly MentionCandidate[],
): MentionCandidate[] {
- return teams.flatMap((team) => {
- if (team.isBuiltin || !team.name.trim()) return [];
+ const individualNames = new Set(
+ candidates.flatMap((candidate) => {
+ if (candidate.kind === "team") return [];
+ const name = candidate.displayName?.trim().toLowerCase();
+ return name ? [name] : [];
+ }),
+ );
+
+ const eligibleTeams = teams.flatMap((team) => {
+ const displayName = team.name.trim();
+ const normalizedName = displayName.toLowerCase();
+ if (team.isBuiltin || !displayName) return [];
const resolution = resolveTeamPersonas(team, personas);
if (!resolution.isUsable) return [];
@@ -116,17 +126,38 @@ export function buildTeamMentionCandidates(
mentionNames.add(mentionName);
}
- return [
- {
- kind: "team" as const,
- teamId: team.id,
- teamMembers,
- displayName: team.name.trim(),
- isMember: false,
- isAgent: true,
- },
- ];
+ return [{ displayName, normalizedName, team, teamMembers }];
});
+
+ const teamNameCounts = new Map();
+ for (const team of eligibleTeams) {
+ teamNameCounts.set(
+ team.normalizedName,
+ (teamNameCounts.get(team.normalizedName) ?? 0) + 1,
+ );
+ }
+
+ return eligibleTeams.flatMap(
+ ({ displayName, normalizedName, team, teamMembers }) => {
+ if (
+ teamNameCounts.get(normalizedName) !== 1 ||
+ individualNames.has(normalizedName)
+ ) {
+ return [];
+ }
+
+ return [
+ {
+ kind: "team" as const,
+ teamId: team.id,
+ teamMembers,
+ displayName,
+ isMember: false,
+ isAgent: true,
+ },
+ ];
+ },
+ );
}
export function formatTeamMention(
diff --git a/desktop/src/features/settings/ui/ChannelTemplatesSettingsCard.tsx b/desktop/src/features/settings/ui/ChannelTemplatesSettingsCard.tsx
index a4efc02aea..1058aaac64 100644
--- a/desktop/src/features/settings/ui/ChannelTemplatesSettingsCard.tsx
+++ b/desktop/src/features/settings/ui/ChannelTemplatesSettingsCard.tsx
@@ -31,7 +31,6 @@ import {
import type {
ChannelTemplate,
CreateChannelTemplateInput,
- TemplateType,
UpdateChannelTemplateInput,
} from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
@@ -67,11 +66,11 @@ import {
DEFAULT_WORKTREE_BASE_BRANCH,
DEFAULT_WORKTREE_LOCATION,
} from "./ChannelTemplateWorkspaceFields";
-import { TemplateStepIndicator, TemplateTypeStep } from "./TemplateTypeStep";
+import { TemplateStepIndicator } from "./TemplateStepIndicator";
import { ChannelTemplateSavedIdentities } from "./ChannelTemplateSavedIdentities";
const TEMPLATE_FORM_ID = "template-form";
-type TemplateFormPage = "type" | "details" | "agents";
+type TemplateFormPage = "details" | "agents";
export function ChannelTemplatesSettingsCard() {
const templatesQuery = useChannelTemplatesQuery();
@@ -85,12 +84,6 @@ export function ChannelTemplatesSettingsCard() {
React.useState(null);
const templates = templatesQuery.data ?? [];
- const channelTemplates = templates.filter(
- (template) => template.templateType === "channel",
- );
- const sectionTemplates = templates.filter(
- (template) => template.templateType === "section",
- );
function handleDuplicate(template: ChannelTemplate) {
duplicateMutation.mutate(template.id, {
@@ -124,9 +117,7 @@ export function ChannelTemplatesSettingsCard() {
Save reusable defaults for new channels and channel sections.>
- }
+ description={<>Save reusable configurations for new channels.>}
action={
}
/>
@@ -145,26 +136,13 @@ export function ChannelTemplatesSettingsCard() {
Loading templates...
) : (
-
-
-
-
+
)}
void;
@@ -230,8 +204,7 @@ function TemplateList({
onDelete: (template: ChannelTemplate) => void;
}) {
return (
-
-
{title}
+
{templates.length === 0 ? (
{emptyCopy}
@@ -396,15 +369,11 @@ export function TemplateFormDialog({
open,
onOpenChange,
onCreated,
- initialTemplateType = "channel",
- fixedTemplateType,
}: {
template: ChannelTemplate | null;
open: boolean;
onOpenChange: (open: boolean) => void;
onCreated?: (template: ChannelTemplate) => void;
- initialTemplateType?: TemplateType;
- fixedTemplateType?: TemplateType;
}) {
const isEditing = template !== null;
const createMutation = useCreateChannelTemplateMutation();
@@ -412,14 +381,9 @@ export function TemplateFormDialog({
const personasQuery = usePersonasQuery();
const teamsQuery = useTeamsQuery();
const shouldReduceMotion = useReducedMotion() ?? false;
- const dialogContentRef = React.useRef
(null);
- const [page, setPage] = React.useState(
- template || fixedTemplateType ? "details" : "type",
- );
+ const [page, setPage] = React.useState("details");
const [pageDirection, setPageDirection] = React.useState<1 | -1>(1);
- const [templateType, setTemplateType] =
- React.useState("channel");
const [name, setName] = React.useState("");
const [canvasTemplate, setCanvasTemplate] = React.useState("");
const [isCanvasOpen, setIsCanvasOpen] = React.useState(false);
@@ -443,7 +407,6 @@ export function TemplateFormDialog({
>({});
const isPending = createMutation.isPending || updateMutation.isPending;
- const isTypePage = page === "type";
const isAgentPage = page === "agents";
const detailsValid =
name.trim().length > 0 &&
@@ -452,11 +415,10 @@ export function TemplateFormDialog({
React.useEffect(() => {
if (!open) return;
- setPage(template || fixedTemplateType ? "details" : "type");
+ setPage("details");
setPageDirection(1);
setIsCanvasOpen(false);
if (template) {
- setTemplateType(template.templateType);
setName(template.name);
setCanvasTemplate(template.canvasTemplate ?? "");
setProjectFolders(template.projectFolders);
@@ -478,7 +440,6 @@ export function TemplateFormDialog({
}
setTeamRuntimes(tRuntimes);
} else {
- setTemplateType(fixedTemplateType ?? initialTemplateType);
setName("");
setCanvasTemplate("");
setProjectFolders([]);
@@ -490,30 +451,20 @@ export function TemplateFormDialog({
setPersonaRuntimes({});
setTeamRuntimes({});
}
- }, [fixedTemplateType, initialTemplateType, open, template]);
+ }, [open, template]);
function showAgentPage() {
setPageDirection(1);
setPage("agents");
}
- function showTypePage() {
- if (fixedTemplateType) return;
- setPageDirection(-1);
- setPage("type");
- }
-
function showDetailsPage() {
- setPageDirection(page === "type" ? 1 : -1);
+ setPageDirection(-1);
setPage("details");
}
function handleSubmit(event: React.FormEvent) {
event.preventDefault();
- if (!isEditing && page === "type") {
- showDetailsPage();
- return;
- }
if (!isEditing && page === "details") {
if (detailsValid) showAgentPage();
return;
@@ -545,7 +496,6 @@ export function TemplateFormDialog({
const input: UpdateChannelTemplateInput = {
id: template.id,
name: trimmedName,
- templateType: template.templateType,
description: template.description ?? undefined,
canvasTemplate: canvasTemplate.trim() || undefined,
projectFolders,
@@ -572,7 +522,6 @@ export function TemplateFormDialog({
} else {
const input: CreateChannelTemplateInput = {
name: trimmedName,
- templateType,
canvasTemplate: canvasTemplate.trim() || undefined,
projectFolders,
worktree: worktreeEnabled
@@ -648,29 +597,15 @@ export function TemplateFormDialog({
const dialogHeight =
isEditing || isAgentPage || isCanvasOpen || worktreeEnabled
? "min(42rem, 85vh)"
- : isTypePage
- ? "min(27rem, 85vh)"
- : "min(31rem, 85vh)";
+ : "min(31rem, 85vh)";
return (
{templates ? (
-
+
Template
+
Optional
@@ -193,7 +195,7 @@ function SectionNameDialog({