feat: add delete confirmation dialog and improve accessibility in MemoryPanel and ThreadList components
This commit is contained in:
@@ -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`).
|
||||
@@ -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'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
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user