Add dialog body layout and width variants

This commit is contained in:
morgmart
2026-05-03 17:13:18 -07:00
parent ebe3315bdd
commit b4f84cf7b2
17 changed files with 1176 additions and 740 deletions
@@ -15,3 +15,16 @@ export function getPersonaSource(persona: Persona): PersonaSource {
export function isPersonaReadOnly(persona: Persona): boolean {
return getPersonaSource(persona) !== "custom";
}
export function getPersonaInitials(displayName: string): string {
const initials = displayName
.trim()
.split(/\s+/)
.map((part) => part.match(/[\p{L}\p{N}]/u)?.[0] ?? "")
.filter(Boolean)
.slice(0, 2)
.join("")
.toUpperCase();
return initials || "?";
}
@@ -0,0 +1,247 @@
import type { ButtonHTMLAttributes, ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Copy, Download, MoreVertical, Pencil, Trash2 } from "lucide-react";
import { MessageResponse } from "@/shared/ui/ai-elements/message";
import {
Avatar as AvatarRoot,
AvatarFallback,
AvatarImage,
} from "@/shared/ui/avatar";
import { Badge } from "@/shared/ui/badge";
import { Button } from "@/shared/ui/button";
import { DetailField } from "@/shared/ui/detail-field";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/shared/ui/dropdown-menu";
import { PageColumns } from "@/shared/ui/page-columns";
import { DetailPageShell, PageHeader } from "@/shared/ui/page-shell";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
import { useAvatarSrc } from "@/shared/hooks/useAvatarSrc";
import type { Persona } from "@/shared/types/agents";
import {
getPersonaInitials,
getPersonaSource,
isPersonaReadOnly,
} from "@/features/agents/lib/personaPresentation";
interface AgentDetailPageProps {
persona: Persona;
onBack: () => void;
onEdit: (persona: Persona) => void;
onDuplicate: (persona: Persona) => void;
onDelete: (persona: Persona) => void;
onExport: (persona: Persona) => void;
}
interface AgentHeaderActionButtonProps
extends ButtonHTMLAttributes<HTMLButtonElement> {
label: string;
icon: ReactNode;
}
function AgentHeaderActionButton({
label,
icon,
type = "button",
...props
}: AgentHeaderActionButtonProps) {
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
type={type}
size="icon-xs"
variant="outline-flat"
aria-label={label}
{...props}
>
{icon}
<span className="sr-only">{label}</span>
</Button>
</TooltipTrigger>
<TooltipContent side="top" align="center" sideOffset={8}>
<p>{label}</p>
</TooltipContent>
</Tooltip>
);
}
function formatDate(value: string): string {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value;
}
return new Intl.DateTimeFormat(undefined, {
month: "long",
day: "numeric",
year: "numeric",
}).format(date);
}
export function AgentDetailPage({
persona,
onBack,
onEdit,
onDuplicate,
onDelete,
onExport,
}: AgentDetailPageProps) {
const { t } = useTranslation(["agents", "common"]);
const avatarSrc = useAvatarSrc(persona.avatar);
const initials = getPersonaInitials(persona.displayName);
const personaSource = getPersonaSource(persona);
const canEditPersona = !isPersonaReadOnly(persona);
const canDeletePersona = personaSource !== "builtin";
const sourceLabel =
personaSource === "builtin"
? t("common:labels.builtIn")
: personaSource === "file"
? t("card.fileBacked")
: t("card.custom");
const providerLabel = persona.provider || t("common:labels.none");
const modelLabel = persona.model || t("common:labels.none");
return (
<DetailPageShell>
<div className="space-y-5 border-b border-border pb-6">
<Button
type="button"
variant="back"
size="sm"
className="w-fit"
onClick={onBack}
>
{t("view.backToAgents")}
</Button>
<PageHeader
variant="detail"
title={
<span className="inline-flex min-w-0 items-center gap-3">
<AvatarRoot className="size-12 shrink-0 border border-border-soft bg-muted/30">
<AvatarImage
src={avatarSrc ?? undefined}
alt={persona.displayName}
/>
<AvatarFallback className="text-base font-semibold">
{initials}
</AvatarFallback>
</AvatarRoot>
<span className="min-w-0 truncate">{persona.displayName}</span>
</span>
}
description={persona.systemPrompt}
descriptionClassName="line-clamp-2 max-w-3xl leading-relaxed"
actionsPlacement="below"
actions={
<>
{canEditPersona ? (
<AgentHeaderActionButton
label={t("common:actions.edit")}
icon={<Pencil className="size-3.5" />}
onClick={() => onEdit(persona)}
/>
) : null}
<AgentHeaderActionButton
label={t("editor.duplicate")}
icon={<Copy className="size-3.5" />}
onClick={() => onDuplicate(persona)}
/>
<AgentHeaderActionButton
label={t("common:actions.export")}
icon={<Download className="size-3.5" />}
onClick={() => onExport(persona)}
/>
{canDeletePersona ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
size="icon-xs"
variant="outline-flat"
aria-label={t("view.more")}
>
<MoreVertical className="size-3.5" />
<span className="sr-only">{t("view.more")}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" sideOffset={8}>
<DropdownMenuItem
variant="destructive"
onSelect={() => onDelete(persona)}
>
<Trash2 className="size-3.5" />
{t("common:actions.delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : null}
</>
}
actionsClassName="gap-2"
/>
</div>
<PageColumns
defaultSidebarSize={30}
minSidebarSize={24}
maxSidebarSize={38}
minContentSize={52}
sidebar={
<aside className="space-y-5">
<section className="space-y-5 border-b border-border pb-5">
<DetailField label={t("view.source")}>
<Badge variant="secondary">{sourceLabel}</Badge>
</DetailField>
<DetailField
label={t("editor.provider")}
contentAs="p"
contentClassName="break-words"
>
{providerLabel}
</DetailField>
<DetailField
label={t("editor.model")}
contentAs="p"
contentClassName="break-words"
>
{modelLabel}
</DetailField>
</section>
<section className="space-y-5">
<DetailField label={t("view.created")} contentAs="p">
{formatDate(persona.createdAt)}
</DetailField>
<DetailField label={t("view.updated")} contentAs="p">
{formatDate(persona.updatedAt)}
</DetailField>
</section>
</aside>
}
>
<section className="space-y-4 pb-6">
<DetailField
label={t("editor.systemPrompt")}
meta={
<span className="text-[10px] text-muted-foreground">
{t("common:labels.characterCount", {
count: persona.systemPrompt.length,
})}
</span>
}
/>
<MessageResponse className="min-w-0 text-sm leading-6">
{persona.systemPrompt || " "}
</MessageResponse>
</section>
</PageColumns>
</DetailPageShell>
);
}
+93 -66
View File
@@ -5,6 +5,7 @@ import { Plus, Upload } from "lucide-react";
import { toast } from "sonner";
import { SearchBar } from "@/shared/ui/SearchBar";
import { Button, buttonVariants } from "@/shared/ui/button";
import { PageHeader, PageShell } from "@/shared/ui/page-shell";
import {
AlertDialog,
AlertDialogAction,
@@ -16,6 +17,7 @@ import {
AlertDialogTitle,
} from "@/shared/ui/alert-dialog";
import { useAgentStore } from "@/features/agents/stores/agentStore";
import { AgentDetailPage } from "@/features/agents/ui/AgentDetailPage";
import { PersonaGallery } from "@/features/agents/ui/PersonaGallery";
import { PersonaEditor } from "@/features/agents/ui/PersonaEditor";
import {
@@ -40,6 +42,7 @@ export function AgentsView() {
const { t } = useTranslation(["agents", "common"]);
const [search, setSearch] = useState("");
const [deletingPersona, setDeletingPersona] = useState<Persona | null>(null);
const [activePersonaId, setActivePersonaId] = useState<string | null>(null);
const personas = useAgentStore((s) => s.personas);
const personasLoading = useAgentStore((s) => s.personasLoading);
@@ -57,6 +60,8 @@ export function AgentsView() {
} = usePersonas();
const lowerSearch = search.toLowerCase();
const activePersona =
personas.find((persona) => persona.id === activePersonaId) ?? null;
const filteredPersonas = useMemo(
() =>
@@ -126,12 +131,22 @@ export function AgentsView() {
if (editingPersona?.id === deletingPersona.id) {
closePersonaEditor();
}
if (activePersonaId === deletingPersona.id) {
setActivePersonaId(null);
}
toast.success(t("view.deleted", { name: deletingPersona.displayName }));
} catch (err) {
toast.error(formatAgentError(err, t("view.deleteFailed")));
}
setDeletingPersona(null);
}, [closePersonaEditor, deletingPersona, deletePersona, editingPersona, t]);
}, [
activePersonaId,
closePersonaEditor,
deletingPersona,
deletePersona,
editingPersona,
t,
]);
const handleExportPersona = useCallback(
async (persona: Persona) => {
@@ -218,69 +233,8 @@ export function AgentsView() {
}
}, [handleImportFileBytes, t, validateImportFile]);
return (
<div className="flex flex-1 flex-col h-full min-h-0">
<div className="flex-1 overflow-y-auto min-h-0">
<div className="max-w-5xl mx-auto w-full px-6 py-8 space-y-5 page-transition">
{/* Header */}
<div className="flex flex-wrap items-end justify-between gap-3">
<div>
<h1 className="text-lg font-semibold font-display tracking-tight">
{t("view.title")}
</h1>
<p className="text-xs text-muted-foreground">
{t("view.description")}
</p>
</div>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline-flat"
size="sm"
onClick={() => void handleImportPicker()}
>
<Upload className="w-3.5 h-3.5" />
{t("common:actions.import")}
</Button>
<Button
type="button"
variant="outline-flat"
size="sm"
onClick={() => openPersonaEditor()}
>
<Plus className="w-3.5 h-3.5" />
{t("view.newPersona")}
</Button>
</div>
</div>
{/* Search */}
<SearchBar
value={search}
onChange={setSearch}
placeholder={t("view.searchPlaceholder")}
/>
{/* Personas section */}
<section aria-labelledby="personas-heading">
<PersonaGallery
personas={filteredPersonas}
onSelectPersona={(p) => openPersonaEditor(p, "details")}
onEditPersona={(p) => openPersonaEditor(p, "edit")}
onDuplicatePersona={handleDuplicatePersona}
onDeletePersona={handleDeletePersona}
onExportPersona={handleExportPersona}
onCreatePersona={() => openPersonaEditor()}
onImportFile={handleImportFileBytes}
validateImportFile={validateImportFile}
onImportError={handleImportError}
isLoading={personasLoading}
/>
</section>
</div>
</div>
{/* Persona editor modal */}
const dialogs = (
<>
<PersonaEditor
persona={editingPersona ?? undefined}
isOpen={personaEditorOpen}
@@ -292,7 +246,6 @@ export function AgentsView() {
onDelete={handleDeletePersona}
/>
{/* Delete confirmation dialog */}
<AlertDialog
open={!!deletingPersona}
onOpenChange={(open) => !open && setDeletingPersona(null)}
@@ -321,6 +274,80 @@ export function AgentsView() {
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</>
);
if (activePersona) {
return (
<>
<AgentDetailPage
persona={activePersona}
onBack={() => setActivePersonaId(null)}
onEdit={(persona) => openPersonaEditor(persona, "edit")}
onDuplicate={handleDuplicatePersona}
onDelete={handleDeletePersona}
onExport={handleExportPersona}
/>
{dialogs}
</>
);
}
return (
<PageShell>
<PageHeader
title={t("view.title")}
description={t("view.description")}
titleClassName="font-normal text-foreground"
actions={
<>
<Button
type="button"
variant="outline-flat"
size="xs"
onClick={() => void handleImportPicker()}
>
<Upload className="size-3.5" />
{t("common:actions.import")}
</Button>
<Button
type="button"
variant="outline-flat"
size="xs"
onClick={() => openPersonaEditor()}
>
<Plus className="size-3.5" />
{t("view.newPersona")}
</Button>
</>
}
/>
<SearchBar
value={search}
onChange={setSearch}
placeholder={t("view.searchPlaceholder")}
aria-label={t("view.searchPlaceholder")}
/>
<section aria-labelledby="personas-heading">
<PersonaGallery
personas={filteredPersonas}
hasAnyPersonas={personas.length > 0}
onSelectPersona={(p) => setActivePersonaId(p.id)}
onEditPersona={(p) => openPersonaEditor(p, "edit")}
onDuplicatePersona={handleDuplicatePersona}
onDeletePersona={handleDeletePersona}
onExportPersona={handleExportPersona}
onCreatePersona={() => openPersonaEditor()}
onImportFile={handleImportFileBytes}
validateImportFile={validateImportFile}
onImportError={handleImportError}
isLoading={personasLoading}
/>
</section>
{dialogs}
</PageShell>
);
}
@@ -1,6 +1,6 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { MoreVertical, Copy, Pencil, Trash2, Download } from "lucide-react";
import { Copy, Download, MoreVertical, Pencil, Trash2 } from "lucide-react";
import { cn } from "@/shared/lib/cn";
import { Avatar, AvatarImage, AvatarFallback } from "@/shared/ui/avatar";
import { Badge } from "@/shared/ui/badge";
@@ -13,7 +13,10 @@ import {
} from "@/shared/ui/dropdown-menu";
import { useAvatarSrc } from "@/shared/hooks/useAvatarSrc";
import type { Persona } from "@/shared/types/agents";
import { getPersonaSource } from "@/features/agents/lib/personaPresentation";
import {
getPersonaInitials,
getPersonaSource,
} from "@/features/agents/lib/personaPresentation";
interface PersonaCardProps {
persona: Persona;
@@ -37,11 +40,12 @@ export function PersonaCard({
const { t } = useTranslation(["agents", "common"]);
const [menuOpen, setMenuOpen] = useState(false);
const initials = persona.displayName.charAt(0).toUpperCase();
const initials = getPersonaInitials(persona.displayName);
const avatarSrc = useAvatarSrc(persona.avatar);
const personaSource = getPersonaSource(persona);
const canEditPersona = personaSource === "custom";
const canDeletePersona = personaSource !== "builtin";
const isFeatured = personaSource === "builtin";
const providerModelLabel = [persona.provider, persona.model]
.filter(Boolean)
.join(" / ");
@@ -65,97 +69,97 @@ export function PersonaCard({
onKeyDown={handleCardKeyDown}
tabIndex={0}
className={cn(
"group relative flex flex-col items-center gap-3 rounded-xl border p-5 cursor-pointer",
"bg-background transition-colors duration-200 motion-safe:animate-in motion-safe:fade-in motion-safe:slide-in-from-bottom-2",
"hover:bg-accent/50",
isActive ? "border-border ring-1 ring-ring" : "border-border",
"group relative flex cursor-pointer flex-col rounded-2xl border border-border-soft bg-background p-5",
"transition-colors duration-200 motion-safe:animate-in motion-safe:fade-in motion-safe:slide-in-from-bottom-2",
"hover:border-border hover:bg-muted/10 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
isActive && "border-border bg-muted/20",
)}
>
{/* Dropdown trigger */}
<div className="absolute right-2 top-2">
<DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label={t("card.options")}
onClick={(e) => e.stopPropagation()}
onKeyDown={(event) => event.stopPropagation()}
className={cn(
"size-6 rounded-md text-muted-foreground hover:text-foreground",
menuOpen ? "opacity-100" : "opacity-0 group-hover:opacity-100",
)}
>
<MoreVertical className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" sideOffset={4}>
{canEditPersona && (
<DropdownMenuItem onSelect={() => onEdit?.(persona)}>
<Pencil className="size-3.5" />
{t("common:actions.edit")}
</DropdownMenuItem>
)}
<DropdownMenuItem onSelect={() => onDuplicate?.(persona)}>
<Copy className="size-3.5" />
{t("common:actions.duplicate")}
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => onExport?.(persona)}>
<Download className="size-3.5" />
{t("common:actions.export")}
</DropdownMenuItem>
{canDeletePersona && (
<DropdownMenuItem
variant="destructive"
onSelect={() => onDelete?.(persona)}
{isFeatured ? (
<Badge
variant="featured"
className="absolute right-5 top-5 z-10 text-[10px]"
>
{t("card.featured")}
</Badge>
) : null}
<div className="flex items-start justify-between gap-3">
<Avatar className="size-12 border border-border-soft bg-muted/30">
<AvatarImage src={avatarSrc ?? undefined} alt={persona.displayName} />
<AvatarFallback className="text-sm font-semibold">
{initials}
</AvatarFallback>
</Avatar>
<div className="relative z-20 -mr-2 -mt-2">
<DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label={t("card.options")}
onClick={(e) => e.stopPropagation()}
onKeyDown={(event) => event.stopPropagation()}
className={cn(
"size-6 rounded-md text-muted-foreground hover:text-foreground",
menuOpen
? "opacity-100"
: "opacity-0 group-hover:opacity-100 group-focus-within:opacity-100",
)}
>
<Trash2 className="size-3.5" />
{t("common:actions.delete")}
<MoreVertical className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" sideOffset={4}>
{canEditPersona && (
<DropdownMenuItem onSelect={() => onEdit?.(persona)}>
<Pencil className="size-3.5" />
{t("common:actions.edit")}
</DropdownMenuItem>
)}
<DropdownMenuItem onSelect={() => onDuplicate?.(persona)}>
<Copy className="size-3.5" />
{t("common:actions.duplicate")}
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenuItem onSelect={() => onExport?.(persona)}>
<Download className="size-3.5" />
{t("common:actions.export")}
</DropdownMenuItem>
{canDeletePersona && (
<DropdownMenuItem
variant="destructive"
onSelect={() => onDelete?.(persona)}
>
<Trash2 className="size-3.5" />
{t("common:actions.delete")}
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
{/* Avatar */}
<Avatar className="h-12 w-12">
<AvatarImage src={avatarSrc ?? undefined} alt={persona.displayName} />
<AvatarFallback className="text-sm font-semibold">
{initials}
</AvatarFallback>
</Avatar>
<div className="mt-4 min-w-0 space-y-1">
<div className="flex min-w-0 items-center gap-2">
<h3 className="min-w-0 flex-1 truncate text-sm font-medium leading-5 text-foreground">
{persona.displayName}
</h3>
</div>
{/* Name */}
<h3 className="text-sm font-medium text-center leading-tight">
{persona.displayName}
</h3>
{providerModelLabel ? (
<p className="truncate text-[11px] leading-4 text-muted-foreground">
{providerModelLabel}
</p>
) : null}
</div>
{/* Built-in badge */}
{personaSource === "builtin" && (
<Badge variant="secondary" className="text-[10px]">
{t("common:labels.builtIn")}
</Badge>
)}
{personaSource === "file" && (
<Badge variant="secondary" className="text-[10px]">
{t("card.fileBacked")}
</Badge>
)}
<div aria-hidden="true" className="h-7 shrink-0" />
{/* System prompt preview */}
<p className="text-xs text-muted-foreground text-center line-clamp-2 w-full">
<p className="line-clamp-3 max-w-2xl text-xs font-light leading-5 text-muted-foreground">
{persona.systemPrompt}
</p>
{/* Provider/model badge */}
{providerModelLabel && (
<Badge variant="secondary" className="max-w-full min-w-0 text-[10px]">
<span className="block max-w-full truncate">
{providerModelLabel}
</span>
</Badge>
)}
</div>
);
}
@@ -9,7 +9,10 @@ import { Badge } from "@/shared/ui/badge";
import { MessageResponse } from "@/shared/ui/ai-elements/message";
import { useAvatarSrc } from "@/shared/hooks/useAvatarSrc";
import type { Avatar } from "@/shared/types/agents";
import type { PersonaSource } from "@/features/agents/lib/personaPresentation";
import {
getPersonaInitials,
type PersonaSource,
} from "@/features/agents/lib/personaPresentation";
interface PersonaDetailsProps {
avatar: Avatar | null;
@@ -30,80 +33,73 @@ export function PersonaDetails({
}: PersonaDetailsProps) {
const { t } = useTranslation(["agents", "common"]);
const avatarSrc = useAvatarSrc(avatar);
const initials = displayName.charAt(0).toUpperCase() || "?";
const initials = getPersonaInitials(displayName);
return (
<div className="min-h-0 flex-1 overflow-y-auto px-5 pb-5">
<div className="space-y-4">
<section className="rounded-xl border border-border bg-muted/20 p-4">
<div className="flex items-start gap-4">
<AvatarRoot className="h-16 w-16 border border-border bg-background">
<AvatarImage
src={avatarSrc ?? undefined}
alt={t("avatar.previewAlt")}
/>
<AvatarFallback className="text-lg font-semibold">
{initials}
</AvatarFallback>
</AvatarRoot>
<div className="min-w-0 flex-1 space-y-2">
<DetailField
label={t("editor.displayName")}
contentClassName="text-base font-semibold tracking-tight"
>
{displayName}
</DetailField>
<div className="flex flex-wrap items-center gap-2">
{personaSource === "builtin" ? (
<Badge variant="secondary">
{t("common:labels.builtIn")}
</Badge>
) : null}
{personaSource === "file" ? (
<Badge variant="secondary">{t("card.fileBacked")}</Badge>
) : null}
</div>
<div className="space-y-4 px-5 pb-5">
<section className="rounded-xl border border-border bg-muted/20 p-4">
<div className="flex items-start gap-4">
<AvatarRoot className="h-16 w-16 border border-border bg-background">
<AvatarImage
src={avatarSrc ?? undefined}
alt={t("avatar.previewAlt")}
/>
<AvatarFallback className="text-lg font-semibold">
{initials}
</AvatarFallback>
</AvatarRoot>
<div className="min-w-0 flex-1 space-y-2">
<DetailField
label={t("editor.displayName")}
contentClassName="text-base font-semibold tracking-tight"
>
{displayName}
</DetailField>
<div className="flex flex-wrap items-center gap-2">
{personaSource === "builtin" ? (
<Badge variant="secondary">{t("common:labels.builtIn")}</Badge>
) : null}
{personaSource === "file" ? (
<Badge variant="secondary">{t("card.fileBacked")}</Badge>
) : null}
</div>
</div>
</section>
</div>
</section>
<section className="grid gap-3 sm:grid-cols-2">
<div className="rounded-xl border border-border bg-background p-4">
<DetailField
label={t("editor.provider")}
contentClassName="font-medium"
>
{providerLabel}
</DetailField>
</div>
<div className="rounded-xl border border-border bg-background p-4">
<DetailField
label={t("editor.model")}
contentClassName="font-medium"
>
{modelLabel}
</DetailField>
</div>
</section>
<section className="space-y-2 rounded-xl border border-border bg-background p-4">
<section className="grid gap-3 sm:grid-cols-2">
<div className="rounded-xl border border-border bg-background p-4">
<DetailField
label={t("editor.systemPrompt")}
meta={
<span className="text-[10px] text-muted-foreground">
{t("common:labels.characterCount", {
count: systemPrompt.length,
})}
</span>
}
/>
<div className="rounded-lg border border-border bg-muted/20 px-4 py-3">
<MessageResponse className="min-w-0 text-sm leading-6">
{systemPrompt}
</MessageResponse>
</div>
</section>
</div>
label={t("editor.provider")}
contentClassName="font-medium"
>
{providerLabel}
</DetailField>
</div>
<div className="rounded-xl border border-border bg-background p-4">
<DetailField label={t("editor.model")} contentClassName="font-medium">
{modelLabel}
</DetailField>
</div>
</section>
<section className="space-y-2 rounded-xl border border-border bg-background p-4">
<DetailField
label={t("editor.systemPrompt")}
meta={
<span className="text-[10px] text-muted-foreground">
{t("common:labels.characterCount", {
count: systemPrompt.length,
})}
</span>
}
/>
<div className="rounded-lg border border-border bg-muted/20 px-4 py-3">
<MessageResponse className="min-w-0 text-sm leading-6">
{systemPrompt}
</MessageResponse>
</div>
</section>
</div>
);
}
+171 -169
View File
@@ -14,6 +14,7 @@ import { Textarea } from "@/shared/ui/textarea";
import { useAvatarSrc } from "@/shared/hooks/useAvatarSrc";
import {
Dialog,
DialogBody,
DialogContent,
DialogFooter,
DialogHeader,
@@ -37,6 +38,7 @@ import { useProviderInventory } from "@/features/providers/hooks/useProviderInve
import { getProviderInventory } from "@/features/providers/api/inventory";
import { useProviderInventoryStore } from "@/features/providers/stores/providerInventoryStore";
import {
getPersonaInitials,
getPersonaSource,
isPersonaReadOnly,
} from "@/features/agents/lib/personaPresentation";
@@ -185,14 +187,14 @@ export function PersonaEditor({
],
);
const initials = displayName.charAt(0).toUpperCase() || "?";
const initials = getPersonaInitials(displayName);
// For new personas, use a temporary ID for the avatar upload
const avatarPersonaId = persona?.id ?? "new-persona";
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-lg max-h-[85vh] flex flex-col gap-0 p-0">
<DialogContent width="wide" className="max-h-[85vh] gap-0 p-0">
<DialogHeader className="shrink-0 px-5 py-4">
<DialogTitle className="text-sm">
{detailsMode
@@ -209,188 +211,188 @@ export function PersonaEditor({
</DialogHeader>
{detailsMode ? (
<PersonaDetails
avatar={avatar}
displayName={displayName}
modelLabel={modelLabel}
personaSource={personaSource}
providerLabel={providerLabel}
systemPrompt={systemPrompt}
/>
<DialogBody>
<PersonaDetails
avatar={avatar}
displayName={displayName}
modelLabel={modelLabel}
personaSource={personaSource}
providerLabel={providerLabel}
systemPrompt={systemPrompt}
/>
</DialogBody>
) : (
<form
id="persona-form"
onSubmit={handleSubmit}
className="min-h-0 flex-1 overflow-y-auto space-y-4 px-5 pb-5"
>
<div className="flex justify-center">
{isReadOnly ? (
<AvatarRoot className="h-16 w-16 border border-border">
<AvatarImage
src={avatarSrc ?? undefined}
alt={t("avatar.previewAlt")}
<DialogBody asChild className="space-y-4 px-5 pb-5">
<form id="persona-form" onSubmit={handleSubmit}>
<div className="flex justify-center">
{isReadOnly ? (
<AvatarRoot className="h-16 w-16 border border-border">
<AvatarImage
src={avatarSrc ?? undefined}
alt={t("avatar.previewAlt")}
/>
<AvatarFallback className="text-lg font-semibold">
{initials}
</AvatarFallback>
</AvatarRoot>
) : (
<AvatarDropZone
personaId={avatarPersonaId}
avatar={avatar}
onChange={setAvatar}
disabled={isReadOnly}
/>
<AvatarFallback className="text-lg font-semibold">
{initials}
</AvatarFallback>
</AvatarRoot>
) : (
<AvatarDropZone
personaId={avatarPersonaId}
avatar={avatar}
onChange={setAvatar}
disabled={isReadOnly}
/>
)}
</div>
)}
</div>
<div className="space-y-1">
<Label className="text-xs font-medium text-muted-foreground">
{t("editor.displayName")}{" "}
<span className="text-destructive">*</span>
</Label>
<Input
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
readOnly={isReadOnly}
required
placeholder={t("editor.displayNamePlaceholder")}
className={cn(isReadOnly && "opacity-70 cursor-not-allowed")}
/>
</div>
<div className="space-y-1">
<div className="flex items-center justify-between">
<div className="space-y-1">
<Label className="text-xs font-medium text-muted-foreground">
{t("editor.systemPrompt")}{" "}
{t("editor.displayName")}{" "}
<span className="text-destructive">*</span>
</Label>
<span className="text-[10px] text-muted-foreground">
{t("common:labels.characterCount", {
count: systemPrompt.length,
})}
</span>
<Input
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
readOnly={isReadOnly}
required
placeholder={t("editor.displayNamePlaceholder")}
className={cn(isReadOnly && "opacity-70 cursor-not-allowed")}
/>
</div>
<Textarea
value={systemPrompt}
onChange={(e) => setSystemPrompt(e.target.value)}
readOnly={isReadOnly}
required
rows={6}
placeholder={t("editor.systemPromptPlaceholder")}
className={cn(
"leading-relaxed",
isReadOnly && "opacity-70 cursor-not-allowed",
)}
/>
</div>
<div className="space-y-1">
<Label className="text-xs font-medium text-muted-foreground">
{t("editor.provider")}
</Label>
<Select
value={provider || "__none__"}
onValueChange={(v: string) => {
const nextProvider =
v === "__none__"
? ("" as ProviderType | "")
: (v as ProviderType);
setProvider(nextProvider);
if (nextProvider !== provider) {
setModel("");
}
}}
disabled={isReadOnly}
>
<SelectTrigger
<div className="space-y-1">
<div className="flex items-center justify-between">
<Label className="text-xs font-medium text-muted-foreground">
{t("editor.systemPrompt")}{" "}
<span className="text-destructive">*</span>
</Label>
<span className="text-[10px] text-muted-foreground">
{t("common:labels.characterCount", {
count: systemPrompt.length,
})}
</span>
</div>
<Textarea
value={systemPrompt}
onChange={(e) => setSystemPrompt(e.target.value)}
readOnly={isReadOnly}
required
rows={6}
placeholder={t("editor.systemPromptPlaceholder")}
className={cn(
"w-full",
"leading-relaxed",
isReadOnly && "opacity-70 cursor-not-allowed",
)}
>
<SelectValue placeholder={t("common:labels.none")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">
{t("common:labels.none")}
</SelectItem>
{acpProviders.map((providerOption) => (
<SelectItem
key={providerOption.id}
value={providerOption.id}
>
{providerOption.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
/>
</div>
<div className="space-y-1">
<Label className="text-xs font-medium text-muted-foreground">
{t("editor.model")}
</Label>
<Select
value={modelSelectValue}
onValueChange={(value: string) => {
if (value === "__none__") {
setModel("");
return;
}
if (value.startsWith("__saved__:")) {
setModel(value.slice("__saved__:".length));
return;
}
setModel(value);
}}
disabled={isReadOnly || !provider}
>
<SelectTrigger
className={cn(
"w-full",
isReadOnly && "opacity-70 cursor-not-allowed",
)}
>
<SelectValue
placeholder={
provider
? t("editor.modelPlaceholder")
: t("editor.chooseProviderFirst")
<div className="space-y-1">
<Label className="text-xs font-medium text-muted-foreground">
{t("editor.provider")}
</Label>
<Select
value={provider || "__none__"}
onValueChange={(v: string) => {
const nextProvider =
v === "__none__"
? ("" as ProviderType | "")
: (v as ProviderType);
setProvider(nextProvider);
if (nextProvider !== provider) {
setModel("");
}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">
{t("common:labels.none")}
</SelectItem>
{hasSavedModelOutsideInventory && (
<SelectItem value={`__saved__:${model}`}>
{t("editor.savedModelUnavailable", { model })}
}}
disabled={isReadOnly}
>
<SelectTrigger
className={cn(
"w-full",
isReadOnly && "opacity-70 cursor-not-allowed",
)}
>
<SelectValue placeholder={t("common:labels.none")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">
{t("common:labels.none")}
</SelectItem>
)}
{availableModels.map((modelOption) => (
<SelectItem key={modelOption.id} value={modelOption.id}>
{modelOption.displayName ?? modelOption.name}
{acpProviders.map((providerOption) => (
<SelectItem
key={providerOption.id}
value={providerOption.id}
>
{providerOption.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs font-medium text-muted-foreground">
{t("editor.model")}
</Label>
<Select
value={modelSelectValue}
onValueChange={(value: string) => {
if (value === "__none__") {
setModel("");
return;
}
if (value.startsWith("__saved__:")) {
setModel(value.slice("__saved__:".length));
return;
}
setModel(value);
}}
disabled={isReadOnly || !provider}
>
<SelectTrigger
className={cn(
"w-full",
isReadOnly && "opacity-70 cursor-not-allowed",
)}
>
<SelectValue
placeholder={
provider
? t("editor.modelPlaceholder")
: t("editor.chooseProviderFirst")
}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">
{t("common:labels.none")}
</SelectItem>
))}
</SelectContent>
</Select>
{hasSavedModelOutsideInventory ? (
<p className="text-[11px] text-muted-foreground">
{t("editor.savedModelUnavailableHelp")}
</p>
) : !provider ? (
<p className="text-[11px] text-muted-foreground">
{t("editor.chooseProviderFirst")}
</p>
) : availableModels.length === 0 ? (
<p className="text-[11px] text-muted-foreground">
{modelStatusMessage ?? t("editor.noModelsAvailable")}
</p>
) : null}
</div>
</form>
{hasSavedModelOutsideInventory && (
<SelectItem value={`__saved__:${model}`}>
{t("editor.savedModelUnavailable", { model })}
</SelectItem>
)}
{availableModels.map((modelOption) => (
<SelectItem key={modelOption.id} value={modelOption.id}>
{modelOption.displayName ?? modelOption.name}
</SelectItem>
))}
</SelectContent>
</Select>
{hasSavedModelOutsideInventory ? (
<p className="text-[11px] text-muted-foreground">
{t("editor.savedModelUnavailableHelp")}
</p>
) : !provider ? (
<p className="text-[11px] text-muted-foreground">
{t("editor.chooseProviderFirst")}
</p>
) : availableModels.length === 0 ? (
<p className="text-[11px] text-muted-foreground">
{modelStatusMessage ?? t("editor.noModelsAvailable")}
</p>
) : null}
</div>
</form>
</DialogBody>
)}
<DialogFooter className="shrink-0 border-t px-5 py-4">
@@ -7,6 +7,7 @@ import { Skeleton } from "@/shared/ui/skeleton";
import type { Persona } from "@/shared/types/agents";
import { PersonaCard } from "@/features/agents/ui/PersonaCard";
import { useFileImportZone } from "@/shared/hooks/useFileImportZone";
import { getPersonaSource } from "@/features/agents/lib/personaPresentation";
interface PersonaGalleryProps {
personas: Persona[];
@@ -21,18 +22,28 @@ interface PersonaGalleryProps {
validateImportFile?: (file: Pick<File, "name" | "type">) => string | null;
onImportError?: (message: string) => void;
isLoading?: boolean;
hasAnyPersonas?: boolean;
}
function SkeletonCard() {
return (
<div
aria-hidden="true"
className="flex flex-col items-center gap-3 rounded-xl border border-border p-5"
className="flex flex-col rounded-2xl border border-border-soft bg-background p-5"
>
<Skeleton className="h-12 w-12 rounded-full" />
<Skeleton className="h-4 w-24" />
<Skeleton className="h-3 w-full" />
<Skeleton className="h-3 w-3/4" />
<div className="flex items-start justify-between gap-3">
<Skeleton className="h-12 w-12 rounded-full" />
<Skeleton className="h-6 w-6 rounded-md" />
</div>
<div className="mt-5 min-w-0 space-y-3">
<Skeleton className="h-4 w-28" />
<Skeleton className="h-3 w-full" />
<Skeleton className="h-3 w-5/6" />
</div>
<div aria-hidden="true" className="h-7 shrink-0" />
<div>
<Skeleton className="h-3 w-3/4" />
</div>
</div>
);
}
@@ -50,6 +61,7 @@ export function PersonaGallery({
validateImportFile,
onImportError,
isLoading = false,
hasAnyPersonas = personas.length > 0,
}: PersonaGalleryProps) {
const { t } = useTranslation("agents");
const { fileInputRef, isDragOver, dropHandlers, handleFileChange } =
@@ -58,22 +70,27 @@ export function PersonaGallery({
validateFile: validateImportFile,
onImportError,
});
const sorted = useMemo(() => {
const builtins = personas
.filter((p) => p.isBuiltin)
.sort((a, b) => a.displayName.localeCompare(b.displayName));
const custom = personas
.filter((p) => !p.isBuiltin)
.sort((a, b) => a.displayName.localeCompare(b.displayName));
return [...builtins, ...custom];
}, [personas]);
const sortedPersonas = useMemo(
() =>
[...personas].sort((a, b) => {
const aFeatured = getPersonaSource(a) === "builtin";
const bFeatured = getPersonaSource(b) === "builtin";
if (aFeatured !== bFeatured) {
return aFeatured ? -1 : 1;
}
return a.displayName.localeCompare(b.displayName);
}),
[personas],
);
if (isLoading) {
return (
<div
role="status"
aria-label={t("gallery.loading")}
className="grid grid-cols-2 gap-4 md:grid-cols-3 xl:grid-cols-4"
className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3"
>
<SkeletonCard />
<SkeletonCard />
@@ -83,9 +100,50 @@ export function PersonaGallery({
);
}
if (personas.length === 0) {
return (
<div
{...dropHandlers}
className={cn(
"flex min-h-72 flex-col items-center justify-center rounded-2xl border border-dashed border-border-soft bg-muted/10 px-6 text-center",
isDragOver && "border-border bg-muted/30",
)}
>
<p className="text-sm font-medium text-foreground">
{hasAnyPersonas ? t("gallery.noResults") : t("view.emptyAgentsTitle")}
</p>
<p className="mt-1 max-w-sm text-xs leading-5 text-muted-foreground">
{hasAnyPersonas
? t("gallery.noResultsDescription")
: t("view.emptyAgentsDescription")}
</p>
<div className="mt-5 flex flex-wrap items-center justify-center gap-2">
<Button type="button" size="sm" onClick={onCreatePersona}>
<Plus className="size-3.5" />
{t("gallery.new")}
</Button>
</div>
{onImportFile && (
<>
<p className="mt-3 text-[11px] text-muted-foreground">
{t("gallery.dropFile")}
</p>
<input
ref={fileInputRef}
type="file"
accept=".json,application/json"
className="hidden"
onChange={handleFileChange}
/>
</>
)}
</div>
);
}
return (
<div className="grid grid-cols-2 gap-4 md:grid-cols-3 xl:grid-cols-4">
{sorted.map((persona) => (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
{sortedPersonas.map((persona) => (
<PersonaCard
key={persona.id}
persona={persona}
@@ -98,7 +156,6 @@ export function PersonaGallery({
/>
))}
{/* Create new card */}
<Button
type="button"
variant="ghost"
@@ -106,15 +163,15 @@ export function PersonaGallery({
aria-label={t("gallery.createAria")}
{...dropHandlers}
className={cn(
"flex h-auto flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed p-5",
"text-muted-foreground",
"hover:border-border hover:text-muted-foreground hover:bg-accent/50",
"flex min-h-48 w-full flex-col items-center justify-center gap-2 rounded-2xl border border-dashed p-5",
"text-muted-foreground transition-colors",
"hover:border-border hover:text-foreground hover:bg-muted/20",
isDragOver
? "border-border bg-muted/50 text-muted-foreground"
: "border-border",
: "border-border-soft",
)}
>
<Plus className="size-8" />
<Plus className="size-6" />
<span className="text-sm font-medium">{t("gallery.new")}</span>
{onImportFile && (
<span className="text-[11px] text-muted-foreground">
@@ -22,21 +22,37 @@ describe("PersonaCard", () => {
expect(screen.getByText("Coder")).toBeInTheDocument();
});
it("shows built-in badge", () => {
it("shows featured badge for built-in personas", () => {
render(<PersonaCard persona={makePersona({ isBuiltin: true })} />);
expect(screen.getByText("Built-in")).toBeInTheDocument();
expect(screen.getByText("Featured")).toBeInTheDocument();
});
it("does not show built-in badge for custom personas", () => {
it("does not show featured badge for custom personas", () => {
render(<PersonaCard persona={makePersona({ isBuiltin: false })} />);
expect(screen.queryByText("Built-in")).not.toBeInTheDocument();
expect(screen.queryByText("Featured")).not.toBeInTheDocument();
});
it("shows avatar with initial", () => {
it("shows avatar with one initial for single-word names", () => {
render(<PersonaCard persona={makePersona({ displayName: "Alpha" })} />);
expect(screen.getByText("A")).toBeInTheDocument();
});
it("shows avatar with two initials for multi-word names", () => {
render(
<PersonaCard persona={makePersona({ displayName: "Code Reviewer" })} />,
);
expect(screen.getByText("CR")).toBeInTheDocument();
});
it("skips punctuation when building initials", () => {
render(
<PersonaCard
persona={makePersona({ displayName: "404Portfolio (Copy)" })}
/>,
);
expect(screen.getByText("4C")).toBeInTheDocument();
});
it("shows system prompt preview", () => {
render(
<PersonaCard
@@ -6,6 +6,7 @@ import { Button } from "@/shared/ui/button";
import { Checkbox } from "@/shared/ui/checkbox";
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
@@ -207,197 +208,195 @@ export function WorkspaceCreateDialog({
</DialogDescription>
</DialogHeader>
<form
id="workspace-create-form"
onSubmit={handleSubmit}
className="space-y-4 px-5 pb-5"
>
{mode === "branch" ? (
<>
<div className="space-y-1.5">
<Label
htmlFor="workspace-branch-name"
className="text-xs font-medium text-muted-foreground"
>
{t("contextPanel.createDialog.branchName")}
</Label>
<Input
id="workspace-branch-name"
value={branchName}
onChange={(event) => {
setBranchName(event.target.value);
setError(null);
}}
placeholder={t(
"contextPanel.createDialog.branchNamePlaceholder",
)}
/>
</div>
<DialogBody asChild className="space-y-4 px-5 pb-5">
<form id="workspace-create-form" onSubmit={handleSubmit}>
{mode === "branch" ? (
<>
<div className="space-y-1.5">
<Label
htmlFor="workspace-branch-name"
className="text-xs font-medium text-muted-foreground"
>
{t("contextPanel.createDialog.branchName")}
</Label>
<Input
id="workspace-branch-name"
value={branchName}
onChange={(event) => {
setBranchName(event.target.value);
setError(null);
}}
placeholder={t(
"contextPanel.createDialog.branchNamePlaceholder",
)}
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs font-medium text-muted-foreground">
{t("contextPanel.createDialog.baseBranch")}
</Label>
<Select value={baseBranch} onValueChange={setBaseBranch}>
<SelectTrigger className="w-full">
<SelectValue
placeholder={t("contextPanel.createDialog.baseBranch")}
/>
</SelectTrigger>
<SelectContent>
{gitState.localBranches.map((branch) => (
<SelectItem key={branch} value={branch}>
{branch}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</>
) : null}
<div className="space-y-1.5">
<Label className="text-xs font-medium text-muted-foreground">
{t("contextPanel.createDialog.baseBranch")}
</Label>
<Select value={baseBranch} onValueChange={setBaseBranch}>
<SelectTrigger className="w-full">
<SelectValue
placeholder={t("contextPanel.createDialog.baseBranch")}
/>
</SelectTrigger>
<SelectContent>
{gitState.localBranches.map((branch) => (
<SelectItem key={branch} value={branch}>
{branch}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</>
) : null}
{mode === "worktree" ? (
<>
<div className="space-y-1.5">
<Label
htmlFor="workspace-worktree-name"
className="text-xs font-medium text-muted-foreground"
>
{t("contextPanel.createDialog.worktreeName")}
</Label>
<Input
id="workspace-worktree-name"
value={worktreeName}
onChange={(event) => {
const nextWorktreeName = event.target.value;
setWorktreeName(nextWorktreeName);
if (useNewBranch && !branchNameManuallyEdited) {
setBranchName(nextWorktreeName);
}
setError(null);
}}
placeholder={t(
"contextPanel.createDialog.worktreeNamePlaceholder",
)}
/>
{previewPath ? (
<p className="text-xxs text-muted-foreground">
{t("contextPanel.createDialog.worktreePath", {
path: previewPath,
})}
</p>
) : null}
</div>
{availableExistingBranches.length > 0 ? (
<div className="flex items-center gap-2">
<Checkbox
id="workspace-create-new-branch"
checked={useNewBranch}
onCheckedChange={(checked) => {
const nextUseNewBranch = checked === true;
setUseNewBranch(nextUseNewBranch);
if (nextUseNewBranch && !branchNameManuallyEdited) {
setBranchName(worktreeName);
{mode === "worktree" ? (
<>
<div className="space-y-1.5">
<Label
htmlFor="workspace-worktree-name"
className="text-xs font-medium text-muted-foreground"
>
{t("contextPanel.createDialog.worktreeName")}
</Label>
<Input
id="workspace-worktree-name"
value={worktreeName}
onChange={(event) => {
const nextWorktreeName = event.target.value;
setWorktreeName(nextWorktreeName);
if (useNewBranch && !branchNameManuallyEdited) {
setBranchName(nextWorktreeName);
}
setError(null);
}}
placeholder={t(
"contextPanel.createDialog.worktreeNamePlaceholder",
)}
/>
<Label
htmlFor="workspace-create-new-branch"
className="cursor-pointer text-xs font-medium text-muted-foreground"
>
{t("contextPanel.createDialog.createNewBranch")}
</Label>
{previewPath ? (
<p className="text-xxs text-muted-foreground">
{t("contextPanel.createDialog.worktreePath", {
path: previewPath,
})}
</p>
) : null}
</div>
) : null}
{useNewBranch ? (
<>
<div className="space-y-1.5">
<Label
htmlFor="workspace-worktree-branch-name"
className="text-xs font-medium text-muted-foreground"
>
{t("contextPanel.createDialog.branchName")}
</Label>
<Input
id="workspace-worktree-branch-name"
value={branchName}
onChange={(event) => {
setBranchNameManuallyEdited(true);
setBranchName(event.target.value);
{availableExistingBranches.length > 0 ? (
<div className="flex items-center gap-2">
<Checkbox
id="workspace-create-new-branch"
checked={useNewBranch}
onCheckedChange={(checked) => {
const nextUseNewBranch = checked === true;
setUseNewBranch(nextUseNewBranch);
if (nextUseNewBranch && !branchNameManuallyEdited) {
setBranchName(worktreeName);
}
setError(null);
}}
placeholder={t(
"contextPanel.createDialog.branchNamePlaceholder",
)}
/>
<Label
htmlFor="workspace-create-new-branch"
className="cursor-pointer text-xs font-medium text-muted-foreground"
>
{t("contextPanel.createDialog.createNewBranch")}
</Label>
</div>
) : null}
{useNewBranch ? (
<>
<div className="space-y-1.5">
<Label
htmlFor="workspace-worktree-branch-name"
className="text-xs font-medium text-muted-foreground"
>
{t("contextPanel.createDialog.branchName")}
</Label>
<Input
id="workspace-worktree-branch-name"
value={branchName}
onChange={(event) => {
setBranchNameManuallyEdited(true);
setBranchName(event.target.value);
setError(null);
}}
placeholder={t(
"contextPanel.createDialog.branchNamePlaceholder",
)}
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs font-medium text-muted-foreground">
{t("contextPanel.createDialog.baseBranch")}
</Label>
<Select value={baseBranch} onValueChange={setBaseBranch}>
<SelectTrigger className="w-full">
<SelectValue
placeholder={t(
"contextPanel.createDialog.baseBranch",
)}
/>
</SelectTrigger>
<SelectContent>
{gitState.localBranches.map((branch) => (
<SelectItem key={branch} value={branch}>
{branch}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</>
) : (
<div className="space-y-1.5">
<Label className="text-xs font-medium text-muted-foreground">
{t("contextPanel.createDialog.baseBranch")}
{t("contextPanel.createDialog.branchToOpen")}
</Label>
<Select value={baseBranch} onValueChange={setBaseBranch}>
<Select
value={existingBranch || UNSET_SELECT_VALUE}
onValueChange={(value) =>
setExistingBranch(
value === UNSET_SELECT_VALUE ? "" : value,
)
}
>
<SelectTrigger className="w-full">
<SelectValue
placeholder={t(
"contextPanel.createDialog.baseBranch",
"contextPanel.createDialog.branchToOpen",
)}
/>
</SelectTrigger>
<SelectContent>
{gitState.localBranches.map((branch) => (
<SelectItem key={branch} value={branch}>
{branch}
{availableExistingBranches.length > 0 ? (
availableExistingBranches.map((branch) => (
<SelectItem key={branch} value={branch}>
{branch}
</SelectItem>
))
) : (
<SelectItem disabled value={UNSET_SELECT_VALUE}>
{t("contextPanel.createDialog.noAvailableBranches")}
</SelectItem>
))}
)}
</SelectContent>
</Select>
</div>
</>
) : (
<div className="space-y-1.5">
<Label className="text-xs font-medium text-muted-foreground">
{t("contextPanel.createDialog.branchToOpen")}
</Label>
<Select
value={existingBranch || UNSET_SELECT_VALUE}
onValueChange={(value) =>
setExistingBranch(
value === UNSET_SELECT_VALUE ? "" : value,
)
}
>
<SelectTrigger className="w-full">
<SelectValue
placeholder={t(
"contextPanel.createDialog.branchToOpen",
)}
/>
</SelectTrigger>
<SelectContent>
{availableExistingBranches.length > 0 ? (
availableExistingBranches.map((branch) => (
<SelectItem key={branch} value={branch}>
{branch}
</SelectItem>
))
) : (
<SelectItem disabled value={UNSET_SELECT_VALUE}>
{t("contextPanel.createDialog.noAvailableBranches")}
</SelectItem>
)}
</SelectContent>
</Select>
</div>
)}
</>
) : null}
)}
</>
) : null}
{error ? <p className="text-xs text-destructive">{error}</p> : null}
</form>
{error ? <p className="text-xs text-destructive">{error}</p> : null}
</form>
</DialogBody>
<DialogFooter className="border-t px-5 py-4">
<Button
@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
import { IconPlus, IconTrash } from "@tabler/icons-react";
import {
Dialog,
DialogBody,
DialogContent,
DialogFooter,
DialogHeader,
@@ -83,7 +84,7 @@ export function ExtensionModal({
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<DialogBody className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="ext-name">{t("extensions.fields.name")}</Label>
<Input
@@ -224,7 +225,7 @@ export function ExtensionModal({
</Button>
</div>
</div>
</div>
</DialogBody>
<DialogFooter>
{isEdit && onDelete && (
@@ -8,6 +8,7 @@ import { Input } from "@/shared/ui/input";
import { Label } from "@/shared/ui/label";
import {
Dialog,
DialogBody,
DialogContent,
DialogFooter,
DialogHeader,
@@ -224,109 +225,107 @@ export function CreateProjectDialog({
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && handleClose()}>
<DialogContent className="max-w-lg max-h-[85vh] flex flex-col gap-0 p-0">
<DialogContent className="max-w-lg max-h-[85vh] gap-0 p-0">
<DialogHeader className="shrink-0 px-5 py-4">
<DialogTitle className="text-sm">
{isEditing ? t("dialog.editTitle") : t("dialog.newTitle")}
</DialogTitle>
</DialogHeader>
<form
id="project-form"
onSubmit={handleSave}
className="min-h-0 flex-1 overflow-y-auto space-y-4 px-5 pb-5"
>
{/* Name */}
<div className="space-y-1">
<Label className="text-xs font-medium text-muted-foreground">
{t("dialog.name")} <span className="text-destructive">*</span>
</Label>
<Input
value={name}
onChange={(e) => {
setName(e.target.value);
setError(null);
}}
placeholder={t("dialog.namePlaceholder")}
<DialogBody asChild className="space-y-4 px-5 pb-5">
<form id="project-form" onSubmit={handleSave}>
{/* Name */}
<div className="space-y-1">
<Label className="text-xs font-medium text-muted-foreground">
{t("dialog.name")} <span className="text-destructive">*</span>
</Label>
<Input
value={name}
onChange={(e) => {
setName(e.target.value);
setError(null);
}}
placeholder={t("dialog.namePlaceholder")}
/>
</div>
<div className="space-y-1">
<Label className="text-xs font-medium text-muted-foreground">
{t("dialog.instructions")}
</Label>
<PromptEditor
value={prompt}
onChange={setPrompt}
ariaLabel={t("dialog.instructions")}
placeholder={t("dialog.instructionsPlaceholder")}
/>
<Button
type="button"
variant="outline"
size="xs"
onClick={handleAddDirectory}
className="mt-1.5"
>
<IconFolderOpen className="size-3.5" />
{t("dialog.addDirectory")}
</Button>
</div>
<ProjectIconPicker
icon={icon}
iconCandidates={iconCandidates}
iconScanPending={iconScanPending}
error={iconError}
onChooseIcon={chooseIcon}
onChooseCustomIcon={handleChooseCustomIcon}
/>
</div>
<div className="space-y-1">
<Label className="text-xs font-medium text-muted-foreground">
{t("dialog.instructions")}
</Label>
<PromptEditor
value={prompt}
onChange={setPrompt}
ariaLabel={t("dialog.instructions")}
placeholder={t("dialog.instructionsPlaceholder")}
/>
<Button
type="button"
variant="outline"
size="xs"
onClick={handleAddDirectory}
className="mt-1.5"
>
<IconFolderOpen className="size-3.5" />
{t("dialog.addDirectory")}
</Button>
</div>
<ProjectIconPicker
icon={icon}
iconCandidates={iconCandidates}
iconScanPending={iconScanPending}
error={iconError}
onChooseIcon={chooseIcon}
onChooseCustomIcon={handleChooseCustomIcon}
/>
{/* Provider */}
<div className="space-y-1.5">
<Label className="text-xs font-medium text-muted-foreground">
{t("dialog.provider")}
</Label>
<Select
value={preferredProvider ?? "__none__"}
onValueChange={(v) =>
setPreferredProvider(v === "__none__" ? null : v)
}
>
<SelectTrigger className="w-full">
<SelectValue placeholder={t("dialog.noneUseDefault")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">
{t("dialog.noneUseDefault")}
</SelectItem>
{acpProviders.map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.label}
{/* Provider */}
<div className="space-y-1.5">
<Label className="text-xs font-medium text-muted-foreground">
{t("dialog.provider")}
</Label>
<Select
value={preferredProvider ?? "__none__"}
onValueChange={(v) =>
setPreferredProvider(v === "__none__" ? null : v)
}
>
<SelectTrigger className="w-full">
<SelectValue placeholder={t("dialog.noneUseDefault")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">
{t("dialog.noneUseDefault")}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{acpProviders.map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Use Worktrees */}
<div className="flex items-center gap-2">
<Checkbox
id="use-worktrees"
checked={useWorktrees}
onCheckedChange={(checked) => setUseWorktrees(checked === true)}
/>
<Label
htmlFor="use-worktrees"
className="text-xs font-medium text-muted-foreground cursor-pointer"
>
{t("dialog.useWorktrees")}
</Label>
</div>
{/* Use Worktrees */}
<div className="flex items-center gap-2">
<Checkbox
id="use-worktrees"
checked={useWorktrees}
onCheckedChange={(checked) => setUseWorktrees(checked === true)}
/>
<Label
htmlFor="use-worktrees"
className="text-xs font-medium text-muted-foreground cursor-pointer"
>
{t("dialog.useWorktrees")}
</Label>
</div>
{/* Error */}
{error && <p className="text-xs text-destructive">{error}</p>}
</form>
{/* Error */}
{error && <p className="text-xs text-destructive">{error}</p>}
</form>
</DialogBody>
<DialogFooter className="shrink-0 border-t px-5 py-4">
<Button
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogHeader,
@@ -287,7 +288,7 @@ export function CustomProviderDialog({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[min(760px,calc(100vh-2rem))] overflow-y-auto sm:max-w-2xl">
<DialogContent className="max-h-[min(760px,calc(100vh-2rem))] sm:max-w-2xl">
<DialogHeader>
<DialogTitle>
{mode === "edit"
@@ -299,7 +300,7 @@ export function CustomProviderDialog({
</DialogDescription>
</DialogHeader>
{renderContent()}
<DialogBody>{renderContent()}</DialogBody>
</DialogContent>
</Dialog>
);
@@ -6,6 +6,7 @@ import { Label } from "@/shared/ui/label";
import { Textarea } from "@/shared/ui/textarea";
import {
Dialog,
DialogBody,
DialogContent,
DialogFooter,
DialogHeader,
@@ -104,75 +105,73 @@ export function SkillEditor({
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && handleClose()}>
<DialogContent className="max-w-lg max-h-[85vh] flex flex-col gap-0 p-0">
<DialogContent className="max-w-lg max-h-[85vh] gap-0 p-0">
<DialogHeader className="shrink-0 px-5 py-4">
<DialogTitle className="text-sm">
{isEditing ? t("dialog.editTitle") : t("dialog.newTitle")}
</DialogTitle>
</DialogHeader>
<form
id="skill-form"
onSubmit={handleSave}
className="min-h-0 flex-1 overflow-y-auto space-y-4 px-5 pb-5"
>
{/* Name */}
<div className="space-y-1">
<Label className="text-xs font-medium text-muted-foreground">
{t("dialog.name")} <span className="text-destructive">*</span>
</Label>
<Input
value={name}
onChange={(e) => handleNameChange(e.target.value)}
placeholder={t("dialog.namePlaceholder")}
/>
{name.length > 0 && !nameValid && (
<p className="text-xs text-destructive">
{t("dialog.nameValidation")}
<DialogBody asChild className="space-y-4 px-5 pb-5">
<form id="skill-form" onSubmit={handleSave}>
{/* Name */}
<div className="space-y-1">
<Label className="text-xs font-medium text-muted-foreground">
{t("dialog.name")} <span className="text-destructive">*</span>
</Label>
<Input
value={name}
onChange={(e) => handleNameChange(e.target.value)}
placeholder={t("dialog.namePlaceholder")}
/>
{name.length > 0 && !nameValid && (
<p className="text-xs text-destructive">
{t("dialog.nameValidation")}
</p>
)}
</div>
{/* Description */}
<div className="space-y-1">
<Label className="text-xs font-medium text-muted-foreground">
{t("dialog.description")}{" "}
<span className="text-destructive">*</span>
</Label>
<Input
value={description}
onChange={(e) => {
setDescription(e.target.value);
setError(null);
}}
placeholder={t("dialog.descriptionPlaceholder")}
/>
</div>
{isEditing && editingSkill ? (
<p className="-mt-2 break-all text-[11px] text-muted-foreground">
{t("dialog.pathOnDisk")}:{" "}
{getRenamedSkillFileLocation(editingSkill.fileLocation, name)}
</p>
)}
</div>
) : null}
{/* Description */}
<div className="space-y-1">
<Label className="text-xs font-medium text-muted-foreground">
{t("dialog.description")}{" "}
<span className="text-destructive">*</span>
</Label>
<Input
value={description}
onChange={(e) => {
setDescription(e.target.value);
setError(null);
}}
placeholder={t("dialog.descriptionPlaceholder")}
/>
</div>
{/* Instructions */}
<div className="space-y-1">
<Label className="text-xs font-medium text-muted-foreground">
{t("dialog.instructions")}
</Label>
<Textarea
value={instructions}
onChange={(e) => setInstructions(e.target.value)}
rows={10}
placeholder={t("dialog.instructionsPlaceholder")}
className="text-xs font-mono leading-relaxed"
/>
</div>
{isEditing && editingSkill ? (
<p className="-mt-2 break-all text-[11px] text-muted-foreground">
{t("dialog.pathOnDisk")}:{" "}
{getRenamedSkillFileLocation(editingSkill.fileLocation, name)}
</p>
) : null}
{/* Instructions */}
<div className="space-y-1">
<Label className="text-xs font-medium text-muted-foreground">
{t("dialog.instructions")}
</Label>
<Textarea
value={instructions}
onChange={(e) => setInstructions(e.target.value)}
rows={10}
placeholder={t("dialog.instructionsPlaceholder")}
className="text-xs font-mono leading-relaxed"
/>
</div>
{/* Error */}
{error && <p className="text-xs text-destructive">{error}</p>}
</form>
{/* Error */}
{error && <p className="text-xs text-destructive">{error}</p>}
</form>
</DialogBody>
<DialogFooter className="shrink-0 border-t px-5 py-4">
<Button
@@ -11,6 +11,8 @@
},
"card": {
"ariaLabel": "Agent: {{name}}",
"custom": "Custom",
"featured": "Featured",
"fileBacked": "File-backed",
"options": "Agent options"
},
@@ -57,7 +59,9 @@
"createAria": "Create new agent",
"dropFile": "or drop a file",
"loading": "Loading agents",
"new": "New Agent"
"new": "New Agent",
"noResults": "No agents found",
"noResultsDescription": "Try a different name, prompt, provider, or model."
},
"statuses": {
"error": "Error",
@@ -67,6 +71,8 @@
},
"view": {
"copyName": "{{name}} (Copy)",
"backToAgents": "Back to agents",
"created": "Created",
"deleteFailed": "Failed to delete agent.",
"deleteDescription": "This agent and its configuration will be permanently removed.",
"deleteTitle": "Delete \"{{name}}\" permanently?",
@@ -81,9 +87,12 @@
"importFailed": "Failed to import agent.",
"imported_one": "Imported {{count}} agent.",
"imported_other": "Imported {{count}} agents.",
"more": "More",
"newPersona": "New Agent",
"optionsAria": "Options for {{name}}",
"searchPlaceholder": "Search agents...",
"title": "Agents"
"source": "Source",
"title": "Agents",
"updated": "Updated"
}
}
@@ -11,6 +11,8 @@
},
"card": {
"ariaLabel": "Agente: {{name}}",
"custom": "Personalizado",
"featured": "Destacado",
"fileBacked": "Desde archivo",
"options": "Opciones del agente"
},
@@ -57,7 +59,9 @@
"createAria": "Crear nuevo agente",
"dropFile": "o suelta un archivo",
"loading": "Cargando agentes",
"new": "Nuevo agente"
"new": "Nuevo agente",
"noResults": "No se encontraron agentes",
"noResultsDescription": "Prueba con otro nombre, prompt, proveedor o modelo."
},
"statuses": {
"error": "Error",
@@ -67,6 +71,8 @@
},
"view": {
"copyName": "{{name}} (Copia)",
"backToAgents": "Volver a agentes",
"created": "Creado",
"deleteFailed": "No se pudo eliminar el agente.",
"deleteDescription": "Este agente y su configuración se eliminarán de forma permanente.",
"deleteTitle": "¿Eliminar \"{{name}}\" de forma permanente?",
@@ -81,9 +87,12 @@
"importFailed": "No se pudo importar el agente.",
"imported_one": "Se importó {{count}} agente.",
"imported_other": "Se importaron {{count}} agentes.",
"more": "Más",
"newPersona": "Nuevo agente",
"optionsAria": "Opciones de {{name}}",
"searchPlaceholder": "Buscar agentes...",
"title": "Agentes"
"source": "Origen",
"title": "Agentes",
"updated": "Actualizado"
}
}
+2
View File
@@ -15,6 +15,8 @@ const badgeVariants = cva(
"border-transparent bg-muted text-foreground [a&]:hover:bg-muted/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
featured:
"border-transparent bg-background-inverse text-text-inverse [a&]:hover:bg-background-inverse/90",
outline:
"text-foreground [a&]:hover:bg-muted [a&]:hover:text-muted-foreground",
},
+57 -2
View File
@@ -1,9 +1,17 @@
import type * as React from "react";
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { Slot } from "@radix-ui/react-slot";
import { XIcon } from "lucide-react";
import { cn } from "@/shared/lib/cn";
type DialogWidth = "compact" | "wide";
const dialogWidthClassName: Record<DialogWidth, string> = {
compact: "max-w-lg",
wide: "max-w-2xl",
};
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
@@ -28,6 +36,26 @@ function DialogClose({
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}
function hasDialogBody(children: React.ReactNode): boolean {
return React.Children.toArray(children).some((child) => {
if (!React.isValidElement(child)) {
return false;
}
if (child.type === DialogBody) {
return true;
}
if (child.type === React.Fragment) {
return hasDialogBody(
(child.props as { children?: React.ReactNode }).children,
);
}
return false;
});
}
function DialogOverlay({
className,
...props
@@ -50,12 +78,16 @@ function DialogContent({
overlayClassName,
positionerClassName,
showCloseButton = true,
width = "compact",
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
overlayClassName?: string;
positionerClassName?: string;
showCloseButton?: boolean;
width?: DialogWidth;
}) {
const hasScrollableBody = hasDialogBody(children);
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay className={overlayClassName} />
@@ -69,7 +101,11 @@ function DialogContent({
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"pointer-events-auto relative grid max-h-[calc(100dvh-2rem)] w-full max-w-lg gap-4 overflow-y-auto rounded-modal border bg-background p-6 shadow-modal data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
"pointer-events-auto relative max-h-[calc(100dvh-2rem)] w-full gap-4 rounded-modal border bg-background p-6 shadow-modal data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
dialogWidthClassName[width],
hasScrollableBody
? "flex flex-col overflow-hidden"
: "grid overflow-y-auto",
className,
)}
{...props}
@@ -87,6 +123,24 @@ function DialogContent({
);
}
function DialogBody({
className,
asChild = false,
...props
}: React.ComponentProps<"div"> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot : "div";
return (
<Comp
data-slot="dialog-body"
className={cn("min-h-0 flex-1 overflow-y-auto", className)}
{...props}
/>
);
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
@@ -141,6 +195,7 @@ function DialogDescription({
export {
Dialog,
DialogBody,
DialogClose,
DialogContent,
DialogDescription,