fix(copilot): timer only ticks after turn fully finishes

The countdown was ticking during LLM streaming (gated on showActions,
not actionsEnabled), so streaming time ate into the user's review
window. A 10s stream meant only 50s to review the plan.

Gate the interval on actionsEnabled (includes !isMessageStreaming) so
the timer starts at the full countdown duration only after all streaming
completes. For session re-entry, the lazy initializer computes remaining
from created_at. Removes the created_at-based recompute from the live
interval (no longer needed since the timer doesn't tick during streaming
and naive decrement is accurate when the tab is active).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
anvyle
2026-04-22 11:14:07 +02:00
parent f8df0fabaa
commit 95362f7881

View File

@@ -186,43 +186,19 @@ export function DecomposeGoalTool({
}
}, [showActions, isEditing]);
// Re-derive remaining seconds from ``created_at`` on every tick (and on
// tab visibility change) instead of decrementing a local counter.
// ``setInterval`` is aggressively throttled in backgrounded tabs, so a
// naive ``s - 1`` drifts behind wall-clock time the user could reopen
// the tab well past the deadline and still see e.g. "Starting in 45".
// Re-deriving keeps the UI in sync with the server-side timer.
const createdAt =
output && isDecompositionOutput(output) ? output.created_at : undefined;
// The timer only ticks once the turn is fully finished (actionsEnabled
// includes !isMessageStreaming). This gives the user the full countdown
// duration to review the plan after all streaming completes — not from
// when the tool returned (which would eat streaming time into the review
// window). For session re-entry, the lazy initializer already seeds
// secondsLeft from created_at, so the timer resumes correctly.
useEffect(() => {
if (!showActions || !timerActive) return;
const deadlineMs = createdAt
? new Date(createdAt).getTime() + countdownSeconds * 1000
: null;
const hasDeadline = deadlineMs !== null && !Number.isNaN(deadlineMs);
function recompute() {
if (hasDeadline) {
const remaining = Math.max(
0,
Math.round((deadlineMs - Date.now()) / 1000),
);
setSecondsLeft(Math.min(countdownSeconds, remaining));
} else {
// Legacy session with no ``created_at`` — fall back to naive decrement.
setSecondsLeft((s) => Math.max(0, s - 1));
}
}
// Only correct stale state for deadline-driven sessions. The legacy
// fallback starts from the seeded ``secondsLeft`` and should not
// decrement before the first 1s tick elapses.
if (hasDeadline) recompute();
const interval = setInterval(recompute, 1000);
document.addEventListener("visibilitychange", recompute);
return () => {
clearInterval(interval);
document.removeEventListener("visibilitychange", recompute);
};
}, [showActions, timerActive, part.toolCallId, createdAt, countdownSeconds]);
if (!actionsEnabled || !timerActive) return;
const interval = setInterval(() => {
setSecondsLeft((s) => Math.max(0, s - 1));
}, 1000);
return () => clearInterval(interval);
}, [actionsEnabled, timerActive, part.toolCallId]);
// Auto-approve when countdown reaches 0. The client fires at 60s; the
// server fires 5s later as a fallback for the "user closed the tab" case.