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
13 changes: 10 additions & 3 deletions backend/canvas.py
Original file line number Diff line number Diff line change
Expand Up @@ -755,9 +755,12 @@ class SceneDocument:
# loadChat (backend/chat_library.py), left None for a fresh session, and
# reset to None by clear_for_load/new_chat below (a freshly loaded OR
# freshly cleared scene has no id of its own yet until the next load/
# save assigns one). Deliberately NOT part of scene_payload() - purely
# server-side bookkeeping the frontend never needs to read directly,
# same posture as gitlink_imported_root/code_sandbox_sandbox_id.
# save assigns one). The id ITSELF stays server-side - the frontend has
# no use for a chats.db row number, same posture as
# gitlink_imported_root/code_sandbox_sandbox_id. R7.5c does expose one
# derived bit of it as scene_payload()'s "hasSavedChat", because legacy's
# New Chat confirm skips only when the canvas is empty AND there is no
# current chat - a predicate the frontend cannot evaluate without it.
current_chat_id: int | None = None
_counter: itertools.count = field(default_factory=itertools.count, repr=False)

Expand Down Expand Up @@ -2807,6 +2810,10 @@ def scene_payload(self) -> dict[str, Any]:
"fadeConnectionsEnabled": self.fade_connections_enabled,
"orthogonalRouting": self.orthogonal_routing,
"smartGuides": self.smart_guides,
# R7.5c: the one derived bit of current_chat_id the frontend needs
# (see that field's own comment) - whether this scene corresponds
# to a saved chats.db row. Never the id itself.
"hasSavedChat": self.current_chat_id is not None,
"dragFactor": self.drag_factor,
"fontFamily": self.font_family,
"fontSizePt": self.font_size_pt,
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 @@ -2204,6 +2204,24 @@ async def run():
asyncio.run(run())


def test_has_saved_chat_tracks_current_chat_id_without_exposing_it():
# R7.5c: the frontend's New Chat confirm needs legacy's full skip
# predicate ("empty canvas AND no current chat"), so scene_payload
# publishes the boolean shadow of current_chat_id - and only that. The
# row id itself stays server-side.
document = SceneDocument()
assert document.scene_payload()["hasSavedChat"] is False

document.current_chat_id = 7
payload = document.scene_payload()
assert payload["hasSavedChat"] is True
assert "currentChatId" not in payload
assert 7 not in payload.values()

document.current_chat_id = None
assert document.scene_payload()["hasSavedChat"] is False


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


Expand Down
5 changes: 5 additions & 0 deletions graphlink_app/graphlink_scene_payload.py
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,11 @@ class SceneStatePayload:
# R7.5b-3: the third and final canvas-visual parity fix - populated 1:1
# from backend/canvas.py's SceneDocument.smart_guides.
smartGuides: bool
# R7.5c: True when this scene corresponds to a saved chats.db row -
# derived from backend/canvas.py's SceneDocument.current_chat_id, which
# itself stays server-side. Lets the frontend evaluate legacy's New Chat
# confirm-skip predicate ("empty canvas AND no current chat").
hasSavedChat: bool
dragFactor: float
fontFamily: str
fontSizePt: int
Expand Down
124 changes: 107 additions & 17 deletions web_ui/src/app/App.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import { ReactFlowProvider } from "@xyflow/react";
import { ReactFlowProvider, useReactFlow } from "@xyflow/react";
import { useEffect, useMemo, useState } from "react";
import { TOPIC_VALIDATORS } from "../lib/api-contract/topics";
import type { AppSettingsState } from "../lib/bridge-core/generated/app-settings-state";
import { isTextEditable } from "../lib/bridge-core/textFocus";
import { ConnectionStatus, WsTransport, defaultWsUrl } from "../lib/ws/transport";
import { SceneCanvas } from "./canvas/SceneCanvas";
import { SceneCanvas, measuredNodeSize } from "./canvas/SceneCanvas";
import { SceneStore } from "./canvas/sceneStore";
import { resolveTreeNavigationTarget, type TreeNavigationDirection } from "./canvas/treeNavigation";
import { requestNewChat } from "./chrome/commands";
import { isGatedWhileTyping, resolveShortcut, type ShortcutId } from "./chrome/shortcuts";
import { AboutDialog } from "./chrome/AboutDialog";
import { AppBar } from "./chrome/AppBar";
import { ChatLibraryDialog } from "./chrome/ChatLibraryDialog";
Expand Down Expand Up @@ -42,26 +46,112 @@ interface SettingsVisibilityState {
showTokenCounter?: boolean;
}

// Ctrl/Cmd+K opens the command palette, Ctrl/Cmd+F the canvas search -
// the conventional bindings the legacy islands' own keyPressEvent handlers
// used. Lives inside OverlayProvider (needs useOverlays()), so it is its
// own small component rather than inline in App().
function GlobalShortcuts() {
/**
* The global keyboard shortcuts (Qt-removal plan R7.5c) - the SPA successor
* to legacy's QShortcut block (graphlink_window.py:307-318) and its
* AcceleratorForwardingFilter typing arbitration. Key matching and the
* suppression rule are pure functions in chrome/shortcuts.ts; branch
* traversal is pure in canvas/treeNavigation.ts. This component owns only
* the dispatch, which needs the live store/overlays/React Flow instance.
*
* Lives inside BOTH OverlayProvider and ReactFlowProvider (see App's tree),
* so useOverlays() and useReactFlow() are both legal here.
*
* R2 shipped Ctrl+K/Ctrl+F here ungated; the recon of legacy's
* GATED_SHORTCUTS confirmed Ctrl+F must be suppressed while typing and
* Ctrl+K must NOT be - both now go through the same table as the rest.
*/
function GlobalShortcuts({ store }: { store: SceneStore }) {
const overlays = useOverlays();
const reactFlow = useReactFlow();

useEffect(() => {
function onKeyDown(event: KeyboardEvent) {
const mod = event.ctrlKey || event.metaKey;
if (mod && event.key.toLowerCase() === "k") {
event.preventDefault();
overlays.toggle("palette", "dialog");
} else if (mod && event.key.toLowerCase() === "f") {
event.preventDefault();
overlays.toggle("search", "popover");
function navigate(direction: TreeNavigationDirection) {
const scene = store.getScene();
// store.getSelectedNodeId() mirrors React Flow's own selection, but
// only ever tracks ONE id - so re-derive from React Flow to honor
// legacy's "exactly one item selected" precondition, which a
// multi-selection must fail.
const selected = reactFlow.getNodes().filter((n) => n.selected);
const currentId = selected.length === 1 ? selected[0].id : null;
const targetId = resolveTreeNavigationTarget(scene, currentId, direction);
if (!targetId) return; // every legacy boundary is a pure no-op

// Legacy clears the whole selection, selects the target, and centers
// the view on it (instant, zoom unchanged). Selecting through
// setNodes also flows out via onSelectionChange -> the store's
// selected-node mirror, so the composer's context anchor follows.
reactFlow.setNodes((nodes) => nodes.map((n) => ({ ...n, selected: n.id === targetId })));
const target = scene.nodes.find((n) => n.id === targetId);
if (!target) return;
// Legacy centerOn() takes the item's bounding-rect CENTER, so the node
// half-size matters. It has to come from measuredNodeSize: React Flow's
// internal `measured` is empty here far more often than not (every
// scene snapshot rebuilds the node objects it is derived from - see
// that helper's own comment), and a naive `?? 0` would silently degrade
// to centering on the top-left corner, throwing the target half off
// screen at high zoom. Only a node absent from the DOM lands on the
// zero fallback, which is the legacy no-size behavior anyway.
const size = measuredNodeSize(reactFlow, targetId) ?? { width: 0, height: 0 };
reactFlow.setCenter(target.x + size.width / 2, target.y + size.height / 2, {
zoom: reactFlow.getZoom(),
});
}

function dispatch(id: ShortcutId) {
switch (id) {
case "new-chat":
return requestNewChat(store);
case "toggle-library":
// Legacy's Ctrl+L is overlay_manager.toggle("library"), not open.
return overlays.toggle("library", "dialog");
case "save-chat":
return store.saveChat();
case "create-frame":
// Legacy createFrame/createContainer need only ONE eligible node
// (graphlink_scene.py:689-730) and silently no-op on zero - the
// shortcuts match that exactly. The palette's own Create Frame
// entry keeps its stricter 2+ gate; see the ledger note.
return applyGrouping("frame");
case "create-container":
return applyGrouping("container");
case "toggle-palette":
return overlays.toggle("palette", "dialog");
case "toggle-search":
return overlays.toggle("search", "popover");
case "navigate-up":
return navigate("up");
case "navigate-down":
return navigate("down");
case "navigate-left":
return navigate("left");
case "navigate-right":
return navigate("right");
}
}

function applyGrouping(kind: "frame" | "container") {
const ids = reactFlow.getNodes().filter((n) => n.selected).map((n) => n.id);
if (ids.length === 0) return; // legacy: bare return, no message
if (kind === "frame") store.createFrame(ids);
else store.createContainer(ids);
}

function onKeyDown(event: KeyboardEvent) {
const id = resolveShortcut(event);
if (!id) return;
// The AcceleratorForwardingFilter port: while a text input has focus,
// the gated shortcuts are left entirely alone so the keystroke reaches
// the field, and only the exempt ones (Save, palette) still fire.
if (isGatedWhileTyping(id) && isTextEditable(document.activeElement)) return;
event.preventDefault();
dispatch(id);
}

document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [overlays]);
}, [overlays, reactFlow, store]);

return null;
}

Expand Down Expand Up @@ -109,7 +199,7 @@ function App() {
return (
<OverlayProvider>
<ReactFlowProvider>
<GlobalShortcuts />
<GlobalShortcuts store={sceneStore} />
<div className="app-shell">
<header className="app-topbar">
<span className="app-title">Graphlink</span>
Expand Down
47 changes: 47 additions & 0 deletions web_ui/src/app/canvas/SceneCanvas.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
makeDebouncedViewportReport,
toFlowEdges,
toFlowNodes,
withPreservedSelection,
type SceneFlowNode,
} from "./SceneCanvas";
import { SceneStore, initialSceneState } from "./sceneStore";
Expand Down Expand Up @@ -1608,3 +1609,49 @@ describe("toFlowEdges (R7.5b-2 orthogonal routing)", () => {
expect(edge.style).toEqual({ opacity: 0.08 });
});
});

// R7.5c: found live, not by a test - Ctrl+Arrow's setCenter round-trips a
// viewport report through the backend, the echoed snapshot rebuilt every
// node, and the selection the keystroke had just made disappeared. Without
// this, branch navigation worked for exactly one hop.
describe("withPreservedSelection (R7.5c snapshot-rebuild selection wipe)", () => {
const node = (id: string, selected?: boolean) =>
({ id, selected, position: { x: 0, y: 0 }, data: {} }) as unknown as SceneFlowNode;

it("re-applies the selection onto the freshly rebuilt nodes", () => {
const rebuilt = [node("a"), node("b"), node("c")];
const current = [node("a"), node("b", true), node("c")];
const merged = withPreservedSelection(rebuilt, current);
expect(merged.map((n) => [n.id, !!n.selected])).toEqual([
["a", false],
["b", true],
["c", false],
]);
});

it("preserves a multi-node selection, not just a single id", () => {
const merged = withPreservedSelection(
[node("a"), node("b"), node("c")],
[node("a", true), node("b"), node("c", true)],
);
expect(merged.filter((n) => n.selected).map((n) => n.id)).toEqual(["a", "c"]);
});

it("returns the rebuilt array untouched when nothing was selected", () => {
const rebuilt = [node("a"), node("b")];
expect(withPreservedSelection(rebuilt, [node("a"), node("b")])).toBe(rebuilt);
});

it("cannot resurrect a node the backend deleted - it is simply absent from the rebuild", () => {
const merged = withPreservedSelection([node("a")], [node("a"), node("gone", true)]);
expect(merged.map((n) => n.id)).toEqual(["a"]);
expect(merged.some((n) => n.selected)).toBe(false);
});

it("does not mutate the node objects it was handed", () => {
const current = [node("a", true)];
const rebuilt = [node("a")];
withPreservedSelection(rebuilt, current);
expect(rebuilt[0].selected).toBeUndefined();
});
});
37 changes: 34 additions & 3 deletions web_ui/src/app/canvas/SceneCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -844,8 +844,10 @@ export function isOrthogonalEligible(sourceKind: string | undefined, targetKind:
// R7.5b-3: one node's current rendered size in flow units, for smart-guide
// rects - see the comment on CanvasInner's reactFlow hoist for why the
// internal store alone is not enough and the DOM is the reliable fallback.
type MeasuredSizeSource = { getInternalNode: (id: string) => { measured?: { width?: number; height?: number } } | undefined };
function measuredNodeSize(reactFlow: MeasuredSizeSource, id: string): { width: number; height: number } | null {
// Exported since R7.5c: App.tsx's Ctrl+Arrow navigation has to center on a
// node's MIDDLE, and hit the exact same empty-`measured` trap this solves.
export type MeasuredSizeSource = { getInternalNode: (id: string) => { measured?: { width?: number; height?: number } } | undefined };
export function measuredNodeSize(reactFlow: MeasuredSizeSource, id: string): { width: number; height: number } | null {
const measured = reactFlow.getInternalNode(id)?.measured;
if (measured?.width !== undefined && measured?.height !== undefined) {
return { width: measured.width, height: measured.height };
Expand All @@ -855,6 +857,34 @@ function measuredNodeSize(reactFlow: MeasuredSizeSource, id: string): { width: n
return { width: el.offsetWidth, height: el.offsetHeight };
}

/**
* R7.5c: carry the current selection across a snapshot rebuild.
*
* toFlowNodes mints brand-new node objects from every scene snapshot, so
* anything React Flow keeps on the node object rather than in the scene -
* selection above all - is dropped unless copied over explicitly.
*
* Found live, not by a test: Ctrl+Arrow calls setCenter, setCenter fires
* onMove, onMove reports the viewport to the backend, the backend echoes a
* fresh scene, and the selection the keystroke had just made vanished about
* 300ms later. That left the NEXT Ctrl+Arrow with no single selected node,
* so branch-walking died after exactly one hop. The same wipe hits any
* selection that merely overlaps a snapshot - an autosave tick, a streaming
* token - which is why it is fixed here at the rebuild rather than inside
* the shortcut handler.
*
* A node the backend actually removed is simply absent from `rebuilt`, so a
* stale id can never resurrect one.
*/
export function withPreservedSelection(
rebuilt: SceneFlowNode[],
current: SceneFlowNode[],
): SceneFlowNode[] {
const selectedIds = new Set(current.filter((n) => n.selected).map((n) => n.id));
if (selectedIds.size === 0) return rebuilt;
return rebuilt.map((n) => (selectedIds.has(n.id) ? { ...n, selected: true } : n));
}

// 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.
Expand Down Expand Up @@ -960,7 +990,8 @@ function CanvasInner({ store }: { store: SceneStore }) {
);

useEffect(() => {
if (!draggingRef.current) setNodes(toFlowNodes(scene, store));
if (draggingRef.current) return;
setNodes((current) => withPreservedSelection(toFlowNodes(scene, store), current));
}, [scene, store]);

const edges = useMemo(() => toFlowEdges(scene, hoveredEdgeId), [scene, hoveredEdgeId]);
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 @@ -99,6 +99,7 @@ function validScenePayload(overrides: Record<string, unknown> = {}) {
fadeConnectionsEnabled: false,
orthogonalRouting: false,
smartGuides: false,
hasSavedChat: false,
dragFactor: 0.5,
fontFamily: "Segoe UI",
fontSizePt: 9,
Expand Down
1 change: 1 addition & 0 deletions web_ui/src/app/canvas/sceneStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export const initialSceneState: SceneState = {
fadeConnectionsEnabled: false,
orthogonalRouting: false,
smartGuides: false,
hasSavedChat: false,
dragFactor: 1,
fontFamily: "Segoe UI",
fontSizePt: 9,
Expand Down
Loading
Loading