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
15 changes: 14 additions & 1 deletion web_ui/src/app/canvas/ChatNodeView.test.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { createElement } from "react";
import { ReactFlowProvider, type NodeProps } from "@xyflow/react";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
Expand All @@ -22,7 +23,19 @@ import { WsTransport } from "../../lib/ws/transport";
// delta count" without touching what gets rendered.
vi.mock("./NodeMarkdown", async (importOriginal) => {
const actual = await importOriginal<typeof import("./NodeMarkdown")>();
return { ...actual, NodeMarkdown: vi.fn(actual.NodeMarkdown) };
// NodeMarkdown is React.memo(...) - a memo descriptor object, not a plain
// callable function - so it can no longer be handed to vi.fn() directly as
// the implementation (vi.fn calls implementation.apply(...) internally, and
// a memo object has no .apply). Spy on a plain wrapper function instead
// that renders an element of the REAL memoized component: call-count
// instrumentation keeps working exactly as before (every ChatNodeView
// re-render that reaches this JSX position still increments the spy,
// same as when NodeMarkdown was an unmemoized plain function), while the
// actual markdown parse still runs through the genuine memoized component.
return {
...actual,
NodeMarkdown: vi.fn((props: { content: string }) => createElement(actual.NodeMarkdown, props)),
};
});

// R7.5a: jsdom implements neither URL.createObjectURL nor
Expand Down
21 changes: 13 additions & 8 deletions web_ui/src/app/canvas/NodeMarkdown.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useRef, useState, type JSX } from "react";
import { memo, useMemo, useRef, useState, type JSX } from "react";
import ReactMarkdown, { type ExtraProps } from "react-markdown";
import type { PluggableList } from "unified";
import remarkGfm from "remark-gfm";
import remarkMath from "remark-math";
import { remarkAlert } from "remark-github-blockquote-alert";
Expand Down Expand Up @@ -105,6 +106,8 @@ import { rehypeHighlightSearchMatches } from "./documentViewSearchHighlight";
* both whether the link renders at all AND how hardened it is.
*/

const REMARK_PLUGINS = [remarkGfm, remarkAlert, remarkMath];

function CodeBlock({ node: _node, children, ...props }: JSX.IntrinsicElements["pre"] & ExtraProps) {
const preRef = useRef<HTMLPreElement>(null);
const [copied, setCopied] = useState(false);
Expand Down Expand Up @@ -208,15 +211,17 @@ function SafeAnchor({ node: _node, href, children, ...props }: JSX.IntrinsicElem
);
}

export function NodeMarkdown({ content }: { content: string }) {
const MARKDOWN_COMPONENTS = { pre: CodeBlock, table: TableWrapper, img: ZoomImage, a: SafeAnchor };

export const NodeMarkdown = memo(function NodeMarkdown({ content }: { content: string }) {
const searchQuery = useCanvasSearchQuery();
const rehypePlugins = useMemo<PluggableList>(
() => [rehypeHighlight, rehypeKatex, [rehypeHighlightSearchMatches, searchQuery]],
[searchQuery],
);
return (
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkAlert, remarkMath]}
rehypePlugins={[rehypeHighlight, rehypeKatex, [rehypeHighlightSearchMatches, searchQuery]]}
components={{ pre: CodeBlock, table: TableWrapper, img: ZoomImage, a: SafeAnchor }}
>
<ReactMarkdown remarkPlugins={REMARK_PLUGINS} rehypePlugins={rehypePlugins} components={MARKDOWN_COMPONENTS}>
{content}
</ReactMarkdown>
);
}
});
38 changes: 29 additions & 9 deletions web_ui/src/app/canvas/SceneCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,15 @@ const EDGE_TYPES = {
orthogonal: OrthogonalEdge,
};

// UI-perf fix: fully static <ReactFlow> props, hoisted so these are the
// SAME array/object reference on every CanvasInner render instead of a
// fresh literal every time - React Flow reads these by reference in its
// own internal effects, so a fresh literal every render meant those
// effects re-ran on every unrelated re-render for no behavioral reason.
const DELETE_KEY_CODES = ["Delete", "Backspace"];
const PRO_OPTIONS = { hideAttribution: true };
const DEFAULT_EDGE_OPTIONS = { type: "default" as const };

// R8a follow-up: legacy's graphlink_window.py show_document_view() showed
// this exact message (via notification_banner.show_message(..., "info"))
// when a node had nothing to show, rather than silently doing nothing - the
Expand Down Expand Up @@ -2546,8 +2555,11 @@ function CanvasInner({
if (settledMoveIntents.length > 0) store.moveNodes(settledMoveIntents);
// Guides re-derive every drag frame (legacy cleared + re-added its
// QGraphicsLineItems per recompute); drag end always clears.
if (sawDragging) setSmartGuideLines(frameGuides);
else if (sawDragEnd) setSmartGuideLines([]);
if (sawDragging) {
setSmartGuideLines((current) => (current.length === 0 && frameGuides.length === 0 ? current : frameGuides));
} else if (sawDragEnd) {
setSmartGuideLines((current) => (current.length === 0 ? current : []));
}
setNodes((current) => applyNodeChanges([...scaled, ...memberChanges], current));
},
[nodes, scene.dragFactor, scene.smartGuides, reactFlow, store],
Expand Down Expand Up @@ -2587,6 +2599,14 @@ function CanvasInner({
[store, nodes],
);

const onSelectionChange = useCallback(
({ nodes: sel }: { nodes: { id: string }[] }) => handleSelectionChange(store, sel),
[store],
);
const onEdgeMouseEnter = useCallback((_event: React.MouseEvent, edge: Edge) => setHoveredEdgeId(edge.id), []);
const onEdgeMouseLeave = useCallback(() => setHoveredEdgeId(null), []);
const snapGrid = useMemo<[number, number]>(() => [grid.gridSize, grid.gridSize], [grid.gridSize]);

const { screenToFlowPosition } = reactFlow;
const onDoubleClick = useCallback(
(event: React.MouseEvent) => {
Expand Down Expand Up @@ -2625,20 +2645,20 @@ function CanvasInner({
// R5.1: mirrors React Flow's own selection state into the store so
// 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)}
onSelectionChange={onSelectionChange}
onEdgeMouseEnter={onEdgeMouseEnter}
onEdgeMouseLeave={onEdgeMouseLeave}
snapToGrid={scene.snapToGrid}
snapGrid={[grid.gridSize, grid.gridSize]}
snapGrid={snapGrid}
// Double-click is the R1 create-node gesture (wrapper onDoubleClick);
// RF's default dblclick-zoom would consume it before it ever bubbles.
zoomOnDoubleClick={false}
fitView
minZoom={0.1}
maxZoom={2.5}
deleteKeyCode={["Delete", "Backspace"]}
proOptions={{ hideAttribution: true }}
defaultEdgeOptions={{ type: "default" }}
deleteKeyCode={DELETE_KEY_CODES}
proOptions={PRO_OPTIONS}
defaultEdgeOptions={DEFAULT_EDGE_OPTIONS}
/*
* ADR-011 stage 11.2: off-viewport nodes no longer mount at all (nor
* re-render, nor pay their markdown/highlight/KaTeX parse cost) -
Expand Down
3 changes: 1 addition & 2 deletions web_ui/src/app/canvas/useLodVisibility.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,5 @@ import { LOD_ZOOM_THRESHOLD } from "./canvasConstants";
* since this only replaces the two lines that produced the boolean, not what
* callers do with it afterward). */
export function useLodVisibility(): boolean {
const zoom = useStore((s) => s.transform[2]);
return zoom < LOD_ZOOM_THRESHOLD;
return useStore((s) => s.transform[2] < LOD_ZOOM_THRESHOLD);
}
10 changes: 10 additions & 0 deletions web_ui/src/app/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,16 @@ body,
box-shadow: 0 0 0 1px var(--gl-surface-border-strong), var(--gl-shadow-3);
}

/* UI-perf fix: while a node is actively being dragged, React Flow's
controlled-node round-trip lag can put the cursor outside the card's
true rendered rect for a frame, toggling hover on/off and replaying
the border-color/box-shadow transition above as visible flicker. Pin
hover back to the resting appearance for the drag's duration. */
.react-flow__node.dragging .scene-node:hover {
border-color: var(--gl-surface-border);
box-shadow: var(--gl-shadow-1);
}

/* ADR-012 stage 12.3: the actually-tabbable element for a node is React
Flow's OWN wrapper div (.react-flow__node, one level up from .scene-node -
see NodeMenu.tsx's own stage-12.3 doc for why), not .scene-node itself, so
Expand Down
Loading