feat(automations): show a run's live phase (#16740)

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Vasco Schiavo <115561717+VascoSch92@users.noreply.github.com>
Co-authored-by: VascoSch92 <vasco.schiavo@protonmail.com>
This commit is contained in:
Artem Altukhov
2026-08-24 12:24:48 +02:00
committed by GitHub
co-authored by Claude Opus 5 Vasco Schiavo VascoSch92
parent 861e9ef501
commit 8511fff62d
20 changed files with 1921 additions and 46 deletions
@@ -1,12 +1,24 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { http, HttpResponse } from "msw";
import { AutomationCard } from "#/components/features/automations/automation-card";
import {
AutomationRunStatus,
type Automation,
type AutomationRun,
} from "#/types/automation";
import { useAutomationRunSummaries } from "#/hooks/query/use-automation-run-summaries";
import {
__resetActiveStoreForTests,
setActiveSelection,
setRegisteredBackends,
} from "#/api/backend-registry/active-store";
import { ActiveBackendProvider } from "#/contexts/active-backend-context";
import type { Backend } from "#/api/backend-registry/types";
import { server } from "#/mocks/node";
import { I18nKey } from "#/i18n/declaration";
import type { InterfaceListInsights } from "#/manifests/types";
vi.mock("react-i18next", () => ({
@@ -158,7 +170,9 @@ describe("AutomationCard", () => {
/>,
);
expect(screen.queryByTestId("automation-health-badge")).not.toBeInTheDocument();
expect(
screen.queryByTestId("automation-health-badge"),
).not.toBeInTheDocument();
expect(
screen.getByTestId("automation-last-run-automation-1"),
).toHaveTextContent("AUTOMATIONS$DETAIL$TIME_MINUTES_AGO");
@@ -171,3 +185,115 @@ describe("AutomationCard", () => {
expect(screen.getByText("100%")).toBeInTheDocument();
});
});
describe("AutomationCard — run phase", () => {
const localBackend: Backend = {
id: "local-1",
name: "Local 1",
host: "http://localhost:8000",
apiKey: "k",
kind: "local",
};
const insightAutomation: Automation = {
id: "auto-with-active-run",
name: "Digest",
prompt: null,
enabled: true,
trigger: { type: "cron", schedule_human: "cron" },
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
};
const insightsSpec: InterfaceListInsights = {
health: {
healthy: "Healthy",
failing: "Failing",
running: "Running",
disabled: "Disabled",
neverRun: "Never run",
checking: "Checking",
},
lastRun: { label: "Last run", never: "Never", justNow: "Just now" },
stats: {
runs: "Runs",
recentSuccess: "Success",
averageDuration: "Duration",
},
};
beforeEach(() => {
__resetActiveStoreForTests();
setRegisteredBackends([localBackend]);
setActiveSelection({ backendId: localBackend.id });
});
afterEach(() => {
__resetActiveStoreForTests();
});
function Harness() {
const byId = useAutomationRunSummaries([insightAutomation]);
return (
<AutomationCard
automation={insightAutomation}
onToggle={vi.fn()}
onRunNow={vi.fn()}
onExport={vi.fn()}
onDelete={vi.fn()}
insights={{ spec: insightsSpec, state: byId.get(insightAutomation.id) }}
/>
);
}
function renderHarness() {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return render(
<QueryClientProvider client={queryClient}>
<ActiveBackendProvider>
<Harness />
</ActiveBackendProvider>
</QueryClientProvider>,
);
}
it("shows the active run's phase using only the run-summaries fetch insights already makes — no extra request", async () => {
// Arrange: count every hit to the runs endpoint the card's insights
// already fetch (via useAutomationRunSummaries) — if displaying the
// phase required a second request, this would be > 1.
let callCount = 0;
server.use(
http.get("*/api/automation/v1/:id/runs", () => {
callCount += 1;
return HttpResponse.json({
runs: [
{
id: "run-active",
status: AutomationRunStatus.RUNNING,
conversation_id: null,
bash_command_id: null,
error_detail: null,
phase_code: "running_agent",
phase_label: null,
phase_updated_at: null,
started_at: "2026-01-01T09:00:00Z",
completed_at: null,
},
],
total: 1,
});
}),
);
// Act
renderHarness();
// Assert: the phase renders ...
await screen.findByText(I18nKey.AUTOMATIONS$DETAIL$PHASE_RUNNING_AGENT);
// ... and exactly one request was made — the pre-existing insights
// fetch, not a new one just for the phase.
expect(callCount).toBe(1);
});
});
@@ -4,10 +4,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { MemoryRouter } from "react-router";
import { ActivityLogItem } from "#/components/features/automations/detail/activity-log-item";
import {
AutomationRunStatus,
type AutomationRun,
} from "#/types/automation";
import { AutomationRunStatus, type AutomationRun } from "#/types/automation";
import {
__resetActiveStoreForTests,
setActiveSelection,
@@ -25,27 +22,24 @@ const LOGS_BUTTON_NAME = (name: string) =>
// The modal is wired to react-query + the conversation lookup. The
// ActivityLogItem tests focus on the trigger button; we mock the modal so
// they don't need to bring up the entire query stack.
vi.mock(
"#/components/features/automations/detail/run-logs-modal",
() => ({
RunLogsModal: ({
isOpen,
onClose,
bashCommandId,
}: {
isOpen: boolean;
onClose: () => void;
bashCommandId: string | null;
}) =>
isOpen ? (
<div data-testid="logs-modal" data-bash-command-id={bashCommandId}>
<button type="button" onClick={onClose}>
close
</button>
</div>
) : null,
}),
);
vi.mock("#/components/features/automations/detail/run-logs-modal", () => ({
RunLogsModal: ({
isOpen,
onClose,
bashCommandId,
}: {
isOpen: boolean;
onClose: () => void;
bashCommandId: string | null;
}) =>
isOpen ? (
<div data-testid="logs-modal" data-bash-command-id={bashCommandId}>
<button type="button" onClick={onClose}>
close
</button>
</div>
) : null,
}));
const localBackend: Backend = {
id: "local-1",
@@ -288,3 +282,242 @@ describe("ActivityLogItem — run cost", () => {
).not.toBeInTheDocument();
});
});
describe("ActivityLogItem — run phase", () => {
beforeEach(() => {
__resetActiveStoreForTests();
setRegisteredBackends([localBackend]);
setActiveSelection({ backendId: localBackend.id });
});
afterEach(() => {
__resetActiveStoreForTests();
});
it("shows the phase as the place of failure for a FAILED run", () => {
// Arrange: the run failed after reaching sandbox_provisioning — a code
// known to the frontend, so it renders translated (raw key in tests).
const run = makeRun({
status: AutomationRunStatus.FAILED,
conversation_id: null,
bash_command_id: null,
phase_code: "sandbox_provisioning",
phase_label: null,
phase_updated_at: "2026-01-01T10:01:30Z",
});
// Act
renderItem(run);
// Assert
expect(
screen.getByText(I18nKey.AUTOMATIONS$DETAIL$PHASE_SANDBOX_PROVISIONING),
).toBeInTheDocument();
});
it("does not display the phase for a COMPLETED run, even though it was saved", () => {
// Arrange: the run completed, but a phase was recorded along the way.
const run = makeRun({
status: AutomationRunStatus.COMPLETED,
phase_code: "running_agent",
phase_label: null,
phase_updated_at: "2026-01-01T10:01:30Z",
});
// Act
renderItem(run);
// Assert: the field exists on the run (saved), but nothing renders it.
expect(run.phase_code).toBe("running_agent");
expect(screen.queryByTestId("run-phase")).not.toBeInTheDocument();
expect(
screen.queryByText(I18nKey.AUTOMATIONS$DETAIL$PHASE_RUNNING_AGENT),
).not.toBeInTheDocument();
});
});
describe("ActivityLogItem — run phase absent entirely", () => {
beforeEach(() => {
__resetActiveStoreForTests();
setRegisteredBackends([localBackend]);
setActiveSelection({ backendId: localBackend.id });
});
afterEach(() => {
__resetActiveStoreForTests();
vi.restoreAllMocks();
});
it("renders a RUNNING row unchanged, without console errors, when the automation service omits phase fields entirely", () => {
// Arrange: an older automation service response — no phase_code,
// phase_label or phase_updated_at keys at all (not even null).
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const run = makeRun({
status: AutomationRunStatus.RUNNING,
completed_at: null,
});
delete (run as Partial<AutomationRun>).phase_code;
delete (run as Partial<AutomationRun>).phase_label;
delete (run as Partial<AutomationRun>).phase_updated_at;
// Act
expect(() => renderItem(run)).not.toThrow();
// Assert: renders as before — status badge present, no phase node, no
// console noise from the missing fields.
expect(
screen.getByText(I18nKey.AUTOMATIONS$DETAIL$RUNNING),
).toBeInTheDocument();
expect(screen.queryByTestId("run-phase")).not.toBeInTheDocument();
expect(errorSpy).not.toHaveBeenCalled();
expect(warnSpy).not.toHaveBeenCalled();
});
});
describe("ActivityLogItem — run phase updates without reload", () => {
beforeEach(() => {
__resetActiveStoreForTests();
setRegisteredBackends([localBackend]);
setActiveSelection({ backendId: localBackend.id });
});
afterEach(() => {
__resetActiveStoreForTests();
});
it("reflects a new phase_code from a prop update (as a poll refetch would produce), with no remount needed", () => {
// Arrange
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
const buildTree = (run: AutomationRun) => (
<QueryClientProvider client={queryClient}>
<ActiveBackendProvider>
<MemoryRouter>
<ActivityLogItem run={run} />
</MemoryRouter>
</ActiveBackendProvider>
</QueryClientProvider>
);
const runningRun = makeRun({
status: AutomationRunStatus.RUNNING,
completed_at: null,
phase_code: "queued",
phase_label: null,
});
const { rerender } = render(buildTree(runningRun));
expect(
screen.getByText(I18nKey.AUTOMATIONS$DETAIL$PHASE_QUEUED),
).toBeInTheDocument();
// Act: same run id, new phase — what the 3s poll in
// useAutomationRuns would hand down as a new `run` prop.
rerender(
buildTree({
...runningRun,
phase_code: "running_agent",
phase_label: null,
}),
);
// Assert: the new phase is shown, the old one is gone — no reload.
expect(
screen.getByText(I18nKey.AUTOMATIONS$DETAIL$PHASE_RUNNING_AGENT),
).toBeInTheDocument();
expect(
screen.queryByText(I18nKey.AUTOMATIONS$DETAIL$PHASE_QUEUED),
).not.toBeInTheDocument();
});
});
describe("ActivityLogItem — run phase (badge still shown)", () => {
beforeEach(() => {
__resetActiveStoreForTests();
setRegisteredBackends([localBackend]);
setActiveSelection({ backendId: localBackend.id });
});
afterEach(() => {
__resetActiveStoreForTests();
});
it("shows only the existing status badge (not merely 'no phase') for an active run whose phase is blank on both fields", () => {
// Arrange
const run = makeRun({
status: AutomationRunStatus.RUNNING,
completed_at: null,
phase_code: null,
phase_label: "",
});
// Act
renderItem(run);
// Assert: no phase node, but the status badge is still on the row —
// the requirement is "only the existing status badge", not "nothing".
expect(screen.queryByTestId("run-phase")).not.toBeInTheDocument();
expect(
screen.getByText(I18nKey.AUTOMATIONS$DETAIL$RUNNING),
).toBeInTheDocument();
});
it("keeps the status badge beside a phase reported as a bare code", () => {
// Arrange: the service accepts `{"code": "custom_step"}` with no label.
const run = makeRun({
status: AutomationRunStatus.RUNNING,
completed_at: null,
phase_code: "custom_step",
phase_label: "",
});
// Act
renderItem(run);
// Assert: the phase reaches the row, and it is added to the badge rather
// than replacing it.
expect(screen.getByTestId("run-phase")).toHaveTextContent("custom_step");
expect(
screen.getByText(I18nKey.AUTOMATIONS$DETAIL$RUNNING),
).toBeInTheDocument();
});
});
describe("ActivityLogItem — run phase hidden for CANCELLED/SKIPPED", () => {
beforeEach(() => {
__resetActiveStoreForTests();
setRegisteredBackends([localBackend]);
setActiveSelection({ backendId: localBackend.id });
});
afterEach(() => {
__resetActiveStoreForTests();
});
it.each([
[AutomationRunStatus.CANCELLED, I18nKey.AUTOMATIONS$DETAIL$CANCELLED],
[AutomationRunStatus.SKIPPED, I18nKey.AUTOMATIONS$DETAIL$SKIPPED],
])(
"does not show the phase for a %s run, even with a known phase_code on record",
(status, badgeKey) => {
// Arrange
const run = makeRun({
status,
phase_code: "running_agent",
phase_label: null,
completed_at: "2026-01-01T10:02:00Z",
});
// Act
renderItem(run);
// Assert: phase hidden, but the status badge for this terminal
// status is still shown.
expect(screen.queryByTestId("run-phase")).not.toBeInTheDocument();
expect(
screen.queryByText(I18nKey.AUTOMATIONS$DETAIL$PHASE_RUNNING_AGENT),
).not.toBeInTheDocument();
expect(screen.getByText(badgeKey)).toBeInTheDocument();
},
);
});
@@ -0,0 +1,346 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen } from "@testing-library/react";
import {
formatRunPhaseAge,
resolveRunPhaseText,
RunPhase,
} from "#/components/features/automations/detail/run-phase";
import { I18nKey } from "#/i18n/declaration";
// Source of truth for translated values — not a hand-maintained duplicate.
import translationData from "#/i18n/translation.json";
import { AutomationRunStatus } from "#/types/automation";
import { formatRelativeTime } from "#/utils/format-relative-time";
type TranslationEntry = Record<string, string>;
const TRANSLATIONS = translationData as unknown as Record<
string,
TranslationEntry
>;
// `t()` is mocked to resolve against the real translation.json content for
// French ("fr"), the same pattern used elsewhere in this repo (see
// server-status.test.tsx) to assert on genuine translated copy rather than
// the ambient test i18n backend, which never resolves real values.
function translate(key: string, options?: Record<string, unknown>): string {
const value = TRANSLATIONS[key]?.fr ?? key;
if (!options) return value;
// Interpolate the same way i18next does, so a test can assert on "il y a
// 12min" rather than on the untouched "il y a {{count}}min" placeholder.
return value.replace(/{{(\w+)}}/g, (_, name: string) =>
String(options[name] ?? `{{${name}}}`),
);
}
vi.mock("react-i18next", async () => {
const actual = await vi.importActual("react-i18next");
return {
...actual,
useTranslation: () => ({
t: translate,
i18n: { language: "fr" },
}),
};
});
const minutesAgo = (minutes: number) =>
new Date(Date.now() - minutes * 60_000).toISOString();
const RUNNING = AutomationRunStatus.RUNNING;
describe("RunPhase — known code, non-English language", () => {
it("shows the French translation.json value for a known phase code, not the raw code", () => {
render(
<RunPhase status={RUNNING} code="sandbox_provisioning" label={null} />,
);
const expected =
TRANSLATIONS[I18nKey.AUTOMATIONS$DETAIL$PHASE_SANDBOX_PROVISIONING].fr;
expect(screen.getByText(expected)).toBeInTheDocument();
expect(screen.queryByText("sandbox_provisioning")).not.toBeInTheDocument();
});
});
describe("RunPhase — unknown code (custom automations)", () => {
it("shows the label as-is, including emoji and non-Latin text, for an unknown code", () => {
render(
<RunPhase status={RUNNING} code="poll_prs" label="🔍 Опрашиваем PR-ы" />,
);
expect(screen.getByText("🔍 Опрашиваем PR-ы")).toBeInTheDocument();
});
it("shows the raw code when the automation reported a code and no label", () => {
// The service accepts `{"code": "checking_out"}` with no label at all, so
// this run has a real phase and dropping it would show nothing.
render(<RunPhase status={RUNNING} code="checking_out" label={null} />);
expect(screen.getByTestId("run-phase")).toHaveTextContent("checking_out");
});
it.each([
["null code and empty label", null, ""],
["undefined on both fields (an older service)", undefined, undefined],
["null on both fields", null, null],
["whitespace on both fields", " ", " "],
])(
"renders nothing and never touches the console with %s",
(_case, code, label) => {
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
expect(() =>
render(<RunPhase status={RUNNING} code={code} label={label} />),
).not.toThrow();
expect(screen.queryByTestId("run-phase")).not.toBeInTheDocument();
expect(errorSpy).not.toHaveBeenCalled();
expect(warnSpy).not.toHaveBeenCalled();
},
);
afterEach(() => {
vi.restoreAllMocks();
});
it("boundary: a 200-character label (the contract's max) reaches the DOM whole, so truncation stays visual", () => {
// Arrange: the longest label the backend contract allows.
const label = "x".repeat(200);
// Act
render(<RunPhase status={RUNNING} code="poll_prs" label={label} />);
// Assert: the full string is still there — clipping must be CSS, never a
// JS slice that would lose characters no tooltip could bring back.
expect(screen.getByTestId("run-phase")).toHaveTextContent(label);
});
it("renders the label when only a label was reported and no code", () => {
// The service accepts a phase carrying just a label, so a custom
// automation may report one. An absent code is the most unknown code
// there is, and an unknown code falls back to its label.
render(
<RunPhase status={RUNNING} code={null} label="Reticulating splines" />,
);
expect(screen.getByTestId("run-phase")).toHaveTextContent(
"Reticulating splines",
);
});
});
describe("RunPhase — phase text is data, not a lookup into JavaScript itself", () => {
// `phase_code` is author-supplied, so a plain-object lookup answers these
// from `Object.prototype` and hands a function to `t()` — taking the card
// tree down inside render instead of falling back to the label.
it.each(["toString", "constructor", "__proto__", "hasOwnProperty"])(
"treats %s as an ordinary unknown code and shows the label",
(code) => {
expect(() =>
render(<RunPhase status={RUNNING} code={code} label="Полный аудит" />),
).not.toThrow();
expect(screen.getByTestId("run-phase")).toHaveTextContent("Полный аудит");
},
);
});
describe("resolveRunPhaseText — one answer for the row and its tooltip", () => {
// The row shows a clipped copy of this text and the tooltip shows all of
// it, so both have to resolve the same phase the same way; a surface that
// resolved it on its own could show one thing and reveal another.
const t = (key: string) => TRANSLATIONS[key]?.fr ?? key;
it("resolves a known code to its translation", () => {
expect(resolveRunPhaseText(t, "sandbox_provisioning", null)).toBe(
TRANSLATIONS[I18nKey.AUTOMATIONS$DETAIL$PHASE_SANDBOX_PROVISIONING].fr,
);
});
it("resolves an unknown code to the author's label, verbatim", () => {
expect(resolveRunPhaseText(t, "poll_prs", "🔍 Опрашиваем PR-ы")).toBe(
"🔍 Опрашиваем PR-ы",
);
});
it("resolves a phase carrying only a label to that label", () => {
expect(resolveRunPhaseText(t, null, "Reticulating splines")).toBe(
"Reticulating splines",
);
});
it("resolves a phase carrying only a code to that code, verbatim", () => {
// Mirrors the backend contract: `code` and `label` are independently
// optional, so a code-only phase is valid and must reach the screen.
expect(resolveRunPhaseText(t, "checking_out", null)).toBe("checking_out");
expect(resolveRunPhaseText(t, "checking_out", "")).toBe("checking_out");
});
it("prefers the author's label over the raw code when both are present", () => {
expect(resolveRunPhaseText(t, "poll_prs", "Polling PRs")).toBe(
"Polling PRs",
);
});
it("falls through a whitespace-only label, which the service stores as sent", () => {
// Only a phase blank on *both* fields is rejected: `{"code": "x",
// "label": " "}` is recorded verbatim, and rendering that label would
// put an empty span on the row with the age dangling beside it.
expect(resolveRunPhaseText(t, "checking_out", " ")).toBe("checking_out");
expect(resolveRunPhaseText(t, " ", " ")).toBeNull();
});
it("resolves to null when there is nothing to show", () => {
expect(resolveRunPhaseText(t, "", "")).toBeNull();
expect(resolveRunPhaseText(t, null, null)).toBeNull();
});
});
describe("formatRunPhaseAge — telling a moving run from a stalled one", () => {
it("reports how long the run has held the phase, localized", () => {
expect(formatRunPhaseAge(minutesAgo(12), "fr", translate)).toBe(
"il y a 12min",
);
});
it("reports a phase written seconds ago as 'just now', not as zero minutes", () => {
expect(formatRunPhaseAge(minutesAgo(0), "fr", translate)).toBe(
TRANSLATIONS[I18nKey.AUTOMATIONS$DETAIL$TIME_JUST_NOW].fr,
);
});
it("boundary: 60 minutes rolls over to the hours wording", () => {
expect(formatRunPhaseAge(minutesAgo(60), "fr", translate)).toBe(
"il y a 1h",
);
});
it("returns null when the service reported no timestamp", () => {
expect(formatRunPhaseAge(null, "fr", translate)).toBeNull();
expect(formatRunPhaseAge(undefined, "fr", translate)).toBeNull();
});
it("negative: returns null for timestamps the formatter would print as garbage", () => {
// The guard is the whole point: left to itself, the shared relative-time
// formatter prints "Invalid Date" for an unparseable string and a 1970
// date for the epoch, which is how the backend leaves a datetime unset.
expect(formatRelativeTime("not-a-date", "fr", translate)).toMatch(
/Invalid/,
);
expect(formatRelativeTime("1970-01-01T00:00:00Z", "fr", translate)).toMatch(
/1970/,
);
expect(formatRunPhaseAge("not-a-date", "fr", translate)).toBeNull();
expect(
formatRunPhaseAge("1970-01-01T00:00:00Z", "fr", translate),
).toBeNull();
});
});
describe("RunPhase — how long the run has been in this phase", () => {
it("shows the age beside the phase, so a stalled run reads differently from a moving one", () => {
render(
<RunPhase
status={RUNNING}
code="running_agent"
label={null}
updatedAt={minutesAgo(41)}
/>,
);
expect(screen.getByTestId("run-phase-age")).toHaveTextContent(
"il y a 41min",
);
});
it("keeps the age in its own node, outside the clipped text, so a maximum-length label cannot push it out of sight", () => {
// Arrange: the longest label the backend contract allows, in the surface
// with the least room.
const label = "x".repeat(200);
render(
<RunPhase
status={RUNNING}
code="poll_prs"
label={label}
updatedAt={minutesAgo(7)}
/>,
);
const text = screen.getByTestId("run-phase");
const age = screen.getByTestId("run-phase-age");
expect(text).not.toContainElement(age);
});
it("shows no age at all against a service that reports phases without a timestamp", () => {
render(<RunPhase status={RUNNING} code="running_agent" label={null} />);
expect(screen.getByTestId("run-phase")).toBeInTheDocument();
expect(screen.queryByTestId("run-phase-age")).not.toBeInTheDocument();
});
it.each([
["unparseable", "not-a-date"],
[
"the epoch, which is how an unset datetime arrives",
"1970-01-01T00:00:00Z",
],
])("shows no age when the timestamp is %s", (_case, updatedAt) => {
render(
<RunPhase
status={RUNNING}
code="running_agent"
label={null}
updatedAt={updatedAt}
/>,
);
expect(screen.queryByTestId("run-phase-age")).not.toBeInTheDocument();
});
it("drops the age on a failed run, whose phase is where it stopped rather than something still running", () => {
// A months-old failure would otherwise render an absolute date — a
// second timestamp beside the one the row already shows.
render(
<RunPhase
status={AutomationRunStatus.FAILED}
code="sandbox_provisioning"
label={null}
updatedAt={minutesAgo(41)}
/>,
);
expect(screen.getByTestId("run-phase")).toBeInTheDocument();
expect(screen.queryByTestId("run-phase-age")).not.toBeInTheDocument();
});
});
describe("RunPhase — reachable without a mouse", () => {
it("leaves the whole phase, age included, in the accessibility tree", () => {
// Truncation is CSS only, so a 200-character label is readable in full
// by a screen reader as long as nothing hides it — and nothing may hide
// it behind a tab stop, because every surface nests the phase inside a
// link, where a focusable descendant is invalid and swallows Enter.
const label = "x".repeat(200);
render(
<RunPhase
status={RUNNING}
code="poll_prs"
label={label}
updatedAt={minutesAgo(7)}
/>,
);
const text = screen.getByTestId("run-phase");
const age = screen.getByTestId("run-phase-age");
expect(text).toHaveTextContent(label);
expect(age).toHaveTextContent("il y a 7min");
expect(text).not.toHaveAttribute("aria-hidden");
expect(age).not.toHaveAttribute("aria-hidden");
// No focusable ancestor: the phase must never be its own tab stop
// inside the link every surface wraps it in.
expect(text.closest("[tabindex]")).toBeNull();
});
});
@@ -465,6 +465,46 @@ describe("home automations composer layout", () => {
).not.toBeInTheDocument();
});
it("shows the pinned card's active-run phase using only the shared latest-run fetch, no extra request (home surface)", async () => {
// Arrange: a single automation with a RUNNING run that has a phase.
// `getAutomationRuns` is the one query both the row and the pinned
// dashboard card read (shared cache key + params) — if showing the
// phase required a second fetch, the call count below would exceed 1.
vi.mocked(AutomationService.getAutomationRuns).mockResolvedValue({
runs: [
makeRun({
status: AutomationRunStatus.RUNNING,
completed_at: null,
phase_code: "running_agent",
phase_label: null,
}),
],
total: 1,
});
const user = userEvent.setup();
// Act
renderHomeAutomations(
<>
<PinnedAutomationsDashboard />
<RunningAutomationsList />
</>,
);
await screen.findByTestId("running-automations-list");
await user.click(screen.getByTestId("running-automation-menu-auto-1"));
await user.click(screen.getByTestId("running-automation-pin-auto-1"));
// Assert: the pinned card shows the phase ...
const dashboard = await screen.findByTestId("pinned-automations-dashboard");
expect(
await within(dashboard).findByText(
"AUTOMATIONS$DETAIL$PHASE_RUNNING_AGENT",
),
).toBeInTheDocument();
// ... and only one runs request was ever made for this automation.
expect(AutomationService.getAutomationRuns).toHaveBeenCalledTimes(1);
});
it("shows an error toast when turning an automation off fails", async () => {
vi.mocked(AutomationService.toggleAutomation).mockRejectedValue(
new Error("backend unavailable"),
@@ -0,0 +1,150 @@
import { describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { HomeAutomationRunTooltip } from "#/components/features/home/featured-automations/home-automation-run-tooltip";
import type { LatestAutomationRunState } from "#/hooks/query/use-latest-automation-runs";
import {
AutomationRunStatus,
type Automation,
type AutomationRun,
} from "#/types/automation";
vi.mock("react-i18next", () => ({
useTranslation: () => ({
// Counted keys keep their count, so a test can assert which relative-time
// key was chosen *and* the number handed to it, not just the key.
t: (key: string, options?: Record<string, unknown>) =>
options?.count === undefined ? key : `${key}:${options.count}`,
i18n: { language: "en" },
}),
}));
const automation: Automation = {
id: "auto-1",
name: "Release notes drafter",
prompt: "Draft release notes",
trigger: { type: "cron", schedule: "0 12 * * 5" },
enabled: true,
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
};
function makeRun(overrides: Partial<AutomationRun> = {}): AutomationRun {
return {
id: "run-1",
status: AutomationRunStatus.RUNNING,
conversation_id: null,
bash_command_id: null,
error_detail: null,
started_at: "2026-08-01T10:00:00Z",
completed_at: null,
...overrides,
};
}
function makeState(run: AutomationRun | null): LatestAutomationRunState {
return {
latestRun: run,
recentRuns: run ? [run] : [],
isLoading: false,
isError: false,
};
}
// The row that opens this hovercard clips a long phase; the hovercard is the
// place where the whole phase has to be readable.
const LONG_LABEL =
"Rendering the changelog for 37 merged pull requests across 4 repositories";
describe("HomeAutomationRunTooltip — phase", () => {
it("shows the phase of a running run in full", () => {
render(
<HomeAutomationRunTooltip
automation={automation}
runState={makeState(
makeRun({ phase_code: "drafting_notes", phase_label: LONG_LABEL }),
)}
/>,
);
expect(screen.getByTestId("run-phase-row")).toHaveTextContent(LONG_LABEL);
});
it("translates a phase code the frontend knows instead of printing it raw", () => {
render(
<HomeAutomationRunTooltip
automation={automation}
runState={makeState(
makeRun({ phase_code: "bundle_upload", phase_label: null }),
)}
/>,
);
expect(screen.getByTestId("run-phase-row")).toHaveTextContent(
"AUTOMATIONS$DETAIL$PHASE_BUNDLE_UPLOAD",
);
});
it("says how long the run has held the phase, so a stall is visible without opening the run", () => {
render(
<HomeAutomationRunTooltip
automation={automation}
runState={makeState(
makeRun({
phase_code: "drafting_notes",
phase_label: LONG_LABEL,
phase_updated_at: new Date(Date.now() - 25 * 60_000).toISOString(),
}),
)}
/>,
);
expect(screen.getByTestId("run-phase-row")).toHaveTextContent(
"AUTOMATIONS$DETAIL$TIME_MINUTES_AGO:25",
);
});
it("shows the phase without an age against a service that sends no timestamp", () => {
render(
<HomeAutomationRunTooltip
automation={automation}
runState={makeState(
makeRun({ phase_code: "drafting_notes", phase_label: LONG_LABEL }),
)}
/>,
);
const row = screen.getByTestId("run-phase-row");
expect(row).toHaveTextContent(LONG_LABEL);
expect(row.textContent).not.toContain("AUTOMATIONS$DETAIL$TIME_");
});
it("omits the phase row for a finished run, matching every other surface", () => {
render(
<HomeAutomationRunTooltip
automation={automation}
runState={makeState(
makeRun({
status: AutomationRunStatus.COMPLETED,
completed_at: "2026-08-01T10:05:00Z",
phase_code: "running_agent",
phase_label: null,
}),
)}
/>,
);
expect(screen.queryByTestId("run-phase-row")).not.toBeInTheDocument();
});
it("omits the phase row when the run never reported one", () => {
render(
<HomeAutomationRunTooltip
automation={automation}
runState={makeState(makeRun())}
/>,
);
expect(screen.queryByTestId("run-phase-row")).not.toBeInTheDocument();
});
});
@@ -0,0 +1,130 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, waitFor } from "@testing-library/react";
import { QueryClientProvider, QueryClient } from "@tanstack/react-query";
import { useAutomationRunSummaries } from "#/hooks/query/use-automation-run-summaries";
import AutomationService from "#/api/automation-service/automation-service.api";
import {
AutomationRunStatus,
type Automation,
type AutomationRun,
} from "#/types/automation";
vi.mock("#/api/automation-service/automation-service.api", () => ({
default: {
getAutomationRuns: vi.fn(),
},
}));
vi.mock("#/contexts/active-backend-context", () => ({
useActiveBackend: () => ({
backend: { id: "test-backend", kind: "local" },
orgId: null,
}),
}));
const automations = [
{ id: "auto-1", name: "Nightly QA" },
] as unknown as Automation[];
function makeRun(status: AutomationRunStatus): AutomationRun {
return {
id: "run-1",
status,
conversation_id: null,
bash_command_id: null,
error_detail: null,
started_at: "2026-08-01T10:00:00Z",
completed_at:
status === AutomationRunStatus.RUNNING ? null : "2026-08-01T10:05:00Z",
};
}
function createWrapper() {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
}
// The dashboard reads its cards from this hook. Its phase — and above all the
// phase's age — is only honest if the data behind it keeps moving: a run that
// is working through its phases must not sit on the card reading "Queued ·
// 40m ago", which is exactly the stall signal the age exists to send.
describe("useAutomationRunSummaries — keeping an in-flight card current", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers({ shouldAdvanceTime: true });
});
afterEach(() => {
vi.useRealTimers();
});
it("refetches while a sampled run is still in flight", async () => {
vi.mocked(AutomationService.getAutomationRuns).mockResolvedValue({
runs: [makeRun(AutomationRunStatus.RUNNING)],
total: 1,
});
renderHook(() => useAutomationRunSummaries(automations), {
wrapper: createWrapper(),
});
await waitFor(() =>
expect(AutomationService.getAutomationRuns).toHaveBeenCalledTimes(1),
);
await vi.advanceTimersByTimeAsync(15_000);
await waitFor(() =>
expect(AutomationService.getAutomationRuns).toHaveBeenCalledTimes(2),
);
});
it("stops once every sampled run has finished", async () => {
vi.mocked(AutomationService.getAutomationRuns).mockResolvedValue({
runs: [makeRun(AutomationRunStatus.COMPLETED)],
total: 1,
});
renderHook(() => useAutomationRunSummaries(automations), {
wrapper: createWrapper(),
});
await waitFor(() =>
expect(AutomationService.getAutomationRuns).toHaveBeenCalledTimes(1),
);
await vi.advanceTimersByTimeAsync(60_000);
expect(AutomationService.getAutomationRuns).toHaveBeenCalledTimes(1);
});
it("stops when the newest run finished, despite an older stuck run", async () => {
// A crashed dispatcher leaves an old run non-terminal forever. The card
// renders `runs[0]` and nothing else, so polling on *any* sampled run
// pins this automation to a permanent refetch that never changes the
// screen — one wasted request every 15s, per automation, indefinitely.
vi.mocked(AutomationService.getAutomationRuns).mockResolvedValue({
runs: [
makeRun(AutomationRunStatus.COMPLETED),
{ ...makeRun(AutomationRunStatus.RUNNING), id: "run-stuck" },
],
total: 2,
});
renderHook(() => useAutomationRunSummaries(automations), {
wrapper: createWrapper(),
});
await waitFor(() =>
expect(AutomationService.getAutomationRuns).toHaveBeenCalledTimes(1),
);
await vi.advanceTimersByTimeAsync(60_000);
expect(AutomationService.getAutomationRuns).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,125 @@
import { describe, expect, it } from "vitest";
import { MOCK_AUTOMATION_RUNS } from "#/mocks/automation-runs.mock";
import {
resolveRunPhaseText,
shouldShowRunPhase,
} from "#/components/features/automations/detail/run-phase";
import { AutomationRunStatus, type AutomationRun } from "#/types/automation";
import { I18nKey } from "#/i18n/declaration";
// `VITE_MOCK_API=true` is how the automations UI is developed and demoed
// without a local automation service, so a phase branch that no fixture
// reaches is a branch nobody sees before it ships. These assertions are on
// the fixtures themselves: they fail when a case is dropped, which is the
// only way a rendering branch quietly stops being exercised.
const ALL_RUNS: AutomationRun[] = Object.values(MOCK_AUTOMATION_RUNS).flat();
const IN_FLIGHT = ALL_RUNS.filter(
(run) =>
run.status === AutomationRunStatus.RUNNING ||
run.status === AutomationRunStatus.PENDING,
);
// Stands in for `t()`: a known code must resolve to something other than the
// code itself, so the assertions below cannot pass on a raw code.
const t = (key: I18nKey) => `translated:${key}`;
describe("mock automation runs — the phase branches mock mode has to reach", () => {
it("has in-flight runs at all, or no surface ever shows a phase", () => {
expect(IN_FLIGHT.length).toBeGreaterThan(0);
});
it("covers a phase code the frontend translates", () => {
const translated = IN_FLIGHT.filter(
(run) =>
resolveRunPhaseText(t, run.phase_code, run.phase_label)?.startsWith(
"translated:",
) ?? false,
);
expect(translated.length).toBeGreaterThan(0);
});
it("covers a custom automation's own code, falling back to its free-form label", () => {
const custom = IN_FLIGHT.filter(
(run) =>
run.phase_code != null &&
run.phase_label != null &&
resolveRunPhaseText(t, run.phase_code, run.phase_label) ===
run.phase_label,
);
expect(custom.length).toBeGreaterThan(0);
});
it("covers a phase carrying only a label, which the service also accepts", () => {
const labelOnly = IN_FLIGHT.filter(
(run) => run.phase_code == null && !!run.phase_label,
);
expect(labelOnly.length).toBeGreaterThan(0);
});
it("covers a phase carrying only a code, which the service also accepts", () => {
const codeOnly = IN_FLIGHT.filter(
(run) =>
!run.phase_label &&
!!run.phase_code &&
resolveRunPhaseText(t, run.phase_code, run.phase_label) ===
run.phase_code,
);
expect(codeOnly.length).toBeGreaterThan(0);
});
it("covers an in-flight run with no phase fields at all — an older service", () => {
const noPhase = IN_FLIGHT.filter(
(run) => run.phase_code === undefined && run.phase_label === undefined,
);
expect(noPhase.length).toBeGreaterThan(0);
});
it("covers a failed run that kept the phase it stopped at", () => {
const failedWithPhase = ALL_RUNS.filter(
(run) =>
run.status === AutomationRunStatus.FAILED &&
resolveRunPhaseText(t, run.phase_code, run.phase_label) != null,
);
expect(failedWithPhase.length).toBeGreaterThan(0);
});
it("covers a finished run that has a phase on record but never shows it", () => {
const hiddenPhase = ALL_RUNS.filter(
(run) =>
!shouldShowRunPhase(run.status) &&
resolveRunPhaseText(t, run.phase_code, run.phase_label) != null,
);
expect(hiddenPhase.length).toBeGreaterThan(0);
});
it("gives every phase-bearing run a usable timestamp, or the age would read 'Invalid Date'", () => {
const withPhase = ALL_RUNS.filter(
(run) => run.phase_code || run.phase_label,
);
expect(withPhase.length).toBeGreaterThan(0);
for (const run of withPhase) {
if (run.phase_updated_at == null) continue;
expect(Number.isNaN(new Date(run.phase_updated_at).getTime())).toBe(
false,
);
}
});
it("keeps every fixture within the service's own limits on code and label", () => {
for (const run of ALL_RUNS) {
if (run.phase_code)
expect(run.phase_code.length).toBeLessThanOrEqual(128);
if (run.phase_label)
expect(run.phase_label.length).toBeLessThanOrEqual(200);
}
});
});
@@ -57,6 +57,7 @@ const sampleRow = (
conversation_url: "http://localhost:8000/conversations/c1",
error: "boom",
cost: null,
phase: null,
...overrides,
});
@@ -113,6 +114,127 @@ describe("automation-activity-log-export", () => {
expect(csv.split("\n")[1]).toContain("0.4213");
});
it("carries the run's raw phase_code into the export row's phase field", () => {
// Arrange
const run = sampleRun({ phase_code: "sandbox_provisioning" });
// Act
const row = mapAutomationRunToExportRow(run, automation);
// Assert
expect(row.phase).toBe("sandbox_provisioning");
});
it.each([
["null", null],
["undefined", undefined],
])(
"normalizes a %s phase_code to a null phase field",
(_label, phase_code) => {
// Arrange
const run = sampleRun({ phase_code });
// Act
const row = mapAutomationRunToExportRow(run, automation);
// Assert
expect(row.phase).toBeNull();
},
);
it.each([
["an empty", ""],
["a whitespace-only", " "],
])(
"falls back to the label when phase_code is %s string",
(_label, phase_code) => {
// The service stores a whitespace-only field as sent — it rejects only
// a phase blank on *both* — so a blank code arrives alongside a real
// label. Nullish-coalescing exports the blank and loses the label,
// which is the empty cell the fallback exists to prevent.
const run = sampleRun({
phase_code,
phase_label: "Running QA checks on PR #4821",
});
const row = mapAutomationRunToExportRow(run, automation);
expect(row.phase).toBe("Running QA checks on PR #4821");
},
);
it("trims a padded phase before exporting it", () => {
// Arrange
const run = sampleRun({ phase_code: " queued ", phase_label: null });
// Act
const row = mapAutomationRunToExportRow(run, automation);
// Assert
expect(row.phase).toBe("queued");
});
it.each([
["completed", AutomationRunStatus.COMPLETED],
["cancelled", AutomationRunStatus.CANCELLED],
])(
"exports the last phase of a %s run, which no screen shows",
(_label, status) => {
// The export deliberately does not go through `shouldShowRunPhase`:
// that predicate decides what deserves screen space, and hides the
// phase once a run's status says everything. A record is not a screen —
// filtering here would make a run that finished in a known phase
// indistinguishable from one that never reported a phase at all.
const run = sampleRun({
status,
phase_code: "running_agent",
phase_label: null,
});
const row = mapAutomationRunToExportRow(run, automation);
expect(row.phase).toBe("running_agent");
},
);
it("includes a phase column in the CSV header", () => {
const rows: AutomationRunExportRow[] = [sampleRow({ phase: "queued" })];
const csv = serializeActivityLogRowsCsv(rows);
expect(csv.split("\n")[0].split(",")).toContain("phase");
});
it("renders an empty cell (not the string 'null') for a run without a phase", () => {
// Arrange: one run with a phase, one without — both in the same export.
// `trigger` is overridden to a single-key object so its JSON-stringified,
// quoted cell has no internal comma to confuse the naive column split
// below (the CSV escaping itself is covered by a separate test).
const rows: AutomationRunExportRow[] = [
sampleRow({
run_id: "r-with-phase",
trigger: { type: "cron" },
phase: "running_agent",
}),
sampleRow({
run_id: "r-without-phase",
trigger: { type: "cron" },
phase: null,
}),
];
// Act
const csv = serializeActivityLogRowsCsv(rows);
const lines = csv.trim().split("\n");
const header = lines[0].split(",");
const phaseColumnIndex = header.indexOf("phase");
// Assert
const withPhaseCells = lines[1].split(",");
const withoutPhaseCells = lines[2].split(",");
expect(withPhaseCells[phaseColumnIndex]).toBe("running_agent");
expect(withoutPhaseCells[phaseColumnIndex]).toBe("");
expect(withoutPhaseCells[phaseColumnIndex]).not.toBe("null");
});
it("serializes CSV with conversation URL and escaped fields", () => {
const rows: AutomationRunExportRow[] = [
sampleRow({
@@ -151,14 +273,22 @@ describe("automation-activity-log-export", () => {
"http://localhost:8000/conversations/c2",
);
expect(AutomationService.listAutomationRuns).toHaveBeenCalledTimes(2);
expect(AutomationService.listAutomationRuns).toHaveBeenNthCalledWith(1, "a1", {
limit: 100,
offset: 0,
});
expect(AutomationService.listAutomationRuns).toHaveBeenNthCalledWith(2, "a1", {
limit: 100,
offset: 1,
});
expect(AutomationService.listAutomationRuns).toHaveBeenNthCalledWith(
1,
"a1",
{
limit: 100,
offset: 0,
},
);
expect(AutomationService.listAutomationRuns).toHaveBeenNthCalledWith(
2,
"a1",
{
limit: 100,
offset: 1,
},
);
});
it("downloads JSON after paging list runs", async () => {
@@ -213,4 +343,20 @@ describe("automation-activity-log-export", () => {
const blob = vi.mocked(downloadBlob).mock.calls[0][0] as Blob;
expect(blob.type).toBe("text/csv;charset=utf-8");
});
it("falls back to the label when a phase carries no code, so an empty cell means no phase", () => {
// Arrange — the service accepts a phase with only a label, and the UI
// shows it. Exporting the code alone would make such a run look identical
// to one that never reported a phase.
const run = sampleRun({
phase_code: null,
phase_label: "Reticulating splines",
});
// Act
const row = mapAutomationRunToExportRow(run, automation);
// Assert
expect(row.phase).toBe("Reticulating splines");
});
});
@@ -22,6 +22,7 @@ import {
extensionModuleCardSurfaceClassName,
} from "#/utils/extension-module-card-classes";
import { toLatestRunState } from "./to-latest-run-state";
import { RunPhase, shouldShowRunPhase } from "./detail/run-phase";
import { RunStatusBadge } from "./detail/run-status-badge";
import { AutomationRunActivitySparkline } from "#/components/features/home/featured-automations/automation-run-activity-sparkline";
import type { RunSummaryState } from "#/manifests/automation-insights";
@@ -101,6 +102,7 @@ export function AutomationCard({
errorDetail != null &&
shortErrorDetail != null &&
shouldShowAutomationErrorHovercard(errorDetail, shortErrorDetail);
const showPhase = shouldShowRunPhase(latestRun?.status);
const disableAnimation = import.meta.env.MODE === "test";
return (
@@ -209,6 +211,15 @@ export function AutomationCard({
<>
<RunStatusBadge status={latestRun.status} iconOnly showLabel />
{showPhase ? (
<RunPhase
status={latestRun.status}
code={latestRun.phase_code}
label={latestRun.phase_label}
updatedAt={latestRun.phase_updated_at}
/>
) : null}
{shortErrorDetail ? (
showErrorHovercard && errorDetail ? (
<Tooltip
@@ -7,7 +7,9 @@ import {
type Automation,
type AutomationRun,
} from "#/types/automation";
import { isInvalidTimestamp } from "#/utils/format-relative-time";
import { RunStatusBadge } from "./run-status-badge";
import { RunPhase, shouldShowRunPhase } from "./run-phase";
import { RunLogsModal } from "./run-logs-modal";
interface ActivityLogItemProps {
@@ -27,12 +29,6 @@ function formatRunTimestamp(dateStr: string, locale: string): string {
});
}
function isInvalidTimestamp(dateStr: string | null | undefined): boolean {
if (!dateStr) return true;
const t = new Date(dateStr).getTime();
return Number.isNaN(t) || t === 0;
}
function getConversationUrl(conversationId: string): string {
// In agent-canvas, conversations are at /conversations/:id
return `/conversations/${conversationId}`;
@@ -65,6 +61,7 @@ export function ActivityLogItem({ run, automation }: ActivityLogItemProps) {
run.status === AutomationRunStatus.COMPLETED ||
run.status === AutomationRunStatus.FAILED;
const showNoConversationLabel = !hasConversation && isTerminal;
const showPhase = shouldShowRunPhase(run.status);
const [logsOpen, setLogsOpen] = useState(false);
// The backend leaves started_at unset (epoch/zero) while a run is Pending
// and only populates it once execution begins. Show the user's local time
@@ -116,7 +113,7 @@ export function ActivityLogItem({ run, automation }: ActivityLogItemProps) {
</span>
)}
</div>
<div className="flex items-center gap-2">
<div className="flex min-w-0 items-center gap-2">
{formattedCost && (
<span
data-testid="run-cost"
@@ -127,6 +124,15 @@ export function ActivityLogItem({ run, automation }: ActivityLogItemProps) {
</span>
)}
{logsButton}
{showPhase && (
<RunPhase
status={run.status}
code={run.phase_code}
label={run.phase_label}
updatedAt={run.phase_updated_at}
wide
/>
)}
<RunStatusBadge status={run.status} />
</div>
</>
@@ -0,0 +1,212 @@
import { Tooltip } from "@heroui/react";
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { AutomationRunStatus } from "#/types/automation";
import {
formatRelativeTime,
isInvalidTimestamp,
} from "#/utils/format-relative-time";
import { cn } from "#/utils/utils";
/**
* Whether a run's phase is worth showing at all: it answers "what is it doing
* now" or "where did it stop", so a finished, cancelled or skipped run has
* nothing to add. Shared by every surface, so one run cannot show a phase on
* one screen and hide it on another.
*/
export function shouldShowRunPhase(
status: AutomationRunStatus | null | undefined,
): boolean {
return (
status === AutomationRunStatus.FAILED ||
status === AutomationRunStatus.PENDING ||
status === AutomationRunStatus.RUNNING
);
}
/**
* Whether the phase's age is worth showing. Only for a run still in flight:
* the age exists to separate a moving run from a stalled one, and a run that
* has failed is neither — its phase is the place it stopped. Left in, the age
* of an old failure degrades to an absolute date (`formatRelativeTime` gives
* up past a week), which reads as a second timestamp beside the one the row
* already shows.
*/
export function shouldShowRunPhaseAge(
status: AutomationRunStatus | null | undefined,
): boolean {
return (
status === AutomationRunStatus.PENDING ||
status === AutomationRunStatus.RUNNING
);
}
interface RunPhaseFields {
/** `AutomationRun.status` — decides whether the age is meaningful. */
status: AutomationRunStatus | null | undefined;
/** `AutomationRun.phase_code` — `null`/absent means no phase reported. */
code: string | null | undefined;
/** `AutomationRun.phase_label` — free-form author text, not interface copy. */
label: string | null | undefined;
/** `AutomationRun.phase_updated_at` — when this phase was last written. */
updatedAt?: string | null;
}
interface RunPhaseProps extends RunPhaseFields {
/** More width before clipping, for rows far wider than a card. */
wide?: boolean;
}
/**
* Codes the frontend can translate: the automation service's own milestones
* and the preset templates'. Any other code is by design unknown — custom
* automations emit their own — and falls back to `phase_label`.
*/
const KNOWN_PHASE_CODES: Record<string, I18nKey> = {
queued: I18nKey.AUTOMATIONS$DETAIL$PHASE_QUEUED,
sandbox_provisioning: I18nKey.AUTOMATIONS$DETAIL$PHASE_SANDBOX_PROVISIONING,
bundle_upload: I18nKey.AUTOMATIONS$DETAIL$PHASE_BUNDLE_UPLOAD,
entrypoint_start: I18nKey.AUTOMATIONS$DETAIL$PHASE_ENTRYPOINT_START,
preparing: I18nKey.AUTOMATIONS$DETAIL$PHASE_PREPARING,
running_agent: I18nKey.AUTOMATIONS$DETAIL$PHASE_RUNNING_AGENT,
};
/**
* The one place a stored phase becomes text, so a run cannot read one way on
* a card and another way in that card's own tooltip. An absent code counts as
* unrecognized rather than as an absent phase: the service accepts a phase
* carrying only a label, and dropping those would hide a real phase.
*
* `code` and `label` are independently optional in the service's contract, so
* the last resort is the raw code — a code-only phase is a real phase and
* must reach the screen. It is shown as stored rather than prettified: the
* code is data like the label, and turning `poll_prs` into "Poll prs" would
* invent English-shaped copy no automation author wrote.
*
* Both fields are author-supplied, which is why the lookup is an own-property
* check and both are trimmed. A code of `toString` would otherwise resolve to
* `Object.prototype.toString` and be handed to `t()`, and the service stores
* a whitespace-only field as sent — it rejects only a phase blank on *both*.
*/
export function resolveRunPhaseText(
t: (key: I18nKey) => string,
code: string | null | undefined,
label: string | null | undefined,
): string | null {
const knownKey =
code && Object.hasOwn(KNOWN_PHASE_CODES, code)
? KNOWN_PHASE_CODES[code]
: undefined;
if (knownKey) return t(knownKey);
return label?.trim() || code?.trim() || null;
}
/**
* How long the run has been in this phase, as localized relative time.
*
* This is the half of the phase that separates progress from a stall: the
* phase text alone says a run is "Running agent", and only its age says
* whether it entered that phase seconds ago or forty minutes ago. Returns
* `null` when the service reported no usable timestamp — an older service
* omits the field entirely, and an unset datetime arrives as the epoch — so
* an age nobody can compute never surfaces as "Invalid Date" or "Jan 1, 1970".
*/
export function formatRunPhaseAge(
updatedAt: string | null | undefined,
locale: string,
t: (key: I18nKey, options?: Record<string, unknown>) => string,
): string | null {
if (!updatedAt || isInvalidTimestamp(updatedAt)) return null;
return formatRelativeTime(updatedAt, locale, t);
}
/**
* The resolved phase of a run, or `null` when it has none worth showing.
* Every surface goes through this — the clipped row below, the home
* hovercard's wrapping one — so they cannot drift into resolving a phase, or
* deciding to show its age, on their own terms.
*/
export function useRunPhase({
status,
code,
label,
updatedAt,
}: RunPhaseFields): { text: string; age: string | null } | null {
const { t, i18n } = useTranslation("openhands");
const text = resolveRunPhaseText(t, code, label);
if (!text) return null;
const age = shouldShowRunPhaseAge(status)
? formatRunPhaseAge(updatedAt, i18n.language, t)
: null;
return { text, age };
}
/**
* A run's current or last-known phase and how long it has held it, clipped to
* the room the surface has with the full text one hover away — author-supplied
* labels routinely outgrow any row. The label is data, not interface copy, so
* it is rendered as-is: passing it through `t()` would be wrong, it is not a
* key. The age sits outside the clipped text so a long label can never push it
* out of sight — it is the part that stays legible when everything else is cut.
*
* The text stays in the accessibility tree rather than hiding behind an
* accessible name on a focusable wrapper. Every surface nests this inside a
* link — the activity log's `<a>`, the cards' `role="link"` — where a tab
* stop is invalid interactive nesting, and Enter on it bubbled to the card
* and navigated the user away from the text they were trying to read.
* Truncation is CSS only, so the full label is already in the DOM and reads
* in full; the tooltip is the sighted mouse user's route to it.
*/
export function RunPhase({
status,
code,
label,
updatedAt,
wide = false,
}: RunPhaseProps) {
const phase = useRunPhase({ status, code, label, updatedAt });
if (!phase) return null;
const { text, age } = phase;
return (
<Tooltip
content={
<>
{text}
{age ? <span className="mt-1 block text-muted">{age}</span> : null}
</>
}
placement="top"
closeDelay={100}
disableAnimation={import.meta.env.MODE === "test"}
classNames={{
content:
"max-w-xs whitespace-pre-wrap break-words rounded-xl border border-[var(--oh-border)] bg-base-secondary px-3 py-2 text-left text-xs text-white shadow-xl",
}}
>
<span className="flex min-w-0 cursor-default items-center gap-1">
<span
data-testid="run-phase"
className={cn(
"min-w-0 truncate text-xs text-muted",
wide ? "max-w-[28rem]" : "max-w-[12rem]",
)}
>
{text}
</span>
{age ? (
<span
data-testid="run-phase-age"
className="shrink-0 whitespace-nowrap text-xs text-muted"
>
· {age}
</span>
) : null}
</span>
</Tooltip>
);
}
@@ -4,7 +4,15 @@ import { useTranslation } from "react-i18next";
import type { LatestAutomationRunState } from "#/hooks/query/use-latest-automation-runs";
import { I18nKey } from "#/i18n/declaration";
import ClockIcon from "#/icons/clock.svg?react";
import { AutomationRunStatus, type Automation } from "#/types/automation";
import {
shouldShowRunPhase,
useRunPhase,
} from "#/components/features/automations/detail/run-phase";
import {
AutomationRunStatus,
type Automation,
type AutomationRun,
} from "#/types/automation";
import { formatRelativeTime } from "#/utils/format-relative-time";
import { AutomationHealthIndicator } from "./automation-health-indicator";
import {
@@ -38,6 +46,37 @@ function PreviewRow({
);
}
/**
* The run's phase and how long it has held it. The row that opens this
* hovercard has to clip a long phase; here there is room to wrap, so this is
* where the whole thing is readable — and the age is what says whether the
* run is moving through phases or sitting in one. Only the layout differs
* from the row's: what the phase says, and whether its age is meaningful,
* comes from the same hook the row uses.
*/
function PhaseRow({ run }: { run: AutomationRun | null }) {
const { t } = useTranslation("openhands");
const phase = useRunPhase({
status: run?.status,
code: run?.phase_code,
label: run?.phase_label,
updatedAt: run?.phase_updated_at,
});
if (!run || !shouldShowRunPhase(run.status) || !phase) return null;
const { text, age } = phase;
return (
<PreviewRow label={t(I18nKey.AUTOMATIONS$DETAIL$PHASE)}>
<span data-testid="run-phase-row">
{text}
{age ? <span className="text-muted"> · {age}</span> : null}
</span>
</PreviewRow>
);
}
/**
* Left-nav-style hovercard body for an automation's latest-run health.
* Matches `ConversationCardPreview` layout: title + label/value rows.
@@ -79,6 +118,8 @@ export function HomeAutomationRunTooltip({
</span>
</PreviewRow>
<PhaseRow run={latestRun} />
{timestamp ? (
<PreviewRow label={t(I18nKey.AUTOMATIONS$DETAIL$LAST_RUN)}>
{formatRelativeTime(timestamp, i18n.language, t)}
@@ -4,6 +4,10 @@ import { useMemo, useRef } from "react";
import { useTranslation } from "react-i18next";
import { buildAutomationMetadataPills } from "#/components/features/automations/build-automation-pills";
import { EditAutomationModal } from "#/components/features/automations/detail/edit-automation-modal";
import {
RunPhase,
shouldShowRunPhase,
} from "#/components/features/automations/detail/run-phase";
import { RunStatusBadge } from "#/components/features/automations/detail/run-status-badge";
import { AutomationRunStats } from "#/components/features/automations/automation-run-insights";
import { toRunSummaryState } from "#/components/features/automations/to-latest-run-state";
@@ -88,6 +92,7 @@ export function PinnedAutomationCard({
() => buildAutomationMetadataPills(automation, scheduleLabel),
[automation, scheduleLabel],
);
const showPhase = shouldShowRunPhase(latestRun?.status);
const errorDetail =
latestRun?.status === AutomationRunStatus.FAILED
? latestRun.error_detail?.trim() || null
@@ -284,6 +289,15 @@ export function PinnedAutomationCard({
}
/>
{showPhase ? (
<RunPhase
status={latestRun.status}
code={latestRun.phase_code}
label={latestRun.phase_label}
updatedAt={latestRun.phase_updated_at}
/>
) : null}
{shortErrorDetail ? (
showErrorHovercard && errorDetail ? (
<Tooltip
@@ -6,7 +6,12 @@ import {
summarizeAutomationRuns,
type RunSummaryState,
} from "#/manifests/automation-insights";
import type { Automation } from "#/types/automation";
import {
AutomationRunStatus,
type Automation,
type AutomationRun,
type AutomationRunsResponse,
} from "#/types/automation";
/**
* The newest runs sampled per automation. Matches the detail page's default
@@ -14,6 +19,20 @@ import type { Automation } from "#/types/automation";
*/
const RECENT_RUN_SAMPLE_SIZE = 20;
/**
* Poll interval while the newest run is non-terminal, matching
* `useLatestAutomationRuns`: one request per listed automation, so a slower
* cadence than the detail page's 3s. Without it the dashboard's phase and its
* age freeze at whatever the first fetch saw, and a healthy run moving
* through its phases reads as one stuck in the first — the opposite of what
* the age is for.
*/
const IN_FLIGHT_POLL_INTERVAL_MS = 15_000;
const isInFlight = (run: AutomationRun) =>
run.status === AutomationRunStatus.PENDING ||
run.status === AutomationRunStatus.RUNNING;
interface UseAutomationRunSummariesOptions {
enabled?: boolean;
}
@@ -47,6 +66,18 @@ export function useAutomationRunSummaries(
),
staleTime: 60 * 1000,
enabled: enabled && !!automation.id,
refetchInterval: (query: {
state: { data?: AutomationRunsResponse };
}) => {
// The newest run only, which is the one the dashboard renders.
// `.some()` over the whole sample keeps polling forever when an
// older run was left non-terminal by a crashed dispatcher, and
// every one of those requests changes nothing on screen.
const latest = query.state.data?.runs?.[0];
return latest && isInFlight(latest)
? IN_FLIGHT_POLL_INTERVAL_MS
: false;
},
})),
combine: (results) => {
const byId = new Map<string, RunSummaryState>();
+119
View File
@@ -13854,6 +13854,125 @@
"uk": "Пропущено",
"ca": "Omès"
},
"AUTOMATIONS$DETAIL$PHASE": {
"ar": "المرحلة",
"ca": "Fase",
"de": "Phase",
"en": "Phase",
"es": "Fase",
"fr": "Phase",
"it": "Fase",
"ja": "フェーズ",
"ko-KR": "단계",
"no": "Fase",
"pt": "Fase",
"tr": "Aşama",
"uk": "Етап",
"zh-CN": "阶段",
"zh-TW": "階段"
},
"AUTOMATIONS$DETAIL$PHASE_QUEUED": {
"ar": "في قائمة الانتظار",
"ca": "A la cua",
"de": "In der Warteschlange",
"en": "Queued",
"es": "En cola",
"fr": "En file d'attente",
"it": "In coda",
"ja": "待機中",
"ko-KR": "대기 중",
"no": "I kø",
"pt": "Na fila",
"tr": "Sırada",
"uk": "У черзі",
"zh-CN": "排队中",
"zh-TW": "排隊中"
},
"AUTOMATIONS$DETAIL$PHASE_SANDBOX_PROVISIONING": {
"ar": "جارٍ إعداد بيئة العمل",
"ca": "Configurant l'entorn aïllat",
"de": "Sandbox wird eingerichtet",
"en": "Setting up sandbox",
"es": "Configurando entorno aislado",
"fr": "Préparation du bac à sable",
"it": "Configurazione sandbox",
"ja": "サンドボックスを準備中",
"ko-KR": "샌드박스 준비 중",
"no": "Setter opp sandkasse",
"pt": "Configurando sandbox",
"tr": "Sandbox hazırlanıyor",
"uk": "Налаштування пісочниці",
"zh-CN": "正在配置沙盒",
"zh-TW": "正在設定沙盒"
},
"AUTOMATIONS$DETAIL$PHASE_BUNDLE_UPLOAD": {
"ar": "جارٍ رفع الحزمة",
"ca": "Pujant el paquet",
"de": "Paket wird hochgeladen",
"en": "Uploading bundle",
"es": "Subiendo paquete",
"fr": "Téléversement du paquet",
"it": "Caricamento del pacchetto",
"ja": "バンドルをアップロード中",
"ko-KR": "번들 업로드 중",
"no": "Laster opp pakke",
"pt": "Enviando pacote",
"tr": "Paket yükleniyor",
"uk": "Завантаження пакета",
"zh-CN": "正在上传软件包",
"zh-TW": "正在上傳套件"
},
"AUTOMATIONS$DETAIL$PHASE_ENTRYPOINT_START": {
"ar": "جارٍ البدء",
"ca": "S'està iniciant",
"de": "Wird gestartet",
"en": "Starting",
"es": "Iniciando",
"fr": "Démarrage",
"it": "Avvio in corso",
"ja": "起動中",
"ko-KR": "시작 중",
"no": "Starter",
"pt": "Iniciando",
"tr": "Başlatılıyor",
"uk": "Запуск",
"zh-CN": "正在启动",
"zh-TW": "正在啟動"
},
"AUTOMATIONS$DETAIL$PHASE_PREPARING": {
"ar": "جارٍ التحضير",
"ca": "Preparant",
"de": "Wird vorbereitet",
"en": "Preparing",
"es": "Preparando",
"fr": "Préparation",
"it": "Preparazione",
"ja": "準備中",
"ko-KR": "준비 중",
"no": "Forbereder",
"pt": "Preparando",
"tr": "Hazırlanıyor",
"uk": "Підготовка",
"zh-CN": "正在准备",
"zh-TW": "正在準備"
},
"AUTOMATIONS$DETAIL$PHASE_RUNNING_AGENT": {
"ar": "الوكيل قيد التشغيل",
"ca": "Executant l'agent",
"de": "Agent wird ausgeführt",
"en": "Running agent",
"es": "Ejecutando agente",
"fr": "Agent en cours d'exécution",
"it": "Agente in esecuzione",
"ja": "エージェントを実行中",
"ko-KR": "에이전트 실행 중",
"no": "Kjører agent",
"pt": "Executando agente",
"tr": "Ajan çalışıyor",
"uk": "Агент виконується",
"zh-CN": "代理运行中",
"zh-TW": "代理執行中"
},
"AUTOMATIONS$DETAIL$INACTIVE": {
"en": "Inactive",
"ja": "非アクティブ",
+5
View File
@@ -285,6 +285,11 @@ export const AUTOMATION_HANDLERS = [
conversation_id: null,
bash_command_id: null,
error_detail: null,
// A freshly dispatched run has not reported a phase yet — the service
// sends the fields as null rather than omitting them.
phase_code: null,
phase_label: null,
phase_updated_at: null,
started_at: new Date().toISOString(),
completed_at: null,
};
+93 -2
View File
@@ -7,12 +7,36 @@ const daysAgo = (days: number, hour = 9) => {
return d.toISOString();
};
const minutesAgo = (minutes: number) =>
new Date(Date.now() - minutes * 60_000).toISOString();
/** Omitting this entirely is a case too: an older service sends no phase. */
interface PhaseFixture {
code: string | null;
label: string | null;
/** Minutes since the phase was last written; defaults to the run's start. */
ageMinutes?: number;
}
function phaseFields(phase: PhaseFixture | undefined, fallbackAt: string) {
if (!phase) return {};
return {
phase_code: phase.code,
phase_label: phase.label,
phase_updated_at:
phase.ageMinutes === undefined
? fallbackAt
: minutesAgo(phase.ageMinutes),
};
}
function makeRun(
id: string,
status: AutomationRunStatus,
startedDaysAgo: number,
hour = 9,
hasConversation = true,
phase?: PhaseFixture,
): AutomationRun {
const started = daysAgo(startedDaysAgo, hour);
return {
@@ -26,17 +50,56 @@ function makeRun(
status === AutomationRunStatus.FAILED
? "Process exited with code 1"
: null,
...phaseFields(phase, started),
started_at: started,
completed_at: new Date(new Date(started).getTime() + 120_000).toISOString(),
};
}
/**
* A run that has not reached a terminal status, started relative to page load
* so its elapsed time keeps growing while the dashboard stays open. These are
* the rows the phase exists for: without one they render as a bare
* "Running"/"Pending" pill for however long the run takes.
*/
function makeInFlightRun(
id: string,
status: AutomationRunStatus.RUNNING | AutomationRunStatus.PENDING,
startedMinutesAgo: number,
hasConversation = true,
phase?: PhaseFixture,
): AutomationRun {
const startedAt = minutesAgo(startedMinutesAgo);
return {
id,
status,
conversation_id: hasConversation ? `conv-${id}` : null,
bash_command_id: hasConversation ? `cmd-${id}` : null,
error_detail: null,
...phaseFields(phase, startedAt),
started_at: startedAt,
completed_at: null,
};
}
// Newest run first: the cards and home rows read runs[0] as the latest, so
// the in-flight rows below are the ones they show. Only three automations get
// one: `latestRun` also drives health, "Last run" and the status badge, so a
// never-terminal fixture pins all three to "running".
export const MOCK_AUTOMATION_RUNS: Record<string, AutomationRun[]> = {
"a1000000-0000-0000-0000-000000000001": [
makeRun("r1-01", AutomationRunStatus.COMPLETED, 0),
makeRun("r1-02", AutomationRunStatus.COMPLETED, 1),
makeRun("r1-03", AutomationRunStatus.FAILED, 2),
makeRun("r1-04", AutomationRunStatus.COMPLETED, 3),
// Failed, keeping the phase it stopped at: died while the sandbox came up.
makeRun("r1-03", AutomationRunStatus.FAILED, 2, 9, true, {
code: "sandbox_provisioning",
label: null,
}),
// A phase on record that no screen shows, but the export still carries.
makeRun("r1-04", AutomationRunStatus.COMPLETED, 3, 9, true, {
code: "running_agent",
label: null,
}),
makeRun("r1-05", AutomationRunStatus.COMPLETED, 4),
makeRun("r1-06", AutomationRunStatus.COMPLETED, 7),
makeRun("r1-07", AutomationRunStatus.FAILED, 8),
@@ -45,6 +108,11 @@ export const MOCK_AUTOMATION_RUNS: Record<string, AutomationRun[]> = {
makeRun("r1-10", AutomationRunStatus.COMPLETED, 11),
],
"a1000000-0000-0000-0000-000000000002": [
// Queued: no sandbox or conversation yet, and only the phase says so.
makeInFlightRun("r2-00", AutomationRunStatus.PENDING, 2, false, {
code: "queued",
label: null,
}),
makeRun("r2-01", AutomationRunStatus.COMPLETED, 0, 1),
makeRun("r2-02", AutomationRunStatus.COMPLETED, 1, 1),
makeRun("r2-03", AutomationRunStatus.COMPLETED, 2, 1),
@@ -52,6 +120,14 @@ export const MOCK_AUTOMATION_RUNS: Record<string, AutomationRun[]> = {
makeRun("r2-05", AutomationRunStatus.COMPLETED, 4, 1),
],
"a1000000-0000-0000-0000-000000000003": [
// The case the issue is about: 52 minutes into a job that usually takes
// two, stuck on one step for 41 of them. The code is the automation's
// own, so its label is shown verbatim — and is long enough to be clipped.
makeInFlightRun("r3-00", AutomationRunStatus.RUNNING, 52, true, {
code: "diffing_docs_tree",
label: "Diffing 340 changed files against main",
ageMinutes: 41,
}),
makeRun("r3-01", AutomationRunStatus.COMPLETED, 1),
// Terminal statuses the backend emits besides COMPLETED/FAILED.
makeRun("r3-02", AutomationRunStatus.CANCELLED, 2),
@@ -63,6 +139,21 @@ export const MOCK_AUTOMATION_RUNS: Record<string, AutomationRun[]> = {
],
"a1000000-0000-0000-0000-000000000005": [],
"a1000000-0000-0000-0000-000000000006": [
// A label with no code: the service accepts that, so the UI must show it.
makeInFlightRun("r6-00", AutomationRunStatus.RUNNING, 13, true, {
code: null,
label: "Running QA checks on PR #4821",
ageMinutes: 4,
}),
// A code with no label: also accepted, and shown as the raw code.
makeInFlightRun("r6-06", AutomationRunStatus.RUNNING, 6, true, {
code: "checking_out",
label: null,
ageMinutes: 2,
}),
// No phase fields at all — an older service: a status pill, no empty
// slot. Below the head, so it costs no card its health.
makeInFlightRun("r6-07", AutomationRunStatus.RUNNING, 21),
makeRun("r6-01", AutomationRunStatus.COMPLETED, 0, 14),
makeRun("r6-02", AutomationRunStatus.COMPLETED, 0, 11),
makeRun("r6-03", AutomationRunStatus.FAILED, 1, 16),
+20
View File
@@ -93,6 +93,21 @@ export interface AutomationRun {
* that added the field, hence optional.
*/
cost?: number | null;
/**
* Machine-readable code for the run's current or last-known phase (e.g.
* "sandbox_provisioning"). `null` means nothing has reported one; absent
* entirely against an automation service that predates phase reporting.
* Code and label are one value, always written together.
*/
phase_code?: string | null;
/**
* Author-supplied description of the phase (at most 200 characters, no
* control or separator characters, emoji and non-Latin text allowed).
* Data, not translatable interface copy.
*/
phase_label?: string | null;
/** UTC datetime the phase was last written. Same nullability as `phase_code`. */
phase_updated_at?: string | null;
started_at: string;
completed_at: string | null;
}
@@ -123,4 +138,9 @@ export interface AutomationRunExportRow {
* missing field to null so every exported record has the same shape.
*/
cost: number | null;
/**
* The raw `phase_code`, like `status`, falling back to `phase_label` for a
* phase reported without a code. Null only when the run has no phase.
*/
phase: string | null;
}
@@ -24,6 +24,7 @@ const CSV_COLUMNS = [
// Appended rather than grouped with the other run metrics so existing
// consumers that read the CSV by column position keep working.
"cost",
"phase",
] as const;
export function getActivityLogExportFilename(
@@ -104,6 +105,20 @@ export function mapAutomationRunToExportRow(
// Exports carry the raw number and leave formatting to the consumer; the
// Activity Log row is the only place that renders it as USD.
cost: run.cost ?? null,
// Deliberately not filtered through `shouldShowRunPhase`: that predicate
// decides what is worth a user's attention on screen, and hides the phase
// of a run whose status already says everything. An export is a record,
// not a screen — a completed run's last phase is real data, and dropping
// it here would make the phase of a run that finished indistinguishable
// from one that never reported a phase at all.
//
// Falling back to the label: without it, a phase reported with only a
// label would export as an empty cell, indistinguishable from no phase.
// Trimmed rather than nullish-coalesced: the service stores a
// whitespace-only field as sent — it rejects only a phase blank on
// *both* — so `??` would let a blank code suppress a real label, which
// is exactly the empty cell this fallback exists to prevent.
phase: run.phase_code?.trim() || run.phase_label?.trim() || null,
};
}
+14
View File
@@ -9,6 +9,20 @@ export function formatDate(dateStr: string, locale: string): string {
});
}
/**
* Whether a timestamp is one nothing can be computed from. Besides an
* unparseable string, that includes the epoch: the backend leaves unset
* datetimes at zero, and formatting one produces "Jan 1, 1970" rather than
* an honest "unknown".
*/
export function isInvalidTimestamp(
dateStr: string | null | undefined,
): boolean {
if (!dateStr) return true;
const parsed = new Date(dateStr).getTime();
return Number.isNaN(parsed) || parsed === 0;
}
export function formatRelativeTime(
dateStr: string,
locale: string,