diff --git a/app/package.json b/app/package.json index 3fc7bc09a4..bfb54dd2d3 100644 --- a/app/package.json +++ b/app/package.json @@ -91,6 +91,7 @@ "@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-os": "^2.3.2", "@types/three": "^0.183.1", + "@xyflow/react": "^12.11.1", "buffer": "^6.0.3", "cmdk": "^1.1.1", "d3-force": "^3.0.0", diff --git a/app/src/AppRoutes.tsx b/app/src/AppRoutes.tsx index d3d19a7557..62a3ba9f52 100644 --- a/app/src/AppRoutes.tsx +++ b/app/src/AppRoutes.tsx @@ -12,6 +12,7 @@ import Accounts from './pages/Accounts'; import Brain from './pages/Brain'; import AgentInsightsPreview from './pages/dev/AgentInsightsPreview'; import Feedback from './pages/Feedback'; +import FlowCanvasPage from './pages/FlowCanvasPage'; import FlowsPage from './pages/FlowsPage'; import Invites from './pages/Invites'; import Notifications from './pages/Notifications'; @@ -96,18 +97,30 @@ const AppRoutes = ({ location }: AppRoutesProps = {}) => { /> {/* Workflows — the `flows::` domain's discoverable list hub (issue - B5a). Distinct from the legacy SKILL.md `/workflows/*` Skill routes - below (create/run) and their `/workflows` → `/settings/automations` - back-compat redirect, which stay untouched. The canvas (B5b) and - agent-proposal surface (B4) are separate, later work. */} + B5a) plus the read-only Workflow Canvas (issue B5b.1) at + `/flows/:id`. Distinct from the legacy SKILL.md `/workflows/*` + Skill routes below (create/run) and their `/workflows` → + `/settings/automations` back-compat redirect, which stay untouched. + Not a tab-level route (unlike `/flows` itself, `/flows/:id` isn't + reached from the BottomTabBar), so `navigation.spec.ts`'s ROUTES + table needs no change. Full editing (B5b.2+) and the agent-proposal + surface (B4) are separate, later work. */} } /> + + + + } + /> {/* Back-compat: /activity and /intelligence → settings notifications page. */} } /> diff --git a/app/src/components/flows/FlowListRow.test.tsx b/app/src/components/flows/FlowListRow.test.tsx index b85637cb5a..d3e86efed1 100644 --- a/app/src/components/flows/FlowListRow.test.tsx +++ b/app/src/components/flows/FlowListRow.test.tsx @@ -1,8 +1,10 @@ /** - * FlowListRow (issue B5a / B5a.1) — one saved-flow row on the Workflows list - * page. Asserts the name/status rendering, the last-run/never-run text - * (including the localized relative-time strings), and that the - * toggle/Run/View runs controls call back with the row's `Flow`. + * FlowListRow (issue B5a / B5a.1 / B5b.1) — one saved-flow row on the + * Workflows list page. Asserts the name/status rendering, the + * last-run/never-run text (including the localized relative-time strings), + * that the toggle/Run/View runs controls call back with the row's `Flow`, + * and that the flow name itself is the "View" affordance that opens the + * read-only Workflow Canvas (issue B5b.1). */ import { fireEvent, screen } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; @@ -29,7 +31,13 @@ function makeFlow(overrides: Partial = {}): Flow { describe('FlowListRow', () => { it('renders the flow name and an Enabled badge when enabled', () => { renderWithProviders( - + ); expect(screen.getByText('Daily digest')).toBeInTheDocument(); @@ -43,6 +51,7 @@ describe('FlowListRow', () => { onToggle={vi.fn()} onRun={vi.fn()} onViewRuns={vi.fn()} + onView={vi.fn()} /> ); @@ -51,7 +60,13 @@ describe('FlowListRow', () => { it('shows "Never run" when the flow has no last_run_at', () => { renderWithProviders( - + ); expect(screen.getByText('Never run')).toBeInTheDocument(); @@ -64,6 +79,7 @@ describe('FlowListRow', () => { onToggle={vi.fn()} onRun={vi.fn()} onViewRuns={vi.fn()} + onView={vi.fn()} /> ); @@ -78,6 +94,7 @@ describe('FlowListRow', () => { onToggle={vi.fn()} onRun={vi.fn()} onViewRuns={vi.fn()} + onView={vi.fn()} /> ); @@ -92,6 +109,7 @@ describe('FlowListRow', () => { onToggle={vi.fn()} onRun={vi.fn()} onViewRuns={vi.fn()} + onView={vi.fn()} /> ); @@ -106,6 +124,7 @@ describe('FlowListRow', () => { onToggle={vi.fn()} onRun={vi.fn()} onViewRuns={vi.fn()} + onView={vi.fn()} /> ); @@ -115,7 +134,13 @@ describe('FlowListRow', () => { it('calls onToggle with the flow when the switch is clicked', () => { const onToggle = vi.fn(); renderWithProviders( - + ); fireEvent.click(screen.getByTestId('flow-toggle-flow-1')); @@ -126,7 +151,13 @@ describe('FlowListRow', () => { it('calls onRun with the flow when the Run button is clicked', () => { const onRun = vi.fn(); renderWithProviders( - + ); fireEvent.click(screen.getByTestId('flow-run-flow-1')); @@ -137,7 +168,13 @@ describe('FlowListRow', () => { it('renders a "View runs" control and calls onViewRuns with the flow when clicked', () => { const onViewRuns = vi.fn(); renderWithProviders( - + ); const viewRunsButton = screen.getByTestId('flow-view-runs-flow-1'); @@ -147,6 +184,25 @@ describe('FlowListRow', () => { expect(onViewRuns).toHaveBeenCalledWith(makeFlow()); }); + it('renders the flow name as a "View" affordance and calls onView with the flow when clicked', () => { + const onView = vi.fn(); + renderWithProviders( + + ); + + const viewButton = screen.getByTestId('flow-view-flow-1'); + expect(viewButton).toHaveTextContent('Daily digest'); + fireEvent.click(viewButton); + + expect(onView).toHaveBeenCalledWith(makeFlow()); + }); + it('shows the running label and disables Run while busy', () => { renderWithProviders( { onToggle={vi.fn()} onRun={vi.fn()} onViewRuns={vi.fn()} + onView={vi.fn()} busy="run" /> ); @@ -170,6 +227,7 @@ describe('FlowListRow', () => { onToggle={vi.fn()} onRun={vi.fn()} onViewRuns={vi.fn()} + onView={vi.fn()} busy="toggle" /> ); diff --git a/app/src/components/flows/FlowListRow.tsx b/app/src/components/flows/FlowListRow.tsx index 91df423e7c..96cc9bd64d 100644 --- a/app/src/components/flows/FlowListRow.tsx +++ b/app/src/components/flows/FlowListRow.tsx @@ -11,6 +11,14 @@ * "View runs" (issue B5a.1) opens `FlowRunsDrawer` (mounted by `FlowsPage`) * for this flow's run history — re-added now that B3b's run inspector has * landed and the drawer has somewhere to send the user. + * + * The flow name (issue B5b.1) is itself the "View" affordance for the new + * read-only Workflow Canvas: it's rendered as a button that calls `onView`, + * which `FlowsPage` wires to `navigate('/flows/' + flow.id)`. Kept as the + * name itself (not a separate icon button) since it's the row's most + * prominent, discoverable element and "view this flow's graph" is the most + * natural action to hang off it — "View runs" and "Run" stay distinct + * actions in the button row below. */ import { useT } from '../../lib/i18n/I18nContext'; import type { Flow } from '../../services/api/flowsApi'; @@ -28,6 +36,8 @@ export interface FlowListRowProps { onToggle: (flow: Flow) => void; onRun: (flow: Flow) => void; onViewRuns: (flow: Flow) => void; + /** Opens the read-only Workflow Canvas for this flow (issue B5b.1). */ + onView: (flow: Flow) => void; busy?: FlowListRowBusy; } @@ -56,7 +66,14 @@ function capitalize(value: string): string { return value.length > 0 ? value.charAt(0).toUpperCase() + value.slice(1) : value; } -const FlowListRow = ({ flow, onToggle, onRun, onViewRuns, busy = null }: FlowListRowProps) => { +const FlowListRow = ({ + flow, + onToggle, + onRun, + onViewRuns, + onView, + busy = null, +}: FlowListRowProps) => { const { t } = useT(); const toggleBusy = busy === 'toggle'; const runBusy = busy === 'run'; @@ -72,7 +89,15 @@ const FlowListRow = ({ flow, onToggle, onRun, onViewRuns, busy = null }: FlowLis className="space-y-3 border-t border-line p-4 first:border-t-0">
-
{flow.name}
+
{lastRunLabel}
+ readonly ? { nodesDraggable: false, nodesConnectable: false, elementsSelectable: false } : {}, + [readonly] + ); + + return ( +
+ + + + + +
+ ); +} + +export default memo(FlowCanvas); diff --git a/app/src/components/flows/canvas/FlowNodeComponent.tsx b/app/src/components/flows/canvas/FlowNodeComponent.tsx new file mode 100644 index 0000000000..8799007a84 --- /dev/null +++ b/app/src/components/flows/canvas/FlowNodeComponent.tsx @@ -0,0 +1,150 @@ +/** + * FlowNodeComponent — the custom xyflow node renderer for the read-only + * Workflow Canvas (issue B5b.1). Renders one rounded card per `WorkflowNode`: + * a per-kind emoji + colored accent, the node's name, and a `Handle` per + * effective input port (left) / output port (right) — see + * `graphAdapter.ts`'s `FlowNodeData` for why "effective" ports aren't simply + * `data.ports`. + * + * Emoji (not an icon library) matches the repo's existing convention — + * there is no `lucide-react` (or any icon-font) dependency in this app today + * (icons are hand-rolled inline SVG, see `components/ui/icons.tsx`), and + * adding one is out of scope for this slice's single approved dependency + * (`@xyflow/react`). + * + * An unrecognized `kind` (not one of the 12 `NodeKind` values — e.g. a future + * tinyflows addition, since `Flow.graph` is `unknown` on the wire) renders as + * a plain neutral node rather than throwing, since a thrown render error here + * has no error boundary around `` and would take down the whole + * canvas. + */ +import { Handle, type NodeProps, Position } from '@xyflow/react'; +import { memo } from 'react'; + +import type { FlowNode } from '../../../lib/flows/graphAdapter'; +import type { NodeKind } from '../../../lib/flows/types'; +import { useT } from '../../../lib/i18n/I18nContext'; + +type NodeColor = 'sage' | 'primary' | 'amber' | 'coral' | 'neutral'; + +/** Per-kind emoji + border/chip color. Colors cycle through the four + * CSS-variable-backed semantic ramps (primary/sage/amber/coral) that support + * Tailwind's `/opacity` modifiers in this codebase (see `tailwind.config.js`) + * so light/dark theming comes for free; with 12 kinds and 4 ramps some kinds + * share a color family; the emoji + name remain the primary distinguishers. + * + * `data.kind` is typed as the 12-entry `NodeKind` union, but a saved graph is + * `unknown` on the wire (cast in `FlowCanvasPage.tsx`) — a future 13th + * tinyflows kind, or any other value the backend ever emits, can reach this + * map at runtime even though TypeScript can't see it. Index lookups below + * fall back to {@link DEFAULT_NODE_META} rather than assuming a hit, so an + * unrecognized kind renders as a plain neutral node instead of crashing the + * whole canvas (there's no error boundary around ``). + */ +const NODE_KIND_META: Record = { + trigger: { emoji: '⚡', color: 'sage' }, + agent: { emoji: '🤖', color: 'primary' }, + tool_call: { emoji: '🔧', color: 'amber' }, + http_request: { emoji: '🌐', color: 'coral' }, + code: { emoji: '📝', color: 'sage' }, + condition: { emoji: '🔀', color: 'primary' }, + switch: { emoji: '🔁', color: 'amber' }, + merge: { emoji: '🔗', color: 'coral' }, + split_out: { emoji: '📤', color: 'sage' }, + transform: { emoji: '♻️', color: 'primary' }, + output_parser: { emoji: '📋', color: 'amber' }, + sub_workflow: { emoji: '🧩', color: 'coral' }, +}; + +/** Fallback for any `kind` outside the map above — see the doc comment on `NODE_KIND_META`. */ +const DEFAULT_NODE_META: { emoji: string; color: NodeColor } = { emoji: '❔', color: 'neutral' }; + +const COLOR_CLASSES: Record = { + sage: { + border: 'border-sage-400 dark:border-sage-500/60', + chip: 'bg-sage-100 dark:bg-sage-500/20', + }, + primary: { + border: 'border-primary-400 dark:border-primary-500/60', + chip: 'bg-primary-100 dark:bg-primary-500/20', + }, + amber: { + border: 'border-amber-400 dark:border-amber-500/60', + chip: 'bg-amber-100 dark:bg-amber-500/20', + }, + coral: { + border: 'border-coral-400 dark:border-coral-500/60', + chip: 'bg-coral-100 dark:bg-coral-500/20', + }, + neutral: { border: 'border-line-strong', chip: 'bg-surface-subtle' }, +}; + +/** Even vertical offsets (in %) for `count` handles along one side of the card. */ +function handleOffsets(count: number): number[] { + if (count <= 1) return [50]; + return Array.from({ length: count }, (_, i) => ((i + 1) / (count + 1)) * 100); +} + +function FlowNodeComponent({ data, selected }: NodeProps) { + const { t } = useT(); + const meta = NODE_KIND_META[data.kind] ?? DEFAULT_NODE_META; + const colors = COLOR_CLASSES[meta.color]; + const inputOffsets = handleOffsets(data.inputPorts.length); + const outputOffsets = handleOffsets(data.outputPorts.length); + const kindLabel = t(`flows.nodeKind.${data.kind}`, data.kind); + + return ( +
+ {data.inputPorts.map((port, i) => ( + + ))} + +
+ +
+
{data.name}
+
+ {kindLabel} +
+
+
+ + {data.outputPorts.length > 1 && ( +
+ {data.outputPorts.map(port => ( +
+ {port} +
+ ))} +
+ )} + + {data.outputPorts.map((port, i) => ( + + ))} +
+ ); +} + +export default memo(FlowNodeComponent); diff --git a/app/src/components/flows/canvas/__tests__/FlowCanvas.test.tsx b/app/src/components/flows/canvas/__tests__/FlowCanvas.test.tsx new file mode 100644 index 0000000000..aff8acd33d --- /dev/null +++ b/app/src/components/flows/canvas/__tests__/FlowCanvas.test.tsx @@ -0,0 +1,102 @@ +/** + * FlowCanvas (issue B5b.1) — smoke tests for the read-only Workflow Canvas + * wrapper around `@xyflow/react`'s ``. Asserts it mounts with a + * sample node/edge set (no crash — `@xyflow/react` needs a measurable + * container, which jsdom doesn't provide, so nodes render at 0x0 but the DOM + * tree, `nodeTypes` wiring, and the minimap/controls/background chrome are + * all still verifiable) and that it renders each node via `FlowNodeComponent` + * (by asserting the node's name text appears). + */ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; + +import type { FlowEdge, FlowNode, FlowNodeData } from '../../../../lib/flows/graphAdapter'; +import type { NodeKind } from '../../../../lib/flows/types'; +import FlowCanvas from '../FlowCanvas'; + +function sampleNodes(): FlowNode[] { + return [ + { + id: 't', + type: 'flowNode', + position: { x: 0, y: 0 }, + data: { + kind: 'trigger', + name: 'Start', + config: {}, + ports: [], + inputPorts: ['main'], + outputPorts: ['main'], + }, + }, + { + id: 'a', + type: 'flowNode', + position: { x: 280, y: 0 }, + data: { + kind: 'agent', + name: 'Reply', + config: {}, + ports: [], + inputPorts: ['main'], + outputPorts: ['main'], + }, + }, + ]; +} + +function sampleEdges(): FlowEdge[] { + return [ + { id: 't-main-a-main', source: 't', target: 'a', sourceHandle: 'main', targetHandle: 'main' }, + ]; +} + +describe('FlowCanvas', () => { + it('renders without crashing given sample nodes/edges', () => { + render(); + expect(screen.getByTestId('flow-canvas')).toBeInTheDocument(); + }); + + it('renders each node via FlowNodeComponent (shows the node names)', () => { + render(); + expect(screen.getByText('Start')).toBeInTheDocument(); + expect(screen.getByText('Reply')).toBeInTheDocument(); + }); + + it('renders the minimap and zoom/pan controls', () => { + const { container } = render(); + expect(container.querySelector('.react-flow__minimap')).not.toBeNull(); + expect(container.querySelector('.react-flow__controls')).not.toBeNull(); + expect(container.querySelector('.react-flow__background')).not.toBeNull(); + }); + + it('renders an empty canvas with no nodes/edges', () => { + render(); + expect(screen.getByTestId('flow-canvas')).toBeInTheDocument(); + }); + + it('renders a node with an unrecognized kind as a plain node instead of crashing', () => { + // `Flow.graph` is `unknown` on the wire — a future 13th tinyflows kind (or + // any unexpected value) must not crash the whole canvas (no error + // boundary wraps ``). Cast past the `NodeKind` union the same + // way a real cast-from-`unknown` graph would. + const unknownKindNode: FlowNode = { + id: 'mystery', + type: 'flowNode', + position: { x: 0, y: 0 }, + data: { + kind: 'time_travel' as unknown as NodeKind, + name: 'Mystery node', + config: {}, + ports: [], + inputPorts: ['main'], + outputPorts: ['main'], + } satisfies FlowNodeData, + }; + + render(); + + expect(screen.getByTestId('flow-canvas')).toBeInTheDocument(); + expect(screen.getByText('Mystery node')).toBeInTheDocument(); + }); +}); diff --git a/app/src/components/flows/canvas/flowCanvasStyles.css b/app/src/components/flows/canvas/flowCanvasStyles.css new file mode 100644 index 0000000000..10bda1b4b9 --- /dev/null +++ b/app/src/components/flows/canvas/flowCanvasStyles.css @@ -0,0 +1,65 @@ +/** + * Minimal palette overrides so `@xyflow/react`'s default look picks up the + * app's ocean/sage/amber/coral theme tokens instead of its own defaults. + * `FlowCanvas.tsx` imports `@xyflow/react/dist/style.css` first, then this + * file, so these rules simply win on specificity/order. + * + * Dark mode follows this repo's actual convention — `:root.dark` (see + * `styles/tokens.css`'s header comment) — not a `data-theme` attribute or a + * `prefers-color-scheme` media query; both of those are conventions for + * *standalone Artifacts*, not this Tailwind `darkMode: 'class'` app. + */ + +.flow-canvas .react-flow__background { + background-color: rgb(var(--surface-canvas)); +} + +.flow-canvas .react-flow__background pattern circle { + fill: rgb(var(--line-strong)); +} + +.flow-canvas .react-flow__edge-path { + stroke: rgb(var(--line-strong)); + stroke-width: 1.5; +} + +.flow-canvas .react-flow__edge.selected .react-flow__edge-path, +.flow-canvas .react-flow__edge:hover .react-flow__edge-path { + stroke: rgb(var(--primary-500)); +} + +.flow-canvas .react-flow__handle { + width: 8px; + height: 8px; + background: rgb(var(--primary-500)); + border: 1.5px solid rgb(var(--surface)); +} + +.flow-canvas .react-flow__controls { + background: rgb(var(--surface)); + border: 1px solid rgb(var(--line)); + border-radius: 0.75rem; + box-shadow: 0 1px 2px rgb(0 0 0 / 0.06); + overflow: hidden; +} + +.flow-canvas .react-flow__controls-button { + background: rgb(var(--surface)); + border-bottom: 1px solid rgb(var(--line)); + fill: rgb(var(--content-secondary)); +} + +.flow-canvas .react-flow__controls-button:hover { + background: rgb(var(--surface-hover)); +} + +.flow-canvas .react-flow__minimap { + background: rgb(var(--surface)); + border: 1px solid rgb(var(--line)); + border-radius: 0.75rem; +} + +.flow-canvas .react-flow__attribution { + background: rgb(var(--surface) / 0.7); + color: rgb(var(--content-muted)); +} diff --git a/app/src/lib/flows/graphAdapter.test.ts b/app/src/lib/flows/graphAdapter.test.ts new file mode 100644 index 0000000000..d168243136 --- /dev/null +++ b/app/src/lib/flows/graphAdapter.test.ts @@ -0,0 +1,334 @@ +import { describe, expect, it } from 'vitest'; + +import { + autoLayout, + edgeId, + type FlowEdge, + type FlowNode, + workflowGraphToXyflow, + xyflowToWorkflowGraph, +} from './graphAdapter'; +import type { WorkflowEdge, WorkflowGraph, WorkflowNode } from './types'; + +function node(overrides: Partial = {}): WorkflowNode { + return { id: 'n1', kind: 'agent', name: 'Agent', config: {}, ports: [], ...overrides }; +} + +function edge(overrides: Partial = {}): WorkflowEdge { + return { from_node: 'a', from_port: 'main', to_node: 'b', to_port: 'main', ...overrides }; +} + +function graph(overrides: Partial = {}): WorkflowGraph { + return { schema_version: 1, id: 'wf_1', name: 'demo', nodes: [], edges: [], ...overrides }; +} + +describe('graphAdapter', () => { + describe('workflowGraphToXyflow', () => { + it('returns empty nodes/edges for an empty graph', () => { + const { nodes, edges } = workflowGraphToXyflow(graph()); + expect(nodes).toEqual([]); + expect(edges).toEqual([]); + }); + + it('maps a node to a flowNode with kind/name/config/ports in data', () => { + const g = graph({ + nodes: [ + node({ + id: 't', + kind: 'trigger', + name: 'Start', + config: { mode: 'manual' }, + ports: [{ name: 'main' }], + position: { x: 10, y: 20 }, + }), + ], + }); + const { nodes } = workflowGraphToXyflow(g); + expect(nodes).toHaveLength(1); + const [flowNode] = nodes; + expect(flowNode.id).toBe('t'); + expect(flowNode.type).toBe('flowNode'); + expect(flowNode.position).toEqual({ x: 10, y: 20 }); + expect(flowNode.data.kind).toBe('trigger'); + expect(flowNode.data.name).toBe('Start'); + expect(flowNode.data.config).toEqual({ mode: 'manual' }); + expect(flowNode.data.ports).toEqual([{ name: 'main' }]); + }); + + it('maps edge handles: id, source/target, sourceHandle/targetHandle', () => { + const g = graph({ + nodes: [node({ id: 'a' }), node({ id: 'b' })], + edges: [edge({ from_node: 'a', from_port: 'true', to_node: 'b', to_port: 'in' })], + }); + const { edges } = workflowGraphToXyflow(g); + expect(edges).toEqual([ + { + id: edgeId({ from_node: 'a', from_port: 'true', to_node: 'b', to_port: 'in' }), + source: 'a', + target: 'b', + sourceHandle: 'true', + targetHandle: 'in', + }, + ]); + }); + + it('uses the saved position when present, without invoking auto-layout', () => { + const g = graph({ nodes: [node({ id: 'a', position: { x: 500, y: 600 } })] }); + const { nodes } = workflowGraphToXyflow(g); + expect(nodes[0].position).toEqual({ x: 500, y: 600 }); + }); + + it('auto-lays-out nodes missing a position', () => { + const g = graph({ + nodes: [node({ id: 't', kind: 'trigger' }), node({ id: 'a', kind: 'agent' })], + edges: [edge({ from_node: 't', to_node: 'a' })], + }); + const { nodes } = workflowGraphToXyflow(g); + const byId = Object.fromEntries(nodes.map(n => [n.id, n.position])); + expect(byId.t).toEqual({ x: 0, y: 0 }); + expect(byId.a).toEqual({ x: 0, y: 160 }); + }); + + it('derives effective input/output ports for a switch node from its edges, not just declared ports', () => { + const g = graph({ + nodes: [ + node({ id: 't', kind: 'trigger' }), + node({ id: 'sw', kind: 'switch', ports: [] }), + node({ id: 'a', kind: 'agent' }), + node({ id: 'b', kind: 'agent' }), + ], + edges: [ + edge({ from_node: 't', from_port: 'main', to_node: 'sw', to_port: 'main' }), + edge({ from_node: 'sw', from_port: 'case_a', to_node: 'a', to_port: 'main' }), + edge({ from_node: 'sw', from_port: 'case_b', to_node: 'b', to_port: 'main' }), + ], + }); + const { nodes } = workflowGraphToXyflow(g); + const sw = nodes.find(n => n.id === 'sw')!; + expect(sw.data.inputPorts).toEqual(['main']); + expect(sw.data.outputPorts).toEqual(['case_a', 'case_b']); + }); + + it('defaults to a single "main" input/output port for an unwired node', () => { + const g = graph({ nodes: [node({ id: 'solo', ports: [] })] }); + const { nodes } = workflowGraphToXyflow(g); + expect(nodes[0].data.inputPorts).toEqual(['main']); + expect(nodes[0].data.outputPorts).toEqual(['main']); + }); + }); + + describe('xyflowToWorkflowGraph', () => { + it('round-trips a graph through workflowGraphToXyflow and back', () => { + const original = graph({ + nodes: [ + node({ + id: 't', + kind: 'trigger', + name: 'Start', + config: { mode: 'manual' }, + ports: [], + position: { x: 0, y: 0 }, + }), + node({ + id: 'a', + kind: 'agent', + name: 'Reply', + config: { prompt: 'hi' }, + ports: [{ name: 'main' }], + position: { x: 280, y: 0 }, + }), + ], + edges: [edge({ from_node: 't', from_port: 'main', to_node: 'a', to_port: 'main' })], + }); + + const { nodes, edges } = workflowGraphToXyflow(original); + const roundTripped = xyflowToWorkflowGraph(nodes, edges, { + schema_version: original.schema_version, + id: original.id, + name: original.name, + }); + + expect(roundTripped).toEqual(original); + }); + + it('round-trips node ids and port names containing "-" without edge id collisions', () => { + // Node "a-b"/port "c" -> node "d"/port "e" and node "a"/port "b-c" -> the + // same target/port would produce the same joined string under a naive + // `${a}-${b}-${c}-${d}` id scheme; both must still round-trip correctly. + const original = graph({ + nodes: [ + node({ id: 'a-b', name: 'First', ports: [{ name: 'c' }], position: { x: 0, y: 0 } }), + node({ id: 'a', name: 'Second', ports: [{ name: 'b-c' }], position: { x: 0, y: 160 } }), + node({ id: 'd', name: 'Target', position: { x: 280, y: 0 } }), + ], + edges: [ + edge({ from_node: 'a-b', from_port: 'c', to_node: 'd', to_port: 'e' }), + edge({ from_node: 'a', from_port: 'b-c', to_node: 'd', to_port: 'e' }), + ], + }); + + const { nodes, edges } = workflowGraphToXyflow(original); + // The two edges must not collide on id despite the ambiguous join. + expect(edges[0].id).not.toBe(edges[1].id); + expect(new Set(edges.map(e => e.id)).size).toBe(2); + + const roundTripped = xyflowToWorkflowGraph(nodes, edges, { + schema_version: original.schema_version, + id: original.id, + name: original.name, + }); + expect(roundTripped).toEqual(original); + }); + + it('round-trips a non-default type_version', () => { + const original = graph({ + nodes: [node({ id: 't', kind: 'trigger', type_version: 3, position: { x: 0, y: 0 } })], + }); + + const { nodes, edges } = workflowGraphToXyflow(original); + expect(nodes[0].data.type_version).toBe(3); + + const roundTripped = xyflowToWorkflowGraph(nodes, edges, { + schema_version: original.schema_version, + id: original.id, + name: original.name, + }); + expect(roundTripped.nodes[0].type_version).toBe(3); + expect(roundTripped).toEqual(original); + }); + + it('reassembles graph-level metadata (schema_version/id/name) from the passed meta, not the nodes', () => { + const result = xyflowToWorkflowGraph([], [], { + schema_version: 1, + id: 'wf_2', + name: 'renamed', + }); + expect(result).toEqual({ + schema_version: 1, + id: 'wf_2', + name: 'renamed', + nodes: [], + edges: [], + }); + }); + + it('defaults a missing sourceHandle/targetHandle to "main"', () => { + const flowNodes: FlowNode[] = [ + { + id: 'a', + type: 'flowNode', + position: { x: 0, y: 0 }, + data: { + kind: 'agent', + name: 'A', + config: {}, + ports: [], + inputPorts: ['main'], + outputPorts: ['main'], + }, + }, + { + id: 'b', + type: 'flowNode', + position: { x: 0, y: 160 }, + data: { + kind: 'agent', + name: 'B', + config: {}, + ports: [], + inputPorts: ['main'], + outputPorts: ['main'], + }, + }, + ]; + const flowEdges: FlowEdge[] = [{ id: 'a-b', source: 'a', target: 'b' }]; + const result = xyflowToWorkflowGraph(flowNodes, flowEdges, { + schema_version: 1, + id: null, + name: 'g', + }); + expect(result.edges).toEqual([ + { from_node: 'a', from_port: 'main', to_node: 'b', to_port: 'main' }, + ]); + }); + + it('returns an empty graph for empty nodes/edges', () => { + const result = xyflowToWorkflowGraph([], [], { schema_version: 1, id: undefined, name: '' }); + expect(result.nodes).toEqual([]); + expect(result.edges).toEqual([]); + }); + }); + + describe('autoLayout', () => { + it('returns an empty map for no nodes', () => { + expect(autoLayout([], []).size).toBe(0); + }); + + it('lays out a linear chain by BFS depth from the trigger', () => { + const nodes = [node({ id: 't', kind: 'trigger' }), node({ id: 'a' }), node({ id: 'b' })]; + const edges = [ + edge({ from_node: 't', to_node: 'a' }), + edge({ from_node: 'a', to_node: 'b' }), + ]; + const positions = autoLayout(nodes, edges); + expect(positions.get('t')).toEqual({ x: 0, y: 0 }); + expect(positions.get('a')).toEqual({ x: 0, y: 160 }); + expect(positions.get('b')).toEqual({ x: 0, y: 320 }); + }); + + it('places parallel branches at the same depth in separate columns', () => { + const nodes = [node({ id: 't', kind: 'trigger' }), node({ id: 'a' }), node({ id: 'b' })]; + const edges = [ + edge({ from_node: 't', to_node: 'a' }), + edge({ from_node: 't', to_node: 'b' }), + ]; + const positions = autoLayout(nodes, edges); + expect(positions.get('t')).toEqual({ x: 0, y: 0 }); + expect(positions.get('a')).toEqual({ x: 0, y: 160 }); + expect(positions.get('b')).toEqual({ x: 280, y: 160 }); + }); + + it('gives every node a position, even a fully disconnected graph', () => { + const nodes = [node({ id: 'a' }), node({ id: 'b' })]; + const positions = autoLayout(nodes, []); + expect(positions.size).toBe(2); + expect(positions.has('a')).toBe(true); + expect(positions.has('b')).toBe(true); + }); + + it('does not throw on an edge referencing an id outside the node set', () => { + const nodes = [node({ id: 'a' })]; + const edges = [edge({ from_node: 'a', to_node: 'ghost' })]; + expect(() => autoLayout(nodes, edges)).not.toThrow(); + expect(autoLayout(nodes, edges).get('a')).toEqual({ x: 0, y: 0 }); + }); + }); + + describe('edgeId', () => { + it('is deterministic for the same edge', () => { + const e = edge({ from_node: 'x', from_port: 'p1', to_node: 'y', to_port: 'p2' }); + expect(edgeId(e)).toBe(edgeId({ ...e })); + }); + + it('does not collide when a "-" in a node id/port name could ambiguously shift the boundary', () => { + // Node "a-b"/port "c" -> node "d"/port "e" vs. node "a"/port "b-c" -> + // node "d"/port "e": a naive `${a}-${b}-${c}-${d}` join produces + // "a-b-c-d-e" for both. `edgeId` must tell them apart. + const first = edgeId({ from_node: 'a-b', from_port: 'c', to_node: 'd', to_port: 'e' }); + const second = edgeId({ from_node: 'a', from_port: 'b-c', to_node: 'd', to_port: 'e' }); + expect(first).not.toBe(second); + }); + + it('produces distinct ids for otherwise-identical edges differing only in one field', () => { + const base = { from_node: 'a', from_port: 'main', to_node: 'b', to_port: 'main' }; + const ids = new Set([ + edgeId(base), + edgeId({ ...base, from_node: 'a2' }), + edgeId({ ...base, from_port: 'other' }), + edgeId({ ...base, to_node: 'b2' }), + edgeId({ ...base, to_port: 'other' }), + ]); + expect(ids.size).toBe(5); + }); + }); +}); diff --git a/app/src/lib/flows/graphAdapter.ts b/app/src/lib/flows/graphAdapter.ts new file mode 100644 index 0000000000..f2c3ede4d4 --- /dev/null +++ b/app/src/lib/flows/graphAdapter.ts @@ -0,0 +1,283 @@ +/** + * Pure conversion between the tinyflows `WorkflowGraph` wire model + * (`./types.ts`) and `@xyflow/react`'s `Node`/`Edge` shapes, plus a + * dependency-free auto-layout for graphs saved without canvas positions. + * + * No React here — kept pure so it's trivially unit-testable and reusable by + * both the read-only canvas (issue B5b.1, `FlowCanvas.tsx`) and the future + * editable canvas (B5b.2+, which will call `xyflowToWorkflowGraph` on save). + * + * Deviation from the B5b.1 plan sketch: `autoLayout` takes `(nodes, edges)`, + * not `(nodes)` alone. A BFS-from-trigger layer assignment is only + * meaningful with the edges to walk — a node-only signature would have + * nothing to traverse and could only ever produce a flat single row. + */ +import type { Edge, Node } from '@xyflow/react'; +import createDebug from 'debug'; + +import type { NodeKind, Port, WorkflowEdge, WorkflowGraph, WorkflowNode } from './types'; + +const log = createDebug('flows:graphAdapter'); + +/** The `nodeTypes` key every flow node renders as (see `FlowCanvas.tsx`). */ +export const FLOW_NODE_TYPE = 'flowNode'; + +/** + * Data carried by every xyflow node's `data` prop. Beyond the raw `kind` / + * `name` / `config` / `ports` the plan calls for, this also carries the + * *effective* input/output port names — computed from a union of the node's + * declared `ports` (output-only, per `types.ts`'s module doc) and whatever + * port names its edges actually reference — since a `switch` node's live + * case ports are computed at runtime from config and are not guaranteed to + * be declared in `ports` at all. `FlowNodeComponent` renders one `Handle` + * per entry so no wired connection is ever left dangling with no handle to + * land on. + */ +export interface FlowNodeData extends Record { + kind: NodeKind; + /** `Node.type_version` — carried through so `xyflowToWorkflowGraph` doesn't + * silently downgrade a node saved with a non-default config version. */ + type_version?: number; + name: string; + config: Record; + ports: Port[]; + /** Effective input port names, derived from incoming edges (`['main']` if none). */ + inputPorts: string[]; + /** Effective output port names: declared `ports` ∪ outgoing edges' `from_port` (`['main']` if neither). */ + outputPorts: string[]; +} + +export type FlowNode = Node; +export type FlowEdge = Edge; + +export interface Point { + x: number; + y: number; +} + +const DEFAULT_PORT = 'main'; +const LAYOUT_COLUMN_WIDTH = 280; +const LAYOUT_ROW_HEIGHT = 160; + +/** + * Stable, collision-free xyflow edge id for one `WorkflowEdge`. Node ids and + * port names are free-form strings that may themselves contain `-`, so a + * plain `${a}-${b}-${c}-${d}` join can collide (e.g. node `"a-b"`/port `"c"` + * targeting node `"d"`/port `"e"` produces the same joined string as node + * `"a"`/port `"b-c"` targeting the same target) — and React Flow requires + * every edge id to be unique. `JSON.stringify` on the 4-tuple escapes any + * embedded delimiter-like characters and round-trips distinct tuples to + * distinct strings. + */ +export function edgeId(edge: WorkflowEdge): string { + return JSON.stringify([edge.from_node, edge.from_port, edge.to_node, edge.to_port]); +} + +/** Unique, order-preserving string list. */ +function dedupe(values: string[]): string[] { + return Array.from(new Set(values)); +} + +/** + * Effective input port names for `node`: every distinct `to_port` an edge + * uses to target it, defaulting to `['main']` when nothing targets it (the + * common case — a plain single-input node, or a trigger with no input at + * all, still gets a default handle so the canvas reads consistently). + */ +function effectiveInputPorts(node: WorkflowNode, edges: WorkflowEdge[]): string[] { + const wired = edges.filter(e => e.to_node === node.id).map(e => e.to_port || DEFAULT_PORT); + return wired.length > 0 ? dedupe(wired) : [DEFAULT_PORT]; +} + +/** + * Effective output port names for `node`: the union of its declared `ports` + * (output-only per the tinyflows model) and every distinct `from_port` an + * edge uses leaving it — covering dynamically-cased nodes (e.g. `switch`) + * whose live ports aren't necessarily declared. Defaults to `['main']` when + * neither source yields anything. + */ +function effectiveOutputPorts(node: WorkflowNode, edges: WorkflowEdge[]): string[] { + const declared = node.ports.map(p => p.name); + const wired = edges.filter(e => e.from_node === node.id).map(e => e.from_port || DEFAULT_PORT); + const combined = dedupe([...declared, ...wired]); + return combined.length > 0 ? combined : [DEFAULT_PORT]; +} + +/** + * Converts a `WorkflowGraph` into xyflow's `{ nodes, edges }` shape for + * read-only rendering. Nodes missing a saved `position` are laid out via + * {@link autoLayout} (BFS depth from the trigger). Edge ids/handles are + * derived directly from the graph's `from_node`/`from_port`/`to_node`/`to_port`. + */ +export function workflowGraphToXyflow(graph: WorkflowGraph): { + nodes: FlowNode[]; + edges: FlowEdge[]; +} { + log('workflowGraphToXyflow: nodes=%d edges=%d', graph.nodes.length, graph.edges.length); + + const laidOut = autoLayout(graph.nodes, graph.edges); + + const nodes: FlowNode[] = graph.nodes.map(node => { + const position = node.position ?? laidOut.get(node.id) ?? { x: 0, y: 0 }; + return { + id: node.id, + type: FLOW_NODE_TYPE, + position, + data: { + kind: node.kind, + type_version: node.type_version, + name: node.name, + config: node.config ?? {}, + ports: node.ports, + inputPorts: effectiveInputPorts(node, graph.edges), + outputPorts: effectiveOutputPorts(node, graph.edges), + }, + }; + }); + + const edges: FlowEdge[] = graph.edges.map(edge => ({ + id: edgeId(edge), + source: edge.from_node, + target: edge.to_node, + sourceHandle: edge.from_port, + targetHandle: edge.to_port, + })); + + log( + 'workflowGraphToXyflow: produced %d xyflow nodes, %d xyflow edges', + nodes.length, + edges.length + ); + return { nodes, edges }; +} + +/** Metadata not carried by xyflow nodes/edges, needed to reassemble a full `WorkflowGraph`. */ +export interface WorkflowGraphMeta { + schema_version: number; + id?: string | null; + name: string; +} + +/** + * Reverses {@link workflowGraphToXyflow}: reassembles a `WorkflowGraph` from + * xyflow's `nodes`/`edges` plus the graph-level metadata xyflow doesn't + * carry (`schema_version`/`id`/`name`). Defined now — read-only B5b.1 has no + * editor UI to call it from yet — so it's co-located with its inverse ahead + * of B5b.4's save path, and so its round-trip behavior is locked in by tests + * from day one. + * + * Every field `workflowGraphToXyflow` carries over from the source + * `WorkflowNode` — including `type_version` — round-trips back out here; + * `node.position` always comes out concrete (never `undefined`) since by the + * time a node exists on an editable canvas it has a real position, which + * matches a freshly-authored node too. `inputPorts`/`outputPorts` are + * canvas-only derived fields (see `FlowNodeData`'s doc comment) and are + * intentionally not written back — only the *declared* `ports` round-trip. + */ +export function xyflowToWorkflowGraph( + nodes: FlowNode[], + edges: FlowEdge[], + meta: WorkflowGraphMeta +): WorkflowGraph { + const workflowNodes: WorkflowNode[] = nodes.map(node => ({ + id: node.id, + kind: node.data.kind, + type_version: node.data.type_version, + name: node.data.name, + config: node.data.config, + ports: node.data.ports, + position: { x: node.position.x, y: node.position.y }, + })); + + const workflowEdges: WorkflowEdge[] = edges.map(edge => ({ + from_node: edge.source, + from_port: edge.sourceHandle ?? DEFAULT_PORT, + to_node: edge.target, + to_port: edge.targetHandle ?? DEFAULT_PORT, + })); + + return { + schema_version: meta.schema_version, + id: meta.id, + name: meta.name, + nodes: workflowNodes, + edges: workflowEdges, + }; +} + +/** + * Assigns a `{x, y}` position to every node in `nodes`, via a simple BFS + * layering over `edges`: `y = depth * 160`, `x = column * 280` where + * `column` is the node's index within its depth layer (assigned in + * declaration order). Roots are nodes with no incoming edge (normally just + * the trigger); disconnected sub-graphs and cycles still terminate — any + * node the BFS doesn't reach is appended one layer past the deepest reached + * node, so nothing is ever silently dropped. No extra dependency (no + * `dagre`) — good enough for a read-only first render; a real editor can + * offer manual repositioning later. + * + * Returns a `Map` for every node passed in (not just the ones + * missing a saved position), so callers can use it as a uniform fallback + * source; `workflowGraphToXyflow` only consults it for nodes lacking + * `position`. + */ +export function autoLayout(nodes: WorkflowNode[], edges: WorkflowEdge[]): Map { + const positions = new Map(); + if (nodes.length === 0) return positions; + + const nodeIds = new Set(nodes.map(n => n.id)); + const incoming = new Map(nodes.map(n => [n.id, 0])); + const adjacency = new Map(nodes.map(n => [n.id, []])); + + for (const edge of edges) { + // Ignore edges referencing ids outside this node set — defensive only; + // a validated graph never has these, but layout should never throw. + if (!nodeIds.has(edge.from_node) || !nodeIds.has(edge.to_node)) continue; + adjacency.get(edge.from_node)?.push(edge.to_node); + incoming.set(edge.to_node, (incoming.get(edge.to_node) ?? 0) + 1); + } + + // Roots: nodes with no incoming edge (usually just the trigger), in + // declaration order for determinism. Falls back to every node if the + // graph has no such root (a cycle, or every node has an incoming edge). + const roots = nodes.filter(n => (incoming.get(n.id) ?? 0) === 0); + const startIds = (roots.length > 0 ? roots : nodes).map(n => n.id); + + const depth = new Map(); + const queue: string[] = []; + for (const id of startIds) { + if (depth.has(id)) continue; + depth.set(id, 0); + queue.push(id); + } + + let head = 0; + while (head < queue.length) { + const id = queue[head++]; + const currentDepth = depth.get(id) ?? 0; + for (const nextId of adjacency.get(id) ?? []) { + if (depth.has(nextId)) continue; + depth.set(nextId, currentDepth + 1); + queue.push(nextId); + } + } + + // Any node unreached by the BFS (disconnected sub-graph, or a cycle with + // no zero-in-degree entry point) still gets a depth so it renders + // somewhere rather than being silently dropped. + let maxDepth = 0; + for (const d of depth.values()) maxDepth = Math.max(maxDepth, d); + for (const node of nodes) { + if (!depth.has(node.id)) depth.set(node.id, ++maxDepth); + } + + const columnByDepth = new Map(); + for (const node of nodes) { + const d = depth.get(node.id) ?? 0; + const column = columnByDepth.get(d) ?? 0; + columnByDepth.set(d, column + 1); + positions.set(node.id, { x: column * LAYOUT_COLUMN_WIDTH, y: d * LAYOUT_ROW_HEIGHT }); + } + + return positions; +} diff --git a/app/src/lib/flows/types.ts b/app/src/lib/flows/types.ts new file mode 100644 index 0000000000..7851c9f9f7 --- /dev/null +++ b/app/src/lib/flows/types.ts @@ -0,0 +1,114 @@ +/** + * TypeScript mirror of the `tinyflows` workflow model (`tinyflows::model`, + * currently pinned at 0.3.0 — see root `Cargo.toml`). This is the frontend's + * only view onto the wire shape of `Flow.graph` (kept as `unknown` in + * `services/api/flowsApi.ts` since the list page never needs to interpret + * it) — the canvas (issue B5b) is the first consumer. + * + * Field names/optionality are checked directly against + * `tinyflows-0.3.0/src/model/mod.rs` and `node_kind.rs` (no `rename_all` on + * the Rust structs, so field names are snake_case on the wire as-is, same + * convention as `flowsApi.ts`'s wire types). Two deliberate deviations from a + * naive "camelCase everything" TS port, both intentional so this file stays a + * faithful mirror instead of drifting from the Rust source of truth: + * + * - `Port` has no `kind: 'input' | 'output'` discriminant — the Rust struct is + * just `{ name, label? }`. `Node.ports` is documented on the Rust side as + * "Declared output ports (for branching / multi-output nodes)" — i.e. it is + * *only* ever a list of a node's extra output ports, never inputs. Nodes + * with a single default output (most kinds) leave it empty; a `switch` + * node's *actual* case ports are computed at runtime from its `config` + * (see `tinyflows`'s `SwitchNode::execute`) and aren't guaranteed to appear + * here at all — the graph's `edges` are the only fully authoritative source + * for which ports are actually wired. `graphAdapter.ts` accounts for this by + * deriving the *effective* input/output handles for the canvas from a union + * of `Node.ports` and the edges touching that node, rather than trusting + * `Node.ports` alone. + * - `Position` is `{ x: number; y: number }` (both required numbers), matching + * the Rust struct exactly; it's `Node.position` itself that is optional + * (`Option`), not the fields within it. + */ + +/** Canvas coordinates for a node. Mirrors `tinyflows::model::Position`. */ +export interface Position { + x: number; + y: number; +} + +/** + * The 12 node kinds `tinyflows` currently defines (`tinyflows::model::NodeKind`). + * Wire values are `snake_case` (`#[serde(rename_all = "snake_case")]`). + */ +export type NodeKind = + | 'trigger' + | 'agent' + | 'tool_call' + | 'http_request' + | 'code' + | 'condition' + | 'switch' + | 'merge' + | 'split_out' + | 'transform' + | 'output_parser' + | 'sub_workflow'; + +/** + * A named connection point on a node. Mirrors `tinyflows::model::Port`. + * Despite the name, only ever appears in `WorkflowNode.ports`, which the Rust + * doc comment describes as "Declared output ports (for branching / + * multi-output nodes)" — see the module doc above for why this is not a + * complete picture of a node's input/output handles. + */ +export interface Port { + name: string; + label?: string; +} + +/** + * A single unit of work in a workflow. Mirrors `tinyflows::model::Node` + * (named `WorkflowNode` here to avoid colliding with DOM's global `Node` + * type and to pair with `WorkflowGraph`/`WorkflowEdge`). + */ +export interface WorkflowNode { + id: string; + kind: NodeKind; + /** Defaults to `1` on the wire (`#[serde(default = "default_type_version")]`). */ + type_version?: number; + name: string; + /** Kind-specific configuration as free-form JSON (`#[serde(default)]` → `null`/`{}`). */ + config: Record; + /** Declared *output* ports (see module doc). Defaults to `[]` on the wire. */ + ports: Port[]; + /** Canvas position, if the graph was authored/saved with one. */ + position?: Position; +} + +/** + * A directed connection from one node's output port to another's input port. + * Mirrors `tinyflows::model::Edge`. `from_port`/`to_port` default to + * `"main"` on the wire when omitted, but the JSON `flows_get` returns has + * already gone through `tinyflows`'s serializer, so both are always present + * by the time this client sees them. + */ +export interface WorkflowEdge { + from_node: string; + from_port: string; + to_node: string; + to_port: string; +} + +/** + * A complete, serializable workflow definition. Mirrors + * `tinyflows::model::WorkflowGraph`. This is the shape `Flow.graph` (kept + * `unknown` in `flowsApi.ts`) must be cast to once loaded. + */ +export interface WorkflowGraph { + /** Overall model-shape version; `tinyflows::model::CURRENT_SCHEMA_VERSION` is `1`. */ + schema_version: number; + /** Optional stable id of the workflow (`Option` on the Rust side). */ + id?: string | null; + name: string; + nodes: WorkflowNode[]; + edges: WorkflowEdge[]; +} diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index 29c55d5ee8..215e833b66 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -3603,6 +3603,24 @@ const messages: TranslationMap = { 'flows.runs.loading': 'جارٍ تحميل عمليات التشغيل…', 'flows.runs.loadError': 'تعذّر تحميل عمليات التشغيل', 'flows.runs.empty': 'لا توجد عمليات تشغيل بعد', + 'flows.list.view': 'عرض سير العمل', + 'flows.canvas.title': 'سير العمل', + 'flows.canvas.loading': 'جارٍ تحميل سير العمل…', + 'flows.canvas.loadError': 'تعذّر تحميل سير العمل هذا. يرجى المحاولة مرة أخرى.', + 'flows.canvas.notFound': 'تعذّر العثور على سير العمل هذا.', + 'flows.canvas.backToList': 'العودة إلى قائمة سير العمل', + 'flows.nodeKind.trigger': 'المُشغِّل', + 'flows.nodeKind.agent': 'الوكيل', + 'flows.nodeKind.tool_call': 'استدعاء أداة', + 'flows.nodeKind.http_request': 'طلب HTTP', + 'flows.nodeKind.code': 'الكود', + 'flows.nodeKind.condition': 'الشرط', + 'flows.nodeKind.switch': 'المحول', + 'flows.nodeKind.merge': 'الدمج', + 'flows.nodeKind.split_out': 'تقسيم المخرجات', + 'flows.nodeKind.transform': 'التحويل', + 'flows.nodeKind.output_parser': 'محلل المخرجات', + 'flows.nodeKind.sub_workflow': 'سير عمل فرعي', 'oauth.button.connecting': 'جارٍ الاتصال...', 'oauth.button.loopbackTimeout': diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index 0748e483e7..1c819181be 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -3685,6 +3685,24 @@ const messages: TranslationMap = { 'flows.runs.loading': 'রান লোড হচ্ছে…', 'flows.runs.loadError': 'রান লোড করা যায়নি', 'flows.runs.empty': 'এখনো কোনো রান নেই', + 'flows.list.view': 'ওয়ার্কফ্লো দেখুন', + 'flows.canvas.title': 'ওয়ার্কফ্লো', + 'flows.canvas.loading': 'ওয়ার্কফ্লো লোড হচ্ছে…', + 'flows.canvas.loadError': 'এই ওয়ার্কফ্লোটি লোড করা যায়নি। আবার চেষ্টা করুন।', + 'flows.canvas.notFound': 'এই ওয়ার্কফ্লোটি পাওয়া যায়নি।', + 'flows.canvas.backToList': 'ওয়ার্কফ্লো তালিকায় ফিরে যান', + 'flows.nodeKind.trigger': 'ট্রিগার', + 'flows.nodeKind.agent': 'এজেন্ট', + 'flows.nodeKind.tool_call': 'টুল কল', + 'flows.nodeKind.http_request': 'HTTP অনুরোধ', + 'flows.nodeKind.code': 'কোড', + 'flows.nodeKind.condition': 'শর্ত', + 'flows.nodeKind.switch': 'সুইচ', + 'flows.nodeKind.merge': 'মার্জ করুন', + 'flows.nodeKind.split_out': 'স্প্লিট আউট', + 'flows.nodeKind.transform': 'রূপান্তর', + 'flows.nodeKind.output_parser': 'আউটপুট পার্সার', + 'flows.nodeKind.sub_workflow': 'সাব-ওয়ার্কফ্লো', 'oauth.button.connecting': 'সংযোগ হচ্ছে...', 'oauth.button.loopbackTimeout': diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index aa04f3e277..5b477a3d5b 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -3775,6 +3775,25 @@ const messages: TranslationMap = { 'flows.runs.loading': 'Ausführungen werden geladen…', 'flows.runs.loadError': 'Ausführungen konnten nicht geladen werden', 'flows.runs.empty': 'Noch keine Ausführungen', + 'flows.list.view': 'Workflow anzeigen', + 'flows.canvas.title': 'Workflow', + 'flows.canvas.loading': 'Workflow wird geladen…', + 'flows.canvas.loadError': + 'Dieser Workflow konnte nicht geladen werden. Bitte versuche es erneut.', + 'flows.canvas.notFound': 'Dieser Workflow wurde nicht gefunden.', + 'flows.canvas.backToList': 'Zurück zu den Workflows', + 'flows.nodeKind.trigger': 'Auslöser', + 'flows.nodeKind.agent': 'Agent', + 'flows.nodeKind.tool_call': 'Tool-Aufruf', + 'flows.nodeKind.http_request': 'HTTP-Anfrage', + 'flows.nodeKind.code': 'Code', + 'flows.nodeKind.condition': 'Bedingung', + 'flows.nodeKind.switch': 'Weiche', + 'flows.nodeKind.merge': 'Zusammenführen', + 'flows.nodeKind.split_out': 'Aufteilen', + 'flows.nodeKind.transform': 'Transformation', + 'flows.nodeKind.output_parser': 'Ausgabe-Parser', + 'flows.nodeKind.sub_workflow': 'Unter-Workflow', 'oauth.button.connecting': 'Verbinden...', 'oauth.button.loopbackTimeout': diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index e91e413159..0d2608ae12 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -4323,12 +4323,34 @@ const en: TranslationMap = { 'flows.list.enabled': 'Enabled', 'flows.list.paused': 'Paused', 'flows.list.runStarted': 'Workflow started', + 'flows.list.view': 'View workflow', 'flows.runs.title': 'Runs for {name}', 'flows.runs.titleFallback': 'Workflow runs', 'flows.runs.loading': 'Loading runs…', 'flows.runs.loadError': 'Could not load runs', 'flows.runs.empty': 'No runs yet', + // ── Workflow Canvas (issue B5b.1) — the read-only graph view of a saved + // flow at /flows/:id. `flows.nodeKind.*` labels the 12 tinyflows node + // kinds (`tinyflows::model::NodeKind`) shown in each canvas node card. + 'flows.canvas.title': 'Workflow', + 'flows.canvas.loading': 'Loading workflow…', + 'flows.canvas.loadError': 'Could not load this workflow. Please try again.', + 'flows.canvas.notFound': 'This workflow could not be found.', + 'flows.canvas.backToList': 'Back to workflows', + 'flows.nodeKind.trigger': 'Trigger', + 'flows.nodeKind.agent': 'Agent', + 'flows.nodeKind.tool_call': 'Tool call', + 'flows.nodeKind.http_request': 'HTTP request', + 'flows.nodeKind.code': 'Code', + 'flows.nodeKind.condition': 'Condition', + 'flows.nodeKind.switch': 'Switch', + 'flows.nodeKind.merge': 'Merge', + 'flows.nodeKind.split_out': 'Split out', + 'flows.nodeKind.transform': 'Transform', + 'flows.nodeKind.output_parser': 'Output parser', + 'flows.nodeKind.sub_workflow': 'Sub-workflow', + 'oauth.button.connecting': 'Connecting...', 'oauth.button.loopbackTimeout': 'Sign-in timed out — the browser did not complete the OAuth redirect. Please try again.', diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index 300c9f0c0a..87236bf461 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -3748,6 +3748,24 @@ const messages: TranslationMap = { 'flows.runs.loading': 'Cargando ejecuciones…', 'flows.runs.loadError': 'No se pudieron cargar las ejecuciones', 'flows.runs.empty': 'Aún no hay ejecuciones', + 'flows.list.view': 'Ver flujo de trabajo', + 'flows.canvas.title': 'Flujo de trabajo', + 'flows.canvas.loading': 'Cargando flujo de trabajo…', + 'flows.canvas.loadError': 'No se pudo cargar este flujo de trabajo. Inténtalo de nuevo.', + 'flows.canvas.notFound': 'No se pudo encontrar este flujo de trabajo.', + 'flows.canvas.backToList': 'Volver a los flujos de trabajo', + 'flows.nodeKind.trigger': 'Disparador', + 'flows.nodeKind.agent': 'Agente', + 'flows.nodeKind.tool_call': 'Llamada a herramienta', + 'flows.nodeKind.http_request': 'Solicitud HTTP', + 'flows.nodeKind.code': 'Código', + 'flows.nodeKind.condition': 'Condición', + 'flows.nodeKind.switch': 'Selector', + 'flows.nodeKind.merge': 'Combinar', + 'flows.nodeKind.split_out': 'Dividir salida', + 'flows.nodeKind.transform': 'Transformar', + 'flows.nodeKind.output_parser': 'Analizador de salida', + 'flows.nodeKind.sub_workflow': 'Subflujo de trabajo', 'oauth.button.connecting': 'Conectando...', 'oauth.button.loopbackTimeout': diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index 58e067cee8..5234cf7226 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -3763,6 +3763,24 @@ const messages: TranslationMap = { 'flows.runs.loading': 'Chargement des exécutions…', 'flows.runs.loadError': 'Impossible de charger les exécutions', 'flows.runs.empty': 'Aucune exécution pour le moment', + 'flows.list.view': 'Voir le workflow', + 'flows.canvas.title': 'Workflow', + 'flows.canvas.loading': 'Chargement du workflow…', + 'flows.canvas.loadError': 'Impossible de charger ce workflow. Veuillez réessayer.', + 'flows.canvas.notFound': 'Ce workflow est introuvable.', + 'flows.canvas.backToList': 'Retour aux workflows', + 'flows.nodeKind.trigger': 'Déclencheur', + 'flows.nodeKind.agent': 'Agent', + 'flows.nodeKind.tool_call': "Appel d'outil", + 'flows.nodeKind.http_request': 'Requête HTTP', + 'flows.nodeKind.code': 'Code', + 'flows.nodeKind.condition': 'Condition', + 'flows.nodeKind.switch': 'Aiguillage', + 'flows.nodeKind.merge': 'Fusionner', + 'flows.nodeKind.split_out': 'Répartition de sortie', + 'flows.nodeKind.transform': 'Transformation', + 'flows.nodeKind.output_parser': 'Analyseur de sortie', + 'flows.nodeKind.sub_workflow': 'Sous-workflow', 'oauth.button.connecting': 'Connexion en cours…', 'oauth.button.loopbackTimeout': diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index 8d264b383c..7b96c3d95d 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -3684,6 +3684,24 @@ const messages: TranslationMap = { 'flows.runs.loading': 'रन लोड हो रहे हैं…', 'flows.runs.loadError': 'रन लोड नहीं हो सके', 'flows.runs.empty': 'अभी तक कोई रन नहीं', + 'flows.list.view': 'वर्कफ़्लो देखें', + 'flows.canvas.title': 'वर्कफ़्लो', + 'flows.canvas.loading': 'वर्कफ़्लो लोड हो रहा है…', + 'flows.canvas.loadError': 'यह वर्कफ़्लो लोड नहीं हो सका। कृपया पुनः प्रयास करें।', + 'flows.canvas.notFound': 'यह वर्कफ़्लो नहीं मिला।', + 'flows.canvas.backToList': 'वर्कफ़्लो सूची पर वापस जाएं', + 'flows.nodeKind.trigger': 'ट्रिगर', + 'flows.nodeKind.agent': 'एजेंट', + 'flows.nodeKind.tool_call': 'टूल कॉल', + 'flows.nodeKind.http_request': 'HTTP अनुरोध', + 'flows.nodeKind.code': 'कोड', + 'flows.nodeKind.condition': 'शर्त', + 'flows.nodeKind.switch': 'स्विच', + 'flows.nodeKind.merge': 'मर्ज करें', + 'flows.nodeKind.split_out': 'स्प्लिट आउट', + 'flows.nodeKind.transform': 'रूपांतरण', + 'flows.nodeKind.output_parser': 'आउटपुट पार्सर', + 'flows.nodeKind.sub_workflow': 'सब-वर्कफ़्लो', 'oauth.button.connecting': 'कनेक्ट हो रहा है...', 'oauth.button.loopbackTimeout': diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index 767198b510..fe02181f47 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -3693,6 +3693,24 @@ const messages: TranslationMap = { 'flows.runs.loading': 'Memuat proses…', 'flows.runs.loadError': 'Tidak dapat memuat proses', 'flows.runs.empty': 'Belum ada proses', + 'flows.list.view': 'Lihat alur kerja', + 'flows.canvas.title': 'Alur kerja', + 'flows.canvas.loading': 'Memuat alur kerja…', + 'flows.canvas.loadError': 'Alur kerja ini tidak dapat dimuat. Silakan coba lagi.', + 'flows.canvas.notFound': 'Alur kerja ini tidak ditemukan.', + 'flows.canvas.backToList': 'Kembali ke daftar alur kerja', + 'flows.nodeKind.trigger': 'Pemicu', + 'flows.nodeKind.agent': 'Agen', + 'flows.nodeKind.tool_call': 'Panggilan alat', + 'flows.nodeKind.http_request': 'Permintaan HTTP', + 'flows.nodeKind.code': 'Kode', + 'flows.nodeKind.condition': 'Kondisi', + 'flows.nodeKind.switch': 'Switch', + 'flows.nodeKind.merge': 'Gabungkan', + 'flows.nodeKind.split_out': 'Pisahkan keluaran', + 'flows.nodeKind.transform': 'Transformasi', + 'flows.nodeKind.output_parser': 'Pengurai keluaran', + 'flows.nodeKind.sub_workflow': 'Sub-alur kerja', 'oauth.button.connecting': 'Menghubungkan...', 'oauth.button.loopbackTimeout': diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index cfb3869ab0..68397f391d 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -3742,6 +3742,24 @@ const messages: TranslationMap = { 'flows.runs.loading': 'Caricamento esecuzioni…', 'flows.runs.loadError': 'Impossibile caricare le esecuzioni', 'flows.runs.empty': 'Ancora nessuna esecuzione', + 'flows.list.view': 'Visualizza flusso di lavoro', + 'flows.canvas.title': 'Flusso di lavoro', + 'flows.canvas.loading': 'Caricamento flusso di lavoro…', + 'flows.canvas.loadError': 'Impossibile caricare questo flusso di lavoro. Riprova.', + 'flows.canvas.notFound': 'Flusso di lavoro non trovato.', + 'flows.canvas.backToList': 'Torna ai flussi di lavoro', + 'flows.nodeKind.trigger': 'Trigger', + 'flows.nodeKind.agent': 'Agente', + 'flows.nodeKind.tool_call': 'Chiamata strumento', + 'flows.nodeKind.http_request': 'Richiesta HTTP', + 'flows.nodeKind.code': 'Codice', + 'flows.nodeKind.condition': 'Condizione', + 'flows.nodeKind.switch': 'Switch', + 'flows.nodeKind.merge': 'Unisci', + 'flows.nodeKind.split_out': 'Dividi output', + 'flows.nodeKind.transform': 'Trasformazione', + 'flows.nodeKind.output_parser': 'Analizzatore di output', + 'flows.nodeKind.sub_workflow': 'Sotto-flusso di lavoro', 'oauth.button.connecting': 'Connessione...', 'oauth.button.loopbackTimeout': diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index d428898ce4..bb76b39743 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -3647,6 +3647,24 @@ const messages: TranslationMap = { 'flows.runs.loading': '실행 기록을 불러오는 중…', 'flows.runs.loadError': '실행 기록을 불러올 수 없습니다', 'flows.runs.empty': '아직 실행 기록이 없습니다', + 'flows.list.view': '워크플로 보기', + 'flows.canvas.title': '워크플로', + 'flows.canvas.loading': '워크플로 로드 중…', + 'flows.canvas.loadError': '이 워크플로를 불러올 수 없습니다. 다시 시도해 주세요.', + 'flows.canvas.notFound': '이 워크플로를 찾을 수 없습니다.', + 'flows.canvas.backToList': '워크플로 목록으로 돌아가기', + 'flows.nodeKind.trigger': '트리거', + 'flows.nodeKind.agent': '에이전트', + 'flows.nodeKind.tool_call': '도구 호출', + 'flows.nodeKind.http_request': 'HTTP 요청', + 'flows.nodeKind.code': '코드', + 'flows.nodeKind.condition': '조건', + 'flows.nodeKind.switch': '스위치', + 'flows.nodeKind.merge': '병합', + 'flows.nodeKind.split_out': '분할 출력', + 'flows.nodeKind.transform': '변환', + 'flows.nodeKind.output_parser': '출력 파서', + 'flows.nodeKind.sub_workflow': '하위 워크플로', 'oauth.button.connecting': '연결 중...', 'oauth.button.loopbackTimeout': diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index a1e606f7f1..9714802c73 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -3729,6 +3729,24 @@ const messages: TranslationMap = { 'flows.runs.loading': 'Ładowanie przebiegów…', 'flows.runs.loadError': 'Nie udało się załadować przebiegów', 'flows.runs.empty': 'Brak przebiegów', + 'flows.list.view': 'Wyświetl przepływ pracy', + 'flows.canvas.title': 'Przepływ pracy', + 'flows.canvas.loading': 'Wczytywanie przepływu pracy…', + 'flows.canvas.loadError': 'Nie można wczytać tego przepływu pracy. Spróbuj ponownie.', + 'flows.canvas.notFound': 'Nie znaleziono tego przepływu pracy.', + 'flows.canvas.backToList': 'Powrót do listy przepływów pracy', + 'flows.nodeKind.trigger': 'Wyzwalacz', + 'flows.nodeKind.agent': 'Agent', + 'flows.nodeKind.tool_call': 'Wywołanie narzędzia', + 'flows.nodeKind.http_request': 'Żądanie HTTP', + 'flows.nodeKind.code': 'Kod', + 'flows.nodeKind.condition': 'Warunek', + 'flows.nodeKind.switch': 'Przełącznik', + 'flows.nodeKind.merge': 'Scal', + 'flows.nodeKind.split_out': 'Podziel wyjście', + 'flows.nodeKind.transform': 'Transformacja', + 'flows.nodeKind.output_parser': 'Parser wyjścia', + 'flows.nodeKind.sub_workflow': 'Podprzepływ pracy', 'oauth.button.connecting': 'Łączenie...', 'oauth.button.loopbackTimeout': diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index 2dbe353d8e..7dc4097c4c 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -3743,6 +3743,24 @@ const messages: TranslationMap = { 'flows.runs.loading': 'Carregando execuções…', 'flows.runs.loadError': 'Não foi possível carregar as execuções', 'flows.runs.empty': 'Ainda não há execuções', + 'flows.list.view': 'Ver fluxo de trabalho', + 'flows.canvas.title': 'Fluxo de trabalho', + 'flows.canvas.loading': 'Carregando fluxo de trabalho…', + 'flows.canvas.loadError': 'Não foi possível carregar este fluxo de trabalho. Tente novamente.', + 'flows.canvas.notFound': 'Este fluxo de trabalho não foi encontrado.', + 'flows.canvas.backToList': 'Voltar para os fluxos de trabalho', + 'flows.nodeKind.trigger': 'Gatilho', + 'flows.nodeKind.agent': 'Agente', + 'flows.nodeKind.tool_call': 'Chamada de ferramenta', + 'flows.nodeKind.http_request': 'Solicitação HTTP', + 'flows.nodeKind.code': 'Código', + 'flows.nodeKind.condition': 'Condição', + 'flows.nodeKind.switch': 'Seletor', + 'flows.nodeKind.merge': 'Mesclar', + 'flows.nodeKind.split_out': 'Dividir saída', + 'flows.nodeKind.transform': 'Transformação', + 'flows.nodeKind.output_parser': 'Analisador de saída', + 'flows.nodeKind.sub_workflow': 'Subfluxo de trabalho', 'oauth.button.connecting': 'Conectando...', 'oauth.button.loopbackTimeout': diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index aca00920f5..29192dceac 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -3718,6 +3718,24 @@ const messages: TranslationMap = { 'flows.runs.loading': 'Загрузка запусков…', 'flows.runs.loadError': 'Не удалось загрузить запуски', 'flows.runs.empty': 'Пока нет запусков', + 'flows.list.view': 'Просмотреть рабочий процесс', + 'flows.canvas.title': 'Рабочий процесс', + 'flows.canvas.loading': 'Загрузка рабочего процесса…', + 'flows.canvas.loadError': 'Не удалось загрузить этот рабочий процесс. Повторите попытку.', + 'flows.canvas.notFound': 'Этот рабочий процесс не найден.', + 'flows.canvas.backToList': 'Назад к рабочим процессам', + 'flows.nodeKind.trigger': 'Триггер', + 'flows.nodeKind.agent': 'Агент', + 'flows.nodeKind.tool_call': 'Вызов инструмента', + 'flows.nodeKind.http_request': 'HTTP-запрос', + 'flows.nodeKind.code': 'Код', + 'flows.nodeKind.condition': 'Условие', + 'flows.nodeKind.switch': 'Переключатель', + 'flows.nodeKind.merge': 'Объединение', + 'flows.nodeKind.split_out': 'Разделение вывода', + 'flows.nodeKind.transform': 'Преобразование', + 'flows.nodeKind.output_parser': 'Парсер вывода', + 'flows.nodeKind.sub_workflow': 'Подпроцесс', 'oauth.button.connecting': 'Подключение...', 'oauth.button.loopbackTimeout': diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index 701567c352..fa0fa31927 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -3493,6 +3493,24 @@ const messages: TranslationMap = { 'flows.runs.loading': '正在加载运行记录…', 'flows.runs.loadError': '无法加载运行记录', 'flows.runs.empty': '暂无运行记录', + 'flows.list.view': '查看工作流', + 'flows.canvas.title': '工作流', + 'flows.canvas.loading': '正在加载工作流…', + 'flows.canvas.loadError': '无法加载此工作流。请重试。', + 'flows.canvas.notFound': '未找到此工作流。', + 'flows.canvas.backToList': '返回工作流列表', + 'flows.nodeKind.trigger': '触发器', + 'flows.nodeKind.agent': '智能体', + 'flows.nodeKind.tool_call': '工具调用', + 'flows.nodeKind.http_request': 'HTTP 请求', + 'flows.nodeKind.code': '代码', + 'flows.nodeKind.condition': '条件', + 'flows.nodeKind.switch': '分支', + 'flows.nodeKind.merge': '合并', + 'flows.nodeKind.split_out': '拆分输出', + 'flows.nodeKind.transform': '转换', + 'flows.nodeKind.output_parser': '输出解析器', + 'flows.nodeKind.sub_workflow': '子工作流', 'oauth.button.connecting': '连接中...', 'oauth.button.loopbackTimeout': '登录超时 — 浏览器未完成 OAuth 跳转。请重试。', diff --git a/app/src/pages/FlowCanvasPage.tsx b/app/src/pages/FlowCanvasPage.tsx new file mode 100644 index 0000000000..a8d71f8cb6 --- /dev/null +++ b/app/src/pages/FlowCanvasPage.tsx @@ -0,0 +1,147 @@ +/** + * FlowCanvasPage — the read-only Workflow Canvas view (issue B5b.1) at + * `/flows/:id`. Loads one saved flow via `flows_get`, converts its + * `WorkflowGraph` (`Flow.graph`, opaque `unknown` on the wire type — see + * `services/api/flowsApi.ts`) to xyflow's shape via `graphAdapter.ts`, and + * renders it in `FlowCanvas` with editing disabled. This is the first slice + * of the visual builder (de-risking the `@xyflow/react` integration) — + * dragging nodes / drawing edges lands in B5b.2+. + */ +import createDebug from 'debug'; +import { useEffect, useState } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; + +import FlowCanvas from '../components/flows/canvas/FlowCanvas'; +import PanelPage from '../components/layout/PanelPage'; +import Button from '../components/ui/Button'; +import { CenteredLoadingState, ErrorBanner } from '../components/ui/LoadingState'; +import { workflowGraphToXyflow } from '../lib/flows/graphAdapter'; +import type { WorkflowGraph } from '../lib/flows/types'; +import { useT } from '../lib/i18n/I18nContext'; +import { type Flow, getFlow } from '../services/api/flowsApi'; + +const log = createDebug('app:flows:canvas'); + +type LoadState = + | { status: 'loading' } + | { status: 'notFound' } + | { status: 'error'; message: string } + | { status: 'ready'; flow: Flow }; + +function errorMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} + +function BackIcon() { + return ( + + ); +} + +export default function FlowCanvasPage() { + const { t } = useT(); + const navigate = useNavigate(); + const { id } = useParams<{ id: string }>(); + const [state, setState] = useState({ status: 'loading' }); + + useEffect(() => { + // Guards a stale response from clobbering newer state: this effect + // re-runs on every `:id` change without the component remounting (same + // route, different param), and on unmount, so a slow fetch for a + // previous id (or one that resolves after the component is gone) must + // not call `setState` once superseded. Same pattern as + // `useFlowRunPoller.ts`'s `cancelled`/`mountedRef` guard. + let cancelled = false; + + if (!id) { + log('load: no id in route params'); + setState({ status: 'notFound' }); + return; + } + + log('load: fetching flow id=%s', id); + setState({ status: 'loading' }); + + void (async () => { + try { + const flow = await getFlow(id); + if (cancelled) { + log('load: fetched flow id=%s but superseded/unmounted, dropping', id); + return; + } + log('load: fetched flow id=%s name=%s', flow.id, flow.name); + setState({ status: 'ready', flow }); + } catch (err) { + if (cancelled) return; + const message = errorMessage(err); + log('load: failed id=%s err=%o', id, err); + if (message.toLowerCase().includes('not found')) { + setState({ status: 'notFound' }); + } else { + setState({ status: 'error', message }); + } + } + })(); + + return () => { + cancelled = true; + }; + }, [id]); + + const backButton = ( + + ); + + const title = state.status === 'ready' ? state.flow.name : t('flows.canvas.title'); + + return ( + + {state.status === 'loading' && ( +
+ +
+ )} + + {state.status === 'error' && ( +
+ +
+ )} + + {state.status === 'notFound' && ( +
+

+ {t('flows.canvas.notFound')} +

+
+ )} + + {state.status === 'ready' && + (() => { + const graph = state.flow.graph as WorkflowGraph; + const { nodes, edges } = workflowGraphToXyflow(graph); + return ; + })()} +
+ ); +} diff --git a/app/src/pages/FlowsPage.test.tsx b/app/src/pages/FlowsPage.test.tsx index ba83783670..6c1663413c 100644 --- a/app/src/pages/FlowsPage.test.tsx +++ b/app/src/pages/FlowsPage.test.tsx @@ -1,11 +1,12 @@ /** - * FlowsPage (issue B5a / B5a.1) — the Workflows list page. Asserts the - * loading/empty/error/list states, that toggling a flow calls + * FlowsPage (issue B5a / B5a.1 / B5b.1) — the Workflows list page. Asserts + * the loading/empty/error/list states, that toggling a flow calls * `setFlowEnabled` and refreshes the row, that Run fires `runFlow`, shows a * "Workflow started" toast, and refetches the list, that "View runs" opens - * `FlowRunsDrawer` for the clicked flow, and that "New workflow" (header + - * empty state) navigates to Chat (no canvas builder yet — bridges to B4's - * agent-proposal flow). + * `FlowRunsDrawer` for the clicked flow, that clicking a flow's name + * navigates to its read-only Workflow Canvas (`/flows/:id`, issue B5b.1), + * and that "New workflow" (header + empty state) navigates to Chat (no + * canvas *builder* yet — bridges to B4's agent-proposal flow). */ import { fireEvent, screen, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; @@ -136,6 +137,16 @@ describe('FlowsPage', () => { expect(screen.queryByTestId('flow-runs-drawer')).not.toBeInTheDocument(); }); + it('navigates to the Workflow Canvas when a flow name is clicked', async () => { + listFlows.mockResolvedValue([makeFlow()]); + renderWithProviders(); + + await waitFor(() => expect(screen.getByTestId('flow-view-flow-1')).toBeInTheDocument()); + fireEvent.click(screen.getByTestId('flow-view-flow-1')); + + expect(mockNavigate).toHaveBeenCalledWith('/flows/flow-1'); + }); + it('renders a "New workflow" header button and navigates to /chat when clicked', async () => { listFlows.mockResolvedValue([makeFlow()]); renderWithProviders(); diff --git a/app/src/pages/FlowsPage.tsx b/app/src/pages/FlowsPage.tsx index 013794ecdf..5f25b22118 100644 --- a/app/src/pages/FlowsPage.tsx +++ b/app/src/pages/FlowsPage.tsx @@ -129,6 +129,15 @@ export default function FlowsPage() { setSelectedFlowId(flow.id); }, []); + /** Opens the read-only Workflow Canvas for this flow (issue B5b.1). */ + const handleView = useCallback( + (flow: Flow) => { + log('view: navigating to canvas id=%s', flow.id); + navigate(`/flows/${flow.id}`); + }, + [navigate] + ); + const selectedFlow = flows.find(f => f.id === selectedFlowId) ?? null; /** @@ -209,6 +218,7 @@ export default function FlowsPage() { onToggle={f => void handleToggle(f)} onRun={f => void handleRun(f)} onViewRuns={handleViewRuns} + onView={handleView} /> ))}
diff --git a/app/src/pages/__tests__/FlowCanvasPage.test.tsx b/app/src/pages/__tests__/FlowCanvasPage.test.tsx new file mode 100644 index 0000000000..a1911e19f3 --- /dev/null +++ b/app/src/pages/__tests__/FlowCanvasPage.test.tsx @@ -0,0 +1,123 @@ +/** + * FlowCanvasPage (issue B5b.1) — the read-only Workflow Canvas view at + * `/flows/:id`. Asserts the loading → canvas happy path, the not-found state + * (mirrors the Rust `flows_get` "not found" error), and the generic error + * state for any other failure. + */ +import { render, screen, waitFor } from '@testing-library/react'; +import { createMemoryRouter, MemoryRouter, Route, RouterProvider, Routes } from 'react-router-dom'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { Flow } from '../../services/api/flowsApi'; +import FlowCanvasPage from '../FlowCanvasPage'; + +const getFlow = vi.hoisted(() => vi.fn()); +vi.mock('../../services/api/flowsApi', () => ({ getFlow })); + +function makeFlow(overrides: Partial = {}): Flow { + return { + id: 'test-id', + name: 'Daily digest', + enabled: true, + graph: { + schema_version: 1, + id: 'test-id', + name: 'Daily digest', + nodes: [ + { + id: 't', + kind: 'trigger', + name: 'Start', + config: {}, + ports: [], + position: { x: 0, y: 0 }, + }, + ], + edges: [], + }, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + last_run_at: null, + last_status: null, + require_approval: false, + ...overrides, + }; +} + +function renderAtFlowId(id: string) { + return render( + + + } /> + + + ); +} + +describe('FlowCanvasPage', () => { + beforeEach(() => { + getFlow.mockReset(); + }); + + it('shows a loading state while the flow is being fetched', () => { + getFlow.mockReturnValue(new Promise(() => {})); // never resolves + renderAtFlowId('test-id'); + + expect(screen.getByText('Loading workflow…')).toBeInTheDocument(); + }); + + it('loads the flow and renders the canvas with the flow name as the title', async () => { + getFlow.mockResolvedValue(makeFlow()); + renderAtFlowId('test-id'); + + await waitFor(() => expect(screen.getByTestId('flow-canvas')).toBeInTheDocument()); + expect(getFlow).toHaveBeenCalledWith('test-id'); + expect(screen.getByText('Daily digest')).toBeInTheDocument(); + }); + + it('shows a not-found state when the flow does not exist', async () => { + getFlow.mockRejectedValue(new Error("flow 'missing-id' not found")); + renderAtFlowId('missing-id'); + + await waitFor(() => expect(screen.getByTestId('flow-canvas-not-found')).toBeInTheDocument()); + }); + + it('shows an error state for any other failure', async () => { + getFlow.mockRejectedValue(new Error('core unreachable')); + renderAtFlowId('test-id'); + + await waitFor(() => expect(screen.getByTestId('flow-canvas-error')).toBeInTheDocument()); + expect(screen.getByText('core unreachable')).toBeInTheDocument(); + }); + + it('ignores a stale response for a superseded id after navigating to a new one', async () => { + // Deferred promises so the test controls resolution order precisely: the + // first (old-id) fetch resolves AFTER the second (new-id) one, mimicking + // a slow response for a page the user has since navigated away from. + let resolveFirst!: (flow: Flow) => void; + const firstFetch = new Promise(resolve => { + resolveFirst = resolve; + }); + getFlow.mockImplementation((id: string) => + id === 'old-id' ? firstFetch : Promise.resolve(makeFlow({ id: 'new-id', name: 'New flow' })) + ); + + const router = createMemoryRouter([{ path: '/flows/:id', element: }], { + initialEntries: ['/flows/old-id'], + }); + render(); + + // Navigate away before the old id's fetch resolves. + router.navigate('/flows/new-id'); + await waitFor(() => expect(screen.getByText('New flow')).toBeInTheDocument()); + + // Now let the stale old-id fetch resolve — it must not clobber the + // already-rendered new-id state. + resolveFirst(makeFlow({ id: 'old-id', name: 'Old flow (stale)' })); + await Promise.resolve(); + await Promise.resolve(); + + expect(screen.getByText('New flow')).toBeInTheDocument(); + expect(screen.queryByText('Old flow (stale)')).not.toBeInTheDocument(); + }); +}); diff --git a/app/src/services/api/flowsApi.ts b/app/src/services/api/flowsApi.ts index d428eb9a83..ef52dc8e03 100644 --- a/app/src/services/api/flowsApi.ts +++ b/app/src/services/api/flowsApi.ts @@ -1,13 +1,15 @@ /** * Frontend client for the durable `openhuman.flows_*` run surface (issue B2 / - * B3 / B4). Wraps the subset of controllers the B3a approval card, the B3b - * run inspector, and the B4 agent-proposal card need: + * B3 / B4 / B5b). Wraps the subset of controllers the B3a approval card, the + * B3b run inspector, the B4 agent-proposal card, and the B5b Workflow Canvas + * need: * - `flows_create` — persist a new flow (B4 — only ever called from the * user's own "Save & enable" click on `WorkflowProposalCard`; the agent's * `propose_workflow` tool only validates and never reaches this RPC) * - `flows_resume` — resume a `pending_approval` run past its checkpoint * - `flows_list_runs` — recent runs for a flow, newest first (B3b) * - `flows_get_run` — a single run record by id (B3b) + * - `flows_get` — a single flow by id, graph included (B5b.1 canvas) * * Wire shape note: every `src/openhuman/flows/ops.rs` handler returns its * value via `RpcOutcome::single_log(value, "...")`, which @@ -257,6 +259,21 @@ export async function setFlowEnabled(id: string, enabled: boolean): Promise { + log('getFlow: request id=%s', id); + const response = await callCoreRpc({ method: 'openhuman.flows_get', params: { id } }); + const flow = unwrapCliEnvelope(response); + log('getFlow: response id=%s name=%s', flow.id, flow.name); + return flow; +} + /** * Run a saved flow to completion (or until it pauses on a human-approval * gate) via `openhuman.flows_run`. This is the call that actually drives the @@ -287,6 +304,7 @@ export const flowsApi = { resumeFlow, listFlowRuns, getFlowRun, + getFlow, listFlows, setFlowEnabled, runFlow, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e5e665e8a5..ba9dba292a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -87,6 +87,9 @@ importers: '@types/three': specifier: ^0.183.1 version: 0.183.1 + '@xyflow/react': + specifier: ^12.11.1 + version: 12.11.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(immer@11.1.4)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) buffer: specifier: ^6.0.3 version: 6.0.3 @@ -285,7 +288,7 @@ importers: version: 28.1.0(@noble/hashes@2.2.0) knip: specifier: ^6.3.1 - version: 6.6.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2) + version: 6.6.2(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) postcss: specifier: ^8.5.6 version: 8.5.10 @@ -1943,6 +1946,9 @@ packages: '@types/d3-color@3.1.3': resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + '@types/d3-drag@3.0.7': + resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==} + '@types/d3-ease@3.0.2': resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} @@ -1958,6 +1964,9 @@ packages: '@types/d3-scale@4.0.9': resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==} + '@types/d3-selection@3.0.11': + resolution: {integrity: sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==} + '@types/d3-shape@3.1.8': resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==} @@ -1967,6 +1976,12 @@ packages: '@types/d3-timer@3.0.2': resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + '@types/d3-transition@3.0.9': + resolution: {integrity: sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==} + + '@types/d3-zoom@3.0.8': + resolution: {integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==} + '@types/debug@4.1.13': resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} @@ -2273,6 +2288,22 @@ packages: resolution: {integrity: sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==} engines: {node: '>=14.6'} + '@xyflow/react@12.11.1': + resolution: {integrity: sha512-L+zBoLGSXham0MnlY8QqjfR7/C5JNw0zxkaey5aZ5XmCgJBAdH4+WRIu8CR40d3l/BdU635V6YbhBK1jMo8/6Q==} + peerDependencies: + '@types/react': '>=17' + '@types/react-dom': '>=17' + react: '>=17' + react-dom: '>=17' + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@xyflow/system@0.0.78': + resolution: {integrity: sha512-lY0z2qP33fUhTva9Vaxrk0lqZta2pkbxB1trHAx1omnJqRtPvDlAQYV2r5fhS6AdpkulYmbNW0svy+A4/t4B/g==} + '@zip.js/zip.js@2.8.26': resolution: {integrity: sha512-RQ4h9F6DOiHxpdocUDrOl6xBM+yOtz+LkUol47AVWcfebGBDpZ7w7Xvz9PS24JgXvLGiXXzSAfdCdVy1tPlaFA==} engines: {bun: '>=0.7.0', deno: '>=1.0.0', node: '>=18.0.0'} @@ -2669,6 +2700,9 @@ packages: resolution: {integrity: sha512-Mz9QMT5fJe7bKI7MH31UilT5cEK5EHHRCccw/YRFsRY47AuNgaV6HY3rscp0/I4Q+tTW/5zoqpSeRRI54TkDWA==} engines: {node: '>= 0.10'} + classcat@5.0.5: + resolution: {integrity: sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==} + cli-width@4.1.0: resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} engines: {node: '>= 12'} @@ -2829,6 +2863,10 @@ packages: resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==} engines: {node: '>=12'} + d3-drag@3.0.0: + resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==} + engines: {node: '>=12'} + d3-ease@3.0.1: resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} engines: {node: '>=12'} @@ -2857,6 +2895,10 @@ packages: resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} engines: {node: '>=12'} + d3-selection@3.0.0: + resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==} + engines: {node: '>=12'} + d3-shape@3.2.0: resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} engines: {node: '>=12'} @@ -2873,6 +2915,16 @@ packages: resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} engines: {node: '>=12'} + d3-transition@3.0.1: + resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==} + engines: {node: '>=12'} + peerDependencies: + d3-selection: 2 - 3 + + d3-zoom@3.0.0: + resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==} + engines: {node: '>=12'} + data-uri-to-buffer@6.0.2: resolution: {integrity: sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==} engines: {node: '>= 14'} @@ -6053,6 +6105,21 @@ packages: zod@4.3.6: resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} + zustand@4.5.7: + resolution: {integrity: sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==} + engines: {node: '>=12.7.0'} + peerDependencies: + '@types/react': '>=16.8' + immer: '>=9.0.6' + react: '>=16.8' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} @@ -6759,9 +6826,9 @@ snapshots: '@oxc-resolver/binding-openharmony-arm64@11.19.1': optional: true - '@oxc-resolver/binding-wasm32-wasi@11.19.1(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)': + '@oxc-resolver/binding-wasm32-wasi@11.19.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': dependencies: - '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2) + '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) transitivePeerDependencies: - '@emnapi/core' - '@emnapi/runtime' @@ -7415,6 +7482,10 @@ snapshots: '@types/d3-color@3.1.3': {} + '@types/d3-drag@3.0.7': + dependencies: + '@types/d3-selection': 3.0.11 + '@types/d3-ease@3.0.2': {} '@types/d3-force@3.0.10': {} @@ -7429,6 +7500,8 @@ snapshots: dependencies: '@types/d3-time': 3.0.4 + '@types/d3-selection@3.0.11': {} + '@types/d3-shape@3.1.8': dependencies: '@types/d3-path': 3.1.1 @@ -7437,6 +7510,15 @@ snapshots: '@types/d3-timer@3.0.2': {} + '@types/d3-transition@3.0.9': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-zoom@3.0.8': + dependencies: + '@types/d3-interpolate': 3.0.4 + '@types/d3-selection': 3.0.11 + '@types/debug@4.1.13': dependencies: '@types/ms': 2.1.0 @@ -7910,6 +7992,31 @@ snapshots: '@xmldom/xmldom@0.9.10': {} + '@xyflow/react@12.11.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(immer@11.1.4)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@xyflow/system': 0.0.78 + classcat: 5.0.5 + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + zustand: 4.5.7(@types/react@19.2.14)(immer@11.1.4)(react@19.2.5) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + transitivePeerDependencies: + - immer + + '@xyflow/system@0.0.78': + dependencies: + '@types/d3-drag': 3.0.7 + '@types/d3-interpolate': 3.0.4 + '@types/d3-selection': 3.0.11 + '@types/d3-transition': 3.0.9 + '@types/d3-zoom': 3.0.8 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-zoom: 3.0.0 + '@zip.js/zip.js@2.8.26': {} abort-controller@3.0.0: @@ -8359,6 +8466,8 @@ snapshots: safe-buffer: 5.2.1 to-buffer: 1.2.2 + classcat@5.0.5: {} + cli-width@4.1.0: {} cliui@7.0.4: @@ -8544,6 +8653,11 @@ snapshots: d3-dispatch@3.0.1: {} + d3-drag@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-selection: 3.0.0 + d3-ease@3.0.1: {} d3-force@3.0.0: @@ -8570,6 +8684,8 @@ snapshots: d3-time: 3.1.0 d3-time-format: 4.1.0 + d3-selection@3.0.0: {} + d3-shape@3.2.0: dependencies: d3-path: 3.1.0 @@ -8584,6 +8700,23 @@ snapshots: d3-timer@3.0.1: {} + d3-transition@3.0.1(d3-selection@3.0.0): + dependencies: + d3-color: 3.1.0 + d3-dispatch: 3.0.1 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-timer: 3.0.1 + + d3-zoom@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + data-uri-to-buffer@6.0.2: {} data-urls@7.0.0(@noble/hashes@2.2.0): @@ -10007,7 +10140,7 @@ snapshots: dependencies: json-buffer: 3.0.1 - knip@6.6.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2): + knip@6.6.2(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0): dependencies: fdir: 6.5.0(picomatch@4.0.4) formatly: 0.3.0 @@ -10015,7 +10148,7 @@ snapshots: jiti: 2.6.1 minimist: 1.2.8 oxc-parser: 0.127.0 - oxc-resolver: 11.19.1(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2) + oxc-resolver: 11.19.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) picomatch: 4.0.4 smol-toml: 1.6.1 strip-json-comments: 5.0.3 @@ -10816,7 +10949,7 @@ snapshots: '@oxc-parser/binding-win32-ia32-msvc': 0.127.0 '@oxc-parser/binding-win32-x64-msvc': 0.127.0 - oxc-resolver@11.19.1(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2): + oxc-resolver@11.19.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0): optionalDependencies: '@oxc-resolver/binding-android-arm-eabi': 11.19.1 '@oxc-resolver/binding-android-arm64': 11.19.1 @@ -10834,7 +10967,7 @@ snapshots: '@oxc-resolver/binding-linux-x64-gnu': 11.19.1 '@oxc-resolver/binding-linux-x64-musl': 11.19.1 '@oxc-resolver/binding-openharmony-arm64': 11.19.1 - '@oxc-resolver/binding-wasm32-wasi': 11.19.1(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2) + '@oxc-resolver/binding-wasm32-wasi': 11.19.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) '@oxc-resolver/binding-win32-arm64-msvc': 11.19.1 '@oxc-resolver/binding-win32-ia32-msvc': 11.19.1 '@oxc-resolver/binding-win32-x64-msvc': 11.19.1 @@ -12572,4 +12705,12 @@ snapshots: zod@4.3.6: {} + zustand@4.5.7(@types/react@19.2.14)(immer@11.1.4)(react@19.2.5): + dependencies: + use-sync-external-store: 1.6.0(react@19.2.5) + optionalDependencies: + '@types/react': 19.2.14 + immer: 11.1.4 + react: 19.2.5 + zwitch@2.0.4: {}