-
+
{labels.documentSourceEdge}
diff --git a/packages/memory-graph/src/components/memory-graph.tsx b/packages/memory-graph/src/components/memory-graph.tsx
index 016d9428..b62b10d9 100644
--- a/packages/memory-graph/src/components/memory-graph.tsx
+++ b/packages/memory-graph/src/components/memory-graph.tsx
@@ -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
(
() => ({ ...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) => {
+ 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({
{
- 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 && (
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 (
)
}
@@ -322,6 +342,8 @@ export const NodeHoverPopover = memo(
onNavigateDown,
onSelectNode,
onOpenDocument,
+ openDocumentShortcut,
+ showDocumentTitle = false,
}) {
const CARD_W = 280
const SHORTCUTS_W = 160
@@ -532,6 +554,18 @@ export const NodeHoverPopover = memo(
/>
) : (
+ {showDocumentTitle && docData && (
+
+ {docData.title || "Untitled document"}
+
+ )}
{content || "No content"}
)}
@@ -632,7 +666,7 @@ export const NodeHoverPopover = memo(
)}
@@ -644,6 +678,7 @@ export const NodeHoverPopover = memo(
colors={colors}
icon={}
label={labels.viewDocument}
+ shortcut={openDocumentShortcut}
onClick={() => onOpenDocument(documentId)}
/>
)}
diff --git a/packages/memory-graph/src/constants.ts b/packages/memory-graph/src/constants.ts
index f3c539ff..1267b964 100644
--- a/packages/memory-graph/src/constants.ts
+++ b/packages/memory-graph/src/constants.ts
@@ -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",
diff --git a/packages/memory-graph/src/index.tsx b/packages/memory-graph/src/index.tsx
index f87baaba..bb3b3c6b 100644
--- a/packages/memory-graph/src/index.tsx
+++ b/packages/memory-graph/src/index.tsx
@@ -26,6 +26,7 @@ export type {
MemoryGraphProps,
MemoryGraphLabels,
MemoryGraphLayering,
+ GraphAppearance,
ResolvedMemoryGraphLabels,
GraphNode,
GraphEdge,
diff --git a/packages/memory-graph/src/types.ts b/packages/memory-graph/src/types.ts
index 1c66419e..6a53f78c 100644
--- a/packages/memory-graph/src/types.ts
+++ b/packages/memory-graph/src/types.ts
@@ -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 {