From 8cc8276cf73180e1d23b9f7d94f3d3e5fee15038 Mon Sep 17 00:00:00 2001 From: Michael Yong Date: Fri, 19 Jun 2026 03:29:33 +0000 Subject: [PATCH] Show workflow activity in sidebar --- apps/app/.ladle/story-fixtures.ts | 1 + .../app/src/components/sidebar/ProjectRow.tsx | 9 +- .../sidebar/SidebarThreadSearchPanel.test.tsx | 1 + .../src/components/sidebar/ThreadRow.test.tsx | 113 ++++++++++++++ apps/app/src/components/sidebar/ThreadRow.tsx | 57 ++++++- .../sidebar/ThreadSearchResultRow.tsx | 21 ++- .../sidebar/pinnedSidebarThreads.test.ts | 1 + .../sidebar/projectThreadGroups.test.ts | 7 + .../app/src/hooks/cache-owners/query-cache.ts | 1 + .../cache-owners/realtime-cache-registry.ts | 11 ++ .../thread-state-cache-owner.test.ts | 1 + .../mutations/thread-state-mutations.test.tsx | 1 + apps/app/src/hooks/realtime-cache-effects.ts | 7 + apps/app/src/lib/thread-activity.test.ts | 70 +++++++++ apps/app/src/lib/thread-activity.ts | 59 ++++++- .../views/RootComposeMobileRecents.test.tsx | 1 + .../src/views/RootComposeMobileRecents.tsx | 22 ++- apps/app/src/views/RootComposeView.test.ts | 1 + .../threadParentSelectorOptions.test.ts | 1 + apps/server/src/internal/events.ts | 43 ++++++ .../threads/thread-runtime-display.ts | 13 ++ packages/db/src/data/events.ts | 77 ++++++++++ packages/db/src/data/index.ts | 3 + packages/db/test/data/events.test.ts | 145 ++++++++++++++++++ packages/domain/src/change-kinds.ts | 2 + packages/domain/src/thread.ts | 7 + .../server-contract/test/contract.test.ts | 4 + 27 files changed, 663 insertions(+), 16 deletions(-) create mode 100644 apps/app/src/components/sidebar/ThreadRow.test.tsx 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,