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:
Wes
2026-03-30 15:39:01 -07:00
committed by GitHub
parent a088b17b80
commit 3b8baf07e3
7 changed files with 214 additions and 35 deletions
@@ -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,
};
}