mirror of
https://github.com/aaif-goose/goose.git
synced 2026-07-03 14:10:03 +02:00
feat(goose2): Alex redesign — agents page (cards, topbar actions, fade)
Commit 4 of 5. See spec at docs/superpowers/specs/2026-04-23-alex-redesign-design.md. Scope expansion at visual review (2026-04-27): same pattern as Skills. AgentsView's inline page header / SearchBar / button row were removed; "Import" + "New Persona" actions moved to TopBar via the existing TopBarActionsProvider context. The dashed "Create new" card in PersonaGallery was removed. What landed: - PersonaCard: full body replacement. Tall cutout figure (220px, shared single asset across all personas per spec §7.3), 1px rule (bg-black/30 — readable mid-grey, matches Alex's reference), name pill, description with line-clamp-6 rendering persona.systemPrompt (data model has no `description` field; original card already used systemPrompt for the body paragraph). Hover-only kebab menu uses opacity + focus-within so it stays keyboard-tabbable. Active state is bg-black/[0.03] tint instead of ring-1 ring-ring. - PersonaGallery: passive grid renderer. useFileImportZone lifted out; props for dropHandlers + isDragOver. Grid bumped to minmax(260px,1fr) with gap-x-12 gap-y-16 for editorial spacing matching Alex's Figma (figures sit as islands, not clustered). Section wrapper rings up when isDragOver. - AgentsView: lifted useFileImportZone, pushed Import + New Persona actions to TopBar via useSetTopBarActions on mount, removed inline header / search / button row, bumped grid container to max-w-7xl, added bottom fade gradient (same soft mask + backdrop blur as Skills page). - StatusBar: bg-background/80 → bg-transparent (global fix — the white opaque footer fill was visible on every page). - Spec §7 + plan Tasks 4.2/4.3 updated to capture corrections (description→systemPrompt, optionsAria→options, stopPropagation preserved, accessibility opacity fix) and the TopBar move scope expansion. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1843,6 +1843,28 @@ Task 4.2 imports `src/assets/agents/figure.png` directly.
|
||||
|
||||
### Task 4.2: Replace `PersonaCard` body render
|
||||
|
||||
**Corrections applied during implementation (2026-04-27):**
|
||||
- Plan called for `persona.description`; the actual data model has
|
||||
`persona.systemPrompt` only. Used `systemPrompt` with `line-clamp-2`
|
||||
to keep long prompts from breaking layout. Original card already
|
||||
rendered this field as the body paragraph.
|
||||
- Plan referenced `t("card.optionsAria", { name })` — that key does
|
||||
not exist in `agents.json`. Kept existing `t("card.options")` aria
|
||||
label; introducing new i18n strings is out of scope for the redesign.
|
||||
- Preserved `onClick={(e) => e.stopPropagation()}` and
|
||||
`onKeyDown={(e) => e.stopPropagation()}` on the kebab `<Button>`
|
||||
even though the plan's snippet omitted them. Without these, clicking
|
||||
the kebab also fires the card's `onSelect` — a real bug.
|
||||
- Kebab visibility: plan said `hidden group-hover:block`; switched to
|
||||
`opacity-0 transition-opacity focus-within:opacity-100
|
||||
group-hover:opacity-100` so keyboard-only users can Tab to the menu.
|
||||
`display: none` removes the element from tab order entirely; opacity
|
||||
preserves it and `focus-within` reveals on focus.
|
||||
|
||||
**Original task content preserved here for reference:**
|
||||
|
||||
### Task 4.2 (original): Replace `PersonaCard` body render
|
||||
|
||||
**Files:**
|
||||
- Modify: `ui/goose2/src/features/agents/ui/PersonaCard.tsx`
|
||||
|
||||
@@ -1967,7 +1989,32 @@ Expected: baseline 4 errors only.
|
||||
|
||||
---
|
||||
|
||||
### Task 4.3: Adjust `PersonaGallery` grid
|
||||
### Task 4.3: Adjust `PersonaGallery` grid (+ TopBar move scope expansion 2026-04-27)
|
||||
|
||||
**Scope expansion at visual review:** mirroring Phase 3's Skills move,
|
||||
AgentsView's inline header / SearchBar / button row are removed and
|
||||
"Import" + "New Persona" move to the TopBar via the same
|
||||
`TopBarActionsProvider` context. The dashed-border "Create new" card
|
||||
in PersonaGallery is removed entirely. PersonaGallery becomes a
|
||||
passive grid renderer; AgentsView owns `useFileImportZone()` and
|
||||
passes `dropHandlers` + `isDragOver` down. Grid container
|
||||
`max-w-5xl` → `max-w-7xl`.
|
||||
|
||||
**Files modified beyond the original task:**
|
||||
- `ui/goose2/src/features/agents/ui/AgentsView.tsx`: lift
|
||||
`useFileImportZone`, drop SearchBar + page header + inline button
|
||||
row + `search`/`filteredPersonas` state, add `useEffect` that
|
||||
pushes 2 actions to TopBar, render PersonaGallery with `personas`
|
||||
directly + drop handlers
|
||||
- `ui/goose2/src/features/agents/ui/PersonaGallery.tsx`: drop the
|
||||
Create-new Button + hidden file input + `useFileImportZone` hook
|
||||
+ 4 props (`onCreatePersona`, `onImportFile`, `validateImportFile`,
|
||||
`onImportError`); accept `dropHandlers` + `isDragOver` props;
|
||||
apply drop handlers to the section wrapper
|
||||
|
||||
**Original task content preserved here for reference:**
|
||||
|
||||
### Task 4.3 (original): Adjust `PersonaGallery` grid
|
||||
|
||||
**Files:**
|
||||
- Modify: `ui/goose2/src/features/agents/ui/PersonaGallery.tsx`
|
||||
|
||||
@@ -644,18 +644,21 @@ composer.
|
||||
|
||||
### 7.1 Persona card body — rebuilt
|
||||
|
||||
**File:** `src/features/agents/ui/PersonaCard.tsx` (161 lines — full
|
||||
render replacement; functionality preserved)
|
||||
**File:** `src/features/agents/ui/PersonaCard.tsx` (161 → 130 lines —
|
||||
full render replacement; functionality preserved)
|
||||
|
||||
```
|
||||
[cutout figure] ~108-114px × ~215-226px (portrait)
|
||||
[cutout figure] ~110px × ~220px (portrait)
|
||||
object-fit: contain, no surface
|
||||
───────────── 1px horizontal rule, width ~149px
|
||||
───────────── 1px horizontal rule, width 149px
|
||||
[Name-pill] bg [--surface-button], rounded-full, h-5,
|
||||
14px Cash Sans Regular #19191a, px-6 pb-3
|
||||
Description text 16px Regular, color: [--text-muted],
|
||||
line-height: 20, width 149px,
|
||||
up to ~6 lines before truncation
|
||||
Description text Renders persona.systemPrompt with
|
||||
line-clamp-2; 16px Regular,
|
||||
color: [--text-muted], width 149px
|
||||
(the data model has `systemPrompt` not
|
||||
`description`; original card already
|
||||
rendered this field as the body paragraph)
|
||||
```
|
||||
|
||||
No card surface. No border, no background, no padding on the cell.
|
||||
@@ -665,8 +668,42 @@ Active state (`isActive === true`): subtle background tint on the whole
|
||||
cell container (not the photo) — `bg-black/[0.03]` on a cell with
|
||||
enough padding to show the tint. Replaces the old `ring-1 ring-ring`.
|
||||
|
||||
Menu trigger (edit/duplicate/delete/export): `MoreHorizontal` icon that
|
||||
appears on `group-hover`, positioned near the name pill.
|
||||
Menu trigger (edit/duplicate/delete/export): `MoreVertical` icon
|
||||
positioned `absolute top-2 right-2`. Visibility uses opacity, not
|
||||
`display`, so the menu stays in the DOM tab order:
|
||||
`opacity-0 transition-opacity focus-within:opacity-100
|
||||
group-hover:opacity-100`. This preserves keyboard accessibility — Tab
|
||||
reaches the kebab, focus-within reveals it. The kebab's
|
||||
`<Button>` keeps `onClick={(e) => e.stopPropagation()}` and
|
||||
`onKeyDown={(e) => e.stopPropagation()}` so triggering the menu
|
||||
doesn't also fire the card's `onSelect`.
|
||||
|
||||
Aria label uses the existing `t("card.options")` key (no new i18n
|
||||
strings introduced for the redesign).
|
||||
|
||||
### 7.1.1 Toolbar — moved to TopBar (revised 2026-04-27)
|
||||
|
||||
Mirroring the Skills page (§6.5), at the Phase 4 visual review the
|
||||
inline page-body header ("Agents" + subtitle), SearchBar, and the
|
||||
inline "Import" / "New Persona" buttons were removed. The same
|
||||
TopBarActionsProvider context Skills uses now serves Agents:
|
||||
|
||||
- AgentsView calls `useFileImportZone()` directly (lifted up from
|
||||
PersonaGallery), holding `dropHandlers` + `isDragOver` locally
|
||||
- AgentsView pushes a 2-button action set ("Import", "New Persona")
|
||||
to the TopBar slot via `useSetTopBarActions()` on mount; cleared on
|
||||
unmount
|
||||
- PersonaGallery becomes a passive grid renderer that accepts
|
||||
`dropHandlers` + `isDragOver` props; the section wrapper carries the
|
||||
drop handlers so the entire gallery is a drop target with a
|
||||
`ring-2 ring-ring ring-offset-2` highlight when `isDragOver`
|
||||
- The dashed-border "Create new" card that previously sat as the last
|
||||
grid cell is removed entirely; New Persona moves to TopBar
|
||||
- Grid container bumped `max-w-5xl` → `max-w-7xl` so 4+ persona
|
||||
columns fit on wider windows (already matched `auto-fill, minmax`)
|
||||
|
||||
No new i18n strings introduced. Existing keys reused:
|
||||
`common:actions.import`, `view.newPersona`.
|
||||
|
||||
### 7.2 Grid layout
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useState, useMemo, useCallback } from "react";
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import { Plus, Upload } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { SearchBar } from "@/shared/ui/SearchBar";
|
||||
import { Button, buttonVariants } from "@/shared/ui/button";
|
||||
import { useFileImportZone } from "@/shared/hooks/useFileImportZone";
|
||||
import { useSetTopBarActions } from "@/app/contexts/TopBarActionsContext";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -38,7 +39,7 @@ import { getPersonaSource } from "@/features/agents/lib/personaPresentation";
|
||||
|
||||
export function AgentsView() {
|
||||
const { t } = useTranslation(["agents", "common"]);
|
||||
const [search, setSearch] = useState("");
|
||||
const setTopBarActions = useSetTopBarActions();
|
||||
const [deletingPersona, setDeletingPersona] = useState<Persona | null>(null);
|
||||
|
||||
const personas = useAgentStore((s) => s.personas);
|
||||
@@ -56,18 +57,6 @@ export function AgentsView() {
|
||||
refreshFromDisk,
|
||||
} = usePersonas();
|
||||
|
||||
const lowerSearch = search.toLowerCase();
|
||||
|
||||
const filteredPersonas = useMemo(
|
||||
() =>
|
||||
personas.filter(
|
||||
(p) =>
|
||||
p.displayName.toLowerCase().includes(lowerSearch) ||
|
||||
p.systemPrompt.toLowerCase().includes(lowerSearch),
|
||||
),
|
||||
[personas, lowerSearch],
|
||||
);
|
||||
|
||||
const handleSavePersona = useCallback(
|
||||
async (data: CreatePersonaRequest | UpdatePersonaRequest) => {
|
||||
try {
|
||||
@@ -218,66 +207,76 @@ export function AgentsView() {
|
||||
}
|
||||
}, [handleImportFileBytes, t, validateImportFile]);
|
||||
|
||||
const { isDragOver, dropHandlers } = useFileImportZone({
|
||||
onImportFile: handleImportFileBytes,
|
||||
validateFile: validateImportFile,
|
||||
onImportError: handleImportError,
|
||||
});
|
||||
|
||||
const handleNewPersona = useCallback(() => {
|
||||
openPersonaEditor();
|
||||
}, [openPersonaEditor]);
|
||||
|
||||
useEffect(() => {
|
||||
const pillCls =
|
||||
"h-8 rounded-full bg-[var(--surface-button)] px-3 text-[14px] text-black/70 hover:bg-[var(--surface-button)]/80";
|
||||
setTopBarActions(
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className={pillCls}
|
||||
onClick={() => void handleImportPicker()}
|
||||
>
|
||||
<Upload className="mr-2 size-4" />
|
||||
{t("common:actions.import")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className={pillCls}
|
||||
onClick={handleNewPersona}
|
||||
>
|
||||
<Plus className="mr-2 size-4" />
|
||||
{t("view.newPersona")}
|
||||
</Button>
|
||||
</>,
|
||||
);
|
||||
return () => setTopBarActions(null);
|
||||
}, [setTopBarActions, t, handleImportPicker, handleNewPersona]);
|
||||
|
||||
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")}
|
||||
<div className="max-w-7xl mx-auto w-full px-6 py-8 page-transition">
|
||||
<PersonaGallery
|
||||
personas={personas}
|
||||
onSelectPersona={(p) => openPersonaEditor(p, "details")}
|
||||
onEditPersona={(p) => openPersonaEditor(p, "edit")}
|
||||
onDuplicatePersona={handleDuplicatePersona}
|
||||
onDeletePersona={handleDeletePersona}
|
||||
onExportPersona={handleExportPersona}
|
||||
isLoading={personasLoading}
|
||||
dropHandlers={dropHandlers}
|
||||
isDragOver={isDragOver}
|
||||
/>
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* Bottom fade — same soft gradient mask as Skills page */}
|
||||
<div
|
||||
className="pointer-events-none sticky bottom-0 left-0 h-64 w-full"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(to bottom, rgba(222,222,222,0) 0%, var(--canvas) 100%)",
|
||||
backdropFilter: "blur(3px)",
|
||||
WebkitBackdropFilter: "blur(3px)",
|
||||
maskImage:
|
||||
"linear-gradient(to bottom, transparent 0%, rgba(0,0,0,0.4) 50%, black 100%)",
|
||||
WebkitMaskImage:
|
||||
"linear-gradient(to bottom, transparent 0%, rgba(0,0,0,0.4) 50%, black 100%)",
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Persona editor modal */}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MoreVertical, Copy, Pencil, Trash2, Download } from "lucide-react";
|
||||
import figureUrl from "@/assets/agents/figure.png";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { Avatar, AvatarImage, AvatarFallback } from "@/shared/ui/avatar";
|
||||
import { Badge } from "@/shared/ui/badge";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -11,7 +10,6 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} 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";
|
||||
|
||||
@@ -37,14 +35,9 @@ export function PersonaCard({
|
||||
const { t } = useTranslation(["agents", "common"]);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
|
||||
const initials = persona.displayName.charAt(0).toUpperCase();
|
||||
const avatarSrc = useAvatarSrc(persona.avatar);
|
||||
const personaSource = getPersonaSource(persona);
|
||||
const canEditPersona = personaSource === "custom";
|
||||
const canDeletePersona = personaSource !== "builtin";
|
||||
const providerModelLabel = [persona.provider, persona.model]
|
||||
.filter(Boolean)
|
||||
.join(" / ");
|
||||
|
||||
const handleCardKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.target !== event.currentTarget || menuOpen) {
|
||||
@@ -65,14 +58,30 @@ 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 flex-col items-center cursor-pointer px-3 py-4",
|
||||
"transition-colors duration-200",
|
||||
isActive && "bg-black/[0.03]",
|
||||
)}
|
||||
>
|
||||
{/* Dropdown trigger */}
|
||||
<div className="absolute right-2 top-2">
|
||||
{/* Single shared cutout figure — visual placeholder; real per-persona avatars deferred */}
|
||||
<img
|
||||
src={figureUrl}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="h-[220px] w-auto select-none"
|
||||
/>
|
||||
|
||||
<div className="mt-3 h-px w-[149px] bg-black/30" />
|
||||
|
||||
<span className="mt-3 inline-flex h-5 items-center rounded-full bg-[var(--surface-button)] px-[6px] pb-[3px] text-[14px] text-[var(--text-title-alex)]">
|
||||
{persona.displayName}
|
||||
</span>
|
||||
|
||||
<p className="mt-3 line-clamp-6 w-[149px] text-[16px] leading-[20px] text-[var(--text-muted-alex)]">
|
||||
{persona.systemPrompt}
|
||||
</p>
|
||||
|
||||
<div className="absolute top-2 right-2 opacity-0 transition-opacity duration-150 focus-within:opacity-100 group-hover:opacity-100">
|
||||
<DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
@@ -82,12 +91,9 @@ export function PersonaCard({
|
||||
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",
|
||||
)}
|
||||
className="size-6 rounded-md text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<MoreVertical className="size-4" />
|
||||
<MoreVertical className="size-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" sideOffset={4}>
|
||||
@@ -101,10 +107,12 @@ export function PersonaCard({
|
||||
<Copy className="size-3.5" />
|
||||
{t("common:actions.duplicate")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => onExport?.(persona)}>
|
||||
<Download className="size-3.5" />
|
||||
{t("common:actions.export")}
|
||||
</DropdownMenuItem>
|
||||
{onExport && (
|
||||
<DropdownMenuItem onSelect={() => onExport(persona)}>
|
||||
<Download className="size-3.5" />
|
||||
{t("common:actions.export")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{canDeletePersona && (
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
@@ -117,45 +125,6 @@ export function PersonaCard({
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{/* Avatar */}
|
||||
<Avatar className="h-12 w-12">
|
||||
<AvatarImage src={avatarSrc ?? undefined} alt={persona.displayName} />
|
||||
<AvatarFallback className="text-sm font-semibold">
|
||||
{initials}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
{/* Name */}
|
||||
<h3 className="text-sm font-medium text-center leading-tight">
|
||||
{persona.displayName}
|
||||
</h3>
|
||||
|
||||
{/* 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>
|
||||
)}
|
||||
|
||||
{/* System prompt preview */}
|
||||
<p className="text-xs text-muted-foreground text-center line-clamp-2 w-full">
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Plus } from "lucide-react";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
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";
|
||||
|
||||
interface PersonaGalleryProps {
|
||||
personas: Persona[];
|
||||
@@ -16,23 +13,19 @@ interface PersonaGalleryProps {
|
||||
onDuplicatePersona: (persona: Persona) => void;
|
||||
onDeletePersona: (persona: Persona) => void;
|
||||
onExportPersona?: (persona: Persona) => void;
|
||||
onCreatePersona: () => void;
|
||||
onImportFile?: (fileBytes: number[], fileName: string) => void;
|
||||
validateImportFile?: (file: Pick<File, "name" | "type">) => string | null;
|
||||
onImportError?: (message: string) => void;
|
||||
isLoading?: boolean;
|
||||
dropHandlers?: React.HTMLAttributes<HTMLElement>;
|
||||
isDragOver?: boolean;
|
||||
}
|
||||
|
||||
function SkeletonCard() {
|
||||
return (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="flex flex-col items-center gap-3 rounded-xl border border-border 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 aria-hidden="true" className="flex flex-col items-center px-3 py-4">
|
||||
<Skeleton className="h-[220px] w-[110px]" />
|
||||
<Skeleton className="mt-3 h-px w-[149px]" />
|
||||
<Skeleton className="mt-3 h-5 w-20" />
|
||||
<Skeleton className="mt-3 h-4 w-full" />
|
||||
<Skeleton className="mt-1 h-4 w-3/4" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -45,19 +38,11 @@ export function PersonaGallery({
|
||||
onDuplicatePersona,
|
||||
onDeletePersona,
|
||||
onExportPersona,
|
||||
onCreatePersona,
|
||||
onImportFile,
|
||||
validateImportFile,
|
||||
onImportError,
|
||||
isLoading = false,
|
||||
dropHandlers,
|
||||
isDragOver = false,
|
||||
}: PersonaGalleryProps) {
|
||||
const { t } = useTranslation("agents");
|
||||
const { fileInputRef, isDragOver, dropHandlers, handleFileChange } =
|
||||
useFileImportZone({
|
||||
onImportFile: onImportFile ?? (() => {}),
|
||||
validateFile: validateImportFile,
|
||||
onImportError,
|
||||
});
|
||||
const sorted = useMemo(() => {
|
||||
const builtins = personas
|
||||
.filter((p) => p.isBuiltin)
|
||||
@@ -70,21 +55,27 @@ export function PersonaGallery({
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div
|
||||
<section
|
||||
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-[repeat(auto-fill,minmax(260px,1fr))] gap-x-12 gap-y-16 p-8"
|
||||
>
|
||||
<SkeletonCard />
|
||||
<SkeletonCard />
|
||||
<SkeletonCard />
|
||||
<SkeletonCard />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-3 xl:grid-cols-4">
|
||||
<section
|
||||
{...dropHandlers}
|
||||
className={cn(
|
||||
"grid grid-cols-[repeat(auto-fill,minmax(260px,1fr))] gap-x-12 gap-y-16 rounded-tile p-8 transition-colors",
|
||||
isDragOver && "ring-2 ring-ring ring-offset-2",
|
||||
)}
|
||||
>
|
||||
{sorted.map((persona) => (
|
||||
<PersonaCard
|
||||
key={persona.id}
|
||||
@@ -97,40 +88,6 @@ export function PersonaGallery({
|
||||
onExport={onExportPersona}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Create new card */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={onCreatePersona}
|
||||
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",
|
||||
isDragOver
|
||||
? "border-border bg-muted/50 text-muted-foreground"
|
||||
: "border-border",
|
||||
)}
|
||||
>
|
||||
<Plus className="size-8" />
|
||||
<span className="text-sm font-medium">{t("gallery.new")}</span>
|
||||
{onImportFile && (
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{t("gallery.dropFile")}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
{onImportFile && (
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".json,application/json"
|
||||
className="hidden"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ export function StatusBar({
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-6 w-full items-center justify-between",
|
||||
"bg-background/80 px-3 text-xs text-muted-foreground",
|
||||
"bg-transparent px-3 text-xs text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
|
||||
Reference in New Issue
Block a user