docs(webui): implementation plan for the error-log bell UI

Covers the remaining piece of the per-user error-log feature: unread
cursor module, useErrorLogs hook, ErrorLogBell component, header mount.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
m4
2026-07-31 16:58:22 +08:00
parent 09d9f49e9f
commit 2aeccea036
@@ -0,0 +1,549 @@
# Error Log Notification Bell Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add a notification bell to the WebUI header that shows the current user's persisted error logs with an unread badge and a "clear all" action.
**Architecture:** The backend is already complete and committed: `src/lib/server/errorLogStore.ts` (per-user sqlite log, 200-entry cap), `src/app/api/error-logs/route.ts` (GET/POST/DELETE), `src/lib/errorReporter.ts` (`reportError`/`errorToast`/`subscribeErrorLogged`, wired into 13 components), and `src/lib/server/routeErrors.ts` (BFF errors recorded for the acting user). This plan builds the only missing piece: the header UI. A thin SWR hook polls `/api/error-logs` (30s) and revalidates instantly when `errorReporter` signals a new entry; unread state is a `localStorage` cursor over monotonically increasing log ids; all badge math lives in a pure, node-testable module because vitest runs in `environment: "node"` (no DOM).
**Tech Stack:** Next.js App Router, React 19, SWR 2, lucide-react icons, shadcn/ui (`@/components/ui/dropdown-menu`, `button`, `scroll-area`), vitest (node env, `.test.ts` only).
## Global Constraints
- Repo root for all paths/commands: `/Users/m4/Projects/EvoSci/OriginEvoScientist/EvoScientist-WebUI`
- Test runner: `npm run test` (vitest run, `environment: "node"`, includes only `src/**/*.test.ts` — no component/DOM tests; logic must be extractable to pure TS)
- Lint: `npm run lint`; format: `npm run format:check`
- All UI strings in English (existing UI copy is English: "New chat", "Sign out", "Backend unavailable.")
- Header icon buttons use `<Button variant="ghost" size="icon" className="size-8">` with `aria-label`
- The unread badge reuses the exact classes of the interrupt badge in `src/app/page.tsx`: `absolute right-0 top-0 inline-flex min-h-4 min-w-4 items-center justify-center rounded-full bg-destructive px-1 text-[10px] text-destructive-foreground`
- Timestamps use the existing helpers `formatTime` / `formatFullTime` from `src/lib/time.ts`
- localStorage key (verbatim): `evoscientist.errorLogs.lastSeenId`
- API response shapes (already implemented, do not change):
- `GET /api/error-logs?limit=100` → `{ logs: ErrorLogEntry[] }` newest first
- `DELETE /api/error-logs` → `{ deleted: number }`
- `ErrorLogEntry = { id: number; userId: string; createdAt: string; source: string; code: string | null; message: string; details: string | null }`
- Do NOT commit the ~28 unrelated modified files currently in the working tree (skills/usage/image-gen WIP). Stage only the files each task touches, by name.
---
### Task 1: Unread-cursor module `src/lib/errorLogUnread.ts`
Pure functions over log ids + a minimal storage interface, so the badge logic is fully testable under the node vitest environment (no `localStorage` global needed — callers inject it).
**Files:**
- Create: `src/lib/errorLogUnread.ts`
- Test: `src/lib/errorLogUnread.test.ts`
**Interfaces:**
- Consumes: nothing (leaf module)
- Produces:
- `LAST_SEEN_STORAGE_KEY: string` — `"evoscientist.errorLogs.lastSeenId"`
- `latestLogId(logs: ReadonlyArray<{ id: number }>): number` — max id, `0` when empty
- `countUnread(logs: ReadonlyArray<{ id: number }>, lastSeenId: number): number`
- `formatBadgeCount(count: number): string` — `"99+"` above 99, `""` for 0
- `readLastSeenId(storage: Pick<Storage, "getItem"> | null): number` — `0` on missing/corrupt value
- `writeLastSeenId(storage: Pick<Storage, "setItem"> | null, id: number): void` — swallows quota errors
- [ ] **Step 1: Write the failing test**
Create `src/lib/errorLogUnread.test.ts`:
```ts
import { describe, expect, it } from "vitest";
import {
LAST_SEEN_STORAGE_KEY,
countUnread,
formatBadgeCount,
latestLogId,
readLastSeenId,
writeLastSeenId,
} from "./errorLogUnread";
function fakeStorage(initial: Record<string, string> = {}) {
const map = new Map(Object.entries(initial));
return {
getItem: (key: string) => map.get(key) ?? null,
setItem: (key: string, value: string) => {
map.set(key, value);
},
map,
};
}
describe("errorLogUnread", () => {
it("latestLogId returns the max id, 0 for empty", () => {
expect(latestLogId([])).toBe(0);
expect(latestLogId([{ id: 3 }, { id: 9 }, { id: 5 }])).toBe(9);
});
it("countUnread counts entries newer than the cursor", () => {
const logs = [{ id: 10 }, { id: 8 }, { id: 4 }];
expect(countUnread(logs, 0)).toBe(3);
expect(countUnread(logs, 8)).toBe(1);
expect(countUnread(logs, 10)).toBe(0);
});
it("formatBadgeCount caps at 99+ and hides zero", () => {
expect(formatBadgeCount(0)).toBe("");
expect(formatBadgeCount(7)).toBe("7");
expect(formatBadgeCount(99)).toBe("99");
expect(formatBadgeCount(150)).toBe("99+");
});
it("readLastSeenId returns 0 for null storage, missing, or corrupt values", () => {
expect(readLastSeenId(null)).toBe(0);
expect(readLastSeenId(fakeStorage())).toBe(0);
expect(
readLastSeenId(fakeStorage({ [LAST_SEEN_STORAGE_KEY]: "abc" }))
).toBe(0);
expect(
readLastSeenId(fakeStorage({ [LAST_SEEN_STORAGE_KEY]: "-5" }))
).toBe(0);
});
it("writeLastSeenId round-trips through storage", () => {
const storage = fakeStorage();
writeLastSeenId(storage, 42);
expect(readLastSeenId(storage)).toBe(42);
});
it("writeLastSeenId swallows storage failures", () => {
const throwing = {
setItem: () => {
throw new Error("quota");
},
};
expect(() => writeLastSeenId(throwing, 1)).not.toThrow();
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `npx vitest run src/lib/errorLogUnread.test.ts`
Expected: FAIL — module `./errorLogUnread` does not exist
- [ ] **Step 3: Write minimal implementation**
Create `src/lib/errorLogUnread.ts`:
```ts
/** Unread-badge state for the error-log bell. The server assigns each entry a
* monotonically increasing id, so a single "last seen id" cursor is enough;
* the cursor lives in localStorage (per browser, per user is unnecessary
* because ids are global and monotonic). */
export const LAST_SEEN_STORAGE_KEY = "evoscientist.errorLogs.lastSeenId";
const BADGE_CAP = 99;
export function latestLogId(logs: ReadonlyArray<{ id: number }>): number {
let max = 0;
for (const log of logs) {
if (log.id > max) max = log.id;
}
return max;
}
export function countUnread(
logs: ReadonlyArray<{ id: number }>,
lastSeenId: number
): number {
let count = 0;
for (const log of logs) {
if (log.id > lastSeenId) count += 1;
}
return count;
}
export function formatBadgeCount(count: number): string {
if (count <= 0) return "";
return count > BADGE_CAP ? `${BADGE_CAP}+` : String(count);
}
export function readLastSeenId(
storage: Pick<Storage, "getItem"> | null
): number {
if (!storage) return 0;
const raw = storage.getItem(LAST_SEEN_STORAGE_KEY);
if (raw === null) return 0;
const value = Number(raw);
if (!Number.isInteger(value) || value < 0) return 0;
return value;
}
export function writeLastSeenId(
storage: Pick<Storage, "setItem"> | null,
id: number
): void {
if (!storage) return;
try {
storage.setItem(LAST_SEEN_STORAGE_KEY, String(id));
} catch {
// Private-mode quota errors must not break the bell.
}
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `npx vitest run src/lib/errorLogUnread.test.ts`
Expected: PASS — 6 tests
- [ ] **Step 5: Commit**
```bash
git add src/lib/errorLogUnread.ts src/lib/errorLogUnread.test.ts
git commit -m "feat(webui): unread-cursor helpers for the error-log bell"
```
---
### Task 2: `useErrorLogs` hook
SWR over `GET /api/error-logs` with a 30s poll; `subscribeErrorLogged` (from `src/lib/errorReporter.ts`) triggers an immediate revalidate so errors reported by this tab appear at once. Owns the unread cursor state and the clear-all action.
**Files:**
- Create: `src/app/hooks/useErrorLogs.ts`
**Interfaces:**
- Consumes:
- `subscribeErrorLogged(listener: () => void): () => void` from `@/lib/errorReporter`
- Task 1's `latestLogId`, `countUnread`, `readLastSeenId`, `writeLastSeenId`
- `GET /api/error-logs` → `{ logs: ErrorLogEntry[] }`; `DELETE /api/error-logs`
- Produces:
- `ErrorLogEntry` (re-declared client-side mirror of the store's interface)
- `useErrorLogs(): { logs: ErrorLogEntry[]; unread: number; loading: boolean; markAllRead: () => void; clearAll: () => Promise<void> }`
No unit test: the hook is a thin wiring layer over SWR + Task 1's tested pure functions; the vitest node environment has no React DOM renderer. Verified via typecheck and the Task 4 browser test.
- [ ] **Step 1: Write the hook**
Create `src/app/hooks/useErrorLogs.ts`:
```ts
"use client";
import { useCallback, useEffect, useState } from "react";
import useSWR from "swr";
import { subscribeErrorLogged } from "@/lib/errorReporter";
import {
countUnread,
latestLogId,
readLastSeenId,
writeLastSeenId,
} from "@/lib/errorLogUnread";
export interface ErrorLogEntry {
id: number;
userId: string;
createdAt: string;
source: string;
code: string | null;
message: string;
details: string | null;
}
const REFRESH_INTERVAL_MS = 30_000;
async function fetchLogs(url: string): Promise<{ logs: ErrorLogEntry[] }> {
const response = await fetch(url, { cache: "no-store" });
if (!response.ok) throw new Error(`Error-log API returned ${response.status}`);
return response.json() as Promise<{ logs: ErrorLogEntry[] }>;
}
function browserStorage(): Storage | null {
return typeof window === "undefined" ? null : window.localStorage;
}
export function useErrorLogs() {
const { data, mutate, isLoading } = useSWR("/api/error-logs", fetchLogs, {
refreshInterval: REFRESH_INTERVAL_MS,
revalidateOnFocus: true,
});
const [lastSeenId, setLastSeenId] = useState(0);
useEffect(() => {
setLastSeenId(readLastSeenId(browserStorage()));
}, []);
useEffect(() => subscribeErrorLogged(() => void mutate()), [mutate]);
const logs = data?.logs ?? [];
const unread = countUnread(logs, lastSeenId);
const markAllRead = useCallback(() => {
const id = latestLogId(logs);
setLastSeenId(id);
writeLastSeenId(browserStorage(), id);
}, [logs]);
const clearAll = useCallback(async () => {
const response = await fetch("/api/error-logs", { method: "DELETE" });
if (!response.ok)
throw new Error(`Error-log API returned ${response.status}`);
await mutate({ logs: [] }, { revalidate: false });
}, [mutate]);
return { logs, unread, loading: isLoading, markAllRead, clearAll };
}
```
- [ ] **Step 2: Typecheck and lint**
Run: `npx tsc --noEmit && npx eslint src/app/hooks/useErrorLogs.ts`
Expected: no errors
- [ ] **Step 3: Commit**
```bash
git add src/app/hooks/useErrorLogs.ts
git commit -m "feat(webui): useErrorLogs hook with unread cursor and clear-all"
```
---
### Task 3: `ErrorLogBell` component
Header bell with unread badge; opens a dropdown listing the user's error logs; footer "Clear all" requires a second click to confirm. Opening the panel marks everything read.
**Files:**
- Create: `src/app/components/ErrorLogBell.tsx`
**Interfaces:**
- Consumes:
- Task 2's `useErrorLogs()` and `ErrorLogEntry`
- Task 1's `formatBadgeCount`
- `formatTime`, `formatFullTime` from `@/lib/time` (both take `Date`)
- `@/components/ui/button` (`Button`), `@/components/ui/dropdown-menu` (`DropdownMenu`, `DropdownMenuTrigger`, `DropdownMenuContent`), `@/components/ui/scroll-area` (`ScrollArea`)
- `Bell` from `lucide-react`
- Produces: `ErrorLogBell` React component (default-named export `export function ErrorLogBell()`)
No unit test (node-only vitest; see Task 2). Verified by typecheck/lint here and browser test in Task 4.
- [ ] **Step 1: Write the component**
Create `src/app/components/ErrorLogBell.tsx`:
```tsx
"use client";
import { useState } from "react";
import { Bell } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { ScrollArea } from "@/components/ui/scroll-area";
import { errorToast } from "@/lib/errorReporter";
import { formatBadgeCount } from "@/lib/errorLogUnread";
import { formatFullTime, formatTime } from "@/lib/time";
import { useErrorLogs, type ErrorLogEntry } from "@/app/hooks/useErrorLogs";
function ErrorLogRow({ entry }: { entry: ErrorLogEntry }) {
const created = new Date(entry.createdAt);
return (
<li className="border-b border-border px-3 py-2 last:border-b-0">
<div className="flex items-baseline justify-between gap-2">
<span className="truncate text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
{entry.source}
</span>
<time
className="shrink-0 text-[11px] text-muted-foreground"
title={formatFullTime(created)}
>
{formatTime(created)}
</time>
</div>
<p className="mt-0.5 whitespace-pre-wrap break-words text-sm">
{entry.message}
</p>
{entry.details && (
<p className="mt-0.5 line-clamp-2 whitespace-pre-wrap break-words text-xs text-muted-foreground">
{entry.details}
</p>
)}
</li>
);
}
export function ErrorLogBell() {
const { logs, unread, loading, markAllRead, clearAll } = useErrorLogs();
const [open, setOpen] = useState(false);
const [confirmingClear, setConfirmingClear] = useState(false);
const [clearing, setClearing] = useState(false);
const badge = formatBadgeCount(unread);
const handleOpenChange = (next: boolean) => {
setOpen(next);
setConfirmingClear(false);
if (next) markAllRead();
};
const handleClear = async () => {
if (!confirmingClear) {
setConfirmingClear(true);
return;
}
setClearing(true);
try {
await clearAll();
setConfirmingClear(false);
} catch (error) {
errorToast(
"errors.clear",
error instanceof Error ? error.message : "Couldn't clear error logs."
);
} finally {
setClearing(false);
}
};
return (
<DropdownMenu modal={false} open={open} onOpenChange={handleOpenChange}>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
aria-label="Error log"
title="Error log"
className="relative size-8"
>
<Bell className="size-5" aria-hidden="true" />
{badge && (
<span className="absolute right-0 top-0 inline-flex min-h-4 min-w-4 items-center justify-center rounded-full bg-destructive px-1 text-[10px] text-destructive-foreground">
{badge}
</span>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-96 p-0">
<div className="border-b border-border px-3 py-2 text-sm font-medium">
Error log
</div>
{loading && logs.length === 0 ? (
<p className="px-3 py-6 text-center text-sm text-muted-foreground">
Loading...
</p>
) : logs.length === 0 ? (
<p className="px-3 py-6 text-center text-sm text-muted-foreground">
No errors logged.
</p>
) : (
<ScrollArea className="max-h-96">
<ul>
{logs.map((entry) => (
<ErrorLogRow key={entry.id} entry={entry} />
))}
</ul>
</ScrollArea>
)}
{logs.length > 0 && (
<div className="border-t border-border p-2">
<Button
variant={confirmingClear ? "destructive" : "ghost"}
size="sm"
className="w-full"
disabled={clearing}
onClick={() => void handleClear()}
>
{clearing
? "Clearing..."
: confirmingClear
? "Click again to clear all"
: "Clear all"}
</Button>
</div>
)}
</DropdownMenuContent>
</DropdownMenu>
);
}
```
- [ ] **Step 2: Typecheck and lint**
Run: `npx tsc --noEmit && npx eslint src/app/components/ErrorLogBell.tsx`
Expected: no errors
- [ ] **Step 3: Commit**
```bash
git add src/app/components/ErrorLogBell.tsx
git commit -m "feat(webui): error-log bell with unread badge and clear-all"
```
---
### Task 4: Mount the bell in the header
**Files:**
- Modify: `src/app/page.tsx` (header right-side icon group, currently lines 357-359: `<HealthIndicator />` then `<ThemeToggle />`)
**Interfaces:**
- Consumes: Task 3's `ErrorLogBell`
- Produces: nothing new
- [ ] **Step 1: Add the import**
In `src/app/page.tsx`, add with the other component imports (keep the existing import grouping/sorting style):
```ts
import { ErrorLogBell } from "@/app/components/ErrorLogBell";
```
- [ ] **Step 2: Render the bell**
In `src/app/page.tsx`, inside the header's right-side container `<div className="flex shrink-0 items-center gap-1 sm:gap-2">`, insert immediately before `<HealthIndicator />`:
```tsx
<ErrorLogBell />
```
Render it unconditionally: when WebUI auth is disabled, `requireActor` resolves every request to the built-in `local-admin` user, so the API and the bell work in single-user mode too.
- [ ] **Step 3: Typecheck, lint, full test suite**
Run: `npx tsc --noEmit && npm run lint && npm run test`
Expected: no type/lint errors; all existing tests pass (no new tests in this task)
- [ ] **Step 4: Browser verification**
Terminal 1 — backend (per project memory, must use the source-tree script so tokens align):
```bash
/Users/m4/Projects/EvoSci/OriginEvoScientist/EvoScientist/scripts/dev_backend.sh
```
Terminal 2 — WebUI:
```bash
cd /Users/m4/Projects/EvoSci/OriginEvoScientist/EvoScientist-WebUI && npm run dev
```
Then in the browser at `http://localhost:4716` (sign in as admin/admin123 if auth is enabled):
1. Force an error: open devtools console and run `fetch("/api/error-logs", {method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({source:"manual.test",message:"Bell smoke test"})})`
2. Within ~1s the bell shows badge "1" (SWR revalidate via `subscribeErrorLogged` fires only for in-tab `reportError` calls, so this console POST appears on the next 30s poll or focus revalidate — to test the instant path instead, run: `window.dispatchEvent(new Event("focus"))` after focusing, or simply wait for the poll; the badge MUST show "1" within 30s)
3. Click the bell → panel opens, entry "Bell smoke test" visible with source `manual.test`; badge cleared
4. Re-open the panel → "Clear all" → button switches to "Click again to clear all" → click again → list empties, footer disappears
5. Trigger a real UI error (e.g. rename a thread to an existing name or disconnect the backend and send a chat message) → toast appears AND bell badge increments within ~1s (instant path via `errorToast` → `reportError` → `subscribeErrorLogged`)
- [ ] **Step 5: Commit**
```bash
git add src/app/page.tsx
git commit -m "feat(webui): mount error-log bell in the header"
```
---
## Self-Review Notes
- Spec coverage: the approved design had four parts — (1) server store, (2) collection, (3) BFF API, (4) bell UI. Parts 1-3 are already implemented and committed (`61c2aba`, `8d455a0`, `dd4b63a`, `09d9f49`); this plan covers only part 4. The design's "SWR 30s poll OR instant refresh" is implemented as both.
- Placeholders: none — every code step contains complete code.
- Type consistency: `ErrorLogEntry` shape matches `src/lib/server/errorLogStore.ts`; `useErrorLogs` return value `{ logs, unread, loading, markAllRead, clearAll }` is what Task 3 consumes; Task 1 exports match Task 2's imports verbatim.