Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 17 additions & 2 deletions apps/desktop/src/window/DesktopStatusIndicator.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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",
Expand Down
9 changes: 6 additions & 3 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ import {
hasActionableProposedPlan,
hasToolActivityForTurn,
isLatestTurnSettled,
isWaitingOnBackgroundTasks,
formatElapsed,
type McpAuthReconnectAction,
type ProviderAuthReconnectAction,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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}
Expand Down
5 changes: 2 additions & 3 deletions apps/web/src/components/Sidebar.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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",
Expand Down
5 changes: 4 additions & 1 deletion apps/web/src/routes/__root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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),
}));
}

Expand Down
17 changes: 17 additions & 0 deletions apps/web/src/session-logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -466,6 +466,23 @@ export function isLatestTurnSettled(
return true;
}

type SessionBackgroundState = SessionLifecycleState &
Partial<Pick<ThreadSession, "pendingBackgroundTaskCount">>;

/**
* 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<OrchestrationThreadActivity>,
latestTurn: OrchestrationLatestTurn | null | undefined,
Expand Down
71 changes: 71 additions & 0 deletions apps/web/src/store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -1550,3 +1552,72 @@ describe("incremental orchestration updates", () => {
expect(threadsOf(next)[0]?.latestTurn?.sourceProposedPlan).toBeUndefined();
});
});

describe("selectRunningSidebarThreadsAcrossEnvironments", () => {
function makeSidebarSummary(
overrides: Partial<SidebarThreadSummary> & Pick<SidebarThreadSummary, "id">,
): 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],
);
});
});
13 changes: 11 additions & 2 deletions apps/web/src/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import {
derivePendingUserInputs,
findLatestProposedPlan,
hasActionableProposedPlan,
isWaitingOnBackgroundTasks,
sumTurnDiffStats,
} from "./session-logic";
import { getThreadFromEnvironmentState } from "./threadDerivation";
Expand Down Expand Up @@ -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),
);
}

Expand Down
5 changes: 3 additions & 2 deletions packages/contracts/src/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading