Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions docs/FRONTEND.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` | `✖ <issue> <status> · Symphony` (within 5 m) | red "S" |
| `RunDetail` | `<issue> · <status> · Symphony` | per-status |
| `Search` | `<query> · 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
Expand Down
13 changes: 13 additions & 0 deletions src/web/Dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -30,6 +35,14 @@ export function Dashboard() {
const [events, setEvents] = useState<ApiEvent[]>([]);
const [settings, setSettings] = useState<ApiOrchestratorSettings | null>(null);
const [workflow, setWorkflow] = useState<ApiWorkflowSummary | null>(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"],
Expand Down
8 changes: 8 additions & 0 deletions src/web/RunDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand All @@ -11,6 +13,12 @@ type LoadState =
export function RunDetail({ runId }: { runId: string }) {
const [state, setState] = useState<LoadState>({ 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);
Expand Down
3 changes: 3 additions & 0 deletions src/web/Search.tsx
Original file line number Diff line number Diff line change
@@ -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" }
Expand Down
196 changes: 196 additions & 0 deletions src/web/documentTitle.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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(/<svg /);
expect(decoded).toContain("#cbd5f5");
});

it("uses a distinct color for the fail state", () => {
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);
});
});
76 changes: 76 additions & 0 deletions src/web/documentTitle.ts
Original file line number Diff line number Diff line change
@@ -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<FaviconColor, string> = {
neutral: "#cbd5f5",
fail: "#fb7185",
};

export function buildFaviconHref(color: FaviconColor): string {
const fg = FAVICON_FOREGROUND[color];
const svg =
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">` +
`<rect width="64" height="64" rx="12" fill="#0f172a"/>` +
`<text x="32" y="46" text-anchor="middle" ` +
`font-family="system-ui,-apple-system,Segoe UI,Roboto,sans-serif" ` +
`font-size="44" font-weight="700" fill="${fg}">S</text>` +
`</svg>`;
return `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`;
}
5 changes: 5 additions & 0 deletions src/web/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Symphony</title>
<link
rel="icon"
type="image/svg+xml"
href="data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20rx%3D%2212%22%20fill%3D%22%230f172a%22%2F%3E%3Ctext%20x%3D%2232%22%20y%3D%2246%22%20text-anchor%3D%22middle%22%20font-family%3D%22system-ui%2C-apple-system%2CSegoe%20UI%2CRoboto%2Csans-serif%22%20font-size%3D%2244%22%20font-weight%3D%22700%22%20fill%3D%22%23cbd5f5%22%3ES%3C%2Ftext%3E%3C%2Fsvg%3E"
/>
</head>
<body class="bg-slate-950 text-slate-100">
<div id="root"></div>
Expand Down
26 changes: 26 additions & 0 deletions src/web/useDocumentChrome.ts
Original file line number Diff line number Diff line change
@@ -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<HTMLLinkElement>("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;
}
Loading