feat(editor): highlight run-blocking blocks and gate the Run CTA (SKY-10569) (#7139)

This commit is contained in:
Aaron Perez 2026-07-06 17:10:22 -05:00 committed by GitHub
parent d723de621d
commit ba2ec32e15
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 779 additions and 74 deletions

View file

@ -58,12 +58,7 @@ import { parseHeaderJson } from "@/util/secretHeaders";
import { MAX_SCREENSHOT_SCROLLS_DEFAULT } from "./editor/nodes/Taskv2Node/types";
import { getLabelForWorkflowParameterType } from "./editor/workflowEditorUtils";
import {
WorkflowApiResponse,
WorkflowBlock,
WorkflowParameter,
isNestedLoopWorkflowBlock,
} from "./types/workflowTypes";
import { WorkflowApiResponse, WorkflowParameter } from "./types/workflowTypes";
import { WorkflowParameterInput } from "./WorkflowParameterInput";
import { BrowserProfileSelector } from "./components/BrowserProfileSelector";
import { TestWebhookDialog } from "@/components/TestWebhookDialog";
@ -72,31 +67,7 @@ import {
parseJsonWorkflowParameterValue,
validateJsonWorkflowParameterValue,
} from "./utils";
/**
* Recursively finds all login blocks that don't have any credential parameters selected.
* Checks nested blocks inside for_loop blocks as well.
*/
function getLoginBlocksWithoutCredentials(
blocks: Array<WorkflowBlock>,
): Array<{ label: string }> {
const result: Array<{ label: string }> = [];
for (const block of blocks) {
if (block.block_type === "login") {
// Login block requires at least one parameter (credential) to be selected
if (!block.parameters || block.parameters.length === 0) {
result.push({ label: block.label });
}
}
if (isNestedLoopWorkflowBlock(block) && block.loop_blocks) {
result.push(...getLoginBlocksWithoutCredentials(block.loop_blocks));
}
}
return result;
}
import { getLoginBlocksWithoutCredentials } from "./runValidation";
/**
* Validates the workflow for issues that would prevent it from running.

View file

@ -116,6 +116,7 @@ import { ZoomInControl } from "./controls/ZoomInControl";
import { ZoomOutControl } from "./controls/ZoomOutControl";
import { blockTypeFromNode } from "./nodes/blockTypeFromNode";
import { OPEN_WORKFLOW_SETTINGS_EVENT } from "./nodes/StartNode/types";
import { useLocateBlockStore } from "./runValidation/useLocateBlockStore";
import {
ParametersState,
parameterIsSkyvernCredential,
@ -636,6 +637,54 @@ function FlowRenderer({
};
}, [centerOffsetX, reactFlowInstance]);
// Locate runs under fitViewInProgressRef so the pan constraint can't clobber the move; read-only canvases ignore it.
const locateRequest = useLocateBlockStore((state) => state.request);
const clearLocate = useLocateBlockStore((state) => state.clearLocate);
const locateGuardTimerRef = useRef<number | null>(null);
useEffect(() => {
if (readOnly || !locateRequest) {
return;
}
const { nodeId } = locateRequest;
if (!reactFlowInstance.getNode(nodeId)) {
clearLocate();
return;
}
const LOCATE_PAN_DURATION_MS = 500;
const LOCATE_GUARD_BUFFER_MS = 50;
setSelectedBlockId(nodeId);
fitViewInProgressRef.current = true;
reactFlowInstance.fitView({
nodes: [{ id: nodeId }],
maxZoom: 1,
duration: LOCATE_PAN_DURATION_MS,
});
// Deliberately not cleared in this effect's cleanup: clearLocate() re-runs the effect, which would drop the guard mid-pan.
if (locateGuardTimerRef.current !== null) {
window.clearTimeout(locateGuardTimerRef.current);
}
locateGuardTimerRef.current = window.setTimeout(() => {
fitViewInProgressRef.current = false;
locateGuardTimerRef.current = null;
}, LOCATE_PAN_DURATION_MS + LOCATE_GUARD_BUFFER_MS);
clearLocate();
}, [
locateRequest,
readOnly,
reactFlowInstance,
setSelectedBlockId,
clearLocate,
]);
useEffect(() => {
return () => {
if (locateGuardTimerRef.current !== null) {
window.clearTimeout(locateGuardTimerRef.current);
}
clearLocate();
};
}, [clearLocate]);
// Canvas zoom + fit-view keyboard shortcuts. Gated to non-read-only
// canvases for the same reason as the Escape handler above. Skip when
// the user is typing in any text field so Cmd+- in a textarea still

View file

@ -36,6 +36,8 @@ import { EditorOverflowMenu } from "./header/EditorOverflowMenu";
import { useIsGeneratingCode } from "./hooks/useIsGeneratingCode";
import { useSaveWorkflow } from "./hooks/useSaveWorkflow";
import { useToggleCodeView } from "./hooks/useToggleCodeView";
import { getRunBlockingTooltipText } from "./runValidation/runBlockingCopy";
import { useRunValidationStore } from "./runValidation/useRunValidationStore";
import { useWorkflowHeaderCollapseStore } from "./useWorkflowHeaderCollapseStore";
import { WorkflowHeaderCollapseTab } from "./WorkflowHeaderCollapseTab";
@ -202,18 +204,44 @@ function RunButton() {
const { workflowPermanentId } = useParams();
const closeWorkflowPanel = useWorkflowPanelStore((s) => s.closeWorkflowPanel);
const isRecording = useRecordingStore().isRecording;
const blockingBlocks = useRunValidationStore((s) => s.blockingBlocks);
const hasBlockingBlocks = blockingBlocks.length > 0;
const handleClick = () => {
closeWorkflowPanel();
navigate(`/agents/${workflowPermanentId}/run`);
};
return (
<Button disabled={isRecording} size="lg" onClick={handleClick}>
const button = (
<Button
disabled={isRecording || hasBlockingBlocks}
size="lg"
onClick={handleClick}
>
<PlayIcon className="mr-2 h-6 w-6" />
Run
</Button>
);
if (!hasBlockingBlocks) {
return button;
}
return (
<TooltipProvider>
<Tooltip>
{/* Disabled buttons swallow pointer events; the focusable span keeps the tooltip reachable. */}
<TooltipTrigger asChild>
<span tabIndex={0} className="inline-flex">
{button}
</span>
</TooltipTrigger>
<TooltipContent className="max-w-xs">
{getRunBlockingTooltipText(blockingBlocks)}
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
function EditorActionToolbar() {

View file

@ -152,6 +152,16 @@ import {
} from "./workflowEditorUtils";
import { replayPersistedCollapseVisibility } from "./collapse/applyDescendantCollapseVisibility";
import { useNodeCollapseStore } from "./collapse/useNodeCollapseStore";
import { useSyncRunValidationStore } from "./runValidation/useSyncRunValidationStore";
import {
RUN_BLOCKING_SURFACE_TOP,
RUN_BLOCKING_SURFACE_TOP_VAR,
RunBlockingSurface,
WORKFLOW_EDITOR_HEADER_HEIGHT,
WORKFLOW_EDITOR_HEADER_HEIGHT_VAR,
WORKFLOW_EDITOR_HEADER_TOP,
WORKFLOW_EDITOR_HEADER_TOP_VAR,
} from "./runValidation/RunBlockingSurface";
import {
BLOCK_SIDEBAR_WIDTH_VAR,
HEADER_RIGHT_INSET_CLOSED,
@ -436,6 +446,7 @@ function Workspace({
const postHog = usePostHog();
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
useSyncRunValidationStore(nodes);
const {
undo: undoWorkflowEdit,
redo: redoWorkflowEdit,
@ -1897,6 +1908,9 @@ function Workspace({
[BLOCK_SIDEBAR_WIDTH_VAR]: embedded
? "0px"
: `${renderedBlockSidebarWidth}px`,
[WORKFLOW_EDITOR_HEADER_TOP_VAR]: WORKFLOW_EDITOR_HEADER_TOP,
[WORKFLOW_EDITOR_HEADER_HEIGHT_VAR]: WORKFLOW_EDITOR_HEADER_HEIGHT,
[RUN_BLOCKING_SURFACE_TOP_VAR]: RUN_BLOCKING_SURFACE_TOP,
} as React.CSSProperties
}
>
@ -1986,7 +2000,8 @@ function Workspace({
{!embedded && (
<div
className={cn(
"absolute left-6 top-8 z-40 h-20 transition-all duration-300 ease-out",
"absolute left-6 top-[var(--workflow-editor-header-top)] z-40",
"h-[var(--workflow-editor-header-height)] transition-all duration-300 ease-out",
headerEffectiveSidebarOpen
? HEADER_RIGHT_INSET_OPEN
: HEADER_RIGHT_INSET_CLOSED,
@ -2001,6 +2016,8 @@ function Workspace({
</div>
)}
<RunBlockingSurface enabled={!workflowPanelState.data?.showComparison} />
{/* comparison view (takes precedence over both browser and non-browser modes) */}
{workflowPanelState.data?.showComparison &&
workflowPanelState.data?.version1 &&

View file

@ -61,6 +61,7 @@ import { PdfFillNode as PdfFillNodeComponent } from "./PdfFillNode/PdfFillNode";
import { withSortableBlock } from "../sortable/withSortableBlock";
import { withCollapsible } from "../collapse/withCollapsible";
import { withSelectableBlock } from "../selection/withSelectableBlock";
import { withRunValidationHighlight } from "../runValidation/withRunValidationHighlight";
export type UtilityNode = StartNode | NodeAdderNode;
@ -108,12 +109,18 @@ export type AppNode = UtilityNode | WorkflowBlockNode;
// withSortableBlock - registers `useSortable({ id })`; the inner tree
// must mount in both open and collapsed states so
// drag pickup works on either
// withRunValidationHighlight - amber outline + badge around the
// selectable card, inside the drag registration
// withSelectableBlock - reads `useSortable.isDragging` to suppress
// selection on drag pickup
// withCollapsible - leaf wrapper for body chrome
function wrapBlock<P extends NodeProps>(Component: ComponentType<P>) {
return memo(
withSortableBlock(withSelectableBlock(withCollapsible(Component))),
withSortableBlock(
withRunValidationHighlight(
withSelectableBlock(withCollapsible(Component)),
),
),
);
}

View file

@ -0,0 +1,75 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { MemoryRouter } from "react-router-dom";
import { RunBlockingSurface } from "./RunBlockingSurface";
import { useLocateBlockStore } from "./useLocateBlockStore";
import { useRunValidationStore } from "./useRunValidationStore";
function renderAt(search: string) {
return render(
<MemoryRouter initialEntries={[`/workflows/x/build${search}`]}>
<RunBlockingSurface />
</MemoryRouter>,
);
}
describe("RunBlockingSurface", () => {
beforeEach(() => {
useRunValidationStore.getState().setBlockingBlocks([]);
useLocateBlockStore.getState().clearLocate();
});
afterEach(() => {
cleanup();
useRunValidationStore.getState().setBlockingBlocks([]);
useLocateBlockStore.getState().clearLocate();
});
test("renders nothing when no blocks are run-blocking", () => {
const { container } = renderAt("");
expect(container.firstChild).toBeNull();
});
test("renders nothing when disabled", () => {
useRunValidationStore
.getState()
.setBlockingBlocks([{ id: "n2", label: "block_2" }]);
const { container } = render(
<MemoryRouter>
<RunBlockingSurface enabled={false} />
</MemoryRouter>,
);
expect(container.firstChild).toBeNull();
});
test("lists every blocking block", () => {
useRunValidationStore.getState().setBlockingBlocks([
{ id: "n2", label: "block_2" },
{ id: "n3", label: "block_3" },
]);
renderAt("");
expect(screen.getByText("2 blocks need fixing")).toBeTruthy();
expect(screen.getByText("block_2")).toBeTruthy();
expect(screen.getByText("block_3")).toBeTruthy();
});
test("clicking a block requests locate with its node id", () => {
useRunValidationStore
.getState()
.setBlockingBlocks([{ id: "n2", label: "block_2" }]);
renderAt("");
fireEvent.click(screen.getByText("block_2"));
expect(useLocateBlockStore.getState().request?.nodeId).toBe("n2");
});
test("keeps obsolete variant query params on the final panel", () => {
useRunValidationStore
.getState()
.setBlockingBlocks([{ id: "n2", label: "block_2" }]);
renderAt("?rbv=b");
expect(screen.getByText("1 block needs fixing")).toBeTruthy();
expect(screen.getByText("block_2")).toBeTruthy();
});
});

View file

@ -0,0 +1,113 @@
import { Crosshair2Icon, LockClosedIcon } from "@radix-ui/react-icons";
import { cn } from "@/util/utils";
import type { RunBlockingBlock } from "./getRunBlockingBlocks";
import { useLocateBlockStore } from "./useLocateBlockStore";
import { useRunValidationStore } from "./useRunValidationStore";
// The only run-blocking rule today is a login block missing a credential.
const BLOCK_REASON = "Login · needs a credential";
const RUN_BLOCKING_PANEL_GAP_BELOW_HEADER = "1.75rem";
const RUN_BLOCKING_PANEL_WIDTH_CLASS = "w-[19rem]";
export const WORKFLOW_EDITOR_HEADER_TOP_VAR = "--workflow-editor-header-top";
export const WORKFLOW_EDITOR_HEADER_HEIGHT_VAR =
"--workflow-editor-header-height";
export const RUN_BLOCKING_SURFACE_TOP_VAR = "--run-blocking-surface-top";
export const WORKFLOW_EDITOR_HEADER_TOP = "2rem";
export const WORKFLOW_EDITOR_HEADER_HEIGHT = "5rem";
export const RUN_BLOCKING_SURFACE_TOP = `calc(${[
`var(${WORKFLOW_EDITOR_HEADER_TOP_VAR})`,
`var(${WORKFLOW_EDITOR_HEADER_HEIGHT_VAR})`,
RUN_BLOCKING_PANEL_GAP_BELOW_HEADER,
].join(" + ")})`;
type ListProps = {
blocks: Array<RunBlockingBlock>;
onLocate: (nodeId: string) => void;
className?: string;
};
function RunBlockingRow({
block,
onLocate,
}: {
block: RunBlockingBlock;
onLocate: (nodeId: string) => void;
}) {
return (
<button
type="button"
onClick={() => onLocate(block.id)}
className="group flex w-full items-center gap-3 rounded-lg px-2.5 py-2 text-left transition-colors hover:bg-slate-elevation5 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-amber-400"
>
<span className="flex size-7 shrink-0 items-center justify-center rounded-md border border-amber-500/40 bg-amber-500/15 text-amber-400">
<LockClosedIcon className="size-3.5" />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium text-slate-50">
{block.label}
</span>
<span className="block truncate text-xs text-slate-400">
{BLOCK_REASON}
</span>
</span>
<span className="flex shrink-0 items-center gap-1 text-xs text-slate-400 group-hover:text-slate-200">
Locate
<Crosshair2Icon className="size-3.5" />
</span>
</button>
);
}
function RunBlockingList({ blocks, onLocate, className }: ListProps) {
return (
<div className={cn("flex flex-col gap-0.5", className)}>
{blocks.map((block) => (
<RunBlockingRow key={block.id} block={block} onLocate={onLocate} />
))}
</div>
);
}
function blockCountText(count: number): string {
return `${count} block${count === 1 ? "" : "s"} need${count === 1 ? "s" : ""} fixing`;
}
function RunBlockingPanel({ blocks, onLocate }: ListProps) {
return (
<div
className={cn(
"absolute left-6 top-[var(--run-blocking-surface-top,8.75rem)] z-50",
RUN_BLOCKING_PANEL_WIDTH_CLASS,
"rounded-xl border border-slate-700 bg-slate-elevation3 p-3 shadow-2xl",
)}
>
<div className="px-1 pb-2">
<p className="text-sm font-semibold text-slate-100">
{blockCountText(blocks.length)}
</p>
<p className="text-xs text-slate-400">
Resolve these before you can run
</p>
</div>
<RunBlockingList blocks={blocks} onLocate={onLocate} />
</div>
);
}
type Props = {
enabled?: boolean;
};
export function RunBlockingSurface({ enabled = true }: Props = {}) {
const blocks = useRunValidationStore((state) => state.blockingBlocks);
const locate = useLocateBlockStore((state) => state.requestLocate);
if (!enabled || blocks.length === 0) {
return null;
}
return <RunBlockingPanel blocks={blocks} onLocate={locate} />;
}

View file

@ -0,0 +1,57 @@
import { describe, it, expect } from "vitest";
import type { AppNode } from "../nodes";
import { getRunBlockingBlocks } from "./getRunBlockingBlocks";
function loginNode(
id: string,
label: string,
parameterKeys: Array<string>,
): AppNode {
return {
id,
type: "login",
position: { x: 0, y: 0 },
data: { label, parameterKeys },
} as unknown as AppNode;
}
function taskNode(id: string, label: string): AppNode {
return {
id,
type: "task",
position: { x: 0, y: 0 },
data: { label },
} as unknown as AppNode;
}
describe("getRunBlockingBlocks", () => {
it("returns id + label for login blocks with no credential", () => {
expect(getRunBlockingBlocks([loginNode("n1", "block_2", [])])).toEqual([
{ id: "n1", label: "block_2" },
]);
});
it("does not flag login blocks that have a credential", () => {
expect(
getRunBlockingBlocks([loginNode("n1", "block_2", ["cred_param"])]),
).toEqual([]);
});
it("returns every offending login block, preserving node identity", () => {
const nodes = [
loginNode("n1", "block_2", []),
taskNode("n2", "block_1"),
loginNode("n3", "block_3", []),
loginNode("n4", "block_4", ["cred"]),
];
expect(getRunBlockingBlocks(nodes)).toEqual([
{ id: "n1", label: "block_2" },
{ id: "n3", label: "block_3" },
]);
});
it("never flags non-login blocks", () => {
expect(getRunBlockingBlocks([taskNode("n1", "block_1")])).toEqual([]);
});
});

View file

@ -0,0 +1,21 @@
import { isLoginNode } from "../nodes/LoginNode/types";
import type { AppNode } from "../nodes";
import { isLoginBlockMissingCredentials } from "../../runValidation";
export type RunBlockingBlock = { id: string; label: string };
// Login blocks missing a credential block a run (never a save); nested logins appear flat here, so no recursion.
export function getRunBlockingBlocks(
nodes: Array<AppNode>,
): Array<RunBlockingBlock> {
return nodes
.filter(isLoginNode)
.filter((node) =>
isLoginBlockMissingCredentials({
block_type: "login",
label: node.data.label,
parameter_keys: node.data.parameterKeys,
}),
)
.map((node) => ({ id: node.id, label: node.data.label }));
}

View file

@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { getRunBlockingTooltipText } from "./runBlockingCopy";
describe("getRunBlockingTooltipText", () => {
it("describes the empty fallback contract", () => {
expect(getRunBlockingTooltipText([])).toBe(
"Select credentials for login blocks before running.",
);
});
it("describes one blocking login block", () => {
expect(getRunBlockingTooltipText([{ id: "n1", label: "block_2" }])).toBe(
'Select a credential for the login block "block_2" before running.',
);
});
it("lists multiple blocking login blocks", () => {
expect(
getRunBlockingTooltipText([
{ id: "n1", label: "block_2" },
{ id: "n3", label: "block_3" },
]),
).toBe(
"Select credentials for these login blocks before running: block_2, block_3.",
);
});
});

View file

@ -0,0 +1,13 @@
import type { RunBlockingBlock } from "./getRunBlockingBlocks";
export function getRunBlockingTooltipText(
blocks: Array<RunBlockingBlock>,
): string {
if (blocks.length === 0) {
return "Select credentials for login blocks before running.";
}
if (blocks.length === 1) {
return `Select a credential for the login block "${blocks[0]?.label}" before running.`;
}
return `Select credentials for these login blocks before running: ${blocks.map((block) => block.label).join(", ")}.`;
}

View file

@ -0,0 +1,3 @@
// outline (not ring-*) coexists with the blue selection ring; the badge keeps the cue non-color-only.
export const RUN_BLOCKING_OUTLINE_CLASSES =
"outline outline-2 outline-offset-2 outline-amber-500";

View file

@ -0,0 +1,21 @@
import { create } from "zustand";
// Fulfilled by FlowRenderer under its pan-constraint guard, so locate works in pan-locked browser/debug mode.
type LocateRequest = {
nodeId: string;
// Bumped per request so locating the same block twice still re-fires the effect.
nonce: number;
};
type LocateBlockStore = {
request: LocateRequest | null;
requestLocate: (nodeId: string) => void;
clearLocate: () => void;
};
export const useLocateBlockStore = create<LocateBlockStore>((set, get) => ({
request: null,
requestLocate: (nodeId) =>
set({ request: { nodeId, nonce: (get().request?.nonce ?? 0) + 1 } }),
clearLocate: () => set({ request: null }),
}));

View file

@ -0,0 +1,42 @@
import { create } from "zustand";
import type { RunBlockingBlock } from "./getRunBlockingBlocks";
type RunValidationStore = {
/** Blocks that must be fixed before the workflow can run. */
blockingBlocks: Array<RunBlockingBlock>;
blockingBlockIds: ReadonlySet<string>;
setBlockingBlocks: (blocks: Array<RunBlockingBlock>) => void;
};
function blockKey(block: RunBlockingBlock): string {
return `${block.id}:${block.label}`;
}
function sameBlocks(
a: Array<RunBlockingBlock>,
b: Array<RunBlockingBlock>,
): boolean {
if (a.length !== b.length) {
return false;
}
const aKeys = new Set(a.map(blockKey));
return (
aKeys.size === b.length && b.every((block) => aKeys.has(blockKey(block)))
);
}
function blockIdSet(blocks: Array<RunBlockingBlock>): ReadonlySet<string> {
return new Set(blocks.map((block) => block.id));
}
export const useRunValidationStore = create<RunValidationStore>((set, get) => ({
blockingBlocks: [],
blockingBlockIds: new Set(),
setBlockingBlocks: (blocks) => {
if (sameBlocks(get().blockingBlocks, blocks)) {
return;
}
set({ blockingBlocks: blocks, blockingBlockIds: blockIdSet(blocks) });
},
}));

View file

@ -0,0 +1,18 @@
import { useEffect } from "react";
import type { AppNode } from "../nodes";
import { getRunBlockingBlocks } from "./getRunBlockingBlocks";
import { useRunValidationStore } from "./useRunValidationStore";
// Mirrors run-blocking blocks from the live canvas into the shared store; resets on unmount.
export function useSyncRunValidationStore(nodes: Array<AppNode>): void {
const setBlockingBlocks = useRunValidationStore((s) => s.setBlockingBlocks);
useEffect(() => {
setBlockingBlocks(getRunBlockingBlocks(nodes));
}, [nodes, setBlockingBlocks]);
useEffect(() => {
return () => setBlockingBlocks([]);
}, [setBlockingBlocks]);
}

View file

@ -0,0 +1,59 @@
// @vitest-environment jsdom
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import type { NodeProps } from "@xyflow/react";
import { type ComponentType } from "react";
import { withRunValidationHighlight } from "./withRunValidationHighlight";
import { useRunValidationStore } from "./useRunValidationStore";
function NodeBody() {
return <div data-testid="node-body">node</div>;
}
const Wrapped = withRunValidationHighlight(
NodeBody as ComponentType<NodeProps>,
);
function nodeProps(id: string, label: string): NodeProps {
return { id, data: { label } } as unknown as NodeProps;
}
const BADGE_LABEL = /needs a credential/i;
describe("withRunValidationHighlight", () => {
beforeEach(() => {
useRunValidationStore.getState().setBlockingBlocks([]);
});
afterEach(() => {
cleanup();
useRunValidationStore.getState().setBlockingBlocks([]);
});
test("flags a block whose node id is run-blocking", () => {
useRunValidationStore
.getState()
.setBlockingBlocks([{ id: "n1", label: "block_2" }]);
const { container } = render(<Wrapped {...nodeProps("n1", "block_2")} />);
expect(screen.getByLabelText(BADGE_LABEL)).toBeTruthy();
expect(container.querySelector('[data-run-blocking="true"]')).toBeTruthy();
});
test("does not flag a healthy block", () => {
useRunValidationStore
.getState()
.setBlockingBlocks([{ id: "n1", label: "block_2" }]);
const { container } = render(<Wrapped {...nodeProps("n2", "block_1")} />);
expect(screen.queryByLabelText(BADGE_LABEL)).toBeNull();
expect(container.querySelector('[data-run-blocking="true"]')).toBeNull();
});
test("matches on node id, not label", () => {
useRunValidationStore
.getState()
.setBlockingBlocks([{ id: "n1", label: "block_2" }]);
const { container } = render(<Wrapped {...nodeProps("n9", "block_2")} />);
expect(container.querySelector('[data-run-blocking="true"]')).toBeNull();
});
});

View file

@ -0,0 +1,60 @@
import { ExclamationTriangleIcon } from "@radix-ui/react-icons";
import type { NodeProps } from "@xyflow/react";
import type { ComponentType } from "react";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/util/utils";
import { RUN_BLOCKING_OUTLINE_CLASSES } from "./runValidationClasses";
import { useRunValidationStore } from "./useRunValidationStore";
// The wrapper div is always present so toggling the highlight never remounts the node.
function withRunValidationHighlight<P extends NodeProps>(
Component: ComponentType<P>,
): ComponentType<P> {
function RunValidationHighlight(props: P) {
const needsAttention = useRunValidationStore((state) =>
state.blockingBlockIds.has(props.id),
);
return (
<div
data-run-blocking={needsAttention ? "true" : undefined}
className={cn(
"rounded-lg",
needsAttention && ["relative", RUN_BLOCKING_OUTLINE_CLASSES],
)}
>
{needsAttention ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span
tabIndex={0}
className="absolute -right-2.5 -top-2.5 z-20 flex size-6 items-center justify-center rounded-full border border-amber-300 bg-amber-500 text-slate-950 shadow-md"
role="img"
aria-label="This login block needs a credential before it can run"
>
<ExclamationTriangleIcon className="size-3.5" />
</span>
</TooltipTrigger>
<TooltipContent className="max-w-xs">
This login block needs a credential selected before it can run.
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : null}
<Component {...props} />
</div>
);
}
RunValidationHighlight.displayName = `withRunValidationHighlight(${Component.displayName ?? Component.name ?? "Component"})`;
return RunValidationHighlight;
}
export { withRunValidationHighlight };

View file

@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import { getLoginBlocksWithoutCredentials } from "./runValidation";
describe("getLoginBlocksWithoutCredentials", () => {
it("finds persisted login blocks without credential parameters", () => {
expect(
getLoginBlocksWithoutCredentials([
{ block_type: "login", label: "block_1", parameters: [] },
{
block_type: "login",
label: "block_2",
parameters: [{ key: "cred_param" }],
},
]),
).toEqual([{ label: "block_1" }]);
});
it("finds editor login blocks without credential parameter keys", () => {
expect(
getLoginBlocksWithoutCredentials([
{ block_type: "login", label: "block_1", parameter_keys: [] },
{
block_type: "login",
label: "block_2",
parameter_keys: ["cred_param"],
},
]),
).toEqual([{ label: "block_1" }]);
});
it("walks nested loop blocks", () => {
expect(
getLoginBlocksWithoutCredentials([
{
block_type: "for_loop",
label: "loop_1",
loop_blocks: [
{ block_type: "task", label: "block_1" },
{ block_type: "login", label: "block_2", parameters: [] },
],
},
]),
).toEqual([{ label: "block_2" }]);
});
});

View file

@ -0,0 +1,47 @@
export type RunValidationBlock = {
block_type: string;
label: string;
parameters?: Array<unknown> | null;
parameter_keys?: Array<unknown> | null;
loop_blocks?: Array<RunValidationBlock> | null;
};
export type LoginBlockWithoutCredentials = { label: string };
function loginBlockHasCredential(block: RunValidationBlock): boolean {
if ("parameters" in block) {
return (block.parameters?.length ?? 0) > 0;
}
return (block.parameter_keys?.length ?? 0) > 0;
}
export function isLoginBlockMissingCredentials(
block: RunValidationBlock,
): boolean {
return block.block_type === "login" && !loginBlockHasCredential(block);
}
function isNestedLoopRunValidationBlock(block: RunValidationBlock): boolean {
return (
(block.block_type === "for_loop" || block.block_type === "while_loop") &&
Array.isArray(block.loop_blocks)
);
}
export function getLoginBlocksWithoutCredentials(
blocks: Array<RunValidationBlock>,
): Array<LoginBlockWithoutCredentials> {
const result: Array<LoginBlockWithoutCredentials> = [];
for (const block of blocks) {
if (isLoginBlockMissingCredentials(block)) {
result.push({ label: block.label });
}
if (isNestedLoopRunValidationBlock(block)) {
result.push(...getLoginBlocksWithoutCredentials(block.loop_blocks ?? []));
}
}
return result;
}

View file

@ -22,6 +22,12 @@ import {
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { toast } from "@/components/ui/use-toast";
import { useCredentialGetter } from "@/hooks/useCredentialGetter";
import { useRecordingStore } from "@/store/useRecordingStore";
@ -36,6 +42,8 @@ import { EditorOverflowMenu } from "../editor/header/EditorOverflowMenu";
import { MakeACopyButton } from "../editor/MakeACopyButton";
import { useSaveWorkflow } from "../editor/hooks/useSaveWorkflow";
import { useToggleHistoryPanel } from "../editor/hooks/useToggleHistoryPanel";
import { getRunBlockingTooltipText } from "../editor/runValidation/runBlockingCopy";
import { useRunValidationStore } from "../editor/runValidation/useRunValidationStore";
import { useIsGlobalWorkflow } from "../hooks/useIsGlobalWorkflow";
import { useWorkflowRunWithWorkflowQuery } from "../hooks/useWorkflowRunWithWorkflowQuery";
import { runOutcomeFromStatus } from "./runProjections";
@ -140,6 +148,8 @@ export function RunStopButton() {
const queryClient = useQueryClient();
const credentialGetter = useCredentialGetter();
const isRecording = useRecordingStore((s) => s.isRecording);
const blockingBlocks = useRunValidationStore((s) => s.blockingBlocks);
const hasBlockingBlocks = blockingBlocks.length > 0;
const { data: workflowRun } = useWorkflowRunWithWorkflowQuery(
runId ? { workflowRunId: runId } : undefined,
);
@ -185,6 +195,31 @@ export function RunStopButton() {
// form round-trip carries nothing.
`/agents/${workflowPermanentId}/run`,
);
const runButton = (onClick?: () => void) => (
<Button
size="default"
className="h-8 border border-transparent px-3"
disabled={isRecording || hasBlockingBlocks}
onClick={onClick}
>
<PlayIcon className="mr-2 size-4" /> Run agent
</Button>
);
const blockedRunButton = (button: ReactNode) => (
<TooltipProvider>
<Tooltip>
{/* Disabled buttons swallow pointer events; the focusable span keeps the tooltip reachable. */}
<TooltipTrigger asChild>
<span tabIndex={0} className="inline-flex">
{button}
</span>
</TooltipTrigger>
<TooltipContent className="max-w-xs">
{getRunBlockingTooltipText(blockingBlocks)}
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
if (running && activeRunId) {
const stopDialog = (
@ -228,51 +263,46 @@ export function RunStopButton() {
if (!isBlockRun) {
return stopDialog;
}
const blockRunButton = runButton();
return (
<>
{stopDialog}
<Dialog>
<DialogTrigger asChild>
<Button
size="default"
className="h-8 border border-transparent px-3"
disabled={isRecording}
>
<PlayIcon className="mr-2 size-4" /> Run agent
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Start a full run?</DialogTitle>
<DialogDescription>
A block run is still executing. It will keep running you can
watch it in the Browser pane while the Overview pane switches to
the new full run.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose asChild>
<Button variant="secondary">Not now</Button>
</DialogClose>
<DialogClose asChild>
<Button onClick={startFullRun}>Start full run</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
{hasBlockingBlocks ? (
blockedRunButton(blockRunButton)
) : (
<Dialog>
<DialogTrigger asChild>{blockRunButton}</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Start a full run?</DialogTitle>
<DialogDescription>
A block run is still executing. It will keep running you can
watch it in the Browser pane while the Overview pane switches
to the new full run.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose asChild>
<Button variant="secondary">Not now</Button>
</DialogClose>
<DialogClose asChild>
<Button onClick={startFullRun}>Start full run</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
)}
</>
);
}
return (
<Button
size="default"
className="h-8 border border-transparent px-3"
disabled={isRecording}
onClick={startFullRun}
>
<PlayIcon className="mr-2 size-4" /> Run agent
</Button>
);
const primaryRunButton = runButton(startFullRun);
if (!hasBlockingBlocks) {
return primaryRunButton;
}
return blockedRunButton(primaryRunButton);
}
export function StudioTopBar() {