fix(dashboard): keep card timer live while task is actively merging

The in-review timer chip was driven by per-step instrumented duration,
which is frozen during a merge (the merge phase isn't tracked as a
workflow step). A stuck merge could read "3m" indefinitely. While
status is "merging"/"merging-pr", show live elapsed since updatedAt
(set by the merger at status flip) with a "Merging Nm" tooltip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-04-29 14:02:24 -07:00
parent 4cc495375c
commit 7c59d9b7e0
2 changed files with 62 additions and 4 deletions

View File

@@ -84,6 +84,7 @@ function abbreviateBadge(text: string, max: number): string {
const EDITABLE_COLUMNS: Set<Column> = new Set(["triage", "todo"]);
const ACTIVE_STATUSES = new Set(["planning", "researching", "executing", "finalizing", "merging"]);
const ACTIVE_MERGE_STATUSES = new Set(["merging", "merging-pr"]);
const COLUMN_PROGRESS_COLOR_MAP: Record<Column, string> = {
triage: "var(--triage)",
@@ -693,7 +694,9 @@ function TaskCardComponent({
return;
}
if (task.column === "in-progress") {
const merging = task.status != null && ACTIVE_MERGE_STATUSES.has(task.status);
if (!merging && task.column === "in-progress") {
const elapsedMs = getInProgressElapsedMs(task, Date.now());
const instrumentedMs = getInstrumentedDurationMs(task, Date.now());
if (elapsedMs == null && instrumentedMs == null) {
@@ -701,7 +704,7 @@ function TaskCardComponent({
}
}
if (task.column === "in-review") {
if (!merging && task.column === "in-review") {
const instrumentedMs = getInstrumentedDurationMs(task, Date.now());
if (instrumentedMs == null) {
return;
@@ -714,13 +717,32 @@ function TaskCardComponent({
}, LIVE_TIME_INDICATOR_POLL_MS);
return () => window.clearInterval(interval);
}, [task.column, task.columnMovedAt, task.updatedAt, task.workflowStepResults, task.timedExecutionMs]);
}, [task.column, task.status, task.columnMovedAt, task.updatedAt, task.workflowStepResults, task.timedExecutionMs]);
const timeIndicator = useMemo(() => {
if (!TIME_INDICATOR_COLUMNS.has(task.column)) {
return null;
}
// While a merge is actively running, the per-step instrumented duration
// is frozen (the merge phase isn't tracked as a workflow step). Show
// live elapsed since `updatedAt` — which the merger sets when it flips
// status to "merging" — so stuck merges don't appear stuck at "3m".
if (task.status != null && ACTIVE_MERGE_STATUSES.has(task.status)) {
const startedMs = parseTimestampToMs(task.updatedAt);
if (startedMs != null) {
const elapsedMs = Math.max(0, timeIndicatorNowMs - startedMs);
const elapsedLabel = formatElapsedDuration(elapsedMs);
if (elapsedLabel) {
return {
label: elapsedLabel,
title: `Merging ${elapsedLabel}`,
ariaLabel: `Merging ${elapsedLabel}`,
};
}
}
}
if (task.column === "in-progress") {
const elapsedMs =
getInProgressElapsedMs(task, timeIndicatorNowMs)
@@ -767,7 +789,7 @@ function TaskCardComponent({
title: `Execution time ${elapsedLabel}. Completed ${completedAt}`,
ariaLabel: `Execution time ${elapsedLabel}. Completed ${completedAt}`,
};
}, [task.column, task.columnMovedAt, task.timedExecutionMs, task.updatedAt, task.workflowStepResults, task.log, timeIndicatorNowMs]);
}, [task.column, task.status, task.columnMovedAt, task.timedExecutionMs, task.updatedAt, task.workflowStepResults, task.log, timeIndicatorNowMs]);
useEffect(() => {
if (!hasGitHubBadge || !isInViewport) {

View File

@@ -738,6 +738,42 @@ describe("TaskCard", () => {
expect(timer?.getAttribute("title")).toContain("Execution time 12m");
});
it("shows live merge elapsed in timer chip while task.status is merging", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-04-25T13:45:00.000Z"));
try {
const { container } = render(
<TaskCard
task={makeTask({
column: "in-review",
status: "merging",
updatedAt: "2026-04-25T13:00:00.000Z",
workflowStepResults: [
{
workflowStepId: "step-1",
workflowStepName: "Plan",
phase: "pre-merge" as const,
status: "passed" as const,
startedAt: "2026-04-25T12:00:00.000Z",
completedAt: "2026-04-25T12:03:00.000Z",
},
],
})}
onOpenDetail={noop}
addToast={noop}
/>,
);
const timer = container.querySelector(".card-time-indicator");
expect(timer).not.toBeNull();
expect(timer?.textContent).toContain("45m");
expect(timer?.getAttribute("title")).toBe("Merging 45m");
} finally {
vi.useRealTimers();
}
});
it("does not render timer chip for in-review cards without instrumentation data", () => {
const { container } = render(
<TaskCard