Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions src/browser/features/RightSidebar/RightSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -674,6 +674,7 @@ const RightSidebarComponent: React.FC<RightSidebarProps> = ({
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.
Expand Down Expand Up @@ -980,6 +981,26 @@ const RightSidebarComponent: React.FC<RightSidebarProps> = ({
});
}, [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);
Expand Down
30 changes: 30 additions & 0 deletions src/browser/features/RightSidebar/Tabs/TabLabels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
Sparkles,
Target,
Terminal as TerminalIcon,
Workflow,
X,
} from "lucide-react";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/browser/components/Tooltip/Tooltip";
Expand Down Expand Up @@ -221,6 +222,35 @@ export const GoalTabLabel: React.FC<GoalTabLabelProps> = ({ 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<WorkflowsTabLabelProps> = ({ workspaceId }) => {
const sidebarState = useOptionalWorkspaceSidebarState(workspaceId);
const activeCount = sidebarState?.activeWorkflowRunCount ?? 0;
return (
<span className="inline-flex items-center gap-1">
<Workflow className="h-3 w-3 shrink-0" />
Workflows
{activeCount > 0 && (
<span
className="text-accent inline-flex items-center gap-1 text-[10px] tabular-nums"
aria-label={`${activeCount} running workflow${activeCount === 1 ? "" : "s"}`}
>
<span className="bg-accent inline-block h-[6px] w-[6px] animate-pulse rounded-full motion-reduce:animate-none" />
{activeCount}
</span>
)}
</span>
);
};

export function OutputTabLabel() {
return <>Output</>;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<WorkspaceSidebarState> = { 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(<WorkflowsTabLabel workspaceId="w1" />);

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(<WorkflowsTabLabel workspaceId="w1" />);

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(<WorkflowsTabLabel workspaceId="w1" />);

expect(getByText("Workflows")).toBeTruthy();
expect(container.querySelector(".text-accent")).toBeNull();
});
});
1 change: 1 addition & 0 deletions src/browser/features/RightSidebar/Tabs/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,5 @@ export {
DebugTabLabel,
DesktopTabLabel,
GoalTabLabel,
WorkflowsTabLabel,
} from "./TabLabels";
9 changes: 9 additions & 0 deletions src/browser/features/RightSidebar/Tabs/tabConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
10 changes: 10 additions & 0 deletions src/browser/features/RightSidebar/Tabs/tabRegistry.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -36,6 +37,7 @@ import {
OutputTabLabel,
ReviewTabLabel,
StatsTabLabel,
WorkflowsTabLabel,
} from "./TabLabels";

export {
Expand Down Expand Up @@ -163,6 +165,14 @@ const TAB_RENDERERS = {
Label: MemoryTabLabel,
renderPanel: (ctx) => <MemoryTab workspaceId={ctx.workspaceId} />,
},
workflows: {
Label: ({ workspaceId }) => <WorkflowsTabLabel workspaceId={workspaceId} />,
renderPanel: (ctx) => (
<ErrorBoundary workspaceInfo="Workflows tab">
<WorkflowsTab workspaceId={ctx.workspaceId} />
</ErrorBoundary>
),
},
desktop: {
Label: DesktopTabLabel,
renderPanel: (ctx) => (
Expand Down
60 changes: 60 additions & 0 deletions src/browser/features/RightSidebar/Workflows/WorkflowBadges.tsx
Original file line number Diff line number Diff line change
@@ -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) => (
<span
className={`inline-block h-[7px] w-[7px] shrink-0 animate-pulse rounded-full motion-reduce:animate-none ${props.className ?? ""}`}
style={{ background: WORKFLOW_TONE_VAR[props.tone ?? "running"] }}
aria-hidden="true"
/>
);

const WorkflowStatusIcon: React.FC<{ status: WorkflowRunStatus; color: string }> = (props) => {
if (props.status === "completed") {
return <Check className="h-3 w-3 shrink-0" style={{ color: props.color }} />;
}
if (props.status === "failed") {
return <X className="h-3 w-3 shrink-0" style={{ color: props.color }} />;
}
if (props.status === "backgrounded" || props.status === "interrupted") {
return <Pause className="h-3 w-3 shrink-0" style={{ color: props.color }} />;
}
return <Circle className="h-3 w-3 shrink-0" style={{ color: props.color }} />;
};

/** Run status pill — colored by status tone; pulses while running. */
export const WorkflowStatusPill: React.FC<{ status: WorkflowRunStatus; pulse?: boolean }> = (
props
) => {
const meta = WORKFLOW_STATUS_META[props.status];
const color = WORKFLOW_TONE_VAR[meta.tone];
const isLive = props.status === "running";
return (
<span
className="inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] font-semibold whitespace-nowrap"
style={{
color,
borderColor: `color-mix(in srgb, ${color} 35%, transparent)`,
background: `color-mix(in srgb, ${color} 12%, transparent)`,
}}
>
{isLive && props.pulse !== false ? (
<WorkflowLiveDot tone={meta.tone} />
) : (
<WorkflowStatusIcon status={props.status} color={color} />
)}
{meta.label}
</span>
);
};

/** Where the workflow script came from. */
export const WorkflowScopeBadge: React.FC<{ scope: WorkflowScriptScope }> = (props) => (
<span className="border-border text-muted rounded border px-1.5 py-px text-[9.5px] font-semibold tracking-wide uppercase">
{props.scope}
</span>
);
Loading
Loading