diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index d8b74c8cc..8966ff844 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -8,6 +8,7 @@ import { type ProjectId, type ServerConfig, type ThreadId, + type TurnId, type WsWelcomePayload, WS_CHANNELS, WS_METHODS, @@ -514,6 +515,16 @@ async function waitForComposerEditor(): Promise { ); } +async function waitForThreadSidebarItem(threadTitle: string): Promise { + return waitForElement( + () => + Array.from(document.querySelectorAll("[data-thread-item]")).find((item) => + item.textContent?.includes(threadTitle), + ) ?? null, + `Unable to find sidebar item for thread "${threadTitle}".`, + ); +} + async function waitForInteractionModeButton( expectedLabel: "Chat" | "Plan", ): Promise { @@ -1048,6 +1059,57 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); + it("keeps the chat and sidebar working indicators visible when a resumed thread stays active", async () => { + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-resume-working" as MessageId, + targetText: "resume working regression", + }), + }); + + try { + await vi.waitFor( + () => { + expect(useStore.getState().threadsHydrated).toBe(true); + expect(useStore.getState().threads).toHaveLength(1); + }, + { timeout: 8_000, interval: 16 }, + ); + + useStore.setState((state) => ({ + ...state, + threads: state.threads.map((thread) => + thread.id === THREAD_ID + ? { + ...thread, + session: thread.session + ? { + ...thread.session, + status: "ready", + orchestrationStatus: "running", + activeTurnId: "turn-resumed" as TurnId, + } + : thread.session, + } + : thread, + ), + })); + + await vi.waitFor( + () => { + expect(document.body.textContent).toContain("Working..."); + }, + { timeout: 8_000, interval: 16 }, + ); + + const threadItem = await waitForThreadSidebarItem("Browser test thread"); + expect(threadItem.textContent).toContain("Working"); + } finally { + await mounted.cleanup(); + } + }); + it("keeps long proposed plans lightweight until the user expands them", async () => { const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 634d91e9d..025261d5d 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -107,6 +107,57 @@ describe("resolveThreadStatusPill", () => { ).toMatchObject({ label: "Working", pulse: true }); }); + it("shows working when orchestration resumed but the legacy session status is ready", () => { + expect( + resolveThreadStatusPill({ + thread: { + ...baseThread, + session: { + ...baseThread.session, + status: "ready", + orchestrationStatus: "running", + }, + }, + hasPendingApprovals: false, + hasPendingUserInput: false, + }), + ).toMatchObject({ label: "Working", pulse: true }); + }); + + it("shows connecting when orchestration is still starting", () => { + expect( + resolveThreadStatusPill({ + thread: { + ...baseThread, + session: { + ...baseThread.session, + status: "ready", + orchestrationStatus: "starting", + }, + }, + hasPendingApprovals: false, + hasPendingUserInput: false, + }), + ).toMatchObject({ label: "Connecting", pulse: true }); + }); + + it("matches the canonical phase precedence when legacy and orchestration states disagree", () => { + expect( + resolveThreadStatusPill({ + thread: { + ...baseThread, + session: { + ...baseThread.session, + status: "connecting", + orchestrationStatus: "running", + }, + }, + hasPendingApprovals: false, + hasPendingUserInput: false, + }), + ).toMatchObject({ label: "Connecting", pulse: true }); + }); + it("shows plan ready when a settled plan turn has a proposed plan ready for follow-up", () => { expect( resolveThreadStatusPill({ diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index f1afa0f64..7f4df9241 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -1,5 +1,5 @@ import type { Thread } from "../types"; -import { findLatestProposedPlan, isLatestTurnSettled } from "../session-logic"; +import { derivePhase, findLatestProposedPlan, isLatestTurnSettled } from "../session-logic"; export const THREAD_SELECTION_SAFE_SELECTOR = "[data-thread-item], [data-thread-selection-safe]"; @@ -43,6 +43,7 @@ export function resolveThreadStatusPill(input: { hasPendingUserInput: boolean; }): ThreadStatusPill | null { const { hasPendingApprovals, hasPendingUserInput, thread } = input; + const phase = derivePhase(thread.session); if (hasPendingApprovals) { return { @@ -62,7 +63,7 @@ export function resolveThreadStatusPill(input: { }; } - if (thread.session?.status === "running") { + if (phase === "running") { return { label: "Working", colorClass: "text-sky-600 dark:text-sky-300/80", @@ -71,7 +72,7 @@ export function resolveThreadStatusPill(input: { }; } - if (thread.session?.status === "connecting") { + if (phase === "connecting") { return { label: "Connecting", colorClass: "text-sky-600 dark:text-sky-300/80", diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index 3d1d269d4..b91582772 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from "vitest"; import { deriveActiveWorkStartedAt, deriveActivePlanState, + derivePhase, PROVIDER_OPTIONS, derivePendingApprovals, derivePendingUserInputs, @@ -639,6 +640,33 @@ describe("deriveActiveWorkStartedAt", () => { }); }); +describe("derivePhase", () => { + it("treats a resumed session as running when orchestration is still active", () => { + expect( + derivePhase({ + provider: "codex", + status: "ready", + createdAt: "2026-03-09T10:00:00.000Z", + updatedAt: "2026-03-09T10:00:01.000Z", + orchestrationStatus: "running", + activeTurnId: TurnId.makeUnsafe("turn-1"), + }), + ).toBe("running"); + }); + + it("treats orchestration startup as connecting even before the legacy status catches up", () => { + expect( + derivePhase({ + provider: "codex", + status: "ready", + createdAt: "2026-03-09T10:00:00.000Z", + updatedAt: "2026-03-09T10:00:01.000Z", + orchestrationStatus: "starting", + }), + ).toBe("connecting"); + }); +}); + describe("PROVIDER_OPTIONS", () => { it("keeps Claude Code and Cursor visible as unavailable placeholders in the stack base", () => { const claude = PROVIDER_OPTIONS.find((option) => option.value === "claudeCode"); diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index aa8f3ffc3..8b7ff76e1 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -121,6 +121,17 @@ export function formatElapsed(startIso: string, endIso: string | undefined): str type LatestTurnTiming = Pick; type SessionActivityState = Pick; +type SessionPhaseState = Pick; + +export function isSessionConnecting(session: SessionPhaseState | null): boolean { + if (!session) return false; + return session.status === "connecting" || session.orchestrationStatus === "starting"; +} + +export function isSessionRunning(session: SessionPhaseState | null): boolean { + if (!session) return false; + return session.status === "running" || session.orchestrationStatus === "running"; +} export function isLatestTurnSettled( latestTurn: LatestTurnTiming | null, @@ -614,7 +625,7 @@ export function inferCheckpointTurnCountByTurnId( export function derivePhase(session: ThreadSession | null): SessionPhase { if (!session || session.status === "closed") return "disconnected"; - if (session.status === "connecting") return "connecting"; - if (session.status === "running") return "running"; + if (isSessionConnecting(session)) return "connecting"; + if (isSessionRunning(session)) return "running"; return "ready"; }