From 7764abc9dd4c1bad13653b61275655edccf03aad Mon Sep 17 00:00:00 2001 From: Trevor Walker Date: Fri, 4 Sep 2026 11:41:31 -0600 Subject: [PATCH] fix(clients): a running session shows Working only while a turn is active --- .../src/features/threads/ThreadComposer.tsx | 1 + .../src/features/threads/threadListV2.test.ts | 23 +++++++++++++++++++ .../src/features/threads/threadListV2.ts | 10 ++++++-- .../threads/threadPresentation.test.ts | 11 ++++++++- .../features/threads/threadPresentation.ts | 5 ++-- apps/web/src/components/Sidebar.logic.test.ts | 20 ++++++++++++++++ apps/web/src/components/Sidebar.logic.ts | 9 ++++++-- apps/web/src/session-logic.test.ts | 9 ++++++++ apps/web/src/session-logic.ts | 4 +++- 9 files changed, 84 insertions(+), 8 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index bfd123155..ce908d2b1 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -837,6 +837,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer props.connectionState === "connected" && composerAuthority.providerAdmissionAvailable && props.selectedThread.session?.status === "running" && + props.selectedThread.session.activeTurnId != null && !props.sessionInputBlocked && props.localOutboxCount === 0 && supportsSessionInputQueueFollowUp(activeSessionProviderStatus); diff --git a/apps/mobile/src/features/threads/threadListV2.test.ts b/apps/mobile/src/features/threads/threadListV2.test.ts index 9657ab6f0..2a47deacf 100644 --- a/apps/mobile/src/features/threads/threadListV2.test.ts +++ b/apps/mobile/src/features/threads/threadListV2.test.ts @@ -218,6 +218,29 @@ describe("resolveThreadListV2Status", () => { }); }); +describe("resolveThreadListV2Status running without a turn", () => { + it("does not report working for a running session with no active turn", () => { + expect( + resolveThreadListV2Status( + makeThread({ + id: ThreadId.make("t"), + title: "t", + session: { + threadId: ThreadId.make("t"), + status: "running", + providerName: "Codex", + providerInstanceId: ProviderInstanceId.make("codex"), + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: NOW, + }, + }), + ), + ).toBe("ready"); + }); +}); + describe("resolveThreadListV2SwipeActions", () => { it("offers settle and snooze for an active snoozable thread", () => { expect( diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index f99b7ada7..cadb715de 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -133,7 +133,8 @@ function isThreadListV2LatestTurnSettled( thread: Pick, ): boolean { if (!thread.latestTurn?.startedAt || !thread.latestTurn.completedAt) return false; - return thread.session?.status !== "running"; + // A running session with no active turn has nothing in flight. + return !(thread.session?.status === "running" && thread.session.activeTurnId != null); } export function resolveThreadListV2Status( @@ -153,7 +154,12 @@ export function resolveThreadListV2Status( if (thread.hasPendingUserInput) { return "input"; } - if (thread.session?.status === "running" || thread.session?.status === "starting") { + // "running" alone is not work: a provider can report it between turns + // (Claude system/status) with nothing in flight. Only an active turn is. + if ( + thread.session?.status === "starting" || + (thread.session?.status === "running" && thread.session.activeTurnId != null) + ) { return "working"; } if (thread.session?.status === "error") { diff --git a/apps/mobile/src/features/threads/threadPresentation.test.ts b/apps/mobile/src/features/threads/threadPresentation.test.ts index 50ad49b77..cb8044440 100644 --- a/apps/mobile/src/features/threads/threadPresentation.test.ts +++ b/apps/mobile/src/features/threads/threadPresentation.test.ts @@ -48,13 +48,22 @@ describe("resolveThreadStatus", () => { }); }); + it("shows no status for a running session with no active turn", () => { + expect( + resolveThreadStatus({ + ...baseThread, + session: { status: "running", activeTurnId: null }, + } as EnvironmentThreadShell), + ).toBeNull(); + }); + it.each(["running", "starting"] as const)( "uses upstream sky while the session is %s", (status) => { expect( resolveThreadStatus({ ...baseThread, - session: { status }, + session: status === "running" ? { status, activeTurnId: "turn-1" } : { status }, } as EnvironmentThreadShell), ).toMatchObject({ pillClassName: "bg-adaptive-sky-500-a12-a16", diff --git a/apps/mobile/src/features/threads/threadPresentation.ts b/apps/mobile/src/features/threads/threadPresentation.ts index 59cf108a0..16467ef5d 100644 --- a/apps/mobile/src/features/threads/threadPresentation.ts +++ b/apps/mobile/src/features/threads/threadPresentation.ts @@ -27,7 +27,8 @@ function isLatestTurnSettled( if (!latestTurn?.startedAt) return false; if (!latestTurn.completedAt) return false; if (!session) return true; - return session.status !== "running"; + // A running session with no active turn has nothing in flight. + return !(session.status === "running" && session.activeTurnId != null); } /** @@ -62,7 +63,7 @@ export function resolveThreadStatus( }; } - if (thread.session?.status === "running") { + if (thread.session?.status === "running" && thread.session.activeTurnId != null) { return { kind: "working", label: "Working", diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index a7aa5bd2e..8bdc6805d 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -707,6 +707,18 @@ describe("resolveSidebarThreadStatus", () => { ).toBe("working"); }); + it("does not report working for a running session with no active turn", () => { + // A provider can report running between turns (Claude system/status). + // Without a turn there is nothing being worked on; background liveness, + // if any, is the honest signal. + expect( + resolveSidebarThreadStatus({ + ...idle, + session: { ...session, activeTurnId: null }, + }), + ).toBe("ready"); + }); + it("distinguishes background delegation from root work", () => { expect( resolveSidebarThreadStatus({ @@ -1223,6 +1235,14 @@ describe("resolveThreadStatusPill", () => { }); }); + it("shows no pill for a running session with no active turn", () => { + expect( + resolveThreadStatusPill({ + thread: { ...baseThread, session: { ...baseThread.session, activeTurnId: null } }, + }), + ).toBeNull(); + }); + it("uses upstream sky pulses for running and connecting", () => { expect(resolveThreadStatusPill({ thread: baseThread })).toMatchObject({ label: "Working", diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 7651eca88..1096779c9 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -573,7 +573,12 @@ export function resolveSidebarThreadStatus(thread: SidebarThreadStatusInput): Si if (thread.hasPendingUserInput) { return "input"; } - if (thread.session?.status === "running" || thread.session?.status === "starting") { + // "running" alone is not work: a provider can report it between turns + // (Claude system/status) with nothing in flight. Only an active turn is. + if ( + thread.session?.status === "starting" || + (thread.session?.status === "running" && thread.session.activeTurnId != null) + ) { return "working"; } // A failed session outranks lingering background liveness: the user must @@ -801,7 +806,7 @@ export function resolveThreadStatusPill(input: { }; } - if (thread.session?.status === "running") { + if (thread.session?.status === "running" && thread.session.activeTurnId != null) { return { label: "Working", 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 2646c31bc..6c83c18ca 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -2223,6 +2223,15 @@ describe("isLatestTurnSettled", () => { ).toBe(false); }); + it("returns true when the session reports running but tracks no turn", () => { + expect( + isLatestTurnSettled(latestTurn, { + status: "running", + activeTurnId: null, + }), + ).toBe(true); + }); + it("returns true once the session is no longer running that turn", () => { expect( isLatestTurnSettled(latestTurn, { diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index e836087e7..55e4d61ba 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -398,7 +398,9 @@ export function isLatestTurnSettled( if (!latestTurn?.startedAt) return false; if (!latestTurn.completedAt) return false; if (!session) return true; - if (session.status === "running") return false; + // A running session with no active turn has nothing in flight; the latest + // turn is as settled as it will get. + if (session.status === "running" && session.activeTurnId != null) return false; return true; }