Parallel Execution Fork/Join — Phase 3: Viewer + Simulation UI Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Make the @apitomy/flow-ui viewer, editor simulation overlay, and simulation panel render
concurrent fork/join branches — multi-active node highlighting, all-active edge animation,
branch-aware node detail, and a multi-branch simulation panel — instead of the single-cursor model.
Architecture: All multi-branch derivation is extracted into pure, framework-free helpers
(utils/parallelView.ts, extended utils/nodeHistory.ts) that are unit-tested with vitest. The React
components (WorkflowViewer.tsx, WorkflowEditor.tsx, SimulationPanel.tsx) become thin renderers
that call those helpers and read the branch collections (activeBranches, parkedBranchIds,
branch-attributed history) already produced by the Phase 1/2 engine and simulator, rather than the
scalar back-compat fields (currentNodeId, blockedOn).
Tech Stack: TypeScript + React 19, @xyflow/react (React Flow v12), Vite, vitest (no
@testing-library/react / jsdom — testable logic is kept pure).
Spec: docs/superpowers/specs/2026-09-04-parallel-execution-fork-join-design.md (Phase 3, under
"UI impact" and "Phased delivery").
Global Constraints
- All commands run from the
ui/directory. Gates:npx vitest run,npx tsc --noEmit,npm run lint,npm run build— all must pass before a task is complete. - No jsdom / React component tests. Only pure logic gets unit tests (vitest). Component changes are verified by typecheck + lint + build only.
- Match each file's existing indentation:
ui/src/utils/*.tsand the React components (WorkflowViewer.tsx,WorkflowEditor.tsx,SimulationPanel.tsx) use 2-space indent. Newutils/parallelView.tsuses 2-space. - Every public function gets a JSDoc docstring.
- Branch-id back-compat: a
HistoryEntrywithbranchId === undefineddenotes the root branch; the rootActiveBranchusesbranchId === 'root'. Any matching of branches against history entries MUST treatentry.branchId ?? 'root'as the entry's branch. - Phase 3 is presentation-only. Do NOT change
simulation/simulate.ts,simulation/parallelRegions.ts,validation/*, or anytypes/*. Their outputs are the inputs to this phase. - This work lands on the existing
issues/gh-88branch (PR #97), continuing issue #88 — do not open a new branch offmainfor delivery (a scratch worktree during SDD is fine; integrate back ontoissues/gh-88).
Task 1: Pure branch-view helpers (parallelView.ts)
Files:
- Create: ui/src/utils/parallelView.ts
- Test: ui/src/utils/parallelView.test.ts
Interfaces:
- Consumes: ActiveBranch, HistoryEntry from ../types/instance.ts; NodeType from
../types/workflow.ts.
- Produces (relied on by Tasks 3, 4, 5):
- activeNodeIds(branches: ActiveBranch[], parkedBranchIds?: readonly string[]): Set<string>
- activeEdgeIds(branches: ActiveBranch[], history: HistoryEntry[]): Set<string>
- parkedNodes(branches: ActiveBranch[], parkedBranchIds: readonly string[], nodeType: (nodeId: string) => NodeType | undefined): ParkedNode[]
where interface ParkedNode { nodeId: string; kind: NodeType; branchId: string }
- branchPaths(history: HistoryEntry[]): BranchPath[]
where interface BranchPath { branchId: string; nodeIds: string[] }
- simNodeClass(nodeId: string, opts: SimNodeClassOpts): string
where interface SimNodeClassOpts { activeIds: Set<string>; parkedIds: Set<string>; visited: ReadonlySet<string>; failedNodeId?: string }
- [ ] Step 1: Write the failing tests
Create ui/src/utils/parallelView.test.ts:
import { describe, it, expect } from 'vitest';
import { type ActiveBranch, type HistoryEntry } from '../types/instance.ts';
import { type NodeType } from '../types/workflow.ts';
import {
activeNodeIds,
activeEdgeIds,
parkedNodes,
branchPaths,
simNodeClass,
} from './parallelView.ts';
function branch(branchId: string, nodeId: string): ActiveBranch {
return { branchId, nodeId };
}
function entry(nodeId: string, extra: Partial<HistoryEntry> = {}): HistoryEntry {
return { nodeId, nodeName: nodeId, enteredOn: '2026-01-01T00:00:00Z', ...extra };
}
describe('activeNodeIds', () => {
it('returns every branch node when no parked ids are given', () => {
const set = activeNodeIds([branch('root.0', 'a'), branch('root.1', 'b')]);
expect(set).toEqual(new Set(['a', 'b']));
});
it('excludes branches whose id is parked', () => {
const set = activeNodeIds(
[branch('root.0', 'a'), branch('root.1', 'b')],
['root.1'],
);
expect(set).toEqual(new Set(['a']));
});
it('returns an empty set for no branches', () => {
expect(activeNodeIds([])).toEqual(new Set());
});
});
describe('activeEdgeIds', () => {
it('collects the arrival edge of each active branch (latest matching visit)', () => {
const history: HistoryEntry[] = [
entry('a', { branchId: 'root.0', edgeId: 'e-fa' }),
entry('b', { branchId: 'root.1', edgeId: 'e-fb' }),
];
const set = activeEdgeIds([branch('root.0', 'a'), branch('root.1', 'b')], history);
expect(set).toEqual(new Set(['e-fa', 'e-fb']));
});
it('uses the most recent matching visit when a branch loops back to a node', () => {
const history: HistoryEntry[] = [
entry('a', { branchId: 'root', edgeId: 'e1' }),
entry('a', { branchId: 'root', edgeId: 'e2' }),
];
expect(activeEdgeIds([branch('root', 'a')], history)).toEqual(new Set(['e2']));
});
it('treats a missing history branchId as the root branch', () => {
const history: HistoryEntry[] = [entry('a', { edgeId: 'e1' })];
expect(activeEdgeIds([branch('root', 'a')], history)).toEqual(new Set(['e1']));
});
it('omits branches with no arrival edge (e.g. the start node)', () => {
const history: HistoryEntry[] = [entry('start', { branchId: 'root' })];
expect(activeEdgeIds([branch('root', 'start')], history)).toEqual(new Set());
});
});
describe('parkedNodes', () => {
const kinds: Record<string, NodeType> = { a: 'action', w: 'wait' };
const lookup = (id: string): NodeType | undefined => kinds[id];
it('returns one entry per parked branch with its node kind and branch id', () => {
const result = parkedNodes(
[branch('root.0', 'a'), branch('root.1', 'w')],
['root.0', 'root.1'],
lookup,
);
expect(result).toEqual([
{ nodeId: 'a', kind: 'action', branchId: 'root.0' },
{ nodeId: 'w', kind: 'wait', branchId: 'root.1' },
]);
});
it('ignores non-parked branches', () => {
const result = parkedNodes([branch('root.0', 'a'), branch('root.1', 'w')], ['root.1'], lookup);
expect(result).toEqual([{ nodeId: 'w', kind: 'wait', branchId: 'root.1' }]);
});
it('drops parked branches whose node type cannot be resolved', () => {
const result = parkedNodes([branch('root.0', 'gone')], ['root.0'], lookup);
expect(result).toEqual([]);
});
});
describe('branchPaths', () => {
it('groups history nodes per branch, preserving first-appearance branch order', () => {
const history: HistoryEntry[] = [
entry('start', { branchId: 'root' }),
entry('a', { branchId: 'root.0' }),
entry('b', { branchId: 'root.1' }),
entry('a2', { branchId: 'root.0' }),
];
expect(branchPaths(history)).toEqual([
{ branchId: 'root', nodeIds: ['start'] },
{ branchId: 'root.0', nodeIds: ['a', 'a2'] },
{ branchId: 'root.1', nodeIds: ['b'] },
]);
});
it('treats a missing branchId as the root branch', () => {
const history: HistoryEntry[] = [entry('start'), entry('next')];
expect(branchPaths(history)).toEqual([{ branchId: 'root', nodeIds: ['start', 'next'] }]);
});
it('returns an empty array for empty history', () => {
expect(branchPaths([])).toEqual([]);
});
});
describe('simNodeClass', () => {
const base = { activeIds: new Set<string>(), parkedIds: new Set<string>(), visited: new Set<string>() };
it('marks the failed node when a failure is present', () => {
expect(simNodeClass('x', { ...base, activeIds: new Set(['x']), failedNodeId: 'x' }))
.toBe('flow-sim-node-failed');
});
it('marks a parked node as blocked', () => {
expect(simNodeClass('x', { ...base, parkedIds: new Set(['x']) })).toBe('flow-sim-node-blocked');
});
it('marks an active (non-parked) node as current', () => {
expect(simNodeClass('x', { ...base, activeIds: new Set(['x']) })).toBe('flow-sim-node-current');
});
it('prefers blocked over current when a node is both', () => {
expect(simNodeClass('x', { ...base, activeIds: new Set(['x']), parkedIds: new Set(['x']) }))
.toBe('flow-sim-node-blocked');
});
it('marks a visited-but-inactive node as visited', () => {
expect(simNodeClass('x', { ...base, visited: new Set(['x']) })).toBe('flow-sim-node-visited');
});
it('marks an untouched node as idle', () => {
expect(simNodeClass('x', base)).toBe('flow-sim-node-idle');
});
});
- [ ] Step 2: Run the tests to verify they fail
Run: cd ui && npx vitest run src/utils/parallelView.test.ts
Expected: FAIL — Failed to resolve import "./parallelView.ts" / functions not defined.
- [ ] Step 3: Write the implementation
Create ui/src/utils/parallelView.ts:
import { type ActiveBranch, type HistoryEntry } from '../types/instance.ts';
import { type NodeType } from '../types/workflow.ts';
/** A branch parked on a blocking node, with the node kind resolved for display. */
export interface ParkedNode {
nodeId: string;
kind: NodeType;
branchId: string;
}
/** The ordered node path taken by a single branch. */
export interface BranchPath {
branchId: string;
nodeIds: string[];
}
/** Inputs for {@link simNodeClass}. */
export interface SimNodeClassOpts {
/** Node ids of currently-active (runnable) branches. */
activeIds: Set<string>;
/** Node ids of parked (blocked) branches. */
parkedIds: Set<string>;
/** Node ids visited at any point in the run. */
visited: ReadonlySet<string>;
/** The failing node id when the run has failed; omitted otherwise. */
failedNodeId?: string;
}
/** The branch id an entry belongs to, treating a missing value as the root branch. */
function entryBranchId(entry: HistoryEntry): string {
return entry.branchId ?? 'root';
}
/**
* The set of node ids where branches currently sit.
*
* @param branches the live branch tokens
* @param parkedBranchIds branch ids to exclude (parked/blocked); omit to include every branch
* @returns a set of node ids (never null)
*/
export function activeNodeIds(
branches: ActiveBranch[],
parkedBranchIds?: readonly string[],
): Set<string> {
const parked = parkedBranchIds ? new Set(parkedBranchIds) : undefined;
const ids = new Set<string>();
for (const branch of branches) {
if (parked && parked.has(branch.branchId)) continue;
ids.add(branch.nodeId);
}
return ids;
}
/**
* The set of arrival edge ids for the currently-active branches: for each branch, the edge id of the
* most recent history entry matching that branch and its current node.
*
* @param branches the live branch tokens
* @param history the full, branch-attributed history
* @returns a set of edge ids (branches with no arrival edge, e.g. the start node, contribute nothing)
*/
export function activeEdgeIds(branches: ActiveBranch[], history: HistoryEntry[]): Set<string> {
const edges = new Set<string>();
for (const branch of branches) {
let arrivalEdgeId: string | undefined;
for (const entry of history) {
if (entry.nodeId === branch.nodeId && entryBranchId(entry) === branch.branchId && entry.edgeId) {
arrivalEdgeId = entry.edgeId;
}
}
if (arrivalEdgeId) edges.add(arrivalEdgeId);
}
return edges;
}
/**
* Resolves the parked (blocked) branches to displayable {@link ParkedNode}s.
*
* @param branches the live branch tokens
* @param parkedBranchIds branch ids that are parked on a blocking node
* @param nodeType resolves a node id to its type (undefined when the node is unknown)
* @returns one entry per parked branch whose node type resolves, in branch order
*/
export function parkedNodes(
branches: ActiveBranch[],
parkedBranchIds: readonly string[],
nodeType: (nodeId: string) => NodeType | undefined,
): ParkedNode[] {
const parked = new Set(parkedBranchIds);
const result: ParkedNode[] = [];
for (const branch of branches) {
if (!parked.has(branch.branchId)) continue;
const kind = nodeType(branch.nodeId);
if (!kind) continue;
result.push({ nodeId: branch.nodeId, kind, branchId: branch.branchId });
}
return result;
}
/**
* Groups a branch-attributed history into per-branch ordered paths.
*
* @param history the full, branch-attributed history
* @returns one {@link BranchPath} per branch, in the order each branch first appears
*/
export function branchPaths(history: HistoryEntry[]): BranchPath[] {
const order: string[] = [];
const byBranch = new Map<string, string[]>();
for (const entry of history) {
const branchId = entryBranchId(entry);
let nodeIds = byBranch.get(branchId);
if (!nodeIds) {
nodeIds = [];
byBranch.set(branchId, nodeIds);
order.push(branchId);
}
nodeIds.push(entry.nodeId);
}
return order.map(branchId => ({ branchId, nodeIds: byBranch.get(branchId)! }));
}
/**
* The simulation overlay CSS class for a node, given the multi-branch run state. Precedence:
* failed > blocked (parked) > current (active) > visited > idle.
*
* @param nodeId the node to classify
* @param opts the active/parked/visited sets and optional failing node id
* @returns one of `flow-sim-node-{failed,blocked,current,visited,idle}`
*/
export function simNodeClass(nodeId: string, opts: SimNodeClassOpts): string {
if (opts.failedNodeId && nodeId === opts.failedNodeId) return 'flow-sim-node-failed';
if (opts.parkedIds.has(nodeId)) return 'flow-sim-node-blocked';
if (opts.activeIds.has(nodeId)) return 'flow-sim-node-current';
return opts.visited.has(nodeId) ? 'flow-sim-node-visited' : 'flow-sim-node-idle';
}
- [ ] Step 4: Run the tests to verify they pass
Run: cd ui && npx vitest run src/utils/parallelView.test.ts
Expected: PASS (all cases).
- [ ] Step 5: Typecheck and lint
Run: cd ui && npx tsc --noEmit && npm run lint
Expected: clean.
- [ ] Step 6: Commit
git add ui/src/utils/parallelView.ts ui/src/utils/parallelView.test.ts
git commit -m "Add pure branch-view helpers for parallel viewer/sim UI (#88)"
Task 2: Branch grouping in nodeHistory.ts
Files:
- Modify: ui/src/utils/nodeHistory.ts
- Test: ui/src/utils/nodeHistory.test.ts
Interfaces:
- Consumes: HistoryEntry from ../types/instance.ts.
- Produces (relied on by Task 3):
- nodeVisitsByBranch(history: HistoryEntry[], nodeId: string | null): NodeBranchVisits[]
where interface NodeBranchVisits { branchId: string; visits: HistoryEntry[] }
- Existing nodeVisits(history, nodeId) is unchanged.
- [ ] Step 1: Write the failing tests
Add to ui/src/utils/nodeHistory.test.ts (keep existing tests). Ensure the import line includes the
new symbol:
Add this describe block:
describe('nodeVisitsByBranch', () => {
const h = (nodeId: string, branchId?: string): HistoryEntry => ({
nodeId, nodeName: nodeId, enteredOn: '2026-01-01T00:00:00Z', branchId,
});
it('returns an empty array when nodeId is null', () => {
expect(nodeVisitsByBranch([h('a', 'root')], null)).toEqual([]);
});
it('returns an empty array when the node was never visited', () => {
expect(nodeVisitsByBranch([h('a', 'root')], 'b')).toEqual([]);
});
it('groups a node visited across multiple branches, in first-appearance order', () => {
const history = [h('a', 'root.1'), h('a', 'root.0'), h('a', 'root.1')];
const result = nodeVisitsByBranch(history, 'a');
expect(result.map(g => g.branchId)).toEqual(['root.1', 'root.0']);
expect(result[0].visits).toHaveLength(2);
expect(result[1].visits).toHaveLength(1);
});
it('treats a missing branchId as the root branch', () => {
const result = nodeVisitsByBranch([h('a'), h('a')], 'a');
expect(result).toEqual([{ branchId: 'root', visits: [h('a'), h('a')] }]);
});
});
If nodeHistory.test.ts does not already import HistoryEntry, add:
import { type HistoryEntry } from '../types/instance.ts';
- [ ] Step 2: Run the tests to verify they fail
Run: cd ui && npx vitest run src/utils/nodeHistory.test.ts
Expected: FAIL — nodeVisitsByBranch is not exported.
- [ ] Step 3: Write the implementation
Add to ui/src/utils/nodeHistory.ts (below the existing nodeVisits):
/** A node's visits within a single branch. */
export interface NodeBranchVisits {
branchId: string;
visits: HistoryEntry[];
}
/**
* Groups a node's visits by branch, so a node reached concurrently (or repeatedly) across parallel
* branches can be shown per-branch. Branches appear in the order they are first seen in the history.
* A visit whose {@link HistoryEntry.branchId} is absent is attributed to the root branch.
*
* @param history the full instance history
* @param nodeId the id of the node whose visits should be grouped
* @returns one group per branch that visited the node (empty when never visited or nodeId is null)
*/
export function nodeVisitsByBranch(history: HistoryEntry[], nodeId: string | null): NodeBranchVisits[] {
if (!nodeId) return [];
const order: string[] = [];
const byBranch = new Map<string, HistoryEntry[]>();
for (const entry of history) {
if (entry.nodeId !== nodeId) continue;
const branchId = entry.branchId ?? 'root';
let visits = byBranch.get(branchId);
if (!visits) {
visits = [];
byBranch.set(branchId, visits);
order.push(branchId);
}
visits.push(entry);
}
return order.map(branchId => ({ branchId, visits: byBranch.get(branchId)! }));
}
- [ ] Step 4: Run the tests to verify they pass
Run: cd ui && npx vitest run src/utils/nodeHistory.test.ts
Expected: PASS.
- [ ] Step 5: Typecheck and lint
Run: cd ui && npx tsc --noEmit && npm run lint
Expected: clean.
- [ ] Step 6: Commit
git add ui/src/utils/nodeHistory.ts ui/src/utils/nodeHistory.test.ts
git commit -m "Add branch-grouped node-history helper (#88)"
Task 3: Multi-active highlighting + branch-aware NodeDetail in WorkflowViewer
Files:
- Modify: ui/src/components/WorkflowViewer.tsx
Interfaces:
- Consumes: activeNodeIds, activeEdgeIds from ../utils/parallelView.ts; nodeVisitsByBranch
from ../utils/nodeHistory.ts; instance.activeBranches, instance.history,
instance.currentNodeId, instance.status from the existing WorkflowInstance.
- Produces: no new exports; behavioral change only. Verified by typecheck/lint/build (no component
unit test — project convention).
This task has no vitest step (component-only). The verification steps are typecheck, lint, and build.
- [ ] Step 1: Add the helper imports
In ui/src/components/WorkflowViewer.tsx, add near the existing util imports (after the
nodeVisits import on line 8):
import { nodeVisits, nodeVisitsByBranch } from '../utils/nodeHistory.ts';
import { activeNodeIds, activeEdgeIds } from '../utils/parallelView.ts';
(Replace the existing single nodeVisits import line with the two lines above.)
- [ ] Step 2: Compute the active node/edge sets
Replace the nodes and edges useMemo blocks (currently lines ~70–109) so highlighting is
driven by the active-branch sets. Add these memos immediately before the nodes memo:
// Nodes where branches currently sit. At a terminal state the branch set is empty, so fall back to
// the preserved terminal/failing node id to keep completed/failed highlighting unchanged.
const activeIds = useMemo(
() => (isTerminal
? new Set([instance.currentNodeId].filter((id): id is string => !!id))
: activeNodeIds(instance.activeBranches)),
[isTerminal, instance.currentNodeId, instance.activeBranches],
);
// Arrival edges of the currently-active branches (empty at terminal states).
const activeEdges = useMemo(
() => (isTerminal ? new Set<string>() : activeEdgeIds(instance.activeBranches, instance.history)),
[isTerminal, instance.activeBranches, instance.history],
);
Then change the nodes memo to test set membership instead of === instance.currentNodeId:
const nodes = useMemo(() => {
return toReactFlowNodes(laidOutNodes).map(node => {
const isCurrent = activeIds.has(node.id);
const isVisited = visitedNodeIds.has(node.id);
let className: string;
if (isCurrent && isTerminal) {
className = instance.status === 'failed' ? 'flow-node-failed'
: instance.status === 'cancelled' ? 'flow-node-cancelled'
: 'flow-node-visited';
} else if (isCurrent) {
className = 'flow-node-current';
} else if (isVisited) {
className = 'flow-node-visited';
} else {
className = 'flow-node-unvisited';
}
return {
...node,
data: { ...node.data, isCurrent: isCurrent && !isTerminal },
className,
draggable: false,
};
});
}, [laidOutNodes, activeIds, instance.status, isTerminal, visitedNodeIds]);
And change the edges memo to animate every active arrival edge:
const edges = useMemo(() => {
return toReactFlowEdges(workflow.edges).map(edge => {
const isVisited = visitedEdgeIds.has(edge.id);
return {
...edge,
style: {
...edge.style,
strokeWidth: isVisited ? 2.5 : 1,
stroke: isVisited ? 'var(--flow-status-success, #3e8635)' : undefined,
opacity: isVisited ? 1 : 0.3,
},
animated: !isTerminal && activeEdges.has(edge.id),
};
});
}, [workflow.edges, visitedEdgeIds, activeEdges, isTerminal]);
- [ ] Step 3: Make
NodeDetailbranch-aware
The panel currently derives selectedNodeVisits from nodeVisits and passes a single flat visit
list to NodeDetail. Keep selectedNodeVisits (still used for the flat index math) and add a
grouped view. Immediately after the existing selectedNodeVisits memo (line ~128) add:
const selectedNodeVisitsByBranch = useMemo(
() => nodeVisitsByBranch(instance.history, selectedNodeId),
[selectedNodeId, instance.history],
);
Change the isCurrent prop passed to NodeDetail from selectedNodeId === instance.currentNodeId
to set membership, and pass the grouped visits and the flat visits:
<NodeDetail
node={selectedWorkflowNode}
history={selectedNodeHistory}
visits={selectedNodeVisits}
visitsByBranch={selectedNodeVisitsByBranch}
visitIndex={effectiveVisitIndex}
onSelectVisit={setSelectedVisitIndex}
isCurrent={selectedNodeId ? activeIds.has(selectedNodeId) : false}
instanceStatus={instance.status}
/>
Update the NodeDetail component signature and its visit <select> to group by branch. Change the
props type to add visitsByBranch and import the NodeBranchVisits type:
(Merge this with the import added in Step 1 — the final import is the single line above.)
In the NodeDetail signature, add the new prop:
function NodeDetail({ node, history, visits, visitsByBranch, visitIndex, onSelectVisit, isCurrent, instanceStatus }: {
node: WorkflowViewerProps['workflow']['nodes'][number] | null;
history: HistoryEntry | null;
visits: HistoryEntry[];
visitsByBranch: NodeBranchVisits[];
visitIndex: number;
onSelectVisit: (index: number) => void;
isCurrent: boolean;
instanceStatus: InstanceStatus;
}) {
Replace the visit <select> (currently rendered when visits.length > 1) so its options are grouped
by branch when more than one branch visited the node. The <option> value must remain the index into
the flat visits array so onSelectVisit/effectiveVisitIndex math is unchanged. Use indexOf
against the flat visits array to map each grouped entry back to its flat index:
{visits.length > 1 && (
<select
className="workflow-viewer__visit-select"
value={visitIndex}
onChange={(e) => onSelectVisit(Number(e.target.value))}
aria-label="Select visit"
>
{visitsByBranch.length > 1
? visitsByBranch.map(group => (
<optgroup key={group.branchId} label={`Branch ${group.branchId}`}>
{group.visits.map(visit => {
const index = visits.indexOf(visit);
return (
<option key={index} value={index}>
Visit {index + 1} of {visits.length} — {new Date(visit.enteredOn).toLocaleString()}
</option>
);
})}
</optgroup>
))
: visits.map((visit, index) => (
<option key={index} value={index}>
Visit {index + 1} of {visits.length} — {new Date(visit.enteredOn).toLocaleString()}
</option>
))}
</select>
)}
Add a "Branch" detail row so the selected visit's branch is visible. Insert after the existing "Node
ID" context-entry block:
{history?.branchId && history.branchId !== 'root' && (
<div className="workflow-viewer__context-entry">
<span className="workflow-viewer__context-key">Branch</span>
<span className="workflow-viewer__context-value">{history.branchId}</span>
</div>
)}
- [ ] Step 4: Typecheck, lint, build
Run: cd ui && npx tsc --noEmit && npm run lint && npm run build
Expected: all clean. (nodeVisits remains imported and used for selectedNodeVisits; confirm no
unused-import lint error — if the flat nodeVisits is still used it stays, which it is.)
- [ ] Step 5: Run the full test suite (guard against regressions)
Run: cd ui && npx vitest run
Expected: PASS (no viewer unit tests exist; this confirms nothing else broke).
- [ ] Step 6: Commit
git add ui/src/components/WorkflowViewer.tsx
git commit -m "Render multiple active branches and branch-aware detail in WorkflowViewer (#88)"
Task 4: Editor simulation overlay uses shared simNodeClass + resume targeting
Files:
- Modify: ui/src/components/WorkflowEditor.tsx
Interfaces:
- Consumes: simNodeClass, activeNodeIds, parkedNodes (for parked ids) from
../utils/parallelView.ts; SimState fields activeBranches, parkedBranchIds,
visitedNodeIds, error, status; resumeSimulation(workflow, state, mock, nodeId?) (already
supports the optional nodeId).
- Produces: resumeSim is widened to (mock: SimMock, nodeId?: string) => void and threads nodeId
into resumeSimulation. This is the callback passed as SimulationPanel's onResume (whose type
Task 5 widens to match).
This task has no vitest step (component-only). Verified by typecheck/lint/build.
- [ ] Step 1: Remove the local
simNodeClassand import the shared helper
Delete the local simNodeClass function (currently lines ~86–93). Add the import near the other util
imports:
- [ ] Step 2: Compute the overlay sets from the branch model
In the simNodes memo (currently lines ~495–498), replace the single-cursor computation with the
active/parked sets:
const simNodes = useMemo(() => {
if (!simActive || !simState) return nodesWithValidation;
const visited = new Set(simState.visitedNodeIds);
const parkedIds = activeNodeIds(
simState.activeBranches.filter(b => simState.parkedBranchIds.includes(b.branchId)),
);
const activeIds = activeNodeIds(simState.activeBranches, simState.parkedBranchIds);
const failedNodeId = simState.status === 'failed' ? simState.error?.nodeId : undefined;
return nodesWithValidation.map(n => ({
...n,
className: simNodeClass(n.id, { activeIds, parkedIds, visited, failedNodeId }),
}));
}, [nodesWithValidation, simActive, simState]);
- [ ] Step 3: Thread the target node id through
resumeSim
Change resumeSim (currently lines ~452–454) to accept and forward the parked node id:
const resumeSim = useCallback((mock: SimMock, nodeId?: string) => {
setSimState(prev => (prev ? resumeSimulation(currentWorkflow, prev, mock, nodeId) : prev));
}, [currentWorkflow]);
- [ ] Step 4: Typecheck, lint, build
Run: cd ui && npx tsc --noEmit && npm run lint && npm run build
Expected: all clean. (Passing the wider resumeSim to the still-narrow onResume prop typechecks;
Task 5 widens the prop type.)
- [ ] Step 5: Run the full test suite (guard against regressions)
Run: cd ui && npx vitest run
Expected: PASS.
- [ ] Step 6: Commit
git add ui/src/components/WorkflowEditor.tsx
git commit -m "Drive editor sim overlay from the active-branch set (#88)"
Task 5: Multi-branch SimulationPanel (active list, blocked selector, grouped path)
Files:
- Modify: ui/src/components/panels/SimulationPanel.tsx
Interfaces:
- Consumes: activeNodeIds, parkedNodes, branchPaths, type ParkedNode from
../../utils/parallelView.ts; SimState fields activeBranches, parkedBranchIds, history,
status.
- Produces: SimulationPanelProps.onResume widened to (mock: SimMock, nodeId?: string) => void
(matches the resumeSim from Task 4).
This task has no vitest step (component-only). Verified by typecheck/lint/build.
- [ ] Step 1: Add imports and widen the
onResumeprop
Add the helper import:
import { activeNodeIds, parkedNodes, branchPaths, type ParkedNode } from '../../utils/parallelView.ts';
Widen the onResume prop in SimulationPanelProps:
/** Delivers a mock output/event to a specific blocked node (by id) and continues. */
onResume: (mock: SimMock, nodeId?: string) => void;
- [ ] Step 2: Replace single-cursor derivations with branch-aware ones
Replace the blockedNode / blockedNodeId derivation (currently lines ~98–101) with the parked-node
list and a selection. Use a node-type lookup closure over workflow.nodes:
const nodeType = (nodeId: string) => workflow.nodes.find(n => n.id === nodeId)?.type;
const parked: ParkedNode[] = simState
? parkedNodes(simState.activeBranches, simState.parkedBranchIds, nodeType)
: [];
const activeNodeList = simState
? [...activeNodeIds(simState.activeBranches, simState.parkedBranchIds)]
: [];
// The parked node the mock editor currently targets. Default to the first parked node; reset when
// the current selection is no longer parked (adjusting state during render is React's recommended
// pattern for resetting state on a derived "key" change).
const [selectedParkedId, setSelectedParkedId] = useState<string | undefined>(undefined);
const selectedParked =
parked.find(p => p.nodeId === selectedParkedId) ?? parked[0];
if (parked.length > 0 && selectedParked && selectedParked.nodeId !== selectedParkedId) {
setSelectedParkedId(selectedParked.nodeId);
}
const selectedParkedNode = selectedParked
? workflow.nodes.find(n => n.id === selectedParked.nodeId)
: undefined;
- [ ] Step 3: Re-scaffold the mock editor on the selected parked node
Replace the existing seededFor re-scaffold block (currently lines ~106–110) so it keys off the
selected parked node instead of blockedOn:
const selectedParkedNodeId = selectedParked?.nodeId;
if (selectedParkedNodeId && selectedParkedNodeId !== seededFor) {
setSeededFor(selectedParkedNodeId);
setMockText(scaffoldOutputs(selectedParkedNode));
setMockError(null);
}
- [ ] Step 4: Update
handleContinueto target the selected node
const handleContinue = () => {
if (!selectedParked) return;
const result = parseObject(mockText);
if (!result.ok) {
setMockError(result.error);
return;
}
setMockError(null);
onResume({ output: result.value }, selectedParked.nodeId);
};
- [ ] Step 5: Render active nodes, the blocked selector, and per-branch paths
Replace the status "at <node>" block (currently lines ~189–200) with an active-nodes list:
{simState && (
<div className="simulation-panel__status">
<span className={`simulation-panel__badge is-${simState.status}`}>
{STATUS_LABEL[simState.status]}
</span>
{activeNodeList.map(nodeId => (
<button
key={nodeId}
className="simulation-panel__link"
onClick={() => onFocusNode(nodeId)}
>
{workflow.nodes.find(n => n.id === nodeId)?.name || nodeId}
</button>
))}
</div>
)}
Replace the blocked section (currently lines ~202–223) with a picker across all parked nodes plus the mock editor for the selected one:
{simState?.status === 'blocked' && selectedParked && (
<div className="simulation-panel__block">
{parked.length > 1 && (
<select
className="simulation-panel__block-select"
value={selectedParked.nodeId}
onChange={(e) => setSelectedParkedId(e.target.value)}
aria-label="Select blocked node"
>
{parked.map(p => (
<option key={p.branchId} value={p.nodeId}>
{(workflow.nodes.find(n => n.id === p.nodeId)?.name || p.nodeId)} ({p.kind})
{p.branchId !== 'root' ? ` — ${p.branchId}` : ''}
</option>
))}
</select>
)}
<div className="simulation-panel__block-title">
Blocked at{' '}
<button className="simulation-panel__link" onClick={() => onFocusNode(selectedParked.nodeId)}>
{selectedParkedNode?.name || selectedParked.nodeId}
</button>{' '}
<span className="simulation-panel__kind">({selectedParked.kind})</span>
</div>
<label>Mock output — merged into context (JSON)</label>
<JsonCodeEditor
value={mockText}
onChange={setMockText}
minRows={5}
ariaLabel="Mock output (JSON)"
/>
{mockError && <div className="simulation-panel__error-text">{mockError}</div>}
<button className="simulation-panel__primary" onClick={handleContinue}>
Deliver & continue
</button>
</div>
)}
Replace the single "Path" block (currently lines ~244–257) with per-branch paths:
{simState && simState.history.length > 0 && (
<div className="simulation-panel__field">
<label>Path</label>
{branchPaths(simState.history).map(bp => (
<div key={bp.branchId} className="simulation-panel__branch-path">
{bp.branchId !== 'root' && (
<div className="simulation-panel__branch-label">Branch {bp.branchId}</div>
)}
<ol className="simulation-panel__path">
{bp.nodeIds.map((nodeId, i) => (
<li key={`${nodeId}-${i}`}>
<button className="simulation-panel__link" onClick={() => onFocusNode(nodeId)}>
{workflow.nodes.find(n => n.id === nodeId)?.name || nodeId}
</button>
</li>
))}
</ol>
</div>
))}
</div>
)}
- [ ] Step 6: Add minimal CSS for the new elements
Append to ui/src/components/panels/SimulationPanel.css (match existing spacing/vars):
.simulation-panel__block-select {
width: 100%;
margin-bottom: 8px;
}
.simulation-panel__branch-path {
margin-bottom: 8px;
}
.simulation-panel__branch-label {
font-size: 0.8em;
opacity: 0.7;
margin-bottom: 2px;
}
- [ ] Step 7: Typecheck, lint, build
Run: cd ui && npx tsc --noEmit && npm run lint && npm run build
Expected: all clean. Confirm no unused symbols remain (the old blockedNode/blockedNodeId
references and the simState.currentNodeId status link are fully replaced).
- [ ] Step 8: Run the full test suite (guard against regressions)
Run: cd ui && npx vitest run
Expected: PASS.
- [ ] Step 9: Commit
git add ui/src/components/panels/SimulationPanel.tsx ui/src/components/panels/SimulationPanel.css
git commit -m "Show concurrent branches in the simulation panel with per-node resume (#88)"
Self-Review
Spec coverage:
- "Multi-active highlighting in WorkflowViewer" → Task 3 (node set membership, data.isCurrent per
active node, all-active edge animation). ✅
- "branch-aware NodeDetail/nodeHistory" → Task 2 (nodeVisitsByBranch) + Task 3 (grouped visit
selector, Branch row, membership isCurrent). ✅
- "multi-branch SimulationPanel" → Task 5 (active list, blocked selector [one-at-a-time per the
decision], per-branch grouped path). ✅
- "editor per-node/per-edge simulation overlay classes computed from the active-branch set" → Task 4
(simNodeClass from active/parked sets; edge overlay already per-edge from edgeEvaluations, left
unchanged). ✅
- Shared, tested pure helper → Task 1 (parallelView.ts). ✅
- Decisions honored: one-at-a-time blocked selector (Task 5 Step 5); animate all active arrivals
(Task 3 Step 2); path grouped by branch (Task 5 Step 5). ✅
Placeholder scan: No TBD/TODO; every code step contains complete code. ✅
Type consistency:
- activeNodeIds(branches, parkedBranchIds?) — same signature in Task 1 (def), Task 3 (viewer, one
arg), Task 4 (editor, two args), Task 5 (panel, two args). ✅
- parkedNodes(branches, parkedBranchIds, nodeType) returns ParkedNode[] — used in Task 5. ✅
- branchPaths(history) returns BranchPath[] — used in Task 5. ✅
- simNodeClass(nodeId, { activeIds, parkedIds, visited, failedNodeId }) — def Task 1, call Task 4.
✅
- nodeVisitsByBranch(history, nodeId) returns NodeBranchVisits[] — def Task 2, used Task 3. ✅
- onResume/resumeSim widened to (mock: SimMock, nodeId?: string) => void in both Task 4 (impl)
and Task 5 (prop type). ✅
- Phase-3 read-only contract respected: no task edits simulate.ts, parallelRegions.ts,
validation/*, or types/*. resumeSimulation's optional nodeId already exists. ✅