diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 8aa4df4c09ba5..c241522084415 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -4,6 +4,7 @@ import { buildExpiredTerminalContextToastCopy, mergeDirectPreviewAnnotation, planComposerTurn, + deriveTimelineWorking, snapshotComposerSend, withMessageContext, deriveAvailableRevertTurnCounts, @@ -45,6 +46,7 @@ import { appAtomRegistry } from "../rpc/atomRegistry"; import { environmentThreadDetails } from "../state/threads"; import type { Thread } from "../types"; +import { derivePhase } from "../session-logic"; import { makeThreadFixture, makeThreadProjectionFixture } from "../test-fixtures"; import { getAntigravitySendBlockReason, @@ -1129,6 +1131,28 @@ describe("snapshotComposerSend", () => { }); }); +describe("deriveTimelineWorking", () => { + it.each(["preparing", "starting", "queued"] as const)( + "restores activity from a persisted %s session without a local send", + (status) => { + const state = { + phase: derivePhase({ ...readySession, status }), + isSendBusy: false, + isCompacting: false, + runlessWorkStartedAt: null, + }; + expect(deriveTimelineWorking(state)).toBe(true); + expect(deriveTimelineWorking({ ...state, phase: "ready" })).toBe(false); + expect( + deriveTimelineWorking({ + ...state, + phase: derivePhase({ ...readySession, status: "failed" }), + }), + ).toBe(false); + }, + ); +}); + describe("planComposerTurn", () => { const base = { messageText: "hello", diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index d98ab2ac9bd67..14590bb0ff386 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -1294,6 +1294,22 @@ export function snapshotComposerSend(contents: ComposerSendContents) { export type ComposerSendSnapshot = ReturnType; +/** Derived from the persisted run phase, so a starting run still reads as work after a reload. */ +export function deriveTimelineWorking(input: { + readonly phase: SessionPhase; + readonly isSendBusy: boolean; + readonly isCompacting: boolean; + readonly runlessWorkStartedAt: string | null; +}): boolean { + return ( + input.phase === "connecting" || + input.phase === "running" || + input.isSendBusy || + input.isCompacting || + input.runlessWorkStartedAt !== null + ); +} + /** How a composer send dispatches: where it runs and whether it waits behind other work. */ export function planComposerTurn(input: { readonly messageText: string; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 7da579d0659aa..764ea2c6bf756 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -350,6 +350,7 @@ import { import { buildLocalDraftThread, deriveCommittedServerUserMessageIds, + deriveTimelineWorking, dismissBranchMismatchForSession, shouldRenderPreviewMiniPlayer, deriveLockedProvider, @@ -1495,8 +1496,12 @@ export default function ChatView(props: ChatViewProps) { run: serverProjection?.runs.find((run) => run.id === activeRuntime?.activeRunId), messages: serverProjection?.messages, }); - const isWorking = - phase === "running" || isSendBusy || isCompacting || runlessWorkStartedAt !== null; + const isWorking = deriveTimelineWorking({ + phase, + isSendBusy, + isCompacting, + runlessWorkStartedAt, + }); const activeContextWindow = deriveLatestContextWindowSnapshot( serverVisibleTurnItems ?? [], activeThreadLiveTokenUsage,