diff --git a/apps/storybook/.storybook/preview.tsx b/apps/storybook/.storybook/preview.tsx
index 3d362df82..62554aa32 100644
--- a/apps/storybook/.storybook/preview.tsx
+++ b/apps/storybook/.storybook/preview.tsx
@@ -180,7 +180,13 @@ const preview: Preview = {
'Layout',
'Nodes',
'Panels',
- ['Node Property Trigger', 'Node Manifest Panel', 'Node Flyout Panel', '*'],
+ [
+ 'Node Property Trigger',
+ 'Node Manifest Panel',
+ 'Node Property Panel',
+ 'Node Flyout Panel',
+ '*',
+ ],
'Primitives',
'*',
],
diff --git a/packages/apollo-react/src/canvas/components/NodePropertyPanel/NodePropertyPanel.stories.tsx b/packages/apollo-react/src/canvas/components/NodePropertyPanel/NodePropertyPanel.stories.tsx
new file mode 100644
index 000000000..341aa96b0
--- /dev/null
+++ b/packages/apollo-react/src/canvas/components/NodePropertyPanel/NodePropertyPanel.stories.tsx
@@ -0,0 +1,471 @@
+import type { Meta, StoryObj } from '@storybook/react-vite';
+import type { FormSchema } from '@uipath/apollo-wind';
+import { Play } from 'lucide-react';
+import { NodeRegistryProvider } from '../../core';
+import type { NodeManifest } from '../../schema';
+import { allCategoryManifests } from '../../storybook-utils';
+import { NodePropertyPanel } from './NodePropertyPanel';
+
+// ============================================================================
+// Layout helpers
+// ============================================================================
+
+const CanvasBackground = ({ children }: { children: React.ReactNode }) => (
+
+ {children}
+
+);
+
+const PanelFrame = ({ children }: { children: React.ReactNode }) => (
+
+ {children}
+
+);
+
+function RunButton() {
+ return (
+
+
+ Run
+
+ );
+}
+
+// ============================================================================
+// Meta
+// ============================================================================
+
+const meta: Meta = {
+ title: 'Components/Panels/Node Property Panel',
+ component: NodePropertyPanel,
+ parameters: {
+ layout: 'fullscreen',
+ docs: {
+ description: {
+ component: `
+The **NodePropertyPanel** is a docked properties panel that renders a node's
+\`form: FormSchema\` directly from its manifest in the \`NodeRegistryProvider\`.
+
+## Usage
+
+\`\`\`tsx
+// 1. Define the form schema in the node manifest (tabs = steps):
+const manifest: NodeManifest = {
+ nodeType: 'uipath.http-request',
+ form: {
+ id: 'http-request',
+ title: 'HTTP Request',
+ steps: [
+ { id: 'parameters', title: 'Parameters', sections: [{ id: 'main', fields: [...] }] },
+ { id: 'error-handling', title: 'Error handling', sections: [...] },
+ ],
+ },
+ // ...
+};
+
+// 2. Render the panel — no manifest prop needed:
+
+ saveNodeConfig(nodeId, data)}
+ onClose={() => setSelectedNode(null)}
+ />
+
+\`\`\`
+
+Tabs are defined as \`steps\` in the FormSchema — the component never hardcodes
+tab names or field types. Single-page schemas (\`sections\`) are rendered as a
+flat scrollable form.
+ `,
+ },
+ },
+ },
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
+ tags: ['autodocs'],
+};
+
+export default meta;
+type Story = StoryObj;
+
+// ============================================================================
+// Shared FormSchema definitions
+// Tabs = steps; sections within each step hold the fields.
+// ============================================================================
+
+const httpRequestForm: FormSchema = {
+ id: 'http-request',
+ title: 'HTTP Request',
+ mode: 'onChange',
+ steps: [
+ {
+ id: 'parameters',
+ title: 'Parameters',
+ sections: [
+ {
+ id: 'main',
+ fields: [
+ {
+ type: 'text',
+ name: 'endpoint',
+ label: 'Endpoint',
+ placeholder: 'https://…',
+ description: 'The URL of the HTTP endpoint to call.',
+ defaultValue: 'https://finance.internal/api/invoices',
+ },
+ {
+ type: 'select',
+ name: 'method',
+ label: 'Method',
+ defaultValue: 'GET',
+ dataSource: {
+ type: 'static',
+ options: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'].map((v) => ({
+ label: v,
+ value: v,
+ })),
+ },
+ },
+ {
+ type: 'select',
+ name: 'auth_type',
+ label: 'Auth type',
+ defaultValue: 'bearer',
+ dataSource: {
+ type: 'static',
+ options: ['none', 'bearer', 'api-key', 'oauth'].map((v) => ({
+ label: v,
+ value: v,
+ })),
+ },
+ },
+ {
+ type: 'number',
+ name: 'timeout_ms',
+ label: 'Timeout (ms)',
+ placeholder: '5000',
+ description: 'Request timeout in milliseconds.',
+ defaultValue: 10000,
+ },
+ {
+ type: 'switch',
+ name: 'retry_on_failure',
+ label: 'Retry on failure',
+ defaultValue: true,
+ },
+ ],
+ },
+ ],
+ },
+ {
+ id: 'error-handling',
+ title: 'Error handling',
+ sections: [
+ {
+ id: 'errors',
+ fields: [
+ {
+ type: 'select',
+ name: 'on_error',
+ label: 'On error',
+ defaultValue: 'throw',
+ dataSource: {
+ type: 'static',
+ options: [
+ { label: 'Throw exception', value: 'throw' },
+ { label: 'Return empty', value: 'empty' },
+ { label: 'Retry', value: 'retry' },
+ ],
+ },
+ },
+ {
+ type: 'number',
+ name: 'max_retries',
+ label: 'Max retries',
+ defaultValue: 3,
+ },
+ ],
+ },
+ ],
+ },
+ {
+ id: 'advanced',
+ title: 'Advanced',
+ sections: [
+ {
+ id: 'adv',
+ fields: [
+ {
+ type: 'switch',
+ name: 'follow_redirects',
+ label: 'Follow redirects',
+ defaultValue: true,
+ },
+ {
+ type: 'switch',
+ name: 'verify_ssl',
+ label: 'Verify SSL',
+ defaultValue: true,
+ },
+ ],
+ },
+ ],
+ },
+ ],
+};
+
+const humanTaskForm: FormSchema = {
+ id: 'human-task',
+ title: 'Human Task',
+ mode: 'onChange',
+ steps: [
+ {
+ id: 'parameters',
+ title: 'Parameters',
+ sections: [
+ {
+ id: 'main',
+ fields: [
+ {
+ type: 'text',
+ name: 'assignee',
+ label: 'Assignee',
+ placeholder: 'user@example.com',
+ defaultValue: 'finance.manager@acme.com',
+ validation: { required: true, email: true },
+ },
+ {
+ type: 'number',
+ name: 'timeout_hours',
+ label: 'Timeout (hours)',
+ placeholder: '24',
+ description: 'Hours before task auto-escalates.',
+ defaultValue: 48,
+ },
+ {
+ type: 'text',
+ name: 'escalation_email',
+ label: 'Escalation email',
+ placeholder: 'escalation@example.com',
+ defaultValue: 'director@acme.com',
+ },
+ {
+ type: 'switch',
+ name: 'require_comment',
+ label: 'Require comment',
+ defaultValue: true,
+ },
+ ],
+ },
+ ],
+ },
+ {
+ id: 'error-handling',
+ title: 'Error handling',
+ sections: [{ id: 'errors', fields: [] }],
+ },
+ {
+ id: 'advanced',
+ title: 'Advanced',
+ sections: [{ id: 'adv', fields: [] }],
+ },
+ ],
+};
+
+const agentForm: FormSchema = {
+ id: 'ai-agent',
+ title: 'AI Agent',
+ mode: 'onChange',
+ steps: [
+ {
+ id: 'parameters',
+ title: 'Parameters',
+ sections: [
+ {
+ id: 'main',
+ fields: [
+ {
+ type: 'text',
+ name: 'model',
+ label: 'Model',
+ description: 'AI model identifier.',
+ defaultValue: 'claude-sonnet-4-5',
+ },
+ {
+ type: 'text',
+ name: 'policy_version',
+ label: 'Policy version',
+ defaultValue: 'v2.3',
+ },
+ {
+ type: 'number',
+ name: 'approval_threshold',
+ label: 'Approval threshold ($)',
+ description: 'Invoice amount above which human approval is required.',
+ defaultValue: 5000,
+ },
+ {
+ type: 'switch',
+ name: 'strict_mode',
+ label: 'Strict mode',
+ defaultValue: true,
+ },
+ ],
+ },
+ ],
+ },
+ {
+ id: 'error-handling',
+ title: 'Error handling',
+ sections: [{ id: 'errors', fields: [] }],
+ },
+ {
+ id: 'advanced',
+ title: 'Advanced',
+ sections: [{ id: 'adv', fields: [] }],
+ },
+ ],
+};
+
+// ============================================================================
+// Story node manifests — minimal wrappers that provide the form schema
+// ============================================================================
+
+function makeManifest(
+ nodeType: string,
+ label: string,
+ category: string,
+ form: FormSchema
+): NodeManifest {
+ return {
+ nodeType,
+ version: '1.0.0',
+ category,
+ tags: [],
+ sortOrder: 0,
+ display: { label, shape: 'square' },
+ handleConfiguration: [],
+ form,
+ };
+}
+
+function withRegistry(manifest: NodeManifest) {
+ return (Story: React.ComponentType) => (
+
+
+
+ );
+}
+
+// ============================================================================
+// Stories
+// ============================================================================
+
+export const HttpRequest: Story = {
+ decorators: [
+ withRegistry(
+ makeManifest('uipath.http-request', 'HTTP Request', 'integration', httpRequestForm)
+ ),
+ ],
+ render: () => (
+
+ }
+ onSubmit={(data) => console.log('submit', data)}
+ onClose={() => console.log('close')}
+ />
+
+ ),
+};
+
+export const HumanTask: Story = {
+ decorators: [
+ withRegistry(makeManifest('uipath.human-task', 'Human Task', 'collaboration', humanTaskForm)),
+ ],
+ render: () => (
+
+ }
+ onSubmit={(data) => console.log('submit', data)}
+ onClose={() => console.log('close')}
+ />
+
+ ),
+};
+
+export const AiAgent: Story = {
+ decorators: [withRegistry(makeManifest('uipath.ai-agent', 'AI Agent', 'ai', agentForm))],
+ render: () => (
+
+ }
+ onSubmit={(data) => console.log('submit', data)}
+ onClose={() => console.log('close')}
+ />
+
+ ),
+};
+
+export const NoParameters: Story = {
+ decorators: [
+ withRegistry(
+ makeManifest('uipath.log-event', 'Log Event', 'utility', {
+ id: 'log-event',
+ title: 'Log Event',
+ sections: [{ id: 'main', fields: [] }],
+ })
+ ),
+ ],
+ render: () => (
+
+ console.log('close')}
+ />
+
+ ),
+};
+
+export const ContentOnly: Story = {
+ decorators: [
+ withRegistry(
+ makeManifest('uipath.http-request', 'HTTP Request', 'integration', httpRequestForm)
+ ),
+ ],
+ render: () => (
+
+ console.log('submit', data)}
+ />
+
+ ),
+};
diff --git a/packages/apollo-react/src/canvas/components/NodePropertyPanel/NodePropertyPanel.test.tsx b/packages/apollo-react/src/canvas/components/NodePropertyPanel/NodePropertyPanel.test.tsx
new file mode 100644
index 000000000..d57fdbd60
--- /dev/null
+++ b/packages/apollo-react/src/canvas/components/NodePropertyPanel/NodePropertyPanel.test.tsx
@@ -0,0 +1,147 @@
+import type { FormSchema } from '@uipath/apollo-wind';
+import { describe, expect, it, vi } from 'vitest';
+import { NodeRegistryProvider } from '../../core/NodeRegistryProvider';
+import type { NodeManifest } from '../../schema';
+import { render, screen } from '../../utils/testing';
+import { NodePropertyPanel } from './NodePropertyPanel';
+
+// Keep MetadataForm lightweight — we only need to verify it is rendered and
+// receives the correct schema id/title, not that the full form is interactive.
+vi.mock('@uipath/apollo-wind', async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ MetadataForm: ({ schema }: { schema: { id?: string; title?: string } }) => (
+
+ ),
+ };
+});
+
+// ─── Test fixtures ────────────────────────────────────────────────────────────
+
+const SINGLE_PAGE_FORM: FormSchema = {
+ id: 'http-request',
+ title: 'HTTP Request',
+ sections: [
+ {
+ id: 'main',
+ fields: [{ id: 'url', type: 'text', name: 'url', label: 'URL' }],
+ },
+ ],
+};
+
+const MULTI_STEP_FORM: FormSchema = {
+ id: 'agent',
+ title: 'Agent',
+ steps: [
+ {
+ id: 'parameters',
+ title: 'Parameters',
+ sections: [
+ { id: 's1', fields: [{ id: 'model', type: 'text', name: 'model', label: 'Model' }] },
+ ],
+ },
+ {
+ id: 'error-handling',
+ title: 'Error handling',
+ sections: [],
+ },
+ ],
+};
+
+function makeManifest(nodeType: string, form?: FormSchema): NodeManifest {
+ return {
+ nodeType,
+ version: '1.0.0',
+ tags: [],
+ sortOrder: 0,
+ display: { label: 'Test Node', icon: nodeType, shape: 'rectangle' },
+ handleConfiguration: [],
+ form,
+ } as NodeManifest;
+}
+
+function renderInRegistry(ui: React.ReactElement, manifests: NodeManifest[] = []) {
+ return render({ui} );
+}
+
+// ─── Tests ───────────────────────────────────────────────────────────────────
+
+describe('NodePropertyPanel', () => {
+ it('renders panel title bar when panelTitle is provided', () => {
+ renderInRegistry( , [
+ makeManifest('uipath.test'),
+ ]);
+ expect(screen.getByText('Properties')).toBeInTheDocument();
+ });
+
+ it('does not render title bar when panelTitle is omitted', () => {
+ renderInRegistry( , [makeManifest('uipath.test')]);
+ expect(screen.queryByLabelText('Close')).not.toBeInTheDocument();
+ });
+
+ it('calls onClose when the close button is clicked', async () => {
+ const onClose = vi.fn();
+ const { getByRole } = renderInRegistry(
+ ,
+ [makeManifest('uipath.test')]
+ );
+ getByRole('button', { name: 'Close' }).click();
+ expect(onClose).toHaveBeenCalledOnce();
+ });
+
+ it('renders node label and category in the identity row', () => {
+ renderInRegistry(
+ ,
+ [makeManifest('uipath.test')]
+ );
+ expect(screen.getByText('My Activity')).toBeInTheDocument();
+ expect(screen.getByText('HTTP')).toBeInTheDocument();
+ });
+
+ it('renders empty-state when no form schema is defined', () => {
+ renderInRegistry( , [makeManifest('uipath.test')]);
+ expect(screen.getByText(/No form schema defined/i)).toBeInTheDocument();
+ });
+
+ it('renders a single MetadataForm for a flat (non-multi-step) form schema', () => {
+ renderInRegistry( , [
+ makeManifest('uipath.http', SINGLE_PAGE_FORM),
+ ]);
+ expect(screen.getByTestId('metadata-form')).toBeInTheDocument();
+ expect(screen.getByTestId('metadata-form')).toHaveAttribute('data-schema-id', 'http-request');
+ });
+
+ it('renders tab triggers for each step in a multi-step form', () => {
+ renderInRegistry( , [
+ makeManifest('uipath.agent', MULTI_STEP_FORM),
+ ]);
+ expect(screen.getByRole('tab', { name: 'Parameters' })).toBeInTheDocument();
+ expect(screen.getByRole('tab', { name: 'Error handling' })).toBeInTheDocument();
+ });
+
+ it('resets the active tab when nodeType changes to a different node', () => {
+ const agentManifest = makeManifest('uipath.agent', MULTI_STEP_FORM);
+ const httpManifest = makeManifest('uipath.http', SINGLE_PAGE_FORM);
+
+ const { rerender } = renderInRegistry( , [
+ agentManifest,
+ httpManifest,
+ ]);
+
+ // Switch to a different node type — the component should reset internal tab state
+ rerender(
+
+
+
+ );
+
+ // The single-page form (no tabs) should now be rendered
+ expect(screen.getByTestId('metadata-form')).toBeInTheDocument();
+ expect(screen.queryByRole('tab')).not.toBeInTheDocument();
+ });
+});
diff --git a/packages/apollo-react/src/canvas/components/NodePropertyPanel/NodePropertyPanel.tsx b/packages/apollo-react/src/canvas/components/NodePropertyPanel/NodePropertyPanel.tsx
new file mode 100644
index 000000000..ea8694dfc
--- /dev/null
+++ b/packages/apollo-react/src/canvas/components/NodePropertyPanel/NodePropertyPanel.tsx
@@ -0,0 +1,194 @@
+import type { FormSchema, FormStep } from '@uipath/apollo-wind';
+import { cn, MetadataForm, Tabs, TabsContent, TabsList, TabsTrigger } from '@uipath/apollo-wind';
+import { GripVertical, X } from 'lucide-react';
+import { useEffect, useState } from 'react';
+import { useNodeManifest } from '../../core/useNodeTypeRegistry';
+import type { NodePropertyPanelProps } from './NodePropertyPanel.types';
+
+// ============================================================================
+// Helpers
+// ============================================================================
+
+function isMultiStep(form: FormSchema): form is FormSchema & { steps: FormStep[] } {
+ return 'steps' in form && Array.isArray((form as { steps?: unknown }).steps);
+}
+
+// ============================================================================
+// NodePropertyPanel
+// ============================================================================
+
+/**
+ * NodePropertyPanel — docked properties panel driven by the node manifest.
+ *
+ * The panel reads `manifest.form` (a `FormSchema`) from the `NodeRegistryProvider`
+ * in the tree using `nodeType`. It renders:
+ *
+ * - **Multi-step FormSchema** (`steps`): each step becomes a tab. Tab labels and
+ * the fields within each tab are fully consumer-defined in the FormSchema — the
+ * component does not hardcode any tab names or field types.
+ * - **Single-page FormSchema** (`sections`): rendered as a flat scrollable form.
+ * - **No form schema**: renders an empty-state message.
+ *
+ * @example
+ * ```tsx
+ * // Register the manifest (with form schema) once at app startup:
+ *
+ * saveNodeConfig(nodeId, data)}
+ * onClose={() => setSelectedNode(null)}
+ * />
+ *
+ * ```
+ */
+export function NodePropertyPanel({
+ panelTitle,
+ onClose,
+ nodeType,
+ nodeLabel,
+ nodeCategory,
+ nodeIcon,
+ action,
+ onSubmit,
+ className,
+}: NodePropertyPanelProps) {
+ const manifest = useNodeManifest(nodeType);
+ const form = manifest?.form;
+ const subtitle = nodeCategory ?? manifest?.category ?? nodeType;
+ const hasNodeHeader = !!(nodeLabel || nodeCategory || nodeIcon || action);
+
+ const steps = form && isMultiStep(form) ? form.steps : null;
+ const [activeStep, setActiveStep] = useState('');
+ // Clamp to a valid step id so dynamic manifest/schema updates can't strand
+ // Radix Tabs on a value that no longer exists in `steps`.
+ const currentStep = steps?.some((step) => step.id === activeStep)
+ ? activeStep
+ : (steps?.[0]?.id ?? '');
+
+ // biome-ignore lint/correctness/useExhaustiveDependencies: nodeType is the trigger; setActiveStep is stable
+ useEffect(() => {
+ setActiveStep('');
+ }, [nodeType]);
+
+ return (
+
+ {/* ── Title bar ── */}
+ {panelTitle && (
+
+
+ {onClose && (
+
+
+
+ )}
+
+ )}
+
+ {/* ── Node identity row ── */}
+ {hasNodeHeader && (
+
+
+ {nodeIcon &&
{nodeIcon}
}
+
+ {nodeLabel && (
+
+ {nodeLabel}
+
+ )}
+ {subtitle && (
+
+ {subtitle}
+
+ )}
+
+
+ {action &&
{action}
}
+
+ )}
+
+ {/* ── Form ── */}
+ {!form ? (
+
+ No form schema defined for this node type.
+
+ ) : steps && steps.length === 0 ? (
+
+ No configuration fields defined for this node type.
+
+ ) : steps ? (
+ // Multi-step: steps become tabs — consumer defines the step titles and fields
+
+
+ {steps.map((step) => (
+
+ {step.title}
+
+ ))}
+
+
+ {steps.map((step) => (
+
+ {/* Remap surface-raised → surface-overlay so inputs appear lighter than the panel; labels → foreground-muted (zinc-400) */}
+
+
+
+
+ ))}
+
+ ) : (
+ // Single-page: sections rendered by MetadataForm directly
+
+ {/* Remap surface-raised → surface-overlay so inputs appear lighter than the panel; labels → foreground-muted (zinc-400) */}
+
+
+
+
+ )}
+
+ );
+}
diff --git a/packages/apollo-react/src/canvas/components/NodePropertyPanel/NodePropertyPanel.types.ts b/packages/apollo-react/src/canvas/components/NodePropertyPanel/NodePropertyPanel.types.ts
new file mode 100644
index 000000000..3003e0dd1
--- /dev/null
+++ b/packages/apollo-react/src/canvas/components/NodePropertyPanel/NodePropertyPanel.types.ts
@@ -0,0 +1,26 @@
+export interface NodePropertyPanelProps {
+ /** Title shown in the drag-handle header row (e.g. "Properties"). Omit to hide the header row. */
+ panelTitle?: string;
+ /** Called when the X close button is clicked. Only rendered when `panelTitle` is set. */
+ onClose?: () => void;
+ /**
+ * Node type identifier (e.g. "uipath.http-request"). The component reads the
+ * node's manifest — including its `form: FormSchema` — from the nearest
+ * `NodeRegistryProvider` in the tree.
+ */
+ nodeType: string;
+ /** The node's display label shown in the node identity row. */
+ nodeLabel?: string;
+ /** Category text shown below nodeLabel. Falls back to nodeType when omitted. */
+ nodeCategory?: string;
+ /** Optional icon rendered left of the node name. */
+ nodeIcon?: React.ReactNode;
+ /** Optional action slot rendered on the right of the node identity row (e.g. a Run button). */
+ action?: React.ReactNode;
+ /**
+ * Called when the form is submitted. Receives the full form data object whose
+ * keys match the field `name` values in the FormSchema.
+ */
+ onSubmit?: (data: unknown) => void | Promise;
+ className?: string;
+}
diff --git a/packages/apollo-react/src/canvas/components/NodePropertyPanel/index.ts b/packages/apollo-react/src/canvas/components/NodePropertyPanel/index.ts
new file mode 100644
index 000000000..f06f630e9
--- /dev/null
+++ b/packages/apollo-react/src/canvas/components/NodePropertyPanel/index.ts
@@ -0,0 +1,2 @@
+export { NodePropertyPanel } from './NodePropertyPanel';
+export type { NodePropertyPanelProps } from './NodePropertyPanel.types';
diff --git a/packages/apollo-react/src/canvas/components/ProbeCard/ProbeCard.test.tsx b/packages/apollo-react/src/canvas/components/ProbeCard/ProbeCard.test.tsx
new file mode 100644
index 000000000..5c8bbb362
--- /dev/null
+++ b/packages/apollo-react/src/canvas/components/ProbeCard/ProbeCard.test.tsx
@@ -0,0 +1,135 @@
+import { describe, expect, it, vi } from 'vitest';
+import { fireEvent, render, screen } from '../../utils/testing';
+import { ProbeCard, type ProbeCardProps } from './ProbeCard';
+
+function makeProps(overrides: Partial = {}): ProbeCardProps {
+ return {
+ watches: [],
+ onAddWatch: vi.fn(),
+ onUpdateWatch: vi.fn(),
+ onRemoveWatch: vi.fn(),
+ onDragStart: vi.fn(),
+ onDrag: vi.fn(),
+ onDragEnd: vi.fn(),
+ onResizeStart: vi.fn(),
+ onResize: vi.fn(),
+ onResizeEnd: vi.fn(),
+ onClose: vi.fn(),
+ ...overrides,
+ };
+}
+
+describe('ProbeCard', () => {
+ describe('keyboard shortcuts', () => {
+ it('calls onClose when Delete is pressed while the card has focus', () => {
+ const onClose = vi.fn();
+ render( );
+ screen.getByRole('group', { name: 'Probe' }).focus();
+ window.dispatchEvent(
+ new KeyboardEvent('keydown', { key: 'Delete', bubbles: true, cancelable: true })
+ );
+ expect(onClose).toHaveBeenCalledOnce();
+ });
+
+ it('calls onClose when Backspace is pressed while the card has focus', () => {
+ const onClose = vi.fn();
+ render( );
+ screen.getByRole('group', { name: 'Probe' }).focus();
+ window.dispatchEvent(
+ new KeyboardEvent('keydown', { key: 'Backspace', bubbles: true, cancelable: true })
+ );
+ expect(onClose).toHaveBeenCalledOnce();
+ });
+
+ it('does not call onClose when Delete is pressed while a watch input has focus', () => {
+ const onClose = vi.fn();
+ render(
+
+ );
+ screen.getByRole('textbox', { name: 'Watch expression' }).focus();
+ window.dispatchEvent(
+ new KeyboardEvent('keydown', { key: 'Delete', bubbles: true, cancelable: true })
+ );
+ expect(onClose).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('wheel event forwarding', () => {
+ it('does not stopPropagation on ctrl+wheel when onCanvasZoom is not provided', () => {
+ render( );
+ const card = screen.getByRole('group', { name: 'Probe' });
+ const event = new WheelEvent('wheel', { deltaY: -100, bubbles: true, cancelable: true });
+ Object.defineProperty(event, 'ctrlKey', { value: true, configurable: true });
+ const spy = vi.spyOn(event, 'stopPropagation');
+ card.dispatchEvent(event);
+ expect(spy).not.toHaveBeenCalled();
+ });
+
+ it('calls onCanvasZoom and stops propagation when ctrl+wheel fires with a handler provided', () => {
+ const onCanvasZoom = vi.fn();
+ render( );
+ const card = screen.getByRole('group', { name: 'Probe' });
+ // happy-dom does not forward modifier keys via the WheelEvent init dict,
+ // so we set ctrlKey directly on the event object.
+ const event = new WheelEvent('wheel', {
+ deltaY: -100,
+ clientX: 200,
+ clientY: 150,
+ bubbles: true,
+ cancelable: true,
+ });
+ Object.defineProperty(event, 'ctrlKey', { value: true, configurable: true });
+ card.dispatchEvent(event);
+ // happy-dom drops clientX/clientY from the WheelEvent init dict,
+ // so only assert the fields the component actually uses for routing.
+ expect(onCanvasZoom).toHaveBeenCalledWith(
+ expect.objectContaining({ deltaY: -100, ctrlKey: true })
+ );
+ });
+
+ it('does not stopPropagation on plain wheel when onCanvasPan is not provided', () => {
+ render( );
+ const card = screen.getByRole('group', { name: 'Probe' });
+ const event = new WheelEvent('wheel', { deltaY: 10, bubbles: true, cancelable: true });
+ const spy = vi.spyOn(event, 'stopPropagation');
+ card.dispatchEvent(event);
+ expect(spy).not.toHaveBeenCalled();
+ });
+
+ it('calls onCanvasPan and stops propagation when plain wheel fires with a handler provided', () => {
+ const onCanvasPan = vi.fn();
+ render( );
+ const card = screen.getByRole('group', { name: 'Probe' });
+ card.dispatchEvent(
+ new WheelEvent('wheel', { deltaY: 20, deltaX: 0, bubbles: true, cancelable: true })
+ );
+ // Use any(Number) for both axes — -0 and 0 are distinct under Object.is
+ expect(onCanvasPan).toHaveBeenCalledWith({
+ x: expect.any(Number),
+ y: expect.any(Number),
+ });
+ });
+ });
+
+ describe('drag session cleanup', () => {
+ it('removes window mousemove and mouseup listeners on unmount during an active drag', () => {
+ const { container, unmount } = render( );
+ const header = container.querySelector('.cursor-move') as HTMLElement;
+
+ // Start a drag session by pressing the header with the primary button
+ fireEvent.mouseDown(header, { button: 0, clientX: 50, clientY: 50 });
+
+ const spy = vi.spyOn(window, 'removeEventListener');
+ unmount();
+
+ expect(spy).toHaveBeenCalledWith('mousemove', expect.any(Function));
+ expect(spy).toHaveBeenCalledWith('mouseup', expect.any(Function));
+ spy.mockRestore();
+ });
+ });
+});
diff --git a/packages/apollo-react/src/canvas/components/ProbeCard/ProbeCard.tsx b/packages/apollo-react/src/canvas/components/ProbeCard/ProbeCard.tsx
new file mode 100644
index 000000000..040ade3e5
--- /dev/null
+++ b/packages/apollo-react/src/canvas/components/ProbeCard/ProbeCard.tsx
@@ -0,0 +1,607 @@
+import { Button } from '@uipath/apollo-wind';
+import { ChevronDown, ChevronLeft, ChevronRight, Plus, X } from 'lucide-react';
+import { memo, useEffect, useLayoutEffect, useRef, useState } from 'react';
+
+import { ProbeResizeHandles, type ResizeEdges } from './ProbeResizeHandles';
+import { useDragSession } from './useDragSession';
+import { useLatestRef } from './useLatestRef';
+
+// ============================================================================
+// Public types
+// ============================================================================
+
+/** Iteration cycler shown for a node inside a loop. */
+export interface IterationControl {
+ current: number;
+ total: number;
+ onPrev: () => void;
+ onNext: () => void;
+}
+
+/** A watch expression with its evaluated value. */
+export interface WatchResult {
+ id: string;
+ expression: string;
+ value: unknown;
+ /** True once a debug snapshot exists and the expression is non-empty. */
+ hasValue: boolean;
+}
+
+export interface ProbeCardProps {
+ watches: readonly WatchResult[];
+ iterationControl?: IterationControl;
+ onAddWatch: () => void;
+ onUpdateWatch: (watchId: string, expression: string) => void;
+ onRemoveWatch: (watchId: string) => void;
+ onDragStart: () => void;
+ onDrag: (cumulativeDelta: { x: number; y: number }) => void;
+ onDragEnd: () => void;
+ onResizeStart: () => void;
+ onResize: (cumulativeDelta: { x: number; y: number }, edges: ResizeEdges) => void;
+ onResizeEnd: () => void;
+ onClose: () => void;
+ /**
+ * Called when the user scrolls or middle-mouse-drags over the card while
+ * it is embedded in a canvas — allows the card to pan the underlying canvas
+ * instead of swallowing the gesture silently.
+ */
+ onCanvasPan?: (delta: { x: number; y: number }) => void;
+ /**
+ * Called when the user Ctrl+scrolls (pinch-to-zoom) over the card while
+ * embedded in a canvas — forwards the gesture to the canvas zoom handler.
+ */
+ onCanvasZoom?: (params: {
+ clientX: number;
+ clientY: number;
+ deltaY: number;
+ deltaMode: number;
+ ctrlKey: boolean;
+ }) => void;
+}
+
+// ============================================================================
+// Inline value renderer (no external dependencies)
+// ============================================================================
+
+function WatchValueView({ value, depth = 0 }: { value: unknown; depth?: number }) {
+ if (value === null) {
+ return null ;
+ }
+ if (value === undefined) {
+ return undefined ;
+ }
+ if (typeof value === 'string') {
+ return (
+
+ "{value}"
+
+ );
+ }
+ if (typeof value === 'number') {
+ return (
+
+ {String(value)}
+
+ );
+ }
+ if (typeof value === 'boolean') {
+ return (
+
+ {String(value)}
+
+ );
+ }
+ if (typeof value === 'object') {
+ const isArray = Array.isArray(value);
+ const entries = isArray
+ ? (value as unknown[]).map((v, i) => [String(i), v] as [string, unknown])
+ : Object.entries(value as Record);
+
+ if (entries.length === 0) {
+ return (
+
+ {isArray ? '[]' : '{}'}
+
+ );
+ }
+
+ if (depth >= 3) {
+ return (
+
+ {isArray ? `[…${entries.length}]` : '{…}'}
+
+ );
+ }
+
+ return (
+
+ {entries.map(([k, v]) => (
+
+ {k}:
+
+
+ ))}
+
+ );
+ }
+ return {String(value)} ;
+}
+
+// ============================================================================
+// Sub-components
+// ============================================================================
+
+function RemoveButton({ onRemove, label }: { onRemove: () => void; label: string }) {
+ return (
+ e.stopPropagation()}
+ onClick={onRemove}
+ className="opacity-0 group-hover:opacity-100 transition-opacity shrink-0 self-center"
+ title={label}
+ aria-label={label}
+ >
+
+
+ );
+}
+
+function DisclosureButton({
+ expanded,
+ onToggle,
+ label,
+}: {
+ expanded: boolean;
+ onToggle: () => void;
+ label: string;
+}) {
+ return (
+ e.stopPropagation()}
+ onClick={onToggle}
+ title={label}
+ aria-label={label}
+ aria-expanded={expanded}
+ >
+ {expanded ? : }
+
+ );
+}
+
+function WatchRow({
+ watch,
+ onChange,
+ onRemove,
+}: {
+ watch: WatchResult;
+ onChange: (expression: string) => void;
+ onRemove: () => void;
+}) {
+ const [expr, setExpr] = useState(watch.expression);
+ const [expanded, setExpanded] = useState(true);
+
+ useEffect(() => {
+ setExpr(watch.expression);
+ }, [watch.expression]);
+
+ const commit = () => {
+ if (expr !== watch.expression) onChange(expr);
+ };
+
+ return (
+
+
+ setExpanded((e) => !e)}
+ label="Toggle value"
+ />
+ setExpr(e.target.value)}
+ onBlur={commit}
+ onKeyDown={(e) => {
+ e.stopPropagation();
+ if (e.key === 'Enter') commit();
+ }}
+ placeholder="e.g. output.items[0].id"
+ aria-label="Watch expression"
+ data-probe-watch-input="true"
+ className="flex-1 min-w-0 text-sm font-mono text-foreground-secondary bg-transparent outline-none border-b border-transparent focus:border-foreground-accent"
+ />
+
+
+ {expanded && watch.hasValue && (
+
+
+
+ )}
+
+ );
+}
+
+// ============================================================================
+// ProbeCard
+// ============================================================================
+
+const PAN_ON_SCROLL_SPEED = 0.5;
+
+function ProbeCardComponent({
+ watches,
+ iterationControl,
+ onAddWatch,
+ onUpdateWatch,
+ onRemoveWatch,
+ onDragStart,
+ onDrag,
+ onDragEnd,
+ onResizeStart,
+ onResize,
+ onResizeEnd,
+ onClose,
+ onCanvasPan,
+ onCanvasZoom,
+}: ProbeCardProps) {
+ const [hovered, setHovered] = useState(false);
+ const cardRef = useRef(null);
+ const watchListRef = useRef(null);
+ const pendingFocusNewWatchRef = useRef(false);
+ const previousWatchCountRef = useRef(watches.length);
+ const onCloseRef = useLatestRef(onClose);
+ const onCanvasPanRef = useLatestRef(onCanvasPan);
+ const onCanvasZoomRef = useLatestRef(onCanvasZoom);
+ const isSpacePressedRef = useRef(false);
+ const canvasPanCleanupRef = useRef<(() => void) | null>(null);
+ const canvasPanLastPointRef = useRef<{ x: number; y: number } | null>(null);
+ const suppressNextClickRef = useRef(false);
+
+ const handleHeaderMouseDown = useDragSession({
+ onStart: onDragStart,
+ onMove: onDrag,
+ onEnd: onDragEnd,
+ });
+
+ const handleAddWatch = () => {
+ pendingFocusNewWatchRef.current = true;
+ onAddWatch();
+ };
+
+ useLayoutEffect(() => {
+ const previousCount = previousWatchCountRef.current;
+ previousWatchCountRef.current = watches.length;
+ if (!pendingFocusNewWatchRef.current || watches.length <= previousCount) return;
+ pendingFocusNewWatchRef.current = false;
+ const inputs = watchListRef.current?.querySelectorAll(
+ '[data-probe-watch-input="true"]'
+ );
+ const input = inputs?.[inputs.length - 1];
+ if (!input) return;
+ input.scrollIntoView({ block: 'nearest' });
+ input.focus({ preventScroll: true });
+ }, [watches.length]);
+
+ // Delete/Backspace removes the probe when the card has focus and no editable
+ // field is active. Capture phase runs before ReactFlow's document-level handler.
+ useEffect(() => {
+ const handler = (e: KeyboardEvent) => {
+ if (e.key !== 'Delete' && e.key !== 'Backspace') return;
+ const card = cardRef.current;
+ if (!card || !card.contains(document.activeElement)) return;
+ const active = document.activeElement as HTMLElement | null;
+ const isEditable =
+ !!active &&
+ (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA' || active.isContentEditable);
+ if (isEditable) return;
+ e.stopPropagation();
+ e.preventDefault();
+ onCloseRef.current();
+ };
+ window.addEventListener('keydown', handler, true);
+ return () => window.removeEventListener('keydown', handler, true);
+ }, [onCloseRef]);
+
+ useEffect(() => {
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (e.key === ' ' || e.code === 'Space') isSpacePressedRef.current = true;
+ };
+ const handleKeyUp = (e: KeyboardEvent) => {
+ if (e.key === ' ' || e.code === 'Space') isSpacePressedRef.current = false;
+ };
+ window.addEventListener('keydown', handleKeyDown, true);
+ window.addEventListener('keyup', handleKeyUp, true);
+ return () => {
+ window.removeEventListener('keydown', handleKeyDown, true);
+ window.removeEventListener('keyup', handleKeyUp, true);
+ canvasPanCleanupRef.current?.();
+ };
+ }, []);
+
+ const startCanvasPan = (e: React.MouseEvent) => {
+ if (!onCanvasPanRef.current) return;
+ e.stopPropagation();
+ e.preventDefault();
+ suppressNextClickRef.current = true;
+ canvasPanCleanupRef.current?.();
+ canvasPanLastPointRef.current = { x: e.clientX, y: e.clientY };
+
+ const handleMove = (ev: MouseEvent) => {
+ const last = canvasPanLastPointRef.current;
+ if (!last) return;
+ const next = { x: ev.clientX, y: ev.clientY };
+ canvasPanLastPointRef.current = next;
+ onCanvasPanRef.current?.({ x: next.x - last.x, y: next.y - last.y });
+ };
+ const handleUp = () => {
+ window.removeEventListener('mousemove', handleMove);
+ window.removeEventListener('mouseup', handleUp);
+ canvasPanCleanupRef.current = null;
+ canvasPanLastPointRef.current = null;
+ window.setTimeout(() => {
+ suppressNextClickRef.current = false;
+ }, 0);
+ };
+ canvasPanCleanupRef.current = handleUp;
+ window.addEventListener('mousemove', handleMove);
+ window.addEventListener('mouseup', handleUp);
+ };
+
+ const handleMouseDownCapture = (e: React.MouseEvent) => {
+ const target = e.target instanceof Element ? e.target : null;
+ const startedMiddlePan = e.button === 1;
+ const startedSpacePan =
+ e.button === 0 && isSpacePressedRef.current && !isInteractiveTarget(target);
+ if (startedMiddlePan || startedSpacePan) {
+ startCanvasPan(e);
+ return;
+ }
+ if (e.button === 0) cardRef.current?.focus();
+ };
+
+ const handleClickCapture = (e: React.MouseEvent) => {
+ if (!suppressNextClickRef.current) return;
+ suppressNextClickRef.current = false;
+ e.stopPropagation();
+ e.preventDefault();
+ };
+
+ // Non-passive wheel listener so we can preventDefault on pinch-to-zoom
+ useEffect(() => {
+ const card = cardRef.current;
+ if (!card) return;
+
+ const onWheel = (e: WheelEvent) => {
+ if (e.ctrlKey) {
+ // Only consume pinch-to-zoom when a handler is wired; otherwise let
+ // the gesture bubble up to the parent canvas.
+ if (onCanvasZoomRef.current) {
+ e.stopPropagation();
+ e.preventDefault();
+ onCanvasZoomRef.current({
+ clientX: e.clientX,
+ clientY: e.clientY,
+ deltaY: e.deltaY,
+ deltaMode: e.deltaMode,
+ ctrlKey: e.ctrlKey,
+ });
+ }
+ return;
+ }
+ const target = e.target instanceof Node ? e.target : null;
+ if (
+ target &&
+ watchListRef.current?.contains(target) &&
+ canScrollVertically(watchListRef.current, e.deltaY)
+ ) {
+ // Watch list is scrolling — consume but don't forward to canvas.
+ e.stopPropagation();
+ return;
+ }
+ // Only forward pan when a handler is wired; otherwise let the wheel
+ // event bubble to the canvas so pan still works over the card.
+ if (onCanvasPanRef.current) {
+ e.stopPropagation();
+ e.preventDefault();
+ const deltaNormalize = e.deltaMode === 1 ? 20 : 1;
+ let deltaX = e.deltaX * deltaNormalize;
+ let deltaY = e.deltaY * deltaNormalize;
+ if (!isMac() && e.shiftKey) {
+ deltaX = e.deltaY * deltaNormalize;
+ deltaY = 0;
+ }
+ onCanvasPanRef.current({
+ x: -deltaX * PAN_ON_SCROLL_SPEED,
+ y: -deltaY * PAN_ON_SCROLL_SPEED,
+ });
+ }
+ };
+
+ card.addEventListener('wheel', onWheel, { passive: false });
+ return () => card.removeEventListener('wheel', onWheel);
+ }, [onCanvasZoomRef, onCanvasPanRef]);
+
+ return (
+ // biome-ignore lint/a11y/useSemanticElements: no semantic element covers a draggable, resizable, keyboard-navigable floating overlay
+ e.stopPropagation()}
+ onClick={(e) => e.stopPropagation()}
+ onKeyDown={(e) => e.stopPropagation()}
+ onMouseEnter={() => setHovered(true)}
+ onMouseLeave={() => setHovered(false)}
+ >
+ {/* Header */}
+
+
Probe
+
+ {iterationControl && (
+
+ e.stopPropagation()}
+ onClick={iterationControl.onPrev}
+ title="Previous iteration"
+ aria-label="Previous iteration"
+ >
+
+
+
+ {iterationControl.current + 1}/{iterationControl.total}
+
+ e.stopPropagation()}
+ onClick={iterationControl.onNext}
+ title="Next iteration"
+ aria-label="Next iteration"
+ >
+
+
+
+ )}
+
+
e.stopPropagation()}
+ onClick={handleAddWatch}
+ title="Add watch"
+ aria-label="Add watch"
+ >
+
+
+
e.stopPropagation()}
+ onClick={onClose}
+ title="Remove probe"
+ aria-label="Remove probe"
+ >
+
+
+
+
+ {/* Watch list */}
+
+ {watches.length === 0 ? (
+
+
+ No watches — use + to add one
+
+
+ ) : (
+ watches.map((w) => (
+
onUpdateWatch(w.id, expr)}
+ onRemove={() => onRemoveWatch(w.id)}
+ />
+ ))
+ )}
+
+
+
+
+ );
+}
+
+/**
+ * ProbeCard — a floating debug card for inspecting canvas node output values.
+ *
+ * Memoized so it skips re-rendering when the parent re-renders only because
+ * the canvas viewport panned or zoomed — all props are referentially stable
+ * across those frames.
+ *
+ * The card is fully controlled: the caller owns position, size, and the watch
+ * list. ProbeCard handles all interactions internally (drag, resize, keyboard
+ * shortcuts, scroll-to-pan, and pinch-to-zoom forwarding).
+ *
+ * ### Integration pattern
+ *
+ * 1. **State** — maintain `offset`, `size`, and `watches` in your own store.
+ * 2. **Position** — render the card `position: absolute` inside a container
+ * that covers the canvas viewport. Calculate `left`/`top` from the anchor
+ * node's canvas-to-screen coordinates and the stored `offset`.
+ * 3. **Connector** — draw an SVG dashed line from the anchor node's edge to
+ * the card's center using the same coordinate conversion.
+ * 4. **Watch values** — evaluate `watch.expression` against the node's runtime
+ * output snapshot and pass results as `WatchResult[]`. Set `hasValue: true`
+ * only when a snapshot is available so the value row is shown.
+ * 5. **Canvas pan/zoom** — pass `onCanvasPan` and `onCanvasZoom` so scroll and
+ * middle-mouse gestures over the card are forwarded to the canvas viewport
+ * instead of being swallowed.
+ *
+ * @example
+ * ```tsx
+ * import { ProbeCard } from '@uipath/apollo-react/canvas';
+ *
+ *
+ *
addWatch(probeId)}
+ * onUpdateWatch={(id, expr) => updateWatch(probeId, id, expr)}
+ * onRemoveWatch={(id) => removeWatch(probeId, id)}
+ * onDragStart={() => captureOffset()}
+ * onDrag={(delta) => setOffset(prev => ({ x: prev.x + delta.x / zoom, y: prev.y + delta.y / zoom }))}
+ * onDragEnd={() => persistOffset()}
+ * onResizeStart={() => captureSize()}
+ * onResize={(delta, edges) => applyResize(delta, edges)}
+ * onResizeEnd={() => persistSize()}
+ * onClose={() => removeProbe(probeId)}
+ * onCanvasPan={(delta) => panBy(delta)}
+ * onCanvasZoom={(params) => zoomAtPoint(params)}
+ * />
+ *
+ * ```
+ */
+export const ProbeCard = memo(ProbeCardComponent);
+
+// ============================================================================
+// Helpers
+// ============================================================================
+
+function isInteractiveTarget(target: Element | null): boolean {
+ return !!target?.closest(
+ 'input, textarea, select, button, a, [contenteditable], [role="button"], [role="menuitem"]'
+ );
+}
+
+function isMac(): boolean {
+ return typeof navigator !== 'undefined' && navigator.userAgent.includes('Mac');
+}
+
+function canScrollVertically(element: HTMLElement, deltaY: number): boolean {
+ if (deltaY === 0) return false;
+ const maxScrollTop = element.scrollHeight - element.clientHeight;
+ if (maxScrollTop <= 0) return false;
+ return deltaY < 0 ? element.scrollTop > 0 : element.scrollTop < maxScrollTop;
+}
diff --git a/packages/apollo-react/src/canvas/components/ProbeCard/ProbeResizeHandles.tsx b/packages/apollo-react/src/canvas/components/ProbeCard/ProbeResizeHandles.tsx
new file mode 100644
index 000000000..430e7d273
--- /dev/null
+++ b/packages/apollo-react/src/canvas/components/ProbeCard/ProbeResizeHandles.tsx
@@ -0,0 +1,79 @@
+import { useDragSession } from './useDragSession';
+
+export interface ResizeEdges {
+ left?: boolean;
+ right?: boolean;
+ top?: boolean;
+ bottom?: boolean;
+}
+
+interface ProbeResizeHandlesProps {
+ active: boolean;
+ onResizeStart: () => void;
+ onResize: (cumulativeDelta: { x: number; y: number }, edges: ResizeEdges) => void;
+ onResizeEnd: () => void;
+}
+
+const HANDLES: { edges: ResizeEdges; style: React.CSSProperties; cursor: string }[] = [
+ { edges: { top: true, left: true }, style: { top: -4, left: -4 }, cursor: 'nwse-resize' },
+ { edges: { top: true, right: true }, style: { top: -4, right: -4 }, cursor: 'nesw-resize' },
+ { edges: { bottom: true, right: true }, style: { bottom: -4, right: -4 }, cursor: 'nwse-resize' },
+ { edges: { bottom: true, left: true }, style: { bottom: -4, left: -4 }, cursor: 'nesw-resize' },
+];
+
+export function ProbeResizeHandles({
+ active,
+ onResizeStart,
+ onResize,
+ onResizeEnd,
+}: ProbeResizeHandlesProps) {
+ return (
+
+
+ {HANDLES.map((h, i) => (
+
+ ))}
+
+ );
+}
+
+function ResizeHandle({
+ edges,
+ style,
+ cursor,
+ onResizeStart,
+ onResize,
+ onResizeEnd,
+}: {
+ edges: ResizeEdges;
+ style: React.CSSProperties;
+ cursor: string;
+ onResizeStart: () => void;
+ onResize: (cumulativeDelta: { x: number; y: number }, edges: ResizeEdges) => void;
+ onResizeEnd: () => void;
+}) {
+ const handleMouseDown = useDragSession({
+ onStart: onResizeStart,
+ onMove: (delta) => onResize(delta, edges),
+ onEnd: onResizeEnd,
+ });
+
+ return (
+
+ );
+}
diff --git a/packages/apollo-react/src/canvas/components/ProbeCard/index.ts b/packages/apollo-react/src/canvas/components/ProbeCard/index.ts
new file mode 100644
index 000000000..fa5808bbc
--- /dev/null
+++ b/packages/apollo-react/src/canvas/components/ProbeCard/index.ts
@@ -0,0 +1,21 @@
+/**
+ * ProbeCard — floating debug card for canvas node output inspection.
+ *
+ * Import from the canvas barrel:
+ * ```ts
+ * import { ProbeCard } from '@uipath/apollo-react/canvas';
+ * import type { WatchResult, IterationControl, ProbeCardProps, ResizeEdges } from '@uipath/apollo-react/canvas';
+ * ```
+ *
+ * The card is UI-only. Callers own:
+ * - **Position & size** — calculated from node canvas→screen coordinates
+ * - **Watch list** — expressions + pre-evaluated `WatchResult[]` values
+ * - **Connector line** — SVG dashed line from node edge to card center
+ * - **Store** — persistence (localStorage, Zustand, etc.)
+ *
+ * See `ProbeCardProps` for the full callback contract.
+ */
+
+export type { IterationControl, ProbeCardProps, WatchResult } from './ProbeCard';
+export { ProbeCard } from './ProbeCard';
+export type { ResizeEdges } from './ProbeResizeHandles';
diff --git a/packages/apollo-react/src/canvas/components/ProbeCard/useDragSession.ts b/packages/apollo-react/src/canvas/components/ProbeCard/useDragSession.ts
new file mode 100644
index 000000000..e3cc97fee
--- /dev/null
+++ b/packages/apollo-react/src/canvas/components/ProbeCard/useDragSession.ts
@@ -0,0 +1,49 @@
+import { useCallback, useEffect, useRef } from 'react';
+import { useLatestRef } from './useLatestRef';
+
+interface DragSessionHandlers {
+ onStart?: () => void;
+ onMove?: (cumulativeDelta: { x: number; y: number }) => void;
+ onEnd?: () => void;
+}
+
+/**
+ * Pointer-drag session: captures clientX/Y on mousedown, calls `onMove` with
+ * cumulative deltas, and cleans up window listeners on mouseup or unmount.
+ */
+export function useDragSession(handlers: DragSessionHandlers): (e: React.MouseEvent) => void {
+ const handlersRef = useLatestRef(handlers);
+ const startRef = useRef<{ x: number; y: number } | null>(null);
+ const cleanupRef = useRef<(() => void) | null>(null);
+
+ useEffect(() => () => cleanupRef.current?.(), []);
+
+ // biome-ignore lint/correctness/useExhaustiveDependencies: handlers are read via a stable ref so the callback never needs to be recreated
+ return useCallback((e: React.MouseEvent) => {
+ if (e.button !== 0) return;
+ e.stopPropagation();
+ e.preventDefault();
+ startRef.current = { x: e.clientX, y: e.clientY };
+ handlersRef.current.onStart?.();
+
+ const handleMove = (ev: MouseEvent) => {
+ const start = startRef.current;
+ if (!start) return;
+ handlersRef.current.onMove?.({ x: ev.clientX - start.x, y: ev.clientY - start.y });
+ };
+ const handleUp = () => {
+ startRef.current = null;
+ window.removeEventListener('mousemove', handleMove);
+ window.removeEventListener('mouseup', handleUp);
+ cleanupRef.current = null;
+ handlersRef.current.onEnd?.();
+ };
+ cleanupRef.current = () => {
+ window.removeEventListener('mousemove', handleMove);
+ window.removeEventListener('mouseup', handleUp);
+ startRef.current = null;
+ };
+ window.addEventListener('mousemove', handleMove);
+ window.addEventListener('mouseup', handleUp);
+ }, []);
+}
diff --git a/packages/apollo-react/src/canvas/components/ProbeCard/useLatestRef.ts b/packages/apollo-react/src/canvas/components/ProbeCard/useLatestRef.ts
new file mode 100644
index 000000000..efd541039
--- /dev/null
+++ b/packages/apollo-react/src/canvas/components/ProbeCard/useLatestRef.ts
@@ -0,0 +1,11 @@
+import { useEffect, useLayoutEffect, useRef } from 'react';
+
+const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect;
+
+export function useLatestRef(value: T) {
+ const ref = useRef(value);
+ useIsomorphicLayoutEffect(() => {
+ ref.current = value;
+ }, [value]);
+ return ref;
+}
diff --git a/packages/apollo-react/src/canvas/components/index.ts b/packages/apollo-react/src/canvas/components/index.ts
index eec8b1c64..08d0cbe12 100644
--- a/packages/apollo-react/src/canvas/components/index.ts
+++ b/packages/apollo-react/src/canvas/components/index.ts
@@ -18,9 +18,11 @@ export * from './MiniCanvasNavigator';
export * from './NodeContextMenu';
export * from './NodeInspector';
export * from './NodePropertiesPanel';
-export * from './shared';
+export * from './NodePropertyPanel';
+export * from './ProbeCard';
export * from './StageNode';
export * from './StickyNoteNode';
+export * from './shared';
export * from './TaskIcon';
export * from './Toolbar';
export * from './Toolbox';