diff --git a/packages/apollo-react/src/canvas/components/StageNode/AdhocTask.tsx b/packages/apollo-react/src/canvas/components/StageNode/AdhocTask.tsx
index 6eb18a584..abe604ef4 100644
--- a/packages/apollo-react/src/canvas/components/StageNode/AdhocTask.tsx
+++ b/packages/apollo-react/src/canvas/components/StageNode/AdhocTask.tsx
@@ -2,6 +2,7 @@ import { memo, useCallback, useRef } from 'react';
import type { NodeMenuItem } from '../NodeContextMenu';
import { StageTask } from './StageNode.styles';
import type { StageTaskExecution, StageTaskItem } from './StageNode.types';
+import { TaskBreakpointDot } from './TaskBreakpointDot';
import { TaskContent } from './TaskContent';
import { TaskMenu, type TaskMenuHandle } from './TaskMenu';
@@ -50,6 +51,7 @@ const AdhocTaskItemComponent = ({
onClick={handleClick}
{...(getContextMenuItems && !isTaskLoading && { onContextMenu: handleContextMenu })}
>
+
{getContextMenuItems && (
{
expect(container.querySelector('[data-testid="stage-task-task-1"]')).toBeInTheDocument();
});
});
+
+ describe('Breakpoint marker', () => {
+ it('renders the breakpoint dot for a sequential task with a breakpoint set', () => {
+ render();
+
+ expect(screen.getByTestId('stage-task-breakpoint-task-1')).toBeInTheDocument();
+ });
+
+ it('does not render the breakpoint dot when no breakpoint is set', () => {
+ render();
+
+ expect(screen.queryByTestId('stage-task-breakpoint-task-1')).not.toBeInTheDocument();
+ });
+
+ it('marker is display-only and does not intercept pointer events', () => {
+ render();
+
+ expect(screen.getByTestId('stage-task-breakpoint-task-1')).toHaveClass('pointer-events-none');
+ });
+ });
});
diff --git a/packages/apollo-react/src/canvas/components/StageNode/DraggableTask.tsx b/packages/apollo-react/src/canvas/components/StageNode/DraggableTask.tsx
index 59847c589..06e95996e 100644
--- a/packages/apollo-react/src/canvas/components/StageNode/DraggableTask.tsx
+++ b/packages/apollo-react/src/canvas/components/StageNode/DraggableTask.tsx
@@ -10,6 +10,7 @@ import {
StageTaskDragPlaceholderWrapper,
StageTaskWrapper,
} from './StageNode.styles';
+import { TaskBreakpointDot } from './TaskBreakpointDot';
import { TaskContent } from './TaskContent';
import { TaskMenu, type TaskMenuHandle } from './TaskMenu';
@@ -91,6 +92,7 @@ const DraggableTaskComponent = ({
onClick={handleClick}
{...(getContextMenuItems && !isTaskLoading && { onContextMenu: handleContextMenu })}
>
+
{getContextMenuItems && (
diff --git a/packages/apollo-react/src/canvas/components/StageNode/EventDrivenTask.tsx b/packages/apollo-react/src/canvas/components/StageNode/EventDrivenTask.tsx
index aee684962..a89ddb9fd 100644
--- a/packages/apollo-react/src/canvas/components/StageNode/EventDrivenTask.tsx
+++ b/packages/apollo-react/src/canvas/components/StageNode/EventDrivenTask.tsx
@@ -2,6 +2,7 @@ import { memo, useCallback, useRef } from 'react';
import type { NodeMenuItem } from '../NodeContextMenu';
import { StageTask } from './StageNode.styles';
import type { StageTaskExecution, StageTaskItem } from './StageNode.types';
+import { TaskBreakpointDot } from './TaskBreakpointDot';
import { TaskContent } from './TaskContent';
import { TaskMenu, type TaskMenuHandle } from './TaskMenu';
@@ -48,6 +49,7 @@ const EventDrivenTaskItemComponent = ({
onClick={handleClick}
{...(getContextMenuItems && !isTaskLoading && { onContextMenu: handleContextMenu })}
>
+
{getContextMenuItems && (
diff --git a/packages/apollo-react/src/canvas/components/StageNode/StageNode.breakpoints.stories.tsx b/packages/apollo-react/src/canvas/components/StageNode/StageNode.breakpoints.stories.tsx
new file mode 100644
index 000000000..a7075109c
--- /dev/null
+++ b/packages/apollo-react/src/canvas/components/StageNode/StageNode.breakpoints.stories.tsx
@@ -0,0 +1,308 @@
+import type { Meta, StoryObj } from '@storybook/react';
+import {
+ ConnectionMode,
+ Panel,
+ ReactFlowProvider,
+ useEdgesState,
+ useNodesState,
+} from '@uipath/apollo-react/canvas/xyflow/react';
+import { useCallback, useEffect, useMemo, useState } from 'react';
+import { DefaultCanvasTranslations } from '../../types';
+import { BaseCanvas } from '../BaseCanvas';
+import { CanvasPositionControls } from '../CanvasPositionControls';
+import type { NodeMenuItem } from '../NodeContextMenu';
+import { StageConnectionEdge } from './StageConnectionEdge';
+import { StageEdge } from './StageEdge';
+import { StageNode } from './StageNode';
+import { StageNodeWrapper } from './StageNode.stories.utils';
+import type {
+ StageNodeProps,
+ StageTaskExecution,
+ StageTaskItem,
+ StageTaskStatus,
+} from './StageNode.types';
+
+// ============================================================================
+// StageNode: Breakpoints feature
+//
+// A task carries a breakpoint when its `execution.taskStatus[id].breakpoint` is
+// true. The marker is a solid red dot at the top-left corner of the task card
+// (debugger-gutter style) and is display-only. Adding and removing breakpoints is
+// done through the task's right-click / task menu, whose items the consumer
+// (PO.frontend) supplies via `getTaskContextMenuItems`; these stories wire that up
+// the same way.
+// ============================================================================
+
+const ProcessIcon = () => (
+
+);
+
+const VerificationIcon = () => (
+
+);
+
+const DocumentIcon = () => (
+
+);
+
+const meta: Meta = {
+ title: 'Components/Nodes/StageNode/Breakpoints',
+ component: StageNode,
+ parameters: {
+ layout: 'fullscreen',
+ },
+};
+
+export default meta;
+type Story = StoryObj;
+
+const BREAKPOINT_TASKS: StageTaskItem[][] = [
+ [{ id: 'classify', label: 'Classify Request', icon: }],
+ [{ id: 'review', label: 'Review and Decide', icon: }],
+ [{ id: 'notify', label: 'Notify Outcome', icon: }],
+];
+
+// Reusable interactive playground: manages breakpoint state for an arbitrary task
+// list. A set breakpoint shows a solid, display-only marker; adding and removing
+// is done through the task's right-click menu (wired via getTaskContextMenuItems),
+// exactly as the consumer (PO.frontend) does it.
+const BreakpointPlaygroundStory = ({
+ stageLabel,
+ tasks,
+ initialBreakpoints,
+ taskStatuses,
+ allowBreakpoint,
+ isReadOnly,
+}: {
+ stageLabel: string;
+ tasks: StageTaskItem[][];
+ initialBreakpoints: string[];
+ /** Fixed execution statuses per task id (e.g. a paused or completed task). */
+ taskStatuses?: Record;
+ /** Optional predicate gating which tasks can host a breakpoint. */
+ allowBreakpoint?: (task: StageTaskItem) => boolean;
+ /** Render the stage read-only (like Debug view). The breakpoint menu must still work. */
+ isReadOnly?: boolean;
+}) => {
+ const nodeTypes = useMemo(() => ({ stage: StageNodeWrapper }), []);
+ const edgeTypes = useMemo(() => ({ stage: StageEdge }), []);
+
+ const [breakpoints, setBreakpoints] = useState>(() => new Set(initialBreakpoints));
+
+ const toggleBreakpoint = useCallback((taskId: string) => {
+ setBreakpoints((prev) => {
+ const next = new Set(prev);
+ if (next.has(taskId)) {
+ next.delete(taskId);
+ } else {
+ next.add(taskId);
+ }
+ return next;
+ });
+ }, []);
+
+ // Derives the stage node's data from the current breakpoint set plus the fixed
+ // task statuses. A set breakpoint is a static solid dot; a paused task shows the
+ // card's amber border and pause icon (from its status), not a changed dot.
+ const buildData = useCallback(() => {
+ const taskStatus: Record = {};
+ let anyPaused = false;
+ for (const group of tasks) {
+ for (const task of group) {
+ const status = taskStatuses?.[task.id];
+ const armed = breakpoints.has(task.id);
+ if (!status && !armed) {
+ continue;
+ }
+ if (status === 'Paused') {
+ anyPaused = true;
+ }
+ taskStatus[task.id] = {
+ ...(status
+ ? { status, ...(status === 'Paused' ? { message: 'Paused on breakpoint' } : {}) }
+ : {}),
+ ...(armed ? { breakpoint: true } : {}),
+ };
+ }
+ }
+ return {
+ stageDetails: { label: stageLabel, tasks, isReadOnly },
+ execution: {
+ stageStatus: anyPaused ? { status: 'Paused' as const, label: 'Paused on breakpoint' } : {},
+ taskStatus,
+ },
+ // Breakpoints are added/removed through the task's right-click menu (works read-only too).
+ getTaskContextMenuItems: ({ task }: { task: StageTaskItem }): NodeMenuItem[] => {
+ // Restricted tasks get no breakpoint action.
+ if (allowBreakpoint && !allowBreakpoint(task)) {
+ return [];
+ }
+ return [
+ {
+ id: 'toggle-breakpoint',
+ label: breakpoints.has(task.id) ? 'Remove breakpoint' : 'Add breakpoint',
+ onClick: () => toggleBreakpoint(task.id),
+ },
+ ];
+ },
+ };
+ }, [tasks, taskStatuses, breakpoints, stageLabel, toggleBreakpoint, allowBreakpoint, isReadOnly]);
+
+ const [nodes, setNodes, onNodesChange] = useNodesState([
+ { id: 'debug-stage', type: 'stage', position: { x: 320, y: 96 }, data: buildData() },
+ ]);
+ const [edges, , onEdgesChange] = useEdgesState([]);
+
+ // Write fresh data into React Flow's own node state whenever the breakpoint set
+ // changes, so the stage re-renders immediately. Overlaying a derived `nodes`
+ // prop did not reliably re-sync to the store, which left a toggle visually
+ // stale until the next canvas interaction.
+ useEffect(() => {
+ setNodes((prev) =>
+ prev.map((node) => (node.id === 'debug-stage' ? { ...node, data: buildData() } : node))
+ );
+ }, [buildData, setNodes]);
+
+ return (
+
+ );
+};
+
+// The full breakpoint lifecycle in one stage:
+// - "Classify Request": completed with a breakpoint -> solid dot (passed).
+// - "Review and Decide": the run is paused here on its breakpoint -> solid dot
+// plus the card's amber Paused border and pause icon (the dot itself is static).
+// - "Notify Outcome": no breakpoint.
+// Right-click any task to add / remove its breakpoint.
+export const Interactive: Story = {
+ name: 'Interactive (right-click to add/remove)',
+ render: () => (
+
+ ),
+};
+
+// Breakpoints apply to every task type, not just sequential ones: sequential,
+// ad hoc, and event-driven tasks all render the same corner marker. Right-click
+// a task to add / remove a breakpoint.
+const TASK_TYPE_TASKS: StageTaskItem[][] = [
+ [{ id: 'seq-review', label: 'Review and Decide', icon: }],
+ [{ id: 'adhoc-manual', label: 'Manual Check', icon: , isAdhoc: true }],
+ [
+ {
+ id: 'evt-escalation',
+ label: 'On Escalation',
+ icon: ,
+ taskGroupType: 'event-driven',
+ },
+ ],
+];
+
+export const AcrossTaskTypes: Story = {
+ name: 'Across task types (sequential, ad hoc, event-driven)',
+ render: () => (
+
+ ),
+};
+
+// Only some tasks may host a breakpoint. Here only sequential tasks qualify: the
+// right-click menu on an ad hoc or event-driven task offers no breakpoint action.
+// This mirrors how a consumer suppresses breakpoints on node types that cannot be
+// paused on (the story gates this in its own getTaskContextMenuItems).
+export const Restricted: Story = {
+ name: 'Restricted (menu gated per task)',
+ render: () => (
+ !task.isAdhoc && task.taskGroupType !== 'event-driven'}
+ />
+ ),
+};
+
+// Read-only stage (as in Debug view): editing is disabled, but the breakpoint
+// right-click menu (Add/Remove) must still work. "Review and Decide" is paused,
+// shown by the amber card border and pause icon.
+export const ReadOnlyDebugView: Story = {
+ name: 'Read-only / Debug view',
+ render: () => (
+
+ ),
+};
+
+// Breakpoints on tasks inside parallel groups. "Credit Check"/"Fraud Check" and
+// "Notify Applicant"/"Write Audit Log" are two parallel groups (rendered with the
+// parallel bracket + label); breakpoints sit on tasks within them so you can see
+// the marker on parallel tasks (add/remove via right-click).
+const BREAKPOINT_PARALLEL_TASKS: StageTaskItem[][] = [
+ [{ id: 'intake', label: 'Intake Request', icon: }],
+ [
+ { id: 'credit', label: 'Credit Check', icon: },
+ { id: 'fraud', label: 'Fraud Check', icon: },
+ ],
+ [
+ { id: 'notify-applicant', label: 'Notify Applicant', icon: },
+ { id: 'audit-log', label: 'Write Audit Log', icon: },
+ ],
+ [{ id: 'finalize', label: 'Finalize', icon: }],
+];
+
+export const ParallelTaskGroups: Story = {
+ name: 'Parallel task groups',
+ render: () => (
+
+ ),
+};
diff --git a/packages/apollo-react/src/canvas/components/StageNode/StageNode.test.tsx b/packages/apollo-react/src/canvas/components/StageNode/StageNode.test.tsx
index 9acc438f2..353f8ad44 100644
--- a/packages/apollo-react/src/canvas/components/StageNode/StageNode.test.tsx
+++ b/packages/apollo-react/src/canvas/components/StageNode/StageNode.test.tsx
@@ -1311,3 +1311,58 @@ describe('StageNode - getTaskContextMenuItems', () => {
expect(onCustom).toHaveBeenCalledTimes(1);
});
});
+
+// Sequential tasks render through DraggableTask, which is mocked in this suite
+// (see vi.mock('./DraggableTask') above), so the real breakpoint dot cannot be
+// asserted here for that path. The sequential path is covered against the real
+// component in DraggableTask.test.tsx; the ad hoc and event-driven paths use the
+// real task components and are verified below.
+describe('StageNode - Breakpoints on adhoc and event-driven tasks', () => {
+ it('renders a breakpoint marker on an armed adhoc task', () => {
+ renderStageNode({
+ stageDetails: {
+ label: 'Test Stage',
+ isReadOnly: true,
+ tasks: [[{ id: 'adhoc-1', label: 'Adhoc Task', isAdhoc: true }]],
+ },
+ execution: {
+ stageStatus: {},
+ taskStatus: { 'adhoc-1': { breakpoint: true } },
+ },
+ });
+
+ expect(screen.getByTestId('stage-task-breakpoint-adhoc-1')).toBeInTheDocument();
+ });
+
+ it('renders a breakpoint marker on an armed event-driven task', () => {
+ renderStageNode({
+ stageDetails: {
+ label: 'Test Stage',
+ isReadOnly: true,
+ tasks: [[{ id: 'evt-1', label: 'Event Task', taskGroupType: 'event-driven' }]],
+ },
+ execution: {
+ stageStatus: {},
+ taskStatus: { 'evt-1': { breakpoint: true } },
+ },
+ });
+
+ expect(screen.getByTestId('stage-task-breakpoint-evt-1')).toBeInTheDocument();
+ });
+
+ it('does not render a breakpoint marker on an unarmed adhoc task', () => {
+ renderStageNode({
+ stageDetails: {
+ label: 'Test Stage',
+ isReadOnly: true,
+ tasks: [[{ id: 'adhoc-1', label: 'Adhoc Task', isAdhoc: true }]],
+ },
+ execution: {
+ stageStatus: {},
+ taskStatus: {},
+ },
+ });
+
+ expect(screen.queryByTestId('stage-task-breakpoint-adhoc-1')).not.toBeInTheDocument();
+ });
+});
diff --git a/packages/apollo-react/src/canvas/components/StageNode/StageNode.types.ts b/packages/apollo-react/src/canvas/components/StageNode/StageNode.types.ts
index 0bcd4697d..5bdbf5f34 100644
--- a/packages/apollo-react/src/canvas/components/StageNode/StageNode.types.ts
+++ b/packages/apollo-react/src/canvas/components/StageNode/StageNode.types.ts
@@ -123,6 +123,12 @@ export interface StageTaskExecution {
* pre-localized by the consumer.
*/
retryCount?: number;
+ /**
+ * When `true`, the task shows a breakpoint marker (a red gutter dot) indicating the
+ * debugger will pause on this task. Breakpoints attach to individual tasks, not to the
+ * stage container.
+ */
+ breakpoint?: boolean;
}
export interface StageTaskDragOverlayProps {
diff --git a/packages/apollo-react/src/canvas/components/StageNode/StageNode.utils.test.ts b/packages/apollo-react/src/canvas/components/StageNode/StageNode.utils.test.ts
index 06a9ed719..ebdbd7e00 100644
--- a/packages/apollo-react/src/canvas/components/StageNode/StageNode.utils.test.ts
+++ b/packages/apollo-react/src/canvas/components/StageNode/StageNode.utils.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from 'vitest';
-import type { NodeMenuAction, NodeMenuItem } from '../NodeContextMenu';
+import type { NodeMenuItem } from '../NodeContextMenu';
import { INDENTATION_WIDTH } from './StageNode.styles';
import type { StageTaskItem } from './StageNode.types';
import {
diff --git a/packages/apollo-react/src/canvas/components/StageNode/StageNodeAdhocTaskGroups.tsx b/packages/apollo-react/src/canvas/components/StageNode/StageNodeAdhocTaskGroups.tsx
index 73025757a..72cce62ba 100644
--- a/packages/apollo-react/src/canvas/components/StageNode/StageNodeAdhocTaskGroups.tsx
+++ b/packages/apollo-react/src/canvas/components/StageNode/StageNodeAdhocTaskGroups.tsx
@@ -78,15 +78,14 @@ const StageNodeAdhocTaskGroupsInner = ({
{adhocTasks.map(({ task }) => {
const taskExecution = execution?.taskStatus?.[task.id];
- const customItems =
- !isReadOnly && !hasBuiltInTaskActions
- ? (getTaskContextMenuItems?.({
- task,
- taskGroupType: 'adhoc',
- isParallel: false,
- }) ?? [])
- : [];
- const hasMenu = !isReadOnly && (hasBuiltInTaskActions || customItems.length > 0);
+ // Consumer items (e.g. breakpoints) are allowed even in read-only/Debug view;
+ // only the built-in edit actions are gated on !isReadOnly. When built-in actions
+ // already guarantee a menu we skip the eager consumer call; otherwise we ask the
+ // consumer whether it contributes any items.
+ const hasMenu =
+ (!isReadOnly && hasBuiltInTaskActions) ||
+ (getTaskContextMenuItems?.({ task, taskGroupType: 'adhoc', isParallel: false })
+ ?.length ?? 0) > 0;
return (
{eventDrivenTasks.map(({ task }) => {
const taskExecution = execution?.taskStatus?.[task.id];
- const customItems =
- !isReadOnly && !hasBuiltInTaskActions
- ? (getTaskContextMenuItems?.({
- task,
- taskGroupType: 'event-driven',
- isParallel: false,
- }) ?? [])
- : [];
- const hasMenu = !isReadOnly && (hasBuiltInTaskActions || customItems.length > 0);
+ // Consumer items (e.g. breakpoints) are allowed even in read-only/Debug view;
+ // only the built-in edit actions are gated on !isReadOnly. When built-in actions
+ // already guarantee a menu we skip the eager consumer call; otherwise we ask the
+ // consumer whether it contributes any items.
+ const hasMenu =
+ (!isReadOnly && hasBuiltInTaskActions) ||
+ (getTaskContextMenuItems?.({ task, taskGroupType: 'event-driven', isParallel: false })
+ ?.length ?? 0) > 0;
return (
{
const taskExecution = execution?.taskStatus?.[task.id];
- const customItems =
- !isReadOnly && !hasBuiltInTaskActions
- ? (getTaskContextMenuItems?.({
- task,
- taskGroupType: 'sequential',
- isParallel,
- }) ?? [])
- : [];
+ // Consumer items (e.g. breakpoints) are allowed even in read-only/Debug
+ // view; only the built-in edit actions are gated on !isReadOnly. When
+ // built-in actions already guarantee a menu we skip the eager consumer
+ // call; otherwise we ask the consumer whether it contributes any items.
const hasMenu =
- !isReadOnly && (hasBuiltInTaskActions || customItems.length > 0);
+ (!isReadOnly && hasBuiltInTaskActions) ||
+ (getTaskContextMenuItems?.({
+ task,
+ taskGroupType: 'sequential',
+ isParallel,
+ })?.length ?? 0) > 0;
return (
{
+ it('renders nothing when no breakpoint is set', () => {
+ render();
+
+ expect(screen.queryByTestId('stage-task-breakpoint-t1')).not.toBeInTheDocument();
+ });
+
+ it('renders a solid marker when a breakpoint is set', () => {
+ render();
+
+ expect(screen.getByTestId('stage-task-breakpoint-t1')).toBeInTheDocument();
+ });
+
+ it('is display-only and never intercepts pointer events', () => {
+ // Breakpoints are managed via the task menu / hover toolbar; the marker itself
+ // must let clicks/drags on the card corner pass through to the task.
+ render();
+
+ expect(screen.getByTestId('stage-task-breakpoint-t1')).toHaveClass('pointer-events-none');
+ });
+
+ it('is static: the marker never animates (matches Flow, which does not pulse)', () => {
+ const { container } = render();
+
+ expect(container.querySelector('[class*="animate-"]')).not.toBeInTheDocument();
+ });
+});
diff --git a/packages/apollo-react/src/canvas/components/StageNode/TaskBreakpointDot.tsx b/packages/apollo-react/src/canvas/components/StageNode/TaskBreakpointDot.tsx
new file mode 100644
index 000000000..eccef612b
--- /dev/null
+++ b/packages/apollo-react/src/canvas/components/StageNode/TaskBreakpointDot.tsx
@@ -0,0 +1,33 @@
+import { memo } from 'react';
+
+export interface TaskBreakpointDotProps {
+ /** Whether a breakpoint is set (placed) on this task. */
+ active: boolean;
+ /** Task id, used for a stable test id. */
+ taskId: string;
+}
+
+/**
+ * Breakpoint marker for a stage task, at the top-left corner of the task card.
+ *
+ * Mirrors the Flow/BPMN canvas breakpoint (`DebugBreakpoint`): a 14px solid circle
+ * filled with the canvas error-icon color, no border, shadow, or animation.
+ *
+ * Display only: it renders nothing until a breakpoint is set and never intercepts
+ * pointer events. Adding and removing breakpoints is done through the task's
+ * right-click menu (items supplied by the consumer via `getTaskContextMenuItems`).
+ */
+function TaskBreakpointDotInner({ active, taskId }: TaskBreakpointDotProps) {
+ if (!active) {
+ return null;
+ }
+
+ return (
+
+ );
+}
+
+export const TaskBreakpointDot = memo(TaskBreakpointDotInner);