feat(desktop): catalog-style chooser for the create-agent wizard's first step

Restores the old Agent Catalog layout (PersonaCatalogDialog, removed in
24ff2034) for the New agent starting-point step, replacing the flat grid
of cards with a sidebar list + detail preview.

- CreateAgentStartDialog.tsx: rebuilt on ChooserDialogContent (h-168 /
  max-w-4xl, bg-sidebar header). The left sidebar keeps "Blank agent"
  and "Import from file" as immediate actions with their existing
  testids, above a Templates list; clicking a template now selects it
  for preview instead of advancing straight to the form. The right pane
  shows the selected template's avatar, name, the Type / Preferred
  model / Preferred runtime meta group, and the system prompt rendered
  as non-interactive Markdown, with a floating "Use template" confirm
  button (create-agent-start-use-template) over a gradient that hands
  the template to the details step. Selection defaults to the first
  template on open, list/detail skeletons cover loading, and the
  drag-and-drop import overlay + hidden file input carry over from the
  grid version. Props are unchanged so AgentsView needed no edits.
  Tailwind classes use the current v4 lint forms (h-168, bg-linear-to-t)
  instead of the old catalog's literals
- agents.spec.ts: the two template-selection tests click the template
  row and then the new confirm button; blank/import flows in the other
  suites are unchanged (all four affected suites pass)

Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
Taylor Ho
2026-07-02 23:08:06 -07:00
parent 4461c70074
commit b36f727996
2 changed files with 327 additions and 114 deletions
@@ -1,21 +1,40 @@
import { FileUp, Plus } from "lucide-react";
import * as React from "react";
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
import { useAgentTemplatesQuery } from "@/features/agents/hooks";
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
import type { AgentTemplate } from "@/shared/api/types";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/shared/ui/dialog";
import { useFileImportZone } from "@/shared/hooks/useFileImportZone";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import { ChooserDialogContent } from "@/shared/ui/chooser-dialog-content";
import { Dialog } from "@/shared/ui/dialog";
import { Markdown } from "@/shared/ui/markdown";
import { Skeleton } from "@/shared/ui/skeleton";
const agentInstructionMarkdownClassName = [
"mt-3 leading-6 text-muted-foreground [&_blockquote]:!text-muted-foreground [&_code]:!text-muted-foreground [&_li]:text-muted-foreground [&_ol]:text-muted-foreground [&_p]:text-muted-foreground [&_strong]:text-muted-foreground [&_td]:text-muted-foreground [&_ul]:text-muted-foreground",
"[&>h1]:!text-sm [&>h1]:!font-semibold [&>h1]:!leading-6 [&>h1]:!tracking-normal [&>h1]:!text-foreground",
"[&>h2]:!text-sm [&>h2]:!font-semibold [&>h2]:!leading-6 [&>h2]:!tracking-normal [&>h2]:!text-foreground",
"[&>h3]:!text-sm [&>h3]:!font-semibold [&>h3]:!leading-6 [&>h3]:!tracking-normal [&>h3]:!text-foreground",
"[&>h4]:!text-sm [&>h4]:!font-semibold [&>h4]:!leading-6 [&>h4]:!tracking-normal [&>h4]:!text-foreground",
"[&>h5]:!text-sm [&>h5]:!font-semibold [&>h5]:!leading-6 [&>h5]:!tracking-normal [&>h5]:!text-foreground",
"[&>h6]:!text-sm [&>h6]:!font-semibold [&>h6]:!leading-6 [&>h6]:!tracking-normal [&>h6]:!text-foreground",
].join(" ");
const sidebarRowClassName =
"flex w-full items-center gap-2 rounded-lg px-4 py-1.5 text-left transition-[background-color,color,box-shadow] focus:outline-hidden focus-visible:ring-2 focus-visible:ring-sidebar-ring/50 focus-visible:ring-offset-2 focus-visible:ring-offset-sidebar";
const sidebarRowIdleClassName =
"text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground";
/**
* Step 1 of the Create Agent wizard: choose a starting point — a blank
* agent, a built-in starter template, or an imported agent file. Selecting
* anything hands off to the details step (CreateAgentDialog) prefilled.
* agent, a built-in starter template, or an imported agent file. Uses the
* catalog chooser layout: a sidebar list on the left and a template detail
* pane on the right. Blank/import act immediately; picking a template
* previews it and "Use template" hands off to the details step
* (CreateAgentDialog) prefilled.
*/
export function CreateAgentStartDialog({
open,
@@ -30,8 +49,12 @@ export function CreateAgentStartDialog({
onPickBlank: () => void;
onPickTemplate: (template: AgentTemplate) => void;
}) {
const contentRef = React.useRef<HTMLDivElement | null>(null);
const templatesQuery = useAgentTemplatesQuery({ enabled: open });
const templates = templatesQuery.data ?? [];
const isLoading = templatesQuery.isLoading;
const error =
templatesQuery.error instanceof Error ? templatesQuery.error : null;
const {
fileInputRef,
isDragOver,
@@ -40,130 +63,318 @@ export function CreateAgentStartDialog({
openFilePicker,
} = useFileImportZone({ onImportFile });
const [selectedTemplateId, setSelectedTemplateId] = React.useState<
string | null
>(null);
const selectedTemplate = React.useMemo(() => {
if (templates.length === 0) {
return null;
}
return (
templates.find((template) => template.id === selectedTemplateId) ??
templates[0]
);
}, [templates, selectedTemplateId]);
React.useEffect(() => {
if (!open) {
return;
}
if (templates.length === 0) {
setSelectedTemplateId(null);
return;
}
setSelectedTemplateId((current) =>
current && templates.some((template) => template.id === current)
? current
: templates[0].id,
);
}, [open, templates]);
const handleUseTemplate = () => {
if (!selectedTemplate) {
return;
}
onPickTemplate(selectedTemplate);
};
return (
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogContent className="max-w-2xl overflow-hidden p-0">
<div className="relative flex max-h-[85vh] flex-col" {...dropHandlers}>
{isDragOver ? (
<div className="pointer-events-none absolute inset-1 z-10 flex items-center justify-center rounded-2xl border-2 border-dashed border-primary/50 bg-background/80 backdrop-blur-sm">
<p className="text-sm font-medium text-primary">
Drop an agent file to import
</p>
</div>
) : null}
<ChooserDialogContent
className="h-168 max-w-4xl"
contentClassName="flex min-h-0 flex-1 p-0"
data-testid="create-agent-start-dialog"
description="Choose a starting point. You can adjust everything in the next step."
headerClassName="bg-sidebar pb-3 text-sidebar-foreground"
headerTestId="create-agent-start-dialog-header"
onOpenAutoFocus={(event) => {
event.preventDefault();
contentRef.current?.focus();
}}
ref={contentRef}
scrollAreaClassName="flex min-h-0 overflow-hidden px-0"
scrollAreaTestId="create-agent-start-dialog-body"
tabIndex={-1}
title="New agent"
{...dropHandlers}
>
{isDragOver ? (
<div className="pointer-events-none absolute inset-1 z-20 flex items-center justify-center rounded-2xl border-2 border-dashed border-primary/50 bg-background/80 backdrop-blur-sm">
<p className="text-sm font-medium text-primary">
Drop an agent file to import
</p>
</div>
) : null}
<DialogHeader className="shrink-0 border-b border-border/60 px-6 py-5 pr-14">
<DialogTitle>New agent</DialogTitle>
<DialogDescription>
Choose a starting point. You can adjust everything in the next
step.
</DialogDescription>
</DialogHeader>
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto px-6 py-5">
<div className="grid gap-2 sm:grid-cols-2">
<button
className="flex items-center gap-3 rounded-2xl border border-border/70 bg-background/70 px-4 py-3 text-left transition-colors hover:border-primary/50 hover:bg-primary/5"
data-testid="create-agent-start-blank"
onClick={onPickBlank}
type="button"
>
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-dashed border-muted-foreground/40 text-muted-foreground">
<Plus className="h-4 w-4" />
</span>
<span className="min-w-0">
<span className="block text-sm font-semibold tracking-tight">
<div className="flex min-h-0 flex-1 flex-col overflow-hidden bg-sidebar sm:flex-row">
<div className="flex max-h-56 min-h-0 flex-col sm:max-h-none sm:w-56">
<div
className="min-h-0 flex-1 overflow-y-auto px-2 py-3"
data-testid="create-agent-start-dialog-scroll-area"
>
<div className="space-y-1">
<button
className={cn(sidebarRowClassName, sidebarRowIdleClassName)}
data-testid="create-agent-start-blank"
onClick={onPickBlank}
type="button"
>
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md border border-dashed border-sidebar-foreground/40">
<Plus className="h-3.5 w-3.5" />
</span>
<span className="min-w-0 flex-1 truncate text-sm font-medium">
Blank agent
</span>
<span className="block truncate text-xs text-muted-foreground">
Start from scratch
</span>
</span>
</button>
</button>
<button
className="flex items-center gap-3 rounded-2xl border border-border/70 bg-background/70 px-4 py-3 text-left transition-colors hover:border-primary/50 hover:bg-primary/5"
data-testid="create-agent-start-import"
onClick={openFilePicker}
type="button"
>
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-dashed border-muted-foreground/40 text-muted-foreground">
<FileUp className="h-4 w-4" />
</span>
<span className="min-w-0">
<span className="block text-sm font-semibold tracking-tight">
<button
className={cn(sidebarRowClassName, sidebarRowIdleClassName)}
data-testid="create-agent-start-import"
onClick={openFilePicker}
type="button"
>
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md border border-dashed border-sidebar-foreground/40">
<FileUp className="h-3.5 w-3.5" />
</span>
<span className="min-w-0 flex-1 truncate text-sm font-medium">
Import from file
</span>
<span className="block truncate text-xs text-muted-foreground">
.persona.md, .persona.json, .persona.png, or .zip
</span>
</span>
</button>
</div>
</button>
</div>
<div className="space-y-2">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
<p className="px-4 pb-1 pt-4 text-xs font-medium uppercase tracking-wide text-sidebar-foreground/60">
Templates
</p>
<div className="grid gap-2 sm:grid-cols-2">
{templates.map((template) => (
<TemplateCard
key={template.id}
onPick={onPickTemplate}
template={template}
/>
))}
</div>
{isLoading ? <TemplateListSkeleton /> : null}
{!isLoading && templates.length > 0 ? (
<div className="space-y-1">
{templates.map((template) => {
const isCurrent = template.id === selectedTemplate?.id;
return (
<button
aria-current={isCurrent ? "true" : undefined}
className={cn(
sidebarRowClassName,
isCurrent
? "bg-sidebar-active text-sidebar-active-foreground"
: sidebarRowIdleClassName,
)}
data-testid={`create-agent-template-${template.id}`}
key={template.id}
onClick={() => {
setSelectedTemplateId(template.id);
}}
type="button"
>
<ProfileAvatar
avatarUrl={template.avatarUrl}
className="h-6 w-6 text-3xs"
label={template.displayName}
/>
<span className="min-w-0 flex-1 truncate text-sm font-medium">
{template.displayName}
</span>
</button>
);
})}
</div>
) : null}
</div>
</div>
<input
accept=".md,.json,.png,.zip"
className="hidden"
onChange={handleFileChange}
ref={fileInputRef}
type="file"
/>
<div className="relative z-10 ml-px flex min-h-0 flex-1 flex-col overflow-hidden rounded-tl-xl bg-background shadow-[-1px_0_0_0_hsl(var(--sidebar-border)/0.45)]">
<div
className="min-h-0 flex-1 overflow-y-auto px-5 pb-24 pt-5"
data-testid="create-agent-start-detail-pane"
>
{isLoading ? <TemplateDetailSkeleton /> : null}
{!isLoading && selectedTemplate ? (
<TemplateDetail template={selectedTemplate} />
) : null}
{!isLoading && templates.length === 0 && !error ? (
<div className="flex min-h-80 items-center justify-center rounded-lg border border-dashed border-border/70 px-6 text-center">
<div>
<p className="text-sm font-semibold">
No templates available
</p>
<p className="mt-2 max-w-sm text-sm text-muted-foreground">
Start from a blank agent or import an agent file instead.
</p>
</div>
</div>
) : null}
{error ? (
<p className="rounded-lg border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{error.message}
</p>
) : null}
</div>
<div className="pointer-events-none absolute inset-x-0 bottom-0 flex justify-end bg-linear-to-t from-background via-background/95 to-transparent px-5 pb-4 pt-12">
<Button
aria-label={
selectedTemplate
? `Use the ${selectedTemplate.displayName} template`
: undefined
}
className="pointer-events-auto"
data-testid="create-agent-start-use-template"
disabled={!selectedTemplate}
onClick={handleUseTemplate}
size="sm"
type="button"
>
Use template
</Button>
</div>
</div>
</div>
</DialogContent>
<input
accept=".md,.json,.png,.zip"
className="hidden"
onChange={handleFileChange}
ref={fileInputRef}
type="file"
/>
</ChooserDialogContent>
</Dialog>
);
}
function TemplateCard({
template,
onPick,
}: {
template: AgentTemplate;
onPick: (template: AgentTemplate) => void;
}) {
const firstLine = template.systemPrompt
.trim()
.split("\n")
.find((line) => line.trim().length > 0);
function TemplateDetail({ template }: { template: AgentTemplate }) {
return (
<button
className="flex items-center gap-3 rounded-2xl border border-border/70 bg-background/70 px-4 py-3 text-left transition-colors hover:border-primary/50 hover:bg-primary/5"
data-testid={`create-agent-template-${template.id}`}
onClick={() => onPick(template)}
type="button"
>
<ProfileAvatar
avatarUrl={template.avatarUrl}
className="h-9 w-9 shrink-0 rounded-lg text-xs"
label={template.displayName}
<div className="space-y-6">
<div className="flex items-center gap-3">
<ProfileAvatar
avatarUrl={template.avatarUrl}
className="h-12 w-12 text-sm"
label={template.displayName}
/>
<div className="min-w-0">
<h3 className="truncate text-xl font-semibold leading-snug">
{template.displayName}
</h3>
</div>
</div>
<TemplateMetaGroup
items={[
{ label: "Type", value: "Built-in template" },
{
label: "Preferred model",
value: template.model ?? "Use app default",
},
{
label: "Preferred runtime",
value: template.runtime ?? "Use app default",
},
]}
/>
<span className="min-w-0">
<span className="block text-sm font-semibold tracking-tight">
{template.displayName}
</span>
{firstLine ? (
<span className="block truncate text-xs text-muted-foreground">
{firstLine}
</span>
) : null}
</span>
</button>
<div className="pt-3">
<p className="text-base font-semibold text-foreground">
Agent instruction
</p>
<Markdown
className={agentInstructionMarkdownClassName}
content={template.systemPrompt}
interactive={false}
/>
</div>
</div>
);
}
function TemplateMetaGroup({
items,
}: {
items: { label: string; value: string }[];
}) {
return (
<div className="rounded-lg border border-border/70 bg-card/70">
<div className="grid sm:grid-cols-3">
{items.map((item, index) => (
<div
className={cn(
"relative px-4 py-3",
index > 0 &&
"border-t border-border/60 sm:border-t-0 sm:before:absolute sm:before:bottom-3 sm:before:left-0 sm:before:top-3 sm:before:w-px sm:before:bg-border/70",
)}
key={item.label}
>
<p className="text-xs font-semibold text-muted-foreground">
{item.label}
</p>
<p className="mt-2 text-sm font-medium text-foreground">
{item.value}
</p>
</div>
))}
</div>
</div>
);
}
function TemplateListSkeleton() {
return (
<div className="space-y-2">
{["first", "second", "third", "fourth", "fifth"].map((key) => (
<div
className="flex items-center gap-2 rounded-lg px-4 py-1.5"
key={key}
>
<Skeleton className="h-6 w-6 rounded-full" />
<Skeleton className="h-4 w-28" />
</div>
))}
</div>
);
}
function TemplateDetailSkeleton() {
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<Skeleton className="h-12 w-12 rounded-full" />
<Skeleton className="h-6 w-40" />
</div>
<div className="grid overflow-hidden rounded-lg border border-border/70 sm:grid-cols-3">
<Skeleton className="h-20 rounded-none" />
<Skeleton className="h-20 rounded-none" />
<Skeleton className="h-20 rounded-none" />
</div>
<Skeleton className="h-48 rounded-lg" />
</div>
);
}
+2
View File
@@ -155,6 +155,7 @@ test("selecting a template prefills the create agent form", async ({
await openCreateAgentStart(page);
await page.getByTestId("create-agent-template-builtin:fizz").click();
await page.getByTestId("create-agent-start-use-template").click();
await expect(page.getByTestId("agent-name-input")).toHaveValue("Fizz");
await expect(page.getByTestId("agent-instructions-input")).toHaveValue(
@@ -169,6 +170,7 @@ test("template submit creates an agent card in the grid", async ({ page }) => {
await openCreateAgentStart(page);
await page.getByTestId("create-agent-template-builtin:fizz").click();
await page.getByTestId("create-agent-start-use-template").click();
await expect(page.getByTestId("agent-name-input")).toHaveValue("Fizz");
await expect(page.getByTestId("create-agent-submit")).toBeEnabled();
await page.getByTestId("create-agent-submit").click();