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:
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user