diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..59463eb --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,28 @@ + + +## What & 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 + + + +## 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`). diff --git a/src/app/components/MemoryPanel.tsx b/src/app/components/MemoryPanel.tsx index d5e86f8..a52080a 100644 --- a/src/app/components/MemoryPanel.tsx +++ b/src/app/components/MemoryPanel.tsx @@ -138,6 +138,8 @@ export function MemoryPanel() { const [newName, setNewName] = useState(""); const [newBusy, setNewBusy] = useState(false); const [newError, setNewError] = useState(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" > {copied ? ( @@ -531,6 +544,7 @@ export function MemoryPanel() { className="h-8 px-2" onClick={startEdit} disabled={!file} + aria-label="Edit memory file" > 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" > @@ -555,6 +571,7 @@ export function MemoryPanel() { if (confirmDiscard()) setEditing(false); }} disabled={saving} + aria-label="Cancel editing" > 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 ? ( @@ -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() { 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 &&

{newError}

} + {newError && ( + + )} + + + + ); } diff --git a/src/app/components/ThreadList.tsx b/src/app/components/ThreadList.tsx index cb994be..21962f5 100644 --- a/src/app/components/ThreadList.tsx +++ b/src/app/components/ThreadList.tsx @@ -173,6 +173,7 @@ export function ThreadList({ const [renameValue, setRenameValue] = useState(""); const [deleteTarget, setDeleteTarget] = useState(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" > @@ -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 - {view !== "skills" && view !== "memory" && ( + {isResearchView && (
)} - {/* Header with title and status filter */} -
-

Research

-
- - {onClose && ( - )}
- )} - +
+ ) : onClose ? ( + + ) : null} + + {isResearchView ? ( + + {threads.error && } + + {!threads.error && !threads.data && threads.isLoading && ( + + )} + + {!threads.error && !threads.isLoading && isEmpty && } + + {!threads.error && + !isEmpty && + search.trim() && + filtered.length === 0 && ( +
+

+ No research matches your search. +

+
+ )} + + {!threads.error && !isEmpty && filtered.length > 0 && ( +
+ {( + Object.keys(GROUP_LABELS) as Array + ).map((group) => { + const groupThreads = grouped[group]; + if (groupThreads.length === 0) return null; + + return ( +
+

+ {GROUP_LABELS[group]} +

+
+ {groupThreads.map((thread) => ( +
+ {/* Selectable row — a native button so Enter/Space and + role come for free. Action buttons are SIBLINGS (below), + never nested inside this button. */} + + {/* Per-thread actions — siblings of the select button (not + nested); shown on touch, reveal on hover/focus on desktop. */} +
+ + +
+
+ ))} +
+
+ ); + })} + + {!isReachingEnd && ( +
+ +
+ )} +
+ )} +
+ ) : ( +
+ Use the navigation above to switch views, or start a new chat to + return to research history. +
+ )} {/* Rename dialog */} 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} />