From 1e24f95eefd3d5e3f4923ffe87a1ae5a2e0f1455 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 4 May 2026 06:53:24 +0000 Subject: [PATCH] feat(web): reflect run status in tab title + favicon (BEN-50) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each top-level route now sets its own tab title and a tiny SVG favicon. The dashboard escalates to a recent failure for five minutes, so a tab parked alongside other work surfaces a red 'S' + '✖ BEN-30 failed' without forcing the user to switch tabs. - documentTitle.ts: pure helpers (dashboardTitle, runDetailTitle, faviconColor, buildFaviconHref) with 14 unit tests. - useDocumentChrome.ts: thin React hook that mutates document.title and a single on each render. - Dashboard / RunDetail / Search call the hook with the right data; Dashboard ticks 'now' every 30s so the failure window expires without an SSE event. - index.html ships the neutral favicon up front so the very first paint already has it; runtime swaps to the fail variant when needed. --- docs/FRONTEND.md | 18 ++++ src/web/Dashboard.tsx | 13 +++ src/web/RunDetail.tsx | 8 ++ src/web/Search.tsx | 3 + src/web/documentTitle.test.ts | 196 ++++++++++++++++++++++++++++++++++ src/web/documentTitle.ts | 76 +++++++++++++ src/web/index.html | 5 + src/web/useDocumentChrome.ts | 26 +++++ 8 files changed, 345 insertions(+) create mode 100644 src/web/documentTitle.test.ts create mode 100644 src/web/documentTitle.ts create mode 100644 src/web/useDocumentChrome.ts diff --git a/docs/FRONTEND.md b/docs/FRONTEND.md index 7acbc13..9e2f675 100644 --- a/docs/FRONTEND.md +++ b/docs/FRONTEND.md @@ -48,6 +48,24 @@ from a component. - **Monospace for identifiers.** Issue IDs, run IDs, timestamps use `font-mono`. +## Tab title + favicon + +Each top-level route owns its tab title via +[`useDocumentChrome`](../src/web/useDocumentChrome.ts), backed by pure helpers +in [`documentTitle.ts`](../src/web/documentTitle.ts): + +| Route | Title pattern | Favicon | +| ----------- | -------------------------------------------- | ----------- | +| `Dashboard` | `● N running · Symphony` / `Symphony` | neutral "S" | +| `Dashboard` | `✖ · Symphony` (within 5 m) | red "S" | +| `RunDetail` | ` · · Symphony` | per-status | +| `Search` | ` · search · Symphony` | neutral "S" | + +Failures (`failed` / `rate_limited`) escalate the dashboard tab title for +`RECENT_FAILURE_WINDOW_MS` (5 min) past the run's `finishedAt`. Dashboard +re-derives `now` on a 30 s `setInterval` so escalations expire on their own +without an SSE event. + ## Styling conventions - Status colors are sourced once from `Dashboard.tsx` and reused; don't duplicate diff --git a/src/web/Dashboard.tsx b/src/web/Dashboard.tsx index 45a37a4..ab077aa 100644 --- a/src/web/Dashboard.tsx +++ b/src/web/Dashboard.tsx @@ -17,6 +17,11 @@ import { MetricsPanel } from "./MetricsPanel.js"; import { ErrorFeed } from "./ErrorFeed.js"; import { SettingsPanel } from "./SettingsPanel.js"; import { StatusBadge, formatTs } from "./shared.js"; +import { dashboardFaviconColor, dashboardTitle } from "./documentTitle.js"; +import { useDocumentChrome } from "./useDocumentChrome.js"; + +// 30s keeps the recent-failure escalation honest without churning the DOM. +const TITLE_REFRESH_INTERVAL_MS = 30_000; export { StatusBadge } from "./shared.js"; @@ -30,6 +35,14 @@ export function Dashboard() { const [events, setEvents] = useState([]); const [settings, setSettings] = useState(null); const [workflow, setWorkflow] = useState(null); + const [now, setNow] = useState(() => Date.now()); + + useEffect(() => { + const id = window.setInterval(() => setNow(Date.now()), TITLE_REFRESH_INTERVAL_MS); + return () => window.clearInterval(id); + }, []); + + useDocumentChrome(dashboardTitle(runs, now), dashboardFaviconColor(runs, now)); const streamStatus = useEventStream( ["runStarted", "turn", "runFinished", "usageUpdated", "tick", "settingsUpdated"], diff --git a/src/web/RunDetail.tsx b/src/web/RunDetail.tsx index 18a6417..ee9aec6 100644 --- a/src/web/RunDetail.tsx +++ b/src/web/RunDetail.tsx @@ -2,6 +2,8 @@ import { useEffect, useState } from "react"; import { fetchRun, type ApiEvent, type ApiRun, type ApiRunDetail } from "./api.js"; import { StatusBadge } from "./Dashboard.js"; import { useEventStream } from "./useEventStream.js"; +import { APP_NAME, runDetailTitle, runFaviconColor } from "./documentTitle.js"; +import { useDocumentChrome } from "./useDocumentChrome.js"; type LoadState = | { tag: "loading" } @@ -11,6 +13,12 @@ type LoadState = export function RunDetail({ runId }: { runId: string }) { const [state, setState] = useState({ tag: "loading" }); + const titleRun = state.tag === "ready" ? state.detail.run : null; + useDocumentChrome( + titleRun ? runDetailTitle(titleRun) : APP_NAME, + titleRun ? runFaviconColor(titleRun) : "neutral", + ); + useEventStream(["turn", "runFinished"], async () => { try { const detail = await fetchRun(runId); diff --git a/src/web/Search.tsx b/src/web/Search.tsx index 2b9d8a5..d188666 100644 --- a/src/web/Search.tsx +++ b/src/web/Search.tsx @@ -1,8 +1,11 @@ import { useEffect, useMemo, useState } from "react"; import { searchRuns, type ApiSearchMatch } from "./api.js"; import { StatusBadge } from "./Dashboard.js"; +import { APP_NAME } from "./documentTitle.js"; +import { useDocumentChrome } from "./useDocumentChrome.js"; export function Search({ query }: { query: string }) { + useDocumentChrome(query ? `${query} · search · ${APP_NAME}` : `search · ${APP_NAME}`, "neutral"); const [input, setInput] = useState(query); const [state, setState] = useState< | { tag: "idle" } diff --git a/src/web/documentTitle.test.ts b/src/web/documentTitle.test.ts new file mode 100644 index 0000000..357ce15 --- /dev/null +++ b/src/web/documentTitle.test.ts @@ -0,0 +1,196 @@ +import { describe, expect, it } from "vitest"; +import type { ApiRun } from "./api.js"; +import { + APP_NAME, + buildFaviconHref, + countRunning, + dashboardFaviconColor, + dashboardTitle, + findMostRecentFailure, + isRecentFailure, + runDetailTitle, + runFaviconColor, + RECENT_FAILURE_WINDOW_MS, +} from "./documentTitle.js"; + +const NOW = Date.parse("2026-05-04T12:00:00.000Z"); + +function makeRun(overrides: Partial = {}): ApiRun { + return { + id: "run-1", + issueId: "issue-1", + issueIdentifier: "BEN-99", + issueTitle: null, + status: "running", + startedAt: new Date(NOW - 60_000).toISOString(), + finishedAt: null, + scenario: null, + turnCount: 0, + tokensInput: null, + tokensOutput: null, + tokensCacheRead: null, + tokensCacheCreation: null, + totalCostUsd: null, + authStatus: null, + startFiveHourUtil: null, + startSevenDayUtil: null, + ...overrides, + }; +} + +describe("dashboardTitle", () => { + it("falls back to the app name when nothing notable is happening", () => { + expect(dashboardTitle([], NOW)).toBe(APP_NAME); + expect( + dashboardTitle( + [makeRun({ status: "completed", finishedAt: new Date(NOW).toISOString() })], + NOW, + ), + ).toBe(APP_NAME); + }); + + it("shows the running count when at least one run is in flight", () => { + const runs = [ + makeRun({ id: "a", status: "running" }), + makeRun({ id: "b", status: "running" }), + makeRun({ + id: "c", + status: "completed", + finishedAt: new Date(NOW - 10 * 60_000).toISOString(), + }), + ]; + expect(dashboardTitle(runs, NOW)).toBe(`● 2 running · ${APP_NAME}`); + }); + + it("escalates to the most recent failure within the window", () => { + const runs = [ + makeRun({ id: "a", status: "running" }), + makeRun({ + id: "b", + issueIdentifier: "BEN-30", + status: "failed", + finishedAt: new Date(NOW - 60_000).toISOString(), + }), + makeRun({ + id: "c", + issueIdentifier: "BEN-31", + status: "rate_limited", + finishedAt: new Date(NOW - 30_000).toISOString(), + }), + ]; + expect(dashboardTitle(runs, NOW)).toBe(`✖ BEN-31 rate_limited · ${APP_NAME}`); + }); + + it("ignores stale failures past the window", () => { + const runs = [ + makeRun({ + id: "b", + issueIdentifier: "BEN-30", + status: "failed", + finishedAt: new Date(NOW - RECENT_FAILURE_WINDOW_MS - 1_000).toISOString(), + }), + ]; + expect(dashboardTitle(runs, NOW)).toBe(APP_NAME); + }); + + it("ignores failures with no finishedAt", () => { + const runs = [ + makeRun({ + id: "b", + issueIdentifier: "BEN-30", + status: "failed", + finishedAt: null, + }), + ]; + expect(dashboardTitle(runs, NOW)).toBe(APP_NAME); + }); +}); + +describe("runDetailTitle", () => { + it("reads issue id · status · app", () => { + expect(runDetailTitle({ issueIdentifier: "BEN-30", status: "running" })).toBe( + `BEN-30 · running · ${APP_NAME}`, + ); + }); + + it("does not coerce identifiers", () => { + expect(runDetailTitle({ issueIdentifier: "ABC-1", status: "completed" })).toBe( + `ABC-1 · completed · ${APP_NAME}`, + ); + }); +}); + +describe("favicon color helpers", () => { + it("dashboardFaviconColor flips to fail on a recent failure", () => { + const recent = [ + makeRun({ + status: "failed", + finishedAt: new Date(NOW - 60_000).toISOString(), + }), + ]; + expect(dashboardFaviconColor(recent, NOW)).toBe("fail"); + expect(dashboardFaviconColor([], NOW)).toBe("neutral"); + }); + + it("runFaviconColor reflects the current run status", () => { + expect(runFaviconColor({ status: "running" })).toBe("neutral"); + expect(runFaviconColor({ status: "failed" })).toBe("fail"); + expect(runFaviconColor({ status: "rate_limited" })).toBe("fail"); + expect(runFaviconColor({ status: "completed" })).toBe("neutral"); + }); +}); + +describe("buildFaviconHref", () => { + it("encodes a self-contained svg data URL", () => { + const href = buildFaviconHref("neutral"); + expect(href.startsWith("data:image/svg+xml;utf8,")).toBe(true); + const decoded = decodeURIComponent(href.slice("data:image/svg+xml;utf8,".length)); + expect(decoded).toMatch(/ { + const decoded = decodeURIComponent( + buildFaviconHref("fail").slice("data:image/svg+xml;utf8,".length), + ); + expect(decoded).toContain("#fb7185"); + }); +}); + +describe("low-level helpers", () => { + it("countRunning ignores non-running statuses", () => { + expect( + countRunning([ + makeRun({ status: "running" }), + makeRun({ status: "failed" }), + makeRun({ status: "completed" }), + makeRun({ status: "running" }), + ]), + ).toBe(2); + }); + + it("findMostRecentFailure picks the latest failure within the window", () => { + const oldest = makeRun({ + id: "a", + issueIdentifier: "BEN-1", + status: "failed", + finishedAt: new Date(NOW - 4 * 60_000).toISOString(), + }); + const newest = makeRun({ + id: "b", + issueIdentifier: "BEN-2", + status: "rate_limited", + finishedAt: new Date(NOW - 30_000).toISOString(), + }); + expect(findMostRecentFailure([oldest, newest], NOW)?.issueIdentifier).toBe("BEN-2"); + expect(findMostRecentFailure([oldest, newest], NOW + RECENT_FAILURE_WINDOW_MS)).toBeNull(); + }); + + it("isRecentFailure rejects future-dated finishedAt (clock skew)", () => { + const future = makeRun({ + status: "failed", + finishedAt: new Date(NOW + 60_000).toISOString(), + }); + expect(isRecentFailure(future, NOW)).toBe(false); + }); +}); diff --git a/src/web/documentTitle.ts b/src/web/documentTitle.ts new file mode 100644 index 0000000..69de437 --- /dev/null +++ b/src/web/documentTitle.ts @@ -0,0 +1,76 @@ +import type { ApiRun } from "./api.js"; + +export const APP_NAME = "Symphony"; +export const RECENT_FAILURE_WINDOW_MS = 5 * 60 * 1000; + +const FAILURE_STATUSES = new Set(["failed", "rate_limited"]); + +export type FaviconColor = "neutral" | "fail"; + +export function isRecentFailure(run: ApiRun, nowMs: number): boolean { + if (!FAILURE_STATUSES.has(run.status)) return false; + if (!run.finishedAt) return false; + const finished = Date.parse(run.finishedAt); + if (!Number.isFinite(finished)) return false; + const age = nowMs - finished; + return age >= 0 && age <= RECENT_FAILURE_WINDOW_MS; +} + +export function findMostRecentFailure(runs: ApiRun[], nowMs: number): ApiRun | null { + let best: ApiRun | null = null; + let bestTs = -Infinity; + for (const r of runs) { + if (!isRecentFailure(r, nowMs)) continue; + const t = Date.parse(r.finishedAt as string); + if (t > bestTs) { + best = r; + bestTs = t; + } + } + return best; +} + +export function countRunning(runs: ApiRun[]): number { + return runs.filter((r) => r.status === "running").length; +} + +export function dashboardTitle(runs: ApiRun[], nowMs: number): string { + const failure = findMostRecentFailure(runs, nowMs); + if (failure) { + return `✖ ${failure.issueIdentifier} ${failure.status} · ${APP_NAME}`; + } + const running = countRunning(runs); + if (running > 0) { + return `● ${running} running · ${APP_NAME}`; + } + return APP_NAME; +} + +export function runDetailTitle(run: { issueIdentifier: string; status: string }): string { + return `${run.issueIdentifier} · ${run.status} · ${APP_NAME}`; +} + +export function dashboardFaviconColor(runs: ApiRun[], nowMs: number): FaviconColor { + return findMostRecentFailure(runs, nowMs) ? "fail" : "neutral"; +} + +export function runFaviconColor(run: { status: string }): FaviconColor { + return FAILURE_STATUSES.has(run.status) ? "fail" : "neutral"; +} + +const FAVICON_FOREGROUND: Record = { + neutral: "#cbd5f5", + fail: "#fb7185", +}; + +export function buildFaviconHref(color: FaviconColor): string { + const fg = FAVICON_FOREGROUND[color]; + const svg = + `` + + `` + + `S` + + ``; + return `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`; +} diff --git a/src/web/index.html b/src/web/index.html index 83363fe..babefeb 100644 --- a/src/web/index.html +++ b/src/web/index.html @@ -4,6 +4,11 @@ Symphony +
diff --git a/src/web/useDocumentChrome.ts b/src/web/useDocumentChrome.ts new file mode 100644 index 0000000..98a40f8 --- /dev/null +++ b/src/web/useDocumentChrome.ts @@ -0,0 +1,26 @@ +import { useEffect } from "react"; +import { buildFaviconHref, type FaviconColor } from "./documentTitle.js"; + +export function useDocumentChrome(title: string, favicon: FaviconColor): void { + useEffect(() => { + document.title = title; + }, [title]); + + useEffect(() => { + const link = ensureFaviconLink(); + link.href = buildFaviconHref(favicon); + }, [favicon]); +} + +function ensureFaviconLink(): HTMLLinkElement { + const existing = document.querySelector("link[rel='icon']"); + if (existing) { + if (existing.type !== "image/svg+xml") existing.type = "image/svg+xml"; + return existing; + } + const link = document.createElement("link"); + link.rel = "icon"; + link.type = "image/svg+xml"; + document.head.appendChild(link); + return link; +}