sync(ladder): handle branches with nested parallels#756
Conversation
Pairs with openplc-web PR #395 (multi-branch-ladder). Brings handle branches: contacts/coils/parallels can be wired to a function block's secondary boolean input/output handles (e.g. CTU `R`) via a compact branch with its own local rail. Includes layout (placement, spacing, vertical clearance, parallel paths inside branches with nested-parallel support), routing (placeholders, drops, splices), drag-drop dispatch (classify-then-dispatch), persistence (rung state index, Zod load paths), and XML serializer wiring for FB input handles. Shared surface verified byte-identical against openplc-web at d79f2a1a (plus the type-cast/lint/prettier follow-ups 3f1acfe1, cb923351).
WalkthroughThis PR introduces a comprehensive handle-branches feature for the ladder graphical editor, enabling elements (contacts, coils, parallels) to be attached to block input/output handles. The implementation includes branch positioning, layout expansion, drag-and-drop routing, XML code generation for branches, and full store persistence with rung duplication support. ChangesHandle Branches Feature Implementation
Estimated code review effort🎯 4 (Complex) | ⏱️ ~75 minutes Possibly related PRs
Suggested labels
Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 inconclusive)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 3
🧹 Nitpick comments (1)
src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/placeholder/index.ts (1)
285-368: 💤 Low valueExtract a shared helper for input/output handle placeholder generation.
The input (296-331) and output (335-367) loops differ only in
direction, the posX sign/offset, and the'left'/'right'position string. Any future change to handle placeholder gating, geometry, orhandleBranchTargetshape must be applied in two places, which is an easy source of drift (e.g., the recent BOOL gating +hasBranchOnHandleskip already needs to stay in lockstep).♻️ Suggested extraction
const makeHandlePlaceholder = ( blockNode: Node, handle: CustomHandleProps, blockData: BasicNodeData & { variant: BlockVariant }, direction: 'input' | 'output', ) => { const handleId = handle.id as string const variableType = blockData.variant?.variables?.find((v) => v.name === handleId) if (!variableType) return null if (!canPlaceElementOnHandle(variableType)) return null if (hasBranchOnHandle(rung, blockNode.id, handleId)) return null const blockWidth = defaultCustomNodesStyles.block?.width ?? 80 const offset = pStyle.gap + pStyle.width / 2 const x = direction === 'input' ? blockNode.position.x - offset : blockNode.position.x + blockWidth + offset const placeholder = nodesBuilder.placeholder({ id: `placeholder_handle_${blockNode.id}_${handleId}`, type: 'default', relatedNode: blockNode, position: direction === 'input' ? 'left' : 'right', posX: x, posY: handle.glbPosition.y - pStyle.handle.y, handleX: x, handleY: handle.glbPosition.y, }) placeholder.data = { ...placeholder.data, handleBranchTarget: { blockId: blockNode.id, handleId, direction, handlePosition: { x: handle.glbPosition.x, y: handle.glbPosition.y }, }, } return placeholder }Then iterate
inputHandles.slice(1)andoutputHandles.slice(1)calling this helper.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/placeholder/index.ts` around lines 285 - 368, The input/output placeholder generation is duplicated in the nodes.forEach loop; extract a helper (e.g., makeHandlePlaceholder) that accepts (blockNode, handle, blockData, direction) and encapsulates the shared checks (variable lookup, canPlaceElementOnHandle, hasBranchOnHandle), position/offset calculation (use pStyle and defaultCustomNodesStyles.block width), nodesBuilder.placeholder creation, and attaching handleBranchTarget, then replace both inputHandles and outputHandles loops to call this helper for each handle (slice starting at index 1) and push non-null results into placeholderNodes; keep references to hasBranchOnHandle, canPlaceElementOnHandle, nodesBuilder.placeholder, and placeholder.data shape unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In
`@src/frontend/components/_atoms/graphical-editor/ladder/autocomplete/index.tsx`:
- Around line 165-187: Summary: Clearing a variable node leaves the parent
block's connectedVariables metadata pointing at a now-empty handle, so the
handle remains logically occupied. Fix: when handling the blockType ===
'variable' clear path, after you update the variable node via updateNode (the
existing call that updates variableNode.data.variable), also locate the parent
block node (the local block Node<BasicNodeData> in this scope) and update its
data.connectedVariables to remove/clear the entry for the handle (use the same
updateNode API to persist the change). Use getLadderPouVariablesRungNodeAndEdges
to get rung/variableNode as before and then call updateNode for the block node
to set block.data.connectedVariables[handleId] = undefined or delete that key so
the handle is no longer considered occupied.
In
`@src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/handle-branch/index.ts`:
- Around line 788-809: In insertIntoBranch, abort the insertion when oldEdge is
not found: instead of only logging a warning, return the unchanged rung state
(no new nodes/edges/handleBranches) and do not include newElement or modified
nodeIds; specifically, if oldEdge is falsy (the variable checked at the top),
skip calling spliceEdgeAndInsertNode and avoid pushing newElement into
rung.nodes or updating handleBranches — return an object signaling failure or
the original nodes/edges/handleBranches (consistent with the function's return
shape) so callers won't get disconnected branch nodes or stale metadata.
In `@src/frontend/utils/PLC/xml-generator/codesys/language/ladder-xml.ts`:
- Around line 85-95: The serialization anchors currently always use
node.data.inputConnector?.glbPosition even when a targetHandle is provided;
update findConnections to select the connector by handle when targetHandle is
set (e.g., resolve connector = targetHandle ? node.data.inputConnectors?.find(c
=> c.handle === targetHandle) : node.data.inputConnector) and use
connector.glbPosition for destination coordinates; apply the same change to the
other connection-serialization sites that reference node.data.inputConnector
(the blocks handling explicit handle-scoped connections) so they also pick the
per-handle connector position when targetHandle is present.
---
Nitpick comments:
In
`@src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/placeholder/index.ts`:
- Around line 285-368: The input/output placeholder generation is duplicated in
the nodes.forEach loop; extract a helper (e.g., makeHandlePlaceholder) that
accepts (blockNode, handle, blockData, direction) and encapsulates the shared
checks (variable lookup, canPlaceElementOnHandle, hasBranchOnHandle),
position/offset calculation (use pStyle and defaultCustomNodesStyles.block
width), nodesBuilder.placeholder creation, and attaching handleBranchTarget,
then replace both inputHandles and outputHandles loops to call this helper for
each handle (slice starting at index 1) and push non-null results into
placeholderNodes; keep references to hasBranchOnHandle, canPlaceElementOnHandle,
nodesBuilder.placeholder, and placeholder.data shape unchanged.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro
Run ID: 1390de75-2026-4e74-bf01-290c8417be18
📒 Files selected for processing (25)
src/frontend/components/_atoms/graphical-editor/ladder/autocomplete/index.tsxsrc/frontend/components/_atoms/graphical-editor/ladder/block.tsxsrc/frontend/components/_atoms/graphical-editor/ladder/power-rail.tsxsrc/frontend/components/_atoms/graphical-editor/ladder/utils/types.tssrc/frontend/components/_atoms/graphical-editor/ladder/variable.tsxsrc/frontend/components/_atoms/highlighted-textarea/index.tsxsrc/frontend/components/_features/[workspace]/editor/graphical/elements/ladder/block/index.tsxsrc/frontend/components/_molecules/graphical-editor/ladder/rung/body.tsxsrc/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/edges.tssrc/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/core/index.tssrc/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/diagram/index.tssrc/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/drag-n-drop/handlers.tssrc/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/drag-n-drop/index.tssrc/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/handle-branch/index.tssrc/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/index.tssrc/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/parallel/index.tssrc/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/placeholder/index.tssrc/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/utils/index.tssrc/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/variable-block/index.tssrc/frontend/store/slices/ladder/slice.tssrc/frontend/store/slices/ladder/types.tssrc/frontend/store/slices/ladder/utils/index.tssrc/frontend/utils/PLC/xml-generator/codesys/language/ladder-xml.tssrc/frontend/utils/PLC/xml-generator/old-editor/language/ladder-xml.tssrc/middleware/shared/ports/types.ts
| if (!variableName.trim()) { | ||
| // For variable nodes on block handles, clearing the name resets the variable | ||
| // so that a branch (contacts/coils) can be placed on the handle instead. | ||
| if (blockType === 'variable') { | ||
| const { rung, node: variableNode } = getLadderPouVariablesRungNodeAndEdges(editor, pous, ladderFlows, { | ||
| nodeId: (block as Node<BasicNodeData>).id, | ||
| }) | ||
| if (rung && variableNode) { | ||
| updateNode({ | ||
| editorName: editor.meta.name, | ||
| rungId: rung.id, | ||
| nodeId: variableNode.id, | ||
| node: { | ||
| ...variableNode, | ||
| data: { | ||
| ...variableNode.data, | ||
| variable: { id: '', name: '' }, | ||
| }, | ||
| }, | ||
| }) | ||
| } | ||
| return | ||
| } |
There was a problem hiding this comment.
Clear path leaves stale block-pin linkage metadata.
On Line 173-184, clearing a variable updates only the variable node. The connected block’s connectedVariables entry for that handle is not cleared, so handle state can remain logically “occupied” after clear.
💡 Suggested fix
if (blockType === 'variable') {
const { rung, node: variableNode } = getLadderPouVariablesRungNodeAndEdges(editor, pous, ladderFlows, {
nodeId: (block as Node<BasicNodeData>).id,
})
if (rung && variableNode) {
updateNode({
editorName: editor.meta.name,
rungId: rung.id,
nodeId: variableNode.id,
node: {
...variableNode,
data: {
...variableNode.data,
variable: { id: '', name: '' },
},
},
})
+
+ const relatedBlock = rung.nodes.find((n) => n.id === (variableNode as VariableNode).data.block?.id)
+ if (relatedBlock) {
+ const existing = Array.isArray((relatedBlock.data as BlockNodeData<BlockVariant>).connectedVariables)
+ ? (relatedBlock.data as BlockNodeData<BlockVariant>).connectedVariables
+ : []
+ const nextConnected = existing.filter(
+ (v) =>
+ v.type !== (variableNode as VariableNode).data.variant ||
+ v.handleId !== (variableNode as VariableNode).data.block.handleId,
+ )
+ updateNode({
+ editorName: editor.meta.name,
+ rungId: rung.id,
+ nodeId: relatedBlock.id,
+ node: {
+ ...relatedBlock,
+ data: {
+ ...relatedBlock.data,
+ connectedVariables: nextConnected,
+ },
+ },
+ })
+ }
}
return
}🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In
`@src/frontend/components/_atoms/graphical-editor/ladder/autocomplete/index.tsx`
around lines 165 - 187, Summary: Clearing a variable node leaves the parent
block's connectedVariables metadata pointing at a now-empty handle, so the
handle remains logically occupied. Fix: when handling the blockType ===
'variable' clear path, after you update the variable node via updateNode (the
existing call that updates variableNode.data.variable), also locate the parent
block node (the local block Node<BasicNodeData> in this scope) and update its
data.connectedVariables to remove/clear the entry for the handle (use the same
updateNode API to persist the change). Use getLadderPouVariablesRungNodeAndEdges
to get rung/variableNode as before and then call updateNode for the block node
to set block.data.connectedVariables[handleId] = undefined or delete that key so
the handle is no longer considered occupied.
| if (!oldEdge) { | ||
| console.warn( | ||
| `insertIntoBranch: expected edge ${sourceId}[${sourceHandle}] → ${targetId}[${targetHandle}] not found — branch metadata may be stale`, | ||
| ) | ||
| } | ||
| const newEdges = oldEdge | ||
| ? spliceEdgeAndInsertNode(rung.edges, oldEdge, newElement.id, 'input', 'output') | ||
| : [...rung.edges] | ||
|
|
||
| // Step 4: Splice new element ID into branch's nodeIds | ||
| const newNodeIds = [...nodeIds] | ||
| newNodeIds.splice(idx, 0, newElement.id) | ||
|
|
||
| const handleBranches = (rung.handleBranches ?? []).map((b) => | ||
| b.blockId === target.blockId && b.handleId === target.handleId ? { ...b, nodeIds: newNodeIds } : b, | ||
| ) | ||
|
|
||
| // Step 5: Add element to nodes array | ||
| const newNodes = [...rung.nodes, newElement] | ||
|
|
||
| return { nodes: newNodes, edges: newEdges, handleBranches, newNode: newElement } | ||
| } |
There was a problem hiding this comment.
Abort insertion when the split edge is missing.
Line 788 only warns, but Lines 798-807 still persist nodeIds and newElement. That can create disconnected branch nodes and stale branch metadata.
Suggested fix
- if (!oldEdge) {
- console.warn(
- `insertIntoBranch: expected edge ${sourceId}[${sourceHandle}] → ${targetId}[${targetHandle}] not found — branch metadata may be stale`,
- )
- }
- const newEdges = oldEdge
- ? spliceEdgeAndInsertNode(rung.edges, oldEdge, newElement.id, 'input', 'output')
- : [...rung.edges]
+ if (!oldEdge) {
+ throw new Error(
+ `insertIntoBranch: expected edge ${sourceId}[${sourceHandle}] → ${targetId}[${targetHandle}] not found`,
+ )
+ }
+ const newEdges = spliceEdgeAndInsertNode(rung.edges, oldEdge, newElement.id, 'input', 'output')📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| if (!oldEdge) { | |
| console.warn( | |
| `insertIntoBranch: expected edge ${sourceId}[${sourceHandle}] → ${targetId}[${targetHandle}] not found — branch metadata may be stale`, | |
| ) | |
| } | |
| const newEdges = oldEdge | |
| ? spliceEdgeAndInsertNode(rung.edges, oldEdge, newElement.id, 'input', 'output') | |
| : [...rung.edges] | |
| // Step 4: Splice new element ID into branch's nodeIds | |
| const newNodeIds = [...nodeIds] | |
| newNodeIds.splice(idx, 0, newElement.id) | |
| const handleBranches = (rung.handleBranches ?? []).map((b) => | |
| b.blockId === target.blockId && b.handleId === target.handleId ? { ...b, nodeIds: newNodeIds } : b, | |
| ) | |
| // Step 5: Add element to nodes array | |
| const newNodes = [...rung.nodes, newElement] | |
| return { nodes: newNodes, edges: newEdges, handleBranches, newNode: newElement } | |
| } | |
| if (!oldEdge) { | |
| throw new Error( | |
| `insertIntoBranch: expected edge ${sourceId}[${sourceHandle}] → ${targetId}[${targetHandle}] not found`, | |
| ) | |
| } | |
| const newEdges = spliceEdgeAndInsertNode(rung.edges, oldEdge, newElement.id, 'input', 'output') | |
| // Step 4: Splice new element ID into branch's nodeIds | |
| const newNodeIds = [...nodeIds] | |
| newNodeIds.splice(idx, 0, newElement.id) | |
| const handleBranches = (rung.handleBranches ?? []).map((b) => | |
| b.blockId === target.blockId && b.handleId === target.handleId ? { ...b, nodeIds: newNodeIds } : b, | |
| ) | |
| // Step 5: Add element to nodes array | |
| const newNodes = [...rung.nodes, newElement] | |
| return { nodes: newNodes, edges: newEdges, handleBranches, newNode: newElement } | |
| } |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In
`@src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/handle-branch/index.ts`
around lines 788 - 809, In insertIntoBranch, abort the insertion when oldEdge is
not found: instead of only logging a warning, return the unchanged rung state
(no new nodes/edges/handleBranches) and do not include newElement or modified
nodeIds; specifically, if oldEdge is falsy (the variable checked at the top),
skip calling spliceEdgeAndInsertNode and avoid pushing newElement into
rung.nodes or updating handleBranches — return an object signaling failure or
the original nodes/edges/handleBranches (consistent with the function's return
shape) so callers won't get disconnected branch nodes or stale metadata.
| const findConnections = ( | ||
| node: Node<BasicNodeData>, | ||
| rung: RungLadderState, | ||
| offsetY: number = 0, | ||
| targetHandle?: string, | ||
| ) => { | ||
| const { nodes: rungNodes, edges: rungEdges } = rung | ||
|
|
||
| const connectedEdges = rungEdges.filter((edge) => edge.target === node.id) | ||
| const connectedEdges = rungEdges.filter( | ||
| (edge) => edge.target === node.id && (targetHandle === undefined || edge.targetHandle === targetHandle), | ||
| ) |
There was a problem hiding this comment.
Handle-scoped serialization still anchors coordinates to the main input connector.
When targetHandle is set, Line 112-114 still uses node.data.inputConnector?.glbPosition, so connections on secondary inputs (e.g., R) are serialized with wrong destination coordinates.
💡 Suggested fix
const findConnections = (
node: Node<BasicNodeData>,
rung: RungLadderState,
offsetY: number = 0,
targetHandle?: string,
) => {
const { nodes: rungNodes, edges: rungEdges } = rung
+ const targetConnector =
+ targetHandle && node.type === 'block'
+ ? (node as BlockNode<BlockVariant>).data.inputHandles.find((h) => h.id === targetHandle)
+ : node.data.inputConnector
@@
return {
'@refLocalId': sourceNode.data.numericId,
'@formalParameter': sourceHandle === 'OUT' ? '' : sourceHandle,
position: [
// Final edge destination
{
- '@x': node.data.inputConnector?.glbPosition.x ?? 0,
- '@y': (node.data.inputConnector?.glbPosition.y ?? 0) + offsetY,
+ '@x': targetConnector?.glbPosition.x ?? 0,
+ '@y': (targetConnector?.glbPosition.y ?? 0) + offsetY,
},Also applies to: 112-114, 445-510
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/frontend/utils/PLC/xml-generator/codesys/language/ladder-xml.ts` around
lines 85 - 95, The serialization anchors currently always use
node.data.inputConnector?.glbPosition even when a targetHandle is provided;
update findConnections to select the connector by handle when targetHandle is
set (e.g., resolve connector = targetHandle ? node.data.inputConnectors?.find(c
=> c.handle === targetHandle) : node.data.inputConnector) and use
connector.glbPosition for destination coordinates; apply the same change to the
other connection-serialization sites that reference node.data.inputConnector
(the blocks handling explicit handle-scoped connections) so they also pick the
per-handle connector position when targetHandle is present.
Brings the ~33 development commits accumulated since v4.2.0-rc1 was cut back onto the release branch: EtherCAT scan + stats pipeline, AI chat-history persistence, ladder branches with nested parallels (#756), and the server-tree visibility fix. Substantive conflict resolutions: * `store/slices/device/{types,slice}.ts` + tests Both branches added different actions to the device slice (rc1: vendor-screen persistence; dev: ethercat status + polling toggle + temporary DHCP IP). Kept both sets verbatim. * `_features/.../device/configuration/board.tsx` * `_features/.../device/orchestrators/orchestrators-list.tsx` Both branches reworked the connected-runtime stats panel. Kept rc1's `<ScanCycleStats>` / `<EtherCATStats>` / `<PluginStatsPanel>` molecule layout (renders immediately on connect, no scan-count gate) and adopted dev's `setIncludeEthercatStatsInPolling` polling toggle so the global `useRuntimePolling` hook fetches EtherCAT status only while a stats screen is mounted. * `_molecules/ethercat-stats/index.tsx` Rewrote so the molecule consumes `runtimeConnection.ethercatStatus` from the device slice instead of self-fetching on its own 2 s timer. Extended the column set with the metrics dev's `EthercatStatsSection` introduced (Master State, Slave Count, Max Exchange, Recovery Attempts, WKC consecutive-error badge) so no new health info is lost moving from cards back to the table. * `_features/.../device/ethercat/components/ethercat-stats-section.tsx` Deleted — its render is now handled by the unified `<EtherCATStats>` molecule. * `_organisms/explorer/project.tsx` Combined both gating intents: kept rc1's `projectCaps.hasServers` library gate AND dropped the `capabilities.hasLocalSerialPorts` clause per dev's d257e2a ("show Servers branch on platforms without local serial ports"). * `_features/.../device/ethercat/index.tsx` Kept rc1's `collectUsedIecAddresses` import path (`backend/shared/utils/iec-address`) and the extra `vendorScreenData` argument it now takes; adopted dev's `Modal` imports and `unmatched`-devices tracking in `handleAddSelectedFromScan`. Test-fixture updates to match the now-richer `EtherCATCycleMetrics` shape (min_*, period_*, latency_* fields are mandatory) and the expanded `RuntimeConnection` state (`ethercatStatus` + `includeEthercatStatsInPolling`). `npx tsc --noEmit`, `npm run build:main`, and the device / shared / ethercat / ladder test suites (1061 tests) all pass locally. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Brings the ~33 development commits accumulated since v4.2.0-rc1 was cut back onto the release branch: EtherCAT scan + stats pipeline, AI chat-history persistence, ladder branches with nested parallels (#756), and the server-tree visibility fix. Substantive conflict resolutions: * `store/slices/device/{types,slice}.ts` + tests Both branches added different actions to the device slice (rc1: vendor-screen persistence; dev: ethercat status + polling toggle + temporary DHCP IP). Kept both sets verbatim. * `_features/.../device/configuration/board.tsx` * `_features/.../device/orchestrators/orchestrators-list.tsx` Both branches reworked the connected-runtime stats panel. Kept rc1's `<ScanCycleStats>` / `<EtherCATStats>` / `<PluginStatsPanel>` molecule layout (renders immediately on connect, no scan-count gate) and adopted dev's `setIncludeEthercatStatsInPolling` polling toggle so the global `useRuntimePolling` hook fetches EtherCAT status only while a stats screen is mounted. * `_molecules/ethercat-stats/index.tsx` Rewrote so the molecule consumes `runtimeConnection.ethercatStatus` from the device slice instead of self-fetching on its own 2 s timer. Extended the column set with the metrics dev's `EthercatStatsSection` introduced (Master State, Slave Count, Max Exchange, Recovery Attempts, WKC consecutive-error badge) so no new health info is lost moving from cards back to the table. * `_features/.../device/ethercat/components/ethercat-stats-section.tsx` Deleted — its render is now handled by the unified `<EtherCATStats>` molecule. * `_organisms/explorer/project.tsx` Combined both gating intents: kept rc1's `projectCaps.hasServers` library gate AND dropped the `capabilities.hasLocalSerialPorts` clause per dev's d257e2a ("show Servers branch on platforms without local serial ports"). * `_features/.../device/ethercat/index.tsx` Kept rc1's `collectUsedIecAddresses` import path (`backend/shared/utils/iec-address`) and the extra `vendorScreenData` argument it now takes; adopted dev's `Modal` imports and `unmatched`-devices tracking in `handleAddSelectedFromScan`. Test-fixture updates to match the now-richer `EtherCATCycleMetrics` shape (min_*, period_*, latency_* fields are mandatory) and the expanded `RuntimeConnection` state (`ethercatStatus` + `includeEthercatStatsInPolling`). `npx tsc --noEmit`, `npm run build:main`, and the device / shared / ethercat / ladder test suites (1061 tests) all pass locally. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Summary
multi-branch-ladder).R) via a compact branch with its own local rail.frontend/andmiddleware/shared/ports/types.ts. Shared surface verified byte-identical against the web branch.What's different vs editor PR #750
PR #750 was an earlier attempt that ran into too many layout bugs; this PR is the rewritten implementation that web has been iterating on. They are mutually exclusive — once this lands, #750 can be closed.
Test plan
Rboolean input — it appears as a branch with a local rail next to the FB.res1contact to CTURvia a branch and compile — whenres1is TRUE, the counter resets.🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Bug Fixes
Improvements