diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 07561e8e64..c6267923d7 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -52,6 +52,7 @@ import { deriveWorkLogEntries, hasActionableProposedPlan, hasToolActivityForTurn, + isSessionActivelyRunningTurn, isLatestTurnSettled, formatElapsed, } from "../session-logic"; @@ -926,7 +927,11 @@ export default function ChatView({ threadId }: ChatViewProps) { activePendingUserInput: activePendingUserInput?.requestId ?? null, threadError: activeThread?.error, }); - const isWorking = phase === "running" || isSendBusy || isConnecting || isRevertingCheckpoint; + const isTurnRunning = isSessionActivelyRunningTurn( + activeLatestTurn, + activeThread?.session ?? null, + ); + const isWorking = isTurnRunning || isSendBusy || isConnecting || isRevertingCheckpoint; const nowIso = new Date(nowTick).toISOString(); const activeWorkStartedAt = deriveActiveWorkStartedAt( activeLatestTurn, @@ -943,7 +948,7 @@ export default function ChatView({ threadId }: ChatViewProps) { if (activePendingProgress) { return `pending:${activePendingProgress.questionIndex}:${activePendingProgress.isLastQuestion}:${activePendingIsResponding}`; } - if (phase === "running") { + if (isTurnRunning) { return "running"; } if (showPlanFollowUpPrompt) { @@ -957,7 +962,7 @@ export default function ChatView({ threadId }: ChatViewProps) { isConnecting, isPreparingWorktree, isSendBusy, - phase, + isTurnRunning, prompt, showPlanFollowUpPrompt, ]); @@ -2143,10 +2148,10 @@ export default function ChatView({ threadId }: ChatViewProps) { scheduleStickToBottom(); }, [messageCount, scheduleStickToBottom]); useEffect(() => { - if (phase !== "running") return; + if (!isTurnRunning) return; if (!shouldAutoScrollRef.current) return; scheduleStickToBottom(); - }, [phase, scheduleStickToBottom, timelineEntries]); + }, [isTurnRunning, scheduleStickToBottom, timelineEntries]); useEffect(() => { setExpandedWorkGroups({}); @@ -2365,14 +2370,14 @@ export default function ChatView({ threadId }: ChatViewProps) { : "local"; useEffect(() => { - if (phase !== "running") return; + if (!isTurnRunning) return; const timer = window.setInterval(() => { setNowTick(Date.now()); }, 1000); return () => { window.clearInterval(timer); }; - }, [phase]); + }, [isTurnRunning]); useEffect(() => { if (!activeThreadId) return; @@ -2592,7 +2597,7 @@ export default function ChatView({ threadId }: ChatViewProps) { const api = readNativeApi(); if (!api || !activeThread || isRevertingCheckpoint) return; - if (phase === "running" || isSendBusy || isConnecting) { + if (isTurnRunning || isSendBusy || isConnecting) { setThreadError(activeThread.id, "Interrupt the current turn before reverting checkpoints."); return; } @@ -2625,7 +2630,7 @@ export default function ChatView({ threadId }: ChatViewProps) { } setIsRevertingCheckpoint(false); }, - [activeThread, isConnecting, isRevertingCheckpoint, isSendBusy, phase, setThreadError], + [activeThread, isConnecting, isRevertingCheckpoint, isSendBusy, isTurnRunning, setThreadError], ); const onSend = async (e?: { preventDefault: () => void }) => { @@ -4214,7 +4219,7 @@ export default function ChatView({ threadId }: ChatViewProps) { } : null } - isRunning={phase === "running"} + isRunning={isTurnRunning} showPlanFollowUpPrompt={ pendingUserInputs.length === 0 && showPlanFollowUpPrompt } diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index e05c3b5e93..bc1b69841d 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -20,6 +20,7 @@ import { findSidebarProposedPlan, hasActionableProposedPlan, hasToolActivityForTurn, + isSessionActivelyRunningTurn, isLatestTurnSettled, } from "./session-logic"; @@ -1072,15 +1073,30 @@ describe("isLatestTurnSettled", () => { } as const; it("returns false while the same turn is still active in a running session", () => { + expect( + isLatestTurnSettled( + { + ...latestTurn, + completedAt: null, + }, + { + orchestrationStatus: "running", + activeTurnId: TurnId.makeUnsafe("turn-1"), + }, + ), + ).toBe(false); + }); + + it("returns true when the turn completed but the session status stayed stale-running", () => { expect( isLatestTurnSettled(latestTurn, { orchestrationStatus: "running", activeTurnId: TurnId.makeUnsafe("turn-1"), }), - ).toBe(false); + ).toBe(true); }); - it("returns false while any turn is running to avoid stale latest-turn banners", () => { + it("returns false while a different turn is still running", () => { expect( isLatestTurnSettled(latestTurn, { orchestrationStatus: "running", @@ -1112,6 +1128,56 @@ describe("isLatestTurnSettled", () => { }); }); +describe("isSessionActivelyRunningTurn", () => { + const completedTurn = { + turnId: TurnId.makeUnsafe("turn-1"), + startedAt: "2026-02-27T21:10:00.000Z", + completedAt: "2026-02-27T21:10:06.000Z", + } as const; + + it("returns true when the current turn has not completed yet", () => { + expect( + isSessionActivelyRunningTurn( + { + ...completedTurn, + completedAt: null, + }, + { + orchestrationStatus: "running", + activeTurnId: TurnId.makeUnsafe("turn-1"), + }, + ), + ).toBe(true); + }); + + it("returns false when the same turn already completed and only the session is stale", () => { + expect( + isSessionActivelyRunningTurn(completedTurn, { + orchestrationStatus: "running", + activeTurnId: TurnId.makeUnsafe("turn-1"), + }), + ).toBe(false); + }); + + it("returns true when a different turn is still active", () => { + expect( + isSessionActivelyRunningTurn(completedTurn, { + orchestrationStatus: "running", + activeTurnId: TurnId.makeUnsafe("turn-2"), + }), + ).toBe(true); + }); + + it("returns false when the session is not running", () => { + expect( + isSessionActivelyRunningTurn(completedTurn, { + orchestrationStatus: "ready", + activeTurnId: undefined, + }), + ).toBe(false); + }); +}); + describe("deriveActiveWorkStartedAt", () => { const latestTurn = { turnId: TurnId.makeUnsafe("turn-1"), @@ -1122,7 +1188,10 @@ describe("deriveActiveWorkStartedAt", () => { it("prefers the in-flight turn start when the latest turn is not settled", () => { expect( deriveActiveWorkStartedAt( - latestTurn, + { + ...latestTurn, + completedAt: null, + }, { orchestrationStatus: "running", activeTurnId: TurnId.makeUnsafe("turn-1"), @@ -1132,6 +1201,19 @@ describe("deriveActiveWorkStartedAt", () => { ).toBe("2026-02-27T21:10:00.000Z"); }); + it("falls back to sendStartedAt when only the session status is stale-running", () => { + expect( + deriveActiveWorkStartedAt( + latestTurn, + { + orchestrationStatus: "running", + activeTurnId: TurnId.makeUnsafe("turn-1"), + }, + "2026-02-27T21:11:00.000Z", + ), + ).toBe("2026-02-27T21:11:00.000Z"); + }); + it("falls back to sendStartedAt once the latest turn is settled", () => { expect( deriveActiveWorkStartedAt( diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index fc33827014..90d8da8208 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -128,15 +128,30 @@ export function formatElapsed(startIso: string, endIso: string | undefined): str type LatestTurnTiming = Pick; type SessionActivityState = Pick; +export function isSessionActivelyRunningTurn( + latestTurn: LatestTurnTiming | null, + session: SessionActivityState | null, +): boolean { + if (!session || session.orchestrationStatus !== "running") return false; + if (!latestTurn) return true; + + const activeTurnId = session.activeTurnId; + if (activeTurnId === undefined) { + return latestTurn.completedAt === null; + } + if (latestTurn.turnId !== activeTurnId) { + return true; + } + return latestTurn.completedAt === null; +} + export function isLatestTurnSettled( latestTurn: LatestTurnTiming | null, session: SessionActivityState | null, ): boolean { if (!latestTurn?.startedAt) return false; if (!latestTurn.completedAt) return false; - if (!session) return true; - if (session.orchestrationStatus === "running") return false; - return true; + return !isSessionActivelyRunningTurn(latestTurn, session); } export function deriveActiveWorkStartedAt(