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 = {