Skip to content
Merged
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
17 changes: 17 additions & 0 deletions docs/FRONTEND.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,23 @@ file. Don't install a router.
All fetches live in [`src/web/api.ts`](../src/web/api.ts). Never call `fetch`
from a component.

### SSE → state updates

The dashboard does **not** refetch `/api/runs` on every `turn` event. The
in-memory runs list is patched from the SSE payload via the pure helpers in
[`dashboardEvents.ts`](../src/web/dashboardEvents.ts):

- `turn` → `applyTurnEvent` increments `turnCount` on the matching row.
- `runFinished` → `applyRunFinishedEvent` stamps `status` + `finishedAt`
immediately, then a single `GET /api/runs/:id` (`replaceRun`) fills in
authoritative token totals + cost.
- `runStarted` → falls back to `GET /api/runs` (rare event, payload is
`Issue`-shaped not `ApiRun`-shaped).
- A `turn` event for an unknown `runId` (stale tab, missed `runStarted`)
falls back to a full `GET /api/runs`.

This keeps a live run from triggering a full table refetch per turn.

## Conventions

- **Accessibility first.** Every interactive element is a real `<button>` or
Expand Down
46 changes: 43 additions & 3 deletions src/web/Dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
import {
fetchHealth,
fetchRecentEvents,
fetchRun,
fetchRuns,
fetchSettings,
type ApiEvent,
Expand All @@ -11,6 +12,7 @@ import {
type ApiUsage,
type ApiWorkflowSummary,
} from "./api.js";
import { applyRunFinishedEvent, applyTurnEvent, hasRun, replaceRun } from "./dashboardEvents.js";
import { useEventStream } from "./useEventStream.js";
import { HealthStrip } from "./HealthStrip.js";
import { MetricsPanel } from "./MetricsPanel.js";
Expand Down Expand Up @@ -46,9 +48,47 @@ export function Dashboard() {
setSettings(data as ApiOrchestratorSettings);
return;
}
const [freshRuns, freshEvents] = await Promise.all([fetchRuns(), fetchRecentEvents()]);
setRuns(freshRuns);
setEvents(freshEvents.events);
const payload = data as { runId?: string; status?: string } | null;
const runId = payload?.runId;
if (name === "turn" && runId) {
let needsFullRefetch = false;
setRuns((prev) => {
if (!hasRun(prev, runId)) {
needsFullRefetch = true;
return prev;
}
return applyTurnEvent(prev, runId).next;
});
if (needsFullRefetch) {
const fresh = await fetchRuns();
setRuns(fresh);
}
return;
}
if (name === "runFinished" && runId) {
const finishedAt = new Date().toISOString();
const status = typeof payload?.status === "string" ? payload.status : "completed";
setRuns((prev) =>
hasRun(prev, runId) ? applyRunFinishedEvent(prev, runId, status, finishedAt).next : prev,
);
try {
const detail = await fetchRun(runId);
setRuns((prev) =>
hasRun(prev, runId) ? replaceRun(prev, detail.run) : [detail.run, ...prev],
);
} catch {
const fresh = await fetchRuns();
setRuns(fresh);
}
const recent = await fetchRecentEvents();
setEvents(recent.events);
return;
}
if (name === "runStarted" && runId) {
const fresh = await fetchRuns();
setRuns(fresh);
return;
}
},
);

Expand Down
113 changes: 113 additions & 0 deletions src/web/dashboardEvents.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { describe, expect, it } from "vitest";
import type { ApiRun } from "./api.js";
import { applyRunFinishedEvent, applyTurnEvent, hasRun, replaceRun } from "./dashboardEvents.js";

function makeRun(overrides: Partial<ApiRun> = {}): ApiRun {
return {
id: "run-1",
issueId: "issue-1",
issueIdentifier: "BEN-1",
issueTitle: "demo",
status: "running",
startedAt: "2026-05-03T00:00:00.000Z",
finishedAt: null,
scenario: null,
turnCount: 0,
tokensInput: null,
tokensOutput: null,
tokensCacheRead: null,
tokensCacheCreation: null,
totalCostUsd: null,
authStatus: null,
startFiveHourUtil: null,
startSevenDayUtil: null,
...overrides,
};
}

describe("applyTurnEvent", () => {
it("increments turnCount on the matching run", () => {
const runs = [makeRun({ id: "a", turnCount: 2 }), makeRun({ id: "b", turnCount: 5 })];
const { next, matched } = applyTurnEvent(runs, "b");
expect(matched).toBe(true);
expect(next.map((r) => [r.id, r.turnCount])).toEqual([
["a", 2],
["b", 6],
]);
});

it("returns the same array reference when the run is not present", () => {
const runs = [makeRun({ id: "a" })];
const result = applyTurnEvent(runs, "missing");
expect(result.matched).toBe(false);
expect(result.next).toBe(runs);
});

it("does not mutate the input array or row", () => {
const row = makeRun({ id: "a", turnCount: 1 });
const runs = [row];
applyTurnEvent(runs, "a");
expect(row.turnCount).toBe(1);
expect(runs[0]).toBe(row);
});
});

describe("applyRunFinishedEvent", () => {
it("updates status and stamps finishedAt when missing", () => {
const runs = [makeRun({ id: "a", status: "running", finishedAt: null })];
const finishedAt = "2026-05-03T01:23:45.000Z";
const { next, matched } = applyRunFinishedEvent(runs, "a", "completed", finishedAt);
expect(matched).toBe(true);
expect(next[0].status).toBe("completed");
expect(next[0].finishedAt).toBe(finishedAt);
});

it("preserves an existing finishedAt rather than overwriting it", () => {
const earlier = "2026-05-02T00:00:00.000Z";
const runs = [makeRun({ id: "a", status: "running", finishedAt: earlier })];
const { next } = applyRunFinishedEvent(runs, "a", "failed", "2026-05-03T01:23:45.000Z");
expect(next[0].finishedAt).toBe(earlier);
expect(next[0].status).toBe("failed");
});

it("returns the original array when no run matches", () => {
const runs = [makeRun({ id: "a" })];
const result = applyRunFinishedEvent(runs, "missing", "completed", "2026-05-03T00:00:00.000Z");
expect(result.matched).toBe(false);
expect(result.next).toBe(runs);
});
});

describe("replaceRun", () => {
it("replaces the matching row by id", () => {
const runs = [makeRun({ id: "a", turnCount: 1 }), makeRun({ id: "b", turnCount: 2 })];
const updated = makeRun({
id: "b",
turnCount: 9,
status: "completed",
finishedAt: "2026-05-03T02:00:00.000Z",
tokensInput: 100,
});
const next = replaceRun(runs, updated);
expect(next[0]).toBe(runs[0]);
expect(next[1]).toBe(updated);
});

it("returns the input array when the id is unknown", () => {
const runs = [makeRun({ id: "a" })];
const updated = makeRun({ id: "missing" });
expect(replaceRun(runs, updated)).toBe(runs);
});
});

describe("hasRun", () => {
it("is true when the id is present", () => {
expect(hasRun([makeRun({ id: "a" })], "a")).toBe(true);
});
it("is false when the id is absent", () => {
expect(hasRun([makeRun({ id: "a" })], "b")).toBe(false);
});
it("is false on an empty list", () => {
expect(hasRun([], "a")).toBe(false);
});
});
45 changes: 45 additions & 0 deletions src/web/dashboardEvents.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import type { ApiRun } from "./api.js";

export interface ApplyResult {
next: ApiRun[];
matched: boolean;
}

export function applyTurnEvent(runs: ApiRun[], runId: string): ApplyResult {
let matched = false;
const next = runs.map((r) => {
if (r.id !== runId) return r;
matched = true;
return { ...r, turnCount: r.turnCount + 1 };
});
return matched ? { next, matched } : { next: runs, matched };
}

export function applyRunFinishedEvent(
runs: ApiRun[],
runId: string,
status: string,
finishedAtIso: string,
): ApplyResult {
let matched = false;
const next = runs.map((r) => {
if (r.id !== runId) return r;
matched = true;
return { ...r, status, finishedAt: r.finishedAt ?? finishedAtIso };
});
return matched ? { next, matched } : { next: runs, matched };
}

export function replaceRun(runs: ApiRun[], updated: ApiRun): ApiRun[] {
let matched = false;
const next = runs.map((r) => {
if (r.id !== updated.id) return r;
matched = true;
return updated;
});
return matched ? next : runs;
}

export function hasRun(runs: ApiRun[], runId: string): boolean {
return runs.some((r) => r.id === runId);
}
Loading