Skip to content
Open
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
10 changes: 10 additions & 0 deletions src/browser/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,9 @@ function AppInner() {
);

const [isMultiProjectWorkspaceModalOpen, setMultiProjectWorkspaceModalOpen] = useState(false);
const agentBrowserEnabled = useExperimentValue(EXPERIMENT_IDS.AGENT_BROWSER);
const dynamicWorkflowsEnabled = useExperimentValue(EXPERIMENT_IDS.DYNAMIC_WORKFLOWS);
const portableDesktopEnabled = useExperimentValue(EXPERIMENT_IDS.PORTABLE_DESKTOP);
const multiProjectWorkspacesEnabled = useExperimentValue(EXPERIMENT_IDS.MULTI_PROJECT_WORKSPACES);

// Left sidebar is drag-resizable (mirrors RightSidebar). Width is persisted globally;
Expand Down Expand Up @@ -708,6 +711,12 @@ function AppInner() {
[handleNavigateWorkspace]
);

const enabledRightSidebarFeatureFlags = new Set<string>();
if (agentBrowserEnabled) enabledRightSidebarFeatureFlags.add(EXPERIMENT_IDS.AGENT_BROWSER);
if (dynamicWorkflowsEnabled)
enabledRightSidebarFeatureFlags.add(EXPERIMENT_IDS.DYNAMIC_WORKFLOWS);
if (portableDesktopEnabled) enabledRightSidebarFeatureFlags.add(EXPERIMENT_IDS.PORTABLE_DESKTOP);

registerParamsRef.current = {
userProjects,
workspaceMetadata,
Expand All @@ -717,6 +726,7 @@ function AppInner() {
onSetThinkingLevel: setThinkingLevelFromPalette,
getMinThinkingOverride,
onStartWorkspaceCreation: openNewWorkspaceFromPalette,
enabledRightSidebarFeatureFlags,
onStartMultiProjectWorkspaceCreation: openNewMultiProjectWorkspaceFromPalette,
multiProjectWorkspacesEnabled,
onArchiveMergedWorkspacesInProject: archiveMergedWorkspacesInProjectFromPalette,
Expand Down
127 changes: 127 additions & 0 deletions src/browser/components/WorkflowIndicator/WorkflowIndicator.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { cleanup, fireEvent, render } from "@testing-library/react";
import { installDom } from "../../../../tests/ui/dom";

import type { WorkflowDefinitionDescriptor, WorkflowRunRecord } from "@/common/types/workflow";
import { TooltipProvider } from "@/browser/components/Tooltip/Tooltip";
import { WorkflowIndicatorPopoverContent, WorkflowIndicatorView } from "./WorkflowIndicator";
import {
groupWorkflowDefinitionsByScope,
summarizeWorkflowRuns,
} from "@/browser/features/Workflows/workflowStatusPresentation";

function definition(
name: string,
scope: WorkflowDefinitionDescriptor["scope"]
): WorkflowDefinitionDescriptor {
return { name, scope, description: `${name} workflow`, executable: true };
}

function run(overrides: Partial<WorkflowRunRecord>): WorkflowRunRecord {
return {
id: "wfr_test",
workspaceId: "workspace-1",
definition: definition("demo", "project"),
definitionSource: "/repo/.mux/workflows/demo.js",
definitionHash: "hash",
args: {},
status: "running",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
events: [],
steps: [],
...overrides,
};
}

describe("WorkflowIndicatorView", () => {
let cleanupDom: (() => void) | null = null;

beforeEach(() => {
cleanupDom = installDom();
});

afterEach(() => {
cleanup();
cleanupDom?.();
cleanupDom = null;
});

test("prioritizes problem runs over active counts", () => {
const definitions = [definition("project-flow", "project")];
const runs = [
run({ id: "wfr_failed", status: "failed", definition: definitions[0] }),
run({ id: "wfr_running", status: "running" }),
];
const view = render(
<TooltipProvider>
<WorkflowIndicatorView
workspaceId="workspace-1"
snapshot={{
definitions,
definitionGroups: groupWorkflowDefinitionsByScope(definitions),
runs,
currentRuns: runs,
historyRuns: [],
summary: summarizeWorkflowRuns(runs),
isLoading: false,
error: null,
}}
/>
</TooltipProvider>
);

const button = view.getByLabelText("1 workflow needs attention");
expect(button.textContent).toContain("1");
});

test("uses plural grammar for multiple problem runs", () => {
const failed = run({ id: "wfr_failed", status: "failed" });
const interrupted = run({ id: "wfr_interrupted", status: "interrupted" });
const runs = [failed, interrupted];
const view = render(
<TooltipProvider>
<WorkflowIndicatorView
workspaceId="workspace-1"
snapshot={{
definitions: [],
definitionGroups: groupWorkflowDefinitionsByScope([]),
runs,
currentRuns: runs,
historyRuns: [],
summary: summarizeWorkflowRuns(runs),
isLoading: false,
error: null,
}}
/>
</TooltipProvider>
);

expect(view.getByLabelText("2 workflows need attention")).toBeTruthy();
});

test("opens the workflows tab from the popover action", () => {
let opened = false;
const view = render(
<WorkflowIndicatorPopoverContent
onOpenWorkflowsTab={() => {
opened = true;
}}
snapshot={{
definitions: [],
definitionGroups: groupWorkflowDefinitionsByScope([]),
runs: [],
currentRuns: [],
historyRuns: [],
summary: summarizeWorkflowRuns([]),
isLoading: false,
error: null,
}}
/>
);

fireEvent.click(view.getByText("Open tab"));

expect(opened).toBe(true);
});
});
151 changes: 151 additions & 0 deletions src/browser/components/WorkflowIndicator/WorkflowIndicator.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import React from "react";
import { Workflow } from "lucide-react";

import { Popover, PopoverContent, PopoverTrigger } from "@/browser/components/Popover/Popover";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/browser/components/Tooltip/Tooltip";
import { CUSTOM_EVENTS, createCustomEvent } from "@/common/constants/events";
import { cn } from "@/common/lib/utils";

import {
useWorkflowWorkspaceSnapshot,
type WorkflowWorkspaceSnapshot,
} from "@/browser/features/Workflows/WorkflowStore";
import { getWorkflowStatusPresentation } from "@/browser/features/Workflows/workflowStatusPresentation";

interface WorkflowIndicatorProps {
workspaceId: string;
}

interface WorkflowIndicatorViewProps {
workspaceId: string;
snapshot: WorkflowWorkspaceSnapshot;
onOpenWorkflowsTab?: () => void;
}

export function WorkflowIndicator(props: WorkflowIndicatorProps) {
const snapshot = useWorkflowWorkspaceSnapshot(props.workspaceId);
return <WorkflowIndicatorView workspaceId={props.workspaceId} snapshot={snapshot} />;
}

export function WorkflowIndicatorView(props: WorkflowIndicatorViewProps) {
const [open, setOpen] = React.useState(false);
const problemCount = props.snapshot.summary.problemCount;
const activeCount = props.snapshot.summary.activeCount;
const hasProblems = problemCount > 0;
const hasActive = activeCount > 0;
const badgeCount = hasProblems ? problemCount : activeCount;
const label = hasProblems
? `${problemCount} workflow${problemCount === 1 ? "" : "s"} ${problemCount === 1 ? "needs" : "need"} attention`
: hasActive
? `${activeCount} active workflow${activeCount === 1 ? "" : "s"}`
: "Workflows";

const openWorkflowsTab = () => {
setOpen(false);
if (props.onOpenWorkflowsTab) {
props.onOpenWorkflowsTab();
return;
}
window.dispatchEvent(
createCustomEvent(CUSTOM_EVENTS.OPEN_WORKFLOWS_TAB, { workspaceId: props.workspaceId })
);
};

return (
<Popover open={open} onOpenChange={setOpen}>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<button
type="button"
className={cn(
"relative flex h-6 w-6 shrink-0 items-center justify-center rounded text-muted hover:bg-sidebar-hover hover:text-foreground max-[520px]:hidden",
hasProblems && "text-error",
!hasProblems && hasActive && "text-success"
)}
aria-label={label}
>
<Workflow className="h-3.5 w-3.5" />
{(hasProblems || hasActive) && (
<span className="bg-background counter-nums absolute -top-1 -right-1 min-w-3 rounded-full px-0.5 text-[9px] leading-3">
{badgeCount}
</span>
)}
</button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent side="bottom" align="end">
{label}
</TooltipContent>
</Tooltip>
<PopoverContent
side="bottom"
align="end"
className="bg-modal-bg border-separator-light w-72 overflow-visible rounded px-3 py-2 text-xs font-normal shadow-[0_2px_8px_rgba(0,0,0,0.4)]"
>
<WorkflowIndicatorPopoverContent
snapshot={props.snapshot}
onOpenWorkflowsTab={openWorkflowsTab}
/>
</PopoverContent>
</Popover>
);
}

export function WorkflowIndicatorPopoverContent(props: {
snapshot: WorkflowWorkspaceSnapshot;
onOpenWorkflowsTab: () => void;
}) {
return (
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between gap-2">
<div className="text-foreground font-medium">Workflows</div>
<button
type="button"
className="text-accent hover:underline"
onClick={props.onOpenWorkflowsTab}
>
Open tab
</button>
</div>
<IndicatorSection title="Active and attention">
{props.snapshot.currentRuns.length === 0 ? (
<p className="text-muted">No active workflows</p>
) : (
props.snapshot.currentRuns.slice(0, 5).map((run) => {
const presentation = getWorkflowStatusPresentation(run.status);
return (
<div key={run.id} className="flex items-center justify-between gap-2">
<span className="truncate">{run.definition.name}</span>
<span className="text-muted shrink-0">{presentation.label}</span>
</div>
);
})
)}
</IndicatorSection>
<IndicatorSection title="Available definitions">
{props.snapshot.definitions.length === 0 ? (
<p className="text-muted">No definitions found</p>
) : (
<p className="text-muted">
{props.snapshot.definitionGroups.project.length} project Β·{" "}
{props.snapshot.definitionGroups.global.length} global Β·{" "}
{props.snapshot.definitionGroups["built-in"].length} built-in Β·{" "}
{props.snapshot.definitionGroups.scratch.length} scratch
</p>
)}
</IndicatorSection>
</div>
);
}

function IndicatorSection(props: { title: string; children: React.ReactNode }) {
return (
<section className="flex flex-col gap-1">
<h3 className="text-muted text-[11px] font-semibold tracking-wide uppercase">
{props.title}
</h3>
{props.children}
</section>
);
}
3 changes: 3 additions & 0 deletions src/browser/components/WorkspaceMenuBar/WorkspaceMenuBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import { PopoverError } from "../PopoverError/PopoverError";
import { WorkspaceActionsMenuContent } from "../WorkspaceActionsMenuContent/WorkspaceActionsMenuContent";
import { WorkspaceTerminalIcon } from "../icons/WorkspaceTerminalIcon/WorkspaceTerminalIcon";

import { WorkflowIndicator } from "../WorkflowIndicator/WorkflowIndicator";
import { SkillIndicator } from "../SkillIndicator/SkillIndicator";
import { useAPI } from "@/browser/contexts/API";
import { useAgent } from "@/browser/contexts/AgentContext";
Expand Down Expand Up @@ -95,6 +96,7 @@ export const WorkspaceMenuBar: React.FC<WorkspaceMenuBarProps> = ({
const { disableWorkspaceAgents } = useAgent();
const { preflightArchiveWorkspace, archiveWorkspace } = useWorkspaceActions();
const { workspaceMetadata } = useWorkspaceContext();
const dynamicWorkflowsEnabled = useExperimentValue(EXPERIMENT_IDS.DYNAMIC_WORKFLOWS);
const workspaceHeartbeatsEnabled = useExperimentValue(EXPERIMENT_IDS.WORKSPACE_HEARTBEATS);
const linkSharingEnabled = useLinkSharingEnabled();
const openTerminalPopout = useOpenTerminal();
Expand Down Expand Up @@ -683,6 +685,7 @@ export const WorkspaceMenuBar: React.FC<WorkspaceMenuBarProps> = ({
</div>
</PopoverContent>
</Popover>
{dynamicWorkflowsEnabled && <WorkflowIndicator workspaceId={workspaceId} />}
<SkillIndicator
loadedSkills={loadedSkills}
availableSkills={availableSkills}
Expand Down
29 changes: 29 additions & 0 deletions src/browser/features/RightSidebar/RightSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -671,6 +671,7 @@ const RightSidebarComponent: React.FC<RightSidebarProps> = ({
// API for reading config and managing terminal sessions.
const apiState = useAPI();
const api = apiState.api;
const workflowsExperimentEnabled = useExperimentValue(EXPERIMENT_IDS.DYNAMIC_WORKFLOWS);
const desktopExperimentEnabled = useExperimentValue(EXPERIMENT_IDS.PORTABLE_DESKTOP);
const browserExperimentEnabled = useExperimentValue(EXPERIMENT_IDS.AGENT_BROWSER);
// Child task workspaces can't run goal actions β€” backend rejects them
Expand Down Expand Up @@ -983,6 +984,19 @@ const RightSidebarComponent: React.FC<RightSidebarProps> = ({
});
}, [initialActiveTab, setLayoutRaw, isChildWorkspaceForGoal]);

React.useEffect(() => {
if (workflowsExperimentEnabled) {
return;
}

setLayoutRaw((prevRaw) => {
const prev = parseRightSidebarLayoutState(prevRaw, initialActiveTab);
return collectAllTabs(prev.root).includes("workflows")
? removeTabEverywhere(prev, "workflows")
: prev;
});
}, [initialActiveTab, setLayoutRaw, workflowsExperimentEnabled]);

React.useEffect(() => {
if (!desktopExperimentEnabled) {
setDesktopAvailable(false);
Expand Down Expand Up @@ -1109,6 +1123,21 @@ const RightSidebarComponent: React.FC<RightSidebarProps> = ({
return () => window.removeEventListener(CUSTOM_EVENTS.OPEN_GOAL_TAB, handleOpenGoalTab);
}, [setCollapsed, setLayout, workspaceId]);

React.useEffect(() => {
const handleOpenWorkflowsTab = (event: Event) => {
const detail = (event as CustomEvent<{ workspaceId: string }>).detail;
if (detail?.workspaceId !== workspaceId || !workflowsExperimentEnabled) {
return;
}
setCollapsed(false);
setLayout((prev) => selectOrAddTab(prev, "workflows"));
};

window.addEventListener(CUSTOM_EVENTS.OPEN_WORKFLOWS_TAB, handleOpenWorkflowsTab);
return () =>
window.removeEventListener(CUSTOM_EVENTS.OPEN_WORKFLOWS_TAB, handleOpenWorkflowsTab);
}, [setCollapsed, setLayout, workflowsExperimentEnabled, workspaceId]);

React.useEffect(() => {
const handleOpenTouchReviewImmersive = (event: Event) => {
const detail = (event as CustomEvent<{ workspaceId: string }>).detail;
Expand Down
Loading
Loading