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
11 changes: 11 additions & 0 deletions backend/canvas.py
Original file line number Diff line number Diff line change
Expand Up @@ -693,6 +693,11 @@ class SceneDocument:
pins: NavigationPinStore = field(default_factory=NavigationPinStore)
grid: GridViewSettings = field(default_factory=GridViewSettings)
snap_to_grid: bool = False
# R7.5b-1: Qt-removal plan R7.5's first canvas-visual parity fix - the
# legacy scene's fade_connections_enabled bool (graphlink_scene.py),
# 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
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 @@ -2789,6 +2794,7 @@ def scene_payload(self) -> dict[str, Any]:
for p in self.pins.records
],
"snapToGrid": self.snap_to_grid,
"fadeConnectionsEnabled": self.fade_connections_enabled,
"dragFactor": self.drag_factor,
"fontFamily": self.font_family,
"fontSizePt": self.font_size_pt,
Expand Down Expand Up @@ -3982,6 +3988,10 @@ async def set_snap_to_grid(enabled):
document.snap_to_grid = bool(enabled)
await publish_scene()

async def set_fade_connections(enabled):
document.fade_connections_enabled = bool(enabled)
await publish_scene()

async def set_drag_factor(factor):
document.set_drag_factor(factor)
await publish_scene()
Expand Down Expand Up @@ -4042,6 +4052,7 @@ async def set_view_state(zoom_factor, scroll_x, scroll_y):
bus.register_intent("scene", "removePin", remove_pin)
bus.register_intent("scene", "updatePin", update_pin)
bus.register_intent("scene", "setSnapToGrid", set_snap_to_grid)
bus.register_intent("scene", "setFadeConnections", set_fade_connections)
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
17 changes: 17 additions & 0 deletions backend/tests/test_canvas.py
Original file line number Diff line number Diff line change
Expand Up @@ -2151,6 +2151,23 @@ async def run():
asyncio.run(run())


def test_fade_connections_enabled_defaults_false_and_the_setter_publishes_scene():
# R7.5b-1: Qt-removal plan R7.5's first canvas-visual parity fix - same
# bare-bool/"scene"-topic shape as setSnapToGrid above.
async def run():
bus, document, recorder = make_bus()
assert document.scene_payload()["fadeConnectionsEnabled"] is False

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

await bus.dispatch_intent("scene", "setFadeConnections", [False])
assert document.scene_payload()["fadeConnectionsEnabled"] 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 @@ -321,6 +321,9 @@ class SceneStatePayload:
edges: list[SceneEdgeRow]
pins: list[ScenePinRow]
snapToGrid: bool
# 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
dragFactor: float
fontFamily: str
fontSizePt: int
Expand Down
52 changes: 52 additions & 0 deletions web_ui/src/app/canvas/SceneCanvas.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
groupDragKindOf,
handleSelectionChange,
makeDebouncedViewportReport,
toFlowEdges,
toFlowNodes,
type SceneFlowNode,
} from "./SceneCanvas";
Expand Down Expand Up @@ -1483,3 +1484,54 @@ describe("applyGroupDragDelta (R6.1 group-drag)", () => {
expect(applyGroupDragDelta(nodes, "frame-1", { x: 10, y: 10 })).toEqual([]);
});
});

describe("toFlowEdges (R7.5b-1 faded connections)", () => {
const scene = baseScene({
nodes: [baseNode({ id: "a" }), baseNode({ id: "b" }), baseNode({ id: "c" })],
edges: [
{ id: "e1", source: "a", target: "b" },
{ id: "e2", source: "a", target: "c" },
],
});

it("applies no opacity style to any edge when fadeConnectionsEnabled is false, regardless of hover", () => {
const off = baseScene({ ...scene, fadeConnectionsEnabled: false });
expect(toFlowEdges(off, null)).toEqual([
{ id: "e1", source: "a", target: "b" },
{ id: "e2", source: "a", target: "c" },
]);
expect(toFlowEdges(off, "e1")).toEqual([
{ id: "e1", source: "a", target: "b" },
{ id: "e2", source: "a", target: "c" },
]);
});

it("fades every edge except the hovered one when fadeConnectionsEnabled is true", () => {
const on = baseScene({ ...scene, fadeConnectionsEnabled: true });
const edges = toFlowEdges(on, "e1");
expect(edges.find((e) => e.id === "e1")).toEqual({ id: "e1", source: "a", target: "b" });
expect(edges.find((e) => e.id === "e2")).toEqual({
id: "e2",
source: "a",
target: "c",
style: { opacity: 0.08 },
});
});

it("fades every edge when nothing is hovered", () => {
const on = baseScene({ ...scene, fadeConnectionsEnabled: true });
const edges = toFlowEdges(on, null);
for (const edge of edges) {
expect(edge.style).toEqual({ opacity: 0.08 });
}
});

it("still suppresses an edge pointing at a docked node, independent of fade state", () => {
const docked = baseScene({
nodes: [baseNode({ id: "a" }), baseNode({ id: "b", isDocked: true })],
edges: [{ id: "e1", source: "a", target: "b" }],
fadeConnectionsEnabled: true,
});
expect(toFlowEdges(docked, null)).toEqual([]);
});
});
31 changes: 28 additions & 3 deletions web_ui/src/app/canvas/SceneCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -794,13 +794,30 @@ export function applyGroupDragDelta(
return memberChanges;
}

function toFlowEdges(scene: SceneState): Edge[] {
// R7.5b-1: legacy's exact faded-connections opacity (graphlink_connections.py's
// _sync_connection_visibility_mode) - every connection sits at this opacity
// except the one under the mouse, once the feature is toggled on. Legacy also
// had a `is_selected` stay-bright branch, but the recon confirmed it's dead
// code (never set True anywhere in the Qt codebase) - deliberately not ported.
const FADED_CONNECTION_OPACITY = 0.08;

// 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));
return scene.edges
.filter((e) => !dockedNodeIds.has(e.target))
.map((e) => ({ id: e.id, source: e.source, target: e.target }));
.map((e) => ({
id: e.id,
source: e.source,
target: e.target,
...(scene.fadeConnectionsEnabled && e.id !== hoveredEdgeId
? { style: { opacity: FADED_CONNECTION_OPACITY } }
: {}),
}));
}

// R6.3: the debounce wrapper for viewport (pan/zoom) reporting - same posture
Expand Down Expand Up @@ -835,6 +852,12 @@ function CanvasInner({ store }: { store: SceneStore }) {
const dragStartRef = useRef<Map<string, { x: number; y: number }>>(new Map());
const draggingRef = useRef(false);

// R7.5b-1: which edge (if any) is under the mouse right now - the one
// exemption from faded-connections' blanket low-opacity effect. Local-only
// (never scene state), same posture as legacy's purely presentational hover
// flag.
const [hoveredEdgeId, setHoveredEdgeId] = useState<string | null>(null);

// R6.3: viewport (pan/zoom) reporting - see makeDebouncedViewportReport's
// own doc above. onMove fires on every frame of a pan/zoom gesture (never
// just once at the end, unlike NodeResizer's onResizeEnd), so the debounce
Expand All @@ -860,7 +883,7 @@ function CanvasInner({ store }: { store: SceneStore }) {
if (!draggingRef.current) setNodes(toFlowNodes(scene, store));
}, [scene, store]);

const edges = useMemo(() => toFlowEdges(scene), [scene]);
const edges = useMemo(() => toFlowEdges(scene, hoveredEdgeId), [scene, hoveredEdgeId]);

const onNodesChange = useCallback(
(changes: NodeChange<SceneFlowNode>[]) => {
Expand Down Expand Up @@ -976,6 +999,8 @@ function CanvasInner({ store }: { store: SceneStore }) {
// PluginPicker can attach "which node was selected" to executePlugin
// without either component reaching into the other's internals.
onSelectionChange={({ nodes: sel }) => handleSelectionChange(store, sel)}
onEdgeMouseEnter={(_event, edge) => setHoveredEdgeId(edge.id)}
onEdgeMouseLeave={() => setHoveredEdgeId(null)}
snapToGrid={scene.snapToGrid}
snapGrid={[grid.gridSize, grid.gridSize]}
// Double-click is the R1 create-node gesture (wrapper onDoubleClick);
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 @@ -96,6 +96,7 @@ function validScenePayload(overrides: Record<string, unknown> = {}) {
edges: [],
pins: [],
snapToGrid: true,
fadeConnectionsEnabled: false,
dragFactor: 0.5,
fontFamily: "Segoe UI",
fontSizePt: 9,
Expand Down
6 changes: 6 additions & 0 deletions web_ui/src/app/canvas/sceneStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export const initialSceneState: SceneState = {
edges: [],
pins: [],
snapToGrid: false,
fadeConnectionsEnabled: false,
dragFactor: 1,
fontFamily: "Segoe UI",
fontSizePt: 9,
Expand Down Expand Up @@ -528,6 +529,11 @@ export class SceneStore {
this.transport.intent("scene", "setSnapToGrid", [enabled]);
}

// R7.5b-1: same bare-bool/"scene"-topic shape as setSnapToGrid above.
setFadeConnections(enabled: boolean): void {
this.transport.intent("scene", "setFadeConnections", [enabled]);
}

setDragFactor(factor: number): void {
this.transport.intent("scene", "setDragFactor", [factor]);
}
Expand Down
88 changes: 88 additions & 0 deletions web_ui/src/app/chrome/ViewPopover.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { ViewPopover } from "./ViewPopover";
import {
initialDragSpeedState,
initialFontControlState,
initialGridState,
initialSceneState,
} from "../canvas/sceneStore";
import { OverlayProvider, useOverlays } from "../overlays/overlays";

// ViewPopover renders via <Popover name="view">, which only mounts content
// while open - force it open through the real overlay context (same pattern
// PinOverlay.test.tsx already established), not a bypass.
function OpenViewOnMount({ children }: { children: React.ReactNode }) {
const overlays = useOverlays();
if (!overlays.isOpen("view")) overlays.open("view", "popover");
return <>{children}</>;
}

function makeStore(sceneOverrides: Partial<typeof initialSceneState> = {}) {
const listeners = new Set<() => void>();
const scene = { ...initialSceneState, ...sceneOverrides };
const setFadeConnections = vi.fn();
const setSnapToGrid = vi.fn();
const store = {
subscribe: (l: () => void) => {
listeners.add(l);
return () => listeners.delete(l);
},
getScene: () => scene,
getGrid: () => initialGridState,
getDragConfig: () => initialDragSpeedState,
getFontConfig: () => initialFontControlState,
setDragFactor: vi.fn(),
setGridSize: vi.fn(),
setGridOpacityPercent: vi.fn(),
setGridStyle: vi.fn(),
setGridColor: vi.fn(),
setSnapToGrid,
setFadeConnections,
setFontFamily: vi.fn(),
setFontSize: vi.fn(),
setFontColor: vi.fn(),
};
return { store, setFadeConnections, setSnapToGrid };
}

function renderOpen(store: unknown) {
return render(
<OverlayProvider>
<OpenViewOnMount>
{/* @ts-expect-error - test double */}
<ViewPopover store={store} />
</OpenViewOnMount>
</OverlayProvider>,
);
}

// R7.5b-1: only the new Fade Connections checkbox is covered here -
// ViewPopover.tsx otherwise has no prior test coverage (a pre-existing gap,
// not introduced by this increment) and backfilling the rest is out of scope
// for a canvas-visuals increment.
describe("ViewPopover (R7.5b-1 Fade Connections checkbox)", () => {
it("reflects fadeConnectionsEnabled=false as unchecked", () => {
const { store } = makeStore({ fadeConnectionsEnabled: false });
renderOpen(store);
expect(screen.getByRole("checkbox", { name: "Fade Connections" })).not.toBeChecked();
});

it("reflects fadeConnectionsEnabled=true as checked", () => {
const { store } = makeStore({ fadeConnectionsEnabled: true });
renderOpen(store);
expect(screen.getByRole("checkbox", { name: "Fade Connections" })).toBeChecked();
});

it("calls store.setFadeConnections with the new value on toggle, independent of Snap to Grid", async () => {
const user = userEvent.setup();
const { store, setFadeConnections, setSnapToGrid } = makeStore({ fadeConnectionsEnabled: false });
renderOpen(store);

await user.click(screen.getByRole("checkbox", { name: "Fade Connections" }));

expect(setFadeConnections).toHaveBeenCalledWith(true);
expect(setSnapToGrid).not.toHaveBeenCalled();
});
});
9 changes: 9 additions & 0 deletions web_ui/src/app/chrome/ViewPopover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,15 @@ export function ViewPopover({ store }: { store: SceneStore }) {
/>
Snap to Grid
</label>
{/* R7.5b-1: same view-check-row pattern as Snap to Grid above. */}
<label className="view-check-row">
<input
type="checkbox"
checked={scene.fadeConnectionsEnabled}
onChange={(e) => store.setFadeConnections(e.target.checked)}
/>
Fade Connections
</label>
</section>

<section className="view-section" aria-label="Font">
Expand Down
2 changes: 1 addition & 1 deletion web_ui/src/app/chrome/help-data/sections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -279,7 +279,7 @@ export const HELP_SECTIONS: HelpSection[] = [
},
{
"action": "Grid and Guide Controls",
"description": "The controls overlay can enable snap-to-grid and font controls. Smart guides, orthogonal routing, and faded connections 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, 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."
},
{
"action": "Reduce Visual Noise",
Expand Down
4 changes: 4 additions & 0 deletions web_ui/src/lib/bridge-core/generated/scene-state.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@
},
"type": "array"
},
"fadeConnectionsEnabled": {
"type": "boolean"
},
"fontColor": {
"type": "string"
},
Expand Down Expand Up @@ -508,6 +511,7 @@
"edges",
"pins",
"snapToGrid",
"fadeConnectionsEnabled",
"dragFactor",
"fontFamily",
"fontSizePt",
Expand Down
6 changes: 6 additions & 0 deletions web_ui/src/lib/bridge-core/generated/scene-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ export interface SceneState {
edges: SceneEdgeRow[];
pins: ScenePinRow[];
snapToGrid: boolean;
fadeConnectionsEnabled: boolean;
dragFactor: number;
fontFamily: string;
fontSizePt: number;
Expand Down Expand Up @@ -712,6 +713,11 @@ function checkSceneState(value: unknown, path: string, errors: string[]): void {
if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.snapToGrid: missing required field`);
else { if (typeof fieldValue !== "boolean") errors.push(`${path}.snapToGrid` + ": expected boolean"); }
}
{
const fieldValue = value["fadeConnectionsEnabled"];
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["dragFactor"];
if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.dragFactor: missing required field`);
Expand Down
Loading