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
25 changes: 14 additions & 11 deletions src/browser/components/AgentListItem/AgentListItem.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { ReactNode } from "react";
import { useEffect } from "react";
import { AgentListItem } from "@/browser/components/AgentListItem/AgentListItem";
import { APIProvider } from "@/browser/contexts/API";
import { ProjectProvider } from "@/browser/contexts/ProjectContext";
import { TelemetryEnabledProvider } from "@/browser/contexts/TelemetryEnabledContext";
import { TitleEditProvider } from "@/browser/contexts/WorkspaceTitleEditContext";
import { DndProvider } from "react-dnd";
Expand Down Expand Up @@ -181,17 +182,19 @@ function StoryScaffold(props: {

return (
<APIProvider client={api}>
<TelemetryEnabledProvider>
<TitleEditProvider onUpdateTitle={() => Promise.resolve({ success: true })}>
<TooltipProvider>
<DndProvider backend={HTML5Backend}>
<div className="border-border bg-surface-primary w-[360px] rounded-md border p-2">
<div className={props.rowContainerClassName ?? "space-y-1"}>{props.children}</div>
</div>
</DndProvider>
</TooltipProvider>
</TitleEditProvider>
</TelemetryEnabledProvider>
<ProjectProvider>
<TelemetryEnabledProvider>
<TitleEditProvider onUpdateTitle={() => Promise.resolve({ success: true })}>
<TooltipProvider>
<DndProvider backend={HTML5Backend}>
<div className="border-border bg-surface-primary w-[360px] rounded-md border p-2">
<div className={props.rowContainerClassName ?? "space-y-1"}>{props.children}</div>
</div>
</DndProvider>
</TooltipProvider>
</TitleEditProvider>
</TelemetryEnabledProvider>
</ProjectProvider>
</APIProvider>
);
}
Expand Down
15 changes: 15 additions & 0 deletions src/browser/components/AgentListItem/AgentListItem.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { installDom } from "../../../../tests/ui/dom";
import type * as ReactDndModuleType from "react-dnd";
import type * as ReactDndHtml5BackendModuleType from "react-dnd-html5-backend";
import type * as APIModuleType from "@/browser/contexts/API";
import type * as ProjectContextModuleType from "@/browser/contexts/ProjectContext";
import type * as TelemetryEnabledContextModuleType from "@/browser/contexts/TelemetryEnabledContext";
import type * as WorkspaceTitleEditContextModuleType from "@/browser/contexts/WorkspaceTitleEditContext";
import type * as ContextMenuPositionModuleType from "@/browser/hooks/useContextMenuPosition";
Expand Down Expand Up @@ -133,6 +134,8 @@ function installAgentListItemTestDoubles() {
const actualReactDndHtml5Backend =
require("react-dnd-html5-backend?real=1") as typeof ReactDndHtml5BackendModuleType;
const actualApi = require("@/browser/contexts/API?real=1") as typeof APIModuleType;
const actualProjectContext =
require("@/browser/contexts/ProjectContext?real=1") as typeof ProjectContextModuleType;
const actualTelemetryEnabledContext =
require("@/browser/contexts/TelemetryEnabledContext?real=1") as typeof TelemetryEnabledContextModuleType;
const actualWorkspaceTitleEditContext =
Expand Down Expand Up @@ -172,6 +175,14 @@ function installAgentListItemTestDoubles() {
}),
}));

void mock.module("@/browser/contexts/ProjectContext", () => ({
...actualProjectContext,
useProjectContext: () => ({
getProjectConfig: () => undefined,
userProjects: new Map(),
}),
}));

void mock.module("@/browser/contexts/TelemetryEnabledContext", () => ({
...actualTelemetryEnabledContext,
useLinkSharingEnabled: () => false,
Expand All @@ -193,6 +204,10 @@ function installAgentListItemTestDoubles() {
WorkspaceHeartbeatModal: () => null,
}));

void mock.module("../AutomationModal", () => ({
AutomationModal: () => null,
}));

void mock.module("@/browser/hooks/useContextMenuPosition", () => ({
...actualContextMenuPosition,
useContextMenuPosition: () => ({
Expand Down
39 changes: 39 additions & 0 deletions src/browser/components/AgentListItem/AgentListItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,11 @@ import { useLinkSharingEnabled } from "@/browser/contexts/TelemetryEnabledContex
import { formatKeybind, KEYBINDS } from "@/browser/utils/ui/keybinds";
import { ShareTranscriptDialog } from "../ShareTranscriptDialog/ShareTranscriptDialog";
import { WorkspaceHeartbeatModal } from "../WorkspaceHeartbeatModal";
import { AutomationModal } from "../AutomationModal";
import { WorkspaceActionsMenuContent } from "../WorkspaceActionsMenuContent/WorkspaceActionsMenuContent";
import { useAPI } from "@/browser/contexts/API";
import { useProjectContext } from "@/browser/contexts/ProjectContext";
import { getExistingWorkspaceProjectWorkflowScheduleMatch } from "@/browser/utils/projectWorkflowSchedules";

export interface WorkspaceSelection {
projectPath: string;
Expand Down Expand Up @@ -432,6 +435,7 @@ function RegularAgentListItemInner(props: AgentListItemProps) {

// Destructure metadata for convenience
const { id: workspaceId, namedWorkspacePath } = metadata;
const dynamicWorkflowsEnabled = useExperimentValue(EXPERIMENT_IDS.DYNAMIC_WORKFLOWS);
const workspaceHeartbeatsEnabled = useExperimentValue(EXPERIMENT_IDS.WORKSPACE_HEARTBEATS);
const isInitializing = metadata.isInitializing === true;
const isRemoving = isRemovingProp === true || metadata.isRemoving === true;
Expand Down Expand Up @@ -485,9 +489,30 @@ function RegularAgentListItemInner(props: AgentListItemProps) {
? `${groupLabel} · ${workspaceTitle}`
: workspaceTitle;
const isEditing = editingWorkspaceId === workspaceId;
const { getProjectConfig, userProjects } = useProjectContext();
const metadataSubProjectConfig =
metadata.subProjectPath != null ? getProjectConfig(metadata.subProjectPath) : undefined;
const sectionProjectConfig = sectionId != null ? getProjectConfig(sectionId) : undefined;
const automationProjectPath =
metadata.subProjectPath != null && metadataSubProjectConfig?.parentProjectPath != null
? metadata.subProjectPath
: sectionId != null && sectionProjectConfig?.parentProjectPath != null
? sectionId
: projectPath;
const projectConfig = getProjectConfig(automationProjectPath);
const projectWorkflowScheduleMatch = getExistingWorkspaceProjectWorkflowScheduleMatch({
projectPath: automationProjectPath,
projectConfig,
userProjects,
workspaceId,
});
const projectWorkflowSchedule = projectWorkflowScheduleMatch?.schedule;
const automationScheduleProjectPath =
projectWorkflowScheduleMatch?.projectPath ?? automationProjectPath;

const linkSharingEnabled = useLinkSharingEnabled();
const [shareTranscriptOpen, setShareTranscriptOpen] = useState(false);
const [automationModalOpen, setAutomationModalOpen] = useState(false);
const [heartbeatModalOpen, setHeartbeatModalOpen] = useState(false);
const overflowMenuButtonRef = useRef<HTMLButtonElement | null>(null);
const overflowMenuFrameRef = useRef<number | null>(null);
Expand Down Expand Up @@ -931,6 +956,9 @@ function RegularAgentListItemInner(props: AgentListItemProps) {
onConfigureHeartbeat={
workspaceHeartbeatsEnabled ? () => setHeartbeatModalOpen(true) : null
}
onConfigureAutomation={
dynamicWorkflowsEnabled ? () => setAutomationModalOpen(true) : null
}
onStopRuntime={
isRuntimeRunning && onStopRuntime
? () =>
Expand Down Expand Up @@ -996,6 +1024,17 @@ function RegularAgentListItemInner(props: AgentListItemProps) {
/>
</PopoverContent>
</Popover>
{dynamicWorkflowsEnabled && automationModalOpen && (
<AutomationModal
projectPath={automationScheduleProjectPath}
workspaceId={workspaceId}
workspaceName={displayTitle}
workspaceWorkflowSchedule={metadata.workflowSchedule}
projectWorkflowSchedule={projectWorkflowSchedule}
open={automationModalOpen}
onOpenChange={setAutomationModalOpen}
/>
)}
{workspaceHeartbeatsEnabled && (
<WorkspaceHeartbeatModal
workspaceId={workspaceId}
Expand Down
143 changes: 143 additions & 0 deletions src/browser/components/AutomationModal/AutomationModal.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import { useRef } from "react";
import type { FC, ReactNode } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, within } from "@storybook/test";

import { APIProvider, type APIClient } from "@/browser/contexts/API";
import { ProjectProvider } from "@/browser/contexts/ProjectContext";
import { StoryUiShell } from "@/browser/stories/meta";
import type { ProjectWorkflowSchedule } from "@/common/types/project";
import type { WorkflowDefinitionDescriptor } from "@/common/types/workflow";
import { AutomationModal } from "./AutomationModal";

const PROJECT_PATH = "/Users/test/mux";
const WORKSPACE_ID = "triage-control";
const WORKSPACE_NAME = "Triage control";

const WORKFLOW_DEFINITIONS: WorkflowDefinitionDescriptor[] = [
{
name: "triage-github-issues",
description: "Scan untriaged GitHub issues and create triage workspaces.",
scope: "project",
sourcePath: `${PROJECT_PATH}/.mux/workflows/triage-github-issues.js`,
executable: true,
},
{
name: "daily-maintenance",
description: "Run daily repository maintenance checks.",
scope: "global",
sourcePath: "/Users/test/.mux/workflows/daily-maintenance.js",
executable: true,
},
{
name: "blocked-project-workflow",
description: "A trusted-project-only workflow that should not be selectable.",
scope: "project",
sourcePath: `${PROJECT_PATH}/.mux/workflows/blocked-project-workflow.js`,
executable: false,
blockedReason: "Trust this project before running project-local workflows.",
},
];

const WORKFLOW_SCHEDULE: ProjectWorkflowSchedule = {
id: "triage-control-schedule",
enabled: true,
workflowName: "triage-github-issues",
intervalMs: 30 * 60_000,
args: { label: "needs-triage" },
target: { type: "existing-workspace", workspaceId: WORKSPACE_ID },
lastRunStartedAt: "2026-06-13T08:00:00.000Z",
};

function createAutomationClient(): APIClient {
return {
workflows: {
listDefinitions: () => Promise.resolve(WORKFLOW_DEFINITIONS),
},
workspace: {
setWorkflowSchedule: () => Promise.resolve({ success: true as const, data: undefined }),
},
projects: {
list: () =>
Promise.resolve([
[
PROJECT_PATH,
{
workspaces: [{ id: WORKSPACE_ID, path: "/tmp/triage-control" }],
workflowSchedules: [WORKFLOW_SCHEDULE],
},
],
]),
workflowSchedules: {
set: () => Promise.resolve({ success: true as const, data: WORKFLOW_SCHEDULE }),
remove: () => Promise.resolve({ success: true as const, data: undefined }),
},
},
} as unknown as APIClient;
}

const AutomationStoryShell: FC<{ children: ReactNode }> = (props) => {
const clientRef = useRef<APIClient | null>(null);
clientRef.current ??= createAutomationClient();

return (
<StoryUiShell>
<APIProvider client={clientRef.current}>
<ProjectProvider>{props.children}</ProjectProvider>
</APIProvider>
</StoryUiShell>
);
};

function renderAutomationModal(): JSX.Element {
return (
<AutomationStoryShell>
<AutomationModal
open={true}
projectPath={PROJECT_PATH}
workspaceId={WORKSPACE_ID}
workspaceName={WORKSPACE_NAME}
projectWorkflowSchedule={WORKFLOW_SCHEDULE}
onOpenChange={() => {
// Keep the story stable while users interact with the form.
}}
/>
</AutomationStoryShell>
);
}

const meta: Meta<typeof AutomationModal> = {
title: "Components/AutomationModal",
component: AutomationModal,
parameters: {
layout: "fullscreen",
chromatic: { delay: 500 },
},
};

export default meta;

type Story = StoryObj<typeof meta>;

export const Default: Story = {
render: renderAutomationModal,
play: async ({ canvasElement }) => {
const canvas = within(canvasElement.ownerDocument.body);
await expect(canvas.findByText("Automation for Triage control")).resolves.toBeInTheDocument();
await expect(canvas.findByLabelText("Automation workflow")).resolves.toBeInTheDocument();
},
};

export const Mobile: Story = {
render: renderAutomationModal,
globals: { viewport: { value: "mobile1", isRotated: false } },
parameters: {
// Pinned mobile mode so Chromatic snapshots the responsive modal controls at phone width.
chromatic: { modes: { "dark-mobile": { theme: "dark", viewport: "mobile1", hasTouch: true } } },
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement.ownerDocument.body);
await expect(canvas.findByText("Automation for Triage control")).resolves.toBeInTheDocument();
await expect(canvas.findByLabelText("Automation args")).resolves.toBeInTheDocument();
},
};
Loading
Loading