feat(memory-graph): add opt-in stacked nodes and emphasized connections (#1792)
Some checks are pending
Publish Memory Graph / publish (push) Waiting to run

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
Dhravya Shah 2026-10-11 14:24:42 -07:00 • committed by GitHub
parent 709723dea6
commit a5e84e0676
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 422 additions and 24 deletions

View file

@ -59,6 +59,23 @@ export default function GraphPage() {
}
```
## Optional rendering and document navigation
Existing icon-based document nodes and connection styling remain the defaults. Opt in to stacked squares and stronger semantic connections with `appearance`:
```tsx
<MemoryGraph
documents={documents}
appearance={{ documentNodes: 'stacked', connections: 'emphasized' }}
openDocumentOnEnter
onOpenDocument={openPersistedDocument}
/>
```
`documentNodes` accepts `'icons'` or `'stacked'`; `connections` accepts `'default'` or `'emphasized'`. Both fields are optional and also work on `GraphCanvas`. Stacks stay inside existing node bounds and simplify at low zoom. Use `colors.edgeDocument` to configure document-source ink; older custom `GraphCanvas` palettes without that field use `textSecondary` in emphasized mode.
`openDocumentOnEnter` is off by default. With an `onOpenDocument` callback, `MemoryGraph` exposes a focused Enter action for the selected persisted document or one confirmed memory source. Typing, modifier shortcuts, repeats, composition and keys outside the graph do not open documents. Your callback retains responsibility for routing and access control; consumers without a callback receive no new opening behavior.
## Backend API route
Create an API route to fetch documents from Supermemory:

View file

@ -204,7 +204,7 @@
},
"packages/memory-graph": {
"name": "@supermemory/memory-graph",
"version": "0.2.4",
"version": "0.2.5",
"dependencies": {
"d3-force": "^3.0.0",
},

View file

@ -69,6 +69,25 @@ function App() {
Console mode uses the supplied theme colors for its surface, 16px dot grid, document icons, and node fills and strokes. Consumer mode keeps its transparent surface and cluster colors.
### Opt-in appearance and document opening
The default appearance remains the icon-based document nodes and existing connection styling. Configure the same options on `MemoryGraph` or `GraphCanvas` to use stacked document squares and stronger connections:
```tsx
<MemoryGraph
documents={documents}
appearance={{ documentNodes: "stacked", connections: "emphasized" }}
openDocumentOnEnter
onOpenDocument={openPersistedDocument}
/>
```
`appearance.documentNodes` accepts `"icons"` (default) or `"stacked"`. Stacks have no icons or text, stay inside the original node bounds and collapse to plain blocks at smaller zooms. Selection uses a single accented boundary; full document identity stays in the popover.
`appearance.connections` accepts `"default"` (default) or `"emphasized"`. Emphasized connections recover smoothly from a quiet overview, with stronger semantic relations and subordinate, glow-free document sources. Configure `colors.edgeDocument` for the source ink. Older custom `GraphCanvas` palettes without that field use `textSecondary` for emphasized sources. No node positions, hit targets or stored graph data change.
`openDocumentOnEnter` defaults to `false` and applies only to `MemoryGraph`. With an `onOpenDocument` callback, it adds a discoverable, focus-scoped Enter action. Opening uses a loaded persisted document ID; memories require one confirmed source. Typing, modifiers, repeated keys and composition do not open a document. Native buttons retain their normal activation. The package calls your callback and does not own routing or authorization. Without the option or callback, no new focus target or Enter behavior is added.
Set `colors.dotColor` or the `--graph-dot` CSS variable to style the dot grid independently of text. When neither is set, the grid uses `textMuted`.
Document-to-memory links use `colors.edgeDocument` or `--graph-edge-document`, while derived-memory relations use `colors.edgeDerives` or `--graph-edge-derives`.

View file

@ -1,6 +1,6 @@
{
"name": "@supermemory/memory-graph",
"version": "0.2.4",
"version": "0.2.5",
"description": "Interactive graph visualization component for Supermemory - visualize and explore your memory connections",
"type": "module",
"main": "./src/index.tsx",

View file

@ -3,6 +3,7 @@ import type {
GraphEdge,
GraphNode,
GraphThemeColors,
GraphAppearance,
MemoryNodeData,
} from "../types"
import type { ViewportState } from "./viewport"
@ -14,6 +15,7 @@ export interface RenderState {
hoveredNodeId: string | null
highlightIds: Set<string>
dimProgress: number
appearance?: GraphAppearance
}
// Module-level reusable batch map – cleared each frame instead of reallocating
@ -23,6 +25,8 @@ const RELATION_LOD_MAX_BACKGROUND_EDGES = 260
const RELATION_LOD_DENSE_COUNT = 180
const DOCUMENT_LOD_ZOOM = 0.38
const DOCUMENT_LOD_MAX_BACKGROUND_EDGES = 3200
const EDGE_OVERVIEW_ZOOM = 0.1
const EDGE_DETAIL_ZOOM = 0.35
const DENSE_POINT_THRESHOLD = 25000
const DENSE_POINT_ZOOM = 0.42
@ -82,9 +86,21 @@ export function renderFrame(
function edgeStyle(
edge: GraphEdge,
colors: GraphThemeColors,
emphasized = false,
): { color: string; width: number; opacity: number } {
if (emphasized && edge.edgeType !== "document") {
if (edge.edgeType === "derives")
return { color: colors.edgeDerives, width: 2, opacity: 0.9 }
if (edge.edgeType === "updates")
return { color: colors.edgeUpdates, width: 2.2, opacity: 0.94 }
return { color: colors.edgeExtends, width: 2, opacity: 0.88 }
}
if (edge.edgeType === "document")
return { color: colors.edgeDocument, width: 1.2, opacity: 0.4 }
return {
color: colors.edgeDocument ?? colors.edgeDerives,
width: 1.2,
opacity: 0.4,
}
if (edge.edgeType === "derives")
return { color: colors.edgeDerives, width: 1.2, opacity: 0.4 }
if (edge.edgeType === "updates")
@ -139,7 +155,40 @@ function applyRelationLevelOfDetail(
zoom: number,
hasFocus: boolean,
hasActiveHover: boolean,
emphasized = false,
) {
if (emphasized) {
if (edgeType === "document") return { style, glow: false }
if (hasFocus || hasActiveHover) {
return {
style:
edgeType === "updates"
? {
...style,
width: Math.max(style.width, hasActiveHover ? 3.2 : 2.8),
opacity: Math.max(style.opacity, hasActiveHover ? 1 : 0.98),
}
: style,
glow: edgeType !== "extends",
}
}
const detail = edgeDetailProgress(zoom)
const overviewOpacity =
edgeType === "updates" ? 0.039 : edgeType === "derives" ? 0.044 : 0.03
return {
style: {
...style,
width: 0.45 + (style.width - 0.45) * detail,
opacity: overviewOpacity + (style.opacity - overviewOpacity) * detail,
},
glow:
edgeType === "derives"
? zoom >= DOCUMENT_LOD_ZOOM
: edgeType === "updates" &&
(zoom >= RELATION_LOD_ZOOM ||
(relationEdgeCount <= RELATION_LOD_DENSE_COUNT && detail === 1)),
}
}
if (edgeType === "document") return { style, glow: true }
if (hasFocus || hasActiveHover) {
const isUpdate = edgeType === "updates"
@ -181,6 +230,27 @@ function applyRelationLevelOfDetail(
}
}
function edgeDetailProgress(zoom: number): number {
const progress = clampNumber(
(zoom - EDGE_OVERVIEW_ZOOM) / (EDGE_DETAIL_ZOOM - EDGE_OVERVIEW_ZOOM),
0,
1,
)
return progress * progress * (3 - 2 * progress)
}
function isDocumentSourceEdge(
edge: GraphEdge,
nodeMap: Map<string, GraphNode>,
): boolean {
if (edge.edgeType === "document") return true
const source =
typeof edge.source === "string" ? nodeMap.get(edge.source) : edge.source
const target =
typeof edge.target === "string" ? nodeMap.get(edge.target) : edge.target
return source?.type === "document" || target?.type === "document"
}
function clampNumber(value: number, min: number, max: number): number {
return value < min ? min : value > max ? max : value
}
@ -217,8 +287,17 @@ function drawEdges(
): void {
const margin = 100
const hasDim = state.selectedNodeId !== null && state.dimProgress > 0
const emphasized = state.appearance?.connections === "emphasized"
const relationEdgeCount = edges.reduce(
(count, edge) => count + (edge.edgeType === "document" ? 0 : 1),
(count, edge) =>
count +
((
emphasized
? isDocumentSourceEdge(edge, nodeMap)
: edge.edgeType === "document"
)
? 0
: 1),
0,
)
const documentEdgeCount = edges.length - relationEdgeCount
@ -228,7 +307,10 @@ function drawEdges(
const prepared: PreparedEdge[] = []
for (const edge of edges) {
const edgeType = edge.edgeType ?? "document"
const edgeType =
emphasized && isDocumentSourceEdge(edge, nodeMap)
? "document"
: (edge.edgeType ?? "document")
const srcId = typeof edge.source === "string" ? edge.source : edge.source.id
const tgtId = typeof edge.target === "string" ? edge.target : edge.target.id
const hoverConnected =
@ -293,16 +375,29 @@ function drawEdges(
}
const edgeDetail = applyRelationLevelOfDetail(
edgeStyle(edge, colors),
edgeStyle(edge, colors, emphasized),
edgeType,
relationEdgeCount,
viewport.zoom,
hasDim && connected,
edgeType === "updates" && hoverConnected,
emphasized,
)
let style = edgeDetail.style
let glow = edgeDetail.glow
if (edgeType === "document" && documentStride > 1 && !activeConnected) {
if (emphasized && edgeType === "document") {
const detail = edgeDetailProgress(viewport.zoom)
style = {
color: colors.edgeDocument ?? colors.textSecondary,
width: activeConnected ? 1.8 : 0.35 + 0.85 * detail,
opacity: activeConnected ? 0.65 : 0.0112 + (0.45 - 0.0112) * detail,
}
glow = false
} else if (
edgeType === "document" &&
documentStride > 1 &&
!activeConnected
) {
const zoomFactor = clampNumber(
viewport.zoom / DOCUMENT_LOD_ZOOM,
0.08,
@ -534,6 +629,7 @@ function drawNodes(
isHovered,
isHighlighted,
colors,
state.appearance?.documentNodes,
)
} else {
drawMemoryNode(
@ -549,7 +645,11 @@ function drawNodes(
)
}
if (isSelected || isHighlighted || isHovered) {
if (
(node.type === "memory" ||
state.appearance?.documentNodes !== "stacked") &&
(isSelected || isHighlighted || isHovered)
) {
drawGlow(
ctx,
screen.x,
@ -712,12 +812,26 @@ function drawDocumentNode(
isHovered: boolean,
isHighlighted: boolean,
colors: GraphThemeColors,
style: GraphAppearance["documentNodes"] = "icons",
): void {
if (style === "stacked") {
drawStackedDocumentNode(
ctx,
sx,
sy,
size,
node,
isSelected,
isHovered,
isHighlighted,
colors,
)
return
}
const half = size * 0.5
const cornerR = 8 * (size / 50)
const clusterColor = node.clusterColor ?? colors.docStroke
// Drop shadow for selected/hovered nodes
if (isSelected || isHovered) {
ctx.save()
ctx.shadowColor = isSelected ? colors.accent : clusterColor
@ -726,7 +840,6 @@ function drawDocumentNode(
ctx.shadowOffsetY = 0
}
// Subtle gradient fill for document nodes
const grad = ctx.createLinearGradient(
sx - half,
sy - half,
@ -784,6 +897,59 @@ function drawDocumentNode(
)
}
function drawStackedDocumentNode(
ctx: CanvasRenderingContext2D,
sx: number,
sy: number,
size: number,
node: GraphNode,
isSelected: boolean,
isHovered: boolean,
isHighlighted: boolean,
colors: GraphThemeColors,
): void {
const half = size * 0.5
const cornerR = Math.min(8, Math.max(2, size * 0.12))
const stackOffset = Math.min(6, Math.max(0, (size - 24) * 0.1))
const layerSize = size - stackOffset
const focused = isSelected || isHighlighted
const borderColor = node.clusterColor ?? node.borderColor ?? colors.docStroke
ctx.save()
if (stackOffset > 0) {
ctx.fillStyle = focused
? mixHexColors(colors.docFill, colors.accent, 0.2)
: colors.docFill
ctx.strokeStyle = colors.textMuted
ctx.lineWidth = 1
roundRect(
ctx,
sx - half + stackOffset,
sy - half,
layerSize,
layerSize,
cornerR,
)
ctx.fill()
if (!focused) ctx.stroke()
}
ctx.fillStyle = focused
? mixHexColors(colors.textMuted, colors.accent, 0.28)
: colors.textMuted
ctx.strokeStyle = focused || isHovered ? colors.accent : borderColor
ctx.lineWidth = focused ? 2.25 : isHovered ? 1.5 : 1
roundRect(
ctx,
sx - half,
sy - half + stackOffset,
layerSize,
layerSize,
cornerR,
)
ctx.fill()
ctx.stroke()
ctx.restore()
}
function drawMemoryNode(
ctx: CanvasRenderingContext2D,
sx: number,

View file

@ -19,6 +19,7 @@ export const GraphCanvas = memo<ExtendedGraphCanvasProps>(function GraphCanvas({
width,
height,
colors,
appearance,
highlightDocumentIds,
selectedNodeId = null,
onNodeHover,
@ -62,6 +63,7 @@ export const GraphCanvas = memo<ExtendedGraphCanvasProps>(function GraphCanvas({
width,
height,
colors,
appearance,
selectedNodeId,
hoveredNodeId: null as string | null,
highlightIds: new Set(highlightDocumentIds ?? []),
@ -75,6 +77,7 @@ export const GraphCanvas = memo<ExtendedGraphCanvasProps>(function GraphCanvas({
s.current.width = width
s.current.height = height
s.current.colors = colors
s.current.appearance = appearance
// Stable callback refs so InputHandler never needs recreation
const cb = useRef({
@ -128,8 +131,18 @@ export const GraphCanvas = memo<ExtendedGraphCanvasProps>(function GraphCanvas({
useEffect(() => {
s.current.selectedNodeId = selectedNodeId
s.current.dimTarget = selectedNodeId ? 1 : 0
s.current.colors = colors
s.current.appearance = {
documentNodes: appearance?.documentNodes,
connections: appearance?.connections,
}
renderNeeded.current = true
}, [selectedNodeId])
}, [
selectedNodeId,
colors,
appearance?.documentNodes,
appearance?.connections,
])
// Create viewport + input handler (once per variant)
// biome-ignore lint/correctness/useExhaustiveDependencies: canvasRef and externalViewportRef are refs — mutations do not trigger re-renders, intentionally omitted
@ -278,6 +291,7 @@ export const GraphCanvas = memo<ExtendedGraphCanvasProps>(function GraphCanvas({
hoveredNodeId: cur.hoveredNodeId,
highlightIds: cur.highlightIds,
dimProgress: cur.dimProgress,
appearance: cur.appearance,
},
nodeMapRef.current,
cur.colors,

View file

@ -532,7 +532,9 @@ export const Legend = memo(function Legend({
<div>
<div style={rowStyle}>
<div style={rowLeftStyle}>
<LineIcon color={colors.edgeDocument} />
<LineIcon
color={colors.edgeDocument ?? colors.edgeDerives}
/>
<span style={edgeLabelStyle}>
{labels.documentSourceEdge}
</span>

View file

@ -18,6 +18,8 @@ import { useGraphTheme } from "../hooks/use-graph-theme"
import { useInitialGraphFit } from "../hooks/use-initial-graph-fit"
import type {
GraphApiDocument,
GraphNode,
MemoryNodeData,
GraphThemeColors,
MemoryGraphProps,
ResolvedMemoryGraphLabels,
@ -28,6 +30,31 @@ import { LoadingIndicator } from "./loading-indicator"
import { NavigationControls } from "./navigation-controls"
import { NodeHoverPopover } from "./node-hover-popover"
function documentIdForNode(
node: GraphNode | null,
documents: GraphApiDocument[],
): string | null {
if (!node || typeof node.data.id !== "string" || !node.data.id.trim())
return null
if (node.type === "document") {
return documents.some((document) => document.id === node.data.id)
? node.data.id
: null
}
const sourceId = (node.data as MemoryNodeData).documentId
if (typeof sourceId !== "string" || !sourceId.trim()) return null
const ids = new Set(
documents
.filter(
(document) =>
document.id.trim() &&
document.memories.some((memory) => memory.id === node.data.id),
)
.map((document) => document.id),
)
return ids.size === 1 && ids.has(sourceId) ? sourceId : null
}
export function MemoryGraph({
documents = [],
isLoading: externalIsLoading = false,
@ -46,9 +73,11 @@ export function MemoryGraph({
canvasRef: externalCanvasRef,
colors: colorOverrides,
totalCount,
onOpenDocument,
onOpenDocument: externalOnOpenDocument,
labels: labelOverrides,
layering,
appearance,
openDocumentOnEnter = false,
}: MemoryGraphProps) {
const resolvedLabels = useMemo<ResolvedMemoryGraphLabels>(
() => ({ ...DEFAULT_LABELS, ...labelOverrides }),
@ -358,9 +387,38 @@ export function MemoryGraph({
vp.zoomTo(vp.zoom / 1.3, containerSize.width / 2, graphFitHeight / 2)
}, [containerSize.width, graphFitHeight, stopFollowing])
// Wrap onOpenDocument to dismiss the popover before opening the modal.
// Without this, the popover overlay stays mounted on top of the
// document modal, obscuring it and intercepting clicks.
const activeDocumentId = useMemo(
() =>
openDocumentOnEnter
? documentIdForNode(
nodes.find((node) => node.id === (selectedNode ?? hoveredNode)) ??
null,
documents,
)
: null,
[openDocumentOnEnter, selectedNode, hoveredNode, nodes, documents],
)
const onOpenDocument = useMemo(
() =>
!openDocumentOnEnter
? externalOnOpenDocument
: externalOnOpenDocument && activeDocumentId
? (_documentId: string) => externalOnOpenDocument(activeDocumentId)
: undefined,
[openDocumentOnEnter, externalOnOpenDocument, activeDocumentId],
)
const selectedDocumentId = useMemo(
() =>
openDocumentOnEnter
? documentIdForNode(
nodes.find((node) => node.id === selectedNode) ?? null,
documents,
)
: null,
[openDocumentOnEnter, selectedNode, nodes, documents],
)
const canOpenOnEnter = openDocumentOnEnter && !!externalOnOpenDocument
const handleOpenDocument = useCallback(
(documentId: string) => {
setSelectedNode(null)
@ -370,11 +428,53 @@ export function MemoryGraph({
[onOpenDocument],
)
// Keyboard shortcuts — using useEffect with keydown listener
const documentNavigationProps = canOpenOnEnter
? {
tabIndex: 0,
role: "region" as const,
"aria-label":
"Memory graph. Select a node, then press Enter to open its document.",
onKeyDown: (event: React.KeyboardEvent<HTMLDivElement>) => {
if (
!selectedDocumentId ||
event.key !== "Enter" ||
event.defaultPrevented ||
event.repeat ||
event.nativeEvent.isComposing ||
event.ctrlKey ||
event.metaKey ||
event.altKey ||
event.shiftKey
)
return
if (
event.target !== event.currentTarget &&
!(event.target instanceof HTMLCanvasElement)
)
return
event.preventDefault()
event.stopPropagation()
handleOpenDocument(selectedDocumentId)
},
}
: {}
useEffect(() => {
const handler = (e: KeyboardEvent) => {
// Don't handle if user is typing in an input
const target = e.target as HTMLElement
if (
openDocumentOnEnter &&
(e.defaultPrevented ||
e.isComposing ||
e.ctrlKey ||
e.metaKey ||
e.altKey ||
(target instanceof Element &&
target.closest(
"input, textarea, select, [contenteditable], [role=textbox]",
)))
)
return
if (
target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
@ -407,7 +507,13 @@ export function MemoryGraph({
window.addEventListener("keydown", handler)
return () => window.removeEventListener("keydown", handler)
}, [handleAutoFit, handleCenter, handleZoomIn, handleZoomOut])
}, [
handleAutoFit,
handleCenter,
handleZoomIn,
handleZoomOut,
openDocumentOnEnter,
])
// Arrow key navigation through nodes
const selectAndCenter = useCallback(
@ -523,6 +629,20 @@ export function MemoryGraph({
useEffect(() => {
const handler = (e: KeyboardEvent) => {
const target = e.target as HTMLElement
if (
openDocumentOnEnter &&
(e.defaultPrevented ||
e.isComposing ||
e.ctrlKey ||
e.metaKey ||
e.altKey ||
e.shiftKey ||
(target instanceof Element &&
target.closest(
"input, textarea, select, [contenteditable], [role=textbox]",
)))
)
return
if (
target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
@ -552,7 +672,13 @@ export function MemoryGraph({
window.addEventListener("keydown", handler)
return () => window.removeEventListener("keydown", handler)
}, [navigateUp, navigateDown, navigateNext, navigatePrev])
}, [
navigateUp,
navigateDown,
navigateNext,
navigatePrev,
openDocumentOnEnter,
])
// Slideshow — use refs to avoid re-creating the interval on resize or node updates
const nodesRef = useRef(nodes)
@ -728,8 +854,13 @@ export function MemoryGraph({
<div
style={canvasContainerStyle}
ref={containerRef}
{...documentNavigationProps}
onPointerDownCapture={(event) => {
if (event.target instanceof HTMLCanvasElement) stopFollowing()
if (event.target instanceof HTMLCanvasElement) {
stopFollowing()
if (canOpenOnEnter)
containerRef.current?.focus({ preventScroll: true })
}
}}
onWheelCapture={(event) => {
if (event.target instanceof HTMLCanvasElement) stopFollowing()
@ -737,6 +868,7 @@ export function MemoryGraph({
>
{hasContainerSize && (
<GraphCanvas
appearance={appearance}
colors={colors}
edges={edges}
height={containerSize.height}
@ -771,6 +903,10 @@ export function MemoryGraph({
onNavigateUp={navigateUp}
onSelectNode={handleNodeClick}
onOpenDocument={onOpenDocument ? handleOpenDocument : undefined}
openDocumentShortcut={
canOpenOnEnter && selectedDocumentId ? "Enter" : undefined
}
showDocumentTitle={appearance?.documentNodes === "stacked"}
screenX={activePopoverPosition.screenX}
screenY={activePopoverPosition.screenY}
versionChain={activeVersionChain}

View file

@ -25,6 +25,8 @@ export interface NodeHoverPopoverProps {
onNavigateDown?: () => void
onSelectNode?: (nodeId: string) => void
onOpenDocument?: (documentId: string) => void
openDocumentShortcut?: string
showDocumentTitle?: boolean
}
function useCopyToClipboard(timeout = 2000) {
@ -105,11 +107,13 @@ function NavButton({
label,
onClick,
colors,
shortcut,
}: {
icon: React.ReactNode
label: string
onClick?: () => void
colors: GraphThemeColors
shortcut?: string
}) {
const buttonStyle: React.CSSProperties = {
display: "flex",
@ -130,6 +134,8 @@ function NavButton({
return (
<button
aria-label={shortcut ? label : undefined}
aria-keyshortcuts={shortcut}
onClick={onClick}
style={buttonStyle}
type="button"
@ -142,6 +148,20 @@ function NavButton({
>
<KeyBadge colors={colors}>{icon}</KeyBadge>
<span style={labelStyle}>{label}</span>
{shortcut && (
<kbd
style={{
fontSize: 10,
color: colors.popoverTextMuted,
border: `1px solid ${colors.controlBorder}`,
borderRadius: 4,
padding: "2px 4px",
backgroundColor: colors.controlBg,
}}
>
{shortcut}
</kbd>
)}
</button>
)
}
@ -322,6 +342,8 @@ export const NodeHoverPopover = memo<NodeHoverPopoverProps>(
onNavigateDown,
onSelectNode,
onOpenDocument,
openDocumentShortcut,
showDocumentTitle = false,
}) {
const CARD_W = 280
const SHORTCUTS_W = 160
@ -532,6 +554,18 @@ export const NodeHoverPopover = memo<NodeHoverPopoverProps>(
/>
) : (
<div style={contentPadStyle}>
{showDocumentTitle && docData && (
<div
style={{
fontSize: 12,
fontWeight: 600,
color: colors.popoverTextPrimary,
marginBottom: 6,
}}
>
{docData.title || "Untitled document"}
</div>
)}
<p style={contentTextStyle}>{content || "No content"}</p>
</div>
)}
@ -632,7 +666,7 @@ export const NodeHoverPopover = memo<NodeHoverPopoverProps>(
<CopyableId
colors={colors}
label={labels.documentIdLabel}
value={node.id}
value={showDocumentTitle ? (docData?.id ?? node.id) : node.id}
/>
)}
</div>
@ -644,6 +678,7 @@ export const NodeHoverPopover = memo<NodeHoverPopoverProps>(
colors={colors}
icon={<EyeIcon color={colors.popoverTextMuted} />}
label={labels.viewDocument}
shortcut={openDocumentShortcut}
onClick={() => onOpenDocument(documentId)}
/>
)}

View file

@ -61,7 +61,7 @@ export const ANIMATION = {
dimDuration: 1500,
}
export const DEFAULT_COLORS: GraphThemeColors = {
export const DEFAULT_COLORS: GraphThemeColors & { edgeDocument: string } = {
bg: "#0f1419",
docFill: "#1B1F24",
docStroke: "#2A2F36",

View file

@ -26,6 +26,7 @@ export type {
MemoryGraphProps,
MemoryGraphLabels,
MemoryGraphLayering,
GraphAppearance,
ResolvedMemoryGraphLabels,
GraphNode,
GraphEdge,

View file

@ -118,7 +118,7 @@ export interface GraphThemeColors {
textPrimary: string
textSecondary: string
textMuted: string
edgeDocument: string
edgeDocument?: string
edgeDerives: string
edgeUpdates: string
edgeExtends: string
@ -157,12 +157,18 @@ export interface MemoryGraphLayering {
hoverPopoverZIndex?: number
}
export interface GraphAppearance {
documentNodes?: "icons" | "stacked"
connections?: "default" | "emphasized"
}
export interface GraphCanvasProps {
nodes: GraphNode[]
edges: GraphEdge[]
width: number
height: number
colors: GraphThemeColors
appearance?: GraphAppearance
highlightDocumentIds?: string[]
selectedNodeId?: string | null
onNodeHover: (nodeId: string | null) => void
@ -224,6 +230,8 @@ export interface MemoryGraphProps {
labels?: MemoryGraphLabels
/** Overlay layering controls */
layering?: MemoryGraphLayering
appearance?: GraphAppearance
openDocumentOnEnter?: boolean
}
export interface ChainEntry {