Files
EvoScientist-WebUI/docs/superpowers/plans/2026-07-31-error-log-bell.md
T
m4 2aeccea036 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>
2026-07-31 16:58:22 +08:00

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 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:

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): () => 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:

"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() 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:

"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):

  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
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.