feat(agents): add category filter pills to template picker

25 templates across 7 categories made the picker scroll-heavy on first
open. Add a single-select category filter row above the grid so a PM
can isolate Product templates in one click, an engineer can jump
straight to Engineering, etc.

Visual reuses the IssuesHeader scope-toggle pattern verbatim — Button
variant="outline" + active class swap (bg-accent / text-muted-foreground)
— so the affordance reads the same as the existing filter pills in
issues / squads / runtimes / my-issues. flex-wrap keeps the 8 pills
(All + 7 categories) honest on narrow widths.

Counts are inlined into the label ("Engineering (8)") rather than
shown as a separate badge — single-line-tall pills look right next to
the picker grid, and surfacing the per-category density up front
doubles as a hint at the catalog's "less but sharper" intent.

When a specific category is active, the grid renders flat (no
section headers) — the active pill already names what's on screen,
and a header reading "Engineering" above an only-Engineering grid is
visual duplication. "All" falls back to the prior grouped layout.

State is component-local (no URL sync, no persistence) since the
picker is dialog-internal transient state — closing the dialog
naturally resets the filter, which is the expected behaviour for a
"choose from a catalog" surface.

i18n: new `create_dialog.template_picker.filter_all` key in en + zh.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Naiyuan Qing
2026-05-14 14:01:14 +08:00
parent fb0246acdb
commit ce0ec401dc
3 changed files with 109 additions and 20 deletions

View File

@@ -1,6 +1,6 @@
"use client";
import { useMemo } from "react";
import { useMemo, useState } from "react";
import {
AlertTriangle,
AlignLeft,
@@ -38,6 +38,7 @@ 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";
@@ -68,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<string | null>(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
@@ -83,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 (
<div className="flex flex-1 items-center justify-center">
@@ -113,28 +131,97 @@ export function TemplatePicker({ onSelect }: TemplatePickerProps) {
return (
<div className="flex-1 overflow-y-auto">
<div className="mx-auto max-w-5xl space-y-6 p-6">
{groups.map(([category, tmpls]) => (
<section key={category}>
<h2 className="sticky top-0 z-10 -mx-6 border-b bg-background px-6 py-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{category}
</h2>
<div className="grid grid-cols-1 gap-3 pt-3 md:grid-cols-2">
{tmpls.map((tmpl) => (
<TemplateCard
key={tmpl.slug}
template={tmpl}
onClick={() => onSelect(tmpl)}
/>
))}
</div>
</section>
))}
<div className="mx-auto max-w-5xl space-y-4 p-6">
{/* 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. */}
<div className="flex flex-wrap items-center gap-1">
<FilterPill
label={`${t(($) => $.create_dialog.template_picker.filter_all)} (${templates.length})`}
active={selectedCategory === null}
onClick={() => setSelectedCategory(null)}
/>
{groups.map(([category, tmpls]) => (
<FilterPill
key={category}
label={`${category} (${tmpls.length})`}
active={selectedCategory === category}
onClick={() => setSelectedCategory(category)}
/>
))}
</div>
{/* 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 ? (
<div className="space-y-6">
{groups.map(([category, tmpls]) => (
<section key={category}>
<h2 className="sticky top-0 z-10 -mx-6 border-b bg-background px-6 py-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{category}
</h2>
<div className="grid grid-cols-1 gap-3 pt-3 md:grid-cols-2">
{tmpls.map((tmpl) => (
<TemplateCard
key={tmpl.slug}
template={tmpl}
onClick={() => onSelect(tmpl)}
/>
))}
</div>
</section>
))}
</div>
) : (
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
{visibleTemplates.map((tmpl) => (
<TemplateCard
key={tmpl.slug}
template={tmpl}
onClick={() => onSelect(tmpl)}
/>
))}
</div>
)}
</div>
</div>
);
}
/** 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 (
<Button
type="button"
variant="outline"
size="sm"
className={cn(
"h-7 text-xs",
active
? "bg-accent text-accent-foreground hover:bg-accent/80"
: "text-muted-foreground",
)}
onClick={onClick}
>
{label}
</Button>
);
}
interface TemplateCardProps {
template: AgentTemplateSummary;
onClick: () => void;

View File

@@ -234,7 +234,8 @@
"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",

View File

@@ -229,7 +229,8 @@
"title": "选择模板",
"empty": "暂无可用模板。",
"load_failed": "加载模板失败",
"other_category": "其他"
"other_category": "其他",
"filter_all": "全部"
},
"template_card": {
"skills_other": "{{count}} 个 skill",