From 9e7c63ef03e0ec722a576e8f8acba53ab685770f Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Wed, 24 Jun 2026 13:48:08 +0200 Subject: [PATCH] feat: add Workflows right-sidebar tab with live run streaming Implement the "Workflows Tab" design (imported from claude.ai/design) so a workspace's durable workflow runs get a dedicated, live observation surface in the right sidebar instead of relying on the large in-chat workflow tool card. Backend - workflows.subscribe: push stream (initial snapshot + per-write deltas) of a workspace's top-level runs. WorkflowService is constructed per oRPC request, so it can't be the singleton event source a subscription needs; instead every run mutation already flows through WorkflowRunStore.writeRunFile, which now notifies a module-level workflowRunStreamHub that the subscribe handler listens to. This decouples "who runs the workflow" (chat tool, CLI, background, crash recovery) from "who is watching it". - workflows.listScripts: enumerates runnable workflow scripts (built-in + project + global skills exposing a workflow.js) for the empty-state launcher, reusing the existing resolver/skill discovery and surfacing each script's declared args. Frontend - A pure, unit-tested projector folds the intentionally-lean run record (events + steps; steps carry no phase/title/usage) into the phase->step view the tab renders: phase assignment by event sequence, titles from task events, durations from timestamps, final report/error from events. - Timeline UI: run header (interrupt / resume / retry-from-checkpoint / re-run), phases (collapsed by default so a 20+ step fan-out stays scannable, with expandable phase info objects), expandable steps with per-step output + structured output, run history, and an empty-state launcher with a per-arg input form. The final report is collapsible and renders the full structured output returned to the agent, not just the summary chips. - The tab is gated on the dynamic-workflows experiment (it is that feature's observation surface) and added to the layout via the same per-experiment effect the memory/browser tabs use, with a live active-run count badge on the label. When the tab is available, the in-chat workflow tool card auto-collapses by default (any status); without it the prior behavior (auto-collapse completed runs only) is preserved, so behavior and the existing tests are unchanged when the experiment is off. Deferred deliberately: per-step token/cost (sub-agent usage rolls up into the parent workspace and isn't reliably queryable by task once a step completes) and converting a live foreground run to backgrounded (needs runner detach support; Interrupt covers the stop case). The projector/UI already accept a usage overlay for when per-step usage capture lands. Change-Id: I2bc47dc9c850af7b7d232c36aea18c4464fbdb78 Signed-off-by: Thomas Kosiewski --- .../features/RightSidebar/RightSidebar.tsx | 21 + .../features/RightSidebar/Tabs/TabLabels.tsx | 30 + .../Tabs/TabLabels.workflows.test.tsx | 67 +++ .../features/RightSidebar/Tabs/index.ts | 1 + .../features/RightSidebar/Tabs/tabConfig.ts | 9 + .../RightSidebar/Tabs/tabRegistry.tsx | 10 + .../RightSidebar/Workflows/WorkflowBadges.tsx | 60 ++ .../Workflows/WorkflowEmptyState.tsx | 242 +++++++++ .../Workflows/WorkflowRunHeader.tsx | 187 +++++++ .../Workflows/WorkflowRunHistory.tsx | 72 +++ .../Workflows/WorkflowTimeline.tsx | 379 +++++++++++++ .../RightSidebar/Workflows/WorkflowsTab.tsx | 139 +++++ .../Workflows/projectWorkflowRun.test.ts | 513 ++++++++++++++++++ .../Workflows/projectWorkflowRun.ts | 392 +++++++++++++ .../RightSidebar/Workflows/useWorkflowRuns.ts | 99 ++++ .../Workflows/workflowDisplay.test.ts | 101 ++++ .../RightSidebar/Workflows/workflowDisplay.ts | 144 +++++ .../Tools/WorkflowRunToolCall.test.tsx | 8 + .../features/Tools/WorkflowRunToolCall.tsx | 17 +- src/common/orpc/schemas.ts | 2 + src/common/orpc/schemas/api.ts | 16 +- src/common/orpc/schemas/workflow.ts | 23 + src/common/types/workflow.ts | 4 + src/node/orpc/router.ts | 101 ++++ .../services/workflows/WorkflowRunStore.ts | 5 + .../services/workflows/WorkflowService.ts | 4 +- .../workflows/workflowRunStreamHub.ts | 43 ++ .../workflows/workflowScriptDiscovery.ts | 78 +++ 28 files changed, 2760 insertions(+), 7 deletions(-) create mode 100644 src/browser/features/RightSidebar/Tabs/TabLabels.workflows.test.tsx create mode 100644 src/browser/features/RightSidebar/Workflows/WorkflowBadges.tsx create mode 100644 src/browser/features/RightSidebar/Workflows/WorkflowEmptyState.tsx create mode 100644 src/browser/features/RightSidebar/Workflows/WorkflowRunHeader.tsx create mode 100644 src/browser/features/RightSidebar/Workflows/WorkflowRunHistory.tsx create mode 100644 src/browser/features/RightSidebar/Workflows/WorkflowTimeline.tsx create mode 100644 src/browser/features/RightSidebar/Workflows/WorkflowsTab.tsx create mode 100644 src/browser/features/RightSidebar/Workflows/projectWorkflowRun.test.ts create mode 100644 src/browser/features/RightSidebar/Workflows/projectWorkflowRun.ts create mode 100644 src/browser/features/RightSidebar/Workflows/useWorkflowRuns.ts create mode 100644 src/browser/features/RightSidebar/Workflows/workflowDisplay.test.ts create mode 100644 src/browser/features/RightSidebar/Workflows/workflowDisplay.ts create mode 100644 src/node/services/workflows/workflowRunStreamHub.ts create mode 100644 src/node/services/workflows/workflowScriptDiscovery.ts diff --git a/src/browser/features/RightSidebar/RightSidebar.tsx b/src/browser/features/RightSidebar/RightSidebar.tsx index bb1f03db5e..2845d0c608 100644 --- a/src/browser/features/RightSidebar/RightSidebar.tsx +++ b/src/browser/features/RightSidebar/RightSidebar.tsx @@ -674,6 +674,7 @@ const RightSidebarComponent: React.FC = ({ const desktopExperimentEnabled = useExperimentValue(EXPERIMENT_IDS.PORTABLE_DESKTOP); const browserExperimentEnabled = useExperimentValue(EXPERIMENT_IDS.AGENT_BROWSER); const memoryExperimentEnabled = useExperimentValue(EXPERIMENT_IDS.MEMORY); + const workflowsExperimentEnabled = useExperimentValue(EXPERIMENT_IDS.DYNAMIC_WORKFLOWS); // Child task workspaces can't run goal actions — backend rejects them // via `WorkspaceGoalService.assertParentWorkspace`. We use this flag // both to hide the Goal tab below and to gate any inline goal UX. @@ -980,6 +981,26 @@ const RightSidebarComponent: React.FC = ({ }); }, [memoryExperimentEnabled, initialActiveTab, setLayoutRaw]); + // Workflows tab follows the dynamic-workflows experiment (same shape as the memory tab): + // experimental tabs are added/removed from the persisted layout here, not via tabConfig's + // featureFlag (which only filters the Add-Tool picker / command palette). + React.useEffect(() => { + setLayoutRaw((prevRaw) => { + const prev = parseRightSidebarLayoutState(prevRaw, initialActiveTab); + const hasWorkflows = collectAllTabs(prev.root).includes("workflows"); + + if (workflowsExperimentEnabled && !hasWorkflows) { + return addTabToFocusedTabset(prev, "workflows", false); + } + + if (!workflowsExperimentEnabled && hasWorkflows) { + return removeTabEverywhere(prev, "workflows"); + } + + return prev; + }); + }, [workflowsExperimentEnabled, initialActiveTab, setLayoutRaw]); + React.useEffect(() => { setLayoutRaw((prevRaw) => { const prev = parseRightSidebarLayoutState(prevRaw, initialActiveTab); diff --git a/src/browser/features/RightSidebar/Tabs/TabLabels.tsx b/src/browser/features/RightSidebar/Tabs/TabLabels.tsx index 333ce8fdd9..5bf7821259 100644 --- a/src/browser/features/RightSidebar/Tabs/TabLabels.tsx +++ b/src/browser/features/RightSidebar/Tabs/TabLabels.tsx @@ -16,6 +16,7 @@ import { Sparkles, Target, Terminal as TerminalIcon, + Workflow, X, } from "lucide-react"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/browser/components/Tooltip/Tooltip"; @@ -221,6 +222,35 @@ export const GoalTabLabel: React.FC = ({ workspaceId }) => { ); }; +interface WorkflowsTabLabelProps { + workspaceId: string; +} + +/** + * Workflows tab label. Subscribes to the workspace's sidebar state to surface a + * pulsing accent count of currently-active (pending/running/backgrounded) runs, + * mirroring how the Goal/Stats labels expose live workspace signals. + */ +export const WorkflowsTabLabel: React.FC = ({ workspaceId }) => { + const sidebarState = useOptionalWorkspaceSidebarState(workspaceId); + const activeCount = sidebarState?.activeWorkflowRunCount ?? 0; + return ( + + + Workflows + {activeCount > 0 && ( + + + {activeCount} + + )} + + ); +}; + export function OutputTabLabel() { return <>Output; } diff --git a/src/browser/features/RightSidebar/Tabs/TabLabels.workflows.test.tsx b/src/browser/features/RightSidebar/Tabs/TabLabels.workflows.test.tsx new file mode 100644 index 0000000000..6111651391 --- /dev/null +++ b/src/browser/features/RightSidebar/Tabs/TabLabels.workflows.test.tsx @@ -0,0 +1,67 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { cleanup, render } from "@testing-library/react"; + +import { installDom } from "../../../../../tests/ui/dom"; +import type { WorkspaceSidebarState } from "@/browser/stores/WorkspaceStore"; + +// The label subscribes to the workspace sidebar store to surface a live count +// of active workflow runs. Mock the hook so these tests stay focused on the +// badge's gating without a real workspace store. +let mockedSidebarState: WorkspaceSidebarState | null = null; +void mock.module("@/browser/stores/WorkspaceStore", () => ({ + useOptionalWorkspaceSidebarState: () => mockedSidebarState, + useWorkspaceUsage: () => ({ sessionTotal: null, liveCostUsage: null }), +})); + +import { WorkflowsTabLabel } from "./TabLabels"; + +function makeSidebarState(activeWorkflowRunCount: number): WorkspaceSidebarState { + // Only activeWorkflowRunCount matters for this label; keep the fixture narrow. + const state: Partial = { activeWorkflowRunCount }; + return state as WorkspaceSidebarState; +} + +describe("WorkflowsTabLabel", () => { + let cleanupDom: (() => void) | null = null; + + beforeEach(() => { + cleanupDom = installDom(); + mockedSidebarState = null; + }); + + afterEach(() => { + cleanup(); + cleanupDom?.(); + cleanupDom = null; + mockedSidebarState = null; + }); + + test("shows no count badge when there are no active runs", () => { + mockedSidebarState = makeSidebarState(0); + + const { container, getByText } = render(); + + expect(getByText("Workflows")).toBeTruthy(); + // No active-run accent when the workspace has nothing running. + expect(container.querySelector(".text-accent")).toBeNull(); + }); + + test("shows the active-run count badge when runs are in flight", () => { + mockedSidebarState = makeSidebarState(3); + + const { container, getByText } = render(); + + const badge = container.querySelector(".text-accent"); + expect(badge).not.toBeNull(); + expect(getByText("3")).toBeTruthy(); + }); + + test("renders without a badge when sidebar state is unavailable", () => { + mockedSidebarState = null; + + const { container, getByText } = render(); + + expect(getByText("Workflows")).toBeTruthy(); + expect(container.querySelector(".text-accent")).toBeNull(); + }); +}); diff --git a/src/browser/features/RightSidebar/Tabs/index.ts b/src/browser/features/RightSidebar/Tabs/index.ts index 0f101f7649..1a6ce85b47 100644 --- a/src/browser/features/RightSidebar/Tabs/index.ts +++ b/src/browser/features/RightSidebar/Tabs/index.ts @@ -34,4 +34,5 @@ export { DebugTabLabel, DesktopTabLabel, GoalTabLabel, + WorkflowsTabLabel, } from "./TabLabels"; diff --git a/src/browser/features/RightSidebar/Tabs/tabConfig.ts b/src/browser/features/RightSidebar/Tabs/tabConfig.ts index 7d99ad771d..0d22e926cd 100644 --- a/src/browser/features/RightSidebar/Tabs/tabConfig.ts +++ b/src/browser/features/RightSidebar/Tabs/tabConfig.ts @@ -53,6 +53,15 @@ const TAB_CONFIG_DEF = { defaultOrder: 35, paletteKeywords: ["goal", "target", "objective"], }, + workflows: { + name: "Workflows", + contentClassName: "overflow-y-auto p-[15px]", + // Gated on the same experiment that enables durable workflows — the tab is + // their observation surface, so a separate flag would just be a second toggle. + featureFlag: EXPERIMENT_IDS.DYNAMIC_WORKFLOWS, + defaultOrder: 36, + paletteKeywords: ["workflow", "workflows", "orchestration", "agents", "run"], + }, memory: { name: "Memory", contentClassName: "overflow-hidden p-0", diff --git a/src/browser/features/RightSidebar/Tabs/tabRegistry.tsx b/src/browser/features/RightSidebar/Tabs/tabRegistry.tsx index fec368c503..4cee5a73c0 100644 --- a/src/browser/features/RightSidebar/Tabs/tabRegistry.tsx +++ b/src/browser/features/RightSidebar/Tabs/tabRegistry.tsx @@ -23,6 +23,7 @@ import { BrowserTab } from "@/browser/features/RightSidebar/BrowserTab"; import { DevToolsTab } from "@/browser/features/RightSidebar/DevToolsTab"; import { GoalTab, type GoalCreateIntent } from "@/browser/features/RightSidebar/GoalTab"; import { MemoryTab } from "@/browser/features/RightSidebar/Memory/MemoryTab"; +import { WorkflowsTab } from "@/browser/features/RightSidebar/Workflows/WorkflowsTab"; import type { GoalSnapshot, GoalStatus } from "@/common/types/goal"; import type { ReviewNoteData } from "@/common/types/review"; import { BASE_TAB_IDS, TAB_CONFIG, type BaseTabType, type TabConfig } from "./tabConfig"; @@ -36,6 +37,7 @@ import { OutputTabLabel, ReviewTabLabel, StatsTabLabel, + WorkflowsTabLabel, } from "./TabLabels"; export { @@ -163,6 +165,14 @@ const TAB_RENDERERS = { Label: MemoryTabLabel, renderPanel: (ctx) => , }, + workflows: { + Label: ({ workspaceId }) => , + renderPanel: (ctx) => ( + + + + ), + }, desktop: { Label: DesktopTabLabel, renderPanel: (ctx) => ( diff --git a/src/browser/features/RightSidebar/Workflows/WorkflowBadges.tsx b/src/browser/features/RightSidebar/Workflows/WorkflowBadges.tsx new file mode 100644 index 0000000000..59cf23369e --- /dev/null +++ b/src/browser/features/RightSidebar/Workflows/WorkflowBadges.tsx @@ -0,0 +1,60 @@ +import React from "react"; +import { Check, Circle, Pause, X } from "lucide-react"; + +import type { WorkflowRunStatus, WorkflowScriptScope } from "@/common/types/workflow"; +import { WORKFLOW_STATUS_META, WORKFLOW_TONE_VAR, type WorkflowTone } from "./workflowDisplay"; + +/** Small pulsing dot used to mark live / running work. */ +export const WorkflowLiveDot: React.FC<{ tone?: WorkflowTone; className?: string }> = (props) => ( +