diff --git a/apps/desktop/src/window/DesktopStatusIndicator.ts b/apps/desktop/src/window/DesktopStatusIndicator.ts index 6828508a..e38e7d42 100644 --- a/apps/desktop/src/window/DesktopStatusIndicator.ts +++ b/apps/desktop/src/window/DesktopStatusIndicator.ts @@ -1,4 +1,8 @@ -import type { DesktopMenuActionPayload, DesktopTaskbarStatusInput } from "@threadlines/contracts"; +import type { + DesktopMenuActionPayload, + DesktopTaskbarStatusInput, + DesktopTaskbarThreadSummary, +} from "@threadlines/contracts"; import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; @@ -128,6 +132,17 @@ function createDefaultStatus(): DesktopTaskbarStatusInput { return { status: "idle", description: "No active agent sessions" }; } +function describeThreadMenuState(state: DesktopTaskbarThreadSummary["state"]): string { + switch (state) { + case "running": + return "running"; + case "waiting": + return "waiting on background tasks"; + case "completed": + return "completed"; + } +} + function truncateThreadMenuLabel(title: string): string { const trimmed = title.trim(); if (trimmed.length === 0) { @@ -324,7 +339,7 @@ const make = Effect.gen(function* () { .map((thread) => ({ label: truncateThreadMenuLabel(thread.title), icon: thread.state === "completed" ? images.menuCompleted : images.menuRunning, - toolTip: `${thread.title.trim()} — ${thread.state === "completed" ? "completed" : "running"}`, + toolTip: `${thread.title.trim()} — ${describeThreadMenuState(thread.state)}`, click: () => runTrayEffect( "open-thread", diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 96d0a9bc..d286a74f 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -87,6 +87,7 @@ import { hasActionableProposedPlan, hasToolActivityForTurn, isLatestTurnSettled, + isWaitingOnBackgroundTasks, formatElapsed, type McpAuthReconnectAction, type ProviderAuthReconnectAction, @@ -1404,8 +1405,10 @@ export default function ChatView(props: ChatViewProps) { const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null); // Same rule as the sidebar's "Background" pill: settled, but a provider // task will start the thread back up on its own. - const isWaitingOnBackgroundTasks = - latestTurnSettled && (activeThread?.session?.pendingBackgroundTaskCount ?? 0) > 0; + const waitingOnBackgroundTasks = isWaitingOnBackgroundTasks( + activeLatestTurn, + activeThread?.session ?? null, + ); const activeProjectRef = activeThread ? scopeProjectRef(activeThread.environmentId, activeThread.projectId) : null; @@ -6706,7 +6709,7 @@ export default function ChatView(props: ChatViewProps) { key={activeThread.id} emptyState={firstRunSetupEmptyState ?? draftTimelineEmptyState} isWorking={isWorking} - isWaitingOnBackgroundTasks={isWaitingOnBackgroundTasks} + isWaitingOnBackgroundTasks={waitingOnBackgroundTasks} activeStatusLabel={activeStatusLabel} activeTurnInProgress={isWorking || !latestTurnSettled} activeTurnId={activeLatestTurn?.turnId ?? null} diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 20eadd35..9ef18197 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -8,7 +8,7 @@ import { type ThreadSortInput, } from "../lib/threadSort"; import type { SidebarThreadSummary, Thread } from "../types"; -import { isLatestTurnSettled } from "../session-logic"; +import { isLatestTurnSettled, isWaitingOnBackgroundTasks } from "../session-logic"; export const THREAD_SELECTION_SAFE_SELECTOR = "[data-thread-item], [data-thread-selection-safe]"; export const THREAD_JUMP_HINT_SHOW_DELAY_MS = 100; @@ -358,8 +358,7 @@ export function resolveThreadStatusPill(input: { // Settled turn with provider tasks still running: the provider will start // the thread back up on its own when they finish. - const pendingBackgroundTaskCount = thread.session?.pendingBackgroundTaskCount ?? 0; - if (pendingBackgroundTaskCount > 0 && isLatestTurnSettled(thread.latestTurn, thread.session)) { + if (isWaitingOnBackgroundTasks(thread.latestTurn, thread.session)) { return { label: "Background", colorClass: "text-cyan-600 dark:text-cyan-300/90", diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 90d0d9a7..5b9cac68 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -53,6 +53,7 @@ import { useServerConfigUpdatedSubscription, useServerWelcomeSubscription, } from "../rpc/serverState"; +import { isWaitingOnBackgroundTasks } from "../session-logic"; import { type AppState, selectRunningSidebarThreadsAcrossEnvironments, useStore } from "../store"; import { useUiStateStore } from "../uiStateStore"; import { syncBrowserChromeTheme } from "../hooks/useTheme"; @@ -193,7 +194,9 @@ function selectRunningTaskbarThreads(state: AppState): DesktopTaskbarThreadSumma threadId: thread.id, environmentId: thread.environmentId, title: thread.title, - state: "running" as const, + state: isWaitingOnBackgroundTasks(thread.latestTurn, thread.session) + ? ("waiting" as const) + : ("running" as const), })); } diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 710ef1df..8368bc70 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -466,6 +466,23 @@ export function isLatestTurnSettled( return true; } +type SessionBackgroundState = SessionLifecycleState & + Partial>; + +/** + * The turn has settled but provider tasks (background subagents, deferred + * shell commands) are still running and will start the thread back up on + * their own. The thread is waiting, not finished, so it counts as live work + * everywhere the app counts it: sidebar pill, taskbar badge, quit and update + * warnings. + */ +export function isWaitingOnBackgroundTasks( + latestTurn: LatestTurnTiming | null, + session: SessionBackgroundState | null, +): boolean { + return (session?.pendingBackgroundTaskCount ?? 0) > 0 && isLatestTurnSettled(latestTurn, session); +} + export function deriveActiveModelFallbackState( activities: ReadonlyArray, latestTurn: OrchestrationLatestTurn | null | undefined, diff --git a/apps/web/src/store.test.ts b/apps/web/src/store.test.ts index 1e2a91aa..26419c9b 100644 --- a/apps/web/src/store.test.ts +++ b/apps/web/src/store.test.ts @@ -28,12 +28,14 @@ import { selectThreadCheckout, setThreadBranch, selectThreadsAcrossEnvironments, + selectRunningSidebarThreadsAcrossEnvironments, type AppState, type EnvironmentState, } from "./store"; import { DEFAULT_INTERACTION_MODE, DEFAULT_COMPOSER_RUNTIME_MODE, + type SidebarThreadSummary, type Thread, type ThreadSession, } from "./types"; @@ -1550,3 +1552,72 @@ describe("incremental orchestration updates", () => { expect(threadsOf(next)[0]?.latestTurn?.sourceProposedPlan).toBeUndefined(); }); }); + +describe("selectRunningSidebarThreadsAcrossEnvironments", () => { + function makeSidebarSummary( + overrides: Partial & Pick, + ): SidebarThreadSummary { + return { + environmentId: localEnvironmentId, + projectId: ProjectId.make("project-1"), + title: "Thread", + interactionMode: DEFAULT_INTERACTION_MODE, + session: null, + createdAt: "2026-02-13T00:00:00.000Z", + archivedAt: null, + pinnedAt: null, + doneOverride: null, + lastSeenAt: null, + latestTurn: null, + branch: null, + worktreePath: null, + effectiveCwd: null, + latestUserMessageAt: null, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + cumulativeDiffStat: null, + ...overrides, + }; + } + + it("keeps a settled thread live while background provider tasks are still running", () => { + const settledSession: ThreadSession = { + provider: ProviderDriverKind.make("claude"), + status: "ready", + orchestrationStatus: "ready", + checkoutCwd: "/tmp/project", + createdAt: "2026-02-13T00:00:00.000Z", + updatedAt: "2026-02-13T00:05:00.000Z", + }; + const settledTurn = { + turnId: TurnId.make("turn-1"), + state: "completed" as const, + assistantMessageId: null, + requestedAt: "2026-02-13T00:00:00.000Z", + startedAt: "2026-02-13T00:00:00.000Z", + completedAt: "2026-02-13T00:05:00.000Z", + }; + const waiting = makeSidebarSummary({ + id: ThreadId.make("thread-waiting"), + session: { ...settledSession, pendingBackgroundTaskCount: 2 }, + latestTurn: settledTurn, + }); + const finished = makeSidebarSummary({ + id: ThreadId.make("thread-finished"), + session: { ...settledSession, pendingBackgroundTaskCount: 0 }, + latestTurn: settledTurn, + }); + const state = makeEmptyState({ + threadIds: [waiting.id, finished.id], + sidebarThreadSummaryById: { + [waiting.id]: waiting, + [finished.id]: finished, + }, + }); + + expect(selectRunningSidebarThreadsAcrossEnvironments(state).map((thread) => thread.id)).toEqual( + [waiting.id], + ); + }); +}); diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index cb919bda..fb2d9678 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -50,6 +50,7 @@ import { derivePendingUserInputs, findLatestProposedPlan, hasActionableProposedPlan, + isWaitingOnBackgroundTasks, sumTurnDiffStats, } from "./session-logic"; import { getThreadFromEnvironmentState } from "./threadDerivation"; @@ -2198,13 +2199,21 @@ export function selectSidebarThreadsAcrossEnvironments(state: AppState): Sidebar ); } -/** Sidebar threads whose agent session is running right now, across every environment. */ +/** + * Sidebar threads with live agent work, across every environment: the session + * is running a turn, or the turn settled and background provider tasks + * (subagents, deferred commands) will start it back up on their own. Drives the + * taskbar badge and the quit and update warnings, so a thread that is only + * waiting on its subagents must not read as finished here. + */ export function selectRunningSidebarThreadsAcrossEnvironments( state: AppState, ): SidebarThreadSummary[] { return selectSidebarThreadsAcrossEnvironments(state).filter( (thread) => - thread.session?.status === "running" || thread.session?.orchestrationStatus === "running", + thread.session?.status === "running" || + thread.session?.orchestrationStatus === "running" || + isWaitingOnBackgroundTasks(thread.latestTurn, thread.session), ); } diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index ea908b64..cb5d0618 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -367,9 +367,10 @@ export const DesktopUpdateCheckResultSchema = Schema.Struct({ state: DesktopUpdateStateSchema, }); -export type DesktopTaskbarThreadState = "running" | "completed"; +/** "waiting": the turn settled but background provider tasks keep the thread live. */ +export type DesktopTaskbarThreadState = "running" | "waiting" | "completed"; -export const DesktopTaskbarThreadStateSchema = Schema.Literals(["running", "completed"]); +export const DesktopTaskbarThreadStateSchema = Schema.Literals(["running", "waiting", "completed"]); /** A thread surfaced in the desktop status item menu (click-to-open). */ export interface DesktopTaskbarThreadSummary {