From 92b5bf6bb10012015a00b768b556a02bc1bc767b Mon Sep 17 00:00:00 2001 From: rdoupe Date: Mon, 23 Mar 2026 23:37:22 -0400 Subject: [PATCH 1/4] fix: stop working timer when agent is awaiting user input The isWorking flag was computed before pendingUserInputs, so it never accounted for the agent being in a paused state waiting for user input. This caused the "Working for Xm Xs" timer to keep counting indefinitely even after the agent had asked a question and was no longer actively running. Fixes #1069, closes #1335 Co-Authored-By: Claude Sonnet 4.6 --- apps/web/src/components/ChatView.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index e628f6ea6a..00e495c908 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -660,7 +660,6 @@ export default function ChatView({ threadId }: ChatViewProps) { const phase = derivePhase(activeThread?.session ?? null); const isSendBusy = sendPhase !== "idle"; const isPreparingWorktree = sendPhase === "preparing-worktree"; - const isWorking = phase === "running" || isSendBusy || isConnecting || isRevertingCheckpoint; const nowIso = new Date(nowTick).toISOString(); const activeWorkStartedAt = deriveActiveWorkStartedAt( activeLatestTurn, @@ -684,6 +683,9 @@ export default function ChatView({ threadId }: ChatViewProps) { () => derivePendingUserInputs(threadActivities), [threadActivities], ); + const isWorking = + (phase === "running" || isSendBusy || isConnecting || isRevertingCheckpoint) && + pendingUserInputs.length === 0; const activePendingUserInput = pendingUserInputs[0] ?? null; const activePendingDraftAnswers = useMemo( () => From d07855fb0d8c0fcd4e3168034de01ea84e1b2a17 Mon Sep 17 00:00:00 2001 From: rdoupe Date: Tue, 24 Mar 2026 00:03:07 -0400 Subject: [PATCH 2/4] fix: stop working timer when turn is complete or awaiting user input Two locations showed stale "Working" state after the agent finished: 1. ChatView.tsx - "Working for Xm Xs" timer in the main chat 2. Sidebar.logic.ts - "Working" pill on thread list items Root cause: both checked only session.status === "running", but the server can lag in transitioning that status even after the latest turn has a completedAt timestamp. Also, neither checked for pending user inputs, so the timer kept running when the agent was blocked waiting for the user to answer a question. Fix: suppress "working" state when latestTurn.completedAt is set (turn is done regardless of server status lag), or when there are pending user inputs (agent is waiting, not working). Fixes #1069, closes #1335 Co-Authored-By: Claude Sonnet 4.6 --- apps/web/src/components/ChatView.tsx | 11 ++++++++--- apps/web/src/components/Sidebar.logic.ts | 2 +- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 00e495c908..d5e8f30d5f 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -683,9 +683,14 @@ export default function ChatView({ threadId }: ChatViewProps) { () => derivePendingUserInputs(threadActivities), [threadActivities], ); - const isWorking = - (phase === "running" || isSendBusy || isConnecting || isRevertingCheckpoint) && - pendingUserInputs.length === 0; + // phase === "running" alone isn't sufficient: the session can stay "running" even after + // the latest turn has completed (server status lag) or while waiting for user input. + // Only treat the agent as "working" if the turn has no completedAt yet and no pending questions. + const isActivelyRunning = + phase === "running" && + pendingUserInputs.length === 0 && + !activeLatestTurn?.completedAt; + const isWorking = isActivelyRunning || isSendBusy || isConnecting || isRevertingCheckpoint; const activePendingUserInput = pendingUserInputs[0] ?? null; const activePendingDraftAnswers = useMemo( () => diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index ef338dab67..6d39269220 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -115,7 +115,7 @@ export function resolveThreadStatusPill(input: { }; } - if (thread.session?.status === "running") { + if (thread.session?.status === "running" && !thread.latestTurn?.completedAt) { return { label: "Working", colorClass: "text-sky-600 dark:text-sky-300/80", From a8559289559a62499ed24f24d84e450f3f079427 Mon Sep 17 00:00:00 2001 From: rdoupe Date: Tue, 24 Mar 2026 00:12:40 -0400 Subject: [PATCH 3/4] style: apply oxfmt formatting Co-Authored-By: Claude Sonnet 4.6 --- apps/web/src/components/ChatView.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index d5e8f30d5f..aa8514b54a 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -687,9 +687,7 @@ export default function ChatView({ threadId }: ChatViewProps) { // the latest turn has completed (server status lag) or while waiting for user input. // Only treat the agent as "working" if the turn has no completedAt yet and no pending questions. const isActivelyRunning = - phase === "running" && - pendingUserInputs.length === 0 && - !activeLatestTurn?.completedAt; + phase === "running" && pendingUserInputs.length === 0 && !activeLatestTurn?.completedAt; const isWorking = isActivelyRunning || isSendBusy || isConnecting || isRevertingCheckpoint; const activePendingUserInput = pendingUserInputs[0] ?? null; const activePendingDraftAnswers = useMemo( From 96eba9468478be6f9e56b52d03713f490fcb08d2 Mon Sep 17 00:00:00 2001 From: rdoupe Date: Tue, 24 Mar 2026 10:14:23 -0400 Subject: [PATCH 4/4] fix: use completedAt as reliable turn completion signal The server lifecycle guard (shouldApplyThreadLifecycle) can reject turn.completed events, leaving session.status stuck on "running" and activeTurnId permanently stale. Since completedAt is set through an independent path (thread.turn.diff.complete), it reliably indicates turn completion regardless of session status lag. Extract turnDone variable and add comments explaining the root cause. Co-Authored-By: Claude Opus 4.6 --- apps/web/src/components/ChatView.tsx | 9 +++++---- apps/web/src/components/Sidebar.logic.ts | 6 +++++- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index aa8514b54a..4f09c7e1dc 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -684,10 +684,11 @@ export default function ChatView({ threadId }: ChatViewProps) { [threadActivities], ); // phase === "running" alone isn't sufficient: the session can stay "running" even after - // the latest turn has completed (server status lag) or while waiting for user input. - // Only treat the agent as "working" if the turn has no completedAt yet and no pending questions. - const isActivelyRunning = - phase === "running" && pendingUserInputs.length === 0 && !activeLatestTurn?.completedAt; + // the latest turn has completed (server lifecycle guard can reject status updates while + // completedAt is set through a separate path) or while waiting for user input. + // Use completedAt on the latest turn as the reliable completion signal. + const turnDone = !!activeLatestTurn?.completedAt; + const isActivelyRunning = phase === "running" && pendingUserInputs.length === 0 && !turnDone; const isWorking = isActivelyRunning || isSendBusy || isConnecting || isRevertingCheckpoint; const activePendingUserInput = pendingUserInputs[0] ?? null; const activePendingDraftAnswers = useMemo( diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 6d39269220..abc52754ac 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -115,7 +115,11 @@ export function resolveThreadStatusPill(input: { }; } - if (thread.session?.status === "running" && !thread.latestTurn?.completedAt) { + // Use completedAt as the reliable completion signal — activeTurnId can be permanently + // stale when the server lifecycle guard rejects status updates. + const sidebarTurnDone = !!thread.latestTurn?.completedAt; + + if (thread.session?.status === "running" && !sidebarTurnDone) { return { label: "Working", colorClass: "text-sky-600 dark:text-sky-300/80",