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

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

// Left sidebar is drag-resizable (mirrors RightSidebar). Width is persisted globally;
Expand Down Expand Up @@ -717,6 +718,9 @@ function AppInner() {
onSetThinkingLevel: setThinkingLevelFromPalette,
getMinThinkingOverride,
onStartWorkspaceCreation: openNewWorkspaceFromPalette,
enabledRightSidebarFeatureFlags: dynamicWorkflowsEnabled
? new Set([EXPERIMENT_IDS.DYNAMIC_WORKFLOWS])
: undefined,
Comment thread
ThomasK33 marked this conversation as resolved.
Outdated
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