diff --git a/apps/app/.ladle/story-fixtures.ts b/apps/app/.ladle/story-fixtures.ts
index 476a66387..732ad408b 100644
--- a/apps/app/.ladle/story-fixtures.ts
+++ b/apps/app/.ladle/story-fixtures.ts
@@ -318,6 +318,7 @@ export function makeThreadListEntry(
latestAttentionAt: 100,
createdAt: 0,
updatedAt: 100,
+ activity: { activeWorkflowCount: 0, activeBackgroundSubagentCount: 0 },
hasPendingInteraction: false,
environmentHostId: null,
environmentName: null,
diff --git a/apps/app/src/components/sidebar/ProjectRow.tsx b/apps/app/src/components/sidebar/ProjectRow.tsx
index eeef9bec9..bc5875f4e 100644
--- a/apps/app/src/components/sidebar/ProjectRow.tsx
+++ b/apps/app/src/components/sidebar/ProjectRow.tsx
@@ -720,7 +720,14 @@ function EnvironmentThreadGroupHeader({
>
diff --git a/apps/app/src/components/sidebar/SidebarThreadSearchPanel.test.tsx b/apps/app/src/components/sidebar/SidebarThreadSearchPanel.test.tsx
index f4ad0ed97..4296d0a71 100644
--- a/apps/app/src/components/sidebar/SidebarThreadSearchPanel.test.tsx
+++ b/apps/app/src/components/sidebar/SidebarThreadSearchPanel.test.tsx
@@ -33,6 +33,7 @@ function createThreadListEntry({
title: string;
}): ThreadListEntry {
return {
+ activity: { activeWorkflowCount: 0, activeBackgroundSubagentCount: 0 },
archivedAt: null,
childOrigin: null,
createdAt: 1000,
diff --git a/apps/app/src/components/sidebar/ThreadRow.test.tsx b/apps/app/src/components/sidebar/ThreadRow.test.tsx
new file mode 100644
index 000000000..b74912cc5
--- /dev/null
+++ b/apps/app/src/components/sidebar/ThreadRow.test.tsx
@@ -0,0 +1,113 @@
+// @vitest-environment jsdom
+
+import { cleanup, render, screen } from "@testing-library/react";
+import { MemoryRouter } from "react-router-dom";
+import type { ReactNode } from "react";
+import type { ThreadListEntry } from "@bb/domain";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { ThreadRow, type ThreadRowOptions } from "./ThreadRow";
+
+vi.mock("@/components/thread/ThreadActionsMenu", () => ({
+ ThreadActionsContextMenu: ({ children }: { children: ReactNode }) => (
+ <>{children}>
+ ),
+ ThreadActionsMenu: () => null,
+}));
+
+function createThread(
+ overrides: Partial = {},
+): ThreadListEntry {
+ return {
+ id: "thr_test",
+ projectId: "proj_test",
+ environmentId: null,
+ providerId: "codex",
+ title: "Thread",
+ titleFallback: "Thread",
+ status: "idle",
+ parentThreadId: null,
+ sourceThreadId: null,
+ originKind: null,
+ childOrigin: null,
+ archivedAt: null,
+ pinnedAt: null,
+ pinSortKey: null,
+ deletedAt: null,
+ lastReadAt: 0,
+ latestAttentionAt: 1,
+ createdAt: 1,
+ updatedAt: 1,
+ activity: { activeWorkflowCount: 0, activeBackgroundSubagentCount: 0 },
+ hasPendingInteraction: false,
+ environmentHostId: null,
+ environmentName: null,
+ environmentBranchName: null,
+ environmentWorkspaceDisplayKind: "other",
+ runtime: {
+ displayStatus: "idle",
+ hostReconnectGraceExpiresAt: null,
+ },
+ ...overrides,
+ };
+}
+
+const DEFAULT_OPTIONS: ThreadRowOptions = {
+ kind: "default",
+ depth: 1,
+ isCompact: false,
+};
+
+function renderThreadRow({
+ isActive = false,
+ options = DEFAULT_OPTIONS,
+ thread = createThread(),
+}: {
+ isActive?: boolean;
+ options?: ThreadRowOptions;
+ thread?: ThreadListEntry;
+}) {
+ render(
+
+
+ ,
+ );
+}
+
+afterEach(cleanup);
+
+describe("ThreadRow", () => {
+ it("shows a workflow glyph for an idle thread with an active workflow", () => {
+ renderThreadRow({
+ thread: createThread({
+ title: "Workflow thread",
+ activity: { activeWorkflowCount: 1, activeBackgroundSubagentCount: 0 },
+ }),
+ });
+
+ expect(screen.getByLabelText("Workflow running")).not.toBeNull();
+ expect(screen.queryByLabelText("Thread working")).toBeNull();
+ });
+
+ it("keeps the spinner for active runtime work even with an active workflow", () => {
+ renderThreadRow({
+ thread: createThread({
+ title: "Active workflow thread",
+ status: "active",
+ runtime: {
+ displayStatus: "active",
+ hostReconnectGraceExpiresAt: null,
+ },
+ activity: { activeWorkflowCount: 1, activeBackgroundSubagentCount: 0 },
+ }),
+ });
+
+ expect(screen.getByLabelText("Thread working")).not.toBeNull();
+ expect(screen.queryByLabelText("Workflow running")).toBeNull();
+ });
+});
diff --git a/apps/app/src/components/sidebar/ThreadRow.tsx b/apps/app/src/components/sidebar/ThreadRow.tsx
index 4f52e270f..2686a0858 100644
--- a/apps/app/src/components/sidebar/ThreadRow.tsx
+++ b/apps/app/src/components/sidebar/ThreadRow.tsx
@@ -30,7 +30,10 @@ import {
SIDEBAR_HOVER_ACTIONS_ROW_CLASS,
} from "@/components/ui/sidebar-hover-actions.js";
import {
+ hasActiveBackgroundActivity,
+ hasActiveWorkflowActivity,
isBusyThread,
+ isRuntimeBusyThread,
isUnreadDoneThread,
NO_COLLAPSED_CHILD_ACTIVITY,
type CollapsedChildActivity,
@@ -144,6 +147,7 @@ function renderThreadRowContainer({
interface ThreadStatusGlyphProps {
hasPendingInteraction: boolean;
isBusy: boolean;
+ isWorkflowActive: boolean;
showUnreadBadge: boolean;
unreadBadgeTone: SidebarUnreadDotTone;
}
@@ -155,6 +159,7 @@ interface ThreadUnreadBadgeLabelArgs {
export function ThreadStatusGlyph({
hasPendingInteraction,
isBusy,
+ isWorkflowActive,
showUnreadBadge,
unreadBadgeTone,
}: ThreadStatusGlyphProps) {
@@ -184,6 +189,19 @@ export function ThreadStatusGlyph({
);
}
+ if (isWorkflowActive) {
+ return (
+
+ );
+ }
+
if (showUnreadBadge) {
const label = getThreadUnreadBadgeLabel({ tone: unreadBadgeTone });
return (
@@ -206,13 +224,17 @@ function getThreadUnreadBadgeLabel({
: "Unread thread requires attention";
}
+type ThreadTrailingIndicatorProps = ThreadStatusGlyphProps;
+
function ThreadTrailingIndicator({
hasPendingInteraction,
isBusy,
+ isWorkflowActive,
showUnreadBadge,
unreadBadgeTone,
-}: ThreadStatusGlyphProps) {
- const showStatusGlyph = hasPendingInteraction || isBusy || showUnreadBadge;
+}: ThreadTrailingIndicatorProps) {
+ const showStatusGlyph =
+ hasPendingInteraction || isBusy || isWorkflowActive || showUnreadBadge;
if (!showStatusGlyph) {
return null;
@@ -225,6 +247,7 @@ function ThreadTrailingIndicator({
@@ -247,8 +270,20 @@ function ThreadRowComponent({
);
const showActive = isActive;
const hasPendingInteraction = thread.hasPendingInteraction;
+ const threadRuntimeBusy =
+ isRuntimeBusyThread(thread) && !hasPendingInteraction;
+ const threadWorkflowActive =
+ !threadRuntimeBusy &&
+ !hasPendingInteraction &&
+ hasActiveWorkflowActivity(thread);
+ const threadBackgroundBusy =
+ !threadRuntimeBusy &&
+ !threadWorkflowActive &&
+ !hasPendingInteraction &&
+ hasActiveBackgroundActivity(thread);
const threadIsBusy = isBusyThread(thread) && !hasPendingInteraction;
- const showUnreadBadge = !hasPendingInteraction && isUnreadDoneThread(thread);
+ const showUnreadBadge =
+ !hasPendingInteraction && !threadIsBusy && isUnreadDoneThread(thread);
const unreadBadgeTone: SidebarUnreadDotTone =
showUnreadBadge && thread.status === "error" ? "error" : "default";
const threadTitle = getThreadDisplayTitle(thread);
@@ -266,9 +301,18 @@ function ThreadRowComponent({
const trailingHasPendingInteraction = hasHiddenChildren
? hasPendingInteraction || childActivity.pending
: hasPendingInteraction;
- const trailingIsBusy = hasHiddenChildren
- ? threadIsBusy || childActivity.working
- : threadIsBusy;
+ const trailingRuntimeBusy = hasHiddenChildren
+ ? threadRuntimeBusy || childActivity.runtimeWorking
+ : threadRuntimeBusy;
+ const trailingBackgroundBusy = hasHiddenChildren
+ ? threadBackgroundBusy || childActivity.backgroundWorking
+ : threadBackgroundBusy;
+ const trailingIsWorkflowActive = hasHiddenChildren
+ ? !trailingRuntimeBusy &&
+ !trailingBackgroundBusy &&
+ (threadWorkflowActive || childActivity.workflow)
+ : threadWorkflowActive;
+ const trailingIsBusy = trailingRuntimeBusy || trailingBackgroundBusy;
const trailingShowUnreadBadge = hasHiddenChildren
? showUnreadBadge || childActivity.unread
: showUnreadBadge;
@@ -356,6 +400,7 @@ function ThreadRowComponent({
diff --git a/apps/app/src/components/sidebar/ThreadSearchResultRow.tsx b/apps/app/src/components/sidebar/ThreadSearchResultRow.tsx
index 30e58b4dc..a477478fd 100644
--- a/apps/app/src/components/sidebar/ThreadSearchResultRow.tsx
+++ b/apps/app/src/components/sidebar/ThreadSearchResultRow.tsx
@@ -11,7 +11,12 @@ import type { ThreadSearchMatch } from "@bb/server-contract";
import { PERSONAL_PROJECT_ID } from "@bb/domain";
import { COARSE_POINTER_ICON_SIZE_CLASS } from "@/components/ui/coarse-pointer-sizing.js";
import { Icon } from "@/components/ui/icon.js";
-import { isBusyThread } from "@/lib/thread-activity";
+import {
+ hasActiveBackgroundActivity,
+ hasActiveWorkflowActivity,
+ isBusyThread,
+ isRuntimeBusyThread,
+} from "@/lib/thread-activity";
import { getThreadDisplayTitle } from "@/lib/thread-title";
import { cn } from "@/lib/utils";
import { ThreadStatusGlyph } from "./ThreadRow";
@@ -117,6 +122,17 @@ function ThreadSearchResultRowComponent({
const titleMatch = getTitleMatch(title, matches);
const snippetMatch = getSnippetMatch(matches);
const hasPendingInteraction = thread.hasPendingInteraction;
+ const threadRuntimeBusy =
+ isRuntimeBusyThread(thread) && !hasPendingInteraction;
+ const threadWorkflowActive =
+ !threadRuntimeBusy &&
+ !hasPendingInteraction &&
+ hasActiveWorkflowActivity(thread);
+ const threadBackgroundBusy =
+ !threadRuntimeBusy &&
+ !threadWorkflowActive &&
+ !hasPendingInteraction &&
+ hasActiveBackgroundActivity(thread);
const threadIsBusy = isBusyThread(thread) && !hasPendingInteraction;
const metadataParts = [
thread.projectId !== PERSONAL_PROJECT_ID ? projectName : undefined,
@@ -194,7 +210,8 @@ function ThreadSearchResultRowComponent({
diff --git a/apps/app/src/components/sidebar/pinnedSidebarThreads.test.ts b/apps/app/src/components/sidebar/pinnedSidebarThreads.test.ts
index 97d9fcc77..c781271d8 100644
--- a/apps/app/src/components/sidebar/pinnedSidebarThreads.test.ts
+++ b/apps/app/src/components/sidebar/pinnedSidebarThreads.test.ts
@@ -27,6 +27,7 @@ function createThread(
latestAttentionAt: 2,
createdAt: 1,
updatedAt: 2,
+ activity: { activeWorkflowCount: 0, activeBackgroundSubagentCount: 0 },
hasPendingInteraction: false,
environmentHostId: null,
environmentName: null,
diff --git a/apps/app/src/components/sidebar/projectThreadGroups.test.ts b/apps/app/src/components/sidebar/projectThreadGroups.test.ts
index 3cfb4a9cc..8f64cfabf 100644
--- a/apps/app/src/components/sidebar/projectThreadGroups.test.ts
+++ b/apps/app/src/components/sidebar/projectThreadGroups.test.ts
@@ -37,6 +37,7 @@ function createThread(
latestAttentionAt: 2,
createdAt: 1,
updatedAt: 2,
+ activity: { activeWorkflowCount: 0, activeBackgroundSubagentCount: 0 },
hasPendingInteraction: false,
environmentHostId: null,
environmentName: null,
@@ -328,6 +329,9 @@ describe("buildProjectThreadGroups", () => {
childActivity: {
pending: true,
working: true,
+ runtimeWorking: true,
+ backgroundWorking: false,
+ workflow: false,
unread: false,
unreadError: false,
},
@@ -337,6 +341,9 @@ describe("buildProjectThreadGroups", () => {
childActivity: {
pending: true,
working: true,
+ runtimeWorking: true,
+ backgroundWorking: false,
+ workflow: false,
unread: false,
unreadError: false,
},
diff --git a/apps/app/src/hooks/cache-owners/query-cache.ts b/apps/app/src/hooks/cache-owners/query-cache.ts
index 256d46910..0fbf5719c 100644
--- a/apps/app/src/hooks/cache-owners/query-cache.ts
+++ b/apps/app/src/hooks/cache-owners/query-cache.ts
@@ -528,6 +528,7 @@ export function optimisticallyInsertThread(
queryClient.setQueryData(queryKey, [
{
...thread,
+ activity: { activeWorkflowCount: 0, activeBackgroundSubagentCount: 0 },
environmentBranchName: null,
environmentHostId: null,
environmentName: null,
diff --git a/apps/app/src/hooks/cache-owners/realtime-cache-registry.ts b/apps/app/src/hooks/cache-owners/realtime-cache-registry.ts
index 1d71f5ca7..8ff4c4e58 100644
--- a/apps/app/src/hooks/cache-owners/realtime-cache-registry.ts
+++ b/apps/app/src/hooks/cache-owners/realtime-cache-registry.ts
@@ -211,6 +211,7 @@ export const REALTIME_THREAD_CHANGE_REGISTRY = {
"events-appended": {
flush: "debounced",
dirty: [
+ dirtyThreadListQueriesForBackgroundActivity, // Sidebar rows render active workflow/background task state.
dirtyThreadSearchQueries, // Indexed conversation content may now match a search query.
dirtyThreadTimelineQueries, // Timeline rows are built from appended events.
dirtyThreadPromptHistoryQueriesForTurnRequests, // Follow-up recall is built from client turn requests.
@@ -425,6 +426,7 @@ export interface RealtimeDirtyContext {
}
export interface ThreadRealtimeDirtyContext extends RealtimeDirtyContext {
+ backgroundActivityChanged: boolean | undefined;
eventTypes: readonly ThreadEventType[] | undefined;
hasPendingInteraction: boolean | undefined;
projectId: string | undefined;
@@ -552,6 +554,15 @@ function dirtyThreadListQueries({
return getThreadListInvalidationQueryKeys({ projectId, queryClient });
}
+function dirtyThreadListQueriesForBackgroundActivity(
+ context: ThreadRealtimeDirtyContext,
+): QueryKey[] {
+ if (context.backgroundActivityChanged !== true) {
+ return [];
+ }
+ return dirtyThreadListQueries(context);
+}
+
function dirtyRootOrderThreadListQueries({
projectId,
queryClient,
diff --git a/apps/app/src/hooks/cache-owners/thread-state-cache-owner.test.ts b/apps/app/src/hooks/cache-owners/thread-state-cache-owner.test.ts
index 2d995a36d..a6afd498d 100644
--- a/apps/app/src/hooks/cache-owners/thread-state-cache-owner.test.ts
+++ b/apps/app/src/hooks/cache-owners/thread-state-cache-owner.test.ts
@@ -48,6 +48,7 @@ function makeThreadListEntry(
): ThreadListEntry {
return {
...makeThreadWithRuntime(thread),
+ activity: { activeWorkflowCount: 0, activeBackgroundSubagentCount: 0 },
pinSortKey: null,
hasPendingInteraction: false,
environmentHostId: "host-1",
diff --git a/apps/app/src/hooks/mutations/thread-state-mutations.test.tsx b/apps/app/src/hooks/mutations/thread-state-mutations.test.tsx
index bd9017127..27f66f11e 100644
--- a/apps/app/src/hooks/mutations/thread-state-mutations.test.tsx
+++ b/apps/app/src/hooks/mutations/thread-state-mutations.test.tsx
@@ -69,6 +69,7 @@ function makeThreadListEntry(
): ThreadListEntry {
return {
...makeThreadWithRuntime(thread),
+ activity: { activeWorkflowCount: 0, activeBackgroundSubagentCount: 0 },
pinSortKey: null,
hasPendingInteraction: false,
environmentHostId: "host-1",
diff --git a/apps/app/src/hooks/realtime-cache-effects.ts b/apps/app/src/hooks/realtime-cache-effects.ts
index 288f94777..cbc82be7b 100644
--- a/apps/app/src/hooks/realtime-cache-effects.ts
+++ b/apps/app/src/hooks/realtime-cache-effects.ts
@@ -85,6 +85,8 @@ function mergeThreadChangeMetadata(
next: ThreadChangeMetadata,
): ThreadChangeMetadata {
const eventTypes = mergeEventTypes(current?.eventTypes, next.eventTypes);
+ const backgroundActivityChanged =
+ next.backgroundActivityChanged ?? current?.backgroundActivityChanged;
const hasPendingInteraction =
next.hasPendingInteraction ?? current?.hasPendingInteraction;
const projectId = next.projectId ?? current?.projectId;
@@ -92,6 +94,9 @@ function mergeThreadChangeMetadata(
if (eventTypes) {
metadata.eventTypes = eventTypes;
}
+ if (backgroundActivityChanged !== undefined) {
+ metadata.backgroundActivityChanged = backgroundActivityChanged;
+ }
if (hasPendingInteraction !== undefined) {
metadata.hasPendingInteraction = hasPendingInteraction;
}
@@ -137,6 +142,7 @@ function flushThreadInvalidations(
for (const changeKind of state.globalChangeKinds) {
executeRealtimeDirtyHandlers({
context: {
+ backgroundActivityChanged: undefined,
eventTypes: undefined,
hasPendingInteraction: undefined,
projectId: undefined,
@@ -152,6 +158,7 @@ function flushThreadInvalidations(
for (const changeKind of changeKinds) {
executeRealtimeDirtyHandlers({
context: {
+ backgroundActivityChanged: metadata?.backgroundActivityChanged,
hasPendingInteraction: metadata?.hasPendingInteraction,
eventTypes: metadata?.eventTypes,
projectId: metadata?.projectId,
diff --git a/apps/app/src/lib/thread-activity.test.ts b/apps/app/src/lib/thread-activity.test.ts
index fcb4d05b6..ac4e9f162 100644
--- a/apps/app/src/lib/thread-activity.test.ts
+++ b/apps/app/src/lib/thread-activity.test.ts
@@ -18,6 +18,7 @@ function makeChild(
latestAttentionAt: 10,
parentThreadId: null,
hasPendingInteraction: false,
+ activity: { activeWorkflowCount: 0, activeBackgroundSubagentCount: 0 },
runtime: { displayStatus: "idle", hostReconnectGraceExpiresAt: null },
...overrides,
};
@@ -39,6 +40,7 @@ describe("thread-activity", () => {
it("exposes shared running/unread helpers", () => {
expect(
isBusyThread({
+ activity: { activeWorkflowCount: 0, activeBackgroundSubagentCount: 0 },
runtime: {
displayStatus: "active",
hostReconnectGraceExpiresAt: null,
@@ -47,6 +49,7 @@ describe("thread-activity", () => {
).toBe(true);
expect(
isBusyThread({
+ activity: { activeWorkflowCount: 0, activeBackgroundSubagentCount: 0 },
runtime: {
displayStatus: "host-reconnecting",
hostReconnectGraceExpiresAt: 100,
@@ -55,6 +58,7 @@ describe("thread-activity", () => {
).toBe(true);
expect(
isBusyThread({
+ activity: { activeWorkflowCount: 0, activeBackgroundSubagentCount: 0 },
runtime: {
displayStatus: "provisioning",
hostReconnectGraceExpiresAt: null,
@@ -63,6 +67,7 @@ describe("thread-activity", () => {
).toBe(true);
expect(
isBusyThread({
+ activity: { activeWorkflowCount: 0, activeBackgroundSubagentCount: 0 },
runtime: {
displayStatus: "waiting-for-host",
hostReconnectGraceExpiresAt: null,
@@ -70,6 +75,16 @@ describe("thread-activity", () => {
}),
).toBe(false);
+ expect(
+ isBusyThread({
+ activity: { activeWorkflowCount: 1, activeBackgroundSubagentCount: 0 },
+ runtime: {
+ displayStatus: "idle",
+ hostReconnectGraceExpiresAt: null,
+ },
+ }),
+ ).toBe(true);
+
expect(
isUnreadDoneThread({
status: "idle",
@@ -109,12 +124,18 @@ describe("thread-activity", () => {
expect(getCollapsedChildActivity([])).toEqual({
pending: false,
working: false,
+ runtimeWorking: false,
+ backgroundWorking: false,
+ workflow: false,
unread: false,
unreadError: false,
});
expect(getCollapsedChildActivity([makeChild(), makeChild()])).toEqual({
pending: false,
working: false,
+ runtimeWorking: false,
+ backgroundWorking: false,
+ workflow: false,
unread: false,
unreadError: false,
});
@@ -124,24 +145,36 @@ describe("thread-activity", () => {
expect(getCollapsedChildActivity([busyChild])).toEqual({
pending: false,
working: true,
+ runtimeWorking: true,
+ backgroundWorking: false,
+ workflow: false,
unread: false,
unreadError: false,
});
expect(getCollapsedChildActivity([pendingChild])).toEqual({
pending: true,
working: false,
+ runtimeWorking: false,
+ backgroundWorking: false,
+ workflow: false,
unread: false,
unreadError: false,
});
expect(getCollapsedChildActivity([unreadChild])).toEqual({
pending: false,
working: false,
+ runtimeWorking: false,
+ backgroundWorking: false,
+ workflow: false,
unread: true,
unreadError: false,
});
expect(getCollapsedChildActivity([unreadErrorChild])).toEqual({
pending: false,
working: false,
+ runtimeWorking: false,
+ backgroundWorking: false,
+ workflow: false,
unread: true,
unreadError: true,
});
@@ -153,6 +186,9 @@ describe("thread-activity", () => {
).toEqual({
pending: true,
working: true,
+ runtimeWorking: true,
+ backgroundWorking: false,
+ workflow: false,
unread: true,
unreadError: false,
});
@@ -166,6 +202,9 @@ describe("thread-activity", () => {
).toEqual({
pending: true,
working: true,
+ runtimeWorking: true,
+ backgroundWorking: false,
+ workflow: false,
unread: true,
unreadError: true,
});
@@ -180,6 +219,37 @@ describe("thread-activity", () => {
expect(getCollapsedChildActivity([busyAndPending])).toEqual({
pending: true,
working: false,
+ runtimeWorking: false,
+ backgroundWorking: false,
+ workflow: false,
+ unread: false,
+ unreadError: false,
+ });
+ });
+
+ it("distinguishes idle workflow activity from runtime work", () => {
+ const workflowChild = makeChild({
+ activity: { activeWorkflowCount: 1, activeBackgroundSubagentCount: 0 },
+ });
+ const backgroundSubagentChild = makeChild({
+ activity: { activeWorkflowCount: 0, activeBackgroundSubagentCount: 1 },
+ });
+
+ expect(getCollapsedChildActivity([workflowChild])).toEqual({
+ pending: false,
+ working: true,
+ runtimeWorking: false,
+ backgroundWorking: false,
+ workflow: true,
+ unread: false,
+ unreadError: false,
+ });
+ expect(getCollapsedChildActivity([backgroundSubagentChild])).toEqual({
+ pending: false,
+ working: true,
+ runtimeWorking: false,
+ backgroundWorking: true,
+ workflow: false,
unread: false,
unreadError: false,
});
diff --git a/apps/app/src/lib/thread-activity.ts b/apps/app/src/lib/thread-activity.ts
index 16ae8de57..d067cbc93 100644
--- a/apps/app/src/lib/thread-activity.ts
+++ b/apps/app/src/lib/thread-activity.ts
@@ -9,11 +9,33 @@ type ThreadStatusShape = Pick<
>;
type ThreadRuntimeShape = Pick;
+type ThreadActivityStateShape = Pick;
-export function isBusyThread(thread: ThreadRuntimeShape): boolean {
+export function isRuntimeBusyThread(thread: ThreadRuntimeShape): boolean {
return isRunningThreadRuntimeDisplayStatus(thread.runtime.displayStatus);
}
+export function hasActiveWorkflowActivity(
+ thread: ThreadActivityStateShape,
+): boolean {
+ return thread.activity.activeWorkflowCount > 0;
+}
+
+export function hasActiveBackgroundActivity(
+ thread: ThreadActivityStateShape,
+): boolean {
+ return (
+ thread.activity.activeWorkflowCount > 0 ||
+ thread.activity.activeBackgroundSubagentCount > 0
+ );
+}
+
+export function isBusyThread(
+ thread: ThreadRuntimeShape & ThreadActivityStateShape,
+): boolean {
+ return isRuntimeBusyThread(thread) || hasActiveBackgroundActivity(thread);
+}
+
/**
* The signals a collapsed parent row surfaces on behalf of its hidden children.
* A collapsed row renders these through its single trailing status glyph, using
@@ -26,8 +48,14 @@ export function isBusyThread(thread: ThreadRuntimeShape): boolean {
export interface CollapsedChildActivity {
/** At least one child is blocked on the user (needs input). */
pending: boolean;
- /** At least one child is actively working. */
+ /** At least one child is actively working, including background work. */
working: boolean;
+ /** At least one child is actively running a foreground/runtime turn. */
+ runtimeWorking: boolean;
+ /** At least one idle child has non-workflow background work running. */
+ backgroundWorking: boolean;
+ /** At least one idle child has a provider workflow still running. */
+ workflow: boolean;
/**
* At least one finished child is unread. Only top-level worktree children
* qualify — `isUnreadDoneThread` is false for parented threads, so manager
@@ -41,13 +69,16 @@ export interface CollapsedChildActivity {
export const NO_COLLAPSED_CHILD_ACTIVITY: CollapsedChildActivity = {
pending: false,
working: false,
+ runtimeWorking: false,
+ backgroundWorking: false,
+ workflow: false,
unread: false,
unreadError: false,
};
type ThreadActivityShape = ThreadStatusShape &
ThreadRuntimeShape &
- Pick;
+ Pick;
/** Rolls a child thread list up to the set of activity signals present in it. */
export function getCollapsedChildActivity(
@@ -55,6 +86,9 @@ export function getCollapsedChildActivity(
): CollapsedChildActivity {
let pending = false;
let working = false;
+ let runtimeWorking = false;
+ let backgroundWorking = false;
+ let workflow = false;
let unread = false;
let unreadError = false;
for (const thread of threads) {
@@ -63,7 +97,14 @@ export function getCollapsedChildActivity(
pending = true;
continue;
}
- if (isBusyThread(thread)) {
+ if (isRuntimeBusyThread(thread)) {
+ runtimeWorking = true;
+ working = true;
+ } else if (hasActiveWorkflowActivity(thread)) {
+ workflow = true;
+ working = true;
+ } else if (hasActiveBackgroundActivity(thread)) {
+ backgroundWorking = true;
working = true;
} else if (isUnreadDoneThread(thread)) {
unread = true;
@@ -72,7 +113,15 @@ export function getCollapsedChildActivity(
}
}
}
- return { pending, working, unread, unreadError };
+ return {
+ pending,
+ working,
+ runtimeWorking,
+ backgroundWorking,
+ workflow,
+ unread,
+ unreadError,
+ };
}
export function isUnreadDoneThread(thread: ThreadStatusShape): boolean {
diff --git a/apps/app/src/views/RootComposeMobileRecents.test.tsx b/apps/app/src/views/RootComposeMobileRecents.test.tsx
index 1f7835146..0a9b56e67 100644
--- a/apps/app/src/views/RootComposeMobileRecents.test.tsx
+++ b/apps/app/src/views/RootComposeMobileRecents.test.tsx
@@ -31,6 +31,7 @@ function makeThread(args: MakeThreadArgs): ThreadListEntry {
latestAttentionAt: 100,
createdAt: 100,
updatedAt: 100,
+ activity: { activeWorkflowCount: 0, activeBackgroundSubagentCount: 0 },
hasPendingInteraction: false,
environmentHostId: null,
environmentName: null,
diff --git a/apps/app/src/views/RootComposeMobileRecents.tsx b/apps/app/src/views/RootComposeMobileRecents.tsx
index fb2cb2e79..2713500a8 100644
--- a/apps/app/src/views/RootComposeMobileRecents.tsx
+++ b/apps/app/src/views/RootComposeMobileRecents.tsx
@@ -4,7 +4,13 @@ import type { ThreadListEntry } from "@bb/domain";
import { ThreadStatusGlyph } from "@/components/sidebar/ThreadRow";
import { Icon } from "@/components/ui/icon.js";
import { getThreadRoutePath, isProjectlessProjectId } from "@/lib/route-paths";
-import { isBusyThread, isUnreadDoneThread } from "@/lib/thread-activity";
+import {
+ hasActiveBackgroundActivity,
+ hasActiveWorkflowActivity,
+ isBusyThread,
+ isRuntimeBusyThread,
+ isUnreadDoneThread,
+} from "@/lib/thread-activity";
import { getThreadDisplayTitle } from "@/lib/thread-title";
import { cn } from "@/lib/utils";
@@ -78,11 +84,23 @@ function getMobileRecentThreads({
function MobileRecentThreadStatus({ thread }: MobileRecentThreadStatusProps) {
const isBusy = isBusyThread(thread);
+ const isRuntimeBusy = isRuntimeBusyThread(thread);
+ const isWorkflowActive =
+ !isRuntimeBusy &&
+ hasActiveWorkflowActivity(thread) &&
+ !thread.hasPendingInteraction;
+ const isBackgroundBusy =
+ !isRuntimeBusy &&
+ !isWorkflowActive &&
+ hasActiveBackgroundActivity(thread);
return (
;
function makeThread(overrides: ThreadListEntryOverrides = {}): ThreadListEntry {
return {
+ activity: { activeWorkflowCount: 0, activeBackgroundSubagentCount: 0 },
archivedAt: null,
childOrigin: null,
createdAt: 1,
diff --git a/apps/server/src/internal/events.ts b/apps/server/src/internal/events.ts
index 842becb63..75f424aec 100644
--- a/apps/server/src/internal/events.ts
+++ b/apps/server/src/internal/events.ts
@@ -93,6 +93,42 @@ interface NotifyInsertedEventThreadsArgs {
insertedInputIndexes: number[];
}
+function parseStoredBackgroundTaskItemStatus(
+ data: string,
+): string | undefined {
+ const parsed: unknown = JSON.parse(data);
+ if (parsed === null || typeof parsed !== "object" || !("item" in parsed)) {
+ return undefined;
+ }
+ const item = parsed.item;
+ if (item === null || typeof item !== "object" || !("status" in item)) {
+ return undefined;
+ }
+ return typeof item.status === "string" ? item.status : undefined;
+}
+
+function eventInputChangesBackgroundActivity(
+ input: AppendDaemonEventInput,
+): boolean {
+ if (input.itemKind !== "backgroundTask") {
+ return false;
+ }
+ if (
+ input.type === "item/started" ||
+ input.type === "item/backgroundTask/completed"
+ ) {
+ return true;
+ }
+ if (input.type !== "item/backgroundTask/progress") {
+ return false;
+ }
+ try {
+ return parseStoredBackgroundTaskItemStatus(input.data) !== "pending";
+ } catch {
+ return false;
+ }
+}
+
interface ShouldApplyEventEffectArgs {
completedTurnKeyLookup: Set;
entry: HostDaemonEventEnvelope;
@@ -238,6 +274,7 @@ function notifyInsertedEventThreads(
args: NotifyInsertedEventThreadsArgs,
): void {
const eventTypesByThreadId = new Map>();
+ const backgroundActivityThreadIds = new Set();
for (const index of args.insertedInputIndexes) {
const eventInput = args.eventInputs[index];
if (eventInput) {
@@ -246,10 +283,16 @@ function notifyInsertedEventThreads(
new Set();
eventTypes.add(eventInput.type);
eventTypesByThreadId.set(eventInput.threadId, eventTypes);
+ if (eventInputChangesBackgroundActivity(eventInput)) {
+ backgroundActivityThreadIds.add(eventInput.threadId);
+ }
}
}
for (const [threadId, eventTypes] of eventTypesByThreadId) {
deps.hub.notifyThread(threadId, ["events-appended"], {
+ ...(backgroundActivityThreadIds.has(threadId)
+ ? { backgroundActivityChanged: true }
+ : {}),
eventTypes: Array.from(eventTypes),
});
}
diff --git a/apps/server/src/services/threads/thread-runtime-display.ts b/apps/server/src/services/threads/thread-runtime-display.ts
index 035c8f3d5..8086152d9 100644
--- a/apps/server/src/services/threads/thread-runtime-display.ts
+++ b/apps/server/src/services/threads/thread-runtime-display.ts
@@ -1,6 +1,7 @@
import {
getEnvironment,
getLatestSessionForHost,
+ listActiveBackgroundTaskCountsByThreadIds,
listLatestSessionsForHosts,
type DbConnection,
type HostDaemonSessionRow,
@@ -8,6 +9,7 @@ import {
} from "@bb/db";
import type {
Thread,
+ ThreadActivityState,
ThreadListEntry,
ThreadRuntimeState,
ThreadStatus,
@@ -49,6 +51,7 @@ interface ToThreadListEntryResponsesArgs {
}
interface ToThreadListEntryResponseFromLatestSessionArgs {
+ activity: ThreadActivityState;
latestSession: HostDaemonSessionRow | null;
now?: number;
thread: ThreadWithPendingInteractionState;
@@ -200,6 +203,11 @@ export function toThreadListEntryResponses(
deps: ThreadRuntimeDisplayDeps,
args: ToThreadListEntryResponsesArgs,
): ThreadListEntry[] {
+ const threadActivityById = new Map(
+ listActiveBackgroundTaskCountsByThreadIds(deps.db, {
+ threadIds: args.threads.map((thread) => thread.id),
+ }).map((activity) => [activity.threadId, activity]),
+ );
const activeHostIds = [
...new Set(
args.threads.flatMap((thread) =>
@@ -217,6 +225,10 @@ export function toThreadListEntryResponses(
return args.threads.map((thread) =>
toThreadListEntryResponseFromLatestSession({
+ activity: threadActivityById.get(thread.id) ?? {
+ activeWorkflowCount: 0,
+ activeBackgroundSubagentCount: 0,
+ },
latestSession:
thread.environmentHostId === null
? null
@@ -233,6 +245,7 @@ function toThreadListEntryResponseFromLatestSession(
const thread = toPublicThread(args.thread);
return {
...thread,
+ activity: args.activity,
pinSortKey: args.thread.pinSortKey,
environmentBranchName: args.thread.environmentBranchName,
environmentHostId: args.thread.environmentHostId,
diff --git a/packages/db/src/data/events.ts b/packages/db/src/data/events.ts
index e16202f92..65360f43b 100644
--- a/packages/db/src/data/events.ts
+++ b/packages/db/src/data/events.ts
@@ -1077,6 +1077,16 @@ export interface ListLatestBackgroundTaskStateRowsByItemIdsArgs {
threadId: string;
}
+export interface ListActiveBackgroundTaskCountsByThreadIdsArgs {
+ threadIds: readonly string[];
+}
+
+export interface ActiveBackgroundTaskCountRow {
+ activeBackgroundSubagentCount: number;
+ activeWorkflowCount: number;
+ threadId: string;
+}
+
/**
* Latest thread-scoped lifecycle row per backgroundTask item, regardless of
* sequence. Timeline windows backfill these for in-window items so a page
@@ -1126,6 +1136,73 @@ export function listLatestBackgroundTaskStateRowsByItemIds(
.all();
}
+/**
+ * Counts open provider background tasks by thread, using each item's latest
+ * start/progress row. A task can report a terminal status in a progress row
+ * before the final completed event arrives, so active means the latest
+ * lifecycle snapshot still has item.status = "pending".
+ */
+export function listActiveBackgroundTaskCountsByThreadIds(
+ db: DbQueryConnection,
+ args: ListActiveBackgroundTaskCountsByThreadIdsArgs,
+): ActiveBackgroundTaskCountRow[] {
+ if (args.threadIds.length === 0) {
+ return [];
+ }
+
+ const startedType = "item/started" satisfies ThreadEventType;
+ const progressType =
+ "item/backgroundTask/progress" satisfies ThreadEventType;
+ const completedType =
+ "item/backgroundTask/completed" satisfies ThreadEventType;
+
+ return db.all(sql`
+ WITH latest_background_task_activity AS (
+ SELECT
+ ${events.threadId} AS thread_id,
+ ${events.itemId} AS item_id,
+ MAX(${events.sequence}) AS sequence
+ FROM ${events}
+ WHERE ${inArray(events.threadId, [...args.threadIds])}
+ AND ${eq(events.itemKind, "backgroundTask")}
+ AND ${inArray(events.type, [startedType, progressType])}
+ AND ${isNotNull(events.itemId)}
+ GROUP BY ${events.threadId}, ${events.itemId}
+ ),
+ completed_background_task_activity AS (
+ SELECT DISTINCT
+ ${events.threadId} AS thread_id,
+ ${events.itemId} AS item_id
+ FROM ${events}
+ WHERE ${inArray(events.threadId, [...args.threadIds])}
+ AND ${eq(events.itemKind, "backgroundTask")}
+ AND ${eq(events.type, completedType)}
+ AND ${isNotNull(events.itemId)}
+ )
+ SELECT
+ active_event.thread_id AS threadId,
+ COALESCE(SUM(CASE
+ WHEN json_extract(active_event.data, '$.item.taskType') = 'local_workflow'
+ THEN 1 ELSE 0
+ END), 0) AS activeWorkflowCount,
+ COALESCE(SUM(CASE
+ WHEN json_extract(active_event.data, '$.item.taskType') = 'local_agent'
+ THEN 1 ELSE 0
+ END), 0) AS activeBackgroundSubagentCount
+ FROM latest_background_task_activity latest
+ JOIN events active_event
+ ON active_event.thread_id = latest.thread_id
+ AND active_event.sequence = latest.sequence
+ LEFT JOIN completed_background_task_activity completed
+ ON completed.thread_id = latest.thread_id
+ AND completed.item_id = latest.item_id
+ WHERE completed.item_id IS NULL
+ AND json_extract(active_event.data, '$.item.status') = 'pending'
+ GROUP BY active_event.thread_id
+ ORDER BY active_event.thread_id
+ `);
+}
+
function listStoredTurnStartedKeysChunk(
db: DbQueryConnection,
keys: readonly ThreadTurnKey[],
diff --git a/packages/db/src/data/index.ts b/packages/db/src/data/index.ts
index 59c688566..9461a2158 100644
--- a/packages/db/src/data/index.ts
+++ b/packages/db/src/data/index.ts
@@ -226,6 +226,7 @@ export {
getLatestThreadSystemErrorEventRow,
getLatestThreadSequence,
insertEvents,
+ listActiveBackgroundTaskCountsByThreadIds,
listContextWindowUsageRows,
listCompletedTurnsByThreadIds,
listEvents,
@@ -255,6 +256,7 @@ export {
} from "./events.js";
export type {
AcceptedDaemonEvent,
+ ActiveBackgroundTaskCountRow,
AppendDaemonEventInput,
AppendDaemonEventsResult,
AppendStoredThreadEventArgs,
@@ -265,6 +267,7 @@ export type {
HasStoredTurnStartedArgs,
InsertEventInput,
InsertEventsResult,
+ ListActiveBackgroundTaskCountsByThreadIdsArgs,
ListEventsOptions,
ListTimelineSegmentAnchorsDescendingArgs,
TimelineSegmentAnchorLookupArgs,
diff --git a/packages/db/test/data/events.test.ts b/packages/db/test/data/events.test.ts
index 52c57014a..679ca028c 100644
--- a/packages/db/test/data/events.test.ts
+++ b/packages/db/test/data/events.test.ts
@@ -31,6 +31,7 @@ import {
listStoredTimelineWindowEventRows,
listStoredTurnInputAcceptedRowsByClientRequestIds,
MissingStoredTurnStartedError,
+ listActiveBackgroundTaskCountsByThreadIds,
listLatestBackgroundTaskStateRowsByItemIds,
listOpenBackgroundTaskItemRowsForHost,
listThreadTurnInterruptionEventStates,
@@ -2722,6 +2723,150 @@ describe("events", () => {
).toEqual([]);
});
+ it("counts only active background workflow snapshots by thread", () => {
+ const { db, project, thread } = setup();
+ const otherThread = createThread(db, noopNotifier, {
+ projectId: project.id,
+ providerId: "codex",
+ });
+
+ const taskData = (args: {
+ itemId: string;
+ itemStatus: string;
+ taskStatus: string;
+ taskType: string;
+ }) =>
+ JSON.stringify({
+ item: {
+ id: args.itemId,
+ type: "backgroundTask",
+ taskType: args.taskType,
+ description: "fixture background task",
+ status: args.itemStatus,
+ taskStatus: args.taskStatus,
+ skipTranscript: false,
+ },
+ });
+
+ insertEvents(db, noopNotifier, [
+ {
+ threadId: thread.id,
+ sequence: 1,
+ scope: turnScope("turn-1"),
+ type: "item/started",
+ itemId: "task:wf-active",
+ itemKind: "backgroundTask",
+ data: taskData({
+ itemId: "task:wf-active",
+ itemStatus: "pending",
+ taskStatus: "running",
+ taskType: "local_workflow",
+ }),
+ },
+ {
+ threadId: thread.id,
+ sequence: 2,
+ scope: threadScope(),
+ type: "item/backgroundTask/progress",
+ itemId: "task:wf-active",
+ itemKind: "backgroundTask",
+ data: taskData({
+ itemId: "task:wf-active",
+ itemStatus: "pending",
+ taskStatus: "running",
+ taskType: "local_workflow",
+ }),
+ },
+ {
+ threadId: thread.id,
+ sequence: 3,
+ scope: turnScope("turn-1"),
+ type: "item/started",
+ itemId: "task:wf-terminal-progress",
+ itemKind: "backgroundTask",
+ data: taskData({
+ itemId: "task:wf-terminal-progress",
+ itemStatus: "pending",
+ taskStatus: "running",
+ taskType: "local_workflow",
+ }),
+ },
+ {
+ threadId: thread.id,
+ sequence: 4,
+ scope: threadScope(),
+ type: "item/backgroundTask/progress",
+ itemId: "task:wf-terminal-progress",
+ itemKind: "backgroundTask",
+ data: taskData({
+ itemId: "task:wf-terminal-progress",
+ itemStatus: "completed",
+ taskStatus: "completed",
+ taskType: "local_workflow",
+ }),
+ },
+ {
+ threadId: thread.id,
+ sequence: 5,
+ scope: turnScope("turn-1"),
+ type: "item/started",
+ itemId: "task:wf-completed",
+ itemKind: "backgroundTask",
+ data: taskData({
+ itemId: "task:wf-completed",
+ itemStatus: "pending",
+ taskStatus: "running",
+ taskType: "local_workflow",
+ }),
+ },
+ {
+ threadId: thread.id,
+ sequence: 6,
+ scope: threadScope(),
+ type: "item/backgroundTask/completed",
+ itemId: "task:wf-completed",
+ itemKind: "backgroundTask",
+ data: taskData({
+ itemId: "task:wf-completed",
+ itemStatus: "completed",
+ taskStatus: "completed",
+ taskType: "local_workflow",
+ }),
+ },
+ {
+ threadId: otherThread.id,
+ sequence: 1,
+ scope: turnScope("turn-2"),
+ type: "item/started",
+ itemId: "task:agent-active",
+ itemKind: "backgroundTask",
+ data: taskData({
+ itemId: "task:agent-active",
+ itemStatus: "pending",
+ taskStatus: "running",
+ taskType: "local_agent",
+ }),
+ },
+ ]);
+
+ const countsByThreadId = new Map(
+ listActiveBackgroundTaskCountsByThreadIds(db, {
+ threadIds: [thread.id, otherThread.id],
+ }).map((row) => [row.threadId, row]),
+ );
+
+ expect(countsByThreadId.get(thread.id)).toEqual({
+ threadId: thread.id,
+ activeWorkflowCount: 1,
+ activeBackgroundSubagentCount: 0,
+ });
+ expect(countsByThreadId.get(otherThread.id)).toEqual({
+ threadId: otherThread.id,
+ activeWorkflowCount: 0,
+ activeBackgroundSubagentCount: 1,
+ });
+ });
+
it("lists the latest lifecycle row per open backgroundTask item on a host", () => {
const db = createConnection(":memory:");
migrate(db);
diff --git a/packages/domain/src/change-kinds.ts b/packages/domain/src/change-kinds.ts
index efbce430c..d5e2a3105 100644
--- a/packages/domain/src/change-kinds.ts
+++ b/packages/domain/src/change-kinds.ts
@@ -167,6 +167,7 @@ export function realtimeSubscriptionTargetKey(
export const threadChangeMetadataSchema = z
.object({
+ backgroundActivityChanged: z.boolean().optional(),
eventTypes: z.array(threadEventTypeSchema).readonly().optional(),
hasPendingInteraction: z.boolean().optional(),
projectId: z.string().optional(),
@@ -267,6 +268,7 @@ const knownThreadEventTypes: ReadonlySet = new Set(
);
const threadChangeMetadataLenientSchema = z.object({
+ backgroundActivityChanged: z.boolean().optional(),
eventTypes: z
.array(z.string())
.transform((values) =>
diff --git a/packages/domain/src/thread.ts b/packages/domain/src/thread.ts
index d3f91c9b0..2c6cfbe6c 100644
--- a/packages/domain/src/thread.ts
+++ b/packages/domain/src/thread.ts
@@ -44,6 +44,12 @@ export const threadRuntimeStateSchema = z.object({
});
export type ThreadRuntimeState = z.infer;
+export const threadActivityStateSchema = z.object({
+ activeWorkflowCount: z.number().int().nonnegative(),
+ activeBackgroundSubagentCount: z.number().int().nonnegative(),
+});
+export type ThreadActivityState = z.infer;
+
export const workspaceStateValues = [
"clean",
"untracked",
@@ -391,6 +397,7 @@ export const threadWithRuntimeSchema = threadSchema.extend({
export type ThreadWithRuntime = z.infer;
export const threadListEntrySchema = threadWithRuntimeSchema.extend({
+ activity: threadActivityStateSchema,
pinSortKey: z.string().nullable(),
hasPendingInteraction: z.boolean(),
environmentHostId: z.string().nullable(),
diff --git a/packages/server-contract/test/contract.test.ts b/packages/server-contract/test/contract.test.ts
index 957e76a25..f8a31eea6 100644
--- a/packages/server-contract/test/contract.test.ts
+++ b/packages/server-contract/test/contract.test.ts
@@ -737,6 +737,10 @@ describe("server-contract canonical schemas", () => {
displayStatus: "idle",
hostReconnectGraceExpiresAt: null,
},
+ activity: {
+ activeWorkflowCount: 0,
+ activeBackgroundSubagentCount: 0,
+ },
hasPendingInteraction: true,
environmentHostId: "host_123",
environmentName: null,