From 58bbfeddb7091e3ce48fee9a1ef69e2a9ec4838b Mon Sep 17 00:00:00 2001 From: badcuban <108198679+badcuban@users.noreply.github.com> Date: Thu, 3 Sep 2026 02:36:29 -0400 Subject: [PATCH] fix(desktop): threads waiting on background agents stay active in the taskbar badge A thread whose turn settled while its subagents were still running showed "waiting" in the sidebar but dropped out of the taskbar badge, quit warning, and update warning, which all counted only sessions with a running turn. Share one rule, isWaitingOnBackgroundTasks, between the sidebar pill, the chat view, and the running-thread selector so every surface treats a thread with pending background tasks as live work. The desktop status menu gets a "waiting" thread state so its tooltip stays truthful. --- .../src/window/DesktopStatusIndicator.ts | 19 ++++- apps/web/src/components/ChatView.tsx | 9 ++- apps/web/src/components/Sidebar.logic.ts | 5 +- apps/web/src/routes/__root.tsx | 5 +- apps/web/src/session-logic.ts | 17 +++++ apps/web/src/store.test.ts | 71 +++++++++++++++++++ apps/web/src/store.ts | 13 +++- packages/contracts/src/ipc.ts | 5 +- 8 files changed, 131 insertions(+), 13 deletions(-) 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 c81ec606..fae2c43e 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, @@ -1396,8 +1397,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; @@ -6691,7 +6694,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 da52ca97..375aebd5 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 acc1536a..c180a3ec 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -457,6 +457,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 22de2a50..45acafe9 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -343,9 +343,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 {