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
5 changes: 4 additions & 1 deletion backend/api/intents_grid.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,10 @@ def register_grid_intents(bus: SessionBus, document: SceneDocument) -> None:
publish_grid = make_publish_grid(bus)

async def set_grid_size(size):
document.grid.grid_size = int(size)
# Clamped: 0/negative would blank or invert the background pattern,
# and the View popover's spacing slider (4-120) relies on the same
# floor being enforced where the value actually lands.
document.grid.grid_size = max(4, min(400, int(size)))
await publish_grid()

async def set_grid_opacity_percent(percent):
Expand Down
25 changes: 19 additions & 6 deletions backend/domain/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,17 @@

from backend.domain.node_states import NodeState

# Dark-theme grid swatches. The Qt bridge derived 3 of 5 from the live
# QPalette; the backend is Qt-free by law (test_no_qt_anywhere.py), so until
# the R2 theme service exists these are the dark theme's actual values,
# frozen here as data, not styling.
GRID_COLOR_PRESETS = ["#404040", "#555555", "#4a90d9", "#2f5b3c", "#5b2f4f"]
# Grid swatches. The first three neutrals cover the subtle-texture case at
# increasing prominence; the five hues are tuned to read on the dark canvas
# without shouting, and to survive the light theme. The old set was frozen
# verbatim from the deleted Qt bridge's live-palette derivation (2 neutrals
# + 3 dark muddy hues that were nearly invisible against the canvas) - kept
# values #404040/#555555/#4a90d9 remain so an existing session's saved
# color still matches a swatch.
GRID_COLOR_PRESETS = [
"#404040", "#555555", "#6E6E6E",
"#4a90d9", "#3FA37E", "#C9A227", "#C96A6A", "#8A63C9",
]

DRAG_FACTOR_MIN = 0.05
DRAG_FACTOR_MAX = 1.0
Expand All @@ -35,7 +41,14 @@
"Courier New", "Times New Roman", "Georgia", "System UI",
"DejaVu Sans", "Segoe UI Variable", "Arial Rounded MT Bold",
]
FONT_COLOR_PRESETS = ["#F0F0F0", "#C7C7C7", "#949494", "#818181"]
# Node-text swatches: three neutral steps plus four soft tints that stay
# readable on the node-card surfaces in both themes. The old set was four
# barely-distinguishable grays (two of them 19 units apart) carried over
# verbatim from the deleted Qt bridge.
FONT_COLOR_PRESETS = [
"#F0F0F0", "#C7C7C7", "#949494",
"#9EC1E8", "#9FD0B5", "#E3C577", "#E0A3A3",
]
FONT_SIZE_MIN = 8
FONT_SIZE_MAX = 16

Expand Down
7 changes: 6 additions & 1 deletion backend/tests/test_canvas.py
Original file line number Diff line number Diff line change
Expand Up @@ -2080,7 +2080,12 @@ def test_grid_payload_matches_generated_validator_shape():
"colorPresets",
}
assert isinstance(payload["gridOpacityPercent"], int)
assert len(payload["colorPresets"]) == 5
# Pinned to "a non-empty list of hex strings" rather than an exact
# count: the palette is a curated data set that legitimately grows
# (it did, when the Qt-frozen 5-swatch set was upgraded), while the
# SHAPE - what the generated validator checks - is the contract.
assert payload["colorPresets"]
assert all(c.startswith("#") and len(c) == 7 for c in payload["colorPresets"])


# -- intent surface over the bus --------------------------------------------
Expand Down
2 changes: 1 addition & 1 deletion web_ui/src/app/canvas/ChartNodeView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@
* cached. `session=default` mirrors this app's single-session-per-window
* assumption everywhere else (see lib/ws/transport.ts's own defaultWsUrl
* default parameter). */
export function chartExportUrl(nodeId: string, format: "png" | "svg" = "png"): string {

Check warning on line 97 in web_ui/src/app/canvas/ChartNodeView.tsx

View workflow job for this annotation

GitHub Actions / Frontend checks (npm run check)

Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components
// ADR-004 stage 4.1: this one is a plain <a href> the user clicks, so it
// has no way to carry a header either - same query-param treatment.
return withAuthToken(`/api/assets/chart/${nodeId}/export?session=default&fmt=${format}`);
Expand All @@ -106,13 +106,13 @@

/** The debounce wrapper described in this file's own module doc, exported
* standalone for direct unit testing without mounting <NodeResizer/> at all -
* same posture as SceneCanvas.tsx's scaleDragPosition/applyGroupDragDelta.
* same posture as SceneCanvas.tsx's applyGroupDragDelta.
* `timerRef` is the caller's own mutable box (a component instance's
* useRef), so this stays a plain function rather than owning any React state
* itself; calling the returned function again before `debounceMs` elapses
* cancels the pending call and restarts the wait, so only the LAST
* width/height pair from a burst of calls ever reaches `onResize`. */
export function makeDebouncedChartResize(

Check warning on line 115 in web_ui/src/app/canvas/ChartNodeView.tsx

View workflow job for this annotation

GitHub Actions / Frontend checks (npm run check)

Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components
timerRef: { current: ReturnType<typeof setTimeout> | null },
onResize: (width: number, height: number) => void,
debounceMs: number = CHART_RESIZE_DEBOUNCE_MS,
Expand Down Expand Up @@ -143,7 +143,7 @@
* chartData reference only appears when the underlying node actually
* changed, so a plain `===` here is both correct and cheap (no risk of
* forcing a re-render off a value-identical-but-freshly-rebuilt object). */
export function chartNodePropsAreEqual(prev: NodeProps<ChartFlowNode>, next: NodeProps<ChartFlowNode>): boolean {

Check warning on line 146 in web_ui/src/app/canvas/ChartNodeView.tsx

View workflow job for this annotation

GitHub Actions / Frontend checks (npm run check)

Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components
if (prev.id !== next.id || prev.selected !== next.selected) return false;
const a = prev.data;
const b = next.data;
Expand Down
2 changes: 1 addition & 1 deletion web_ui/src/app/canvas/SceneCanvas.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ import type { SceneNodeRow, SceneState } from "../../lib/bridge-core/generated/s

// toFlowNodes is exported standalone specifically so this doesn't need a
// full <ReactFlow> mount (same reasoning as sceneStore.test.ts's direct
// scaleDragPosition coverage) - see SceneCanvas.tsx's own comment on the
// drag coverage) - see SceneCanvas.tsx's own comment on the
// export.

function makeStore(): SceneStore {
Expand Down
91 changes: 77 additions & 14 deletions web_ui/src/app/canvas/SceneCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ import {
VIEWPORT_REPORT_DEBOUNCE_MS,
} from "./canvasConstants";
import { handleKeyboardContextMenu } from "./keyboardContextMenu";
import { SceneStore, scaleDragPosition } from "./sceneStore";
import { SceneStore } from "./sceneStore";
import { computeSmartGuideSnap, type GuideLine, type Rect } from "./smartGuides";
import { ConnectionCanvas, type ConnectionSpec } from "./connections/ConnectionCanvas";
import type { ConnectionPath } from "./connections/connectionGeometry";
Expand Down Expand Up @@ -958,7 +958,7 @@ function makeChartFns(id: string, liveRef: { current: DispatcherLive }) {
}

// Exported standalone for direct unit testing (same posture as
// scaleDragPosition in sceneStore.ts) - covers the parentChatNodeId
// toFlowEdges below) - covers the parentChatNodeId
// derivation below without needing a full <ReactFlow> mount.
export function toFlowNodes(
scene: SceneState,
Expand Down Expand Up @@ -1821,7 +1821,7 @@ export function toFlowNodes(
}

// R5.1: the onSelectionChange callback's actual logic, pulled out standalone
// for direct unit testing (same posture as toFlowNodes/scaleDragPosition
// for direct unit testing (same posture as toFlowNodes
// above) - a full <ReactFlow> mount's own drag-select interaction isn't
// something this codebase drives in tests anywhere else, so this is what
// gets covered instead of the mount.
Expand All @@ -1834,7 +1834,7 @@ export function handleSelectionChange(store: SceneStore, nodes: { id: string }[]
// LOCKED frame; false for everything else (including an unlocked frame,
// which is non-draggable anyway - see toFlowNodes' draggable: setting
// above). Exported standalone, same testability convention as
// scaleDragPosition/toFlowNodes/handleSelectionChange above.
// toFlowNodes/handleSelectionChange above.
export function groupDragKindOf(node: SceneFlowNode | undefined): "frame" | "container" | null {
if (!node) return null;
if (node.type === "container") return "container";
Expand Down Expand Up @@ -2186,7 +2186,7 @@ export function toFlowEdges(scene: SceneState, hoveredEdgeId: string | null): Ed
// survives across repeated calls without this function owning any React
// state itself), exported standalone for direct unit testing without
// mounting a real <ReactFlow> pan/zoom gesture - the same testability
// posture as scaleDragPosition/toFlowNodes/handleSelectionChange above.
// posture as toFlowNodes/handleSelectionChange above.
export function makeDebouncedViewportReport(
timerRef: { current: ReturnType<typeof setTimeout> | null },
onReport: (zoomFactor: number, scrollX: number, scrollY: number) => void,
Expand Down Expand Up @@ -2230,6 +2230,12 @@ function CanvasInner({
getComposerRoute: () => { provider: string; modelId: string };
}) {
const scene = useSyncExternalStore(store.subscribe, store.getScene);
// Live mirror for event handlers that must read current scene values
// (the pan handler's drag factor) without re-registering per publish.
const sceneRef = useRef(scene);
useEffect(() => {
sceneRef.current = scene;
}, [scene]);
const grid = useSyncExternalStore(store.subscribe, store.getGrid);
// ADR-002 Workstream 1 ("Branch status and lifecycle") - "Focus Accepted
// Paths", toggled from ViewPopover.tsx's own checkbox (a sibling
Expand Down Expand Up @@ -2269,7 +2275,7 @@ function CanvasInner({

// Local node state exists so dragging is fluid; backend snapshots are the
// truth and reconcile in whenever nothing is being dragged. dragStartRef
// powers the drag-speed scaling contract (see scaleDragPosition).
// records where each gesture began (see the middleware's bookkeeping).
// React Flow owns the rendered node collection (see the <ReactFlow>
// element's own defaultNodes comment). This component keeps only a mirror
// ref for its own logic - drag corrections, delete routing, scene merge -
Expand Down Expand Up @@ -2544,7 +2550,7 @@ function CanvasInner({

/**
* DRAG-SYNC REBUILD: the drag-position corrections this canvas applies -
* the drag-speed factor (scaleDragPosition), smart-guide snapping, and
* smart-guide snapping and
* the group-member cascade - now run as a React Flow CHANGE MIDDLEWARE
* (experimental_useOnNodesChangeMiddleware), i.e. INSIDE React Flow's own
* updateNodePositions, before it commits anything.
Expand Down Expand Up @@ -2605,13 +2611,20 @@ function CanvasInner({
// its corrected position and then reconcile after the backend echo.
if (!change.dragging && !dragStartRef.current.has(change.id)) return change;
sawGestureFrame = true;
let start = dragStartRef.current.get(change.id);
if (!start) {
// Gesture membership bookkeeping only: recording the start is what
// lets the drag-STOP change (which arrives without the dragging
// flag) be recognised above. The drag-speed factor deliberately
// does NOT touch node motion any more - the legacy feature it
// ports scaled canvas PANNING, never item movement
// (graphlink_view.py:72 "For controlling pan speed"; its pan
// handler multiplied each mouse delta by the factor). The straight
// port mis-wired it to node dragging; the factor now applies in
// the wrapper's own pan handler below.
if (!dragStartRef.current.has(change.id)) {
const node = currentNodes.find((n) => n.id === change.id);
start = node ? { ...node.position } : { ...change.position };
dragStartRef.current.set(change.id, start);
dragStartRef.current.set(change.id, node ? { ...node.position } : { ...change.position });
}
let finalPosition = scaleDragPosition(start, change.position, scene.dragFactor);
let finalPosition = { ...change.position };
// R7.5b-3: smart-guide snap, as a LAYERED PASS on top of React
// Flow's native grid-snap (which, when enabled, already ran inside
// RF before this change was emitted) - the recorded design
Expand Down Expand Up @@ -2803,16 +2816,61 @@ function CanvasInner({
[connectionAt, scene.fadeConnectionsEnabled],
);

// Selecting a connection: only when the press did not land on a node card,
// so this can never steal a drag from a node.
// Factor-scaled canvas panning - the drag-speed setting's REAL job. The
// legacy view multiplied each pan delta by the factor
// (graphlink_view.py: "self._drag_factor = 1.0 # For controlling pan
// speed." and `delta *= self._drag_factor` in its pan handler); the
// straight port mis-wired the factor to node motion instead, which read
// as the setting doing nothing. React Flow's own panOnDrag has no speed
// input, so it is disabled on the element below and the gesture is owned
// here, applying the exact legacy contract: viewport moves by
// pointer-delta times factor, incrementally per event.
const panStateRef = useRef<{ lastX: number; lastY: number } | null>(null);
const onCanvasMouseDown = useCallback(
(event: MouseEvent) => {
if ((event.target as HTMLElement).closest(".react-flow__node")) return;
const id = connectionAt(event.clientX, event.clientY);
setSelectedConnectionId(id);
// Begin a pan on a background press (left or middle button), unless
// Shift is held - that remains React Flow's selection-box gesture.
const onPane = (event.target as HTMLElement).closest(".react-flow__pane");
if (onPane && !event.shiftKey && (event.button === 0 || event.button === 1) && id === null) {
panStateRef.current = { lastX: event.clientX, lastY: event.clientY };
canvasWrapperRef.current?.classList.add("panning");
}
},
[connectionAt],
);
useEffect(() => {
const onWindowMouseMove = (event: MouseEvent) => {
const pan = panStateRef.current;
if (!pan) return;
const factor = sceneRef.current.dragFactor;
const dx = (event.clientX - pan.lastX) * factor;
const dy = (event.clientY - pan.lastY) * factor;
pan.lastX = event.clientX;
pan.lastY = event.clientY;
const { transform } = storeApi.getState();
reactFlow.setViewport({ x: transform[0] + dx, y: transform[1] + dy, zoom: transform[2] });
};
const onWindowMouseUp = () => {
if (!panStateRef.current) return;
panStateRef.current = null;
canvasWrapperRef.current?.classList.remove("panning");
// Persist the settled viewport the same way onMove does for zooming -
// programmatic setViewport does not raise React Flow's own onMove.
const [x, y, zoom] = storeApi.getState().transform;
makeDebouncedViewportReport(viewportTimerRef, (zoomFactor, scrollX, scrollY) =>
store.setViewState(zoomFactor, scrollX, scrollY),
)(zoom, x, y);
};
window.addEventListener("mousemove", onWindowMouseMove);
window.addEventListener("mouseup", onWindowMouseUp);
return () => {
window.removeEventListener("mousemove", onWindowMouseMove);
window.removeEventListener("mouseup", onWindowMouseUp);
};
}, [reactFlow, store, storeApi]);

// Delete removes the selected connection. React Flow used to report edge
// deletions through onDelete; it no longer renders connections, so this
Expand Down Expand Up @@ -2898,6 +2956,11 @@ function CanvasInner({
// PluginPicker can attach "which node was selected" to executePlugin
// without either component reaching into the other's internals.
onSelectionChange={onSelectionChange}
/* Pan is owned by the wrapper's factor-scaled handler (see
onCanvasMouseDown) - React Flow's own panOnDrag has no speed
input, which is how the drag-speed setting lost its meaning in
the straight port. */
panOnDrag={false}
snapToGrid={scene.snapToGrid}
snapGrid={snapGrid}
// Double-click is the R1 create-node gesture (wrapper onDoubleClick);
Expand Down
15 changes: 1 addition & 14 deletions web_ui/src/app/canvas/sceneStore.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from "vitest";
import { SceneStore, initialSceneState, scaleDragPosition } from "./sceneStore";
import { SceneStore, initialSceneState } from "./sceneStore";
import type { ScenePatch, WsTransport } from "../../lib/ws/transport";
import type { BridgeRejection } from "../../lib/bridge-core/islandState";

Expand Down Expand Up @@ -1642,16 +1642,3 @@ describe("SceneStore", () => {
});
});

describe("scaleDragPosition (the drag-speed contract)", () => {
it("factor 1 leaves motion unscaled", () => {
expect(scaleDragPosition({ x: 0, y: 0 }, { x: 100, y: 40 }, 1)).toEqual({ x: 100, y: 40 });
});

it("factor 0.5 halves the delta from the drag start", () => {
expect(scaleDragPosition({ x: 10, y: 10 }, { x: 110, y: 50 }, 0.5)).toEqual({ x: 60, y: 30 });
});

it("scales relative to the start, not the origin", () => {
expect(scaleDragPosition({ x: -20, y: 8 }, { x: -20, y: 8 }, 0.25)).toEqual({ x: -20, y: 8 });
});
});
24 changes: 11 additions & 13 deletions web_ui/src/app/canvas/sceneStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -608,6 +608,16 @@ export class SceneStore {
this.emit();
}

// Drops both filter axes at once - the View popover's Clear affordance.
// No-op (no emit) when nothing is filtered, matching every other
// guard-before-emit setter on this store.
clearFilters(): void {
if (this.filterKinds.size === 0 && this.filterStatuses.size === 0) return;
this.filterKinds = new Set();
this.filterStatuses = new Set();
this.emit();
}

setExportInProgress(value: boolean): void {
if (value === this.exportInProgress) return;
this.exportInProgress = value;
Expand Down Expand Up @@ -1411,16 +1421,4 @@ export class SceneStore {
}
}

/** start + (proposed - start) * factor: the drag-speed contract carried over
* from the Qt canvas (ChatView's drag factor scaled item motion the same
* way). Exported standalone for direct unit testing. */
export function scaleDragPosition(
start: { x: number; y: number },
proposed: { x: number; y: number },
factor: number,
): { x: number; y: number } {
return {
x: start.x + (proposed.x - start.x) * factor,
y: start.y + (proposed.y - start.y) * factor,
};
}

8 changes: 4 additions & 4 deletions web_ui/src/app/chrome/ChatLibraryDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -608,7 +608,7 @@ export function ChatLibraryDialog({ transport }: { transport: WsTransport }) {
<div className="library-workspace-tabs" role="group" aria-label="Workspaces">
<button
type="button"
className={"view-preset-btn" + (selectedWorkspaceId === null ? " active" : "")}
className={"view-chip" + (selectedWorkspaceId === null ? " active" : "")}
onClick={() => selectWorkspace(null)}
>
All
Expand All @@ -620,7 +620,7 @@ export function ChatLibraryDialog({ transport }: { transport: WsTransport }) {
<div key={workspace.id} className="library-workspace-tab-group">
<button
type="button"
className={"view-preset-btn" + (selectedWorkspaceId === workspace.id ? " active" : "")}
className={"view-chip" + (selectedWorkspaceId === workspace.id ? " active" : "")}
onClick={() => selectWorkspace(workspace.id)}
>
{workspace.name}
Expand Down Expand Up @@ -726,7 +726,7 @@ export function ChatLibraryDialog({ transport }: { transport: WsTransport }) {
{total > 0 && (
<button
type="button"
className={"view-preset-btn library-archived-toggle" + (showArchived ? " active" : "")}
className={"view-chip library-archived-toggle" + (showArchived ? " active" : "")}
aria-pressed={showArchived}
onClick={() => setShowArchived((prev) => !prev)}
>
Expand All @@ -745,7 +745,7 @@ export function ChatLibraryDialog({ transport }: { transport: WsTransport }) {
<button
key={tag}
type="button"
className={"view-preset-btn" + (selectedTags.has(tag) ? " active" : "")}
className={"view-chip" + (selectedTags.has(tag) ? " active" : "")}
aria-pressed={selectedTags.has(tag)}
onClick={() => toggleTag(tag)}
>
Expand Down
Loading
Loading