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) => ( +