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
14 changes: 14 additions & 0 deletions backend/canvas.py
Original file line number Diff line number Diff line change
Expand Up @@ -698,6 +698,10 @@ class SceneDocument:
# ported 1:1 in shape to snap_to_grid above (bare bool, "scene" topic,
# dedicated setFadeConnections intent - see register_canvas below).
fade_connections_enabled: bool = False
# R7.5b-2: Qt-removal plan R7.5's second canvas-visual parity fix - the
# legacy scene's orthogonal_routing bool, same bare-bool/"scene"-topic
# shape as fade_connections_enabled above.
orthogonal_routing: bool = False
drag_factor: float = 1.0
# Canvas font (ChatScene's setFontFamily/-Size/-Color state, R2): defaults
# match the legacy scene's own construction-time values.
Expand Down Expand Up @@ -2795,6 +2799,7 @@ def scene_payload(self) -> dict[str, Any]:
],
"snapToGrid": self.snap_to_grid,
"fadeConnectionsEnabled": self.fade_connections_enabled,
"orthogonalRouting": self.orthogonal_routing,
"dragFactor": self.drag_factor,
"fontFamily": self.font_family,
"fontSizePt": self.font_size_pt,
Expand Down Expand Up @@ -3992,6 +3997,10 @@ async def set_fade_connections(enabled):
document.fade_connections_enabled = bool(enabled)
await publish_scene()

async def set_orthogonal_routing(enabled):
document.orthogonal_routing = bool(enabled)
await publish_scene()

async def set_drag_factor(factor):
document.set_drag_factor(factor)
await publish_scene()
Expand Down Expand Up @@ -4053,6 +4062,11 @@ async def set_view_state(zoom_factor, scroll_x, scroll_y):
bus.register_intent("scene", "updatePin", update_pin)
bus.register_intent("scene", "setSnapToGrid", set_snap_to_grid)
bus.register_intent("scene", "setFadeConnections", set_fade_connections)
# Intent name matches the legacy GridControlBridge's own
# setOrthogonalConnections Slot name 1:1, same convention as
# setSnapToGrid/setFadeConnections above - the Python function name above
# doesn't need to match.
bus.register_intent("scene", "setOrthogonalConnections", set_orthogonal_routing)
bus.register_intent("scene", "setDragFactor", set_drag_factor)
bus.register_intent("scene", "setViewState", set_view_state)
# R4.3: per-node cancel for a ConversationNode's in-flight reply. Reuses
Expand Down
18 changes: 18 additions & 0 deletions backend/tests/test_canvas.py
Original file line number Diff line number Diff line change
Expand Up @@ -2168,6 +2168,24 @@ async def run():
asyncio.run(run())


def test_orthogonal_routing_defaults_false_and_the_setter_publishes_scene():
# R7.5b-2: Qt-removal plan R7.5's second canvas-visual parity fix - same
# shape again; intent name matches the legacy GridControlBridge's own
# setOrthogonalConnections Slot name 1:1.
async def run():
bus, document, recorder = make_bus()
assert document.scene_payload()["orthogonalRouting"] is False

await bus.dispatch_intent("scene", "setOrthogonalConnections", [True])
assert document.scene_payload()["orthogonalRouting"] is True
assert recorder.topics_seen()[-1] == "scene"

await bus.dispatch_intent("scene", "setOrthogonalConnections", [False])
assert document.scene_payload()["orthogonalRouting"] is False

asyncio.run(run())


# -- R4.4a: Generate/Regenerate Image - domain-level resolvers ---------------


Expand Down
3 changes: 3 additions & 0 deletions graphlink_app/graphlink_scene_payload.py
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,9 @@ class SceneStatePayload:
# R7.5b-1: Qt-removal plan R7.5's first canvas-visual parity fix -
# populated 1:1 from backend/canvas.py's SceneDocument.fade_connections_enabled.
fadeConnectionsEnabled: bool
# R7.5b-2: Qt-removal plan R7.5's second canvas-visual parity fix -
# populated 1:1 from backend/canvas.py's SceneDocument.orthogonal_routing.
orthogonalRouting: bool
dragFactor: float
fontFamily: str
fontSizePt: int
Expand Down
51 changes: 51 additions & 0 deletions web_ui/src/app/canvas/OrthogonalEdge.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { ReactFlowProvider } from "@xyflow/react";
import { render } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { OrthogonalEdge } from "./OrthogonalEdge";

// R7.5b-2: verifies the exact mid-Y step-path formula this component ports
// from legacy's GroupSummaryConnectionItem shape (see this file's own module
// doc for why the mid-Y variant, not legacy's default mid-X variant, is the
// correct translation for this app's top-to-bottom node layout).
function renderEdge(props: Partial<React.ComponentProps<typeof OrthogonalEdge>> = {}) {
const { container } = render(
<ReactFlowProvider>
<svg>
<OrthogonalEdge
{...({
id: "e1",
source: "a",
target: "b",
sourceX: 100,
sourceY: 50,
targetX: 300,
targetY: 250,
sourcePosition: "bottom",
targetPosition: "top",
...props,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any)}
/>
</svg>
</ReactFlowProvider>,
);
return container.querySelector(".react-flow__edge-path") as SVGPathElement;
}

describe("OrthogonalEdge", () => {
it("renders a 3-segment mid-Y step path: source -> midY -> midY -> target", () => {
const path = renderEdge({ sourceX: 100, sourceY: 50, targetX: 300, targetY: 250 });
// midY = 50 + (250-50)/2 = 150
expect(path.getAttribute("d")).toBe("M 100,50 L 100,150 L 300,150 L 300,250");
});

it("recomputes the midpoint correctly for a different geometry", () => {
const path = renderEdge({ sourceX: 0, sourceY: 0, targetX: 40, targetY: 400 });
expect(path.getAttribute("d")).toBe("M 0,0 L 0,200 L 40,200 L 40,400");
});

it("forwards the style prop, so it composes with faded connections' opacity override", () => {
const path = renderEdge({ style: { opacity: 0.08 } });
expect(path.style.opacity).toBe("0.08");
});
});
26 changes: 26 additions & 0 deletions web_ui/src/app/canvas/OrthogonalEdge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { BaseEdge, type EdgeProps } from "@xyflow/react";

/**
* R7.5b-2: the second canvas-visual parity fix (Qt-removal plan R7.5) - a
* right-angle "step" connection path, the first custom edge component in
* this codebase (confirmed by recon: no precedent anywhere in web_ui/src).
*
* DELIBERATE TRANSLATION, not a transcription: legacy's default
* ConnectionItem.update_path() used a horizontal mid-X step (start -> midX,
* startY -> midX,endY -> end) because Qt's primary connections ran
* left-to-right. Every node view in this app uses `Handle target=Top /
* source=Bottom` universally (branches lay out top-to-bottom, not
* left-to-right), so the geometrically correct translation is the mid-Y
* variant - the shape legacy called GroupSummaryConnectionItem, applied here
* as the general case rather than a special one. Sanity-checked against a
* real running instance before shipping (see the plan doc ledger).
*
* `style` is forwarded to BaseEdge so this composes correctly with faded
* connections (SceneCanvas.tsx's toFlowEdges) when both toggles are on at
* once - an orthogonal edge must still dim like any other.
*/
export function OrthogonalEdge({ sourceX, sourceY, targetX, targetY, style, markerEnd }: EdgeProps) {
const midY = sourceY + (targetY - sourceY) / 2;
const path = `M ${sourceX},${sourceY} L ${sourceX},${midY} L ${targetX},${midY} L ${targetX},${targetY}`;
return <BaseEdge path={path} style={style} markerEnd={markerEnd} />;
}
73 changes: 73 additions & 0 deletions web_ui/src/app/canvas/SceneCanvas.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
applyGroupDragDelta,
groupDragKindOf,
handleSelectionChange,
isOrthogonalEligible,
makeDebouncedViewportReport,
toFlowEdges,
toFlowNodes,
Expand Down Expand Up @@ -1535,3 +1536,75 @@ describe("toFlowEdges (R7.5b-1 faded connections)", () => {
expect(toFlowEdges(docked, null)).toEqual([]);
});
});

describe("isOrthogonalEligible (R7.5b-2 orthogonal routing node-kind classification)", () => {
it("is never eligible when the source is a note (legacy SystemPromptConnectionItem: always a fixed Bezier)", () => {
expect(isOrthogonalEligible("note", "chat")).toBe(false);
});

it.each(["code", "document", "image", "thinking"])(
"is never eligible when the target kind is %s (legacy: always a straight line)",
(targetKind) => {
expect(isOrthogonalEligible("chat", targetKind)).toBe(false);
},
);

it.each(["chat", "conversation", "html"])(
"is eligible when the target kind is %s and the source isn't a note (legacy: shares the ortho-gated update_path)",
(targetKind) => {
expect(isOrthogonalEligible("chat", targetKind)).toBe(true);
},
);

it.each(["web_research", "artifact", "gitlink", "pycoder", "code_sandbox", "frame", "container", "chart", "note"])(
"defaults to NOT eligible for %s targets - no legacy connection-type precedent exists for these kinds",
(targetKind) => {
expect(isOrthogonalEligible("chat", targetKind)).toBe(false);
},
);

it("is not eligible when the target kind is unknown/undefined", () => {
expect(isOrthogonalEligible("chat", undefined)).toBe(false);
});
});

describe("toFlowEdges (R7.5b-2 orthogonal routing)", () => {
it("leaves type undefined when orthogonalRouting is off, even for an eligible pair", () => {
const scene = baseScene({
nodes: [baseNode({ id: "a", kind: "chat" }), baseNode({ id: "b", kind: "chat" })],
edges: [{ id: "e1", source: "a", target: "b" }],
orthogonalRouting: false,
});
expect(toFlowEdges(scene, null)[0].type).toBeUndefined();
});

it("sets type to 'orthogonal' when the toggle is on and the pair is eligible", () => {
const scene = baseScene({
nodes: [baseNode({ id: "a", kind: "chat" }), baseNode({ id: "b", kind: "chat" })],
edges: [{ id: "e1", source: "a", target: "b" }],
orthogonalRouting: true,
});
expect(toFlowEdges(scene, null)[0].type).toBe("orthogonal");
});

it("leaves type undefined when the toggle is on but the pair is ineligible (e.g. targeting a code node)", () => {
const scene = baseScene({
nodes: [baseNode({ id: "a", kind: "chat" }), baseNode({ id: "b", kind: "code" })],
edges: [{ id: "e1", source: "a", target: "b" }],
orthogonalRouting: true,
});
expect(toFlowEdges(scene, null)[0].type).toBeUndefined();
});

it("composes with faded connections - an orthogonal edge still dims when both toggles are on", () => {
const scene = baseScene({
nodes: [baseNode({ id: "a", kind: "chat" }), baseNode({ id: "b", kind: "chat" })],
edges: [{ id: "e1", source: "a", target: "b" }],
orthogonalRouting: true,
fadeConnectionsEnabled: true,
});
const edge = toFlowEdges(scene, null)[0];
expect(edge.type).toBe("orthogonal");
expect(edge.style).toEqual({ opacity: 0.08 });
});
});
44 changes: 44 additions & 0 deletions web_ui/src/app/canvas/SceneCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import { GroupNodeView, type GroupFlowNode } from "./GroupNodeView";
import { HtmlNodeView, type HtmlFlowNode } from "./HtmlNodeView";
import { ImageNodeView, type ImageFlowNode } from "./ImageNodeView";
import { NoteNodeView, type NoteFlowNode } from "./NoteNodeView";
import { OrthogonalEdge } from "./OrthogonalEdge";
import { PyCoderNodeView, type PyCoderFlowNode } from "./PyCoderNodeView";
import { ThinkingNodeView, type ThinkingFlowNode } from "./ThinkingNodeView";
import { WebResearchNodeView, type WebResearchFlowNode } from "./WebResearchNodeView";
Expand Down Expand Up @@ -175,6 +176,14 @@ const NODE_TYPES = {
chart: ChartNodeView,
};

// R7.5b-2: the first custom edge type registered in this codebase - see
// OrthogonalEdge.tsx's own module doc. Edges not classified as "orthogonal"
// by isOrthogonalEligible below fall through to defaultEdgeOptions's stock
// bezier via an `undefined` type, same as before this feature existed.
const EDGE_TYPES = {
orthogonal: OrthogonalEdge,
};

// Exported standalone for direct unit testing (same posture as
// scaleDragPosition in sceneStore.ts) - covers the parentChatNodeId
// derivation below without needing a full <ReactFlow> mount.
Expand Down Expand Up @@ -801,19 +810,53 @@ export function applyGroupDragDelta(
// code (never set True anywhere in the Qt codebase) - deliberately not ported.
const FADED_CONNECTION_OPACITY = 0.08;

// R7.5b-2: which node-kind pairs get the orthogonal step path when the
// toggle is on - derived by mapping each legacy *ConnectionItem subclass onto
// this app's node-kind vocabulary (see OrthogonalEdge.tsx's own module doc
// for the path-shape translation). Exported standalone for direct unit
// testing, same posture as toFlowEdges/toFlowNodes.
//
// - source kind "note" -> never eligible (legacy SystemPromptConnectionItem:
// always a fixed Bezier, regardless of the toggle).
// - target kind in {code, document, image, thinking} -> never eligible
// (legacy ContentConnectionItem/DocumentConnectionItem/ImageConnectionItem/
// ThinkingConnectionItem: always straight lines).
// - target kind in {chat, conversation, html} -> eligible (legacy
// ConnectionItem/ConversationConnectionItem/HtmlConnectionItem, which all
// share the ortho-gated update_path).
// - anything else (web_research/artifact/gitlink/pycoder/code_sandbox/frame/
// container/chart/note-as-target) -> defaulted NOT eligible - these node
// kinds didn't exist as distinct connection types in the legacy app, so
// there is no research-backed mapping for them (an explicit, documented
// default, not a silent omission).
const ORTHO_INELIGIBLE_TARGET_KINDS = new Set(["code", "document", "image", "thinking"]);
const ORTHO_ELIGIBLE_TARGET_KINDS = new Set(["chat", "conversation", "html"]);

export function isOrthogonalEligible(sourceKind: string | undefined, targetKind: string | undefined): boolean {
if (sourceKind === "note") return false;
if (targetKind === undefined) return false;
if (ORTHO_INELIGIBLE_TARGET_KINDS.has(targetKind)) return false;
return ORTHO_ELIGIBLE_TARGET_KINDS.has(targetKind);
}

// Exported standalone for direct unit testing, same posture as toFlowNodes
// above - R7.5b-1's fade-connections opacity logic doesn't need a mounted
// <ReactFlow> to verify.
export function toFlowEdges(scene: SceneState, hoveredEdgeId: string | null): Edge[] {
// An edge pointing at a docked node must not render either - mirrors the
// legacy connection-item self-suppression when its end node is docked.
const dockedNodeIds = new Set(scene.nodes.filter((n) => n.isDocked).map((n) => n.id));
const kindOf = new Map(scene.nodes.map((n) => [n.id, n.kind]));
return scene.edges
.filter((e) => !dockedNodeIds.has(e.target))
.map((e) => ({
id: e.id,
source: e.source,
target: e.target,
type:
scene.orthogonalRouting && isOrthogonalEligible(kindOf.get(e.source), kindOf.get(e.target))
? "orthogonal"
: undefined,
...(scene.fadeConnectionsEnabled && e.id !== hoveredEdgeId
? { style: { opacity: FADED_CONNECTION_OPACITY } }
: {}),
Expand Down Expand Up @@ -991,6 +1034,7 @@ function CanvasInner({ store }: { store: SceneStore }) {
nodes={nodes}
edges={edges}
nodeTypes={NODE_TYPES}
edgeTypes={EDGE_TYPES}
onNodesChange={onNodesChange}
onConnect={onConnect}
onDelete={onDelete}
Expand Down
1 change: 1 addition & 0 deletions web_ui/src/app/canvas/sceneStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ function validScenePayload(overrides: Record<string, unknown> = {}) {
pins: [],
snapToGrid: true,
fadeConnectionsEnabled: false,
orthogonalRouting: false,
dragFactor: 0.5,
fontFamily: "Segoe UI",
fontSizePt: 9,
Expand Down
7 changes: 7 additions & 0 deletions web_ui/src/app/canvas/sceneStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export const initialSceneState: SceneState = {
pins: [],
snapToGrid: false,
fadeConnectionsEnabled: false,
orthogonalRouting: false,
dragFactor: 1,
fontFamily: "Segoe UI",
fontSizePt: 9,
Expand Down Expand Up @@ -534,6 +535,12 @@ export class SceneStore {
this.transport.intent("scene", "setFadeConnections", [enabled]);
}

// R7.5b-2: same shape again - intent name matches the legacy
// GridControlBridge's own setOrthogonalConnections Slot name 1:1.
setOrthogonalConnections(enabled: boolean): void {
this.transport.intent("scene", "setOrthogonalConnections", [enabled]);
}

setDragFactor(factor: number): void {
this.transport.intent("scene", "setDragFactor", [factor]);
}
Expand Down
Loading
Loading