diff --git a/apps/mobile/src/features/threads/threadPresentation.ts b/apps/mobile/src/features/threads/threadPresentation.ts index b5f286937e0c..88d28407f54b 100644 --- a/apps/mobile/src/features/threads/threadPresentation.ts +++ b/apps/mobile/src/features/threads/threadPresentation.ts @@ -98,5 +98,15 @@ export function resolveThreadStatus( }; } + if (thread.backgroundLiveness === "working") { + return { + kind: "working", + label: "Working", + pillClassName: "bg-adaptive-sky-500-a12-a16", + textClassName: "text-adaptive-sky-600-400", + pulse: true, + }; + } + return null; } diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 76960f3f533b..89c981a158f8 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -334,6 +334,7 @@ describe("hasUnseenCompletion", () => { interactionMode: "default", latestTurn: makeLatestTurn(), lastVisitedAt: "2026-03-09T10:04:00.000Z", + backgroundLiveness: null, session: null, }), ).toBe(true); @@ -348,10 +349,52 @@ describe("hasUnseenCompletion", () => { interactionMode: "default", latestTurn: makeLatestTurn(), lastVisitedAt: undefined, + backgroundLiveness: null, session: null, }), ).toBe(false); }); + + it.each(["working", "monitoring"] as const)( + "does not mark a thread unread while background work is %s", + (backgroundLiveness) => { + expect( + hasUnseenCompletion({ + hasActionableProposedPlan: false, + hasPendingApprovals: false, + hasPendingUserInput: false, + interactionMode: "default", + latestTurn: makeLatestTurn(), + lastVisitedAt: "2026-03-09T10:04:00.000Z", + backgroundLiveness, + session: null, + }), + ).toBe(false); + }, + ); + + it("does not mark an active provider session unread", () => { + expect( + hasUnseenCompletion({ + hasActionableProposedPlan: false, + hasPendingApprovals: false, + hasPendingUserInput: false, + interactionMode: "default", + latestTurn: makeLatestTurn(), + lastVisitedAt: "2026-03-09T10:04:00.000Z", + backgroundLiveness: null, + session: { + threadId: "thread-1" as never, + status: "running", + providerName: "Codex", + runtimeMode: "full-access", + activeTurnId: "turn-1" as never, + lastError: null, + updatedAt: "2026-03-09T10:05:00.000Z", + }, + }), + ).toBe(false); + }); }); describe("shouldRecedeSidebarThread", () => { diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index fcedf46ce507..310135d7899f 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -633,6 +633,13 @@ export function useThreadJumpHintVisibility(): { } export function hasUnseenCompletion(thread: ThreadStatusInput): boolean { + if ( + thread.session?.status === "running" || + thread.session?.status === "starting" || + thread.backgroundLiveness != null + ) { + return false; + } if (!thread.latestTurn?.completedAt) return false; const completedAt = Date.parse(thread.latestTurn.completedAt); if (Number.isNaN(completedAt)) return false; diff --git a/apps/web/src/components/ThreadNotificationCoordinator.test.tsx b/apps/web/src/components/ThreadNotificationCoordinator.test.tsx index 860b6389dc12..e94a02a31115 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.test.tsx +++ b/apps/web/src/components/ThreadNotificationCoordinator.test.tsx @@ -12,6 +12,7 @@ const state = vi.hoisted(() => ({ visible: "visible", live: true, completedAt: null as string | null, + backgroundLiveness: null as "working" | "monitoring" | null, archivedAt: null as string | null, input: false, approval: false, @@ -38,6 +39,7 @@ vi.mock("@effect/atom-react", () => ({ id: "thread-1", title: "Fix the login form", archivedAt: state.archivedAt, + backgroundLiveness: state.backgroundLiveness, hasPendingUserInput: state.input, hasPendingApprovals: state.approval, session: state.sessionError ? { status: "error" } : null, @@ -104,6 +106,7 @@ beforeEach(() => { visible: "visible", live: true, completedAt: null, + backgroundLiveness: null, archivedAt: null, input: false, approval: false, @@ -239,6 +242,22 @@ describe("thread notifications", () => { expect(state.notification).not.toHaveBeenCalled(); }); + it("waits for background subagents to settle before reporting completion", async () => { + await render(); + state.backgroundLiveness = "working"; + await complete(); + expect(state.add).not.toHaveBeenCalled(); + expect(state.sound).not.toHaveBeenCalled(); + expect(state.notification).not.toHaveBeenCalled(); + + state.backgroundLiveness = null; + await render(); + expect(state.add).toHaveBeenCalledTimes(1); + expect(state.add).toHaveBeenLastCalledWith( + expect.objectContaining({ title: "Thread completed" }), + ); + }); + it("keeps system alerts when the app is in the background", async () => { state.mode = "notifications"; state.focused = false; diff --git a/packages/shared/src/agentAwareness.test.ts b/packages/shared/src/agentAwareness.test.ts index 28e07c04e6f3..4eee4f7ac936 100644 --- a/packages/shared/src/agentAwareness.test.ts +++ b/packages/shared/src/agentAwareness.test.ts @@ -26,6 +26,7 @@ function thread( | "modelSelection" | "session" | "latestTurn" + | "backgroundLiveness" | "updatedAt" | "hasPendingApprovals" | "hasPendingUserInput" @@ -36,6 +37,7 @@ function thread( modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, session: null, latestTurn: null, + backgroundLiveness: null, updatedAt: NOW, hasPendingApprovals: false, hasPendingUserInput: false, @@ -102,6 +104,27 @@ describe("projectThreadAwareness", () => { }); }); + it("keeps completed parent turns running while subagents are active", () => { + const state = projectThreadAwareness({ + environmentId: "env-1" as EnvironmentId, + project, + thread: thread({ + backgroundLiveness: "working", + latestTurn: { + turnId: "turn-1" as TurnId, + state: "completed", + requestedAt: NOW, + startedAt: NOW, + completedAt: NOW, + assistantMessageId: null, + }, + }), + }); + + expect(state?.phase).toBe("running"); + expect(state?.headline).toBe("Agent is working"); + }); + it("projects completed turns as completed even when teardown settled them as interrupted", () => { const finishedTurn = { turnId: "turn-1" as TurnId, diff --git a/packages/shared/src/agentAwareness.ts b/packages/shared/src/agentAwareness.ts index 77c7c845db31..9b3854ed0ff1 100644 --- a/packages/shared/src/agentAwareness.ts +++ b/packages/shared/src/agentAwareness.ts @@ -37,6 +37,7 @@ export interface ProjectThreadAwarenessInput { | "modelSelection" | "session" | "latestTurn" + | "backgroundLiveness" | "updatedAt" | "hasPendingApprovals" | "hasPendingUserInput" @@ -92,6 +93,9 @@ function resolveThreadAwarenessPhase( if (thread.session?.status === "running" || thread.latestTurn?.state === "running") { return "running"; } + if (thread.backgroundLiveness != null) { + return "running"; + } if (thread.latestTurn?.state === "completed") { return "completed"; }