diff --git a/docs/todos/2026-06-18-issue-507-viewer-dashboard-errors/plan.md b/docs/todos/2026-06-18-issue-507-viewer-dashboard-errors/plan.md
new file mode 100644
index 00000000..78343bb5
--- /dev/null
+++ b/docs/todos/2026-06-18-issue-507-viewer-dashboard-errors/plan.md
@@ -0,0 +1,173 @@
+# Issue 507 Viewer Dashboard Errors 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:** Preserve dashboard scroll position during refreshes and show safe user-visible API error notifications in the viewer.
+
+**Architecture:** Keep the fix inside the existing single-file viewer script. Add a small toast host/renderer beside the existing auth prompt patterns, add client-side timeout handling inside `api()`, and wrap dashboard refresh rendering with scroll capture/restore on the dashboard scroll container.
+
+**Tech Stack:** TypeScript tests with Vitest, VM-executed browser script from `src/viewer/index.html`, DOM stubs in `test/helpers/viewer-sandbox.ts`, browser JavaScript in the viewer template.
+
+---
+
+## Files
+
+- Modify: `src/viewer/index.html`
+- Modify: `test/helpers/viewer-sandbox.ts`
+- Modify: `test/viewer-session-id.test.ts`
+- Update: `docs/todos/2026-06-18-issue-507-viewer-dashboard-errors/todo.md`
+
+## Task 1: Red Tests For Viewer API Error Toasts
+
+**Files:**
+- Modify: `test/helpers/viewer-sandbox.ts`
+- Modify: `test/viewer-session-id.test.ts`
+
+- [ ] **Step 1: Extend the sandbox enough to observe abort timeouts and toast DOM**
+
+Add mock element fields used by the viewer code: `scrollTop`, `scrollHeight`, `clientHeight`, `appendChild`, `remove`, `children`, and a minimal `AbortController`/`AbortSignal.timeout` surface if needed by tests.
+
+- [ ] **Step 2: Write failing HTTP error toast test**
+
+Add a test that sets `sandbox.fetch` to return `{ ok: false, status: 503 }`, calls `sandbox.apiGet("memories?
")`, and asserts a toast host contains escaped path/status text and no raw `
offline")`, calls `sandbox.apiGet("health")`, and asserts visible escaped text in the toast host with no raw `");
+ };
+
+ const result = await sandbox.apiGet("health");
+
+ const toastHtml = getElement("viewer-toasts").innerHTML;
+ expect(result).toBeNull();
+ expect(toastHtml).toContain("API request failed");
+ expect(toastHtml).toContain("health");
+ expect(toastHtml).toContain("<script>offline</script>");
+ expect(toastHtml).not.toContain("");
+ });
+
+ it("shows a timeout toast when API requests exceed the viewer timeout", async () => {
+ const { sandbox, getElement, flushTimers } = loadViewerSandbox();
+ sandbox.fetch = async (_url: string, opts?: { signal?: { addEventListener?: (type: string, listener: () => void) => void } }) => {
+ if (!opts?.signal?.addEventListener) {
+ return { ok: true, json: async () => ({ unexpectedlyCompletedWithoutTimeout: true }) };
+ }
+ return new Promise((_resolve, reject) => {
+ opts.signal?.addEventListener?.("abort", () => {
+ const err = new Error("aborted");
+ err.name = "AbortError";
+ reject(err);
+ });
+ });
+ };
+
+ const resultPromise = sandbox.apiGet("health");
+ flushTimers();
+ const result = await resultPromise;
+
+ const toastHtml = getElement("viewer-toasts").innerHTML;
+ expect(result).toBeNull();
+ expect(toastHtml).toContain("API request timed out");
+ expect(toastHtml).toContain("health");
+ });
+
it("shows an explicit dashboard error banner when endpoint groups fail", async () => {
const { sandbox, getElement } = loadViewerSandbox();
const responses: Record = {
@@ -79,6 +159,35 @@ describe("viewer session rendering", () => {
expect(dashboard).toContain("ses_dash");
});
+ it("preserves dashboard scroll position during manual refresh", async () => {
+ const { sandbox, getElement } = loadViewerSandbox();
+ installDashboardFetch(sandbox);
+ const dashboard = getElement("view-dashboard");
+ await sandbox.loadDashboard();
+ dashboard.scrollTop = 240;
+
+ await sandbox.refreshDashboard();
+
+ expect(dashboard.innerHTML).toContain("Recent Sessions");
+ expect(dashboard.scrollTop).toBe(240);
+ });
+
+ it("preserves dashboard scroll position during debounced websocket or polling refresh", async () => {
+ const { sandbox, getElement, flushTimers } = loadViewerSandbox();
+ installDashboardFetch(sandbox);
+ sandbox.state.activeTab = "dashboard";
+ const dashboard = getElement("view-dashboard");
+ await sandbox.loadDashboard();
+ dashboard.scrollTop = 180;
+
+ sandbox.scheduleDashboardRefresh();
+ flushTimers();
+ await new Promise((resolve) => setImmediate(resolve));
+
+ expect(dashboard.innerHTML).toContain("Recent Sessions");
+ expect(dashboard.scrollTop).toBe(180);
+ });
+
it("does not throw when dashboard sessions are missing ids", () => {
const { sandbox, getElement } = loadViewerSandbox();
sandbox.state.dashboard = {