mirror of
https://github.com/aaif-goose/goose.git
synced 2026-07-03 14:10:03 +02:00
Add dialog body layout and width variants
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user