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 EDITABLE_COLUMNS: Set<Column> = new Set(["triage", "todo"]);
|
||||||
|
|
||||||
const ACTIVE_STATUSES = new Set(["planning", "researching", "executing", "finalizing", "merging"]);
|
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> = {
|
const COLUMN_PROGRESS_COLOR_MAP: Record<Column, string> = {
|
||||||
triage: "var(--triage)",
|
triage: "var(--triage)",
|
||||||
@@ -693,7 +694,9 @@ function TaskCardComponent({
|
|||||||
return;
|
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 elapsedMs = getInProgressElapsedMs(task, Date.now());
|
||||||
const instrumentedMs = getInstrumentedDurationMs(task, Date.now());
|
const instrumentedMs = getInstrumentedDurationMs(task, Date.now());
|
||||||
if (elapsedMs == null && instrumentedMs == null) {
|
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());
|
const instrumentedMs = getInstrumentedDurationMs(task, Date.now());
|
||||||
if (instrumentedMs == null) {
|
if (instrumentedMs == null) {
|
||||||
return;
|
return;
|
||||||
@@ -714,13 +717,32 @@ function TaskCardComponent({
|
|||||||
}, LIVE_TIME_INDICATOR_POLL_MS);
|
}, LIVE_TIME_INDICATOR_POLL_MS);
|
||||||
|
|
||||||
return () => window.clearInterval(interval);
|
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(() => {
|
const timeIndicator = useMemo(() => {
|
||||||
if (!TIME_INDICATOR_COLUMNS.has(task.column)) {
|
if (!TIME_INDICATOR_COLUMNS.has(task.column)) {
|
||||||
return null;
|
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") {
|
if (task.column === "in-progress") {
|
||||||
const elapsedMs =
|
const elapsedMs =
|
||||||
getInProgressElapsedMs(task, timeIndicatorNowMs)
|
getInProgressElapsedMs(task, timeIndicatorNowMs)
|
||||||
@@ -767,7 +789,7 @@ function TaskCardComponent({
|
|||||||
title: `Execution time ${elapsedLabel}. Completed ${completedAt}`,
|
title: `Execution time ${elapsedLabel}. Completed ${completedAt}`,
|
||||||
ariaLabel: `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(() => {
|
useEffect(() => {
|
||||||
if (!hasGitHubBadge || !isInViewport) {
|
if (!hasGitHubBadge || !isInViewport) {
|
||||||
|
|||||||
@@ -738,6 +738,42 @@ describe("TaskCard", () => {
|
|||||||
expect(timer?.getAttribute("title")).toContain("Execution time 12m");
|
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", () => {
|
it("does not render timer chip for in-review cards without instrumentation data", () => {
|
||||||
const { container } = render(
|
const { container } = render(
|
||||||
<TaskCard
|
<TaskCard
|
||||||
|
|||||||
Reference in New Issue
Block a user