mirror of
https://github.com/block/goose.git
synced 2026-07-17 12:56:20 +02:00
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) <noreply@anthropic.com> * test: update SkillsView empty state text assertion Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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 (
|
||||
<div className="flex flex-1 flex-col h-full min-h-0">
|
||||
<div className="flex-1 overflow-y-auto min-h-0">
|
||||
@@ -244,6 +256,7 @@ export function AgentsView() {
|
||||
onDeletePersona={handleDeletePersona}
|
||||
onExportPersona={handleExportPersona}
|
||||
onCreatePersona={() => openPersonaEditor()}
|
||||
onImportFile={handleImportFileBytes}
|
||||
isLoading={personasLoading}
|
||||
/>
|
||||
</section>
|
||||
|
||||
@@ -112,12 +112,12 @@ export function PersonaEditor({
|
||||
<div
|
||||
className={cn(
|
||||
"relative z-10 w-full max-w-lg rounded-xl border border-border bg-background shadow-xl",
|
||||
"max-h-[85vh] overflow-y-auto",
|
||||
"max-h-[85vh] flex flex-col",
|
||||
"motion-safe:animate-in motion-safe:fade-in motion-safe:zoom-in-95",
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-border px-5 py-4">
|
||||
<div className="shrink-0 flex items-center justify-between border-b border-border px-5 py-4">
|
||||
<h2 className="text-sm font-semibold">
|
||||
{isReadOnly
|
||||
? persona?.displayName
|
||||
@@ -135,7 +135,11 @@ export function PersonaEditor({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4 p-5">
|
||||
<form
|
||||
id="persona-form"
|
||||
onSubmit={handleSubmit}
|
||||
className="min-h-0 flex-1 overflow-y-auto space-y-4 p-5"
|
||||
>
|
||||
{/* Avatar preview */}
|
||||
<div className="flex justify-center">
|
||||
{avatarUrl ? (
|
||||
@@ -264,9 +268,11 @@ export function PersonaEditor({
|
||||
)}
|
||||
/>
|
||||
</label>
|
||||
</form>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-end gap-2 pt-2">
|
||||
{/* Footer actions */}
|
||||
<div className="shrink-0 border-t border-border px-5 py-4">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{isReadOnly && onDuplicate && persona ? (
|
||||
<Button
|
||||
type="button"
|
||||
@@ -289,6 +295,7 @@ export function PersonaEditor({
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
form="persona-form"
|
||||
size="sm"
|
||||
disabled={!isValid || isPending}
|
||||
>
|
||||
@@ -301,7 +308,7 @@ export function PersonaEditor({
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
)}
|
||||
>
|
||||
<Plus className="h-8 w-8" />
|
||||
<span className="text-sm font-medium">New Persona</span>
|
||||
{onImportFile && (
|
||||
<span className="text-[11px] text-foreground-secondary/40">
|
||||
or drop a file
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{onImportFile && (
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".persona.json,.json"
|
||||
className="hidden"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -107,12 +107,12 @@ export function CreateSkillDialog({
|
||||
<div
|
||||
className={cn(
|
||||
"relative z-10 w-full max-w-lg rounded-xl border border-border bg-background shadow-xl",
|
||||
"max-h-[85vh] overflow-y-auto",
|
||||
"max-h-[85vh] flex flex-col",
|
||||
"motion-safe:animate-in motion-safe:fade-in motion-safe:zoom-in-95",
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-border px-5 py-4">
|
||||
<div className="shrink-0 flex items-center justify-between border-b border-border px-5 py-4">
|
||||
<h2 className="text-sm font-semibold">
|
||||
{isEditing ? "Edit Skill" : "New Skill"}
|
||||
</h2>
|
||||
@@ -126,7 +126,12 @@ export function CreateSkillDialog({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSave} className="space-y-4 p-5">
|
||||
{/* Scrollable content */}
|
||||
<form
|
||||
id="skill-form"
|
||||
onSubmit={handleSave}
|
||||
className="min-h-0 flex-1 overflow-y-auto space-y-4 p-5"
|
||||
>
|
||||
{/* Name */}
|
||||
<label className="block space-y-1">
|
||||
<span className="text-xs font-medium text-foreground-secondary">
|
||||
@@ -193,29 +198,29 @@ export function CreateSkillDialog({
|
||||
|
||||
{/* Error */}
|
||||
{error && <p className="text-xs text-foreground-danger">{error}</p>}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-end gap-2 pt-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleClose}
|
||||
disabled={saving}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" size="sm" disabled={!canSave}>
|
||||
{saving
|
||||
? isEditing
|
||||
? "Saving..."
|
||||
: "Creating..."
|
||||
: isEditing
|
||||
? "Save Changes"
|
||||
: "Create Skill"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="shrink-0 border-t border-border px-5 py-4 flex items-center justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleClose}
|
||||
disabled={saving}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" form="skill-form" size="sm" disabled={!canSave}>
|
||||
{saving
|
||||
? isEditing
|
||||
? "Saving..."
|
||||
: "Creating..."
|
||||
: isEditing
|
||||
? "Save Changes"
|
||||
: "Create Skill"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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() {
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* New Skill card */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleNewSkill}
|
||||
{...dropHandlers}
|
||||
className={cn(
|
||||
"flex w-full items-center justify-center gap-2 rounded-lg border border-dashed px-4 py-3 transition-colors",
|
||||
isDragOver
|
||||
? "border-ring bg-background-secondary"
|
||||
: "border-border hover:border-foreground-secondary/40 hover:bg-background-secondary/50",
|
||||
)}
|
||||
>
|
||||
<Plus className="h-4 w-4 text-foreground-secondary" />
|
||||
<span className="text-sm text-foreground-secondary">
|
||||
New Skill
|
||||
</span>
|
||||
<span className="text-xs text-foreground-secondary/50 ml-1">
|
||||
or drop a file
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{!loading && filtered.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center gap-3 py-16 text-foreground-secondary">
|
||||
<div
|
||||
{...dropHandlers}
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center gap-3 py-16 text-foreground-secondary rounded-lg border border-dashed transition-colors",
|
||||
isDragOver
|
||||
? "border-ring bg-background-secondary"
|
||||
: "border-transparent",
|
||||
)}
|
||||
>
|
||||
<AtSign className="h-10 w-10 opacity-30" />
|
||||
<div className="text-center">
|
||||
<p className="text-sm font-medium">
|
||||
@@ -334,15 +383,34 @@ export function SkillsView() {
|
||||
</p>
|
||||
<p className="text-xs text-foreground-secondary/60 mt-1">
|
||||
{skills.length === 0
|
||||
? "Skills you add will appear here."
|
||||
? "Create a skill or drop a .skill.json file here."
|
||||
: "Try a different search term."}
|
||||
</p>
|
||||
</div>
|
||||
{skills.length === 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleNewSkill}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-md border border-border hover:bg-background-tertiary transition-colors mt-2"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
New Skill
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hidden file input for drag-and-drop import */}
|
||||
<input
|
||||
ref={dropFileInputRef}
|
||||
type="file"
|
||||
accept=".skill.json,.json"
|
||||
className="hidden"
|
||||
onChange={handleDropFileChange}
|
||||
/>
|
||||
|
||||
{/* Create / Edit dialog */}
|
||||
<CreateSkillDialog
|
||||
isOpen={dialogOpen}
|
||||
|
||||
@@ -63,7 +63,7 @@ describe("SkillsView", () => {
|
||||
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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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 `<input type="file">`.
|
||||
*/
|
||||
export function useFileImportZone({ onImportFile }: FileImportZoneOptions) {
|
||||
const fileInputRef = useRef<HTMLInputElement>(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<HTMLInputElement>) => {
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user