Skip to content
Merged
33 changes: 33 additions & 0 deletions src/browser/components/AgentListItem/AgentListItem.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -459,9 +459,42 @@ function renderAppSidebarThreeActiveSubAgents() {
);
}

function renderWorkflowOnlyActivity() {
const workflowWorkspace = createWorkspace({
id: "ws-workflow-only",
name: "workflow-only",
title: "Workflow-only run",
projectName: PROJECT_NAME,
projectPath: PROJECT_PATH,
createdAt: new Date(NOW - 6_000).toISOString(),
});

return (
<StoryScaffold
workspaces={[workflowWorkspace]}
workspaceActivitySnapshots={{
[workflowWorkspace.id]: {
recency: NOW,
streaming: false,
lastModel: null,
lastThinkingLevel: null,
activeWorkflowRunCount: 1,
},
}}
>
<WorkspaceRow workspace={workflowWorkspace} />
</StoryScaffold>
);
}

// Composite gallery covering the primary single-workspace states. Replaces the
// former FigmaStates, Selected, Active, ErrorState, Archiving, Question, and
// Draft stories — one snapshot, all states preserved and labeled.
export const WorkflowOnlyActivity: Story = {
args: undefined as never,
render: renderWorkflowOnlyActivity,
};

export const States: Story = {
args: undefined as never,
render: renderStatesGallery,
Expand Down
26 changes: 26 additions & 0 deletions src/browser/components/AgentListItem/AgentListItem.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ function createWorkspaceSidebarState(
loadedSkills: [],
skillLoadErrors: [],
agentStatus: undefined,
activeWorkflowRunCount: 0,
terminalActiveCount: 0,
terminalSessionCount: 0,
...overrides,
Expand Down Expand Up @@ -362,6 +363,31 @@ describe("AgentListItem", () => {
expect(customTitle.view.getByText("My renamed run")).toBeTruthy();
});

test("shows workflow-only activity on idle workspace rows", () => {
mockWorkspaceSidebarState = createWorkspaceSidebarState({ activeWorkflowRunCount: 1 });

const { row } = renderWorkspaceItem();
const rowView = within(row);

expect(row.querySelector(".workspace-status-dot-active")).toBeTruthy();
expect(rowView.getByText("Workflow running")).toBeTruthy();
expect(rowView.queryByTestId(`workspace-status-indicator-${TEST_WORKSPACE_ID}`)).toBeNull();
});

test("keeps sidebar status text while workflow-only activity drives the active dot", () => {
mockWorkspaceSidebarState = createWorkspaceSidebarState({
activeWorkflowRunCount: 1,
agentStatus: { emoji: "🔄", message: "Verifying workflow output" },
});

const { row } = renderWorkspaceItem();
const rowView = within(row);

expect(row.querySelector(".workspace-status-dot-active")).toBeTruthy();
expect(rowView.getByTestId(`workspace-status-indicator-${TEST_WORKSPACE_ID}`)).toBeTruthy();
expect(rowView.queryByText("Workflow running")).toBeNull();
});

test("shows active delegated workflow work on idle workspace rows", () => {
const { row } = renderWorkspaceItem({
delegatedActivity: {
Expand Down
57 changes: 48 additions & 9 deletions src/browser/components/AgentListItem/AgentListItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
type AgentRowRenderMeta,
type WorkspaceDelegatedActivity,
} from "@/browser/utils/ui/workspaceFiltering";
import assert from "@/common/utils/assert";
import { cn } from "@/common/lib/utils";
import {
TASK_GROUP_KIND,
Expand Down Expand Up @@ -218,6 +219,33 @@ function formatDelegatedActivityText(activity: WorkspaceDelegatedActivity): stri
return parts.length > 0 ? parts.join(" · ") : null;
}

function formatWorkflowRunCount(count: number): string {
assert(count > 0, "formatWorkflowRunCount requires a positive count");
return count === 1 ? "Workflow running" : `${count} workflows running`;
}

function SidebarActivityIndicator(props: { text: string; testId: string }) {
return (
<div
className="text-muted flex min-w-0 items-center gap-1.5 text-xs leading-4"
data-testid={props.testId}
>
<span className="min-w-0 truncate">{props.text}</span>
</div>
);
}

function WorkflowActivityIndicator(props: { workspaceId: string; activeWorkflowRunCount: number }) {
const statusText = formatWorkflowRunCount(props.activeWorkflowRunCount);

return (
<SidebarActivityIndicator
text={statusText}
testId={`workspace-workflow-activity-${props.workspaceId}`}
/>
);
}

function DelegatedActivityIndicator(props: {
workspaceId: string;
activity: WorkspaceDelegatedActivity;
Expand All @@ -228,12 +256,10 @@ function DelegatedActivityIndicator(props: {
}

return (
<div
className="text-muted flex min-w-0 items-center gap-1.5 text-xs leading-4"
data-testid={`workspace-delegated-activity-${props.workspaceId}`}
>
<span className="min-w-0 truncate">{statusText}</span>
</div>
<SidebarActivityIndicator
text={statusText}
testId={`workspace-delegated-activity-${props.workspaceId}`}
/>
);
}

Expand Down Expand Up @@ -612,6 +638,7 @@ function RegularAgentListItemInner(props: AgentListItemProps) {
awaitingUserQuestion,
isStarting,
agentStatus,
activeWorkflowRunCount,
terminalActiveCount,
lastAbortReason,
} = useWorkspaceSidebarState(workspaceId);
Expand All @@ -626,13 +653,19 @@ function RegularAgentListItemInner(props: AgentListItemProps) {
useWorkspaceStreamingStatusPhase(streamingStatusPhase);
const isWorking = displayStreamingStatusPhase !== null && !awaitingUserQuestion;
const hasError = lastAbortReason?.reason === "system";
const hasActiveWorkflowRun = activeWorkflowRunCount > 0;
const hasActiveDelegatedWork = (delegatedActivity?.activeCount ?? 0) > 0;
const delegatedStatusText = delegatedActivity
? formatDelegatedActivityText(delegatedActivity)
: null;
const hasDelegatedStatusText = delegatedStatusText != null;
const shouldShowWorkflowStatus =
hasActiveWorkflowRun && !agentStatus && displayStreamingStatusPhase === null;
const hasOwnLiveStatusText =
awaitingUserQuestion || displayStreamingStatusPhase !== null || isRemoving;
awaitingUserQuestion ||
displayStreamingStatusPhase !== null ||
isRemoving ||
shouldShowWorkflowStatus;
const shouldShowDelegatedStatus = hasDelegatedStatusText && !hasOwnLiveStatusText && !hasError;
const visualState = getVisualState({
awaitingUserQuestion,
Expand All @@ -641,7 +674,7 @@ function RegularAgentListItemInner(props: AgentListItemProps) {
isArchiving: isArchiving === true,
isWorking,
isStarting: displayStreamingStatusPhase === "starting",
hasActiveDelegatedWork,
hasActiveDelegatedWork: hasActiveDelegatedWork || hasActiveWorkflowRun,
isUnread,
isSelected,
hasError,
Expand All @@ -653,7 +686,8 @@ function RegularAgentListItemInner(props: AgentListItemProps) {
Boolean(agentStatus) ||
awaitingUserQuestion ||
displayStreamingStatusPhase !== null ||
isRemoving;
isRemoving ||
shouldShowWorkflowStatus;
// Keep archiving feedback inline with the title so the row doesn't jump to a
// two-line layout right before it disappears from the sidebar.
const shouldShowInlineArchivingStatus = isArchiving === true && !isRemoving;
Expand Down Expand Up @@ -1145,6 +1179,11 @@ function RegularAgentListItemInner(props: AgentListItemProps) {
workspaceId={workspaceId}
activity={delegatedActivity}
/>
) : shouldShowWorkflowStatus ? (
<WorkflowActivityIndicator
workspaceId={workspaceId}
activeWorkflowRunCount={activeWorkflowRunCount}
/>
) : (
<WorkspaceStatusIndicator
workspaceId={workspaceId}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ function buildWorkspaceState(workspaceId: string, state: MockWorkspaceState): Wo
loadedSkills: [],
skillLoadErrors: [],
agentStatus: undefined,
activeWorkflowRunCount: 0,
lastAbortReason: null,
pendingStreamStartTime: null,
pendingStreamModel: null,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -541,6 +541,7 @@ function installProjectSidebarTestDoubles() {
loadedSkills: [],
skillLoadErrors: [],
agentStatus: undefined,
activeWorkflowRunCount: 0,
terminalActiveCount: 0,
terminalSessionCount: 0,
}));
Expand Down
15 changes: 8 additions & 7 deletions src/browser/components/ProjectSidebar/ProjectSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,10 @@ function getWorkspaceAttentionSignal(
try {
const sidebarState = workspaceStore.getWorkspaceSidebarState(workspaceId);
const isWorking =
(sidebarState.canInterrupt || sidebarState.isStarting) && !sidebarState.awaitingUserQuestion;
(sidebarState.canInterrupt ||
sidebarState.isStarting ||
sidebarState.activeWorkflowRunCount > 0) &&
!sidebarState.awaitingUserQuestion;
return {
isWorking,
awaitingUserQuestion: sidebarState.awaitingUserQuestion,
Expand Down Expand Up @@ -1171,12 +1174,10 @@ const ProjectSidebarInner: React.FC<ProjectSidebarProps> = ({
const workspaceHasAttention = useCallback(
(workspace: FrontendWorkspaceMetadata) => {
const workspaceId = workspace.id;
const aggregator = workspaceStore.getAggregator(workspaceId);
const hasActiveStreams = aggregator?.hasInterruptibleActiveStream() ?? false;
const isStarting = aggregator?.getPendingStreamStartTime() != null && !hasActiveStreams;
const awaitingUserQuestion = aggregator?.hasAwaitingUserQuestion() ?? false;
const isWorking = (hasActiveStreams || isStarting) && !awaitingUserQuestion;
const hasError = aggregator?.getLastAbortReason()?.reason === "system";
const attentionSignal = getWorkspaceAttentionSignal(workspaceStore, workspaceId);
const isWorking = attentionSignal?.isWorking === true;
const awaitingUserQuestion = attentionSignal?.awaitingUserQuestion === true;
const hasError = attentionSignal?.hasSystemError === true;
const isRemoving = workspace.isRemoving === true;
const isArchiving = archivingWorkspaceIds.has(workspaceId);
const isInitializing = workspace.isInitializing === true;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ function mockSidebarState(
loadedSkills: [],
skillLoadErrors: [],
agentStatus: undefined,
activeWorkflowRunCount: 0,
terminalActiveCount: 0,
terminalSessionCount: 0,
...overrides,
Expand Down Expand Up @@ -143,6 +144,7 @@ describe("WorkspaceStatusIndicator", () => {
loadedSkills: [],
skillLoadErrors: [],
agentStatus: undefined,
activeWorkflowRunCount: 0,
terminalActiveCount: 0,
terminalSessionCount: 0,
};
Expand Down Expand Up @@ -201,6 +203,7 @@ describe("WorkspaceStatusIndicator", () => {
loadedSkills: [],
skillLoadErrors: [],
agentStatus: undefined,
activeWorkflowRunCount: 0,
terminalActiveCount: 0,
terminalSessionCount: 0,
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ function buildState(workspaceId: string, input: SeedInput): WorkspaceState {
loadedSkills: [],
skillLoadErrors: [],
agentStatus: undefined,
activeWorkflowRunCount: 0,
lastAbortReason: null,
pendingStreamStartTime: null,
pendingStreamModel: null,
Expand Down
3 changes: 3 additions & 0 deletions src/browser/stores/WorkspaceStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2947,6 +2947,7 @@ describe("WorkspaceStore", () => {
streaming: true,
lastModel: "claude-sonnet-4",
lastThinkingLevel: "high",
activeWorkflowRunCount: 1,
todoStatus: { emoji: "🔄", message: "Run checks" },
hasTodos: true,
};
Expand All @@ -2971,6 +2972,8 @@ describe("WorkspaceStore", () => {
expect(state.canInterrupt).toBe(true);
expect(state.currentModel).toBe(activitySnapshot.lastModel);
expect(state.currentThinkingLevel).toBe(activitySnapshot.lastThinkingLevel);
expect(state.activeWorkflowRunCount).toBe(1);
expect(store.getWorkspaceSidebarState(workspaceId).activeWorkflowRunCount).toBe(1);
expect(state.agentStatus).toEqual(activitySnapshot.todoStatus ?? undefined);
expect(state.recencyTimestamp).toBe(activitySnapshot.recency);
});
Expand Down
7 changes: 7 additions & 0 deletions src/browser/stores/WorkspaceStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ export interface WorkspaceState {
loadedSkills: LoadedSkill[];
skillLoadErrors: SkillLoadError[];
agentStatus: { emoji: string; message: string; url?: string } | undefined;
activeWorkflowRunCount: number;
lastAbortReason: StreamAbortReasonSnapshot | null;
pendingStreamStartTime: number | null;
// Model used for the pending send (used during "starting" phase)
Expand Down Expand Up @@ -181,6 +182,7 @@ export interface WorkspaceSidebarState {
loadedSkills: LoadedSkill[];
skillLoadErrors: SkillLoadError[];
agentStatus: { emoji: string; message: string; url?: string } | undefined;
activeWorkflowRunCount: number;
terminalActiveCount: number;
terminalSessionCount: number;
goal?: GoalSnapshot | null;
Expand Down Expand Up @@ -1974,6 +1976,7 @@ export class WorkspaceStore {
(activity?.hasTodos === false ? undefined : deriveTodoStatus(aggregatorTodos)));
const agentStatus =
displayStatus ?? liveTodoStatus ?? fallbackAgentStatus ?? persistedTodoStatus;
const activeWorkflowRunCount = activity?.activeWorkflowRunCount ?? 0;
const goal = activity?.goal ?? null;

return {
Expand All @@ -1998,6 +2001,7 @@ export class WorkspaceStore {
skillLoadErrors: aggregator.getSkillLoadErrors(),
lastAbortReason: aggregator.getLastAbortReason(),
agentStatus,
activeWorkflowRunCount,
pendingStreamStartTime,
pendingStreamModel: aggregator.getPendingStreamModel(),
autoRetryStatus: transient.autoRetryStatus,
Expand Down Expand Up @@ -2108,6 +2112,7 @@ export class WorkspaceStore {
cached.loadedSkills === fullState.loadedSkills &&
cached.skillLoadErrors === fullState.skillLoadErrors &&
cached.agentStatus === fullState.agentStatus &&
cached.activeWorkflowRunCount === fullState.activeWorkflowRunCount &&
cached.terminalActiveCount === terminalActiveCount &&
cached.terminalSessionCount === terminalSessionCount &&
cached.goal === fullState.goal
Expand All @@ -2130,6 +2135,7 @@ export class WorkspaceStore {
loadedSkills: fullState.loadedSkills,
skillLoadErrors: fullState.skillLoadErrors,
agentStatus: fullState.agentStatus,
activeWorkflowRunCount: fullState.activeWorkflowRunCount,
terminalActiveCount,
terminalSessionCount,
goal: fullState.goal,
Expand Down Expand Up @@ -2791,6 +2797,7 @@ export class WorkspaceStore {
previous?.lastThinkingLevel !== snapshot?.lastThinkingLevel ||
previous?.recency !== snapshot?.recency ||
previous?.hasTodos !== snapshot?.hasTodos ||
(previous?.activeWorkflowRunCount ?? 0) !== (snapshot?.activeWorkflowRunCount ?? 0) ||
!areAgentStatusesEqual(previous?.displayStatus, snapshot?.displayStatus) ||
!areAgentStatusesEqual(previous?.todoStatus, snapshot?.todoStatus) ||
previous?.goal?.goalId !== snapshot?.goal?.goalId ||
Expand Down
1 change: 1 addition & 0 deletions src/browser/utils/commands/sources.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -536,6 +536,7 @@ function makeWorkspaceState(goal: WorkspaceState["goal"]): WorkspaceState {
loadedSkills: [],
skillLoadErrors: [],
agentStatus: undefined,
activeWorkflowRunCount: 0,
lastAbortReason: null,
pendingStreamStartTime: null,
pendingStreamModel: null,
Expand Down
4 changes: 4 additions & 0 deletions src/common/orpc/schemas/workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -375,6 +375,10 @@ export const WorkspaceActivitySnapshotSchema = z.object({
isIdleCompaction: z.boolean().optional().meta({
description: "Whether the current streaming activity is an idle (background) compaction",
}),
activeWorkflowRunCount: z.number().int().nonnegative().optional().meta({
description:
"Number of top-level workflow runs in this workspace that are pending, running, or backgrounded.",
}),
goal: GoalSnapshotSchema.nullable().optional().meta({
description: "Current workspace goal snapshot for sidebar indicators and the Goal tab",
}),
Expand Down
16 changes: 15 additions & 1 deletion src/common/types/workflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,21 @@ export type WorkflowStepRecord = z.infer<typeof WorkflowStepRecordSchema>;
export type WorkflowRunParent = z.infer<typeof WorkflowRunParentSchema>;
export type WorkflowRunRecord = z.infer<typeof WorkflowRunRecordSchema>;

export function isNestedWorkflowRun(run: WorkflowRunRecord): boolean {
const ACTIVE_WORKFLOW_RUN_STATUSES = new Set<WorkflowRunStatus>([
"pending",
"running",
"backgrounded",
]);

export function isActiveWorkflowRunStatus(status: WorkflowRunStatus): boolean {
return ACTIVE_WORKFLOW_RUN_STATUSES.has(status);
}

export function isTerminalWorkflowRunStatus(status: WorkflowRunStatus): boolean {
return status === "completed" || status === "failed" || status === "interrupted";
}

export function isNestedWorkflowRun(run: { parentWorkflow?: WorkflowRunParent | null }): boolean {
return run.parentWorkflow != null;
}

Expand Down
Loading
Loading