feat: add delete confirmation dialog and improve accessibility in MemoryPanel and ThreadList components

This commit is contained in:
Xi Zhang
2026-06-03 12:26:18 +01:00
parent a42c44c8f0
commit ba7ba66988
4 changed files with 360 additions and 233 deletions
+28
View File
@@ -0,0 +1,28 @@
<!-- Fill this out before submitting. New features / changes require peer review + manual testing before merge. -->
## What & why
<!-- What does this PR change, and why? -->
## Manual testing
> Must be tested against a real backend (`EvoSci deploy`, port 6174) — a green build is not enough.
- [ ] `npm run lint && npm run format:check && npm run build` all green
- [ ] Started the backend → configured the Deployment URL → chat works
- [ ] The feature(s) touched here were verified to work (Workspace / Skills / Memory / theme / chat …)
- [ ] Significant changes re-tested against the production build (`npm run build && npm start`; dev and standalone behave differently)
### Test steps / screenshots
<!-- How you tested it; attach screenshots or a screen recording. -->
## Checklist
- [ ] Self-tested, no obvious regressions
- [ ] Updated `CLAUDE.md` for any significant architecture / decision changes
- [ ] Requested at least one teammate for **peer review**
---
> Merging ≠ shipping: releases are handled solely by Xi Zhang (EvoScientist pulls the UI via `@latest`).
+86 -16
View File
@@ -138,6 +138,8 @@ export function MemoryPanel() {
const [newName, setNewName] = useState("");
const [newBusy, setNewBusy] = useState(false);
const [newError, setNewError] = useState<string | null>(null);
const [deleteOpen, setDeleteOpen] = useState(false);
const [deleteBusy, setDeleteBusy] = useState(false);
const didAutoSelect = useRef(false);
// Monotonic id so a slow file fetch can't overwrite a newer selection.
@@ -252,13 +254,10 @@ export function MemoryPanel() {
}
};
const remove = async () => {
const confirmDelete = async () => {
if (!selected) return;
if (
!window.confirm(`Delete "${fileNameOf(selected)}"? This can't be undone.`)
)
return;
const reqId = reqRef.current; // detect a file switch during the await
setDeleteBusy(true);
try {
const res = await fetch(
`/api/memory?path=${encodeURIComponent(selected)}`,
@@ -274,11 +273,14 @@ export function MemoryPanel() {
setFile(null);
setEditing(false);
}
setDeleteOpen(false);
load();
} catch (e) {
if (reqId === reqRef.current) {
setFileError(e instanceof Error ? e.message : "Failed to delete.");
}
} finally {
setDeleteBusy(false);
}
};
@@ -359,7 +361,9 @@ export function MemoryPanel() {
setNewError(null);
setNewOpen(true);
}}
className="inline-flex items-center gap-1.5 rounded-md bg-[var(--brand-solid)] px-2.5 py-1.5 text-xs font-medium text-[var(--brand-foreground)] transition-opacity hover:opacity-90"
aria-label="New memory file"
title="New memory file"
className="inline-flex items-center gap-1.5 rounded-md bg-[var(--brand-solid)] px-2.5 py-1.5 text-xs font-medium text-[var(--brand-foreground)] transition-opacity hover:opacity-90 focus-visible:ring-2 focus-visible:ring-ring"
>
<Plus
className="size-3.5"
@@ -429,7 +433,7 @@ export function MemoryPanel() {
if (confirmDiscard()) openFile(entry.path);
}}
className={cn(
"flex items-start gap-2 rounded-md px-2 py-2 text-left transition-colors",
"flex items-start gap-2 rounded-md px-2 py-2 text-left transition-colors focus-visible:ring-2 focus-visible:ring-ring",
active ? "bg-accent" : "hover:bg-accent/60"
)}
aria-current={active}
@@ -489,7 +493,7 @@ export function MemoryPanel() {
setEditing(false);
}}
aria-label="Back to list"
className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground md:hidden"
className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring md:hidden"
>
<ArrowLeft
className="size-4"
@@ -517,7 +521,16 @@ export function MemoryPanel() {
className="h-8 px-2"
onClick={copy}
disabled={!file}
title="Copy"
aria-label={
copied
? "Copied memory content"
: "Copy memory content"
}
title={
copied
? "Copied memory content"
: "Copy memory content"
}
>
{copied ? (
<Check className="size-4" />
@@ -531,6 +544,7 @@ export function MemoryPanel() {
className="h-8 px-2"
onClick={startEdit}
disabled={!file}
aria-label="Edit memory file"
>
<Pencil className="mr-1 size-4" />
Edit
@@ -539,8 +553,10 @@ export function MemoryPanel() {
variant="ghost"
size="sm"
className="h-8 px-2 text-muted-foreground hover:text-destructive"
onClick={remove}
title="Delete"
onClick={() => setDeleteOpen(true)}
disabled={!file}
aria-label="Delete memory file"
title="Delete memory file"
>
<Trash2 className="size-4" />
</Button>
@@ -555,6 +571,7 @@ export function MemoryPanel() {
if (confirmDiscard()) setEditing(false);
}}
disabled={saving}
aria-label="Cancel editing"
>
<Eye className="mr-1 size-4" />
Cancel
@@ -563,7 +580,8 @@ export function MemoryPanel() {
size="sm"
className="h-8 px-3"
onClick={save}
disabled={saving}
disabled={saving || !dirty}
aria-label="Save memory file"
>
{saving ? (
<Loader2 className="mr-1 size-4 animate-spin" />
@@ -596,7 +614,8 @@ export function MemoryPanel() {
value={draft}
onChange={(e) => setDraft(e.target.value)}
spellCheck={false}
className="bg-surface h-full w-full resize-none p-4 font-mono text-sm leading-relaxed text-foreground outline-none"
aria-label="Memory file content"
className="h-full w-full resize-none bg-background p-4 font-mono text-sm leading-relaxed text-foreground outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
placeholder="Write memory…"
/>
) : file ? (
@@ -649,7 +668,9 @@ export function MemoryPanel() {
</DialogDescription>
</DialogHeader>
<Input
autoFocus
name="memory-file-path"
autoComplete="off"
spellCheck={false}
value={newName}
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => {
@@ -658,10 +679,20 @@ export function MemoryPanel() {
createNew();
}
}}
placeholder="e.g. research-notes.md"
placeholder="notes/research-idea.md…"
disabled={newBusy}
aria-invalid={newError ? true : undefined}
aria-describedby={newError ? "new-memory-error" : undefined}
/>
{newError && <p className="text-sm text-destructive">{newError}</p>}
{newError && (
<p
id="new-memory-error"
role="alert"
className="text-sm text-destructive"
>
{newError}
</p>
)}
<DialogFooter>
<Button
variant="outline"
@@ -679,6 +710,45 @@ export function MemoryPanel() {
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog
open={deleteOpen}
onOpenChange={(open) => {
if (!deleteBusy) setDeleteOpen(open);
}}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Delete memory file?</DialogTitle>
<DialogDescription>
{selected ? (
<>
<code>{selected}</code> will be permanently deleted. This
can&apos;t be undone.
</>
) : (
"This memory file will be permanently deleted."
)}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setDeleteOpen(false)}
disabled={deleteBusy}
>
Cancel
</Button>
<Button
onClick={confirmDelete}
disabled={deleteBusy || !selected}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{deleteBusy ? "Deleting…" : "Delete"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
+237 -215
View File
@@ -173,6 +173,7 @@ export function ThreadList({
const [renameValue, setRenameValue] = useState("");
const [deleteTarget, setDeleteTarget] = useState<ThreadItem | null>(null);
const [actionBusy, setActionBusy] = useState(false);
const isResearchView = view !== "skills" && view !== "memory";
const threads = useThreads({
status: statusFilter === "all" ? undefined : statusFilter,
@@ -333,7 +334,7 @@ export function ThreadList({
}
onClose?.();
}}
className="flex flex-shrink-0 items-center gap-3 border-b border-border p-4 text-left text-sm font-medium transition-colors hover:bg-accent"
className="flex flex-shrink-0 items-center gap-3 border-b border-border p-4 text-left text-sm font-medium transition-colors hover:bg-accent focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
>
<SquarePen
className="size-4"
@@ -348,7 +349,7 @@ export function ThreadList({
onClose?.();
}}
className={cn(
"flex flex-shrink-0 items-center gap-3 border-b border-border p-4 text-left text-sm font-medium transition-colors hover:bg-accent",
"flex flex-shrink-0 items-center gap-3 border-b border-border p-4 text-left text-sm font-medium transition-colors hover:bg-accent focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring",
view === "skills" && "bg-accent"
)}
>
@@ -365,7 +366,7 @@ export function ThreadList({
onClose?.();
}}
className={cn(
"flex flex-shrink-0 items-center gap-3 border-b border-border p-4 text-left text-sm font-medium transition-colors hover:bg-accent",
"flex flex-shrink-0 items-center gap-3 border-b border-border p-4 text-left text-sm font-medium transition-colors hover:bg-accent focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring",
view === "memory" && "bg-accent"
)}
>
@@ -375,7 +376,7 @@ export function ThreadList({
/>
Memory
</button>
{view !== "skills" && view !== "memory" && (
{isResearchView && (
<div className="flex-shrink-0 border-b border-border p-3">
<div className="relative">
<Search
@@ -409,220 +410,241 @@ export function ThreadList({
</div>
</div>
)}
{/* Header with title and status filter */}
<div className="grid flex-shrink-0 grid-cols-[1fr_auto] items-center gap-3 border-b border-border p-4">
<h2 className="text-lg font-semibold tracking-tight">Research</h2>
<div className="flex items-center gap-2">
<Select
value={statusFilter}
onValueChange={(v) => setStatusFilter(v as StatusFilter)}
>
<SelectTrigger className="w-fit">
<SelectValue />
</SelectTrigger>
<SelectContent align="end">
<SelectItem value="all">All</SelectItem>
<SelectSeparator />
<SelectGroup>
<SelectLabel>Active</SelectLabel>
<SelectItem value="idle">
<StatusFilterItem
status="idle"
label="Idle"
/>
</SelectItem>
<SelectItem value="busy">
<StatusFilterItem
status="busy"
label="Busy"
/>
</SelectItem>
</SelectGroup>
<SelectSeparator />
<SelectGroup>
<SelectLabel>Attention</SelectLabel>
<SelectItem value="interrupted">
<StatusFilterItem
status="interrupted"
label="Interrupted"
badge={interruptedCount}
/>
</SelectItem>
<SelectItem value="error">
<StatusFilterItem
status="error"
label="Error"
/>
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
{onClose && (
<button
type="button"
aria-label="Close research"
onClick={onClose}
className="rounded-md p-2 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
{isResearchView ? (
<div className="grid flex-shrink-0 grid-cols-[1fr_auto] items-center gap-3 border-b border-border p-4">
<h2 className="text-lg font-semibold tracking-tight">Research</h2>
<div className="flex items-center gap-2">
<Select
value={statusFilter}
onValueChange={(v) => setStatusFilter(v as StatusFilter)}
>
<X
className="size-4"
aria-hidden="true"
/>
</button>
)}
</div>
</div>
<ScrollArea className="h-0 flex-1">
{threads.error && <ErrorState message={threads.error.message} />}
{!threads.error && !threads.data && threads.isLoading && (
<LoadingState />
)}
{!threads.error && !threads.isLoading && isEmpty && <EmptyState />}
{!threads.error &&
!isEmpty &&
search.trim() &&
filtered.length === 0 && (
<div className="flex flex-col items-center justify-center p-8 text-center">
<p className="text-sm text-muted-foreground">
No research matches your search.
</p>
</div>
)}
{!threads.error && !isEmpty && filtered.length > 0 && (
<div className="box-border w-full max-w-full overflow-hidden p-2">
{(
Object.keys(GROUP_LABELS) as Array<keyof typeof GROUP_LABELS>
).map((group) => {
const groupThreads = grouped[group];
if (groupThreads.length === 0) return null;
return (
<div
key={group}
className="mb-4"
>
<h4 className="m-0 px-3 py-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{GROUP_LABELS[group]}
</h4>
<div className="flex flex-col gap-1">
{groupThreads.map((thread) => (
<div
key={thread.id}
className="group relative"
>
{/* Selectable row — a native button so Enter/Space and
role come for free. Action buttons are SIBLINGS (below),
never nested inside this button. */}
<button
type="button"
onClick={() => onThreadSelect(thread.id)}
className={cn(
"grid w-full cursor-pointer items-center gap-3 rounded-lg px-3 py-3 text-left transition-colors duration-200",
"hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
currentThreadId === thread.id
? "border border-primary bg-accent hover:bg-accent"
: "border border-transparent bg-transparent"
)}
aria-current={currentThreadId === thread.id}
>
<div className="min-w-0 flex-1">
{/* Title + Timestamp Row */}
<div className="mb-1 flex items-center justify-between gap-2">
<h3 className="truncate text-sm font-semibold">
{thread.title}
</h3>
<span className="ml-2 flex-shrink-0 text-xs tabular-nums text-muted-foreground">
{formatTime(thread.updatedAt)}
</span>
</div>
{/* Description + Status Row */}
<div className="flex items-center justify-between">
<p className="flex-1 truncate text-sm text-muted-foreground">
{thread.description}
</p>
<div className="ml-2 flex-shrink-0">
<span
role="img"
aria-label={`Status: ${
STATUS_LABELS[thread.status]
}`}
title={`Status: ${
STATUS_LABELS[thread.status]
}`}
className={cn(
"h-2 w-2 rounded-full",
getThreadColor(thread.status)
)}
/>
</div>
</div>
</div>
</button>
{/* Per-thread actions — siblings of the select button (not
nested); shown on touch, reveal on hover/focus on desktop. */}
<div className="absolute right-1.5 top-1.5 flex items-center gap-0.5 rounded-md bg-accent/95 p-0.5 opacity-100 shadow-sm backdrop-blur-sm transition-opacity md:opacity-0 md:group-focus-within:opacity-100 md:group-hover:opacity-100">
<button
type="button"
aria-label={`Rename "${thread.title}"`}
title="Rename"
onClick={() => {
setRenameTarget(thread);
setRenameValue(thread.title);
}}
className="rounded p-1 text-muted-foreground transition-colors hover:bg-background hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
>
<Pencil
className="size-3.5"
aria-hidden="true"
/>
</button>
<button
type="button"
aria-label={`Delete "${thread.title}"`}
title="Delete"
onClick={() => setDeleteTarget(thread)}
className="rounded p-1 text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive focus-visible:ring-2 focus-visible:ring-ring"
>
<Trash2
className="size-3.5"
aria-hidden="true"
/>
</button>
</div>
</div>
))}
</div>
</div>
);
})}
{!isReachingEnd && (
<div className="flex justify-center py-4">
<Button
variant="outline"
size="sm"
onClick={() => threads.setSize(threads.size + 1)}
disabled={isLoadingMore}
>
{isLoadingMore ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Loading…
</>
) : (
"Load More"
)}
</Button>
</div>
<SelectTrigger className="w-fit">
<SelectValue />
</SelectTrigger>
<SelectContent align="end">
<SelectItem value="all">All</SelectItem>
<SelectSeparator />
<SelectGroup>
<SelectLabel>Active</SelectLabel>
<SelectItem value="idle">
<StatusFilterItem
status="idle"
label="Idle"
/>
</SelectItem>
<SelectItem value="busy">
<StatusFilterItem
status="busy"
label="Busy"
/>
</SelectItem>
</SelectGroup>
<SelectSeparator />
<SelectGroup>
<SelectLabel>Attention</SelectLabel>
<SelectItem value="interrupted">
<StatusFilterItem
status="interrupted"
label="Interrupted"
badge={interruptedCount}
/>
</SelectItem>
<SelectItem value="error">
<StatusFilterItem
status="error"
label="Error"
/>
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
{onClose && (
<button
type="button"
aria-label="Close research"
onClick={onClose}
className="rounded-md p-2 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
>
<X
className="size-4"
aria-hidden="true"
/>
</button>
)}
</div>
)}
</ScrollArea>
</div>
) : onClose ? (
<button
type="button"
aria-label="Close navigation"
onClick={onClose}
className="mx-3 mt-3 inline-flex items-center justify-center gap-2 rounded-md border border-border px-3 py-2 text-sm text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
>
<X
className="size-4"
aria-hidden="true"
/>
Close Navigation
</button>
) : null}
{isResearchView ? (
<ScrollArea className="h-0 flex-1">
{threads.error && <ErrorState message={threads.error.message} />}
{!threads.error && !threads.data && threads.isLoading && (
<LoadingState />
)}
{!threads.error && !threads.isLoading && isEmpty && <EmptyState />}
{!threads.error &&
!isEmpty &&
search.trim() &&
filtered.length === 0 && (
<div className="flex flex-col items-center justify-center p-8 text-center">
<p className="text-sm text-muted-foreground">
No research matches your search.
</p>
</div>
)}
{!threads.error && !isEmpty && filtered.length > 0 && (
<div className="box-border w-full max-w-full overflow-hidden p-2">
{(
Object.keys(GROUP_LABELS) as Array<keyof typeof GROUP_LABELS>
).map((group) => {
const groupThreads = grouped[group];
if (groupThreads.length === 0) return null;
return (
<div
key={group}
className="mb-4"
>
<h4 className="m-0 px-3 py-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{GROUP_LABELS[group]}
</h4>
<div className="flex flex-col gap-1">
{groupThreads.map((thread) => (
<div
key={thread.id}
className="group relative"
>
{/* Selectable row — a native button so Enter/Space and
role come for free. Action buttons are SIBLINGS (below),
never nested inside this button. */}
<button
type="button"
onClick={() => onThreadSelect(thread.id)}
className={cn(
"grid w-full cursor-pointer items-center gap-3 rounded-lg px-3 py-3 text-left transition-colors duration-200",
"hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
currentThreadId === thread.id
? "border border-primary bg-accent hover:bg-accent"
: "border border-transparent bg-transparent"
)}
aria-current={currentThreadId === thread.id}
>
<div className="min-w-0 flex-1">
{/* Title + Timestamp Row */}
<div className="mb-1 flex items-center justify-between gap-2">
<h3 className="truncate text-sm font-semibold">
{thread.title}
</h3>
<span className="ml-2 flex-shrink-0 text-xs tabular-nums text-muted-foreground">
{formatTime(thread.updatedAt)}
</span>
</div>
{/* Description + Status Row */}
<div className="flex items-center justify-between">
<p className="flex-1 truncate text-sm text-muted-foreground">
{thread.description}
</p>
<div className="ml-2 flex-shrink-0">
<span
role="img"
aria-label={`Status: ${
STATUS_LABELS[thread.status]
}`}
title={`Status: ${
STATUS_LABELS[thread.status]
}`}
className={cn(
"h-2 w-2 rounded-full",
getThreadColor(thread.status)
)}
/>
</div>
</div>
</div>
</button>
{/* Per-thread actions — siblings of the select button (not
nested); shown on touch, reveal on hover/focus on desktop. */}
<div className="absolute right-1.5 top-1.5 flex items-center gap-0.5 rounded-md bg-accent/95 p-0.5 opacity-100 shadow-sm backdrop-blur-sm transition-opacity md:opacity-0 md:group-focus-within:opacity-100 md:group-hover:opacity-100">
<button
type="button"
aria-label={`Rename "${thread.title}"`}
title="Rename"
onClick={() => {
setRenameTarget(thread);
setRenameValue(thread.title);
}}
className="rounded p-1 text-muted-foreground transition-colors hover:bg-background hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
>
<Pencil
className="size-3.5"
aria-hidden="true"
/>
</button>
<button
type="button"
aria-label={`Delete "${thread.title}"`}
title="Delete"
onClick={() => setDeleteTarget(thread)}
className="rounded p-1 text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive focus-visible:ring-2 focus-visible:ring-ring"
>
<Trash2
className="size-3.5"
aria-hidden="true"
/>
</button>
</div>
</div>
))}
</div>
</div>
);
})}
{!isReachingEnd && (
<div className="flex justify-center py-4">
<Button
variant="outline"
size="sm"
onClick={() => threads.setSize(threads.size + 1)}
disabled={isLoadingMore}
>
{isLoadingMore ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Loading…
</>
) : (
"Load More"
)}
</Button>
</div>
)}
</div>
)}
</ScrollArea>
) : (
<div className="flex min-h-0 flex-1 items-start p-4 text-sm text-muted-foreground">
Use the navigation above to switch views, or start a new chat to
return to research history.
</div>
)}
{/* Rename dialog */}
<Dialog
+9 -2
View File
@@ -116,6 +116,13 @@ function HomePageInner({
}, []);
const closeSidebar = useCallback(() => setSidebar(null), [setSidebar]);
const sidebarToggleLabel = view
? sidebar
? "Hide navigation"
: "Show navigation"
: sidebar
? "Hide research"
: "Show research";
const startNewChat = useCallback(() => {
setThreadId(null);
setView(null);
@@ -160,7 +167,7 @@ function HomePageInner({
variant="ghost"
size="icon"
onClick={() => setSidebar(sidebar ? null : "1")}
aria-label={sidebar ? "Hide research" : "Show research"}
aria-label={sidebarToggleLabel}
className="relative size-8 sm:size-9"
>
{sidebar ? (
@@ -225,7 +232,7 @@ function HomePageInner({
onClick={closeSidebar}
/>
<aside
aria-label="Research navigation"
aria-label={view ? "Navigation" : "Research navigation"}
className="relative z-10 h-full w-[min(20rem,calc(100vw-3rem))] bg-background shadow-xl"
>
<ThreadList