diff --git a/backend/canvas.py b/backend/canvas.py index 9fe019b..9b7b52e 100644 --- a/backend/canvas.py +++ b/backend/canvas.py @@ -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. @@ -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, @@ -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() @@ -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 diff --git a/backend/tests/test_canvas.py b/backend/tests/test_canvas.py index 253faa3..4e6233c 100644 --- a/backend/tests/test_canvas.py +++ b/backend/tests/test_canvas.py @@ -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 --------------- diff --git a/graphlink_app/graphlink_scene_payload.py b/graphlink_app/graphlink_scene_payload.py index bebe6a7..102718f 100644 --- a/graphlink_app/graphlink_scene_payload.py +++ b/graphlink_app/graphlink_scene_payload.py @@ -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 diff --git a/web_ui/src/app/canvas/OrthogonalEdge.test.tsx b/web_ui/src/app/canvas/OrthogonalEdge.test.tsx new file mode 100644 index 0000000..603112a --- /dev/null +++ b/web_ui/src/app/canvas/OrthogonalEdge.test.tsx @@ -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> = {}) { + const { container } = render( + + + + + , + ); + 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"); + }); +}); diff --git a/web_ui/src/app/canvas/OrthogonalEdge.tsx b/web_ui/src/app/canvas/OrthogonalEdge.tsx new file mode 100644 index 0000000..f431e6c --- /dev/null +++ b/web_ui/src/app/canvas/OrthogonalEdge.tsx @@ -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 ; +} diff --git a/web_ui/src/app/canvas/SceneCanvas.test.tsx b/web_ui/src/app/canvas/SceneCanvas.test.tsx index b258211..a5ac305 100644 --- a/web_ui/src/app/canvas/SceneCanvas.test.tsx +++ b/web_ui/src/app/canvas/SceneCanvas.test.tsx @@ -3,6 +3,7 @@ import { applyGroupDragDelta, groupDragKindOf, handleSelectionChange, + isOrthogonalEligible, makeDebouncedViewportReport, toFlowEdges, toFlowNodes, @@ -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 }); + }); +}); diff --git a/web_ui/src/app/canvas/SceneCanvas.tsx b/web_ui/src/app/canvas/SceneCanvas.tsx index c5984a7..0cf29bb 100644 --- a/web_ui/src/app/canvas/SceneCanvas.tsx +++ b/web_ui/src/app/canvas/SceneCanvas.tsx @@ -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"; @@ -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 mount. @@ -801,6 +810,35 @@ 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 // to verify. @@ -808,12 +846,17 @@ export function toFlowEdges(scene: SceneState, hoveredEdgeId: string | null): Ed // 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 } } : {}), @@ -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} diff --git a/web_ui/src/app/canvas/sceneStore.test.ts b/web_ui/src/app/canvas/sceneStore.test.ts index c109e39..afa544a 100644 --- a/web_ui/src/app/canvas/sceneStore.test.ts +++ b/web_ui/src/app/canvas/sceneStore.test.ts @@ -97,6 +97,7 @@ function validScenePayload(overrides: Record = {}) { pins: [], snapToGrid: true, fadeConnectionsEnabled: false, + orthogonalRouting: false, dragFactor: 0.5, fontFamily: "Segoe UI", fontSizePt: 9, diff --git a/web_ui/src/app/canvas/sceneStore.ts b/web_ui/src/app/canvas/sceneStore.ts index a6a8eec..b879246 100644 --- a/web_ui/src/app/canvas/sceneStore.ts +++ b/web_ui/src/app/canvas/sceneStore.ts @@ -24,6 +24,7 @@ export const initialSceneState: SceneState = { pins: [], snapToGrid: false, fadeConnectionsEnabled: false, + orthogonalRouting: false, dragFactor: 1, fontFamily: "Segoe UI", fontSizePt: 9, @@ -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]); } diff --git a/web_ui/src/app/chrome/ViewPopover.test.tsx b/web_ui/src/app/chrome/ViewPopover.test.tsx index d0b040f..94b6a0c 100644 --- a/web_ui/src/app/chrome/ViewPopover.test.tsx +++ b/web_ui/src/app/chrome/ViewPopover.test.tsx @@ -24,6 +24,7 @@ function makeStore(sceneOverrides: Partial = {}) { const scene = { ...initialSceneState, ...sceneOverrides }; const setFadeConnections = vi.fn(); const setSnapToGrid = vi.fn(); + const setOrthogonalConnections = vi.fn(); const store = { subscribe: (l: () => void) => { listeners.add(l); @@ -40,11 +41,12 @@ function makeStore(sceneOverrides: Partial = {}) { setGridColor: vi.fn(), setSnapToGrid, setFadeConnections, + setOrthogonalConnections, setFontFamily: vi.fn(), setFontSize: vi.fn(), setFontColor: vi.fn(), }; - return { store, setFadeConnections, setSnapToGrid }; + return { store, setFadeConnections, setSnapToGrid, setOrthogonalConnections }; } function renderOpen(store: unknown) { @@ -86,3 +88,30 @@ describe("ViewPopover (R7.5b-1 Fade Connections checkbox)", () => { expect(setSnapToGrid).not.toHaveBeenCalled(); }); }); + +// R7.5b-2: same posture as the Fade Connections describe block above - only +// the new control is covered. +describe("ViewPopover (R7.5b-2 Orthogonal Routing checkbox)", () => { + it("reflects orthogonalRouting=false as unchecked", () => { + const { store } = makeStore({ orthogonalRouting: false }); + renderOpen(store); + expect(screen.getByRole("checkbox", { name: "Orthogonal Routing" })).not.toBeChecked(); + }); + + it("reflects orthogonalRouting=true as checked", () => { + const { store } = makeStore({ orthogonalRouting: true }); + renderOpen(store); + expect(screen.getByRole("checkbox", { name: "Orthogonal Routing" })).toBeChecked(); + }); + + it("calls store.setOrthogonalConnections with the new value on toggle, independent of Fade Connections", async () => { + const user = userEvent.setup(); + const { store, setOrthogonalConnections, setFadeConnections } = makeStore({ orthogonalRouting: false }); + renderOpen(store); + + await user.click(screen.getByRole("checkbox", { name: "Orthogonal Routing" })); + + expect(setOrthogonalConnections).toHaveBeenCalledWith(true); + expect(setFadeConnections).not.toHaveBeenCalled(); + }); +}); diff --git a/web_ui/src/app/chrome/ViewPopover.tsx b/web_ui/src/app/chrome/ViewPopover.tsx index 263cd6c..dbe16fb 100644 --- a/web_ui/src/app/chrome/ViewPopover.tsx +++ b/web_ui/src/app/chrome/ViewPopover.tsx @@ -108,6 +108,15 @@ export function ViewPopover({ store }: { store: SceneStore }) { /> Fade Connections + {/* R7.5b-2: same view-check-row pattern again. */} +
diff --git a/web_ui/src/app/chrome/help-data/sections.ts b/web_ui/src/app/chrome/help-data/sections.ts index e053fe8..3d23dfa 100644 --- a/web_ui/src/app/chrome/help-data/sections.ts +++ b/web_ui/src/app/chrome/help-data/sections.ts @@ -279,7 +279,7 @@ export const HELP_SECTIONS: HelpSection[] = [ }, { "action": "Grid and Guide Controls", - "description": "The controls overlay can enable snap-to-grid, font controls, and faded connections. Smart guides and orthogonal routing are not available yet in this build. These tools are useful when a canvas needs visual cleanup rather than new AI output." + "description": "The controls overlay can enable snap-to-grid, font controls, faded connections, and orthogonal connection routing. Smart guides are not available yet in this build. These tools are useful when a canvas needs visual cleanup rather than new AI output." }, { "action": "Reduce Visual Noise", diff --git a/web_ui/src/lib/bridge-core/generated/scene-state.schema.json b/web_ui/src/lib/bridge-core/generated/scene-state.schema.json index 0093d0f..9884cac 100644 --- a/web_ui/src/lib/bridge-core/generated/scene-state.schema.json +++ b/web_ui/src/lib/bridge-core/generated/scene-state.schema.json @@ -463,6 +463,9 @@ }, "type": "array" }, + "orthogonalRouting": { + "type": "boolean" + }, "pins": { "items": { "additionalProperties": false, @@ -512,6 +515,7 @@ "pins", "snapToGrid", "fadeConnectionsEnabled", + "orthogonalRouting", "dragFactor", "fontFamily", "fontSizePt", diff --git a/web_ui/src/lib/bridge-core/generated/scene-state.ts b/web_ui/src/lib/bridge-core/generated/scene-state.ts index ec51c03..479e761 100644 --- a/web_ui/src/lib/bridge-core/generated/scene-state.ts +++ b/web_ui/src/lib/bridge-core/generated/scene-state.ts @@ -132,6 +132,7 @@ export interface SceneState { pins: ScenePinRow[]; snapToGrid: boolean; fadeConnectionsEnabled: boolean; + orthogonalRouting: boolean; dragFactor: number; fontFamily: string; fontSizePt: number; @@ -718,6 +719,11 @@ function checkSceneState(value: unknown, path: string, errors: string[]): void { if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.fadeConnectionsEnabled: missing required field`); else { if (typeof fieldValue !== "boolean") errors.push(`${path}.fadeConnectionsEnabled` + ": expected boolean"); } } + { + const fieldValue = value["orthogonalRouting"]; + if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.orthogonalRouting: missing required field`); + else { if (typeof fieldValue !== "boolean") errors.push(`${path}.orthogonalRouting` + ": expected boolean"); } + } { const fieldValue = value["dragFactor"]; if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.dragFactor: missing required field`);