feat(ui): add i18next plumbing and move the Workflows page onto it (#49144)

* test(ui): pin the Workflows page copy with characterization tests

Locks the header, table, filters drawer, detail drawer, relative time and duration text on the Workflows page so the upcoming move onto i18next can be checked against it without editing any test

* feat(ui): add i18next plumbing and move the Workflows page copy onto it

Adds an English-only i18next instance with typed keys from src/i18n/locales/en.json, wraps the root layout in I18nProvider, and loads the instance in the test setup. Every string the Workflows page authors now renders through t(), while backend values such as run status, roles, event types and metadata keys stay raw. The rendered English is unchanged
This commit is contained in:
yuneng-jiang 2026-10-29 17:25:48 -07:00 • committed by GitHub
parent 68b5a737a1
commit 9252709cab
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 522 additions and 120 deletions

View file

@ -25,6 +25,7 @@
"clsx": "^2.1.1",
"date-fns": "^4.4.0",
"dayjs": "1.11.19",
"i18next": "26.5.11",
"jwt-decode": "4.0.0",
"lucide-react": "0.513.0",
"moment": "2.31.0",
@ -43,6 +44,7 @@
"react-dom": "19.2.8",
"react-hook-form": "7.82.0",
"react-hotkeys-hook": "5.3.3",
"react-i18next": "17.1.9",
"react-json-view-lite": "2.5.0",
"react-markdown": "9.1.0",
"react-reconciler": "0.33.0",
@ -412,9 +414,9 @@
}
},
"node_modules/@babel/runtime": {
"version": "7.29.2",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
"integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
"version": "7.29.10",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.10.tgz",
"integrity": "sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@ -7319,6 +7321,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/html-parse-stringify": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-4.0.1.tgz",
"integrity": "sha512-0zHsZJrK7S3K2aucXWL6ycoYJ/iNtIcFHC/nYQgFklPtrv5LpJctIiSCroWZWeuoXvuyFdzp6KzjJQ+OT5MfFw==",
"license": "MIT",
"funding": {
"url": "https://locize.com"
}
},
"node_modules/html-url-attributes": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
@ -7357,6 +7368,34 @@
"node": ">= 14"
}
},
"node_modules/i18next": {
"version": "26.5.11",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.5.11.tgz",
"integrity": "sha512-Wc0klBPKxqZ2tL2qdAnF8xvvnWgr1IMGSY2ikTiAvaFVbz7xiFARtkoU4RidBDw1SyziGROp0qxu6qnJ7l2Wug==",
"funding": [
{
"type": "individual",
"url": "https://www.locize.com/i18next"
},
{
"type": "individual",
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
},
{
"type": "individual",
"url": "https://www.locize.com"
}
],
"license": "MIT",
"peerDependencies": {
"typescript": "^5 || ^6 || ^7"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@ -10597,6 +10636,33 @@
"react-dom": ">=16.8.0"
}
},
"node_modules/react-i18next": {
"version": "17.1.9",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.1.9.tgz",
"integrity": "sha512-OgYqIH13XHPlWTaaI795Dz+k2AdKnXcOxNQVnf750T8Ij0TYWp1nYvXRpoJBulPqlyk2OAbn1PM4tFKiIj/byA==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.29.7",
"html-parse-stringify": "^4.0.1",
"use-sync-external-store": "^1.6.0"
},
"peerDependencies": {
"i18next": ">= 26.2.0",
"react": ">= 16.8.0",
"typescript": "^5 || ^6 || ^7"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
},
"react-native": {
"optional": true
},
"typescript": {
"optional": true
}
}
},
"node_modules/react-intersection-observer": {
"version": "11.0.1",
"resolved": "https://registry.npmjs.org/react-intersection-observer/-/react-intersection-observer-11.0.1.tgz",
@ -12105,7 +12171,7 @@
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",

View file

@ -42,6 +42,7 @@
"clsx": "^2.1.1",
"date-fns": "^4.4.0",
"dayjs": "1.11.19",
"i18next": "26.5.11",
"jwt-decode": "4.0.0",
"lucide-react": "0.513.0",
"moment": "2.31.0",
@ -60,6 +61,7 @@
"react-dom": "19.2.8",
"react-hook-form": "7.82.0",
"react-hotkeys-hook": "5.3.3",
"react-i18next": "17.1.9",
"react-json-view-lite": "2.5.0",
"react-markdown": "9.1.0",
"react-reconciler": "0.33.0",

View file

@ -14,7 +14,7 @@ interface FakeRun {
status: string;
workflow_type: string;
created_at: string;
metadata: { title?: string; state?: string } | null;
metadata: Record<string, string> | null;
}
const RUNS: FakeRun[] = [
@ -34,16 +34,16 @@ const RUNS: FakeRun[] = [
},
];
function mockFetch(runs: FakeRun[]) {
function mockFetch(runs: FakeRun[], events: FakeEvent[] = [], messages: FakeMessage[] = []) {
return vi.fn((url: string) => {
if (url.includes("/runs?limit")) {
return Promise.resolve({ ok: true, json: () => Promise.resolve({ runs }) });
}
if (url.includes("/events")) {
return Promise.resolve({ ok: true, json: () => Promise.resolve({ events: [] }) });
return Promise.resolve({ ok: true, json: () => Promise.resolve({ events }) });
}
if (url.includes("/messages")) {
return Promise.resolve({ ok: true, json: () => Promise.resolve({ messages: [] }) });
return Promise.resolve({ ok: true, json: () => Promise.resolve({ messages }) });
}
return Promise.resolve({ ok: false, status: 404, json: () => Promise.resolve({}) });
});
@ -114,7 +114,7 @@ interface FakeMessage {
created_at: string;
}
const DETAIL_RUN = {
const DETAIL_RUN: FakeRun = {
run_id: "run-aaaaaaaa-1111",
status: "completed",
workflow_type: "grill",
@ -151,33 +151,21 @@ const DETAIL_MESSAGES: FakeMessage[] = [
},
];
function mockDetailFetch(events: FakeEvent[], messages: FakeMessage[]) {
return vi.fn((url: string) => {
if (url.includes("/runs?limit")) {
return Promise.resolve({ ok: true, json: () => Promise.resolve({ runs: [DETAIL_RUN] }) });
}
if (url.includes("/events")) {
return Promise.resolve({ ok: true, json: () => Promise.resolve({ events }) });
}
if (url.includes("/messages")) {
return Promise.resolve({ ok: true, json: () => Promise.resolve({ messages }) });
}
return Promise.resolve({ ok: false, status: 404, json: () => Promise.resolve({}) });
});
}
async function openDetailDrawer(events = DETAIL_EVENTS, messages = DETAIL_MESSAGES) {
async function openRunDrawer(run: FakeRun, events: FakeEvent[], messages: FakeMessage[]) {
const user = userEvent.setup();
const fetchSpy = mockDetailFetch(events, messages);
const fetchSpy = mockFetch([run], events, messages);
vi.stubGlobal("fetch", fetchSpy);
render(<WorkflowRuns accessToken="tok" />);
await user.click(await screen.findByText("First run"));
await user.click(await screen.findByText(run.metadata?.title ?? run.workflow_type));
const drawer = await screen.findByRole("dialog");
await waitFor(() => expect(within(drawer).getByText("Timeline")).toBeInTheDocument());
expect(await within(drawer).findByText("Timeline")).toBeInTheDocument();
return { user, fetchSpy, drawer };
}
const openDetailDrawer = (events = DETAIL_EVENTS, messages = DETAIL_MESSAGES) =>
openRunDrawer(DETAIL_RUN, events, messages);
describe("WorkflowRuns detail drawer", () => {
it("shows the run's identity and metadata fields", async () => {
const { drawer } = await openDetailDrawer();
@ -244,3 +232,219 @@ describe("WorkflowRuns detail drawer", () => {
await waitFor(() => expect(screen.queryAllByRole("dialog")).toHaveLength(0));
});
});
const FULL_RUN: FakeRun = {
run_id: "run-cccccccc-3333",
status: "running",
workflow_type: "autoship",
created_at: "2026-01-01T00:00:00.000Z",
metadata: {
title: "Full run",
state: "reviewing",
pr_url: "https://example.com/pr/2",
worktree_path: "/tmp/wt/full",
grill_session_id: "grill-1",
session_id: "sess-1",
plan_text: "step ".repeat(26),
},
};
const TIMED_EVENTS: FakeEvent[] = [
{
event_id: "ev-a",
event_type: "step.started",
step_name: "plan",
sequence_number: 1,
created_at: "2026-01-01T00:00:00.100Z",
data: null,
},
{
event_id: "ev-b",
event_type: "step.started",
step_name: "implement",
sequence_number: 2,
created_at: "2026-01-01T00:00:00.165Z",
data: null,
},
{
event_id: "ev-c",
event_type: "hook.waiting",
step_name: "review",
sequence_number: 3,
created_at: "2026-01-01T00:00:01.365Z",
data: null,
},
];
describe("WorkflowRuns page copy", () => {
it("announces the loading state while runs are being fetched", async () => {
vi.stubGlobal(
"fetch",
vi.fn(() => new Promise(() => {})),
);
render(<WorkflowRuns accessToken="tok" />);
expect(await screen.findByText("Loading workflow runs…")).toBeInTheDocument();
});
it("labels the page header, the table columns and the search box", async () => {
vi.stubGlobal("fetch", mockFetch(RUNS));
render(<WorkflowRuns accessToken="tok" />);
await screen.findByText("First run");
expect(screen.getByText("Workflow Runs")).toBeInTheDocument();
expect(screen.getByText("Durable state tracking for agents and automated workflows")).toBeInTheDocument();
expect(screen.getAllByRole("columnheader").map((header) => header.textContent)).toEqual([
"Run",
"Type",
"Status",
"Created",
]);
expect(screen.getByPlaceholderText("Search runs…")).toBeInTheDocument();
});
it("names every column in the Columns menu", async () => {
const user = userEvent.setup();
vi.stubGlobal("fetch", mockFetch(RUNS));
render(<WorkflowRuns accessToken="tok" />);
await screen.findByText("First run");
await user.click(screen.getByRole("button", { name: "Columns" }));
expect((await screen.findAllByRole("menuitemcheckbox")).map((item) => item.textContent)).toEqual([
"Run",
"Type",
"Status",
"Created",
]);
});
it("labels the filters drawer and offers every run status as a filter", async () => {
const user = userEvent.setup();
vi.stubGlobal("fetch", mockFetch(RUNS));
render(<WorkflowRuns accessToken="tok" />);
await screen.findByText("First run");
await user.click(screen.getByRole("button", { name: "Filters" }));
const filters = await screen.findByRole("dialog", { name: "Filters" });
expect(filters).toHaveAccessibleDescription("Narrow down workflow runs");
expect(within(filters).getByText("Status")).toBeInTheDocument();
expect(within(filters).getByText("Type")).toBeInTheDocument();
expect(within(filters).getByPlaceholderText("Filter by type…")).toBeInTheDocument();
const statusSelect = within(filters).getByRole("combobox");
expect(statusSelect).toHaveTextContent("All statuses");
await user.click(statusSelect);
expect((await screen.findAllByRole("option")).map((option) => option.textContent)).toEqual([
"All statuses",
"Pending",
"Running",
"Paused",
"Completed",
"Failed",
]);
await user.click(screen.getByRole("option", { name: "Running" }));
expect(statusSelect).toHaveTextContent("Running");
});
it("labels the detail drawer, its controls and every primary metadata field", async () => {
const { user, drawer } = await openRunDrawer(FULL_RUN, [TIMED_EVENTS[0]], []);
expect(drawer).toHaveAccessibleName("Workflow run details");
expect(drawer).toHaveAccessibleDescription("Metadata, timeline and messages for the selected workflow run");
for (const label of ["status", "created", "pr", "state", "worktree", "grill session", "session"]) {
expect(within(drawer).getByText(label)).toBeInTheDocument();
}
expect(within(drawer).getByRole("button", { name: "close" })).toBeInTheDocument();
expect(within(drawer).getByRole("button", { name: "Refresh" })).toBeInTheDocument();
expect(within(drawer).getByText("1 event")).toBeInTheDocument();
await user.click(within(drawer).getByRole("button", { name: /Messages/ }));
expect(await within(drawer).findByText("No messages")).toBeInTheDocument();
});
it("counts zero events in the timeline header", async () => {
const { drawer } = await openRunDrawer(FULL_RUN, [], []);
expect(within(drawer).getByText("0 events")).toBeInTheDocument();
});
it("collapses a long metadata value behind a more toggle that flips to less", async () => {
const { user, drawer } = await openRunDrawer(FULL_RUN, [], []);
await user.click(within(drawer).getByRole("button", { name: "more" }));
expect(within(drawer).getByRole("button", { name: "less" })).toBeInTheDocument();
});
it("formats timeline durations in milliseconds under a second and in seconds above it", async () => {
const { drawer } = await openRunDrawer(FULL_RUN, TIMED_EVENTS, []);
expect(within(drawer).getByText("65ms")).toBeInTheDocument();
expect(within(drawer).getByText("1.2s")).toBeInTheDocument();
expect(within(drawer).getAllByText("1.4s")).toHaveLength(2);
});
});
describe("WorkflowRuns relative times", () => {
afterEach(() => {
vi.useRealTimers();
});
it("shows created times in the largest whole unit and passes an unparseable date through", async () => {
const now = Date.parse("2026-03-10T12:00:00Z");
vi.useFakeTimers({ toFake: ["Date"] });
vi.setSystemTime(now);
const runCreated = (id: string, createdAt: string): FakeRun => ({
run_id: `run-${id}`,
status: "completed",
workflow_type: `type-${id}`,
created_at: createdAt,
metadata: null,
});
const secondsAgo = (seconds: number) => new Date(now - seconds * 1000).toISOString();
vi.stubGlobal(
"fetch",
mockFetch([
runCreated("a", secondsAgo(59)),
runCreated("b", secondsAgo(60)),
runCreated("c", secondsAgo(3600)),
runCreated("d", secondsAgo(86400)),
runCreated("e", "not-a-date"),
]),
);
render(<WorkflowRuns accessToken="tok" />);
expect(await screen.findByText("59s ago")).toBeInTheDocument();
expect(screen.getByText("1m ago")).toBeInTheDocument();
expect(screen.getByText("1h ago")).toBeInTheDocument();
expect(screen.getByText("1d ago")).toBeInTheDocument();
expect(screen.getByText("not-a-date")).toBeInTheDocument();
});
});
describe("WorkflowRuns detail drawer relative times", () => {
afterEach(() => {
vi.useRealTimers();
});
it("labels the run's age, each message's age and every field of a hovered timeline event", async () => {
vi.useFakeTimers({ toFake: ["Date"] });
vi.setSystemTime(Date.parse("2026-01-01T00:00:10Z"));
const { user, drawer } = await openDetailDrawer();
expect(within(drawer).getByText("10s ago")).toBeInTheDocument();
await user.hover(within(drawer).getByText("step.started"));
const tooltip = await screen.findByText(
(_, element) => element?.getAttribute("data-slot") === "tooltip-content",
{},
{ timeout: 3000 },
);
expect(tooltip).toHaveTextContent('type: step.startedstep: planseq: 1time: 9s agodata: {"attempt":1}');
await user.click(within(drawer).getByRole("button", { name: /Messages/ }));
expect(await within(drawer).findByText("8s ago")).toBeInTheDocument();
});
});

View file

@ -1,6 +1,8 @@
import React, { useState, useEffect, useCallback, useMemo } from "react";
import { ArrowLeft, ChevronDown, RefreshCw } from "lucide-react";
import type { ColumnDef, ColumnFiltersState } from "@tanstack/react-table";
import type { ParseKeys, TFunction } from "i18next";
import { useTranslation } from "react-i18next";
import { getGlobalLitellmHeaderName, proxyBaseUrl } from "@/components/networking";
import {
DataTable,
@ -70,12 +72,12 @@ const STATUS_DOT: Record<RunStatus, string> = {
};
const RUN_STATUS_OPTIONS: RunStatus[] = ["pending", "running", "paused", "completed", "failed"];
const STATUS_LABELS: Record<RunStatus, string> = {
pending: "Pending",
running: "Running",
paused: "Paused",
completed: "Completed",
failed: "Failed",
const STATUS_LABEL_KEYS: Record<RunStatus, ParseKeys> = {
pending: "workflows.status.pending",
running: "workflows.status.running",
paused: "workflows.status.paused",
completed: "workflows.status.completed",
failed: "workflows.status.failed",
};
const EVENT_COLOR: Record<string, { bar: string; text: string }> = {
@ -91,22 +93,22 @@ function eventStyle(type: string) {
// ── helpers ───────────────────────────────────────────────────────────────────
function timeAgo(iso: string): string {
function timeAgo(iso: string, t: TFunction): string {
const diff = Date.now() - new Date(iso).getTime();
if (isNaN(diff)) return iso;
const s = Math.floor(diff / 1000);
if (s < 60) return `${s}s ago`;
if (s < 60) return t("workflows.timeAgo.seconds", { count: s });
const m = Math.floor(s / 60);
if (m < 60) return `${m}m ago`;
if (m < 60) return t("workflows.timeAgo.minutes", { count: m });
const h = Math.floor(m / 60);
if (h < 24) return `${h}h ago`;
return `${Math.floor(h / 24)}d ago`;
if (h < 24) return t("workflows.timeAgo.hours", { count: h });
return t("workflows.timeAgo.days", { count: Math.floor(h / 24) });
}
function fmtDuration(ms: number): string {
function fmtDuration(ms: number, t: TFunction): string {
if (ms < 0) return "";
if (ms < 1000) return `${ms}ms`;
return `${(ms / 1000).toFixed(1)}s`;
if (ms < 1000) return t("workflows.duration.ms", { count: ms });
return t("workflows.duration.seconds", { value: (ms / 1000).toFixed(1) });
}
function runTitle(run: WorkflowRun): string {
@ -130,6 +132,7 @@ const StatusDot: React.FC<{ status: RunStatus; className?: string }> = ({ status
const TRUNCATE_AT = 120;
const TruncatedValue: React.FC<{ value: string }> = ({ value }) => {
const { t } = useTranslation();
const [expanded, setExpanded] = useState(false);
if (value.length <= TRUNCATE_AT) {
return <span className="break-all text-foreground">{value}</span>;
@ -138,7 +141,7 @@ const TruncatedValue: React.FC<{ value: string }> = ({ value }) => {
<span className="break-all text-foreground">
{expanded ? value : value.slice(0, TRUNCATE_AT) + "…"}
<Button variant="link" size="xs" className="h-auto px-1 py-0 text-[11px]" onClick={() => setExpanded((e) => !e)}>
{expanded ? "less" : "more"}
{expanded ? t("workflows.truncate.less") : t("workflows.truncate.more")}
</Button>
</span>
);
@ -147,13 +150,14 @@ const TruncatedValue: React.FC<{ value: string }> = ({ value }) => {
// ── metadata card ─────────────────────────────────────────────────────────────
const MetadataCard: React.FC<{ run: WorkflowRun }> = ({ run }) => {
const { t } = useTranslation();
const meta = run.metadata ?? {};
const primaryFields: { key: string; label: string }[] = [
{ key: "state", label: "state" },
{ key: "worktree_path", label: "worktree" },
{ key: "grill_session_id", label: "grill session" },
{ key: "session_id", label: "session" },
{ key: "state", label: t("workflows.metadata.state") },
{ key: "worktree_path", label: t("workflows.metadata.worktree") },
{ key: "grill_session_id", label: t("workflows.metadata.grillSession") },
{ key: "session_id", label: t("workflows.metadata.session") },
];
const primaryKeys = new Set(["title", ...primaryFields.map((f) => f.key)]);
@ -175,15 +179,15 @@ const MetadataCard: React.FC<{ run: WorkflowRun }> = ({ run }) => {
{/* key fields grid */}
<div className="grid grid-cols-[repeat(auto-fill,minmax(220px,1fr))] gap-x-6 gap-y-2 px-5 py-3 font-mono text-xs">
<FieldPair label="status">
<FieldPair label={t("workflows.metadata.status")}>
<span className="capitalize text-foreground">{run.status}</span>
</FieldPair>
<FieldPair label="created">
<span className="text-foreground">{timeAgo(run.created_at)}</span>
<FieldPair label={t("workflows.metadata.created")}>
<span className="text-foreground">{timeAgo(run.created_at, t)}</span>
</FieldPair>
{meta.pr_url && (
<FieldPair label="pr">
<FieldPair label={t("workflows.metadata.pr")}>
<a
href={String(meta.pr_url)}
target="_blank"
@ -232,15 +236,16 @@ const GanttTimeline: React.FC<{
run: WorkflowRun;
events: WorkflowRunEvent[];
}> = ({ run, events }) => {
const { t } = useTranslation();
if (events.length === 0) {
return <div className="py-4 font-mono text-xs text-muted-foreground">No events recorded</div>;
return <div className="py-4 font-mono text-xs text-muted-foreground">{t("workflows.drawer.noEvents")}</div>;
}
const runStart = new Date(run.created_at).getTime();
const eventTimes = events.map((e) => new Date(e.created_at).getTime());
const lastTime = Math.max(...eventTimes);
const totalSpan = Math.max(lastTime - runStart, 1);
const totalDur = fmtDuration(lastTime - runStart);
const totalDur = fmtDuration(lastTime - runStart, t);
return (
<TooltipProvider delay={300}>
@ -275,7 +280,7 @@ const GanttTimeline: React.FC<{
: lastTime + Math.max(totalSpan * 0.12, 500);
const widthPct = Math.max(8, ((nextTime - evTime) / totalSpan) * 100);
const style = eventStyle(ev.event_type);
const dur = fmtDuration(nextTime - evTime);
const dur = fmtDuration(nextTime - evTime, t);
return (
<React.Fragment key={ev.event_id}>
@ -303,24 +308,24 @@ const GanttTimeline: React.FC<{
<TooltipContent className="font-mono text-[11px] leading-relaxed">
<div className="flex flex-col">
<div>
<span className="opacity-70">type: </span>
<span className="opacity-70">{t("workflows.tooltip.type")}</span>
<span>{ev.event_type}</span>
</div>
<div>
<span className="opacity-70">step: </span>
<span className="opacity-70">{t("workflows.tooltip.step")}</span>
{ev.step_name}
</div>
<div>
<span className="opacity-70">seq: </span>
<span className="opacity-70">{t("workflows.tooltip.seq")}</span>
{ev.sequence_number}
</div>
<div>
<span className="opacity-70">time: </span>
{timeAgo(ev.created_at)}
<span className="opacity-70">{t("workflows.tooltip.time")}</span>
{timeAgo(ev.created_at, t)}
</div>
{ev.data && Object.keys(ev.data).length > 0 && (
<div>
<span className="opacity-70">data: </span>
<span className="opacity-70">{t("workflows.tooltip.data")}</span>
{JSON.stringify(ev.data)}
</div>
)}
@ -346,15 +351,18 @@ const ROLE_COLOR: Record<string, string> = {
tool_result: "text-warning",
};
const MessageRow: React.FC<{ msg: WorkflowRunMessage }> = ({ msg }) => (
<div className="grid grid-cols-[80px_minmax(0,1fr)] items-start gap-x-4 border-b py-2.5 font-mono text-xs">
<span className={cn("pt-px", ROLE_COLOR[msg.role] ?? "text-muted-foreground")}>[{msg.role}]</span>
<div>
<span className="block whitespace-pre-wrap break-words leading-relaxed text-foreground">{msg.content}</span>
<span className="mt-0.5 block text-[11px] text-muted-foreground">{timeAgo(msg.created_at)}</span>
const MessageRow: React.FC<{ msg: WorkflowRunMessage }> = ({ msg }) => {
const { t } = useTranslation();
return (
<div className="grid grid-cols-[80px_minmax(0,1fr)] items-start gap-x-4 border-b py-2.5 font-mono text-xs">
<span className={cn("pt-px", ROLE_COLOR[msg.role] ?? "text-muted-foreground")}>[{msg.role}]</span>
<div>
<span className="block whitespace-pre-wrap break-words leading-relaxed text-foreground">{msg.content}</span>
<span className="mt-0.5 block text-[11px] text-muted-foreground">{timeAgo(msg.created_at, t)}</span>
</div>
</div>
</div>
);
);
};
// ── collapsible section ───────────────────────────────────────────────────────
@ -379,6 +387,7 @@ const DetailSection: React.FC<{
// ── main component ────────────────────────────────────────────────────────────
const WorkflowRuns: React.FC<WorkflowRunsProps> = ({ accessToken }) => {
const { t } = useTranslation();
const [runs, setRuns] = useState<WorkflowRun[]>([]);
const [loadingRuns, setLoadingRuns] = useState(false);
const [selectedRun, setSelectedRun] = useState<WorkflowRun | null>(null);
@ -455,8 +464,8 @@ const WorkflowRuns: React.FC<WorkflowRunsProps> = ({ accessToken }) => {
{
id: "run",
accessorFn: (row) => `${runTitle(row)} ${row.run_id}`,
header: "Run",
meta: { title: "Run", skeleton: "twoLine" },
header: t("workflows.table.columns.run"),
meta: { title: t("workflows.table.columns.run"), skeleton: "twoLine" },
cell: ({ row }) => {
const run = row.original;
return (
@ -472,8 +481,8 @@ const WorkflowRuns: React.FC<WorkflowRunsProps> = ({ accessToken }) => {
},
{
accessorKey: "workflow_type",
header: "Type",
meta: { title: "Type" },
header: t("workflows.table.columns.type"),
meta: { title: t("workflows.table.columns.type") },
filterFn: "includesString",
cell: ({ row }) => (
<span className="font-mono text-xs text-muted-foreground">{row.original.workflow_type}</span>
@ -482,8 +491,8 @@ const WorkflowRuns: React.FC<WorkflowRunsProps> = ({ accessToken }) => {
{
id: "status",
accessorKey: "status",
header: "Status",
meta: { title: "Status" },
header: t("workflows.table.columns.status"),
meta: { title: t("workflows.table.columns.status") },
filterFn: "equalsString",
cell: ({ row }) => {
const run = row.original;
@ -497,22 +506,22 @@ const WorkflowRuns: React.FC<WorkflowRunsProps> = ({ accessToken }) => {
},
{
accessorKey: "created_at",
header: "Created",
meta: { title: "Created" },
cell: ({ row }) => <span className="text-xs text-muted-foreground">{timeAgo(row.original.created_at)}</span>,
header: t("workflows.table.columns.created"),
meta: { title: t("workflows.table.columns.created") },
cell: ({ row }) => <span className="text-xs text-muted-foreground">{timeAgo(row.original.created_at, t)}</span>,
},
],
[],
[t],
);
const statusLabels = Object.fromEntries(RUN_STATUS_OPTIONS.map((status) => [status, t(STATUS_LABEL_KEYS[status])]));
return (
<div className="w-full px-8 py-6">
{/* page header */}
<div className="mb-5">
<div className="text-lg font-semibold text-foreground">Workflow Runs</div>
<div className="mt-0.5 text-[13px] text-muted-foreground">
Durable state tracking for agents and automated workflows
</div>
<div className="text-lg font-semibold text-foreground">{t("workflows.header.title")}</div>
<div className="mt-0.5 text-[13px] text-muted-foreground">{t("workflows.header.subtitle")}</div>
</div>
<DataTable
@ -520,8 +529,10 @@ const WorkflowRuns: React.FC<WorkflowRunsProps> = ({ accessToken }) => {
columns={columns}
getRowId={(run) => run.run_id}
isLoading={loadingRuns}
loadingMessage="Loading workflow runs…"
noDataMessage={<div className="py-6 text-center text-[13px] text-muted-foreground">No workflow runs yet</div>}
loadingMessage={t("workflows.table.loading")}
noDataMessage={
<div className="py-6 text-center text-[13px] text-muted-foreground">{t("workflows.table.empty")}</div>
}
paginationMode="client"
pageSizeOptions={[50, 100]}
filterMode="client"
@ -537,7 +548,7 @@ const WorkflowRuns: React.FC<WorkflowRunsProps> = ({ accessToken }) => {
table={table}
searchValue={globalFilter}
onSearchChange={setGlobalFilter}
searchPlaceholder="Search runs…"
searchPlaceholder={t("workflows.table.searchPlaceholder")}
onRefresh={fetchRuns}
isRefreshing={loadingRuns}
onOpenFilters={() => setFiltersOpen(true)}
@ -546,35 +557,35 @@ const WorkflowRuns: React.FC<WorkflowRunsProps> = ({ accessToken }) => {
table={table}
open={filtersOpen}
onOpenChange={setFiltersOpen}
title="Filters"
description="Narrow down workflow runs"
title={t("workflows.filters.title")}
description={t("workflows.filters.description")}
>
{({ get, set }) => (
<>
<DataTableFilterField label="Status">
<DataTableFilterField label={t("workflows.filters.status")}>
<Select
items={STATUS_LABELS}
items={statusLabels}
value={(get("status") as string) || null}
onValueChange={(value: string | null) => set("status", value ?? "")}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="All statuses" />
<SelectValue placeholder={t("workflows.filters.allStatuses")} />
</SelectTrigger>
<SelectContent>
<SelectItem value={null}>All statuses</SelectItem>
<SelectItem value={null}>{t("workflows.filters.allStatuses")}</SelectItem>
{RUN_STATUS_OPTIONS.map((status) => (
<SelectItem key={status} value={status}>
{STATUS_LABELS[status]}
{statusLabels[status]}
</SelectItem>
))}
</SelectContent>
</Select>
</DataTableFilterField>
<DataTableFilterField label="Type">
<DataTableFilterField label={t("workflows.filters.type")}>
<Input
value={(get("workflow_type") as string) ?? ""}
onChange={(event) => set("workflow_type", event.target.value)}
placeholder="Filter by type…"
placeholder={t("workflows.filters.typePlaceholder")}
/>
</DataTableFilterField>
</>
@ -590,10 +601,8 @@ const WorkflowRuns: React.FC<WorkflowRunsProps> = ({ accessToken }) => {
showCloseButton={false}
className="overflow-y-auto p-0 data-[side=right]:w-full data-[side=right]:sm:max-w-[680px]"
>
<SheetTitle className="sr-only">Workflow run details</SheetTitle>
<SheetDescription className="sr-only">
Metadata, timeline and messages for the selected workflow run
</SheetDescription>
<SheetTitle className="sr-only">{t("workflows.drawer.srTitle")}</SheetTitle>
<SheetDescription className="sr-only">{t("workflows.drawer.srDescription")}</SheetDescription>
{!selectedRun ? null : loadingDetail ? (
<div className="flex justify-center py-20">
<UiLoadingSpinner className="size-8 text-muted-foreground" />
@ -609,11 +618,11 @@ const WorkflowRuns: React.FC<WorkflowRunsProps> = ({ accessToken }) => {
onClick={() => setDrawerOpen(false)}
>
<ArrowLeft />
close
{t("workflows.drawer.close")}
</Button>
<Button variant="outline" size="sm" onClick={() => fetchRunDetail(selectedRun)}>
<RefreshCw />
Refresh
{t("workflows.drawer.refresh")}
</Button>
</div>
@ -623,19 +632,17 @@ const WorkflowRuns: React.FC<WorkflowRunsProps> = ({ accessToken }) => {
{/* collapsible sections */}
<div className="divide-y overflow-hidden rounded-lg border">
<DetailSection
title="Timeline"
meta={
<>
{events.length} {events.length === 1 ? "event" : "events"}
</>
}
title={t("workflows.drawer.timeline")}
meta={t("workflows.drawer.eventCount", { count: events.length })}
defaultOpen
>
<GanttTimeline run={selectedRun} events={events} />
</DetailSection>
<DetailSection title="Messages" meta={messages.length}>
<DetailSection title={t("workflows.drawer.messages")} meta={messages.length}>
{messages.length === 0 ? (
<div className="py-3 font-mono text-xs text-muted-foreground">No messages</div>
<div className="py-3 font-mono text-xs text-muted-foreground">
{t("workflows.drawer.noMessages")}
</div>
) : (
<div>
{messages.map((msg) => (

View file

@ -10,6 +10,7 @@ import ReactQueryProvider from "@/contexts/ReactQueryProvider";
import HotkeysProvider from "@/contexts/HotkeysProvider";
import { TerminologyProvider } from "@/contexts/TerminologyContext";
import { Toaster } from "@/components/ui/sonner";
import I18nProvider from "@/i18n/I18nProvider";
const inter = Inter({ subsets: ["latin"] });
@ -29,18 +30,20 @@ export default function RootLayout({
// cannot predict; suppressHydrationWarning confines that mismatch to this element.
<html lang="en" suppressHydrationWarning>
<body className={inter.className}>
<ThemeProvider attribute="class" defaultTheme="light" enableSystem disableTransitionOnChange>
<NuqsAdapter>
<ReactQueryProvider>
<TerminologyProvider>
<HotkeysProvider>
<AuthProvider>{children}</AuthProvider>
</HotkeysProvider>
</TerminologyProvider>
<Toaster />
</ReactQueryProvider>
</NuqsAdapter>
</ThemeProvider>
<I18nProvider>
<ThemeProvider attribute="class" defaultTheme="light" enableSystem disableTransitionOnChange>
<NuqsAdapter>
<ReactQueryProvider>
<TerminologyProvider>
<HotkeysProvider>
<AuthProvider>{children}</AuthProvider>
</HotkeysProvider>
</TerminologyProvider>
<Toaster />
</ReactQueryProvider>
</NuqsAdapter>
</ThemeProvider>
</I18nProvider>
</body>
</html>
);

View file

@ -0,0 +1,9 @@
"use client";
import { I18nextProvider } from "react-i18next";
import i18n from "./i18n";
export default function I18nProvider({ children }: { children: React.ReactNode }) {
return <I18nextProvider i18n={i18n}>{children}</I18nextProvider>;
}

View file

@ -0,0 +1,10 @@
import type { TFunction } from "i18next";
import { expectTypeOf, test } from "vitest";
test("translation keys come from the English message file", () => {
const t = {} as TFunction;
expectTypeOf(t("workflows.drawer.eventCount", { count: 2 })).toBeString();
// @ts-expect-error
t("workflows.header.titel");
});

View file

@ -0,0 +1,23 @@
import i18n, { type InitOptions } from "i18next";
import { initReactI18next } from "react-i18next";
import en from "./locales/en.json";
declare module "i18next" {
interface CustomTypeOptions {
defaultNS: "translation";
resources: { translation: typeof en };
}
}
const options: InitOptions = {
lng: "en",
fallbackLng: "en",
interpolation: { escapeValue: false },
resources: { en: { translation: en } },
initAsync: false,
};
void i18n.use(initReactI18next).init(options);
export default i18n;

View file

@ -0,0 +1,76 @@
{
"workflows": {
"header": {
"title": "Workflow Runs",
"subtitle": "Durable state tracking for agents and automated workflows"
},
"table": {
"columns": {
"run": "Run",
"type": "Type",
"status": "Status",
"created": "Created"
},
"loading": "Loading workflow runs…",
"empty": "No workflow runs yet",
"searchPlaceholder": "Search runs…"
},
"filters": {
"title": "Filters",
"description": "Narrow down workflow runs",
"status": "Status",
"type": "Type",
"allStatuses": "All statuses",
"typePlaceholder": "Filter by type…"
},
"status": {
"pending": "Pending",
"running": "Running",
"paused": "Paused",
"completed": "Completed",
"failed": "Failed"
},
"drawer": {
"srTitle": "Workflow run details",
"srDescription": "Metadata, timeline and messages for the selected workflow run",
"close": "close",
"refresh": "Refresh",
"timeline": "Timeline",
"eventCount_one": "{{count}} event",
"eventCount_other": "{{count}} events",
"messages": "Messages",
"noMessages": "No messages",
"noEvents": "No events recorded"
},
"metadata": {
"status": "status",
"created": "created",
"pr": "pr",
"state": "state",
"worktree": "worktree",
"grillSession": "grill session",
"session": "session"
},
"truncate": {
"more": "more",
"less": "less"
},
"tooltip": {
"type": "type: ",
"step": "step: ",
"seq": "seq: ",
"time": "time: ",
"data": "data: "
},
"timeAgo": {
"seconds": "{{count}}s ago",
"minutes": "{{count}}m ago",
"hours": "{{count}}h ago",
"days": "{{count}}d ago"
},
"duration": {
"ms": "{{count}}ms",
"seconds": "{{value}}s"
}
}
}

View file

@ -2,6 +2,8 @@ import "@testing-library/jest-dom";
import { cleanup } from "@testing-library/react";
import { afterEach, vi } from "vitest";
import "@/i18n/i18n";
const ensureTestLocalStorage = () => {
if (typeof window === "undefined" || typeof window.Storage === "undefined") {
return;