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
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { Maximize2, ZoomIn, ZoomOut } from "lucide-react";
import type { RefObject } from "react";
import { Button } from "@/shared/ui/button";

export function GraphNavigationControls({
surfaceRef,
onFit,
}: {
surfaceRef: RefObject<HTMLDivElement | null>;
onFit: () => void;
}) {
const zoom = (direction: "in" | "out") => {
const canvas = surfaceRef.current?.querySelector("canvas");
if (!canvas) return;

const rect = canvas.getBoundingClientRect();
canvas.dispatchEvent(
new WheelEvent("wheel", {
bubbles: true,
cancelable: true,
clientX: rect.left + rect.width / 2,
clientY: rect.top + rect.height / 2,
deltaMode: WheelEvent.DOM_DELTA_PIXEL,
deltaY: direction === "in" ? -180 : 180,
view: window,
}),
);
};

return (
<div
data-testid="dkg-graph-controls"
className="flex items-center gap-0.5 rounded-md border border-border bg-muted/30 p-0.5"
>
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label="Zoom graph in"
title="Zoom in"
onClick={() => zoom("in")}
>
<ZoomIn />
</Button>
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label="Zoom graph out"
title="Zoom out"
onClick={() => zoom("out")}
>
<ZoomOut />
</Button>
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label="Fit graph to view"
title="Fit graph to view"
onClick={onFit}
>
<Maximize2 />
</Button>
</div>
);
}
20 changes: 18 additions & 2 deletions desktop/src/features/dkg-memory/topology/TopologyView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,10 @@
// NodePanel inspector. "Entity types" coloring is the node-parity default;
// "Contributors" preserves the attribution view (recorded attribution,
// not verification).
import { Suspense, lazy, useMemo, useState } from "react";
import { Suspense, lazy, useMemo, useRef, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { fetchTopologyTriples, type TopologyTarget } from "./client";
import { GraphNavigationControls } from "./GraphNavigationControls";
import {
applyHeaviestSubjectsCap,
attributionLegend,
Expand Down Expand Up @@ -46,6 +47,9 @@ const BUZZ_NS = "https://w3id.org/buzz-dkg/buzz#";
export const NODE_UI_GRAPH_OPTIONS = {
labelMode: "humanized" as const,
renderer: "2d" as const,
// RdfGraph's initialFit performs the one intended camera fit. Disabling the
// renderer's additional settle-time fit keeps later user zoom intact.
autoFitDisabled: true,
labels: { predicates: NODE_UI_LABEL_PREDICATES },
style: {
classColors: {
Expand Down Expand Up @@ -96,6 +100,8 @@ export function TopologyView({
const [colorMode, setColorMode] = useState<"entity" | "attribution">(
"entity",
);
const [fitRevision, setFitRevision] = useState(0);
const graphSurfaceRef = useRef<HTMLDivElement>(null);
const channelWide = target.kind === "channel";
const query = useQuery({
queryKey: [
Expand Down Expand Up @@ -222,6 +228,10 @@ export function TopologyView({
</span>
</span>
))}
<GraphNavigationControls
surfaceRef={graphSurfaceRef}
onFit={() => setFitRevision((revision) => revision + 1)}
/>
<span className="ml-auto text-2xs text-muted-foreground">
{summary.entities} entities · {summary.relationships} relationships ·{" "}
{channelWide
Expand All @@ -237,7 +247,12 @@ export function TopologyView({
{/* Dark node-idiom island: the renderer's palette is tuned for the DKG
node UI's canvas (#0a0a0f); rendering on that background *is* the
node look — bright hexagons, thin edges, light labels. */}
<div className="min-h-0 flex-1" style={{ background: "#0a0a0f" }}>
<div
ref={graphSurfaceRef}
data-buzz-wheel-surface
className="min-h-0 flex-1"
style={{ background: "#0a0a0f" }}
>
<Suspense
fallback={
<div className="p-6 text-sm text-muted-foreground">
Expand All @@ -246,6 +261,7 @@ export function TopologyView({
}
>
<RdfGraph
key={`${colorMode}-${fitRevision}`}
data={graphData}
format="triples"
options={
Expand Down
17 changes: 15 additions & 2 deletions desktop/src/features/dkg-memory/ui/LensTopology.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@
// TopologyView — but fed triples projected from the lens graph instead of a
// provider round-trip, so the Graph view stays scoped to exactly what the
// lens shows. Render-only: node clicks select, they never navigate.
import { Suspense, lazy, useMemo } from "react";
import { Suspense, lazy, useMemo, useRef, useState } from "react";
import type { LensGraph } from "../lensGraphs";
import { lensTriples } from "../lensGraphs";
import { GraphNavigationControls } from "../topology/GraphNavigationControls";
import { NODE_UI_GRAPH_OPTIONS } from "../topology/TopologyView";

const RdfGraph = lazy(() =>
Expand All @@ -27,16 +28,27 @@ export function LensTopology({
onSelectUri: (uri: string, label?: string) => void;
}) {
const triples = useMemo(() => lensTriples(graph), [graph]);
const [fitRevision, setFitRevision] = useState(0);
const graphSurfaceRef = useRef<HTMLDivElement>(null);

return (
<div className="flex h-full min-h-0 flex-col">
<div className="flex items-center gap-2 border-b border-border px-3 py-1.5">
<GraphNavigationControls
surfaceRef={graphSurfaceRef}
onFit={() => setFitRevision((revision) => revision + 1)}
/>
<span className="text-2xs text-muted-foreground">
{graph.nodes.length} entities · {graph.edges.length} relationships ·
colors = entity types, as in your DKG node
</span>
</div>
<div className="min-h-0 flex-1" style={{ background: "#0a0a0f" }}>
<div
ref={graphSurfaceRef}
data-buzz-wheel-surface
className="min-h-0 flex-1"
style={{ background: "#0a0a0f" }}
>
<Suspense
fallback={
<div className="p-6 text-sm text-muted-foreground">
Expand All @@ -45,6 +57,7 @@ export function LensTopology({
}
>
<RdfGraph
key={fitRevision}
data={triples}
format="triples"
options={NODE_UI_GRAPH_OPTIONS}
Expand Down
16 changes: 16 additions & 0 deletions desktop/src/shared/hooks/useWebviewScrollBoundaryLock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import * as React from "react";
const BOUNDARY_EPSILON_PX = 1;
const CONVERSATION_SCROLL_SELECTOR = "[data-buzz-conversation-scroll]";
const TERMINAL_SUBSTRATE_SELECTOR = '[data-terminal-owner="terminal"]';
const INTERACTIVE_WHEEL_SURFACE_SELECTOR = "[data-buzz-wheel-surface]";
const SCROLLABLE_OVERFLOW_VALUES = new Set(["auto", "scroll", "overlay"]);

function isHTMLElement(value: EventTarget | null): value is HTMLElement {
Expand Down Expand Up @@ -96,6 +97,21 @@ export function useWebviewScrollBoundaryLock(enabled = true) {
}

const path = event.composedPath();
// Canvas-based surfaces (for example, the DKG graph) consume wheel and
// trackpad gestures as zoom input without exposing a native scroll
// container. Let those gestures reach the surface instead of treating
// them as dead space that would rubber-band the webview.
if (
Math.abs(deltaY) >= Math.abs(deltaX) &&
path.some(
(target) =>
isHTMLElement(target) &&
target.matches(INTERACTIVE_WHEEL_SURFACE_SELECTOR),
)
) {
return;
}

let firstScrollable: HTMLElement | null = null;
let targetsTerminal = false;

Expand Down
9 changes: 9 additions & 0 deletions desktop/tests/e2e/dkg-memory-fallback.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,15 @@ test("flat capture: All-decisions lens resolves evidence into Traces and Graph",
overlay.getByText(/5 entities · 2 relationships/i),
).toBeVisible();
await expectPaintedGraphCanvas(overlay);
await expect(
overlay.getByRole("button", { name: "Zoom graph in" }),
).toBeVisible();
await expect(
overlay.getByRole("button", { name: "Zoom graph out" }),
).toBeVisible();
await expect(
overlay.getByRole("button", { name: "Fit graph to view" }),
).toBeVisible();
expect(subgraphRequests).toEqual([]);
await waitForAnimations(page);
});
Expand Down
20 changes: 20 additions & 0 deletions desktop/tests/e2e/overscroll-boundary.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,26 @@ test("locks viewport rubber-band outside conversation scrollers", async ({
deltaY: -120,
}),
).resolves.toBe(false);

// Canvas interactions such as the DKG topology use wheel gestures for zoom
// without a native scroll container. The viewport guard must not consume
// those events before the renderer receives them.
await page.evaluate(() => {
const graph = document.createElement("section");
graph.dataset.buzzWheelSurface = "";
graph.dataset.testid = "interactive-wheel-surface";
document.body.append(graph);
});
await expect(
dispatchWheelPrevented(page, '[data-testid="interactive-wheel-surface"]', {
deltaY: -120,
}),
).resolves.toBe(false);
await expect(
dispatchWheelPrevented(page, '[data-testid="interactive-wheel-surface"]', {
deltaX: 120,
}),
).resolves.toBe(true);
});

test("locks horizontal viewport pan everywhere", async ({ page }) => {
Expand Down
Loading