diff --git a/packages/views/agents/components/template-detail.tsx b/packages/views/agents/components/template-detail.tsx index dc64287fc..3abb0f992 100644 --- a/packages/views/agents/components/template-detail.tsx +++ b/packages/views/agents/components/template-detail.tsx @@ -145,32 +145,37 @@ export function TemplateDetail({ /> - {/* Skill list — always visible (summary has cached descriptions) */} -
-

- {t(($) => $.create_dialog.template_detail.skill_count, { - count: template.skills.length, - })} -

- -
+ {/* Skill list — hidden entirely for prompt-only templates. Most of + the catalog is 0-skill; a section reading "Includes 0 skills" + with an empty list would just be visual noise. The Instructions + section below carries the agent's identity for these. */} + {template.skills.length > 0 ? ( +
+

+ {t(($) => $.create_dialog.template_detail.skill_count, { + count: template.skills.length, + })} +

+ +
+ ) : null} {/* Instructions — lazy fetch + loading/error states */}
diff --git a/packages/views/agents/components/template-picker.tsx b/packages/views/agents/components/template-picker.tsx index e56faa571..b94c4ecf0 100644 --- a/packages/views/agents/components/template-picker.tsx +++ b/packages/views/agents/components/template-picker.tsx @@ -1,25 +1,44 @@ "use client"; -import { useMemo } from "react"; +import { useMemo, useState } from "react"; import { + AlertTriangle, + AlignLeft, + BookOpen, Brush, + Briefcase, + Bug, ChevronRight, + ClipboardList, FileText, FlaskConical, + GitCommit, + GitPullRequest, + GraduationCap, + Highlighter, + Languages, LayoutDashboard, + Lightbulb, ListChecks, Loader2, Megaphone, + MessageSquare, + Microscope, Palette, PenLine, Presentation, + Scale, Search, Sparkles, + Target, + Type, + UserRound, } from "lucide-react"; import type { LucideIcon } from "lucide-react"; import { useQuery } from "@tanstack/react-query"; import { agentTemplateListOptions } from "@multica/core/agents/queries"; import type { AgentTemplateSummary } from "@multica/core/types"; +import { Button } from "@multica/ui/components/ui/button"; import { cn } from "@multica/ui/lib/utils"; import { useT } from "../../i18n"; @@ -50,6 +69,11 @@ export function TemplatePicker({ onSelect }: TemplatePickerProps) { agentTemplateListOptions(), ); + // `null` = "All" (default). When a specific category is selected, the + // grid renders flat (no section headers) — the active pill already + // tells the user what they're looking at, so headers would be noise. + const [selectedCategory, setSelectedCategory] = useState(null); + // Group by category. Templates without a category fall into the // localised "Other" bucket so they still render. Preserves the load // order within each group for deterministic UI (matches the @@ -65,6 +89,18 @@ export function TemplatePicker({ onSelect }: TemplatePickerProps) { return Array.from(byCategory.entries()); }, [templates, otherCategory]); + // Templates currently visible given the filter. When "All" is active + // we show every template (grouped by category below); otherwise we + // only show the matching category. + const visibleTemplates = useMemo(() => { + if (selectedCategory === null) return templates; + return templates.filter( + (tmpl) => + (tmpl.category?.trim() ? tmpl.category : otherCategory) === + selectedCategory, + ); + }, [templates, selectedCategory, otherCategory]); + if (isLoading) { return (
@@ -95,28 +131,97 @@ export function TemplatePicker({ onSelect }: TemplatePickerProps) { return (
-
- {groups.map(([category, tmpls]) => ( -
-

- {category} -

-
- {tmpls.map((tmpl) => ( - onSelect(tmpl)} - /> - ))} -
-
- ))} +
+ {/* Category filter — mirrors the IssuesHeader scope pattern + (Button variant="outline" + active-class swap). `flex-wrap` + so the 8 pills (All + 7 categories) degrade gracefully on + narrow widths. Counts are inlined into the label rather than + shown as a separate badge because we want the pill row to + stay one-line-tall per pill. */} +
+ $.create_dialog.template_picker.filter_all)} (${templates.length})`} + active={selectedCategory === null} + onClick={() => setSelectedCategory(null)} + /> + {groups.map(([category, tmpls]) => ( + setSelectedCategory(category)} + /> + ))} +
+ + {/* Grid — grouped with sticky headers when "All" is active; + flat when a single category is filtered (the active pill + already tells the user what they're looking at). */} + {selectedCategory === null ? ( +
+ {groups.map(([category, tmpls]) => ( +
+

+ {category} +

+
+ {tmpls.map((tmpl) => ( + onSelect(tmpl)} + /> + ))} +
+
+ ))} +
+ ) : ( +
+ {visibleTemplates.map((tmpl) => ( + onSelect(tmpl)} + /> + ))} +
+ )}
); } +/** Single filter pill. Visual matches IssuesHeader's scope toggle + * (Button outline + bg-accent on active) so the catalog feels + * consistent with the rest of the app's filter affordances. */ +function FilterPill({ + label, + active, + onClick, +}: { + label: string; + active: boolean; + onClick: () => void; +}) { + return ( + + ); +} + interface TemplateCardProps { template: AgentTemplateSummary; onClick: () => void; @@ -150,9 +255,11 @@ function TemplateCard({ template, onClick }: TemplateCardProps) { {template.description}

- {t(($) => $.create_dialog.template_card.skills, { - count: template.skills.length, - })} + {template.skills.length === 0 + ? t(($) => $.create_dialog.template_card.prompt_only) + : t(($) => $.create_dialog.template_card.skills, { + count: template.skills.length, + })}
@@ -164,17 +271,35 @@ function TemplateCard({ template, onClick }: TemplateCardProps) { /** Lucide icon name → component. Add new entries when shipping templates * that use icons not yet listed here. Unknown names fall back to FileText. */ const ICONS: Record = { - Search, - Palette, + AlertTriangle, + AlignLeft, + BookOpen, + Briefcase, + Brush, + Bug, + ClipboardList, FileText, FlaskConical, - Sparkles, - ListChecks, - Brush, - PenLine, - Megaphone, - Presentation, + GitCommit, + GitPullRequest, + GraduationCap, + Highlighter, + Languages, LayoutDashboard, + Lightbulb, + ListChecks, + Megaphone, + MessageSquare, + Microscope, + Palette, + PenLine, + Presentation, + Scale, + Search, + Sparkles, + Target, + Type, + UserRound, }; /** Semantic accent → Tailwind class string. The class strings are written diff --git a/packages/views/locales/en/agents.json b/packages/views/locales/en/agents.json index 308a4f96c..a6cde0c92 100644 --- a/packages/views/locales/en/agents.json +++ b/packages/views/locales/en/agents.json @@ -235,11 +235,13 @@ "title": "Pick a template", "empty": "No templates available yet.", "load_failed": "Failed to load templates", - "other_category": "Other" + "other_category": "Other", + "filter_all": "All" }, "template_card": { "skills_one": "{{count}} skill", - "skills_other": "{{count}} skills" + "skills_other": "{{count}} skills", + "prompt_only": "Prompt only" }, "template_detail": { "load_failed": "Failed to load template", diff --git a/packages/views/locales/zh-Hans/agents.json b/packages/views/locales/zh-Hans/agents.json index c7bb3807b..2b8404c20 100644 --- a/packages/views/locales/zh-Hans/agents.json +++ b/packages/views/locales/zh-Hans/agents.json @@ -230,10 +230,12 @@ "title": "选择模板", "empty": "暂无可用模板。", "load_failed": "加载模板失败", - "other_category": "其他" + "other_category": "其他", + "filter_all": "全部" }, "template_card": { - "skills_other": "{{count}} 个 skill" + "skills_other": "{{count}} 个 skill", + "prompt_only": "纯指令" }, "template_detail": { "load_failed": "加载模板失败", diff --git a/server/internal/agenttmpl/loader.go b/server/internal/agenttmpl/loader.go index 0efa05029..dc19ab34d 100644 --- a/server/internal/agenttmpl/loader.go +++ b/server/internal/agenttmpl/loader.go @@ -100,9 +100,10 @@ func validate(t Template, filename string) error { if strings.TrimSpace(t.Instructions) == "" { return fmt.Errorf("missing instructions") } - if len(t.Skills) == 0 { - return fmt.Errorf("must declare at least one skill") - } + // 0-skill templates are legitimate — most starter templates are + // prompt-only (instructions alone, no skill fan-out). See + // docs/agent-quick-create-plan.md and the picker UI's "Prompt only" + // rendering for zero-length skill arrays. for i, s := range t.Skills { if strings.TrimSpace(s.SourceURL) == "" { return fmt.Errorf("skill[%d]: missing source_url", i) diff --git a/server/internal/agenttmpl/loader_test.go b/server/internal/agenttmpl/loader_test.go index 68e6139a8..44b925863 100644 --- a/server/internal/agenttmpl/loader_test.go +++ b/server/internal/agenttmpl/loader_test.go @@ -92,11 +92,6 @@ func TestLoadFromFS_Invalid(t *testing.T) { content: `{"slug":"x","name":"X","skills":[{"source_url":"u"}]}`, wantErr: "missing instructions", }, - { - name: "no skills", - content: `{"slug":"x","name":"X","instructions":"do","skills":[]}`, - wantErr: "at least one skill", - }, { name: "skill missing url", content: `{"slug":"x","name":"X","instructions":"do","skills":[{}]}`, @@ -124,6 +119,32 @@ func TestLoadFromFS_Invalid(t *testing.T) { } } +func TestLoadFromFS_PromptOnlyTemplate(t *testing.T) { + // 0-skill templates are legitimate. Most starter templates ship prompt-only + // (no skill fan-out, just an instructions block). Regression guard so the + // "must declare at least one skill" rule doesn't sneak back in. + fsys := fstest.MapFS{ + "templates/prompt-only.json": &fstest.MapFile{Data: []byte(`{ + "slug":"prompt-only", + "name":"Prompt Only", + "description":"no skills here", + "instructions":"just write good prose", + "skills":[] + }`)}, + } + reg, err := loadFromFS(fsys, "templates") + if err != nil { + t.Fatalf("loadFromFS: %v", err) + } + tmpl, ok := reg.Get("prompt-only") + if !ok { + t.Fatal("Get(prompt-only) = false, want true") + } + if len(tmpl.Skills) != 0 { + t.Errorf("len(Skills) = %d, want 0", len(tmpl.Skills)) + } +} + func TestLoadFromFS_DuplicateSlug(t *testing.T) { // Two valid files declaring the same slug — caught by the registry, not // by validate(). Slugs are unique within the registry. diff --git a/server/internal/agenttmpl/templates/adr-writer.json b/server/internal/agenttmpl/templates/adr-writer.json new file mode 100644 index 000000000..2ed808d2c --- /dev/null +++ b/server/internal/agenttmpl/templates/adr-writer.json @@ -0,0 +1,10 @@ +{ + "slug": "adr-writer", + "name": "ADR Writer", + "description": "Captures an architecture decision in the standard Context / Decision / Consequences format — so future-you knows why.", + "category": "Engineering", + "icon": "Scale", + "accent": "info", + "instructions": "You write Architecture Decision Records. Reader: an engineer joining the team in a year who needs to understand why the system looks the way it does — not just what it does.\n\nFixed scaffold:\n\n```\n# ADR-NNN: \n\n## Status\nProposed | Accepted | Superseded by ADR-XXX | Deprecated\n\n## Context\n2-4 sentences. The forces in play: what the system is doing today, what need has emerged, what constraints we must respect, why now. Not the answer — the question.\n\n## Decision\n1-3 sentences. The decision in active voice. \"We will use X\" — not \"Adoption of X is recommended\". Be specific enough that a reader can implement it without a follow-up meeting.\n\n## Alternatives considered\n- **Option B** — one-sentence rejection reason (e.g. \"adds runtime overhead we can't afford\")\n- **Option C** — one-sentence rejection reason\n\n## Consequences\n- **Positive:** what gets easier, faster, or possible\n- **Negative:** what gets harder, slower, or impossible (be honest — burying these is how teams build up resentment)\n- **Neutral / open:** assumptions we're making, things we'll need to revisit\n```\n\nDefaults:\n\n1. **One decision per ADR.** If you find yourself writing \"we also decided to ...\", stop — that's a second ADR.\n2. **Name 1-3 alternatives, no more.** An ADR with no rejected options reads like there was no real choice. An ADR comparing 8 options reads like a survey.\n3. **Consequences are not all positive.** Include the things this decision makes harder. The reader needs to know what they're trading away.\n4. **Keep it short.** Most ADRs fit on one screen. If yours is two pages, you're explaining the system, not the decision.\n5. **Match the repo's existing ADR numbering and prose style** if the user shows you prior ADRs.\n\nDo NOT: include code samples (link the implementation PR instead); write generic platitudes (\"this is good for maintainability\"); skip the Status field; use marketing language (\"best-in-class\", \"future-proof\"); leave alternatives blank to avoid awkward conversation.", + "skills": [] +} diff --git a/server/internal/agenttmpl/templates/article-writer.json b/server/internal/agenttmpl/templates/article-writer.json deleted file mode 100644 index 8a35670f7..000000000 --- a/server/internal/agenttmpl/templates/article-writer.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "slug": "article-writer", - "name": "Article Writer", - "description": "Drafts longform articles, blog posts, and release notes that sound like a person, not a content farm.", - "category": "Writing", - "icon": "PenLine", - "accent": "success", - "instructions": "You write longform prose — blog posts, release notes, deep-dives, engineering write-ups. Defaults:\n\n1. **Find the angle before the outline.** What does the reader walk away knowing that they didn't before? If you can't name it in one sentence, you don't have a piece yet — push back on the brief.\n2. **Open with the thing that matters.** First paragraph either states the surprising result, the concrete problem, or the question you're answering. No \"in today's fast-paced world\", no throat-clearing, no \"I'm excited to share\".\n3. **Use the doc-coauthoring skill's structured workflow.** Draft → self-edit for clarity → tighten → check against tone. Each pass has a single goal; do not blur them.\n4. **Apply the brand-guidelines skill for voice and terminology.** Names, capitalization, product nouns must match. Don't invent synonyms because variety \"reads better\".\n5. **Concrete over abstract.** Every claim gets a number, an example, or a code snippet. Replace adjectives with evidence. \"Faster\" → \"3.2x faster on the 10k-row case\".\n6. **End with what to do next.** Not a CTA, a next step — what the reader does on Monday morning if they buy your argument.\n\nDo NOT: write marketing copy, listicle filler, or \"Top 10 X You Should Know About\". Avoid em-dash overuse. Avoid \"in conclusion\" and \"that being said\". One-sentence paragraphs are allowed when they earn their line.", - "skills": [ - { - "source_url": "https://github.com/anthropics/skills/tree/main/skills/doc-coauthoring", - "cached_name": "doc-coauthoring", - "cached_description": "Structured workflow for co-authoring longform structured content." - }, - { - "source_url": "https://github.com/anthropics/skills/tree/main/skills/brand-guidelines", - "cached_name": "brand-guidelines", - "cached_description": "Apply consistent brand voice, terminology, and style." - } - ] -} diff --git a/server/internal/agenttmpl/templates/brainstormer.json b/server/internal/agenttmpl/templates/brainstormer.json new file mode 100644 index 000000000..dac433dcf --- /dev/null +++ b/server/internal/agenttmpl/templates/brainstormer.json @@ -0,0 +1,10 @@ +{ + "slug": "brainstormer", + "name": "Brainstormer", + "description": "Generates 15-20 wide-ranging options — names, variants, ideas, angles — without prematurely converging.", + "category": "Writing", + "icon": "Lightbulb", + "accent": "primary", + "instructions": "You generate options. Lots of them. Your job is divergence — narrowing happens later (and not by you).\n\nDefaults:\n\n1. **Default to 15-20 options.** Quantity beats quality at the brainstorm stage. The best idea is rarely #1 or #2; it's #11 with #4's twist.\n2. **Span the design space deliberately.** Don't generate 20 variants of one direction. Group your output: literal / metaphorical / contrarian / formal / playful / niche / off-the-wall. Cover at least 4 of those groups.\n3. **Label each option with its angle.** One short tag in parens: `(literal)`, `(metaphor)`, `(contrarian)`, `(constraint inversion)`, etc. The user picks not just an idea but a direction.\n4. **Include one or two deliberately bad ones.** A clearly-too-far option (`(deliberately too far)`) widens the user's sense of the space. It also makes the rest land better.\n5. **No premature filtering.** Don't pre-justify why an option is or isn't the right one. The user judges. You produce.\n\nIntake one question before generating:\n- What's the brief? (One sentence: what you want options for.)\n- Any hard constraints? (Length, tone, audience, formats to avoid.)\n- What direction has already been tried and rejected? (So you don't repeat.)\n\nOutput shape:\n\n```\n**Brief**: \n\n1.