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>
20 KiB
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 onlysrc/**/*.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">witharia-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/formatFullTimefromsrc/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 firstDELETE /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,0when emptycountUnread(logs: ReadonlyArray<{ id: number }>, lastSeenId: number): numberformatBadgeCount(count: number): string—"99+"above 99,""for 0readLastSeenId(storage: Pick<Storage, "getItem"> | null): number—0on missing/corrupt valuewriteLastSeenId(storage: Pick<Storage, "setItem"> | null, id: number): void— swallows quota errors
-
Step 1: Write the failing test
Create src/lib/errorLogUnread.test.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:
/** 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
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): () => voidfrom@/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:
"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
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()andErrorLogEntry - Task 1's
formatBadgeCount formatTime,formatFullTimefrom@/lib/time(both takeDate)@/components/ui/button(Button),@/components/ui/dropdown-menu(DropdownMenu,DropdownMenuTrigger,DropdownMenuContent),@/components/ui/scroll-area(ScrollArea)Bellfromlucide-react
- Task 2's
- Produces:
ErrorLogBellReact component (default-named exportexport 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:
"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
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):
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 />:
<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):
/Users/m4/Projects/EvoSci/OriginEvoScientist/EvoScientist/scripts/dev_backend.sh
Terminal 2 — WebUI:
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):
- 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"})}) - Within ~1s the bell shows badge "1" (SWR revalidate via
subscribeErrorLoggedfires only for in-tabreportErrorcalls, 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) - Click the bell → panel opens, entry "Bell smoke test" visible with source
manual.test; badge cleared - Re-open the panel → "Clear all" → button switches to "Click again to clear all" → click again → list empties, footer disappears
- 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
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:
ErrorLogEntryshape matchessrc/lib/server/errorLogStore.ts;useErrorLogsreturn value{ logs, unread, loading, markAllRead, clearAll }is what Task 3 consumes; Task 1 exports match Task 2's imports verbatim.