From 3b8baf07e38e59f16574dfe10886baad7e31c8ea Mon Sep 17 00:00:00 2001 From: Wes Date: Mon, 30 Mar 2026 15:39:01 -0700 Subject: [PATCH] fix: persona & skill dialog layouts + drag-and-drop on new cards (#9) * fix: persona & skill dialog layouts + drag-and-drop on new cards Restructure PersonaEditor and CreateSkillDialog to use fixed header/footer with a scrollable middle area so action buttons stay visible. Add useFileImportZone hook and wire drag-and-drop file import onto the "New Persona" and "New Skill" cards. Co-Authored-By: Claude Opus 4.6 (1M context) * test: update SkillsView empty state text assertion Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- .../src/features/agents/ui/AgentsView.tsx | 13 ++++ .../src/features/agents/ui/PersonaEditor.tsx | 19 +++-- .../src/features/agents/ui/PersonaGallery.tsx | 27 ++++++- .../features/skills/ui/CreateSkillDialog.tsx | 55 +++++++------- .../src/features/skills/ui/SkillsView.tsx | 72 ++++++++++++++++++- .../skills/ui/__tests__/SkillsView.test.tsx | 2 +- .../src/shared/hooks/useFileImportZone.ts | 61 ++++++++++++++++ 7 files changed, 214 insertions(+), 35 deletions(-) create mode 100644 ui/goose2/src/shared/hooks/useFileImportZone.ts diff --git a/ui/goose2/src/features/agents/ui/AgentsView.tsx b/ui/goose2/src/features/agents/ui/AgentsView.tsx index 5506a29812..36a9293eb5 100644 --- a/ui/goose2/src/features/agents/ui/AgentsView.tsx +++ b/ui/goose2/src/features/agents/ui/AgentsView.tsx @@ -183,6 +183,18 @@ export function AgentsView() { [refreshFromDisk], ); + const handleImportFileBytes = useCallback( + async (fileBytes: number[], fileName: string) => { + try { + await importPersonas(fileBytes, fileName); + await refreshFromDisk(); + } catch (err) { + console.error("Failed to import persona:", err); + } + }, + [refreshFromDisk], + ); + return (
@@ -244,6 +256,7 @@ export function AgentsView() { onDeletePersona={handleDeletePersona} onExportPersona={handleExportPersona} onCreatePersona={() => openPersonaEditor()} + onImportFile={handleImportFileBytes} isLoading={personasLoading} /> diff --git a/ui/goose2/src/features/agents/ui/PersonaEditor.tsx b/ui/goose2/src/features/agents/ui/PersonaEditor.tsx index 06039664c5..09611b4be8 100644 --- a/ui/goose2/src/features/agents/ui/PersonaEditor.tsx +++ b/ui/goose2/src/features/agents/ui/PersonaEditor.tsx @@ -112,12 +112,12 @@ export function PersonaEditor({
{/* Header */} -
+

{isReadOnly ? persona?.displayName @@ -135,7 +135,11 @@ export function PersonaEditor({

-
+ {/* Avatar preview */}
{avatarUrl ? ( @@ -264,9 +268,11 @@ export function PersonaEditor({ )} /> + - {/* Actions */} -
+ {/* Footer actions */} +
+
{isReadOnly && onDuplicate && persona ? (
- +
); diff --git a/ui/goose2/src/features/agents/ui/PersonaGallery.tsx b/ui/goose2/src/features/agents/ui/PersonaGallery.tsx index f1efaf3d09..09a30cef71 100644 --- a/ui/goose2/src/features/agents/ui/PersonaGallery.tsx +++ b/ui/goose2/src/features/agents/ui/PersonaGallery.tsx @@ -3,6 +3,7 @@ import { Plus } from "lucide-react"; import { cn } from "@/shared/lib/cn"; import type { Persona } from "@/shared/types/agents"; import { PersonaCard } from "@/features/agents/ui/PersonaCard"; +import { useFileImportZone } from "@/shared/hooks/useFileImportZone"; interface PersonaGalleryProps { personas: Persona[]; @@ -13,6 +14,7 @@ interface PersonaGalleryProps { onDeletePersona: (persona: Persona) => void; onExportPersona?: (persona: Persona) => void; onCreatePersona: () => void; + onImportFile?: (fileBytes: number[], fileName: string) => void; isLoading?: boolean; } @@ -39,8 +41,13 @@ export function PersonaGallery({ onDeletePersona, onExportPersona, onCreatePersona, + onImportFile, isLoading = false, }: PersonaGalleryProps) { + const { fileInputRef, isDragOver, dropHandlers, handleFileChange } = + useFileImportZone({ + onImportFile: onImportFile ?? (() => {}), + }); const sorted = useMemo(() => { const builtins = personas .filter((p) => p.isBuiltin) @@ -86,15 +93,33 @@ export function PersonaGallery({ type="button" onClick={onCreatePersona} aria-label="Create new persona" + {...dropHandlers} className={cn( - "flex flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed border-border p-5", + "flex flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed p-5", "text-foreground-secondary/60 transition-colors", "hover:border-border-primary/50 hover:text-foreground-secondary hover:bg-background-secondary/50", + isDragOver + ? "border-border-primary bg-background-secondary/50 text-foreground-secondary" + : "border-border", )} > New Persona + {onImportFile && ( + + or drop a file + + )} + {onImportFile && ( + + )}
); } diff --git a/ui/goose2/src/features/skills/ui/CreateSkillDialog.tsx b/ui/goose2/src/features/skills/ui/CreateSkillDialog.tsx index 76819d6c28..5796d4a3de 100644 --- a/ui/goose2/src/features/skills/ui/CreateSkillDialog.tsx +++ b/ui/goose2/src/features/skills/ui/CreateSkillDialog.tsx @@ -107,12 +107,12 @@ export function CreateSkillDialog({
{/* Header */} -
+

{isEditing ? "Edit Skill" : "New Skill"}

@@ -126,7 +126,12 @@ export function CreateSkillDialog({
-
+ {/* Scrollable content */} + {/* Name */}
); diff --git a/ui/goose2/src/features/skills/ui/SkillsView.tsx b/ui/goose2/src/features/skills/ui/SkillsView.tsx index 0eb0dcdef0..36245b1c96 100644 --- a/ui/goose2/src/features/skills/ui/SkillsView.tsx +++ b/ui/goose2/src/features/skills/ui/SkillsView.tsx @@ -11,6 +11,7 @@ import { } from "lucide-react"; import { cn } from "@/shared/lib/cn"; import { SearchBar } from "@/shared/ui/SearchBar"; +import { useFileImportZone } from "@/shared/hooks/useFileImportZone"; import { CreateSkillDialog } from "./CreateSkillDialog"; import { listSkills, @@ -242,6 +243,25 @@ export function SkillsView() { setDialogOpen(true); }; + const handleDropImport = useCallback( + async (fileBytes: number[], fileName: string) => { + try { + await importSkills(fileBytes, fileName); + await loadSkills(); + } catch (err) { + console.error("Failed to import skill:", err); + } + }, + [loadSkills], + ); + + const { + fileInputRef: dropFileInputRef, + isDragOver, + dropHandlers, + handleFileChange: handleDropFileChange, + } = useFileImportZone({ onImportFile: handleDropImport }); + const filtered = skills.filter( (s) => s.name.toLowerCase().includes(search.toLowerCase()) || @@ -321,12 +341,41 @@ export function SkillsView() { />
))} + + {/* New Skill card */} +
)} {/* Empty state */} {!loading && filtered.length === 0 && ( -
+

@@ -334,15 +383,34 @@ export function SkillsView() {

{skills.length === 0 - ? "Skills you add will appear here." + ? "Create a skill or drop a .skill.json file here." : "Try a different search term."}

+ {skills.length === 0 && ( + + )}
)}
+ {/* Hidden file input for drag-and-drop import */} + + {/* Create / Edit dialog */} { expect(screen.getByText("No skills yet")).toBeInTheDocument(); }); expect( - screen.getByText("Skills you add will appear here."), + screen.getByText("Create a skill or drop a .skill.json file here."), ).toBeInTheDocument(); }); diff --git a/ui/goose2/src/shared/hooks/useFileImportZone.ts b/ui/goose2/src/shared/hooks/useFileImportZone.ts new file mode 100644 index 0000000000..f18945aa90 --- /dev/null +++ b/ui/goose2/src/shared/hooks/useFileImportZone.ts @@ -0,0 +1,61 @@ +import { useCallback, useRef, useState } from "react"; + +interface FileImportZoneOptions { + onImportFile: (fileBytes: number[], fileName: string) => void; +} + +/** + * Shared drag-and-drop + file-picker infrastructure for import zones. + * Returns state, handlers, and a ref for the hidden ``. + */ +export function useFileImportZone({ onImportFile }: FileImportZoneOptions) { + const fileInputRef = useRef(null); + const [isDragOver, setIsDragOver] = useState(false); + + const importFile = useCallback( + async (file: File) => { + const buffer = await file.arrayBuffer(); + const bytes = Array.from(new Uint8Array(buffer)); + onImportFile(bytes, file.name); + }, + [onImportFile], + ); + + const dropHandlers = { + onDragLeave: () => setIsDragOver(false), + onDragOver: (e: React.DragEvent) => { + e.preventDefault(); + setIsDragOver(true); + }, + onDrop: (e: React.DragEvent) => { + e.preventDefault(); + setIsDragOver(false); + const file = e.dataTransfer.files[0]; + if (file) { + void importFile(file); + } + }, + }; + + const handleFileChange = useCallback( + (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + void importFile(file); + e.target.value = ""; + }, + [importFile], + ); + + const openFilePicker = useCallback(() => { + fileInputRef.current?.click(); + }, []); + + return { + fileInputRef, + isDragOver, + dropHandlers, + handleFileChange, + openFilePicker, + }; +}