Skip to content
75 changes: 75 additions & 0 deletions apps/server/src/modules/canvas/node-neighbourhood.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

import { describe, expect, it } from 'vitest';

import { buildNodeNeighbourhoodContext } from './node-neighbourhood.js';

import type { SpatialNode } from '@huabu/shared';

function node(
id: string,
x: number,
parentId?: string,
type = 'note',
): SpatialNode {
return {
id,
type,
label: id,
rect: { x, y: 0, width: 100, height: 100 },
...(parentId ? { parentId } : {}),
};
}

function includedIds(
nodes: SpatialNode[],
edges: Array<{ source: string; target: string }>,
) {
const context = buildNodeNeighbourhoodContext(nodes[0], nodes, edges);
return new Set(
context.layers.flatMap((layer) =>
layer.groups.flatMap((group) => group.nodes.map((item) => item.id)),
),
);
}

describe('buildNodeNeighbourhoodContext', () => {
it('uses a narrow default radius for ordinary spatial neighbours', () => {
const ids = includedIds(
[node('anchor', 0), node('nearby', 500), node('distant', -501)],
[],
);

expect(ids).toContain('nearby');
expect(ids).not.toContain('distant');
});

it('retains the containing frame and all direct siblings beyond the radius', () => {
const frame = node('frame', 0, undefined, 'frame');
const anchor = node('anchor', 0, 'frame');
const sibling = node('sibling', 5000, 'frame');
const siblingFrame = node('sibling-frame', 7000, 'frame', 'frame');
const ids = includedIds([anchor, frame, sibling, siblingFrame], []);

expect(ids).toContain('frame');
expect(ids).toContain('sibling');
expect(ids).toContain('sibling-frame');
});

it('retains every directly connected node beyond the radius', () => {
const anchor = node('anchor', 0);
const connectedSource = node('connected-source', 5000);
const connectedTarget = node('connected-target', 7000);
const ids = includedIds(
[anchor, connectedSource, connectedTarget],
[
{ source: 'connected-source', target: 'anchor' },
{ source: 'anchor', target: 'connected-target' },
],
);

expect(ids).toContain('connected-source');
expect(ids).toContain('connected-target');
});
});
60 changes: 57 additions & 3 deletions apps/server/src/modules/canvas/node-neighbourhood.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ import { getCanvasStore } from '../storage/index.js';
import type { AgentNodePreview } from '../agent/node-ref.js';
import type { CanvasNodeType, SpatialNode } from '@huabu/shared';

const DEFAULT_NEIGHBOURHOOD_RADIUS = 400;

// ─── Public entry point ─────────────────────────────────────────────────────

// ─── Adapter: canvasId + anchorNodeId → NodeNeighbourhoodContext ────────────
Expand Down Expand Up @@ -201,7 +203,12 @@ export function buildNodeNeighbourhoodContext(
opts?: { maxDistance?: number },
): NodeNeighbourhoodContext {
const nodeById = new Map(allNodes.map((n) => [n.id, n]));
const maxDistance = opts?.maxDistance ?? 2000;
const maxDistance = opts?.maxDistance ?? DEFAULT_NEIGHBOURHOOD_RADIUS;
const connectedIds = new Set<string>();
for (const edge of edges) {
if (edge.source === anchorNode.id) connectedIds.add(edge.target);
if (edge.target === anchorNode.id) connectedIds.add(edge.source);
}

// All content nodes (non-frame, non-self).
const contentNodes = allNodes.filter(
Expand All @@ -214,21 +221,44 @@ export function buildNodeNeighbourhoodContext(
// ── Walk from inside-out, starting from the anchor node ──
let currentRef: SpatialNode = anchorNode;
let currentFrameId: string | null | undefined = anchorNode.parentId;
let isAnchorFrame = true;

while (true) {
const frame = currentFrameId ? nodeById.get(currentFrameId) : undefined;

if (frame) {
// ── Inner layer: currentRef vs siblings inside this frame ──
const siblings = contentNodes.filter(
const siblingCandidates = isAnchorFrame ? allNodes : contentNodes;
const siblings = siblingCandidates.filter(
(n) => n.parentId === currentFrameId && n.id !== currentRef.id,
);
const siblingGroups = buildGroupsFromNodes(
currentRef,
siblings,
nodeById,
describe,
).filter((g) => g._minEdgeDist <= maxDistance);
).filter((g) => isAnchorFrame || g._minEdgeDist <= maxDistance);

if (isAnchorFrame) {
const frameCenter = rectCenter(frame.rect);
const refCenter = rectCenter(currentRef.rect);
siblingGroups.unshift({
dx: Math.round(frameCenter.x - refCenter.x),
dy: Math.round(frameCenter.y - refCenter.y),
_minEdgeDist: 0,
arrangement: 'containing frame',
frameId: frame.id,
frameLabel: frame.label,
nodes: [
describe?.(frame) ??
buildAgentNodePreview({
id: frame.id,
type: 'frame' as CanvasNodeType,
...(frame.label ? { label: frame.label } : {}),
}),
],
});
}
layers.push({
frameId: frame.id,
frameLabel: frame.label,
Expand All @@ -239,6 +269,7 @@ export function buildNodeNeighbourhoodContext(
// Move outward: the frame itself becomes the reference entity.
currentRef = frame;
currentFrameId = frame.parentId;
isAnchorFrame = false;
} else {
// ── Outermost layer: currentRef vs everything outside ──
// Collect ancestors to exclude.
Expand Down Expand Up @@ -339,6 +370,29 @@ export function buildNodeNeighbourhoodContext(
}
}

// Explicit relationships outrank proximity. A connected endpoint may sit
// beyond the radius or inside an outer frame whose descendants are normally
// represented only by that frame, so append any endpoint not already shown.
const includedIds = new Set(
allGroups.flatMap((group) => group.nodes.map((node) => node.id)),
);
const missingConnectedNodes = [...connectedIds]
.map((id) => nodeById.get(id))
.filter(
(node): node is SpatialNode =>
node !== undefined && !includedIds.has(node.id),
);
if (missingConnectedNodes.length > 0) {
const connectedGroups = buildGroupsFromNodes(
anchorNode,
missingConnectedNodes,
nodeById,
describe,
);
layers.push({ groups: connectedGroups });
allGroups.push(...connectedGroups);
}

// If no layers at all, the node is isolated.
if (layers.length === 0 && allGroups.length === 0) {
return { layers: [], relevantEdges: [] };
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';

import { fingerprintMarkdownKeys } from '@huabu/shared/canvas-engine';

import { normalizeMathDelimiters } from '../markdownUtils';

import type { Node as PMNode } from '@milkdown/prose/model';

const here = dirname(fileURLToPath(import.meta.url));
Expand Down Expand Up @@ -60,7 +62,7 @@ async function createHarness(): Promise<Harness> {
return crepe.editor.action((ctx) => {
const parser = ctx.get(parserCtx);
const serializer = ctx.get(serializerCtx);
const doc = parser(markdown) as PMNode | null;
const doc = parser(normalizeMathDelimiters(markdown)) as PMNode | null;
if (!doc) return { serialized: '', pmBlockCount: 0 };
return { serialized: serializer(doc), pmBlockCount: doc.childCount };
});
Expand All @@ -74,6 +76,30 @@ async function createHarness(): Promise<Harness> {

const FIXTURES = ['simple.md', 'math.md', 'complex.md', 'ai-half-baked.md'];

const AGENT_MARKDOWN_CASES: Array<[string, string]> = [
['blockquote', '> **Finding:** The result changed.\n>\n> Follow-up detail.'],
['ordered-list-start', '3. Third item\n4. Fourth item'],
['thematic-break', 'Before\n\n---\n\nAfter'],
['inline-link', 'See [the documentation](https://example.com "Docs").'],
['image', '![Diagram](artifacts/diagram.png "Architecture")'],
['styled-span', '<span data-huabu-text-color="danger">Important text</span>'],
['nested-quote-list', '> Summary\n>\n> - First\n> - Second'],
['mixed-inline-marks', 'This is ***important*** and ~~obsolete~~.'],
['setext-heading', 'Agent-generated heading\n======================='],
['indented-code', ' const value = 42;\n console.log(value);'],
[
'reference-link',
'Read [the guide][guide].\n\n[guide]: https://example.com',
],
['autolink', 'Contact <person@example.com> or visit <https://example.com>.'],
['escaped-punctuation', String.raw`Literal \*stars\* and \[brackets\].`],
['latex-inline-math', String.raw`The result is \(x + y\).`],
['latex-display-math', String.raw`\[x^2 + y^2 = z^2\]`],
['html-block', '<div data-kind="callout">\nImportant content\n</div>'],
['details-block', '<details>\n<summary>Details</summary>\nBody\n</details>'],
['definition', 'Term\n: Definition emitted by an agent'],
];

describe('block-key parity: server(raw md) ↔ client(Milkdown round-trip)', () => {
let harness: Harness;
beforeAll(async () => {
Expand All @@ -97,4 +123,16 @@ describe('block-key parity: server(raw md) ↔ client(Milkdown round-trip)', ()
expect(rawKeys.length).toBe(pmBlockCount);
});
}

it.each(AGENT_MARKDOWN_CASES)(
'%s: agent markdown survives Milkdown fingerprinting',
(_name, raw) => {
const { serialized, pmBlockCount } = harness.roundTrip(raw);
const rawKeys = fingerprintMarkdownKeys(raw);
const roundTrippedKeys = fingerprintMarkdownKeys(serialized);

expect(roundTrippedKeys).toEqual(rawKeys);
expect(rawKeys.length).toBe(pmBlockCount);
},
);
});
Loading
Loading