Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,44 @@ platform API version.
globs, the openability probe and the raw sidecar commands accept any of them;
the new-file scaffold takes the first. `sourceExtension` stays honoured and is
all a Python product needs.
- `DiagramStorageOptions.platformEntityItems` (and the same on the toolkit's
profile input): `false` leaves the palette's Entities category to the
product's own `entityPaletteItems`, for a product whose nodes are none of the
platform's kinds. Default `true`, so every existing palette is unchanged.
- Sidecar toolkit, for a sidecar that is not Python: `openability.decoratorName`
is optional (with only a probe, an undecidable probe opens the file instead of
scanning for a decorator); `navigation: 'none'` installs no Python navigation
provider and skips the Python caller scan; `chat.sourceMimeType` names what
the agent receives the file as.
- A producer may place a port with its bundle instead of by its direction. A
port's `side` (`west`/`east`) and a boundary node's `meta.side` say where it
sits; `group` names the bundle. A handshake's `ready` then sits beside the
`valid` and `data` it answers, and the wire between two nodes is a straight
segment with the arrow pointing back instead of a loop around both. The
direction is untouched: a `ready` output stays an output. The side reaches
the shared port anchor (`cal:portSide`), ELK's port sides and row ordering,
the label pass, the client hit areas and a mirrored boundary glyph
(`cal:boundarySide`). Absent, everything is as before. An edge whose
`meta.role` is `bidirectional` is drawn without an arrowhead.
- `documentErrorsToProblems` on the sidecar toolkit's profile input and runtime
config: `true` publishes a loaded document's own root-level `errors` to the
Problems panel with their file, line and column. Default `false`: a Python
runtime's load errors stay in the diagram's overlay, as the diagnostics
parity tests pin.
- A location in a graph document (`source`, `referencedSource`, `authoredSource`
on a node, `source` on a port or an edge) may leave out its `file` when it is
the graph's own, `graph.meta.file`; the reader puts it back before anything
navigates. A hierarchy export of a large netlist repeated one absolute path on
every element, a third of the export. A document with every file spelled out
reads exactly as before.

### Fixed

- ELK is told the side the producer placed a port on. It was told the side
from the direction alone, so a bundle's `ready`, placed in the other margin,
was routed to as if it sat on the far edge: the wire came around the outside
of the margin and crossed the whole node, label included, to reach the glyph.
It now arrives from the side the glyph faces, in one straight run.

## [0.7.0] - 2026-09-01

Expand Down
1 change: 1 addition & 0 deletions packages/diagram-client/src/libavoid-edge-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ function anchorOf(port: any): { x: number; y: number; side: PortSide } | undefin
absolute: absolutePosition(port),
size: port.size ?? port.bounds,
direction: port.args?.['cal:portDirection'] as string | undefined,
side: port.args?.['cal:portSide'] as string | undefined,
type: port.type
});
}
Expand Down
73 changes: 54 additions & 19 deletions packages/diagram-client/src/views.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ import {
import { EDGE_OPEN_ANCHOR_X_ARG, EDGE_OPEN_ANCHOR_Y_ARG } from './edge-open-mouse-listener';
import { clientBehavior } from './profile';
import { isIncidentEdgeInActiveDrag } from './elk-live-drag-router';
import { BoundaryPortGeometry, WorkflowDiagramCss, WorkflowDiagramMetadata, WorkflowDiagramTypes } from '@dialogram/shared';
import { BoundaryPortGeometry, WorkflowDiagramCss, WorkflowDiagramMetadata, WorkflowDiagramTypes, normalizePortSide } from '@dialogram/shared';
import { WorkflowDiagramConstants } from '@dialogram/shared';

/** Walk to the model root; the drag-active window is tracked per root. */
Expand Down Expand Up @@ -1077,6 +1077,12 @@ export class ProxyNodeView extends ShapeView {
* Boundary input port node (left margin) - network's input exposed to internal entities
* Styled as a terminal connector shape with port name centered
*/
/** Whether the producer marked the connection as carrying data both ways (`edge-role-bidirectional`). */
function isBidirectionalEdge(edge: Readonly<SEdgeImpl>): boolean {
const classes = (edge as { cssClasses?: unknown }).cssClasses;
return Array.isArray(classes) && classes.includes('edge-role-bidirectional');
}

/** Whether an edge terminates on a boundary port, whose glyph is already an arrow. */
function endsAtBoundaryPort(edge: Readonly<SEdgeImpl>): boolean {
let element: any = (edge as any).target;
Expand All @@ -1090,6 +1096,18 @@ function endsAtBoundaryPort(edge: Readonly<SEdgeImpl>): boolean {
return false;
}

/** Whether the server placed this boundary node in the margin opposite its direction (`cal:boundarySide`). */
function boundaryMirrored(node: unknown, isInput: boolean): boolean {
const margin = normalizePortSide((node as { args?: Record<string, unknown> })?.args?.[WorkflowDiagramMetadata.BOUNDARY_SIDE]);
return margin !== undefined && margin !== (isInput ? 'WEST' : 'EAST');
}

/** Which side of its node a port sits on, as the server placed it: `cal:portSide`, else its direction. */
function portSideOf(port: unknown, fallbackEast: boolean): 'WEST' | 'EAST' {
return normalizePortSide((port as { args?: Record<string, unknown> })?.args?.[WorkflowDiagramMetadata.PORT_SIDE])
?? (fallbackEast ? 'EAST' : 'WEST');
}

/** One of a boundary node's own text values, from the args the server set. */
function boundaryText(node: unknown, key: string): string {
const value = (node as { args?: Record<string, unknown> })?.args?.[key];
Expand All @@ -1112,24 +1130,31 @@ function boundaryText(node: unknown, key: string): string {
* wire misses the arrow — both read `BoundaryPortGeometry` for exactly that
* reason.
*/
function boundaryPort(isInput: boolean, nodeWidth: number, name: string, type: string): VNode[] {
function boundaryPort(isInput: boolean, nodeWidth: number, name: string, type: string, mirrored = false): VNode[] {
const G = BoundaryPortGeometry;
const axis = G.AXIS_Y_PX;
const arrow = isInput ? G.SOURCE_ARROW : G.SINK_ARROW;
// Inputs occupy the node's right edge, outputs its left.
const glyphX = isInput ? nodeWidth - G.glyphWidth(true) : 0;
// Inputs occupy the node's right edge, outputs its left — unless the
// producer placed the node in the other margin with its bundle
// (`cal:boundarySide`), when everything mirrors: the glyph takes the
// other edge and points the other way, the text runs out the other side.
// The shape (bare arrow or arrow and bar) still says input or output.
const glyphEast = isInput !== mirrored;
const pointsEast = !mirrored;
const glyphX = glyphEast ? nodeWidth - G.glyphWidth(isInput) : 0;
const top = axis - arrow.height / 2;
const tipX = glyphX + arrow.width;
const textX = isInput ? nodeWidth - G.textOffset(true) : G.textOffset(false);
const anchor = isInput ? 'end' : 'start';
const tipX = pointsEast ? glyphX + arrow.width : glyphX;
const baseX = pointsEast ? glyphX : glyphX + arrow.width;
const textX = glyphEast ? nodeWidth - G.textOffset(isInput) : G.textOffset(isInput);
const anchor = glyphEast ? 'end' : 'start';

// How far the text reaches, from the glyph outward. Approximate by design —
// see approximateTextWidth; it sizes these rectangles and nothing else.
const nameReach = G.approximateTextWidth(name, G.NAME_FONT_PX);
const textReach = Math.max(nameReach, G.approximateTextWidth(type, G.TYPE_FONT_PX));
const spanFrom = (reach: number): { x: number; width: number } => isInput
? { x: textX - reach, width: reach + G.textOffset(true) }
: { x: 0, width: G.textOffset(false) + reach };
const spanFrom = (reach: number): { x: number; width: number } => glyphEast
? { x: textX - reach, width: reach + G.textOffset(isInput) }
: { x: 0, width: G.textOffset(isInput) + reach };

// Two rectangles, because clicking and highlighting are not the same extent
// — the diagram already draws that distinction and the port was not.
Expand Down Expand Up @@ -1161,14 +1186,14 @@ function boundaryPort(isInput: boolean, nodeWidth: number, name: string, type: s
// input, into the bar for an output.
svg('path', {
class: { 'boundary-glyph': true },
attrs: { d: `M ${glyphX} ${top} L ${tipX} ${axis} L ${glyphX} ${top + arrow.height} Z` }
attrs: { d: `M ${baseX} ${top} L ${tipX} ${axis} L ${baseX} ${top + arrow.height} Z` }
})
];
if (!isInput) {
parts.push(svg('rect', {
class: { 'boundary-glyph': true, 'boundary-glyph-bar': true },
attrs: {
x: tipX,
x: pointsEast ? tipX : tipX - G.SINK_BAR.width,
y: axis - G.SINK_BAR.height / 2,
width: G.SINK_BAR.width,
height: G.SINK_BAR.height
Expand Down Expand Up @@ -1228,7 +1253,7 @@ export class BoundaryInputNodeView extends ShapeView {
// No body: the symbol on the axis IS the port — glyph, name and
// type together, drawn as one thing.
...boundaryPort(true, width, boundaryText(node, WorkflowDiagramMetadata.PORT_NAME),
boundaryText(node, WorkflowDiagramMetadata.PORT_TYPE)),
boundaryText(node, WorkflowDiagramMetadata.PORT_TYPE), boundaryMirrored(node, true)),
// The port element (an anchor only) and the label elements, which
// render nothing — see WorkflowLabelView.
...context.renderChildren(node)
Expand Down Expand Up @@ -1263,7 +1288,7 @@ export class BoundaryOutputNodeView extends ShapeView {
},
// No body — see the input view above.
...boundaryPort(false, width, boundaryText(node, WorkflowDiagramMetadata.PORT_NAME),
boundaryText(node, WorkflowDiagramMetadata.PORT_TYPE)),
boundaryText(node, WorkflowDiagramMetadata.PORT_TYPE), boundaryMirrored(node, false)),
...context.renderChildren(node)
);
}
Expand All @@ -1286,8 +1311,9 @@ export class WorkflowInputPortView extends ShapeView {
// IMPORTANT: Do not expand the hit area past the node boundary.
// If the hit target extends outward, edge anchoring can use the larger bounds,
// which makes connections (and thus labels) look "far" from the visible port.
// For input ports (left side), expand inward (to the right).
const hitX = 0;
// For input ports (left side), expand inward (to the right) — or to the
// left when the producer placed this input on the east side with its bundle.
const hitX = portSideOf(port, false) === 'EAST' ? portWidth - hitSize : 0;
const hitY = (portHeight - hitSize) / 2;

const children: VNode[] = [
Expand Down Expand Up @@ -1365,8 +1391,9 @@ export class WorkflowOutputPortView extends ShapeView {
// Non-visual hit target to make connecting reliable without changing visible port size.
const hitSize = Math.max(14, portWidth, portHeight);
// For output ports (right side), expand inward (to the left) so the hit area right edge
// matches the visible port right edge.
const hitX = portWidth - hitSize;
// matches the visible port right edge — or to the right when the producer placed
// this output on the west side with its bundle.
const hitX = portSideOf(port, true) === 'EAST' ? portWidth - hitSize : 0;
const hitY = (portHeight - hitSize) / 2;

const children: VNode[] = [
Expand Down Expand Up @@ -2282,6 +2309,11 @@ export class WorkflowEdgeView extends PolylineEdgeView {
if (edge && endsAtBoundaryPort(edge)) {
return svg('g', {});
}
// A wire that carries data both ways (`role: bidirectional`, an HDL
// `inout` net) has no head: an arrow would claim a direction it lacks.
if (edge && isBidirectionalEdge(edge)) {
return svg('g', {});
}
if (segments.length < 2) {
return svg('g', {});
}
Expand Down Expand Up @@ -2396,7 +2428,10 @@ export class WorkflowLabelView implements IView {
const direction = parentPort?.args?.['cal:portDirection'] as string | undefined;
const parentType = parentPort?.type as string | undefined;
const isOutputByType = parentType?.includes('output') ?? false;
const isOutput = direction === 'output' || isOutputByType;
// "Output" here means the EAST side: where the label anchors depends on
// which edge the port sits on, which is its direction unless the producer
// placed it with its bundle (`cal:portSide`).
const isOutput = portSideOf(parentPort, direction === 'output' || isOutputByType) === 'EAST';

// Input labels: do not add extra inner padding. The label box already has a
// PORT_LABEL_GAP_PX from the port marker; additional inset makes inputs look
Expand Down
Loading
Loading